OAuth 2.0 and OIDC

Federated login through an Identity Provider you do not control is the most common reason adopters reach for OAuth. The user has a Google account, an Okta account, a corporate Azure AD account, and the application accepts a login from any of them rather than asking the user to invent and remember another password. The mechanism is OAuth 2.0 for the authorisation flow and OpenID Connect for the identity assertion layered on top. This chapter walks through what axess wires up automatically, what the integration code has to do, and the failure modes that have specific defences.

The feature flag is oauth (off by default), enabled with features = ["oauth"] on the axess facade. The feature transitively enables oidc (the discovery and JWKS-cache machinery) and jwt (the ID token validator).

Axess supports generic OIDC-based external login and SSO, including standard providers such as Google and Microsoft Entra ID when configured with the appropriate issuer metadata and client credentials. SAML / Shibboleth federation is not currently supported out of the box.

The shape of the flow

A federated login involves the user, the application (the OAuth client, in OAuth language, which is axess), and the Identity Provider (the OAuth server, which is the third-party IdP). The flow is the authorisation code grant with PKCE, which is what every modern OIDC deployment uses.

sequenceDiagram
    actor User
    participant App as Application (axess client)
    participant IdP as Identity Provider

    User->>App: GET /auth/login/google
    App->>App: build auth URL with PKCE + state + nonce
    App->>User: 302 to IdP authorize endpoint
    User->>IdP: GET /authorize?...
    IdP->>User: login + consent
    User->>App: GET /auth/callback?code=...&state=...
    App->>IdP: POST /token (code + pkce_verifier)
    IdP->>App: { id_token, access_token, refresh_token }
    App->>App: validate ID token (issuer, audience, nonce, signature)
    App->>App: optionally fetch /userinfo
    App->>App: transition session to Authenticated
    App->>User: 302 to /dashboard

The flow has six pieces axess does for you and three pieces the integration code is responsible for. The six axess-owned pieces are: generating the PKCE verifier and challenge, generating and binding the CSRF state, generating and binding the OIDC nonce, the discovery of the IdP's endpoints and signing keys, the token exchange itself, and the ID token validation including signature, audience, nonce, and the azp check when the audience is multi-valued. The three pieces the integration owns are: the redirect to the IdP authorize URL, the callback handler that picks up the code, and the application-specific mapping from the validated claims to the user record in the local identity store.

The provider

OAuthProvider is the trait that represents an IdP. The trait is asynchronous because every method may need to fetch JWKS, perform discovery, or hit the token endpoint. Adopters do not implement this trait themselves under normal circumstances; the OAuthProviderConfig constructor in axess-factors produces a provider from a discovery URL plus client credentials, and the returned provider implements the trait.

let provider = OAuthProviderConfig::discover(
    "https://accounts.google.com/.well-known/openid-configuration",
    client_id,
    client_secret,
    "https://your-app.example.com/auth/callback/google".parse()?,
)
.await?;

discover fetches the IdP's discovery document, validates it contains the endpoints axess needs (authorization, token, JWKS, userinfo, sometimes end-session), constructs a Discovery value, and sets up the JWKS cache against the IdP's signing-key endpoint. The cache is single-flight (concurrent JWKS misses dedupe to one request) and debounced (the cache refuses to refresh more often than once every few seconds, defeating a denial-of-service that triggers constant JWKS fetches).

The configuration record carries the client id and secret (both provisioned at the IdP), the redirect URI (where the IdP sends the user after authentication), the ceremony timeout (how long the intermediate state on the session may live before the flow has to restart), and the list of scopes to request (openid and profile at minimum; email if the application needs the user's email address; offline_access if the application needs a refresh token to continue acting as the user after the initial session expires).

Begin the login

The handler that starts the federated login transitions the session into a state that holds the PKCE verifier, the CSRF state, and the nonce, and returns a redirect to the IdP's authorize URL with those values bound in.

use axess::{AuthnService, AuthSession, OAuthLoginOptions};
use axum::response::{IntoResponse, Redirect};

async fn begin_oauth_login(
    session: AuthSession,
    State(service): State<Arc<AuthnService<...>>>,
    Path(provider_name): Path<String>,
) -> impl IntoResponse {
    match service
        .begin_oauth_login(&session, &provider_name, OAuthLoginOptions::default())
        .await
    {
        Ok(auth_url) => Redirect::to(auth_url.as_str()).into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e}")).into_response(),
    }
}

