Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.opensharing.auth;

import jakarta.servlet.http.HttpServletRequest;
import java.util.Optional;

/** Extracts RFC 6750 bearer tokens from requests. */
public final class BearerTokens {

private static final String SCHEME = "Bearer ";

private BearerTokens() {}

public static Optional<String> from(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (header == null || header.length() <= SCHEME.length()) {
return Optional.empty();
}
if (!header.regionMatches(true, 0, SCHEME, 0, SCHEME.length())) {
return Optional.empty();
}
String token = header.substring(SCHEME.length()).trim();
return token.isEmpty() ? Optional.empty() : Optional.of(token);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package io.opensharing.auth;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HexFormat;

/** Generates bearer tokens and activation nonces, and hashes them for storage. */
public final class Secrets {

public static final String TOKEN_PREFIX = "os_";

private static final SecureRandom RANDOM = new SecureRandom();
private static final int TOKEN_BYTES = 32;

private Secrets() {}

/** A new recipient bearer token. Returned to the caller once and never stored in clear text. */
public static String newToken() {
return TOKEN_PREFIX + randomToken();
}

/** A new single-use activation nonce. */
public static String newActivationNonce() {
return randomToken();
}

/** Lowercase hex SHA-256, the form persisted for tokens and nonces. */
public static String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is required but unavailable", e);
}
}

private static String randomToken() {
byte[] bytes = new byte[TOKEN_BYTES];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.opensharing.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;

class BearerTokensTest {

@Test
void extractsBearerTokenCaseInsensitively() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "bearer token-value");

assertEquals("token-value", BearerTokens.from(request).orElseThrow());
}

@Test
void trimsTokenWhitespace() {
MockHttpServletRequest request = new MockHttpServletRequest();
request.addHeader("Authorization", "Bearer token-value ");

assertEquals("token-value", BearerTokens.from(request).orElseThrow());
}

@Test
void rejectsMissingEmptyAndOtherSchemes() {
assertTrue(BearerTokens.from(new MockHttpServletRequest()).isEmpty());

MockHttpServletRequest empty = new MockHttpServletRequest();
empty.addHeader("Authorization", "Bearer ");
assertTrue(BearerTokens.from(empty).isEmpty());

MockHttpServletRequest basic = new MockHttpServletRequest();
basic.addHeader("Authorization", "Basic token-value");
assertTrue(BearerTokens.from(basic).isEmpty());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package io.opensharing.auth;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.util.Base64;
import java.util.regex.Pattern;
import org.junit.jupiter.api.Test;

class SecretsTest {

private static final int TOKEN_BYTES = 32;
private static final int RANDOM_PART_LENGTH = (TOKEN_BYTES * 8 + 5) / 6;
private static final Pattern URL_SAFE_BASE64 = Pattern.compile("^[A-Za-z0-9_-]+$");

@Test
void hashesDeterministically() {
assertEquals(Secrets.sha256("hello"), Secrets.sha256("hello"));
assertNotEquals(Secrets.sha256("hello"), Secrets.sha256("world"));
}

@Test
void mintsPrefixedTokens() {
String token = Secrets.newToken();
assertTrue(token.startsWith(Secrets.TOKEN_PREFIX));
assertEquals(Secrets.TOKEN_PREFIX.length() + RANDOM_PART_LENGTH, token.length());

String randomPart = token.substring(Secrets.TOKEN_PREFIX.length());
assertTrue(URL_SAFE_BASE64.matcher(randomPart).matches());
assertEquals(TOKEN_BYTES, Base64.getUrlDecoder().decode(randomPart).length);
assertNotEquals(token, Secrets.newToken());
}

@Test
void mintsActivationNonces() {
String nonce = Secrets.newActivationNonce();
assertEquals(RANDOM_PART_LENGTH, nonce.length());
assertTrue(URL_SAFE_BASE64.matcher(nonce).matches());
assertEquals(TOKEN_BYTES, Base64.getUrlDecoder().decode(nonce).length);
assertNotEquals(nonce, Secrets.newActivationNonce());
}
}
Loading