This vignette walks through what happens when a user signs in through
oauth_module_server(). It explains the main OAuth 2.0 and
OpenID Connect (OIDC) steps in package terms, so you can follow the flow
without needing deep protocol knowledge.
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").
‘shinyOAuth’ handles the OAuth 2.0 Authorization Code flow, plus optional OIDC checks, from start to finish. Below is the sequence of steps and why each one matters.
On the first load of your app, the module asks the browser to set a
small random cookie (SameSite=Strict by default;
Secure when required by HTTPS or
SameSite=None).
This browser token is mirrored to Shiny as an input. Its purpose is
to ensure that the same browser that starts login is the one that comes
back after the redirect. If the browser cannot create or read this
cookie bridge (for example because cookies are blocked or Web Crypto is
unavailable), the module surfaces browser_cookie_error and
stops before login continues. If the mirrored token looks invalid,
‘shinyOAuth’ rejects it, records the event, and asks the browser to
generate a fresh token before login or callback processing
continues.
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., when your user clicks
a button).
Without JAR and without PAR, the browser of the app user is
redirected to the provider’s authorization endpoint with the usual OAuth
query 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.
With JAR enabled but without PAR, the browser is still redirected to
the provider’s authorization endpoint, but the URL now carries the
Request Object instead of the raw authorization parameters. In practice,
the redirect contains request=<Request Object JWT>
plus the outer parameters that the active profile still requires. By
default, OIDC providers keep outer client_id,
response_type=code, and an outer scope
containing openid. That outer OIDC shape is required by
OpenID Connect Core Section 6.1, so
authorization_request_front_channel_mode = "minimal" is
rejected for OIDC by-value request transport. Plain OAuth
JAR flows can still use the minimal client_id plus
request shape. The Request Object itself is signed by
default, or signed first and then encrypted as a nested JWT when Request
Object encryption is configured.
With caller-managed request_uri mode, the browser is
redirected with request_uri=<absolute URL> plus any
outer parameters still required by the active profile. By default, OIDC
providers keep outer client_id,
response_type=code, and an outer scope
containing openid. That outer OIDC shape is required by
OpenID Connect Core Section 6.2, so
authorization_request_front_channel_mode = "minimal" is
rejected for caller-managed OIDC request_uri transport.
Plain OAuth request-by-reference flows can still use the minimal
client_id plus request_uri shape. The
authorization server then fetches the Request Object from that URL. This
is different from PAR: the request_uri points at a
client-managed published Request Object, not a provider-issued PAR
handle. The published object still follows the same JAR rules as above:
signed by default, or signed first and then encrypted when Request
Object encryption is configured. In the default Shiny-backed publisher
used by ‘shinyOAuth’, that URL also embeds Shiny session-routing path
segments, so it is not as log-opaque as a provider-issued PAR
handle.
With PAR enabled and selected, the browser is still redirected to the
provider’s authorization endpoint, but the front-channel URL contains
only the PAR handle plus any profile-required outer parameters. By
default, OIDC providers keep outer client_id,
response_type=code, an outer scope containing
openid, and request_uri. If your provider
accepts a smaller PAR redirect carrying only client_id plus
the provider-issued request_uri, set
oauth_provider(authorization_request_front_channel_mode = "minimal").
Plain OAuth PAR flows already use that minimal shape. If JAR request
mode is also enabled, the Request Object pushed to the PAR endpoint
follows the same rule: signed by default, or signed first and then
encrypted when Request Object encryption is configured.
The provider redirects the user’s browser back to your Shiny app
(your redirect_uri), including the code and
state parameters, and optionally RFC 9207 iss,
plus error, error_description, and
error_uri on failure.
handle_callback())Once the user is redirected back to the app, the module processes the callback. In plain terms, it checks that the callback belongs to the login attempt that started earlier and only then continues to token exchange. The main checks are:
browser_cookie_error instead of attempting authentication
without that bindingcode, state,
error, error_description,
error_uri, and ississ query parameter against the
provider’s configured/discovered issuer so the callback must come from
the expected provider (per RFC 9207). When
oauth_client(enforce_callback_issuer = TRUE) is enabled,
callbacks that omit iss are also rejected before token
exchange. A mismatch produces an issuer_mismatch error; a
missing required iss produces an
issuer_missing error and corresponding audit event?error=...),
still require a valid state parameter and browser-token
binding before showing the provider error. That way, attacker-controlled
error values are not trusted on their own. The provider’s
error_uri is only surfaced when it is an absolute HTTPS
URLshinyOAuth_state_errorstate_store_lookup_failed,
state_store_removal_failed)$take() method for single-use semantics.
Without that, ‘shinyOAuth’ rejects shared stores by default unless the
operator explicitly opts into the weaker replay-risk fallback with
options(shinyOAuth.allow_non_atomic_state_store = TRUE)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. The list below is intentionally a bit more detailed than
a typical app needs day to day; the main point is that ‘shinyOAuth’ does
these checks for you before making external calls:
RS256,
RS384, RS512, ES256,
ES384, ES512, EdDSA). HMAC
algorithms (HS256/HS384/HS512)
are only allowed with explicit opt-in
(options(shinyOAuth.allow_hs = TRUE)) and a sufficiently
strong server-held secret. RSA-PSS (PS256,
PS384, PS512) is not currently supportediss must match the expected issuer;
aud must include client_id; sub
must be present; iat 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): when an ID token names multiple
audiences, ‘shinyOAuth’ requires azp = client_id to keep
the client binding explicit. If azp is present at all, 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, must not be in
the future beyond leeway, and must 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, value, or
values, and claims_validation is
"warn" or "strict", the decoded ID token
payload is checked for missing essential claims and unsatisfied
requested claim values. These trigger a warning or error depending on
the mode. For claims$id_token, this enforcement only runs
after ‘shinyOAuth’ has validated the ID token; configure the provider
with id_token_validation = TRUE or
use_nonce = TRUE so those checks run on trusted token
content. 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 the returned
claims. This happens after ID token validation, so the
earlier token checks pass before another external call is made. If the
request fails, the flow aborts with an error.
When the access token is certificate-bound, ‘shinyOAuth’ treats the
userinfo call as protected-resource access: it uses the mTLS alias for
userinfo_endpoint when configured, sends the client
certificate on the TLS connection, and requires the token’s
cnf.x5t#S256 thumbprint to match that certificate before
making the request.
When a refresh response omits any new observable cnf,
‘shinyOAuth’ does not carry forward the previous x5t#S256
thumbprint onto the refreshed token. Refreshed access tokens keep mTLS
sender-constrained state only when the new token itself, or its
introspection response, supplies fresh cnf data.
The userinfo endpoint may return either a standard JSON response or,
less commonly, a JWT response (per OIDC Core section 5.3.2). When the
endpoint returns Content-Type: application/jwt, the body is
verified as a signed JWT against the provider JWKS. Only signed JWS
userinfo responses are supported. Encrypted UserInfo JWTs (JWE) are
rejected; configure the provider to return signed-only JWTs when using
application/jwt responses. When
userinfo_signed_jwt_required = TRUE on the provider, the
endpoint must return application/jwt or the flow is
aborted. UserInfo JWT verification is limited to asymmetric algorithms
from the provider’s allowed_algs (RS*,
ES*, or EdDSA); HS256,
HS384, and HS512 are rejected on this surface
even if HS* is otherwise enabled for ID tokens.
For security-sensitive deployments that rely on signed UserInfo JWTs,
consider requiring at least an expiry claim with
oauth_client(userinfo_jwt_required_temporal_claims = "exp").
OIDC Core does not require exp on signed UserInfo
responses, so ‘shinyOAuth’ leaves that policy opt-in and validates
exp, iat, and nbf whenever they
are present.
sub in userinfo
equals sub in the ID token. Setting
oauth_provider(userinfo_id_token_match = TRUE) additionally
makes the flow fail closed when userinfo is fetched but no validated ID
token baseline is availableclaims parameter with
essential = TRUE, value, or
values, and claims_validation is
"warn" or "strict", the userinfo response is
checked for missing essential claims and unsatisfied requested claim
values. These trigger a warning or error depending on the modeOAuthToken objectOnce the token response and any preceding verification steps have
succeeded, the module builds the OAuthToken object that
your app will work with. This happens before optional token
introspection, but the module still waits for any remaining checks
before marking the session as authenticated.
This is an S7 OAuthToken object which contains:
access_token (string)token_type (string, e.g., Bearer or
DPoP)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)cnf (optional confirmation claim set, such as an mTLS
certificate thumbprint)granted_scopes (normalized scope tokens currently
associated with the access token)granted_scopes_verified (logical indicating whether the
current token response explicitly proved those scopes)userinfo (optional list)Some providers support RFC 7662 token introspection. This is an extra server-to-server check where ‘shinyOAuth’ asks the provider whether a token is currently active and receives related metadata.
If you enable introspect = TRUE when creating your
oauth_client(), the module calls the provider’s
introspection endpoint after the token object has been built and
requires the response to indicate active = TRUE before the
session is treated as authenticated. If introspection fails, or if the
token is reported as inactive, login stops and
$authenticated is not set to TRUE.
You can optionally ask ‘shinyOAuth’ to check 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.
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 keep the
URL cleaner and avoid leaving sensitive values in the address bar, these
values are removed 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 starts with a new per-session token.
Once login is complete, the module manages token lifetime during the active session. Depending on your settings, that may include:
oauth_module_server(refresh_proactively = TRUE) and a
refresh token exists, the access token is refreshed before expiry$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. In short:
grant_type=refresh_token
and the current refresh_tokenDPoP-Nonce, ‘shinyOAuth’ retries the refresh request once
with a fresh proof that includes that nonceaccess_token.
expires_at is updated from expires_in when
present; otherwise ‘shinyOAuth’ synthesizes a finite fallback lifetime
(default 3600 seconds, configurable via
options(shinyOAuth.default_expires_in = ...))refresh_token), it is stored; otherwise the original is
preservedoauth_provider(userinfo_required = TRUE), userinfo
is re-fetched using the fresh access tokenoauth_client(introspect = TRUE), the refreshed
access token is introspected through the same client policy before the
session is updatedWhen the refresh response omits new observable cnf,
‘shinyOAuth’ does not carry forward the previous certificate thumbprint
onto the refreshed token. Refreshed access tokens keep mTLS
sender-constrained state only when the new token itself, or its
introspection response, supplies fresh cnf data.
If you are running a security-sensitive app, set
options(shinyOAuth.default_expires_in = ...) to the
provider’s documented lifetime instead of relying on the package
default, and consider
oauth_module_server(reauth_after_seconds = ...) when you
need a hard upper bound on session age.
Refresh can behave a little differently for OIDC ID tokens:
id_token. When that happens, ‘shinyOAuth’ keeps the
original id_token, so 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 URIsauth_time
when the original ID token had it, rejects a refreshed
nonce when it changes, and requires azp to
match when either token carries itIf refresh fails inside oauth_module_server(), the
module exposes the failure through its reactive state (for example,
auth$error == "token_refresh_error" plus
auth$error_description). By default it also clears the
current session token; if
oauth_module_server(indefinite_session = TRUE), the token
is kept and auth$token_stale becomes TRUE. In
the default mode, $authenticated becomes FALSE
while the error is present. With indefinite_session = TRUE,
$authenticated stays TRUE even if a refresh
error is present.
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 requires the provider to have a configured
revocation_url; otherwise
oauth_module_server() rejects
revoke_on_session_end = TRUE at startup.
This is best-effort: the session may end while the provider is unavailable, and revocation failures do not block local session cleanup.