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:
co-authored by
Claude Opus 5
parent
7f225e0faf
commit
f28fc43150
@@ -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
|
||||
|
||||
+14
-7
@@ -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));
|
||||
|
||||
+7
-20
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user