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").
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.
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).
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.
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=<sealed 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.
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).
handle_callback())Once the user is redirected back to the app, the module processes the callback. This consists of the following steps:
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?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 includedissued_at
window)shinyOAuth_state_errorstate_store_lookup_failed,
state_store_removal_failed)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.
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:
options(shinyOAuth.allow_hs = TRUE)) and a sufficiently
strong server-held secretiss 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 rejectedtyp (when present): must indicate a JWT
(JWT, case-insensitive). Other values (e.g.,
at+jwt) are rejected for ID tokensexp - iat is checked against
options(shinyOAuth.max_id_token_lifetime) (default 24
hours); tokens with unreasonably long lifetimes are rejectedazp): 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_idauth_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 + leewayat_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
failsclaims 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)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 providerIf 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.
oauth_provider(userinfo_id_token_match = TRUE), it is
checked that sub in userinfo equals sub in the
ID tokenclaims 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 modeSome 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.)
OAuthToken objectNow 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.
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.
Now that the flow is complete, the module will manage the token lifetime during the active session. This may consist of:
$authenticated flag to FALSEoauth_module_server(reauth_after_seconds = ...) can force
periodic re-authenticationrefresh_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:
grant_type=refresh_token
and the current refresh_tokenaccess_token.
expires_at is updated from expires_in when
present; otherwise it is set to Infrefresh_token), it is stored; otherwise the original is
preservedoauth_provider(userinfo_required = TRUE), userinfo
is re-fetched using the fresh access tokenWith respect to OIDC ID token handling:
id_token. When omitted, the original id_token
from the initial login is preserved. Thus, a refresh does not
necessarily revalidate identityid_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
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)id_token_validation = TRUE, the refresh-returned
id_token is fully validated (signature + claims); the
sub claim match is enforced as part of validationid_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)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 URIsIf 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.
When auth$logout() is called, the module:
revocation_url is configured. This runs
asynchronously only when
oauth_module_server(async = TRUE)OAuthToken, browser
cookie)"logout" audit eventYou 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:
This is best-effort: the session may end while the provider is unavailable, and revocation failures do not block session cleanup.