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) andcode_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=<sealed state>, PKCE
parameters, nonce (OIDC), scope, plus any
configured extra parameters.
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
- Decrypt and verify the sealed state, ensuring integrity,
authenticity, and freshness (using the
issued_atwindow) - 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)
- If the entry is missing, malformed, or deletion fails, the flow
aborts with a
- 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 clientscope_validationsetting) - If the provider was configured with a non-empty
allowed_token_types, the token response must includetoken_typeand its value must be one of the allowed types (case-insensitive, e.g.,Bearer)
9. 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.
If this request fails, the flow aborts with an error.
10. Validate ID token (OIDC only)
When using oauth_provider(id_token_validation = TRUE),
the following verifications are performed:
- 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:
issmatches expected issuer;audvector containsclient_id;subpresent;iatis required and must be a single finite numeric; time-based claims (expis required,nbfoptional) 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 - Nonce: must match the previously stored value (if configured)
- Subject match: if
oauth_provider(userinfo_id_token_match = TRUE), it is checked thatsubin userinfo equalssubin the ID token
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 introspectionsubto match the session subject -
"client_id"– require introspectionclient_idto match your OAuth client id -
"scope"– validate introspectionscopeagainst requested scopes (respects the client’sscope_validationmode)
(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;Inffor non-expiring tokens) -
id_token(optional string) -
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
$authenticatedflag 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_tokenand the currentrefresh_token - The response must include a new
access_token.expires_atis updated fromexpires_inwhen present; otherwise it is set toInf - 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 originalid_tokenfrom the initial login is preserved. Thus, a refresh does not necessarily revalidate identity - If the provider does return an
id_tokenduring refresh, shinyOAuth enforces OIDC 12.2 subject continuity: the refresh-returnedid_tokenmust have the samesubas the originalid_tokenfrom login- If an original
id_tokendid 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-returnedid_tokenis fully validated (signature + claims); thesubclaim match is enforced as part of validation - If
id_token_validation = FALSE, shinyOAuth still enforces thesubmatch by parsing the JWT payload (ensuring that thesubclaim still matches but without full validation)
- If an original
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 all cases, the $authenticated
flag becomes FALSE while the error is present.
15. Logout and token revocation
When auth$logout() is called, the module:
- Attempts to revoke both refresh and access tokens at the provider
(RFC 7009) if a
revocation_urlis configured. This runs asynchronously only whenoauth_module_server(async = TRUE) - Clears the local session (
OAuthToken, browser cookie) - Emits a
"logout"audit event - 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:
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.