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>
flash-ext-security-oidc
OpenID Connect for flash-ext-security-core: bearer
access tokens, and browser sign-in through the authorization code flow with PKCE.
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, 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
| 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,
where {origin} is SecurityExtension.origin(...).
Each provider is listed at /auth/methods ("kind":"redirect") and published to OpenAPI as an
openIdConnect scheme.
Providers added at runtime
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 (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
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:
new SecurityExtension().roles(ClaimRoles.at("realm_access.roles")) // Keycloak; "groups" for most others
Testing
FakeOidcProvider in flash-ext-security-test 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.