From d287a04290eafe4cbcd6e19dcf88f8ae46990061 Mon Sep 17 00:00:00 2001 From: Sun Cao Date: Tue, 15 Sep 2026 11:58:48 -0700 Subject: [PATCH] Add bearer token and secret utilities --- .../io/opensharing/auth/BearerTokens.java | 24 ++++++++++ .../java/io/opensharing/auth/Secrets.java | 45 +++++++++++++++++++ .../io/opensharing/auth/BearerTokensTest.java | 39 ++++++++++++++++ .../java/io/opensharing/auth/SecretsTest.java | 43 ++++++++++++++++++ 4 files changed, 151 insertions(+) create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/auth/BearerTokens.java create mode 100644 server/opensharing-server-core/src/main/java/io/opensharing/auth/Secrets.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/auth/BearerTokensTest.java create mode 100644 server/opensharing-server-core/src/test/java/io/opensharing/auth/SecretsTest.java diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/auth/BearerTokens.java b/server/opensharing-server-core/src/main/java/io/opensharing/auth/BearerTokens.java new file mode 100644 index 000000000..92e2877cc --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/auth/BearerTokens.java @@ -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 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); + } +} diff --git a/server/opensharing-server-core/src/main/java/io/opensharing/auth/Secrets.java b/server/opensharing-server-core/src/main/java/io/opensharing/auth/Secrets.java new file mode 100644 index 000000000..7c9ccf846 --- /dev/null +++ b/server/opensharing-server-core/src/main/java/io/opensharing/auth/Secrets.java @@ -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); + } +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/auth/BearerTokensTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/auth/BearerTokensTest.java new file mode 100644 index 000000000..5ce18d387 --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/auth/BearerTokensTest.java @@ -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()); + } +} diff --git a/server/opensharing-server-core/src/test/java/io/opensharing/auth/SecretsTest.java b/server/opensharing-server-core/src/test/java/io/opensharing/auth/SecretsTest.java new file mode 100644 index 000000000..561bc997f --- /dev/null +++ b/server/opensharing-server-core/src/test/java/io/opensharing/auth/SecretsTest.java @@ -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()); + } +}