Skip to content

3.4.1

Latest

Choose a tag to compare

@github-actions github-actions released this 21 Aug 03:45
· 4 commits to master since this release
db6c4f5

This release is dominated by security hardening of redirect URI matching, token revocation and
refresh token handling
. Several entries below change behavior that was previously accepted, and
they are spread across Fixed and Security: the "Upgrading to 3.4.1" section of the
Upgrading guide collects everything you need to act on in one place, so start there. Of particular
note: redirect URIs are now matched exactly per
RFC 9700 §2.1, so a request may no
longer carry query parameters, path parameters, credentials or a fragment that the registered URI
does not have; REFRESH_TOKEN_EXPIRE_SECONDS, where set, is now enforced when a refresh token is
presented rather than only by the cleartokens sweep; and the built-in templates now link a
stylesheet shipped with the package instead of a CDN, so run collectstatic or the pages render
unstyled.

Added

  • #681 Redirect URI mismatches are now diagnosed on the oauth2_provider logger at DEBUG,
    reporting the requested URI, every registered candidate it was compared against, and which
    component of each one differed (scheme, hostname, port, path, query). The same detail is
    emitted for post_logout_redirect_uri and for the token endpoint's comparison against the
    URI recorded on the grant. The error response is unchanged: the registered URIs are never
    disclosed to the requester, only to the server's log. See "Debugging redirect URI
    mismatches" in the documentation. Note that AbstractApplication.redirect_uri_allowed()
    and post_logout_redirect_uri_allowed() now call the new check_redirect_to_uri_allowed()
    (same verdict, plus the mismatch reasons) instead of redirect_to_uri_allowed(), so code
    that wrapped or patched the latter to influence those methods must target the former.
  • #634 A system check (oauth2_provider.W011) that warns when the AccessToken and
    RefreshToken models are swapped into different apps, and a new
    "Extending the token models" documentation section explaining how to swap the
    interrelated token models together.
  • #1623 Documentation ("Content Security Policy and the authorization form") on completing
    the authorization-code flow under a strict form-action Content Security Policy, which
    Chromium enforces against the post-authorization redirect to the client's redirect_uri.
  • #410 Documentation ("Resource scope syntax") clarifying that TokenHasResourceScope
    checks each required_scopes entry suffixed with the READ_SCOPE/WRITE_SCOPE setting
    value (defaults read/write, e.g. music:read, music:write), so a bare music scope
    is rejected; with the default settings-based scopes backend the suffixed scopes must be
    declared in SCOPES.
  • #1157 An "Upgrading" documentation page collecting the breaking changes and upgrade steps for
    every release that needs them — 2.0, 3.0 and this release — linked from the documentation index,
    so upgrade guidance is discoverable outside the CHANGELOG. A release that asks nothing of you has
    no section there, so a gap between two versions is an answer rather than an omission.
  • #452 Documentation ("Custom scopes backend") explaining how to replace the default
    settings-driven scopes backend via SCOPES_BACKEND_CLASS, including a worked model-based
    example that stores scopes in the database.
  • #1045 Tutorial ("Managing applications and tokens in the Django admin") walking through the
    admin site for applications and issued tokens, including client-secret hashing, credential
    masking, and that tokens cannot be created by hand.
  • #403 Translatable (gettext_lazy) verbose_name labels on every field of the
    Application, Grant, AccessToken, RefreshToken, IDToken and DeviceGrant models, so
    the Django admin and the authorization UI can be localized. Migration
    oauth2_provider.0021_translatable_field_labels records the label changes; it makes no
    database schema changes.

Deprecated

  • #1773 JSONOAuthLibCore (OAUTH2_PROVIDER["OAUTH2_BACKEND_CLASS"] set to
    oauth2_provider.oauth2_backends.JSONOAuthLibCore) is deprecated and now emits a
    DeprecationWarning. It makes the OAuth token, introspection, and
    revocation endpoints read application/json bodies, but those endpoints are defined to
    use application/x-www-form-urlencoded (RFC 6749, RFC 7662, RFC 7009); the JSON mode is
    non-standard and breaks interoperability with spec-compliant clients. It is scheduled for
    removal in 4.0.

