OIDC Relying Party Login (juneau-rest-server-auth-oidc-rp)
The opt-in juneau-rest-server-auth-oidc-rp module turns a Juneau REST server into a complete OpenID Connect Relying Party — the "Log in with Google / Okta / Entra ID / Keycloak" end-to-end glue. It orchestrates the interactive browser login dance (authorization redirect → callback → session) on top of the building blocks in juneau-rest-server-auth-oauth, wrapping the Nimbus OAuth 2.0 SDK (com.nimbusds:oauth2-oidc-sdk) behind Juneau facades.
See the guide: Secure an endpoint with JWT and OAuth
The Nimbus SDK is declared in provided scope on the module's POM so the dependency does not bleed into juneau-rest-server — consumers explicitly pick the SDK version they want (default pin: 11.37.2, the same line as juneau-rest-server-auth-oauth).
This module owns only orchestration, the session SPI, and the single-use state/nonce store. Every flow / parse / validate step is offloaded to Nimbus; there is no bespoke crypto.
At a glance
| Component | Purpose |
|---|---|
OidcRelyingParty | Builder + facade: startLogin / completeLogin / logout / refresh / backChannelLogout. |
SessionStore | SPI for persisting sessions; createSessionCookieValue / lookup / invalidate + back-channel revocation hooks. |
OidcSession | Immutable record: principal + roles + tokens + sub/sid + expiry. |
SignedCookieSessionStore | The documented default. Stateless HMAC-signed cookie; scales horizontally, survives restart. |
InMemorySessionStore | Single-instance / dev option. Server-side indexed by sub/sid — the one that supports back-channel logout. |
LoginStateStore | SPI for the single-use, TTL-bounded state → PendingLogin (nonce / code_verifier / redirectTarget) store; framework-owned MAX_TTL ceiling + atomic single-use consume. Inject a shared store via Builder.loginStateStore(...) for clustered login. |
InMemoryLoginStateStore | The documented default. Single-use, TTL-bounded, per-JVM LoginStateStore (renamed from EphemeralStore). |
OidcSessionAuthFilter | AuthFilter that resolves the session cookie into a ClaimsPrincipal on each request. |
IdTokenValidatorAdapter | Wraps Nimbus's IDTokenValidator (signature + iss/aud/azp/exp/nonce). |
Wiring the endpoints
The module owns the request-time OidcSessionAuthFilter, but the login / callback / logout endpoints are explicit @RestGet methods you mount — there are no auto-mounted servlet routes, so URL ownership stays with your application.
@Rest(path="/auth")
public class LoginResource extends BasicRestServlet {
private final OidcRelyingParty rp = OidcRelyingParty.create()
.issuer(URI.create("https://accounts.google.com")) // OidcDiscoveryClient under the hood
.clientId("web-app")
.clientSecret(env("OIDC_CLIENT_SECRET"))
.redirectUri(URI.create("https://app.example.com/auth/callback"))
.scope("openid", "profile", "email")
.sessionStore(SignedCookieSessionStore.create().signingKey(env("SESSION_KEY")).build())
.build();
@RestGet(path="/login")
public void login(RestRequest req, RestResponse res) throws Exception {
rp.startLogin(req, res); // generate state+nonce+PKCE, store single-use, 302 → IdP
}
@RestGet(path="/callback")
public void callback(RestRequest req, RestResponse res) throws Exception {
rp.completeLogin(req, res); // verify state+nonce, exchange code, validate ID token, create session, 302 → app
}
@RestGet(path="/logout")
public void logout(RestRequest req, RestResponse res) throws Exception {
rp.logout(req, res); // clear session + cookie, 302 → IdP end_session_endpoint
}
}
RestRequest / RestResponse are accepted directly because they implement HttpServletRequest / HttpServletResponse.
Resolving the identity on each request
Register rp.authFilter() in your filter chain so each request resolves the session cookie into a ClaimsPrincipal. The resolved identity rides on AuthenticatedRequestWrapper, so RoleBasedRestGuard, @RestGet(roleGuard=...), and @Auth Principal injection all keep working with zero downstream changes.
@Bean
public AuthFilterChain authFilters(BeanStore bs) {
return AuthFilterChain.create(bs)
.append(rp.authFilter()) // OidcSessionAuthFilter — cookie → ClaimsPrincipal
.build();
}
Resolution is fail-open to unauthenticated (not 401): a request with no session cookie, or an expired / tampered cookie, passes through so your app's normal "not logged in" handling (typically a redirect to /auth/login) applies.
Roles are extracted from a configurable claim (default "scope", whitespace-split; array claims also accepted) — identical to OAuthFilter's convention, so guard semantics match across the OAuth and OIDC-RP filters. Override with .rolesClaim("groups").
Session stores
| Store | Scales horizontally | Survives restart | Server-revocable (back-channel logout) |
|---|---|---|---|
SignedCookieSessionStore (default) | yes | yes | no |
InMemorySessionStore | no (needs sticky sessions) | no | yes |
| Caller-supplied distributed store (Redis / JDBC) via the SPI | yes | yes | yes (if implemented) |
SignedCookieSessionStore serializes a minimal claim set (subject, name, roles, sid, principal claims, expiry) into an HMAC-signed compact JWT — never the raw access / refresh / ID tokens. The payload is size-capped (~4 KB); an over-cap payload throws rather than silently truncating. Because the session lives entirely client-side it cannot be force-revoked, so it does not support back-channel logout.
// Stateless default (HS256; signing key must be ≥ 32 bytes):
SignedCookieSessionStore.create().signingKey(env("SESSION_KEY")).build();
// Server-side indexed (dev / single-instance / back-channel-logout-capable):
InMemorySessionStore.create();
Login-state store (clustering)
The transient state → PendingLogin map that bridges the authorization redirect and the callback is a separate SPI from the session store. The default InMemoryLoginStateStore is per-JVM, so it enforces single-use only within one node.
| Store | Enforces single-use across nodes | Notes |
|---|---|---|
InMemoryLoginStateStore (default) | no (per-JVM only) | Fine for single-instance or sticky-session deployments. |
Caller-supplied shared store (Redis / JDBC) via Builder.loginStateStore(...) | yes (if implemented atomically) | Required for clustered login without sticky sessions. |
OidcRelyingParty.create()
// ...issuer / clientId / redirectUri / sessionStore...
.loginStateStore(myRedisLoginStateStore) // shared, atomic single-use
.build();
A clustered deployment that cannot pin the redirect and callback to the same node must supply a shared LoginStateStore. When you do, honor the contract on LoginStateStore:
- The store is secret-bearing.
PendingLogincarries the PKCEcode_verifierand thenonce. TLS + ACLs are the floor; encryption-at-rest is recommended; never log the payload (PendingLogin.toString()already redacts the secrets). - Consume must be atomically single-use against the primary — an atomic
GETDEL(Redis) /SELECT … FOR UPDATE … DELETE(JDBC) / Lua, not a read-then-delete race. A replayedstatemust resolve at most once. - The framework does not trust the store as an oracle. On consume it re-validates
redirectTargetviasafeRelativePathand re-checksexpiresAtagainst its own clock (rejectingnull-timestamp or over-MAX_TTLrecords) before any token exchange. Treat the store as an untrusted blob store.
Security
The module is opt-in, security-reviewed glue. The defaults are fail-closed:
- Single-use
state+nonce— generated before the redirect, stored TTL-bounded (default 5 min, capped by a framework-owned 30 minMAX_TTL), and atomically consumed on the callback. A missing / replayedstatefails the callback (CSRF + ID-token-replay defense). On consume the framework re-checksexpiresAtagainst its own clock — independently of the store — and rejects an over-TTL (ornull-timestamp) record fail-closed before the token exchange, so a store that hands back a stale entry cannot extend the login window. - PKCE S256 is enforced end-to-end (verifier persisted across the redirect, used at exchange).
- Strict ID-token validation — signature against the IdP JWKS, exact
issmatch,audcontains the client id,azpon multi-audience tokens,exp/iatwithin clock skew, andnoncematch. The signing-algorithm allowlist defaults to[RS256, ES256];noneand SHA-1-family algorithms are rejected (inheriting thejuneau-rest-server-auth-jwtstrict-default stance). - Session-id rotation — a fresh session id is generated on login; there is no pre-auth fixation window.
- Token redaction — auth responses set
Cache-Control: no-store; tokens are never logged. - Cookie flags — the session cookie is
HttpOnly+Secure+SameSite=Laxby default (override via the builder). - Open-redirect defense — the post-login
redirectparameter is honored only when it is a safe app-relative path; absolute / protocol-relative targets are dropped. TheredirectTargetread back from the login-state store is re-validated viasafeRelativePathon consume as well, so a writable sharedLoginStateStorecannot smuggle in an off-site target (e.g.https://evil.example.com) — the store is treated as an untrusted blob store, not a trusted oracle.
Refresh-token rotation
For long-lived sessions backed by a token-retaining store, rp.refresh(req, res) exchanges the stored refresh token via OAuthRefreshTokenFlow, replaces the stored refresh token with the rotated one the IdP returns, and resets the session cookie. If the IdP rejects the refresh (a rotated token was reused / revoked), the session is invalidated (fail-closed). Session lifetime is bounded independently of token lifetime. (SignedCookieSessionStore retains no tokens, so refresh is a no-op there.)
Back-channel logout
rp.backChannelLogout(logoutToken) accepts an IdP-pushed OpenID Connect logout_token, validates it via Nimbus's LogoutTokenValidator (signature, iss, aud, the back-channel events claim, and the sub/sid rule), and invalidates the matching server-side session(s) — by sid (single session) when present, otherwise by sub (all of a subject's sessions).
@RestPost(path="/backchannel-logout")
public void backchannelLogout(@Content("logout_token") String logoutToken) {
rp.backChannelLogout(logoutToken); // returns the number of sessions invalidated
}
Back-channel logout requires a server-side-revocable store — a stateless cookie is not server-revocable, so the default SignedCookieSessionStore throws IllegalStateException here. Use InMemorySessionStore or a caller-supplied distributed store (which must report supportsServerSideRevocation() as true and implement invalidateBySubject / invalidateBySessionId).
Escape hatches
Nimbus is hidden behind the Juneau facades, but two escape hatches remain for advanced configuration:
httpRequestConfigurator(Consumer<HTTPRequest>)— proxy / TLS / timeout tuning applied to discovery, token, and userinfo calls.authenticationRequestCustomizer(Consumer<AuthenticationRequest.Builder>)— set OIDC parameters such asprompt,max_age, oracr_valueson the authorization redirect.
By default the Principal is built from ID-token claims (no extra round-trip). Configure userInfoClaims("email", ...) to backfill specific claims from the UserInfo endpoint only when they are absent from the ID token.
Single-IdP per instance
An OidcRelyingParty instance is bound to a single IdP. Multi-IdP support is achievable by instantiating several relying parties behind your own selector; federation / issuer-discovery across many IdPs is out of scope.
Containment verification
mvn -pl juneau-rest/juneau-rest-server dependency:tree | grep -iE "(nimbusds|oauth2-oidc)"
# returns nothing — provided scope contains the dep to this module only.
Maven dependency
<dependency>
<groupId>org.apache.juneau</groupId>
<artifactId>juneau-rest-server-auth-oidc-rp</artifactId>
<version>${juneau.version}</version>
</dependency>
<dependency>
<groupId>com.nimbusds</groupId>
<artifactId>oauth2-oidc-sdk</artifactId>
<version>11.37.2</version> <!-- provided scope; consumer-supplied -->
</dependency>
The module also pulls juneau-rest-server-auth-oauth (its building-block dependency) transitively at compile scope.
See also
- Secure an endpoint with JWT and OAuth: a guided walkthrough.
- OAuth 2.0 / OIDC AuthN Support — the building-block module (
OidcDiscoveryClient,OAuthAuthorizationCodeFlow,OAuthRefreshTokenFlow) this RP composes on top of. - AuthN Filter Framework — the
AuthFilter/AuthFilterChain/AuthenticatedRequestWrapperSPI the RP filter plugs into. - AuthN Guards —
ClaimsPrincipal,JwtTokenValidator, and the op-level guard family. - OpenID Connect Core 1.0, OpenID Connect Back-Channel Logout 1.0, RFC 7636 (PKCE).