feat(ext-security): add an OAuth 2.1 authorization server

flash-ext-security-oauth-server issues RFC 9068 access tokens (code + PKCE S256,
CIMD and DCR clients, RFC 8707 resources, rotating refresh tokens) for resources on
the application's own origin. Around it: SecurityExtension resolves a configured
origin instead of X-Forwarded-* headers, mechanisms expose schemes() and a route can
be restricted to some of them, McpConfig.mechanisms(...) uses that, OIDC bearers must
be typed at+jwt, and PublicUrl guards outbound fetches against internal addresses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-09-22 11:39:09 +00:00
co-authored by Claude Opus 5
parent 7f225e0faf
commit f28fc43150
35 changed files with 1958 additions and 80 deletions
@@ -15,9 +15,13 @@ Discovery runs at boot, so an unreachable provider fails the start rather than t
## Bearer tokens
`Authorization: Bearer <jwt>` is matched to its provider by `iss`, then verified against that
provider's keys (RS/PS/ES algorithms, `typ` `JWT` or `at+jwt`, `iss`, `sub`, `exp`). One parse, one map
lookup, however many providers are configured. A token from an unconfigured issuer is left to other
mechanisms; a token from a configured one that fails verification is `401 invalid_token`.
provider's keys (RS/PS/ES algorithms, `iss`, `sub`, `exp`). One parse, one map lookup, however many
providers are configured. A token from an unconfigured issuer is left to other mechanisms; a token from
a configured one that fails verification is `401 invalid_token`.
Only an access token is a bearer credential, and RFC 9068 is how one says so: its `typ` is `at+jwt`.
An ID token — `typ` `JWT` — is the client's proof of sign-in and never passes. Keycloak emits `at+jwt`
once the client's `access.token.header.type.rfc9068` attribute is `true`.
## Sign-in
@@ -30,8 +34,8 @@ The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie sco
so sign-in needs no server-side state and works across instances. The session's principal is renewed
with the refresh token when its access token expires; `POST /auth/logout` ends it and continues to the
provider's `end_session_endpoint`. The client authenticates with `client_secret_basic`. Register
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI;
behind a proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host`.
`{origin}/auth/oidc/{id}/callback` as a redirect URI and `{origin}/` as a post-logout redirect URI,
where `{origin}` is `SecurityExtension.origin(...)`.
Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an
`openIdConnect` scheme.
@@ -45,7 +49,7 @@ oidc.unregister("acme");
```
Discovery runs inside `register`, which refuses a provider — or any endpoint its discovery names — that
is not https on a public address: registration makes the server fetch URLs someone else chose.
is not https on a public address (`PublicUrl`): registration makes the server fetch URLs someone else chose.
`allowLocalProviders()` lifts that for development. Registered providers serve bearer tokens and
`/auth/oidc/{id}/login` immediately, but are not listed at `/auth/methods` or in OpenAPI: which provider
a given user signs in with is the application's decision — typically an `AuthenticationEntryPoint` that
@@ -19,8 +19,10 @@ import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Arrays;
import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@@ -84,7 +86,7 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
* Checks a provider before trusting it: discovery, the client ID and secret, and the redirect URI a
* sign-in from {@code origin} will send. Registers nothing, and holds {@link #register}'s address rules.
*
* @param origin where users will sign in from, as {@link Request#origin()} gives it
* @param origin where users will sign in from, as {@link SecurityExtension#origin(Request)} gives it
* @throws IllegalArgumentException naming what the provider refused
* @throws IllegalStateException the provider could not be reached
*/
@@ -111,12 +113,17 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
ctx.onReady(() -> {
security = ctx.require(SecurityExtension.class).mechanism(this).refresher(OidcPrincipal.class, this::refresh);
for (OidcProvider config : configured) {
security.scheme(SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer))
.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
security.loginMethod(new LoginMethod(config.id(), config.name(), "/auth/oidc/" + config.id() + "/login", LoginMethod.Kind.REDIRECT));
}
});
}
/** One per configured provider; one registered at runtime is the application's to name, never listed. */
@Override
public List<SecurityScheme> schemes() {
return Arrays.stream(configured).map(config -> SecurityScheme.openIdConnect(config.id(), byId.get(config.id()).issuer)).toList();
}
// -- Bearer access tokens -------------------------------------------------
@Override
@@ -150,9 +157,9 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
String nonce = random(16);
res.header("Set-Cookie", FLOW + "=" + state + "." + verifier + "." + nonce + "."
+ BASE64URL.encodeToString(localPath(req.query("redirect")).getBytes(StandardCharsets.UTF_8))
+ "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
+ "; Path=/auth/oidc; Max-Age=600; HttpOnly; SameSite=Lax" + (security.origin(req).startsWith("https") ? "; Secure" : ""));
String challenge = BASE64URL.encodeToString(MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII)));
res.redirect(provider.authorizeUrl(callbackUri(req.origin(), provider.config.id()), state, nonce, challenge));
res.redirect(provider.authorizeUrl(callbackUri(security.origin(req), provider.config.id()), state, nonce, challenge));
return null;
}
@@ -169,13 +176,13 @@ public final class OidcExtension implements FlashExtension, AuthenticationMechan
if (req.query("error") != null) throw HttpException.badRequest("The identity provider refused sign-in: " + req.query("error"));
try {
Map<String, Object> tokens = provider.token("grant_type=authorization_code&code=" + Provider.encode(req.query("code"))
+ "&redirect_uri=" + Provider.encode(callbackUri(req.origin(), provider.config.id())) + "&code_verifier=" + flow[1]);
+ "&redirect_uri=" + Provider.encode(callbackUri(security.origin(req), provider.config.id())) + "&code_verifier=" + flow[1]);
String idToken = (String) tokens.get("id_token");
Map<String, Object> claims = sessionClaims(provider.verifyIdToken(idToken), tokens);
if (!flow[2].equals(claims.get("nonce"))) throw new IllegalStateException("nonce mismatch");
String logout = provider.endSessionEndpoint == null ? null : provider.endSessionEndpoint
+ (provider.endSessionEndpoint.indexOf('?') < 0 ? '?' : '&') + "client_id=" + Provider.encode(provider.config.clientId())
+ "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(req.origin() + "/");
+ "&id_token_hint=" + idToken + "&post_logout_redirect_uri=" + Provider.encode(security.origin(req) + "/");
OidcPrincipal principal = new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims,
(String) tokens.get("access_token"), (String) tokens.get("refresh_token"), logout);
security.signIn(req, res, principal, expiry(tokens));
@@ -12,9 +12,8 @@ import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import dev.relism.flash.ext.security.PublicUrl;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
@@ -48,17 +47,17 @@ final class Provider {
Provider(OidcProvider config, boolean guarded) {
this.config = config;
try {
if (guarded) requirePublic(config.discoveryUrl());
if (guarded) PublicUrl.require(config.discoveryUrl());
Map<String, Object> discovery = JSONObjectUtils.parse(HTTP.send(HttpRequest.newBuilder(URI.create(config.discoveryUrl())).build(),
HttpResponse.BodyHandlers.ofString()).body());
if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) requirePublic((String) discovery.get(key));
if (guarded) for (String key : new String[]{"authorization_endpoint", "token_endpoint", "jwks_uri"}) PublicUrl.require((String) discovery.get(key));
issuer = (String) discovery.get("issuer");
authorizationEndpoint = (String) discovery.get("authorization_endpoint");
tokenEndpoint = (String) discovery.get("token_endpoint");
endSessionEndpoint = (String) discovery.get("end_session_endpoint");
JWKSource<SecurityContext> keys = JWKSourceBuilder.create(URI.create((String) discovery.get("jwks_uri")).toURL()).retrying(true).build();
accessTokens = processor(keys, null, new JOSEObjectType("at+jwt"));
idTokens = processor(keys, config.clientId(), null);
accessTokens = processor(keys, null, new DefaultJOSEObjectTypeVerifier<>(new JOSEObjectType("at+jwt"), new JOSEObjectType("application/at+jwt")));
idTokens = processor(keys, config.clientId(), DefaultJOSEObjectTypeVerifier.JWT);
} catch (Exception e) {
if (e instanceof IllegalArgumentException rejected) throw rejected;
throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e);
@@ -136,29 +135,17 @@ final class Provider {
}
}
/** ponytail: resolved once here and again by the HTTP client, so DNS rebinding between the two is not covered. */
private static void requirePublic(String url) throws Exception {
URI uri = URI.create(url);
if (!"https".equals(uri.getScheme())) throw new IllegalArgumentException("Not https: " + url);
for (InetAddress address : InetAddress.getAllByName(uri.getHost())) {
if (address.isLoopbackAddress() || address.isSiteLocalAddress() || address.isLinkLocalAddress() || address.isAnyLocalAddress()
|| address.isMulticastAddress() || (address instanceof Inet6Address && (address.getAddress()[0] & 0xfe) == 0xfc)) {
throw new IllegalArgumentException("Not a public address: " + url);
}
}
}
static String encode(String value) {
return URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, JOSEObjectType type) {
private DefaultJWTProcessor<SecurityContext> processor(JWKSource<SecurityContext> keys, String audience, DefaultJOSEObjectTypeVerifier<SecurityContext> type) {
Set<JWSAlgorithm> algorithms = new HashSet<>(JWSAlgorithm.Family.RSA);
algorithms.addAll(JWSAlgorithm.Family.EC);
DefaultJWTProcessor<SecurityContext> processor = new DefaultJWTProcessor<>();
processor.setJWSKeySelector(new JWSVerificationKeySelector<>(algorithms, keys));
processor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(audience, new JWTClaimsSet.Builder().issuer(issuer).build(), Set.of("sub", "exp")));
if (type != null) processor.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(JOSEObjectType.JWT, type, null));
processor.setJWSTypeVerifier(type);
return processor;
}
}
@@ -64,6 +64,13 @@ class OidcExtensionTest {
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
}
/** RFC 9068: an access token says so in its typ. An ID token is the client's, never a bearer credential. */
@Test
void anIdTokenIsNotAnAccessToken() {
app.request().header("Authorization", "Bearer " + provider.idToken("bob", Map.of("aud", "app"))).get("/me")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer error=\"invalid_token\"");
}
/** Not rejected as invalid: an issuer this application does not know is some other mechanism's business. */
@Test
void aTokenFromAnUnknownIssuerIsNotThisMechanisms() {