--- title: "Authentication flow" output: rmarkdown::html_vignette vignette: > %\VignetteIndexEntry{Authentication flow} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ## Overview This vignette provides a step-by-step description of what happens during an authentication flow when using the `oauth_module_server()` Shiny module. It maps protocol concepts (OAuth 2.0 Authorization Code + PKCE, OpenID Connect) to the concrete implementation details in the package. For a concise quick-start (minimal and manual button examples, options, and security checklist) see: `vignette("usage", package = "shinyOAuth")`. For an explanation of logging key events during the flow, see: `vignette("audit-logging", package = "shinyOAuth")`. ## What happens during the authentication flow? The package implements the OAuth 2.0 'Authorization Code' flow and optional 'OpenID Connect' (OIDC) checks end‑to‑end. Below is the sequence of operations and the rationale behind each step. ### 1. First page load: set a browser token On the first load of your app, the module sets a small random cookie in the user's browser (SameSite=Strict; Secure when over HTTPS). This browser token is mirrored to Shiny as an input. Its purpose is to ensure that the same browser that starts the OAuth 2.0 flow is the one that finishes it (a "double-submit" style CSRF defense). ### 2. Decide whether to start login If `oauth_module_server(auto_redirect = TRUE)`, an unauthenticated session triggers immediate redirection to the provider authorization endpoint. If `oauth_module_server(auto_redirect = FALSE)`, you manually call `$request_login()` (e.g., via a button) to do so. ### 3. Build the authorization URL (`prepare_call()`) To redirect the user to the provider, the module constructs an authorization request URL. The URL is built from the provider's authorization endpoint and includes various query parameters to ensure security and proper context tracking: - State: this is a high-entropy random string to prevent CSRF; this package seals the state to enhance security (see below) - PKCE: a `code_verifier` (random) and `code_challenge` (S256 hash) proving the same party finishes the flow - Nonce (OIDC): random string echoed back in the ID token, mitigating replay attacks This package seals the state, meaning it encrypts and authenticates (AES-GCM AEAD) a payload containing: - state, client_id, redirect_uri - requested scopes - provider fingerprint (issuer/auth/token URLs) - issued_at timestamp Sealing the state prevents tampering, stale callbacks, and mix-ups with other providers/clients. On the server side, the package will store the sealed state (as a cache-safe hash key) in the state store (e.g., a 'cachem' backend) along with the following data: - browser token - code_verifier - nonce (OIDC) All this data will be used for validation during the callback processing. ### 4. App redirects to the provider The browser of the app user will be redirected to the provider's authorization endpoint with the following parameters: `response_type=code`, `client_id`, `redirect_uri`, `state=`, PKCE parameters, `nonce` (OIDC), `scope`, `claims` (OIDC, when configured via `oauth_client(claims = ...)`), `acr_values` (OIDC, when `required_acr_values` is set on the client), plus any configured extra parameters. Note: when the provider has an `issuer` set (indicating OIDC) and `openid` is missing from the client's scopes, it is automatically prepended with a one-time warning. ### 5. User authenticates and authorizes Once at the provider's authorization page, the user is prompted to log in and authorize the app to access the requested scopes. ### 6. Provider redirects user back to the app The provider redirects the user's browser back to your Shiny app (your `redirect_uri`), including the `code` and `state` parameters (and optionally `error` and `error_description` on failure). ### 7. Callback processing & state verification (`handle_callback()`) Once the user is redirected back to the app, the module processes the callback. This consists of the following steps: - Wait for the browser token input if not yet visible - Enforce callback query size caps (DoS protection) - If the callback includes an `iss` query parameter, validate it against the provider's configured/discovered issuer to defend against authorization-server mix-up attacks (per RFC 9207). A mismatch produces an `issuer_mismatch` error and audit event - If the callback is an error response (`?error=...`), require a valid `state` parameter; missing/invalid/consumed state is treated as `invalid_state` rather than surfacing the attacker-controlled `?error` value. The `error_uri` from the provider (RFC 6749 section 4.1.2.1) is also surfaced as a reactive field when included - Decrypt and verify the sealed state, ensuring integrity, authenticity, and freshness (using the `issued_at` window) - Check that embedded context matches expected client/provider (defends against misconfiguration/multi-tenant mix-ups) - Fetch and immediately delete the one-time state entry from the configured state store - If the entry is missing, malformed, or deletion fails, the flow aborts with a `shinyOAuth_state_error` - Audit events are emitted on failures (e.g., `state_store_lookup_failed`, `state_store_removal_failed`) - Verify that user's browser token matches the previously stored browser token - Ensure PKCE components are available when required Note: in asynchronous token exchange mode, the module may pre‑decrypt the sealed state and prefetch plus remove the state store entry on the main thread before handing work to the async worker, preserving the same single‑use and strict failure behavior. ### 8. Exchange authorization code for tokens Once the callback is verified, the module proceeds to exchange the authorization code for tokens. A POST request is made to the token endpoint with `grant_type=authorization_code`, the code, the redirect_uri, and the `code_verifier` (PKCE). Client authentication method depends on provider style: HTTP Basic header (`client_secret_basic`), body params (`client_secret_post`), or JWT-based assertions (`client_secret_jwt`, `private_key_jwt`) when configured. The response must include at least `access_token`. Malformed or error responses abort the flow. When successful, the package also applies two safety rails: - If the token response includes `scope`, shinyOAuth can reconcile it against the requested scopes (defaults to strict enforcement; configurable via the client `scope_validation` setting) - If the provider was configured with a non-empty `allowed_token_types`, the token response must include `token_type` and its value must be one of the allowed types (case-insensitive, e.g., `Bearer`) ### 9. Validate ID token (OIDC only) When using `oauth_provider(id_token_validation = TRUE)`, the following verifications are performed **before** any userinfo fetch to ensure cryptographic validation occurs prior to making external calls: - Signature: verified against provider JWKS (with optional pinning) for standard asymmetric algorithms (RSA-PKCS1, RSA-PSS, ECDSA, EdDSA). HMAC algorithms (HS256/384/512) are only allowed with explicit opt-in (`options(shinyOAuth.allow_hs = TRUE)`) and a sufficiently strong server-held secret - Claims: `iss` matches expected issuer; `aud` vector contains `client_id`; `sub` present; `iat` is required and must be a single finite numeric; time-based claims (`exp` is required, `nbf` optional) are evaluated with a small configurable leeway; tokens issued in the future are rejected - Header `typ` (when present): must indicate a JWT (`JWT`, case-insensitive). Other values (e.g., `at+jwt`) are rejected for ID tokens - Maximum ID token lifetime: `exp - iat` is checked against `options(shinyOAuth.max_id_token_lifetime)` (default 24 hours); tokens with unreasonably long lifetimes are rejected - Authorized party (`azp`): if `aud` has multiple entries, `azp` MUST be present and equal to `client_id`. If `azp` is present in any case, it MUST equal `client_id` - Nonce: must match the previously stored value (if configured) - `auth_time` validation (OIDC Core §3.1.2.1): when `max_age` is present in `extra_auth_params`, the ID token's `auth_time` claim must be present and satisfy `now - auth_time <= max_age + leeway` - `at_hash` (Access Token hash, OIDC Core §3.1.3.8): when the ID token contains an `at_hash` claim, the access token binding is verified. When `id_token_at_hash_required = TRUE` on the provider, the ID token must contain this claim or login fails - Essential claims (OIDC Core §5.5): if the client requested specific claims via the `claims` parameter with `essential = TRUE`, and `claims_validation` is `"warn"` or `"strict"`, the decoded ID token payload is checked for the presence of those essential claims. Missing essential claims trigger a warning or error depending on the mode. This is skipped when `claims_validation = "none"` (the default) - ACR enforcement (OIDC Core §2, §3.1.2.1): if the client was created with `required_acr_values`, the ID token's `acr` claim must be present and match one of the specified values. This ensures the provider performed the expected authentication context (e.g., MFA). If the `acr` claim is missing or not in the allowlist, login fails with a `shinyOAuth_id_token_error`. The authorization request also includes an `acr_values` parameter as a voluntary hint to the provider ### 10. Fetch userinfo (optional) If userinfo is requested via `oauth_provider(userinfo_required = TRUE)` (for which you should have a `userinfo_url` configured), the module calls the userinfo endpoint with the access token and stores returned claims. This happens **after** ID token validation to ensure cryptographic checks pass before making external calls. If this request fails, the flow aborts with an error. The userinfo endpoint may return either a standard JSON response or a JWT-encoded response (per OIDC Core, section 5.3.2). When the endpoint returns `Content-Type: application/jwt`, the body is decoded as a JWT with signature verification against the provider JWKS. When `userinfo_signed_jwt_required = TRUE` on the provider, the endpoint must return `application/jwt` or the flow is aborted. - Subject match: if `oauth_provider(userinfo_id_token_match = TRUE)`, it is checked that `sub` in userinfo equals `sub` in the ID token - Essential claims (OIDC Core §5.5): if the client requested specific userinfo claims via the `claims` parameter with `essential = TRUE`, and `claims_validation` is `"warn"` or `"strict"`, the userinfo response is checked for those claims. Missing essential claims trigger a warning or error depending on the mode ### 11. Token introspection (optional) Some providers support RFC 7662 token introspection (an additional endpoint where the server can ask the provider whether an access token is currently active and retrieve related metadata). If you enable `introspect = TRUE` when creating your `oauth_client()`, the module calls the provider's introspection endpoint during callback processing and requires the response to indicate `active = TRUE`. If introspection is unsupported by the provider or the introspection request fails, the login is aborted and `$authenticated` is not set to `TRUE`. You can optionally enforce additional provider-dependent fields via `oauth_client(introspect_elements = ...)`: - `"sub"` – require introspection `sub` to match the session subject - `"client_id"` – require introspection `client_id` to match your OAuth client id - `"scope"` – validate introspection `scope` against requested scopes (respects the client's `scope_validation` mode) (Note that not all providers may return each of these fields in introspection responses.) ### 12. Build the `OAuthToken` object Now that all verifications have passed, the module builds the final token object. This is an S7 `OAuthToken` object which contains: - `access_token` (string) - `refresh_token` (optional string) - `expires_at` (numeric timestamp, seconds since epoch; `Inf` for non-expiring tokens) - `id_token` (optional string) - `id_token_validated` (logical, indicating whether the ID token was cryptographically verified) - `id_token_claims` (read-only named list exposing the decoded JWT payload, e.g., `sub`, `acr`, `amr`, `auth_time`) - `userinfo` (optional list) The `$authenticated` value as returned by `oauth_module_server()` now becomes TRUE, meaning all requested verifications have passed. ### 13. Clean URL & tidy UI; clear browser token The user's browser was redirected to your app with OAuth 2.0 query parameters (`code`, `state`, etc.). To improve UX and avoid leaking sensitive data, these values are removed from the address bar with JavaScript. Optionally, the page title may also be adjusted (see the `tab_title_` arguments in `oauth_module_server()`). The browser token cookie is also cleared and immediately re-issued with a fresh value, so a future flow can start with a new per-session token. ### 14. Post-flow session management Now that the flow is complete, the module will manage the token lifetime during the active session. This may consist of: - Proactive refresh: if enabled and a refresh token exists, the access token is refreshed before expiry - Expiration: expired tokens are cleared automatically, setting the `$authenticated` flag to FALSE - Re-authentication: optionally, `oauth_module_server(reauth_after_seconds = ...)` can force periodic re-authentication #### Refresh behavior (`refresh_token()`) When the module refreshes a session (or when you call `refresh_token()` directly), it performs an OAuth 2.0 refresh token grant against the provider's token endpoint and updates the `OAuthToken` object. This works as follows: - A token request is sent with `grant_type=refresh_token` and the current `refresh_token` - The response must include a new `access_token`. `expires_at` is updated from `expires_in` when present; otherwise it is set to `Inf` - If the provider rotates the refresh token (returns a new `refresh_token`), it is stored; otherwise the original is preserved - If `oauth_provider(userinfo_required = TRUE)`, userinfo is re-fetched using the fresh access token With respect to OIDC ID token handling: - Per OIDC Core Section 12.2, refresh responses may omit `id_token`. When omitted, the original `id_token` from the initial login is preserved. Thus, a refresh does not necessarily revalidate identity - If the provider does return an `id_token` during refresh, shinyOAuth enforces OIDC 12.2 subject continuity: the refresh-returned `id_token` must have the same `sub` as the original `id_token` from login - If an original `id_token` did not exist in the session, and the refresh does return one, the refresh fails (cannot establish subject claim match with no baseline) - If `id_token_validation = TRUE`, the refresh-returned `id_token` is fully validated (signature + claims); the `sub` claim match is enforced as part of validation - If `id_token_validation = FALSE`, shinyOAuth still enforces the `sub` match by parsing the JWT payload (ensuring that the `sub` claim still matches but without full validation) - In both validation paths, `iss` and `aud` claims in the refreshed ID token are compared against the original ID token's values (not just the provider configuration) per OIDC Core Section 12.2, to cover edge cases with multi-tenant providers or rotating issuer URIs If refresh fails inside `oauth_module_server()`, the module exposes the failure via its reactive state (for example, `token_refresh_error`). By default it also clears the current session token; if `oauth_module_server(indefinite_session = TRUE)`, the token is kept but marked stale. In the default mode, the `$authenticated` flag becomes `FALSE` while the error is present. However, when `indefinite_session = TRUE`, the `$authenticated` flag remains `TRUE` even if errors are present, allowing long-lived sessions despite transient refresh failures. ### 15. Logout and token revocation When `auth$logout()` is called, the module: 1. Attempts to revoke both refresh and access tokens at the provider (RFC 7009) if a `revocation_url` is configured. This runs asynchronously only when `oauth_module_server(async = TRUE)` 2. Clears the local session (`OAuthToken`, browser cookie) 3. Emits a `"logout"` audit event 4. Re-issues a fresh browser token for subsequent logins You can also revoke tokens directly via `revoke_token(client, token, which = "refresh")`. To automatically attempt revocation when a Shiny session ends (for example, a tab close or session timeout), set `revoke_on_session_end = TRUE`: ```r auth <- oauth_module_server( "auth", client = client, revoke_on_session_end = TRUE ) ``` This is best-effort: the session may end while the provider is unavailable, and revocation failures do not block session cleanup.