Changed

  • #1343 Application.clean() now reports its validation errors per field instead of as
    non-field errors, and reports all of them at once instead of stopping at the first
    problem. The application forms (the built-in registration/edit views and the Django
    admin) render each message next to the offending input — a rejected redirect URI on
    redirect_uris, a non-https CORS origin on allowed_origins, an unusable algorithm on
    algorithm, and the HS256 client-secret conflicts on client_secret /
    hash_client_secret. ValidationError.message_dict is keyed by those field names, so
    callers of Application.full_clean() (including dynamic client registration and CIMD)
    now surface the field name alongside the message. A custom ModelForm that omits one of
    those fields still gets the message as a non-field error, provided it subclasses
    oauth2_provider.forms.ApplicationForm.
  • #730 The templates shipped with the toolkit no longer load Bootstrap 2.3.2 from a
    third-party CDN. oauth2_provider/base.html now links a small stylesheet distributed
    with the package (static/oauth2_provider/css/oauth2_provider.css), which also absorbs
    the inline <style> block that template carried. The built-in pages therefore render in
    air-gapped installs and under a strict Content Security Policy such as
    default-src 'self', which blocks a foreign style host and an inline style block alike,
    and the authorization page no longer makes an unpinned (no Subresource Integrity)
    third-party request while the user is making a consent decision. The stylesheet is served
    through staticfiles, so run collectstatic for the pages to be styled. The Bootstrap 2
    class names used by the templates are unchanged, and the css block of base.html is
    still the supported way to substitute your own styles.
  • The AccessToken and RefreshToken admins now invalidate tokens through a "Revoke selected"
    action instead of raw delete (delete is disabled on those two admins). A raw delete of an access
    token left its bound refresh token behind (RefreshToken.access_token is SET_NULL) — an orphan
    that could still mint new access tokens — and a raw delete of a refresh token discarded the
    revoked tombstone that REFRESH_TOKEN_REUSE_PROTECTION relies on. The revoke action invalidates
    the whole token family consistently; expired rows are still pruned by cleartokens. Grant and
    IDToken admins keep the default delete. The access-token revoke logic is now a single shared
    oauth2_provider.models.revoke_access_token() helper used by the admin action, the /revoke/
    endpoint, and AuthorizedTokenDeleteView.
  • #522 The cleartokens management command now prints a warning to stderr when
    REFRESH_TOKEN_EXPIRE_SECONDS is unset (or 0), explaining that only revoked and
    orphaned refresh tokens are removed and that expired access/ID tokens still bound to a
    refresh token are retained until that refresh token is gone. The management-command docs
    were clarified to match.
  • #746 Revoking an access token (via the RFC 7009 /revoke/ endpoint) now also revokes
    the refresh token bound to it, matching the admin "delete access token" view and
    RFC 7009 §2.1. Previously the refresh token survived and could immediately mint a new
    access token, defeating the revocation and leaving the refresh token an active "orphan"
    (its access_token foreign key is SET_NULL). Whether a refresh token may survive
    access-token revocation will become a configurable policy in 4.0.
  • #1715 Security guidance in the settings reference: recommend a finite
    REFRESH_TOKEN_EXPIRE_SECONDS as defense-in-depth (rotation remains the primary
    mitigation), and document why OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS defaults
    to True (the id_token_hint is a previously issued token per OIDC RP-Initiated Logout)
    and how to harden it.