begin_oauth_login does three things internally. First, it generates the PKCE verifier through SecureRng and derives the S256 challenge that travels in the authorize URL. Second, it generates the CSRF state and the OIDC nonce, also through SecureRng, and stores all three values (verifier, state, nonce) in the session's intermediate state. Third, it composes the authorize URL with the client id, the redirect URI, the requested scopes, the PKCE challenge, the state, and the nonce, and returns it.

The redirect URI passed at this step must exactly match the one registered with the IdP at provisioning time. A mismatch is the single most common reason a federated login fails out of the box.

Handle the callback

The IdP, on successful user authentication and consent, redirects the user to the registered redirect URI with a code and a state query parameter. The application's callback handler picks these up, verifies the state matches what was stored on the session (defeating CSRF), and calls into axess to perform the token exchange.

async fn finish_oauth_login(
    session: AuthSession,
    State(service): State<Arc<AuthnService<...>>>,
    Path(provider_name): Path<String>,
    Query(callback): Query<CallbackQuery>,
) -> impl IntoResponse {
    match service
        .finish_oauth_login(&session, &provider_name, &callback.code, &callback.state)
        .await
    {
        Ok(_authenticated) => Redirect::to("/dashboard").into_response(),
        Err(e) => (StatusCode::UNAUTHORIZED, format!("{e}")).into_response(),
    }
}

#[derive(serde::Deserialize)]
struct CallbackQuery {
    code: String,
    state: String,
}

finish_oauth_login does seven things internally. First, it reads the PKCE verifier, the CSRF state, and the nonce from the session's intermediate state. Second, it cross-checks the supplied state against the stored state, returning OAuthError::CsrfMismatch if they disagree. Third, it constructs the POST to the IdP's token endpoint, including the code, the PKCE verifier, the client id, and the client secret. Fourth, it parses the response and extracts the ID token, access token, and (optional) refresh token. Fifth, it validates the ID token: signature against the cached JWKS, issuer match, audience match, nonce match, expiry, azp check when the audience is multi-valued. Sixth, it optionally fetches the userinfo endpoint with the access token to supplement the ID token claims. Seventh, it transitions the session to Authenticated (or to PendingWorkflow if the federated flow is part of a multi-step ceremony like signup).

If any of the seven steps fails, the function returns an OAuthError variant naming what failed. The session does not transition; the intermediate state is cleared (to prevent replay); the callback handler can render an error.

ID token validation

The ID token validation is where most of the security of an OIDC integration lives. Axess performs the full set of checks RFC 6749 and OpenID Connect Core 1.0 require; the integration code does not have to write them. The checks are:

The first is signature verification against the IdP's JWKS. The cache holds the current signing keys; if the ID token's kid header does not match a cached key, the cache refreshes (subject to the single-flight and debounce protections). A signature that fails against the refreshed keys produces OAuthError::SignatureInvalid.

The second is the issuer check. The ID token's iss claim must exactly match the discovery document's issuer field. A mismatch indicates either a misconfigured IdP, a discovery-document substitution attack, or an attempt to replay an ID token from a different issuer; all three produce OAuthError::IssuerMismatch.

The third is the audience check. The ID token's aud claim must contain the client's registered client id. If aud is a single value, the check is straightforward. If aud is an array (which happens when the IdP issues tokens valid for multiple clients), the check ensures the client id is in the array, and additionally enforces the azp (authorized party) check: the azp claim must exist and equal the client id, regardless of the array's contents. The azp check defeats a class of attacks where an ID token issued for one client is replayed against a different client whose id is also in the audience array.

The fourth is the nonce check. The ID token's nonce claim must exactly match the nonce that was generated at begin_oauth_login time and stored in the session. The nonce defeats ID token replay: an attacker who captures an ID token cannot reuse it against the same client because the session-bound nonce will not match on a later login.

The fifth is the expiry check. The ID token's exp claim must be in the future at the moment of validation, with a small clock-skew allowance. The clock comes from the injected Clock trait, so DST tests can exercise expiry handling deterministically.

The sixth is iat (issued-at) bounds. The token must have been issued within the last few minutes; tokens older than that indicate replay. The bound is configurable but defaults to five minutes, which matches what RFC 7519 implementations typically use.

Back-channel logout

When the IdP supports OIDC back-channel logout, the IdP sends a POST to a registered logout endpoint at the application with a logout_token. The application validates the token and, on success, revokes the user's session.

