Skip to content

v0.3.1

Choose a tag to compare

@github-actions github-actions released this 21 May 14:02
· 22 commits to current since this release

⚠️ Operator-visible behavior change: OIDC RP-Initiated Logout is now
enabled by default. OAUTH2_PROVIDER['OIDC_RP_INITIATED_LOGOUT_ENABLED']
defaults to True via AppConfig; the /o/logout/ route and the
end_session_endpoint field in discovery become live without operator
opt-in. Set the key explicitly to False in your settings to preserve
the previous (upstream DOT) behavior — setdefault preserves any
explicit value. See the new manage.py check warning
allianceauth_oidc.W003 if 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 outbound requests.post against DNS
    rebinding TOCTOU and unsafe-target bypasses:
    (1) the admin-form clean() validator,
    (2) a new pre_save signal that closes the
    Application.objects.create(...) / fixtures / data-migration path
    by-passing full_clean(),
    (3) a new request-time _request_time_ssrf_gate_passes helper that
    re-resolves the host immediately before requests.post and
    fail-closes on transient resolver errors (audit
    reason="dns_resolve_failed" / "unsafe_target_ip").
    The shared _is_unsafe_address predicate now unmaps IPv4-in-IPv6
    (::ffff:...) and 6to4 (2002:...) before evaluation and adds
    is_unspecified to the rejected set — closing 0.0.0.0, ::,
    and 6to4-wrapped private IPv4 bypasses the original five-predicate
    chain missed.

  • BCL fan-out skips deactivated apps. Application.active=False is
    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 signed logout_token POSTs (with
    sub/iss/aud/jti) on lifecycle events.
    logout.apps_with_active_tokens now joins active=True and
    dispatch_backchannel_logout short-circuits via
    application.is_usable(None) next to the existing blank-URI gate.

  • auth_provider._enforce_policy gained the missing
    case _: assert_never(decision) arm on the AccessDecision match.
    A future fourth union variant would otherwise let the function
    fall off the end, returning None, which oauthlib treats as
    falsy at validate_code / validate_refresh_token callsites —
    surfacing as invalid_grant instead of the loud TypeError that
    assert_never produces. Mirrors the existing pattern in
    views_authorize.AuthAuthorizationView.dispatch.

