refactor(ext-oidc): replace auth modules with security extensions

This commit is contained in:
Zakaria El Orche
2026-09-16 15:54:19 +00:00
parent 017c2443f4
commit e0795299fc
126 changed files with 2944 additions and 5130 deletions
@@ -0,0 +1,71 @@
# flash-ext-security-oidc
OpenID Connect for [`flash-ext-security-core`](../../flash-ext-security-core/docs/README.md): bearer
access tokens, and browser sign-in through the authorization code flow with PKCE.
```java
app.install(new SecurityExtension())
.install(new OidcExtension(
OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret).name("Acme SSO"),
OidcProvider.of("partner", "https://login.partner.example/", "app", partnerSecret)));
```
Discovery runs at boot, so an unreachable provider fails the start rather than the first sign-in.
## 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`.
## Sign-in
| Route | |
|---|---|
| `GET /auth/oidc/{id}/login?redirect=/path` | redirects to the provider |
| `GET /auth/oidc/{id}/callback` | exchanges the code, verifies the ID token and nonce, starts a session |
The PKCE verifier, nonce and state travel in a short-lived `HttpOnly` cookie scoped to `/auth/oidc`,
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`.
Each provider is listed at `/auth/methods` (`"kind":"redirect"`) and published to OpenAPI as an
`openIdConnect` scheme.
## Providers added at runtime
```java
OidcExtension oidc = new OidcExtension(central);
oidc.register(OidcProvider.of("acme", "https://login.acme.example/", clientId, secret)); // an organization's own IdP
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.
`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
picks one from the email domain. Their issuer is also a tenant boundary the application must enforce
in its `UserResolver`: a user belongs to the organizations whose issuer vouched for them.
## Principal and roles
`OidcPrincipal` carries the verified claims (`issuer()`, `name()` = `sub`, `email()`, `claim(...)`),
the access token and, for sessions, the refresh token. `hasScope` reads `scope`/`scp`; `hasAudience`
reads `aud`.
Roles are the application's decision. To take them from the token instead:
```java
new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")) // Keycloak; "groups" for most others
```
## Testing
`FakeOidcProvider` in [`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) serves
discovery, keys and the code and refresh flows on a local port; `OidcTokens.passwordGrant(...)` gets a
real token from a real provider such as Keycloak in Testcontainers.
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>dev.relism</groupId>
<artifactId>flash-extensions</artifactId>
<version>2.1.0-SNAPSHOT</version>
</parent>
<artifactId>flash-ext-security-oidc</artifactId>
<dependencies>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-core</artifactId>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>nimbus-jose-jwt</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>dev.relism</groupId>
<artifactId>flash-ext-security-test</artifactId>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,38 @@
package dev.relism.flash.ext.security.oidc;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.RoleResolver;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.Target;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/** Roles read from the provider's token instead of the application's own data. */
public final class ClaimRoles implements RoleResolver {
private final String[] path;
private ClaimRoles(String claimPath) {
this.path = claimPath.split("\\.");
}
/** Roles at a dot-separated claim path — {@code realm_access.roles} for Keycloak, {@code groups} for most others. */
public static ClaimRoles at(String claimPath) {
return new ClaimRoles(claimPath);
}
/** The roles {@code principal}'s token carries; none for a caller OIDC did not authenticate. */
@SuppressWarnings("unchecked")
public List<String> of(Principal principal) {
Object value = principal instanceof OidcPrincipal oidc ? oidc.claims() : null;
for (int i = 0; i < path.length && value != null; i++) value = value instanceof Map<?, ?> map ? map.get(path[i]) : null;
return value instanceof List<?> roles ? (List<String>) roles : value instanceof Collection<?> roles ? List.copyOf((Collection<String>) roles) : List.of();
}
@Override
public boolean hasRole(SecurityIdentity identity, String role, Target on) {
return of(identity.principal()).contains(role);
}
}
@@ -0,0 +1,239 @@
package dev.relism.flash.ext.security.oidc;
import com.nimbusds.jwt.SignedJWT;
import dev.relism.flash.exceptions.HttpException;
import dev.relism.flash.ext.security.AuthenticationFailedException;
import dev.relism.flash.ext.security.AuthenticationMechanism;
import dev.relism.flash.ext.security.LoginMethod;
import dev.relism.flash.ext.security.Principal;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityScheme;
import dev.relism.flash.ext.security.Session;
import dev.relism.flash.extension.FlashContext;
import dev.relism.flash.extension.FlashExtension;
import dev.relism.flash.extension.FlashRegistrar;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* OpenID Connect: bearer access tokens from any configured provider, and browser sign-in through the
* authorization code flow with PKCE, which ends in a {@link SecurityExtension} session renewed with
* the refresh token.
*
* <pre>{@code
* app.install(new SecurityExtension())
* .install(new OidcExtension(OidcProvider.of("sso", "https://id.example.com/realms/acme", "app", secret)));
* }</pre>
*
* <p>Routes: {@code GET /auth/oidc/{provider}/login?redirect=/path} and its callback. A bearer token
* is matched to its provider by {@code iss} before its signature is checked against that provider's
* keys, so any number of providers costs one lookup; a token from an issuer not configured here is
* left to other mechanisms.
*/
public final class OidcExtension implements FlashExtension, AuthenticationMechanism {
private static final AuthenticationFailedException INVALID = new AuthenticationFailedException("Bearer error=\"invalid_token\"");
private static final String FLOW = "flash_oidc";
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder BASE64URL = Base64.getUrlEncoder().withoutPadding();
private final OidcProvider[] configured;
private final Map<String, Provider> byId = new ConcurrentHashMap<>();
private final Map<String, Provider> byIssuer = new ConcurrentHashMap<>();
private SecurityExtension security;
private boolean localProviders;
public OidcExtension(OidcProvider... providers) {
this.configured = providers;
}
/** Lets {@link #register} accept http and private addresses — for a provider on a developer's machine, never in production. */
public OidcExtension allowLocalProviders() {
this.localProviders = true;
return this;
}
/**
* Trusts another provider from now on — an organization connecting its own. Discovery runs here,
* so a bad provider fails this call. It serves bearer tokens and {@code /auth/oidc/{id}/login}, but is
* not listed at {@code /auth/methods}: which provider a user signs in with is the application's call.
*
* @throws IllegalArgumentException the provider, or an endpoint it names, is not https on a public address,
* or its id is a configured provider's, which it would otherwise replace
*/
public void register(OidcProvider config) {
for (OidcProvider fixed : configured) {
if (fixed.id().equals(config.id())) throw new IllegalArgumentException("A configured provider already uses the id " + config.id());
}
Provider provider = new Provider(config, !localProviders);
Provider replaced = byId.put(config.id(), provider);
if (replaced != null) byIssuer.remove(replaced.issuer);
byIssuer.put(provider.issuer, provider);
}
/**
* 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
* @throws IllegalArgumentException naming what the provider refused
* @throws IllegalStateException the provider could not be reached
*/
public void verify(OidcProvider config, String origin) {
new Provider(config, !localProviders).verify(callbackUri(origin, config.id()));
}
/** Stops trusting a provider: its tokens are no longer this mechanism's, its sessions stop renewing. */
public void unregister(String id) {
Provider removed = byId.remove(id);
if (removed != null) byIssuer.remove(removed.issuer);
}
@Override
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
for (OidcProvider config : configured) {
Provider provider = new Provider(config, false);
byId.put(config.id(), provider);
byIssuer.put(provider.issuer, provider);
}
ctx.provide(OidcExtension.class, this);
app.get("/auth/oidc/{provider}/login", this::login);
app.get("/auth/oidc/{provider}/callback", this::callback);
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));
}
});
}
// -- Bearer access tokens -------------------------------------------------
@Override
public Principal authenticate(Request req) {
String header = req.header("Authorization");
if (header == null || !header.startsWith("Bearer ") || header.indexOf('.') < 0 || header.indexOf('.') == header.lastIndexOf('.')) return null;
String token = header.substring(7);
Provider provider;
SignedJWT jwt;
try {
jwt = SignedJWT.parse(token);
provider = byIssuer.get(String.valueOf(jwt.getJWTClaimsSet().getIssuer()));
} catch (Exception malformed) {
throw INVALID;
}
if (provider == null) return null;
try {
Map<String, Object> claims = provider.verifyAccessToken(jwt);
return new OidcPrincipal(provider.config.id(), (String) claims.get("sub"), claims, token, null, null);
} catch (Exception invalid) {
throw INVALID;
}
}
// -- Browser sign-in --------------------------------------------------------
private Object login(Request req, Response res) throws Exception {
Provider provider = provider(req);
String state = random(16);
String verifier = random(32);
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" : ""));
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));
return null;
}
private Object callback(Request req, Response res) {
Provider provider = provider(req);
String cookie = req.cookie(FLOW);
String[] flow = cookie == null ? null : cookie.split("\\.");
String state = req.query("state");
res.header("Set-Cookie", FLOW + "=; Path=/auth/oidc; Max-Age=0");
if (flow == null || flow.length != 4 || state == null
|| !MessageDigest.isEqual(state.getBytes(StandardCharsets.US_ASCII), flow[0].getBytes(StandardCharsets.US_ASCII))) {
throw HttpException.badRequest("Sign-in state does not match; start again");
}
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]);
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() + "/");
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));
} catch (Exception failed) {
throw HttpException.badRequest("Sign-in could not be completed; start again");
}
res.redirect(localPath(new String(Base64.getUrlDecoder().decode(flow[3]), StandardCharsets.UTF_8)));
return null;
}
private Session refresh(Session expired) {
OidcPrincipal old = (OidcPrincipal) expired.principal();
Provider provider = byId.get(old.provider());
if (provider == null || old.refreshToken() == null) return null;
try {
Map<String, Object> tokens = provider.token("grant_type=refresh_token&refresh_token=" + Provider.encode(old.refreshToken()));
Map<String, Object> claims = tokens.get("id_token") == null ? old.claims() : sessionClaims(provider.verifyIdToken(tokens.get("id_token")), tokens);
if (!old.name().equals(claims.get("sub"))) return null;
Object refreshToken = tokens.getOrDefault("refresh_token", old.refreshToken());
return new Session(expired.id(), new OidcPrincipal(old.provider(), old.name(), claims,
(String) tokens.get("access_token"), (String) refreshToken, old.logoutUrl()), expiry(tokens));
} catch (Exception failed) {
return null;
}
}
// -- Helpers ----------------------------------------------------------------
private Provider provider(Request req) {
Provider provider = byId.get(req.param("provider"));
if (provider == null) throw HttpException.notFound("OIDC provider");
return provider;
}
/** The ID token's claims plus the scopes the token response granted, which ID tokens do not carry. */
private static Map<String, Object> sessionClaims(Map<String, Object> idClaims, Map<String, Object> tokens) {
if (tokens.get("scope") == null) return idClaims;
Map<String, Object> claims = new HashMap<>(idClaims);
claims.put("scope", tokens.get("scope"));
return claims;
}
private static Instant expiry(Map<String, Object> tokens) {
return Instant.now().plusSeconds(tokens.get("expires_in") instanceof Number seconds ? seconds.longValue() : 300);
}
private static String callbackUri(String origin, String provider) {
return origin + "/auth/oidc/" + provider + "/callback";
}
/** Only same-origin paths: anything else would make sign-in an open redirect. */
private static String localPath(String path) {
return path != null && path.startsWith("/") && !path.startsWith("//") && !path.startsWith("/\\") ? path : "/";
}
private static String random(int bytes) {
byte[] value = new byte[bytes];
RANDOM.nextBytes(value);
return BASE64URL.encodeToString(value);
}
}
@@ -0,0 +1,49 @@
package dev.relism.flash.ext.security.oidc;
import dev.relism.flash.ext.security.Principal;
import java.util.Collection;
import java.util.Map;
/**
* A caller an OpenID Provider vouched for: through a bearer access token, or by signing in. Its
* {@link #name()} is the {@code sub} claim — unique per {@link #issuer()}, never across issuers.
*
* @param provider the {@link OidcProvider#id()}
* @param claims verified: the access token's for a bearer caller, the ID token's for a session
* @param refreshToken {@code null} for a bearer caller
*/
public record OidcPrincipal(String provider, String name, Map<String, Object> claims,
String accessToken, String refreshToken, String logoutUrl) implements Principal {
public String issuer() {
return (String) claims.get("iss");
}
public String email() {
return (String) claims.get("email");
}
public Object claim(String name) {
return claims.get(name);
}
/** From {@code scope} (space-delimited) or {@code scp}; a token that grants none grants none. */
@Override
public boolean hasScope(String scope) {
Object granted = claims.containsKey("scope") ? claims.get("scope") : claims.get("scp");
if (granted instanceof Collection<?> list) return list.contains(scope);
if (!(granted instanceof String delimited)) return false;
for (int at = delimited.indexOf(scope); at >= 0; at = delimited.indexOf(scope, at + 1)) {
int end = at + scope.length();
if ((at == 0 || delimited.charAt(at - 1) == ' ') && (end == delimited.length() || delimited.charAt(end) == ' ')) return true;
}
return false;
}
@Override
public boolean hasAudience(String audience) {
Object aud = claims.get("aud");
return aud instanceof Collection<?> list ? list.contains(audience) : audience.equals(aud);
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.ext.security.oidc;
/**
* An OpenID Provider the application trusts, and the client the application is registered as there.
*
* @param id names the provider in routes ({@code /auth/oidc/{id}/login}), OpenAPI and principals
* @param name what a sign-in page shows
* @param issuer exactly as the provider publishes it — some end in {@code /}
* @param scopes requested at sign-in, space-delimited
*/
public record OidcProvider(String id, String name, String issuer, String clientId, String clientSecret, String scopes) {
public OidcProvider {
if (!id.matches("[A-Za-z0-9_-]+")) throw new IllegalArgumentException("OIDC provider id must be URL-safe: " + id);
}
public static OidcProvider of(String id, String issuer, String clientId, String clientSecret) {
return new OidcProvider(id, id, issuer, clientId, clientSecret, "openid profile email");
}
public OidcProvider name(String name) {
return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes);
}
public OidcProvider scopes(String scopes) {
return new OidcProvider(id, name, issuer, clientId, clientSecret, scopes);
}
String discoveryUrl() {
return issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration";
}
}
@@ -0,0 +1,164 @@
package dev.relism.flash.ext.security.oidc;
import com.nimbusds.jose.JOSEObjectType;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.jwk.source.JWKSourceBuilder;
import com.nimbusds.jose.proc.DefaultJOSEObjectTypeVerifier;
import com.nimbusds.jose.proc.JWSVerificationKeySelector;
import com.nimbusds.jose.proc.SecurityContext;
import com.nimbusds.jose.util.JSONObjectUtils;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier;
import com.nimbusds.jwt.proc.DefaultJWTProcessor;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
/** One discovered provider: its endpoints, its signing keys, and the two token verifiers they back. */
final class Provider {
private static final HttpClient HTTP = HttpClient.newHttpClient();
final OidcProvider config;
final String issuer;
final String authorizationEndpoint;
final String endSessionEndpoint;
private final String tokenEndpoint;
private final String clientAuthorization;
private final DefaultJWTProcessor<SecurityContext> accessTokens;
private final DefaultJWTProcessor<SecurityContext> idTokens;
/**
* Fetches discovery. {@code guarded} providers — registered at runtime, from input the operator does
* not control — must be https on public addresses, and so must every endpoint their discovery names:
* otherwise registering one is a request forgery against the server's own network.
*/
Provider(OidcProvider config, boolean guarded) {
this.config = config;
try {
if (guarded) requirePublic(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));
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);
} catch (Exception e) {
if (e instanceof IllegalArgumentException rejected) throw rejected;
throw new IllegalStateException("OIDC discovery failed for " + config.discoveryUrl(), e);
}
if (issuer == null || authorizationEndpoint == null || tokenEndpoint == null) {
throw new IllegalStateException("Incomplete OIDC discovery document at " + config.discoveryUrl());
}
clientAuthorization = "Basic " + Base64.getEncoder().encodeToString(
(encode(config.clientId()) + ":" + encode(config.clientSecret())).getBytes(StandardCharsets.UTF_8));
}
Map<String, Object> verifyAccessToken(SignedJWT token) throws Exception {
return accessTokens.process(token, null).getClaims();
}
Map<String, Object> verifyIdToken(Object idToken) throws Exception {
if (!(idToken instanceof String jwt)) throw new IllegalStateException("The provider returned no ID token");
return idTokens.process(jwt, null).getClaims();
}
/** The sign-in request. {@link #verify} sends this same one, so it is refused exactly when a real sign-in would be. */
String authorizeUrl(String redirectUri, String state, String nonce, String challenge) {
return authorizationEndpoint + (authorizationEndpoint.indexOf('?') < 0 ? '?' : '&')
+ "response_type=code&client_id=" + encode(config.clientId())
+ "&redirect_uri=" + encode(redirectUri)
+ "&scope=" + encode(config.scopes())
+ "&state=" + state + "&nonce=" + nonce + "&code_challenge=" + challenge + "&code_challenge_method=S256";
}
/** A token endpoint call, authenticated as the client ({@code client_secret_basic}). */
Map<String, Object> token(String form) throws Exception {
HttpResponse<String> response = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", clientAuthorization)
.POST(HttpRequest.BodyPublishers.ofString(form)).build(), HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) throw new IllegalStateException("Token endpoint answered " + response.statusCode());
return JSONObjectUtils.parse(response.body());
}
/**
* Throws naming what the provider refuses, without anyone signing in. Client authentication is
* checked before the grant (RFC 6749 §5.2), so a made-up code tells a bad secret (401) from a good
* one. A redirect URI the client has not registered is answered with an error page, never a
* redirect (§4.1.2.1), but not always on the first hop: some providers bounce to their login page
* first, so same-host redirects are followed.
*/
void verify(String redirectUri) {
try {
HttpResponse<String> token = HTTP.send(HttpRequest.newBuilder(URI.create(tokenEndpoint))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Authorization", clientAuthorization)
.POST(HttpRequest.BodyPublishers.ofString("grant_type=authorization_code&code=verify&redirect_uri=" + encode(redirectUri)))
.build(), HttpResponse.BodyHandlers.ofString());
String error = token.body().contains("\"error\"") ? (String) JSONObjectUtils.parse(token.body()).get("error") : null;
if (token.statusCode() == 401 || "invalid_client".equals(error) || "unauthorized_client".equals(error)) {
throw new IllegalArgumentException("The provider rejected the client ID or secret");
}
// RFC 7636's example challenge: well-formed, so a provider that insists on PKCE judges the rest.
URI hop = URI.create(authorizeUrl(redirectUri, "verify", "verify", "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"));
String authority = hop.getAuthority();
for (int i = 0; i < 5; i++) {
HttpResponse<Void> answer = HTTP.send(HttpRequest.newBuilder(hop).build(), HttpResponse.BodyHandlers.discarding());
if (answer.statusCode() >= 400) throw new IllegalArgumentException("The provider does not accept the redirect URI " + redirectUri);
String location = answer.headers().firstValue("Location").orElse(null);
if (answer.statusCode() / 100 != 3 || location == null) return;
hop = hop.resolve(location);
// Back to the client, or onwards to a login somewhere else: either way it was accepted. Authority,
// not host: in development the provider and the client share localhost on different ports.
if (hop.toString().startsWith(redirectUri) || !authority.equals(hop.getAuthority())) return;
}
} catch (IllegalArgumentException refused) {
throw refused;
} catch (Exception unreachable) {
throw new IllegalStateException("Could not reach " + config.issuer(), unreachable);
}
}
/** 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) {
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));
return processor;
}
}
@@ -0,0 +1,167 @@
package dev.relism.flash.ext.security.oidc;
import dev.relism.flash.ext.security.RolesAllowed;
import dev.relism.flash.ext.security.ScopesAllowed;
import dev.relism.flash.ext.security.SecurityExtension;
import dev.relism.flash.ext.security.SecurityIdentity;
import dev.relism.flash.ext.security.SecurityPolicy;
import dev.relism.flash.ext.security.test.FakeOidcProvider;
import dev.relism.flash.testing.FlashResponse;
import dev.relism.flash.testing.FlashTest;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class OidcExtensionTest {
static final FakeOidcProvider provider = start();
static final FakeOidcProvider stranger = start();
static final SecurityExtension security = new SecurityExtension().roles(ClaimRoles.at("realm_access.roles"));
@RolesAllowed("admin") static class AdminOnly {}
@ScopesAllowed("write") static class Writers {}
@RegisterExtension
static final FlashTest app = FlashTest.of(flash -> flash
.install(security)
.install(new OidcExtension(OidcProvider.of("fake", provider.issuer(), "app", "secret").name("Fake")))
.get("/me", (req, res) -> {
OidcPrincipal principal = SecurityIdentity.current().principal(OidcPrincipal.class);
return principal.name() + " " + principal.email();
}, security.enforce(SecurityPolicy.AUTHENTICATED))
.get("/admin", (req, res) -> "admin", security.enforce(SecurityPolicy.of(AdminOnly.class)))
.get("/write", (req, res) -> "write", security.enforce(SecurityPolicy.of(Writers.class))));
static FakeOidcProvider start() {
try {
return new FakeOidcProvider();
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
// -- Bearer ---------------------------------------------------------------
@Test
void aBearerTokenFromTheProviderAuthenticates() {
app.request().with(provider.bearer("bob", Map.of("email", "bob@example.test"))).get("/me")
.expectStatus(200).expectBody("bob bob@example.test");
}
@Test
void aTamperedTokenIsInvalid() {
String token = provider.token("bob", Map.of());
app.request().header("Authorization", "Bearer " + token.substring(0, token.length() - 4) + "AAAA").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() {
app.request().with(stranger.bearer("bob", Map.of())).get("/me")
.expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"fake\"");
}
@Test
void rolesAndScopesAreReadFromTheToken() {
app.request().with(provider.bearer("root", Map.of("realm_access", Map.of("roles", List.of("admin"))))).get("/admin").expectStatus(200);
app.request().with(provider.bearer("bob", Map.of("realm_access", Map.of("roles", List.of("user"))))).get("/admin").expectStatus(403);
app.request().with(provider.bearer("bob", Map.of("scope", "read write"))).get("/write").expectStatus(200);
app.request().with(provider.bearer("bob", Map.of("scope", "read"))).get("/write").expectStatus(403);
}
// -- Browser sign-in --------------------------------------------------------
@Test
void signingInEndsInASessionThatRefreshesAndSignsOutAtTheProvider() throws Exception {
provider.signInAs("carol", Map.of("email", "carol@example.test")).expiresIn(0);
FlashResponse login = app.request().get("/auth/oidc/fake/login?redirect=%2Fme").expectStatus(302);
String flow = cookie(login.headers().allValues("Set-Cookie"), "flash_oidc");
HttpResponse<String> approved = HttpClient.newHttpClient().send(
HttpRequest.newBuilder(URI.create(login.header("Location"))).build(), HttpResponse.BodyHandlers.ofString());
String callback = URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawPath()
+ "?" + URI.create(approved.headers().firstValue("Location").orElseThrow()).getRawQuery();
FlashResponse signedIn = app.request().header("Cookie", flow).get(callback).expectStatus(302).expectHeader("Location", "/me");
String session = cookie(signedIn.headers().allValues("Set-Cookie"), "flash_session");
// expiresIn(0): the session is already expired, so this answer came through the refresh token.
app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol carol@example.test");
String logout = app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).header("Location");
assertTrue(logout.startsWith(provider.issuer() + "/logout?client_id=app&id_token_hint="), logout);
}
@Test
void aCallbackWithoutItsFlowCookieIsRefused() {
app.get("/auth/oidc/fake/callback?code=x&state=y").expectStatus(400);
app.get("/auth/oidc/nobody/login").expectStatus(404);
}
@Test
void theProviderIsListedAndDocumented() {
app.get("/auth/methods").expectBodyContains("{\"id\":\"fake\",\"name\":\"Fake\",\"url\":\"/auth/oidc/fake/login\",\"kind\":\"redirect\"}");
}
@Test
void aProviderRegisteredAtRuntimeIsTrustedUntilUnregistered() throws Exception {
OidcExtension extension = new OidcExtension().allowLocalProviders();
SecurityExtension own = new SecurityExtension();
FlashTest runtime = FlashTest.of(flash -> flash.install(own).install(extension)
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), own.enforce(SecurityPolicy.AUTHENTICATED)));
try {
runtime.get("/me").expectStatus(401);
extension.register(OidcProvider.of("byo", stranger.issuer(), "app", "secret"));
runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(200).expectBody("dora");
extension.unregister("byo");
runtime.request().with(stranger.bearer("dora", Map.of())).get("/me").expectStatus(401);
} finally {
runtime.app().stop().join();
}
}
/** Checked before anyone trusts it, and told apart: a bad secret is not a bad redirect URI. */
@Test
void aProviderIsVerifiedBeforeItIsTrusted() throws Exception {
try (FakeOidcProvider customer = new FakeOidcProvider().client("s3cret", "https://app.example/auth/oidc/acme/callback")) {
OidcExtension extension = new OidcExtension().allowLocalProviders();
extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example");
assertTrue(assertThrows(IllegalArgumentException.class,
() -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "wrong"), "https://app.example"))
.getMessage().contains("client ID or secret"));
assertTrue(assertThrows(IllegalArgumentException.class,
() -> extension.verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://elsewhere.example"))
.getMessage().contains("https://elsewhere.example/auth/oidc/acme/callback"));
// Verifying fetches URLs a customer chose, so it keeps register's address rules.
assertThrows(IllegalArgumentException.class,
() -> new OidcExtension().verify(OidcProvider.of("acme", customer.issuer(), "app", "s3cret"), "https://app.example"));
}
}
/** A provider registered at runtime, from a customer's input, never replaces one the application configured. */
@Test
void aRuntimeProviderCannotTakeAConfiguredId() {
OidcExtension extension = new OidcExtension(OidcProvider.of("central", provider.issuer(), "app", "secret")).allowLocalProviders();
assertThrows(IllegalArgumentException.class, () -> extension.register(OidcProvider.of("central", stranger.issuer(), "app", "secret")));
}
/** Registering a provider makes the server fetch URLs a customer chose: not on its own network, not in clear text. */
@Test
void aRuntimeProviderOnAPrivateAddressIsRefused() {
assertThrows(IllegalArgumentException.class, () -> new OidcExtension().register(OidcProvider.of("evil", stranger.issuer(), "app", "secret")));
}
private static String cookie(List<String> setCookies, String name) {
return setCookies.stream().filter(c -> c.startsWith(name + "=")).map(c -> c.substring(0, c.indexOf(';'))).findFirst().orElseThrow();
}
}