‘shinyOAuth’ emits structured audit events as the OAuth 2.0/OIDC flow runs. These events can help you understand what happened during login and spot problems such as repeated failures, replay attempts, or configuration issues.
This vignette covers:
You can use
options(shinyOAuth.audit_hook = function(event) { ... }) to
register a hook function that will be called whenever ‘shinyOAuth’ emits
a structured audit or error event. Keep this function fast,
non-blocking, and safe: it should not throw errors.
Example of printing audit events to console:
options(shinyOAuth.audit_hook = function(event) {
cat(sprintf("[AUDIT] %s %s\n", event$type, event$trace_id))
str(event)
})Another way that ‘shinyOAuth’ can emit audit events is via
OpenTelemetry logging. Please see
vignette("opentelemetry", package = "shinyOAuth") for more
details.
All audit events share the following base shape:
type: a string starting with
audit_...trace_id: a short correlation id for linking related
records from the same operation or auth flowtimestamp: POSIXct time when the event was created
(from Sys.time())For the interactive login flow, ‘shinyOAuth’ propagates the same
trace_id from redirect preparation through callback
validation, token exchange, and login success or failure handling. The
trace id is stored inside the sealed state so it survives the browser
round-trip without exposing raw OAuth values.
This trace_id is the application-level correlation id
used by ‘shinyOAuth’. When OTel logging is enabled it is exported as the
shinyoauth.trace_id log attribute. It is separate from any
trace/span ids created by your OpenTelemetry backend.
When events are emitted from within a Shiny session, a JSON-friendly
shiny_session list is attached to every event so you can
tie the audit record back to the app session and HTTP request. The
structure is designed to serialize cleanly with
jsonlite::toJSON():
shiny_session$token: the Shiny per-session token
(session$token) when available.shiny_session$is_async: FALSE for events
emitted from the main R process and TRUE for events emitted
from an async worker. This helps distinguish background work such as
async token exchange or refresh from the main reactive flow.capture_shiny_session_context(is_async = TRUE) but emit the
event before any worker work actually starts, ‘shinyOAuth’ resets that
borrowed context back to FALSE because the event still came
from the main process.shiny_session$process_id: the process ID (PID) of the R
process that emitted the event.shiny_session$main_process_id: (async events only) the
PID of the main R process that spawned the async worker. This allows you
to correlate events from workers back to the originating main
process.mirai_error_type: a top-level event field present only
on async failure variants of login_failed,
session_cleared, and
refresh_failed_but_kept_session. It classifies mirai
transport-level failures separately from application-level errors. When
present:
"mirai_error" — code threw an R error inside the
worker"mirai_timeout" — the task exceeded its timeout and was
cancelled by dispatcher"mirai_connection_reset" — the daemon process crashed
or was terminated"mirai_interrupt" — the task was interrupted/cancelled
via stop_mirai()NA — not a mirai-specific error (e.g., sync path or
future backend)shiny_session$http: a compact HTTP summary with fields:
method, path, query_string,
host, scheme, remote_addrheaders: a list of request headers derived from
HTTP_* environment variables, with lowercase names (e.g.,
user_agent).With the default
options(shinyOAuth.audit_redact_http = TRUE),
remote_addr is replaced with [REDACTED] before
the event is emitted.
Note: the raw session$request from Shiny is not included
to keep the event JSON-serializable and concise.
For safety, the shiny_session$http summary is
automatically sanitized before being attached to events. This prevents
accidental secret leakage when forwarding events to log sinks:
code, state, access_token,
refresh_token, id_token, token,
session_state, code_verifier, and
nonce, plus credential-bearing token-endpoint parameters
such as client_secret, client_assertion,
assertion, username, and
password, are replaced with [REDACTED].Cookie,
Set-Cookie, Authorization,
Proxy_Authorization, Proxy_Authenticate, and
WWW-Authenticate headers are stripped entirely.x_
(e.g., x_forwarded_for, x_real_ip) are
replaced with [REDACTED] to avoid leaking internal
infrastructure details.remote_addr is
replaced with [REDACTED] while HTTP redaction stays
enabled.This means you can safely forward the shiny_session$http
object to external logging systems without manually stripping
secrets.
If you need the raw, unsanitized HTTP context in audit events for local debugging, you can disable redaction temporarily:
Do not use this in production log sinks. Raw mode can expose authorization codes, state values, cookies, authorization headers, client assertions, and client IP addresses.
To completely exclude HTTP request details from audit events:
This means that the shiny_session$http field will be
NULL in all audit events.
When async = TRUE is configured in
oauth_module_server(), token exchange, refresh, and
revocation run through the async dispatch layer in ‘shinyOAuth’. With
mirai daemons and future multisession plans this means background worker
processes; with future::sequential the code stays
in-process but still uses the async promise path. The package
automatically propagates your shinyOAuth.audit_hook option
to these async executions, so audit events fire there as well. For async
work managed by ‘shinyOAuth’, the package also replays the parent
session’s relevant OTEL_* / OTEL_R_*
environment variables inside the async execution context so exporter
configuration stays aligned with the main R process. It also propagates
the effective shinyOAuth.otel_tracing_enabled and
shinyOAuth.otel_logging_enabled option gates so reused
workers do not keep stale telemetry-disabled state from an earlier
task.
Note that your audit hook function (and any objects referenced in its closure) must be serializable. If your hook writes to a database connection, file handle, or other non-serializable resource, it will fail in the worker process and ‘shinyOAuth’ will surface that failure as a warning (captured and replayed to the main process when using async workers). Use hooks that create connections on demand (e.g., open a database connection inside the hook body) rather than capturing an existing connection in the closure.
Also note that in true async mode a worker cannot mutate the main R
process’s in-memory objects. Patterns like appending to a global list or
incrementing a counter inside audit_hook only affect the
worker’s private copy. For cross-process delivery, write to an external
sink (database, file, queue, OTLP exporter, etc.) or explicitly return
captured data from the worker in tests.
Many audit events include digest fields such as
client_id_digest, state_digest,
code_digest, browser_token_digest, and
sub_digest. These let you connect related events without
logging the raw sensitive values themselves.
By default, these digests use HMAC-SHA256 with an auto-generated
per-process key. If you do not configure
options(shinyOAuth.audit_digest_key = ...), each R process
gets its own random key at runtime. That makes accidental cross-process
matching harder if logs leak, but it also means digests are not stable
across unrelated processes by default.
For async work managed by ‘shinyOAuth’, the package propagates the effective digest key into its workers so main-process and worker events from the same app instance remain comparable. If you need digests to stay comparable across multiple app processes or separate R sessions, configure a shared key.
If you run multiple workers/processes and want digests to be comparable across them, configure a shared key:
To disable keying (legacy deterministic SHA-256 digests):
Note: unkeyed digests are easier to compare across systems, but they are also easier to guess for low-entropy values such as email addresses.
audit_callback_query_rejectedprovider, issuer,
client_id_digest, error_classphase, reason, and
handle_digest.audit_callback_iss_missingenforce_callback_issuer = TRUE and the callback
omits the RFC 9207 iss parameterprovider, expected_issuer,
client_id_digest, error_classaudit_callback_iss_mismatchiss query parameter (per
RFC 9207) that does not match the provider’s expected issuerprovider, expected_issuer,
callback_issuer, client_id_digest,
error_classaudit_callback_iss_validation_failedprovider, expected_issuer,
callback_issuer (when present),
client_id_digest, error_classaudit_callback_receivedstate payload has been decrypted
and validated (or prevalidated on the main process for async callback
handling), just before state-store consumption and the later
browser-token/PKCE/nonce checksprovider, issuer,
client_id_digest, code_digest,
state_digest, browser_token_digestCallback validation covers both the sealed-state checks and the later checks of the values tied to that state, such as the browser token, PKCE code verifier, and nonce. Each stage emits either a success event or a failure event.
audit_callback_validation_successstate payload has been decrypted
and verified for freshness and client/provider binding (emitted from
state_payload_decrypt_validate())provider, issuer,
client_id_digest, state_digestaudit_callback_validation_failedprovider, issuer,
client_id_digest, state_digest,
phase, error_class (+
browser_token_digest when phase is
browser_token_validation)payload_validation,
browser_token_validation,
pkce_verifier_validation, nonce_validation,
form_post_request_validation,
form_post_callback_lookuphandle_digest is included when a form_post callback
handle is missing, expired, or already consumed.callback_validation_failed event.State retrieval and removal of the single-use state entry are emitted
as separate events by state_store_get_remove().
audit_state_store_lookup_failedstate_store fails (missing, malformed, or underlying cache
error)provider, issuer,
client_id_digest, state_digest,
error_class, phase
(state_store_lookup or
state_store_atomic_take)state_store_atomic_take phase applies when using a store
with an atomic $take() method.audit_state_store_removal_failedprovider, issuer,
client_id_digest, state_digest,
error_class, phase
(state_store_removal)Digest differences: For audit_callback_validation_failed
during payload decryption (phase = "payload_validation")
the state_digest is computed from the encrypted payload
(plaintext not yet available). For state store events the digest
reflects the plaintext state string.
audit_token_exchangeprovider, issuer,
client_id_digest, code_digest,
used_pkce, received_id_token,
received_refresh_token,
expires_in_synthesizedexpires_in_synthesized (logical): TRUE
when the token response did not include a usable expires_in
value and the package fell back to
resolve_missing_expires_in()audit_token_exchange_errorprovider, issuer,
client_id_digest, code_digest,
error_classDetailed sender-constraint diagnostics such as DPoP token-type
inference, DPoP nonce retries, and mTLS endpoint-alias selection are
emitted on the OpenTelemetry spans documented in
vignette("opentelemetry") rather than on the high-level
audit events.
audit_token_introspectionintrospect_token() reaches a final result (for
example during login or refresh when
introspect = TRUE)provider, issuer,
client_id_digestwhich (“access” or “refresh”)supported (logical), active (logical|NA),
statussub_digest, introspected_client_id_digest,
scope_digest (when available)status values include "ok",
"introspection_unsupported", "missing_token",
"body_too_large", "invalid_json",
"missing_active", "invalid_active", and
"http_<code>"audit_login_successOAuthToken is
createdprovider, issuer,
client_id_digest, sub_digest,
sub_source, refresh_token_present,
expires_atsub_source indicates where sub_digest was
derived from:
userinfo: subject came from the userinfo responseid_token: subject came from an ID token that was
validated (signature + claims)id_token_unverified: subject came from an ID token
payload parse when ID token validation was not performedaudit_login_failedprovider, issuer,
client_id_digest, phase,
error_class, mirai_error_typephase currently includes:
sync_token_exchangeasync_token_exchangeasync_payload_validationasync_state_store_lookupmirai_error_type is only present on async failure
pathsaudit_logoutvalues$logout() is called on the moduleprovider, issuer,
client_id_digest, reason (default
manual_logout)audit_session_clearedprovider, issuer,
client_id_digest, reason,
error_class, mirai_error_typerefresh_failed_async,
refresh_failed_sync, reauth_window,
token_expirederror_class is present on refresh failure reasons
(refresh_failed_async, refresh_failed_sync)
but absent for reauth_window and
token_expired; mirai_error_type is only
present for async refresh-failure clearsaudit_token_revocationrevoke_token() reaches a final outcome (including
early unsupported or missing_token returns)
during logout or session endprovider, issuer,
client_id_digestwhich (“access” or “refresh”)supported (logical), revoked (logical|NA),
statusstatus values include "ok",
"revocation_unsupported", "missing_token", and
"http_<code>"audit_refresh_failed_but_kept_sessionindefinite_session = TRUE in
oauth_module_server())provider, issuer,
client_id_digest, reason
(refresh_failed_async|refresh_failed_sync),
kept_token (TRUE), error_class,
mirai_error_typemirai_error_type is only present on async refresh
failuresaudit_invalid_browser_tokenshinyOAuth_sid
value from the browser and requests regenerationprovider, issuer,
client_id_digest, reason,
lengthaudit_token_refreshrefresh_token() successfully refreshes the access
tokenprovider, issuer,
client_id_digest, refresh_token_rotated,
new_expires_at, expires_in_synthesizedexpires_in_synthesized (logical): TRUE
when the refresh response did not include a usable
expires_in value and the package fell back to
resolve_missing_expires_in()audit_userinfoget_userinfo() is called to retrieve user
information (emitted on success and various failure modes)provider, issuer,
client_id_digest, sub_digest,
statusstatus values:
"ok" – userinfo successfully parsed"parse_error" – response could not be parsed as JSON or
JWT. Additional fields: http_status, url,
content_type, body_digest"userinfo_missing_sub" – OIDC userinfo response was
parsed but omitted the required sub claim"userinfo_not_jwt" – signed JWT required but response
was not application/jwt. Additional fields:
content_type"userinfo_jwt_encrypted" – userinfo response was a JWE,
which ‘shinyOAuth’ does not decrypt"userinfo_jwt_header_parse_failed" – JWT header could
not be parsed"userinfo_jwt_header_invalid" – JWT header parsed but
was malformed or structurally invalid"userinfo_jwt_typ_invalid" – JWT header
typ did not indicate a JWT"userinfo_jwt_unsigned" – JWT uses
alg=none. Additional fields: jwt_alg"userinfo_jwt_alg_rejected" – JWT algorithm not in
provider’s allowed asymmetric algorithms. Additional fields:
jwt_alg"userinfo_jwt_no_issuer" – provider issuer not
configured for JWKS verification"userinfo_jwt_jwks_fetch_failed" – JWKS fetch failed
during signature verification"userinfo_jwt_signature_invalid" – signature
verification failed against candidate JWKS keys"userinfo_jwt_no_matching_key" – provider JWKS had no
compatible key for the JWT"userinfo_jwt_payload_parse_failed" – JWT payload could
not be parsed as JSON"userinfo_jwt_missing_sub",
"userinfo_jwt_missing_iss",
"userinfo_jwt_missing_aud" – signed JWT omitted a required
claim"userinfo_jwt_iss_mismatch",
"userinfo_jwt_aud_mismatch" – signed JWT claims did not
match the configured issuer/client"userinfo_jwt_missing_required_temporal_claims" –
signed JWT omitted required temporal claims such as exp or
iat"userinfo_jwt_invalid_exp",
"userinfo_jwt_invalid_iat",
"userinfo_jwt_invalid_nbf" – temporal claim was present but
not a single usable numeric value"userinfo_jwt_expired",
"userinfo_jwt_iat_future",
"userinfo_jwt_nbf_future" – temporal claim failed time
validationState parsing failures occur while decoding and validating the encrypted wrapper prior to extracting the logical state value, and also when deriving a cache key from a malformed logical state string.
audit_state_parse_failurephase (decrypt or
cache_key), a reason code, and either
token_digest (phase = decrypt) or
state_digest (phase = cache_key), plus any
additional details (such as lengths). Emitted best-effort from parsing
utilities and never interferes with control flow.When the provider returns an error response (e.g.,
access_denied) but includes the state
parameter, the module waits for the browser token, consumes the state to
prevent replay and clean up the store, and then verifies the
browser-token binding before surfacing the provider error. Browser-token
mismatches are reported via
audit_callback_validation_failed with
phase = "browser_token_validation"; the events below cover
the state-consumption portion of that flow.
audit_error_state_consumedprovider, issuer,
client_id_digest, state_digestaudit_error_state_consumption_failedprovider, issuer,
client_id_digest, state_digest,
error_class, error_messageDigest note: when the callback state can be decrypted,
these events use the logical plaintext state digest so they correlate
with audit_redirect_issued and the normal callback
validation/store events. If decryption fails, the digest falls back to
the encrypted callback payload because the logical state is unknown.
audit_session_startedoauth_module_server())
is initialized for a Shiny sessionmodule_id, ns_prefix,
client_provider, client_issuer,
client_id_digest, plus the standard
shiny_session context described aboveaudit_session_endedonSessionEnded, regardless of configuration)provider, issuer,
client_id_digest, was_authenticatedaudit_session_ended_revokerevoke_on_session_end = TRUE and a token was presentprovider, issuer,
client_id_digest; the actual revocation attempt is logged
separately as audit_token_revocation eventsaudit_authenticated_changed$authenticated reactive value changes (TRUE ↔︎
FALSE)provider, issuer,
client_id_digest, authenticated,
previous_authenticated, reasonlogin (when becoming authenticated),
or the error code/state that caused de-authentication (e.g.,
token_expired, logged_out,
token_cleared)In addition to the audit_* events above, the hook also
receives error events emitted just before the package raises an R error
condition. These let you log failures to the same sink as audit
events.
errorerr_*() helpers (state validation, PKCE, token, ID token,
userinfo, configuration, input, parse errors)type ("error"), trace_id,
message,context fields from the call site (typically
provider, issuer,
client_id_digest, phase,
error_class)http_errortype ("http_error"),
trace_id, messagestatus: HTTP status code (integer, or NA
if unavailable)url: the request URLbody_digest: SHA-256 hex digest of the response body
(for correlation without leaking content)oauth_error, oauth_error_uri: RFC 6749
§5.2 structured error fields extracted from JSON error responses (e.g.,
from the token endpoint)oauth_error_description: included only when
options(shinyOAuth.expose_error_body = TRUE) is enabled for
debugging, because provider-controlled text can contain request-specific
detailscontext fields from the call sitetransport_errortype ("transport_error"),
trace_id, messagecontext fields from the call site