‘shinyOAuth’ emits structured audit events at key steps in the OAuth 2.0/OIDC flow. These may help detect anomalous activity (e.g., brute force, replay, or configuration errors).
This vignette covers: - How to register audit hooks to export/store events - Which audit events are emitted & what fields are included in each event - Best practices
There are two hook options you can set. Both receive the same event object (a named list). The functions you should register under these options should be fast, non-blocking, and never throw errors.
options(shinyOAuth.audit_hook = function(event) { ... })
- intended for audit-specific sinksoptions(shinyOAuth.trace_hook = function(event) { ... })
- a more general-purpose tracing hook used for both audit events and
error tracesExample 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)
})To stop receiving events, unset the option:
All audit events share the following base shape:
type: a string starting with
audit_...trace_id: a short correlation id for linking related
recordstimestamp: POSIXct time when the event was created
(from Sys.time())When events are emitted from within a Shiny session, a JSON-friendly
shiny_session list is attached to every event to correlate
audit activity with the HTTP request and session. The structure is
designed to be directly serializable with
jsonlite::toJSON():
shiny_session$token: the Shiny per-session token
(session$token) when available.shiny_session$is_async: a logical indicating whether
the event was emitted from the main R process (FALSE) or
from an async worker (TRUE). This helps distinguish logs
produced by background tasks (e.g., async token exchange or refresh)
from those in the main reactive flow.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: (async failure events only)
classifies mirai transport-level failures separately from
application-level errors. Present on login_failed,
session_cleared, and
refresh_failed_but_kept_session events:
"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).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 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.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, you can disable redaction:
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(), and when you have set daemon workers
mirai::daemons(), token exchange, refresh, and revocation
run in background mirai daemon processes. The package automatically
propagates your shinyOAuth.audit_hook and
shinyOAuth.trace_hook options to these workers, so audit
events fire also in the async worker processes and your hooks apply
there.
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 silently in the worker process. 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.
Many audit events include digest fields such as
client_id_digest, state_digest,
code_digest, browser_token_digest, and
sub_digest. These are intended to let you correlate events
without emitting raw sensitive values.
By default, these digests use HMAC-SHA256 with an auto-generated per-process key. This reduces the risk of correlation or dictionary reidentification if logs leak.
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 pseudonymous, not anonymized—low-entropy identifiers (like email addresses) can be dictionary-attacked.
audit_callback_query_rejectedprovider, 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,
client_id_digestaudit_callback_receivedhandle_callback() begins processing a
callbackprovider, issuer,
client_id_digest, code_digest,
state_digest, browser_token_digestCallback validation spans decryption + freshness + binding of the encrypted payload as well as subsequent checks of values bound to the state (browser token, PKCE code verifier, nonce). Each check emits either a success (only once for the payload) or a failure audit 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_validationcallback_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_tokenaudit_token_exchange_errorprovider, issuer,
client_id_digest, code_digest,
error_classaudit_token_introspectionintrospect_token() is called (e.g., during login
if 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)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
(sync_token_exchange|async_token_exchange),
error_class, mirai_error_typeaudit_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_expiredaudit_token_revocationrevoke_token() is called (e.g., during logout or
session end)provider, issuer,
client_id_digestwhich (“access” or “refresh”)supported (logical), revoked (logical|NA),
statusaudit_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_typeaudit_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_ataudit_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_not_jwt" – signed JWT required but response
was not application/jwt. Additional fields:
content_type"userinfo_jwt_header_parse_failed" – JWT header could
not be parsed"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 verificationState parsing failures occur while decoding and validating the encrypted wrapper prior to extracting the logical state value.
audit_state_parse_failurephase = decrypt, a
reason code (e.g., token_b64_invalid,
iv_missing, tag_len_invalid),
token_digest, and 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 attempts to consume the state to prevent replay
and clean up the store.
audit_error_state_consumedprovider, issuer,
client_id_digest, state_digestaudit_error_state_consumption_failedprovider, issuer,
client_id_digest, state_digest,
error_class, error_messageaudit_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)R/methods__login.RR/oauth_module_server.Raudit_event() defined in
R/errors.R, which delegates to the hook optionstry(..., silent = TRUE) if neededoptions(shinyOAuth.trace_hook=...)Example of a JSON export hook: