v0.3.1
⚠️ Operator-visible behavior change: OIDC RP-Initiated Logout is now
enabled by default.OAUTH2_PROVIDER['OIDC_RP_INITIATED_LOGOUT_ENABLED']
defaults toTruevia AppConfig; the/o/logout/route and the
end_session_endpointfield in discovery become live without operator
opt-in. Set the key explicitly toFalsein your settings to preserve
the previous (upstream DOT) behavior —setdefaultpreserves any
explicit value. See the newmanage.py checkwarning
allianceauth_oidc.W003if you disable RP-init logout while
back-channel logout RPs are registered.
Security
-
BCL Server-Side Request Forgery hardening. Three independent gates
now defend the worker's outboundrequests.postagainst DNS
rebinding TOCTOU and unsafe-target bypasses:
(1) the admin-formclean()validator,
(2) a newpre_savesignal that closes the
Application.objects.create(...)/ fixtures / data-migration path
by-passingfull_clean(),
(3) a new request-time_request_time_ssrf_gate_passeshelper that
re-resolves the host immediately beforerequests.postand
fail-closes on transient resolver errors (audit
reason="dns_resolve_failed"/"unsafe_target_ip").
The shared_is_unsafe_addresspredicate now unmaps IPv4-in-IPv6
(::ffff:...) and 6to4 (2002:...) before evaluation and adds
is_unspecifiedto the rejected set — closing0.0.0.0,::,
and 6to4-wrapped private IPv4 bypasses the original five-predicate
chain missed. -
BCL fan-out skips deactivated apps.
Application.active=Falseis
documented as the kill-switch for compromised/retired clients;
previously the fan-out filtered tokens but not the Application, so
a deactivated RP kept receiving signedlogout_tokenPOSTs (with
sub/iss/aud/jti) on lifecycle events.
logout.apps_with_active_tokensnow joinsactive=Trueand
dispatch_backchannel_logoutshort-circuits via
application.is_usable(None)next to the existing blank-URI gate. -
auth_provider._enforce_policygained the missing
case _: assert_never(decision)arm on theAccessDecisionmatch.
A future fourth union variant would otherwise let the function
fall off the end, returningNone, which oauthlib treats as
falsy atvalidate_code/validate_refresh_tokencallsites —
surfacing asinvalid_grantinstead of the loudTypeErrorthat
assert_neverproduces. Mirrors the existing pattern in
views_authorize.AuthAuthorizationView.dispatch.
Added
-
OIDC Back-Channel Logout 1.0 (sub-only v1). Set
backchannel_logout_urion an application to register an RP for
fan-out; five trigger sites (revoke command,User.is_active
flip, group / state change, account delete) emit
oidc_logout_requiredand a Celery task POSTs a signed
logout_tokento every registered RP. SSRF defenses: scheme
allow-list, DNS host check via per-call
concurrent.futures.ThreadPoolExecutor(3 s wall-clock, defends
against thesetdefaulttimeoutno-op trap), private/loopback/
link-local/multicast/reserved/unspecified-IP rejection with IPv4-
in-IPv6 + 6to4 unmap and
ALLIANCEAUTH_OIDC_LOGOUT_URI_ALLOW_PRIVATEdev escape hatch.
Outbound HTTP discipline:allow_redirects=False, body never
read, boundedtimeout=(5, 10). Retries are byte-identical
(worker rebuilds the JWT against pinned(jti, iat, signing_kid)).
Discovery emitsbackchannel_logout_supported: true;
backchannel_logout_session_supportedis intentionally absent
(sub-only v1). Per-app opt-in via
AllianceAuthApplication.backchannel_logout_on_revoke_onlyto
receive logout tokens only on explicitoidc_revoke_user_tokens
invocations. Operator guide:
docs/BACK_CHANNEL_LOGOUT.md. -
JWT access tokens (RFC 9068). Opt-in via two
OAUTH2_PROVIDERkeys —
ALLIANCEAUTH_OIDC_DEFAULT_ACCESS_TOKEN_FORMAT = "jwt"AND
ACCESS_TOKEN_GENERATOR = "allianceauth_oidc.tokens.dispatching_access_token_generator". Per-app
override viaAllianceAuthApplication.access_token_format("opaque"/
"jwt"/ blank). Default format remains"opaque"for zero-disruption
upgrades. Tokens stay stateful — JWT lives in
oauth2_provider_accesstoken.tokenso introspection, revocation, and
theoidc_token_issuedaudit signal continue to work; the audit body
gains aformatfield. Identity claims gated through DOT's canonical
get_oidc_claimshook, so AT and id_token claim sets are byte-equivalent
for the same scope. New configurable size guard
ALLIANCEAUTH_OIDC_JWT_SIZE_WARN_BYTES(default4096) emits
WARNINGon oversize tokens without mutating issuance. Discovery
endpoint advertisesaccess_token_signing_alg_values_supported: ["RS256"]. Operator guide:
docs/JWT_ACCESS_TOKENS.md covers opt-in,
RP cookbook (oauth2-proxy / mod_auth_openidc / WikiJS), key rotation,
data minimization, and rollback. -
OIDC RP-Initiated Logout 1.0 (
/o/logout/) default-on via
AllianceAuthOIDC.readyAppConfig hook. Discovery advertises
end_session_endpoint; the route isoauth2_provider's
RPInitiatedLogoutView, gated by DOT internally. Operators retain
full control viaOAUTH2_PROVIDER['OIDC_RP_INITIATED_LOGOUT_ENABLED']
—setdefaultsemantics preserve any explicit value (including
Falsefor opt-out). -
Eleven new Django system checks at
manage.py check— six errors,
five warnings, all under theallianceauth_oidc.*namespace:E001(Error) —backchannel_logout_uriregistered without
OAUTH2_PROVIDER['OIDC_ISS_ENDPOINT'](Celery worker has no
HTTP request to deriveiss).E002(Error) —OAUTH2_PROVIDER_APPLICATION_MODELdoes not
resolve toAllianceAuthApplication. Stock DOT model bypasses
the three-layer policy enforcement.E003(Error) —OAUTH2_PROVIDER['OAUTH2_VALIDATOR_CLASS']
does not resolve toAllianceAuthOAuth2Validator. Stock DOT
validator drops layers 2 and 3 of the policy gate.E004(Error) —OAUTH2_PROVIDER['SCOPES']does not contain
theopenidscope. DOT's default{"read": ..., "write": ...}
silently disables id_token issuance.E005(Error) —OAUTH2_PROVIDER['PKCE_REQUIRED']is not (or
does not wrap)allianceauth_oidc.pkce.per_app_pkce_required.
Without the adapter the per-app override silently no-ops, leaving
public clients vulnerable to auth-code interception per RFC 9700.E006(Error) — the dangerous triple-combination has a concrete
victim:ALLIANCEAUTH_OIDC_LOGOUT_URI_ALLOW_PRIVATE=TrueAND
DEBUG=FalseAND at least one registeredbackchannel_logout_uri.
Silenced by the explicit
ALLIANCEAUTH_OIDC_ALLOW_PRIVATE_BCL_IN_PRODUCTION=Trueopt-in.W001(Warning) —ALLIANCEAUTH_OIDC_LOG_MASKED_SECRETS=True
whileDEBUG=False. Masked-fragment logging is a development aid;
enabling it in production leaks identifiable token/secret
fragments into log storage.W002(Warning) — half-wired JWT mode: default format set to
jwtwithout the dispatchingACCESS_TOKEN_GENERATOR, or the
generator wired without the default format set tojwt. Both
halves must agree for JWT to be the global default.W003(Warning) —OIDC_RP_INITIATED_LOGOUT_ENABLED=False
while applications carrybackchannel_logout_uri. The Single-
Logout chain breaks at the first hop because the RP-init logout
endpoint is the entry-point that triggers BCL fan-out.W004(Warning) — an active application carries a
backchannel_logout_uriusinghttp://whileDEBUG=False.
Legacy rows persisted underDEBUG=Truesurvive a flip; the
worker re-checks DNS but not scheme.W005(Warning) —ALLIANCEAUTH_OIDC_LOGOUT_URI_ALLOW_PRIVATE=True
whileDEBUG=Falsewithout a registeredbackchannel_logout_uri
(elseE006fires). The SSRF gate on BCL targets is disabled.
-
oidc_token_introspectedaudit signal (RFC 7662 introspection
endpoint). Fires on every/o/introspect/call with the
OIDCIntrospectionAuditBodyTypedDict
(introspector,token_sha256,active,client_id). SIEM/audit
forwarders attach a custom receiver — the default receiver logs at
INFO with secrets redacted. -
oidc_code_reuse_detectedaudit signal +IssuedCodeAuditmodel
implementing RFC 6749 §10.5 reuse detection. DOT 3.2 deletes the
Grantrow on first exchange; the new side-table preserves the
code-hash → tokens link past the Grant's lifetime so reuse triggers
revocation of the linked access + refresh tokens, not just
invalid_grant. Stored assha256(code); plaintext code never
persisted. -
manage.py oidc_show_effective_policy— operator inspection of
the per-app state/group whitelist as it actually evaluates against
a user, including the global gate. Useful when a user reports an
unexpectedinvalid_grantand the operator needs to know which
layer denied. -
manage.py oidc_revoke_user_tokens --reason TEXT— optional
free-form audit string threaded into theoidc_token_issued
revoke audit body and the BCL fan-out trigger. -
manage.py oidc_jwks_rotate— operator command to rotate the
JWKS signing key. Generates a fresh RSA key, retires the current
active key (rows pinned to the oldsigning_kidkeep producing
byte-identical retries until they expire), and refreshes the
JWKS endpoint. Honours--dry-runto preview the rotation
without persistence. -
Admin: bulk-action "Send test back-channel logout" on
AllianceAuthApplicationchangelist. Fires
oidc_logout_requiredwithreason="admin_test"on a no-op
user, exercising the full dispatcher → Celery → RP HTTP path for
operator validation without affecting real sessions. -
Clickjacking defence on
/o/authorize/. Response now carries
X-Frame-Options: DENYplus
Content-Security-Policy: frame-ancestors 'none'to defeat the
RFC 9700 §2.5 attack: an attacker iframes the authorize page to
capture user interaction. DOT does not set these by default. -
aa_oidc_policy_rejections_totalPrometheus counter, labelled
bystage(authorize/validate_silent_auth/
validate_code/validate_refresh/validate_bearer/
save_bearer) andreason(global/app/app_unusable
/no_client/unknown). Cross-cuts the three-layer policy
enforcement so dashboards can answer "which gate fires the most
at which stage" with a single counter. -
aa_oidc_code_reuse_audit_misses_totalPrometheus counter,
labelled byclient_id._handle_potential_code_reuse
increments when the reuse path is hit for a code with no
matchingIssuedCodeAuditrow. After the atomic-wrap of
save_bearer_token+ audit insert the race-window source is
closed, so the counter now exclusively fires on never-issued
codes (fuzzers / wrong-provider replays). Operators correlate
against theoidc_code_reuse_detectedsignal — the two should
be disjoint. -
aa_oidc_audit_receiver_failures_totalPrometheus counter,
labelled bysignalandreceiver_dispatch_uid. Increments
per receiver that raised duringsend_robustdispatch of any
audit signal (oidc_token_issued,oidc_code_reuse_detected,
oidc_token_introspected,oidc_logout_dispatched). Non-zero
rate means the audit pipeline is silently dropping events for
at least one downstream consumer. -
Discovery (
/o/.well-known/openid-configuration/) advertises
nine additional fields per OIDC Discovery 1.0 §3 / RFC 8414 §2:
response_types_supportednarrowed to["code"](was DOT's full
implicit/hybrid enum);response_modes_supported,
code_challenge_methods_supported(S256),acr_values_supported
(["0"]per RFC 6711),prompt_values_supported,
claims_parameter_supported(True),request_parameter_supported
/request_uri_parameter_supported(both False — JAR/PAR not
implemented),op_policy_uri/op_tos_uri(operator-provided
viaALLIANCEAUTH_OIDC_POLICY_URI/
ALLIANCEAUTH_OIDC_TOS_URI).
Changed
tests/test_migrations.pyMIGRATION_TARGETconstant bumped to
"0015_backchannellogoutattempt"so post-migrate live-model
objects.create(...)calls hit a schema with every field added
this cycle (pkce_required,access_token_format,
backchannel_logout_uri,backchannel_logout_on_revoke_only,
BackChannelLogoutAttempt). PKCE-specific assertions still
cover the0011data step because the chain runs forward.