refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
+38
@@ -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);
|
||||
}
|
||||
}
|
||||
+239
@@ -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);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
}
|
||||
}
|
||||
+32
@@ -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";
|
||||
}
|
||||
}
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
+167
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user