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>
76 lines
3.7 KiB
Markdown
76 lines
3.7 KiB
Markdown
# 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, `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
|
|
|
|
```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 (`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:
|
|
|
|
```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.
|