Skip to main content

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​

ComponentPurpose
OidcRelyingPartyBuilder + facade: startLogin / completeLogin / logout / refresh / backChannelLogout.
SessionStoreSPI for persisting sessions; createSessionCookieValue / lookup / invalidate + back-channel revocation hooks.
OidcSessionImmutable record: principal + roles + tokens + sub/sid + expiry.
SignedCookieSessionStoreThe documented default. Stateless HMAC-signed cookie; scales horizontally, survives restart.
InMemorySessionStoreSingle-instance / dev option. Server-side indexed by sub/sid — the one that supports back-channel logout.
LoginStateStoreSPI 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.
InMemoryLoginStateStoreThe documented default. Single-use, TTL-bounded, per-JVM LoginStateStore (renamed from EphemeralStore).
OidcSessionAuthFilterAuthFilter that resolves the session cookie into a ClaimsPrincipal on each request.
IdTokenValidatorAdapterWraps 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​

StoreScales horizontallySurvives restartServer-revocable (back-channel logout)
SignedCookieSessionStore (default)yesyesno
InMemorySessionStoreno (needs sticky sessions)noyes
Caller-supplied distributed store (Redis / JDBC) via the SPIyesyesyes (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.

StoreEnforces single-use across nodesNotes
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. PendingLogin carries the PKCE code_verifier and the nonce. 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 replayed state must resolve at most once.
  • The framework does not trust the store as an oracle. On consume it re-validates redirectTarget via safeRelativePath and re-checks expiresAt against its own clock (rejecting null-timestamp or over-MAX_TTL records) 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 min MAX_TTL), and atomically consumed on the callback. A missing / replayed state fails the callback (CSRF + ID-token-replay defense). On consume the framework re-checks expiresAt against its own clock — independently of the store — and rejects an over-TTL (or null-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 iss match, aud contains the client id, azp on multi-audience tokens, exp/iat within clock skew, and nonce match. The signing-algorithm allowlist defaults to [RS256, ES256]; none and SHA-1-family algorithms are rejected (inheriting the juneau-rest-server-auth-jwt strict-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=Lax by default (override via the builder).
  • Open-redirect defense — the post-login redirect parameter is honored only when it is a safe app-relative path; absolute / protocol-relative targets are dropped. The redirectTarget read back from the login-state store is re-validated via safeRelativePath on consume as well, so a writable shared LoginStateStore cannot 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 as prompt, max_age, or acr_values on 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​