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_providerlogger atDEBUG,
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 forpost_logout_redirect_uriand 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 thatAbstractApplication.redirect_uri_allowed()
andpost_logout_redirect_uri_allowed()now call the newcheck_redirect_to_uri_allowed()
(same verdict, plus the mismatch reasons) instead ofredirect_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 theAccessTokenand
RefreshTokenmodels 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 strictform-actionContent Security Policy, which
Chromium enforces against the post-authorization redirect to the client'sredirect_uri. - #410 Documentation ("Resource scope syntax") clarifying that
TokenHasResourceScope
checks eachrequired_scopesentry suffixed with theREAD_SCOPE/WRITE_SCOPEsetting
value (defaultsread/write, e.g.music:read,music:write), so a baremusicscope
is rejected; with the default settings-based scopes backend the suffixed scopes must be
declared inSCOPES. - #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 viaSCOPES_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_namelabels on every field of the
Application,Grant,AccessToken,RefreshToken,IDTokenandDeviceGrantmodels, so
the Django admin and the authorization UI can be localized. Migration
oauth2_provider.0021_translatable_field_labelsrecords 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 readapplication/jsonbodies, but those endpoints are defined to
useapplication/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 onallowed_origins, an unusable algorithm on
algorithm, and the HS256 client-secret conflicts onclient_secret/
hash_client_secret.ValidationError.message_dictis keyed by those field names, so
callers ofApplication.full_clean()(including dynamic client registration and CIMD)
now surface the field name alongside the message. A customModelFormthat 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.htmlnow 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
throughstaticfiles, so runcollectstaticfor the pages to be styled. The Bootstrap 2
class names used by the templates are unchanged, and thecssblock ofbase.htmlis
still the supported way to substitute your own styles. - The
AccessTokenandRefreshTokenadmins 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_tokenisSET_NULL) — an orphan
that could still mint new access tokens — and a raw delete of a refresh token discarded the
revoked tombstone thatREFRESH_TOKEN_REUSE_PROTECTIONrelies on. The revoke action invalidates
the whole token family consistently; expired rows are still pruned bycleartokens.Grantand
IDTokenadmins 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, andAuthorizedTokenDeleteView. - #522 The
cleartokensmanagement command now prints a warning to stderr when
REFRESH_TOKEN_EXPIRE_SECONDSis unset (or0), 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"
(itsaccess_tokenforeign key isSET_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_SECONDSas defense-in-depth (rotation remains the primary
mitigation), and document whyOIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENSdefaults
toTrue(theid_token_hintis a previously issued token per OIDC RP-Initiated Logout)
and how to harden it.
Fixed
- The system check that verifies the
AccessToken,IDToken, andRefreshTokenmodels are
routed to a single database is now registered under themodelstag instead ofdatabase.
Django 6.1 stopped runningdatabase-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 plainmanage.py checkwould
have silently stopped reporting a cross-database token configuration on Django 6.1. - #1809
REFRESH_TOKEN_REUSE_PROTECTIONnow 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 oneSELECT ... FOR UPDATEround
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 newAbstractRefreshToken.revoke_family(), andtoken_familyis indexed
(migration0022_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, runmakemigrationsto
pick up the index, and if you overriderevoke()overriderevoke_family()to match.
Where a family holds two live tokens sharing a checksum -- whichRefreshToken'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_grantrather 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), butApplication.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 produceredirect_uri_mismatchagainst each other. Schemes that require an authority
(http,https,ws,wss,ftp) must still include a host, and the redundant
com.example.app:///oauth2redirectand rootlesscom.example.app:oauth2redirectspellings
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 tocom.example.app://oauth2redirect— registeringoauth2redirectas 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 raisesAttributeErrorwhen
ALLOW_URI_WILDCARDSis enabled and a redirect URI has no hostname, as is the case for
private-use URI scheme redirects. - #746
REFRESH_TOKEN_EXPIRE_SECONDSis now enforced when a refresh token is presented,
not only by thecleartokens(clear_expired) cleanup job. Previously a refresh token
past its configured lifetime kept working until a cleanup sweep happened to remove it —
or forever, ifcleartokenswas never scheduled. Expiry is idle-based: a refresh token
is rejectedREFRESH_TOKEN_EXPIRE_SECONDSafter 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 setREFRESH_TOKEN_EXPIRE_SECONDSmay 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, leavingaccess_tokenNULL. The
previousaccess_token__expires__ltjoin could never match aNULLaccess token, so
such rows could remain in the database indefinitely. - #1687 Reusing a refresh token within
REFRESH_TOKEN_GRACE_PERIOD_SECONDSno longer
raisesAttributeError: '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. byclear_expiredor a concurrent rotation.
_save_bearer_tokennow 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-oneAccessToken.source_refresh_tokenrelation). - #958 Return a spec-compliant 400 instead of raising an uncaught
AssertionError
(HTTP 500) when an application without any registeredredirect_uris(e.g. a
client_credentialsapplication) is driven through a flow that needs a default
redirect URI.Application.default_redirect_urinow raises
oauthlib'sMissingRedirectURIError, consistent with the multiple-URI case. - #1260
OAuth2Validator.validate_bearer_tokennow rejects a token whose
application is not usable (Application.is_usable()returnsFalse) with an
invalid_tokenerror, mirroring the check the issuance path already performs
in_load_application. The defaultis_usable()returnsTrue, so this only
affects swapped Application models that override it. - #1169 The DRF
TokenHasScopeandTokenMatchesOASRequirementspermissions now
deny (returnFalse) and log a warning, instead of raising anAssertionError
(HTTP 500), whenrequest.authis 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.DeviceUserCodeViewclaims a pending grant for the
user who enters theuser_code, butDeviceConfirmViewandDeviceGrantStatusViewlooked the
grant up byclient_idanduser_codealone. 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 onuser=request.userand return404to 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, butrevokedrecords 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"). WithROTATE_REFRESH_TOKEN = Falseit 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 defaultREFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0were never
exposed.This generalizes a test that previously applied only when
REFRESH_TOKEN_REUSE_PROTECTIONwas 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-legitimateredirect_uriand 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;paramsoff 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 usesurlsplit(), 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,
sohttps://example.com/cb#xmatched a registeredhttps://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%23is not a
fragment delimiter and is unaffected.
Registration is tightened to match:
AllowedURIValidatortested 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 aValidationErrorwhere
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_WILDCARDSstill
opts out of exact host matching and remains flagged byoauth2_provider.W009/E004.Upgrade note: clients that pass per-request data through the
redirect_uriquery
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
stateparameter, which is what it is for. Applications whose registered
redirect_urisalready carry no query component are unaffected. - A request could carry query parameters that were never registered — the check
-
#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_PROTECTIONenabled,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 returns200(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.