Added

  • OIDC Back-Channel Logout 1.0 (sub-only v1). Set
    backchannel_logout_uri on 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_required and a Celery task POSTs a signed
    logout_token to every registered RP. SSRF defenses: scheme
    allow-list, DNS host check via per-call
    concurrent.futures.ThreadPoolExecutor (3 s wall-clock, defends
    against the setdefaulttimeout no-op trap), private/loopback/
    link-local/multicast/reserved/unspecified-IP rejection with IPv4-
    in-IPv6 + 6to4 unmap and
    ALLIANCEAUTH_OIDC_LOGOUT_URI_ALLOW_PRIVATE dev escape hatch.
    Outbound HTTP discipline: allow_redirects=False, body never
    read, bounded timeout=(5, 10). Retries are byte-identical
    (worker rebuilds the JWT against pinned (jti, iat, signing_kid)).
    Discovery emits backchannel_logout_supported: true;
    backchannel_logout_session_supported is intentionally absent
    (sub-only v1). Per-app opt-in via
    AllianceAuthApplication.backchannel_logout_on_revoke_only to
    receive logout tokens only on explicit oidc_revoke_user_tokens
    invocations. Operator guide:
    docs/BACK_CHANNEL_LOGOUT.md.

  • JWT access tokens (RFC 9068). Opt-in via two OAUTH2_PROVIDER keys —
    ALLIANCEAUTH_OIDC_DEFAULT_ACCESS_TOKEN_FORMAT = "jwt" AND
    ACCESS_TOKEN_GENERATOR = "allianceauth_oidc.tokens.dispatching_access_token_generator". Per-app
    override via AllianceAuthApplication.access_token_format ("opaque" /
    "jwt" / blank). Default format remains "opaque" for zero-disruption
    upgrades. Tokens stay stateful — JWT lives in
    oauth2_provider_accesstoken.token so introspection, revocation, and
    the oidc_token_issued audit signal continue to work; the audit body
    gains a format field. Identity claims gated through DOT's canonical
    get_oidc_claims hook, so AT and id_token claim sets are byte-equivalent
    for the same scope. New configurable size guard
    ALLIANCEAUTH_OIDC_JWT_SIZE_WARN_BYTES (default 4096) emits
    WARNING on oversize tokens without mutating issuance. Discovery
    endpoint advertises access_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.ready AppConfig hook. Discovery advertises
    end_session_endpoint; the route is oauth2_provider's
    RPInitiatedLogoutView, gated by DOT internally. Operators retain
    full control via OAUTH2_PROVIDER['OIDC_RP_INITIATED_LOGOUT_ENABLED']
    setdefault semantics preserve any explicit value (including
    False for opt-out).

  • Eleven new Django system checks at manage.py check — six errors,
    five warnings, all under the allianceauth_oidc.* namespace:

    • E001 (Error) — backchannel_logout_uri registered without
      OAUTH2_PROVIDER['OIDC_ISS_ENDPOINT'] (Celery worker has no
      HTTP request to derive iss).
    • E002 (Error) — OAUTH2_PROVIDER_APPLICATION_MODEL does not
      resolve to AllianceAuthApplication. Stock DOT model bypasses
      the three-layer policy enforcement.
    • E003 (Error) — OAUTH2_PROVIDER['OAUTH2_VALIDATOR_CLASS']
      does not resolve to AllianceAuthOAuth2Validator. Stock DOT
      validator drops layers 2 and 3 of the policy gate.
    • E004 (Error) — OAUTH2_PROVIDER['SCOPES'] does not contain
      the openid scope. 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=True AND
      DEBUG=False AND at least one registered backchannel_logout_uri.
      Silenced by the explicit
      ALLIANCEAUTH_OIDC_ALLOW_PRIVATE_BCL_IN_PRODUCTION=True opt-in.
    • W001 (Warning) — ALLIANCEAUTH_OIDC_LOG_MASKED_SECRETS=True
      while DEBUG=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
      jwt without the dispatching ACCESS_TOKEN_GENERATOR, or the
      generator wired without the default format set to jwt. Both
      halves must agree for JWT to be the global default.
    • W003 (Warning) — OIDC_RP_INITIATED_LOGOUT_ENABLED=False
      while applications carry backchannel_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_uri using http:// while DEBUG=False.
      Legacy rows persisted under DEBUG=True survive a flip; the
      worker re-checks DNS but not scheme.
    • W005 (Warning) — ALLIANCEAUTH_OIDC_LOGOUT_URI_ALLOW_PRIVATE=True
      while DEBUG=False without a registered backchannel_logout_uri
      (else E006 fires). The SSRF gate on BCL targets is disabled.
  • oidc_token_introspected audit signal (RFC 7662 introspection
    endpoint). Fires on every /o/introspect/ call with the
    OIDCIntrospectionAuditBody TypedDict
    (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_detected audit signal + IssuedCodeAudit model
    implementing RFC 6749 §10.5 reuse detection. DOT 3.2 deletes the
    Grant row 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 as sha256(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
    unexpected invalid_grant and the operator needs to know which
    layer denied.

  • manage.py oidc_revoke_user_tokens --reason TEXT — optional
    free-form audit string threaded into the oidc_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 old signing_kid keep producing
    byte-identical retries until they expire), and refreshes the
    JWKS endpoint. Honours --dry-run to preview the rotation
    without persistence.

  • Admin: bulk-action "Send test back-channel logout" on
    AllianceAuthApplication changelist. Fires
    oidc_logout_required with reason="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: DENY plus
    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_total Prometheus counter, labelled
    by stage (authorize / validate_silent_auth /
    validate_code / validate_refresh / validate_bearer /
    save_bearer) and reason (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_total Prometheus counter,
    labelled by client_id. _handle_potential_code_reuse
    increments when the reuse path is hit for a code with no
    matching IssuedCodeAudit row. 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 the oidc_code_reuse_detected signal — the two should
    be disjoint.

  • aa_oidc_audit_receiver_failures_total Prometheus counter,
    labelled by signal and receiver_dispatch_uid. Increments
    per receiver that raised during send_robust dispatch 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_supported narrowed 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
    via ALLIANCEAUTH_OIDC_POLICY_URI /
    ALLIANCEAUTH_OIDC_TOS_URI).

Changed

  • tests/test_migrations.py MIGRATION_TARGET constant 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 the 0011 data step because the chain runs forward.