Fixed

  • The system check that verifies the AccessToken, IDToken, and RefreshToken models are
    routed to a single database is now registered under the models tag instead of database.
    Django 6.1 stopped running database-tagged checks unless a database alias is passed
    explicitly (manage.py check --database default), because such checks may do more than
    static analysis; this one only asks the configured routers where the token models would be
    written and never opens a connection, so under the old tag a plain manage.py check would
    have silently stopped reporting a cross-database token configuration on Django 6.1.
  • #1809 REFRESH_TOKEN_REUSE_PROTECTION now revokes a compromised token family as a set
    instead of one row at a time. A rotating client keeps every refresh token it has ever been
    issued in the same family, so the old per-row loop cost one SELECT ... FOR UPDATE round
    trip per token in the family, paid again on every replay of the stale token: a client stuck
    on a retry timer could hold a worker and a database connection for tens of seconds per
    request. The sweep now runs in a fixed number of queries whatever the size of the family,
    through the new AbstractRefreshToken.revoke_family(), and token_family is indexed
    (migration 0022_refreshtoken_token_family_index) so it no longer scans the whole refresh
    token table. What gets revoked is unchanged: every live member of the family, and the
    family's access tokens. If you swap in your own refresh token model, run makemigrations to
    pick up the index, and if you override revoke() override revoke_family() to match.
    Where a family holds two live tokens sharing a checksum -- which RefreshToken's
    (token_checksum, revoked) uniqueness permits but the bulk write cannot express (#1816) --
    the sweep falls back to revoking row by row, so reuse detection still returns
    invalid_grant rather than raising.
  • #1796 Redirect URIs using an RFC 8252 §7.1 private-use URI scheme can now be registered.
    Such a scheme has no naming authority, so only a single slash follows it
    (com.example.app:/oauth2redirect), but Application.clean() reassembled every URI with
    :// before validating and rejected the result with "Enter a valid URL." — leaving native
    apps no way to register the form the RFC prescribes and their clients actually send. The
    double-slash variant was not a workaround: the two spellings parse to different hostnames
    and produce redirect_uri_mismatch against each other. Schemes that require an authority
    (http, https, ws, wss, ftp) must still include a host, and the redundant
    com.example.app:///oauth2redirect and rootless com.example.app:oauth2redirect spellings
    are rejected so that each callback has one canonical registration (RFC 9700 §2.1).
    Upgrade note: the rootless spelling was previously accepted, but the same reassembly
    rewrote it to com.example.app://oauth2redirect — registering oauth2redirect as a
    hostname, which no client matches. It is now rejected at registration instead of being
    silently reinterpreted; re-register any such URI in the single-slash form.
  • #1796 redirect_to_uri_allowed() no longer raises AttributeError when
    ALLOW_URI_WILDCARDS is enabled and a redirect URI has no hostname, as is the case for
    private-use URI scheme redirects.
  • #746 REFRESH_TOKEN_EXPIRE_SECONDS is now enforced when a refresh token is presented,
    not only by the cleartokens (clear_expired) cleanup job. Previously a refresh token
    past its configured lifetime kept working until a cleanup sweep happened to remove it —
    or forever, if cleartokens was never scheduled. Expiry is idle-based: a refresh token
    is rejected REFRESH_TOKEN_EXPIRE_SECONDS after its access token expires (the deadline
    slides forward on every refresh), so actively-used tokens are unaffected. The default
    (REFRESH_TOKEN_EXPIRE_SECONDS = None) still never expires refresh tokens. Upgrade
    note:
    deployments that set REFRESH_TOKEN_EXPIRE_SECONDS may see idle refresh tokens
    that are already past their lifetime rejected on upgrade, forcing those clients to
    re-authenticate.
  • #746 clear_expired() now reclaims "orphaned" refresh tokens — non-revoked refresh
    tokens whose access token was deleted out of band, leaving access_token NULL. The
    previous access_token__expires__lt join could never match a NULL access token, so
    such rows could remain in the database indefinitely.
  • #1687 Reusing a refresh token within REFRESH_TOKEN_GRACE_PERIOD_SECONDS no longer
    raises AttributeError: 'NoneType' object has no attribute 'token' (HTTP 500) when the
    access token previously minted from that refresh token still exists but its own refresh
    token has since been removed — e.g. by clear_expired or a concurrent rotation.
    _save_bearer_token now re-issues a refresh token bound to the surviving access token
    instead of dereferencing the missing one (creating a fresh access token there would
    violate the one-to-one AccessToken.source_refresh_token relation).
  • #958 Return a spec-compliant 400 instead of raising an uncaught AssertionError
    (HTTP 500) when an application without any registered redirect_uris (e.g. a
    client_credentials application) is driven through a flow that needs a default
    redirect URI. Application.default_redirect_uri now raises
    oauthlib's MissingRedirectURIError, consistent with the multiple-URI case.
  • #1260 OAuth2Validator.validate_bearer_token now rejects a token whose
    application is not usable (Application.is_usable() returns False) with an
    invalid_token error, mirroring the check the issuance path already performs
    in _load_application. The default is_usable() returns True, so this only
    affects swapped Application models that override it.
  • #1169 The DRF TokenHasScope and TokenMatchesOASRequirements permissions now
    deny (return False) and log a warning, instead of raising an AssertionError
    (HTTP 500), when request.auth is not an OAuth2 access token. This lets them be
    composed with other permission classes (e.g. OR-ed) without a non-OAuth2 token
    turning into a server error.

Security

  • #1819 The device authorization flow's confirmation and status views now act only on a device
    grant that belongs to the signed-in user. DeviceUserCodeView claims a pending grant for the
    user who enters the user_code, but DeviceConfirmView and DeviceGrantStatusView looked the
    grant up by client_id and user_code alone. Any other authenticated user who learned that
    short, human-readable code — it is displayed on the device's screen for a person to read and
    type — could therefore approve the pending authorization, handing the device tokens bound to
    the account of the user who entered it, or deny it, or read its status page. RFC 8628 §3.3 has
    the user who is being asked to authorize the device grant that authorization. Both views now
    filter on user=request.user and return 404 to anyone else; a grant that has not yet been
    claimed through the user-code step likewise can no longer be confirmed by navigating straight
    to the confirmation URL.

  • #1816 A refresh token that was deliberately revoked is no longer honored inside
    REFRESH_TOKEN_GRACE_PERIOD_SECONDS. The grace window exists to shield the token a
    client retries when it did not receive the rotated response, but revoked records both
    that supersession and a deliberate revocation (the RFC 7009 /revoke/ endpoint,
    AuthorizedTokenDeleteView, the admin, RP-initiated logout, revoking the bound access
    token), and validation could not tell them apart. A revoked token was therefore usable
    for the length of the window, contrary to
    RFC 7009 §2.1 ("the
    invalidation takes place immediately, and the token cannot be used again after the
    revocation"). With ROTATE_REFRESH_TOKEN = False it was additionally re-issued as a new
    live row carrying the same token value, bringing the repudiated credential back to life
    in the database. The two are now distinguished by whether the token was ever consumed to
    mint a successor access token. A genuine rotation retry inside the window is unaffected,
    and deployments on the default REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 were never
    exposed.

    This generalizes a test that previously applied only when
    REFRESH_TOKEN_REUSE_PROTECTION was enabled, so it is also a behavior change with reuse
    protection off: a superseded token whose successor access token has since been deleted is
    now rejected inside the window rather than accepted.

  • Redirect URIs are now matched exactly, as
    RFC 9700 §2.1 requires
    ("authorization servers MUST utilize exact string matching except for port numbers in
    localhost redirection URIs of native apps") and OpenID Connect Core §3.1.2.1 restates
    via RFC 3986 §6.2.1 Simple String Comparison. Four deviations are closed, each of which
    let a request differ from the registered URI while still matching it:

    • A request could carry query parameters that were never registered — the check
      tested that the registered query was a subset of the requested one. An attacker
      could append parameters to an otherwise-legitimate redirect_uri and have the
      authorization server reflect them into the client's callback alongside the
      authorization code, the redirect-URI manipulation class described in
      RFC 9700 §4.1.
    • A request could carry path parameters (https://example.com/cb;evil=1). urlparse()
      peels ;params off the last path segment into a separate attribute, and only .path
      was compared, so these smuggled data to the callback the same way extra query
      parameters did. Matching now uses urlsplit(), which leaves them in the path.
    • A request could carry credentials (https://evil@example.com/cb). Only .hostname
      was compared, so userinfo rode along unnoticed. Credentials are not part of a
      registered callback and are now rejected on either side.
    • A request could carry a fragment, which urlparse() split off before comparison,
      so https://example.com/cb#x matched a registered https://example.com/cb.
      RFC 6749 §3.1.2 states
      the endpoint URI MUST NOT include a fragment component. A bare trailing # is an
      empty fragment component and is rejected too; a percent-encoded %23 is not a
      fragment delimiter and is unaffected.

    Registration is tightened to match: AllowedURIValidator tested the parsed fragment,
    which is empty both for a URI with no fragment and for one ending in a bare #, so
    https://example.com/cb# was accepted at save time. With matching now denying any #,
    such a registration would be stored and then never authorize anything; it is rejected
    up front instead. Registering a URI ending in # now raises a ValidationError where
    it previously succeeded.

    Case-insensitive scheme/host comparison (RFC 3986 §6.2.2.1 normalization) and the
    RFC 8252 §7.3 loopback any-port exemption are unchanged; ALLOW_URI_WILDCARDS still
    opts out of exact host matching and remains flagged by oauth2_provider.W009/E004.

    Upgrade note: clients that pass per-request data through the redirect_uri query
    string will now be rejected — every query parameter must be registered, and in the same
    order. Register the full URI including its query, or move per-request data into the
    state parameter, which is what it is for. Applications whose registered
    redirect_uris already carry no query component are unaffected.

  • #1510 Revoking an access token from the authorized-tokens page
    (AuthorizedTokenDeleteView) now also revokes the refresh token issued
    alongside it. Previously only the access token was deleted, leaving the
    refresh token usable to mint a fresh access token and defeating the
    revocation (a regression from 2.3.0). Per
    RFC 7009 §2.1 an
    access token revocation may also revoke the respective refresh token; for a
    user-initiated revocation that is now the behavior.

  • #1617 With REFRESH_TOKEN_REUSE_PROTECTION enabled, REFRESH_TOKEN_GRACE_PERIOD_SECONDS
    no longer extends the validity of a refresh token that is several generations old in
    the rotation chain. The grace period now only shields the immediately preceding
    refresh token (the token a client retries when it did not receive the rotated
    response); replaying an older, already-rotated-past token within the grace window is
    rejected and revokes the whole token family, instead of being honored (and, without a
    requested scope, minting a fresh token pair).

  • #727 The token revocation endpoint (/o/revoke_token/) now only revokes tokens that
    were issued to the authenticated client. Previously it revoked any token matching the
    submitted value regardless of which application issued it, so a client could revoke
    another client's tokens. Per
    RFC 7009 §2.1 the server
    verifies the token was issued to the client making the request; a token belonging to a
    different client is now left untouched and the endpoint still returns 200 (RFC 7009
    §2.2) without disclosing whether the token exists.

  • #1799 RFC 7592 registration access tokens now honour COMPLIANT_BCP_RFC9700_TOKEN_STORAGE. The
    dynamic client registration views assigned the token straight onto the model instead of routing it
    through the storage path that honours the setting, so a deployment that had opted into hashed-at-rest
    storage still had this one token persisted in cleartext. The registration response and the management
    endpoint continue to return the token to its owner; only what is written to the database changes.