The validation is similar to ID token validation but slightly different: the audience and issuer checks apply, the azp check applies when audience is multi-valued, and an additional check on the events claim verifies the token is a back-channel logout token (the URI http://schemas.openid.net/event/backchannel-logout must be present). Axess implements this through OAuthProvider::verify_logout_jwt, which returns the claims on success.

The size cap on the logout token is eight kilobytes, the iat bound is five minutes, and the clock-skew tolerance is sixty seconds. The caps protect against denial-of-service through oversize tokens; the bounds defeat replay of a captured logout token after a meaningful delay.

RP-Initiated Logout

The opposite direction is RP-Initiated Logout: the application initiates a logout that propagates to the IdP, so the user is logged out of the IdP session as well as the application session. Axess constructs the end-session URL through OAuthProvider::build_end_session_url, which takes the ID token hint (the user's last issued ID token, signed by the IdP), an optional post_logout_redirect_uri (where to send the user after logout), and an optional state value.

The post_logout_redirect_uri must be on an allowlist that the application configures. The allowlist exists to defeat open-redirect attacks: an attacker who can manipulate the redirect URI could send the user to an arbitrary external site after logout, which is the shape of a phishing setup. The allowlist is a small explicit list of allowed URIs; anything else is rejected at build_end_session_url time.

Multiple providers

A common shape is to offer login with several IdPs side by side (Google, GitHub, Microsoft). Each provider is its own OAuthProvider instance constructed at startup; the application registers them under a provider_name key. The login URL carries the provider name (GET /auth/login/google); the callback URL also carries the name (GET /auth/callback/google). Axess dispatches to the right provider per request.

A per-tenant variation is also common: each tenant's users federate against the tenant's own IdP (an Okta workspace, an Azure AD directory). The provider name in this case is the tenant slug; the provider is constructed at tenant provisioning time (or lazily, on first use) and cached. The scope hierarchy chapter covers the pattern for storing per-tenant configurations.

Threat model

OAuth and OIDC together are robust against a handful of attacks when the implementation does the validations above correctly.

Against CSRF on the callback: the state parameter binds the callback to the session that started the login. An attacker who tricks a user into hitting the callback URL with a stolen code cannot complete the login because the state will not match.

Against ID token replay: the nonce binds the ID token to the session's login attempt. An ID token captured by an attacker cannot be replayed against a different session.

Against ID token forgery: signature validation against the JWKS catches an attacker who synthesises an ID token without the IdP's signing key.

Against audience confusion (an ID token issued for one client used against another): the audience check plus the azp check on multi-element audiences catch this.

Against authorization code interception: PKCE binds the code to the verifier the application generated. An attacker who intercepts the code cannot exchange it without the verifier.

Against open-redirect phishing on logout: the allowed_post_logout_redirect_uris allowlist catches an attacker who tries to manipulate the redirect URI.

The attacks OAuth and OIDC do not defend against are the ones FIDO2 defends against (real-time phishing of the IdP login page itself) and the ones that depend on the IdP's own security posture (a compromised IdP issues compromised tokens, and no client-side check catches that). The defence for the latter is operational: monitor which IdPs the application accepts, audit periodically, and rotate the registered client secret if the IdP suffers a breach.

Troubleshooting

A few failure modes recur during initial integration.

If the callback returns an error about state mismatch, the most likely cause is that the user took longer than the ceremony timeout to complete the IdP login. The intermediate state on the session has expired and the state value is no longer recoverable. Increasing the ceremony timeout (a generous fifteen minutes is reasonable) is the fix.

If the token exchange returns an invalid-client error, the client id or secret in OAuthProviderConfig does not match what the IdP has registered. The most common variant is using a public-client id at the IdP while configuring axess with a confidential-client expectation (or vice versa). Check the IdP's client registration page.

If the ID token validation returns an audience mismatch on an IdP that supports multiple clients, the aud claim is probably an array and the azp claim is missing. Some IdPs do not emit azp when they should; configuring the IdP to issue azp is the fix. Axess deliberately refuses to bypass the azp check because doing so would open the audience-confusion attack.

If the userinfo endpoint returns a 401 after a successful token exchange, the access token's scopes do not include the ones the userinfo endpoint requires. The fix is to add the required scopes (typically profile and email) to the scopes configuration.

Further reading

FAPI 2.0 covers the financial-grade extensions (PAR, DPoP, JARM) that layer on top of the OAuth provider for regulated deployments. Workload identity overview covers the inbound resolver side of the same machinery, where the application is the OAuth server accepting tokens issued by federated workload-identity systems. Local IdP covers the in-process IdP, both production LocalIdp for workload-identity issuance and the LocalIdpFixture that mints test tokens against a controllable JWKS for integration tests.