One of the biggest our releases so far! A big step to the first Beta release.
30 features, 27 bugfixes, 19 breaking changes, 100 commits, 25 contributors.
Thanks a lot for all the help from our amazing community and #opensource_september event ❤️
Release description
JWT can live in cookies now
CookieJWTSyncAuth reads the token. CookieObtainTokens*, CookieRefreshTokens*, CookieLogout* controllers issue, rotate and drop it.
POST /api/auth/
HTTP/1.1 200 OK
Set-Cookie: access_token=…; HttpOnly; Secure; SameSite=Lax
Set-Cookie: refresh_token=…; Path=/api/auth/refresh/; HttpOnly; Secure; SameSite=Lax
Cache-Control: no-store- The browser never sees the token.
- The refresh cookie is scoped to the refresh endpoint.
- Refresh and logout enforce CSRF.
auth = (CookieJWTSyncAuth(), HeaderJWTSyncAuth())accepts both.
Reusable controllers, resolved late
@modify.lazy and @validate.lazy take a classmethod. The endpoint is built when the final controller is, so children override status codes, headers, auth and throttling with class attributes.
class LoginController(Controller[_SerializerT]):
status_code: ClassVar[HTTPStatus] = HTTPStatus.OK
@classmethod
def lazy_spec(cls) -> ModifyAnyCallable:
return modify(status_code=cls.status_code)
@modify.lazy(lazy_spec)
def get(self) -> str:
return 'login'
class CreatedLogin(LoginController[PydanticSerializer]):
status_code = HTTPStatus.CREATEDEvery shipped view supports it via modify_spec.
401 finally says how to authenticate
Every NotAuthenticatedError carries a WWW-Authenticate challenge, as RFC 9110 requires.
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="api", charset="UTF-8", Bearer- Basic, header JWT and header Token auth send a challenge. Cookie and custom-header auth send none.
realm=names it,www_authenticate=Falseopts out.- Custom auth classes must declare
www_authenticate_challenge.
Validate every response, except the ones you mean
DMR_SETTINGS = {
Settings.exclude_validate_responses: {HTTPStatus.INTERNAL_SERVER_ERROR},
}Same name on a controller attribute and as a @modify / @validate argument. The most specific level wins. None resets all levels.
Faster on the hot path
JWToken.encode with msgspec
| 1.30× |
JWToken.decode with msgspec
| 1.15× |
OpenAPI.convert()is cached per instance,cache_clear()drops it.accepted_typeskips regex parsing for media types without parameters.- Throttling keys are hashed, backends expose
lock, Redis skips it.
Hardened by default
@sensitive_variableson every auth view, request data no longer sits in the endpoint frame.- Tokens without
jtino longer bypass the blocklist. - Refresh tokens are rejected where access tokens are expected.
- Every shipped auth view returns
Cache-Control: no-store. - CSRF failure details only in
DEBUG. QueryTokenSyncAuthandQueryTokenAsyncAuthare removed.- HTTP Basic requires the
Basicprefix and no longer URL-decodes credentials. - Bad
exp,iator subject returns401, not500.
What's Changed
Breaking changes
check_authofRefreshTokenSyncController,RefreshTokenAsyncController,
VerifyTokenSyncController, andVerifyTokenAsyncControllernow takes
the decodedtokenas its second argument and typesuser
asAbstractBaseUserinstead ofAny.
It used to differ fromcheck_authof the auth classes,
which is whyJWTokenBlocklistSyncMixinandJWTokenBlocklistAsyncMixin
could not be mixed into these controllers, #1290dmr.security.jwt.viewsis now a package
ofbase,body, andcookiemodules.
Every public name is still importable fromdmr.security.jwt.views,
only the private bases moved, #1290JWToken.encodenow raisesJWTokenError(a token-layer semantic error)
instead of the HTTP-layerInternalServerErrorwhen encoding fails.
The error is converted back toInternalServerErrorat the HTTP boundary
inBaseTokenController.create_jwt_token, so request-serving paths keep
their 500 contract while non-request callers (management commands, Celery
tasks, test factories) get a meaningful exception. The originalpyjwt
cause is preserved in the traceback (no morefrom None).- Renamed
json_dumptojson_dumpsindmr.openapi.dumpanddmr.internal.json
to follow standard string-serialization conventions, #1399 - Removed
QueryTokenSyncAuthandQueryTokenAsyncAuthauth classes,
because they were insecure, you can use older existing versions, #1288 - Removed
FileResponseSpec.file_body,
useFileResponseSpec.return_typeinstead, #1278 - Removed
FileMetadataComponent.schema_metadata,
now we useSupportsFileParsing.schema_metadatainstead, #1278 SSEventdoes not checkidandeventfields for null bytes
and line breaks on creation anymore, this is now a part of the events
validation pipeline, so it respectsvalidate_events, #1329check_event_fieldnow raisesValidationErrorinstead ofValueError,
so a wrong field is streamed as anerrorevent
and does not break the whole stream, #1329401responses now carry aWWW-Authenticateheader as required
by RFC 9110, when the endpoint's auth can express a challenge.
Note that browsers show their native login prompt on aBasicchallenge,
passwww_authenticate=Falseto the auth instance to opt out, #1334SyncAuthandAsyncAuthnow have an abstract
www_authenticate_challengeproperty, so custom auth classes
must say what challenge they send, or returnNone
when they cannot be expressed as one, #1334- Removed init-only
leewayargument ofJWToken,
it is only used byJWToken.decodenow, #1324 JWTokendoes not validateexpandiaton creation anymore,
nowJWToken.encodevalidates them instead, #1324JWTokendoes not allow dataclass instances inextrasanymore, #1408- Throttling cache keys are now hashed to keep their length bounded, #1337
- HTTP Basic Auth credentials are no longer URL-decoded,
so percent-encoded characters such as%40are preserved as-is, #1363 HttpBasicSyncAuthandHttpBasicAsyncAuthnow require
theauth_schemeheader prefix, it isBasicby default
and is matched exactly, credentials sent without it
are not accepted anymore.
Passauth_scheme=''to keep reading prefixless
credentials like the older versions did, #1330HttpBasicSyncAuthandHttpBasicAsyncAuthnow raise
NotAuthenticatedErrorwhen credentials have the right
auth_schemeprefix, but cannot be decoded,
previously the next auth in the chain was tried, #1330ToJsonKwargsandToModelKwargsare nowclosed=Truetyped dicts, #1430
Features
- Added
@modify.lazyand@validate.lazydecorators
for reusable controllers, #1409 - Added
exclude_validate_responsessetting, controller attribute,
and@modify/@validateargument to skip response validation
for the given status codes, like500, #1370 - Added
WWW-Authenticatesupport for auth classes that read
theAuthorizationheader:HttpBasicSyncAuth,HttpBasicAsyncAuth,
HeaderJWTSyncAuth,HeaderJWTAsyncAuth,HeaderTokenSyncAuth,
andHeaderTokenAsyncAuth. Cookie-based and custom-header auth
send no challenge, because there is none to express.
Configurable via the newwww_authenticate=andrealm=arguments
and theSyncAuth.www_authenticate_challengeproperty, #1334 - Added
dmr.security.add_www_authenticatefunction to add
theWWW-Authenticateheader to aNotAuthenticatedError.
global_error_handlercalls it, so replacing that handler
is how you change or drop this behavior, #1334 - Added
CookieJWTSyncAuthandCookieJWTAsyncAuth
to read JWT tokens from cookies instead of headers, #1193 - Added
CookieObtainTokensSyncController,
CookieObtainTokensAsyncController,
CookieRefreshTokensSyncController,
CookieRefreshTokensAsyncController,
CookieLogoutSyncController, andCookieLogoutAsyncController
to issue, rotate, and drop JWT tokens as cookies
thatCookieJWTSyncAuthandCookieJWTAsyncAuthread back.
Cookies arehttponly,secure, andsamesite='lax'by default,
the refresh cookie is scoped to the refresh endpoint,
and refresh and logout enforce CSRF, #1290 - Added
DEFAULT_ACCESS_COOKIEandDEFAULT_REFRESH_COOKIEconstants
todmr.security.jwt.auth.cookie, they are the default cookie names
of both the cookie auth and the cookie views, #1290 - Added
NewCookie.from_specto build a response cookie
from itsCookieSpec, so runtime cookies of@validateendpoints
cannot drift away from the spec they are validated against, #1290 JWTokenBlocklistSyncMixinandJWTokenBlocklistAsyncMixincan now
be mixed into the refresh controllers, both the body and the cookie ones,
so a blocklisted token cannot buy a new pair of tokens.
They used to only work with auth classes, #1290- Added
get_usertoRefreshTokenSyncController
andRefreshTokenAsyncController, the user lookup used to be inlined
intorefreshwith no way to override it alone, #1290 - Added
response_headersandresponse_headers_spec
to the cookie controllers, sovalidate_speccan be redefined
without repeating theCache-Controlheader by hand, #1290 CookieSpec.pathandNewCookie.pathnow accept lazy strings,
so a cookie can be scoped to areverse_lazyurl, #1290- Added
HeaderJWTSyncAuthandHeaderJWTAsyncAuth,
JWTSyncAuthandJWTAsyncAuthare kept as their aliases, #1193 - Added
XSessionTokenSyncAuthandXSessionTokenAsyncAuth
to authenticatedjango-allauthheadless session tokens,
you would need to install
django-allauth
separately, #1193 - Added
querymethod support forPathItemOpenAPI 3.2.0 spec, #1300 - Added
Parser.validatemethod for import-time validation of parser
configuration, #1304 - Added
Renderer.validatemethod for import-time validation of renderer
configuration, #1306 - Added
Router.ignore_from_specto exclude entire router subtrees
from the generated OpenAPI specification, #1309 - Added
FileMetadataconditional types, #1278 - Added
SupportsFileParsing.schema_metadatamethod to customize
file schema from the parser, #1278 - Added
validate_event_fieldsto theSSEStreamingValidatorpipeline,
it checksidandeventfields of all event types,
including custom ones, #1329 - Added
JWToken.validate_issued_claimsmethod to customize
the checks we run before signing a token, #1324 - Added
security.NO_STORE_HEADERS, all auth views we ship now
return theCache-Control: no-storeheader
and document it in the OpenAPI schema, #1335 - Added
@modify.lazysupport andmodify_specmethod
to all all views we ship, #1423 - JWT tokens are now encoded and decoded with
msgspec
when it is installed, which makesJWToken.encodeabout 1.3x
andJWToken.decodeabout 1.15x faster.
Note that only json-native values inJWToken.extrasare guaranteed
to be encoded identically with and withoutmsgspec, #1390 - Optimized
JWTokenencoding and decoding algorithms, #1408 - Added
BaseThrottleSyncBackend.lockandBaseThrottleAsyncBackend.lock
to control the in-process lock forincr,
SyncRedisandAsyncRedisskip it because Lua scripts are atomic, #1339 OpenAPI.convert()now caches and returns
the same dictionary per instance, #1402accepted_typeandaccepted_headerare faster now,
media types without parameters skip the regex based parsing
of parameters and of theqweight entirely, #1407dmr.openapi.OpenAPInow hascache_clearmethod
to drop all cached internal state, #1431
Bugfixes
- Fixed
CookieSpec(max_age=0)never matching the response cookie
it describes,0was treated as a missing value.
It is how a cookie is dropped, so it could not be described at all, #1290 - Fixed
PydanticFastSerializerfailing to validate an empty response body,
so@validateendpoints that return204with it
raised a serialization error instead of the response, #1290 - Fixed
@modifyand@validatetyping: passing asyncauth
orthrottlingto a sync endpoint
(and sync ones to an async endpoint) is now a type error,
linksis now also accepted by all@modifyoverloads, #1393 - Fixed
@validatereturn type inference for all the type-checkers,
now it does not change the originalHttpResponseBasesubtype, #1409 - Fixed
EndpointMetadata.validate_responsesbeing annotated
asbool | None, it is always resolved
from the settings, the controller, and the endpoint, #1370 - Fixed
responsesofObtainTokenSyncController,
ObtainTokenAsyncController,DjangoSessionSyncController,
andDjangoSessionAsyncControllerbeing narrowed
to a fixed-size tuple, subclasses could not change it, #1371 - Added missing
@sensitive_variablesdecorator to all auth views,
so credentials and tokens are hidden
in error reporting middlewares and logs, #1323 - Parsed request data is no longer stored as a local variable
of the endpoint's frame, because it was shown
in error reports of any endpoint, #1323 - Fixed
JWToken.encoderaising a bareTypeError
whenextrascannot be serialized to json, #1373 - JWT auth, refresh, and verify now return
401instead of500
when the token subject cannot be a value of the user lookup field,
for example a non-numericsubwith the default integerpk, #1284 - Fixed
DjangoSessionSyncAuth,DjangoSessionAsyncAuth,
CookieTokenSyncAuth, andCookieTokenAsyncAuthto check CSRF only
when this auth class is actually used and not skipped, #1289 - Allow using lazy translations in many places,
likeController.summary,ResponseSpec.description,
HeaderSpec.description, #1298 - Fixed
Router.includedroppingtagsanddeprecatedmetadata, #1299 - Fixed
PathItemto supportadditionalOperationsfield for custom
HTTP methods (likePURGE,LINK), #1300 - Fixed a bug when non-file parsers were listed in the response schema
for file responses, #1278 - Fixed
SimpleRatethrottling reports with redis backends,
it used to error on missing throttling stats, #1333 - SSE events are not validated at all when
validate_eventsisFalse,
idandeventfields used to be checked even then, #1329 - Custom SSE event types now have their
idandeventfields
validated just likeSSEventdoes, #1329 - Fixed
JWToken.decodevalidatingexpandiattwice,
nowleeway,verify_exp, andverify_iatare respected
and invalid tokens return401instead of500, #1324 - Fixed the JWT blocklist being silently bypassed by tokens without
jti,
JWTokenBlocklistSyncMixinandJWTokenBlocklistAsyncMixin
now addjtitorequire_claims, so such tokens get401.
Blocklisting them returns401as well
instead of failing with a databaseIntegrityError, #1322 - JWT authentication now rejects refresh tokens when access tokens are expected,
#1320 - Fixed a bug when request data might be copied in
parse_as_post, #1328 - Fixed postponed annotation resolution for wrapped endpoint functions
whose decorators are defined in another module, #1417 - Detailed CSRF failure reasons are now included
in error responses only in debug mode and not in production, #1332 - Empty response body checks now cover
1xx,
205 Reset Content, andHEAD, #1340 - Media types with
q=0in theAcceptheader are not selected
for the response anymore,q=0means "not acceptable",
so such requests now get a406response.
This matchesdjango.http.HttpRequest.accepted_types, #1407 - Fixed
Acceptheaders with out of rangeqvalues returning500,
q=infused to raiseOverflowErrorwhile sorting media types.
Out of range weights are now discarded and treated asq=1,
just likedjango.http.request.MediaTypedoes, #1407
Misc
- Documented that
500must be described or excluded from validation,
when running withvalidate_responsesenabled, #1370 - Fixes AI docs and plugin install instructions, #1311
- Documented safe use of user-provided redirect targets with
RedirectTo,
#1326 - Added
dmr-from-dj-rest-authagent skill to migratedj-rest-auth
installations todjango-modern-restanddjango-allauthheadless, #1193 - Documented why and how to remove expired
BlocklistedJWToken
andTokenrows on a schedule, #1336 - Added a guide on writing your own auth class
for transports we don't ship, #1366
Migration prompt
It covers all the breaking changes.
You are migrating a Python project from `django-modern-rest` 0.14.0 to 0.15.0.
Load the latest documentation from https://django-modern-rest.readthedocs.io/llms-full.txt
before making any changes.
Apply **all** of the following breaking changes to the codebase.
For each change, search the entire project (including tests, fixtures, and
any helper modules) before editing.
---
### `check_auth` of the JWT refresh and verify controllers takes the `token`
`RefreshTokenSyncController`, `RefreshTokenAsyncController`,
`VerifyTokenSyncController`, and `VerifyTokenAsyncController`
now call `check_auth(user, token)`. The `user` argument is typed
as `AbstractBaseUser` instead of `Any`, the new `token` argument
is the decoded `JWToken`. This is the same signature
that `check_auth` of the JWT auth classes already had.
Find every subclass of these controllers that overrides `check_auth`
and add the `token` parameter (keep it even if it is unused),
including `super().check_auth(...)` calls and direct calls in tests.
Before:
from typing import Any
class MyRefresh(RefreshTokenSyncController[...]):
def check_auth(self, user: Any) -> None:
super().check_auth(user)
...
After:
from django.contrib.auth.base_user import AbstractBaseUser
from dmr.security.jwt import JWToken
class MyRefresh(RefreshTokenSyncController[...]):
def check_auth(self, user: AbstractBaseUser, token: JWToken) -> None:
super().check_auth(user, token)
...
Do the same for the async variants (`async def check_auth`).
Now that the signatures match, `JWTokenBlocklistSyncMixin`
and `JWTokenBlocklistAsyncMixin` can be mixed into these controllers.
If the project reimplemented blocklist checks inside a refresh controller
by hand, consider replacing that code with the mixin.
---
### `JWToken.encode` raises `JWTokenError` instead of `InternalServerError`
`JWToken.encode` now raises `dmr.security.jwt.token.JWTokenError`
(a plain `Exception` subclass about the token itself)
when encoding fails or when the token cannot be issued right now.
The original `pyjwt` exception is preserved as `__cause__`.
Inside request handling nothing changes: `BaseTokenController.create_jwt_token`
converts `JWTokenError` back into `InternalServerError`, so the `500` contract
of the shipped views is kept.
Find every place that calls `JWToken(...).encode(...)` directly
(management commands, Celery tasks, test factories, custom views that bypass
`create_jwt_token`) and update the exception handling:
Before:
from dmr.exceptions import InternalServerError
try:
token = JWToken(...).encode(secret, algorithm)
except InternalServerError:
...
After:
from dmr.security.jwt.token import JWTokenError
try:
token = JWToken(...).encode(secret, algorithm)
except JWTokenError:
...
Tests that assert `pytest.raises(InternalServerError)` around `encode`
must now assert `JWTokenError`. Tests that expected `pyjwt` errors
to be swallowed (`from None`) can now inspect `exc.__cause__`.
---
### `json_dump` → `json_dumps`
The function was renamed in both `dmr.openapi.dump` and `dmr.internal.json`.
Before:
from dmr.openapi.dump import json_dump
json_dump(schema)
After:
from dmr.openapi.dump import json_dumps
json_dumps(schema)
Do the same for `dmr.internal.json.json_dump`, if it was used
(it is an internal module and should not be imported by user code,
prefer `dmr.openapi.dump.json_dumps`).
---
### Removed `QueryTokenSyncAuth` and `QueryTokenAsyncAuth`
Both classes were removed from `dmr.security.token`, because passing
auth tokens in the query string is insecure (tokens leak into logs,
browser history, and `Referer` headers).
Find every import and usage of `QueryTokenSyncAuth` and `QueryTokenAsyncAuth`
(auth lists in `@modify`/`@validate`, controller `auth = [...]` attributes,
`DMR_SETTINGS['auth']` defaults, subclasses, tests) and replace them
with the header-based classes, keeping the same constructor arguments
where they still apply:
Before:
from dmr.security.token import QueryTokenSyncAuth
auth = [QueryTokenSyncAuth()]
After:
from dmr.security.token import HeaderTokenSyncAuth
auth = [HeaderTokenSyncAuth()]
Do the same for `QueryTokenAsyncAuth` → `HeaderTokenAsyncAuth`.
Update clients and tests to send the token in the header
(`Authorization: Bearer <token>` by default) instead of `?token=...`.
If the project truly cannot move tokens out of the query string,
copy the last shipped implementation into the project as a custom auth
(and add the `www_authenticate_challenge` property from change 11):
https://github.com/wemake-services/django-modern-rest/blob/14884b432ee075ec3d78ff388944ebc5f0b5d432/dmr/security/token/auth/query.py
---
### `FileResponseSpec.file_body` → `FileResponseSpec.return_type`
Find every `FileResponseSpec(...)` construction, attribute access,
and subclass that uses `file_body` and rename it to `return_type`.
The value is the same kind of object: a `FileBody` (now `FileBodyLike`) subclass.
Before:
FileResponseSpec(file_body=MyFileBody, status_code=HTTPStatus.OK)
spec.file_body
After:
FileResponseSpec(return_type=MyFileBody, status_code=HTTPStatus.OK)
spec.return_type
---
### Removed `FileMetadataComponent.schema_metadata`, use `SupportsFileParsing.schema_metadata`
`FileMetadataComponent` no longer takes or stores a `schema_metadata` model.
The file schema is now provided by the parser: `SupportsFileParsing.schema_metadata`
is an abstract method that every file-capable parser must implement.
Find every `FileMetadataComponent(schema_metadata=...)` call
or `component.schema_metadata` access and remove it.
Move the customization to the parser class that handles file uploads
(a subclass of a parser that inherits from `SupportsFileParsing`):
Before:
FileMetadataComponent(schema_metadata=MyFileBody)
After:
class MyMultiPartParser(MultiPartParser):
def schema_metadata(
self,
model: Any,
model_meta: tuple[Any, ...],
metadata: EndpointMetadata,
serializer: type[BaseSerializer],
context: OpenAPIContext,
) -> type[FileBodyLike]:
return MyFileBody
# and register `MyMultiPartParser` in `parsers` of the controller
# or in `DMR_SETTINGS`.
If the project defines its own parser that inherits from `SupportsFileParsing`,
it must now implement `schema_metadata`, otherwise it cannot be instantiated.
Return `FileBody` (from `dmr.files`) to keep the default schema.
---
### `SSEvent` no longer validates `id` and `event` on creation
`SSEvent(...)` does not check the `id` and `event` fields for null bytes
and line breaks in `__init__` anymore. The check moved into
the `SSEStreamingValidator` pipeline as `validate_event_fields`,
so it now respects the `validate_events` setting and runs for custom
event types too.
Consequences to handle:
- Constructing an invalid `SSEvent` no longer raises. Tests that assert
`pytest.raises(ValueError)` on `SSEvent(id='a\n')` must be rewritten
to stream the event through an endpoint and assert
the resulting `error` event instead.
- With `validate_events=False` these fields are not checked at all anymore.
Enable `validate_events` where the stream may carry untrusted `id`/`event`.
- If the project has a custom `SSEStreamingValidator.validation_pipeline`
override, add `validate_event_fields` to it (after `validate_event_type`).
---
### `check_event_field` raises `ValidationError` instead of `ValueError`
`dmr.streaming.sse.validation.check_event_field` now raises
`dmr.exceptions.ValidationError` (with `ErrorType.streaming`),
so a wrong field is streamed as an `error` event instead of breaking
the whole stream.
Find every direct call of `check_event_field` and every
`except ValueError` / `pytest.raises(ValueError)` around it:
Before:
try:
check_event_field(event.id, field_name='id')
except ValueError:
...
After:
from dmr.exceptions import ValidationError
try:
check_event_field(event.id, field_name='id')
except ValidationError:
...
---
### `401` responses now carry a `WWW-Authenticate` header
When the endpoint's auth chain can express a challenge
(`HttpBasicSyncAuth`, `HttpBasicAsyncAuth`, `HeaderJWTSyncAuth`,
`HeaderJWTAsyncAuth`, `HeaderTokenSyncAuth`, `HeaderTokenAsyncAuth`,
and any custom auth returning a challenge), every `401` response now
includes `WWW-Authenticate`, for example `Bearer` or
`Basic realm="api", charset="UTF-8"`. The header is also documented
in the OpenAPI schema of the `401` response.
- Update tests that assert the exact set of response headers
or compare a generated OpenAPI schema snapshot: regenerate the snapshot.
- Browsers show their native login prompt on a `Basic` challenge.
For browser-facing APIs using HTTP Basic auth pass `www_authenticate=False`
to the auth instance to opt out, or set a custom `realm=`:
HttpBasicSyncAuth(www_authenticate=False)
HttpBasicSyncAuth(realm='admin')
- The header is added by `global_error_handler` via
`dmr.security.add_www_authenticate`. If the project replaces
`global_error_handler`, decide whether to call `add_www_authenticate(exc, auth)`
in the replacement; without it, `401` responses will have no challenge.
---
### Custom auth classes must implement `www_authenticate_challenge`
`SyncAuth` and `AsyncAuth` gained an abstract property
`www_authenticate_challenge -> str | None`. Every custom auth class
in the project must define it, or it cannot be instantiated.
- Return the HTTP auth scheme challenge when credentials are read
from the `Authorization` header (`'Bearer'`, `'Basic realm="api"'`, ...).
- Return `None` when the auth reads a cookie, a custom header,
or anything that cannot be expressed as a challenge.
Before:
class ProxyHeaderSyncAuth(SyncAuth):
def __call__(self, endpoint, controller): ...
After:
class ProxyHeaderSyncAuth(SyncAuth):
@property
def www_authenticate_challenge(self) -> str | None:
return None # reads `X-Proxy-User`, nothing to advertise
def __call__(self, endpoint, controller): ...
Do this for every subclass of `SyncAuth`, `AsyncAuth`, and for custom
subclasses of the token/JWT base auth classes that do not inherit
the property from a shipped concrete class.
---
### Removed init-only `leeway` argument of `JWToken`
`JWToken(..., leeway=N)` is gone. `leeway` is only accepted
by `JWToken.decode(...)` now.
Before:
JWToken(sub='1', exp=exp, leeway=30)
After:
JWToken(sub='1', exp=exp)
# and, where the token is decoded:
JWToken.decode(raw, secret, algorithms, leeway=30)
Search for `leeway=` in every `JWToken(` constructor call, dataclass
`replace(...)` call, and subclass `__post_init__` override, and remove it.
---
### `JWToken` validates `exp` and `iat` in `encode`, not on creation
`JWToken.__post_init__` no longer raises for an expired `exp`
or a future `iat`. The checks moved to `JWToken.validate_issued_claims`,
which `JWToken.encode` calls before signing, raising `JWTokenError`.
- Creating a `JWToken` with a past `exp` is now allowed (it is how decoded
or fixture tokens are represented). Tests that expected `ValueError`
from the constructor must call `.encode(...)` and expect `JWTokenError`.
- Code that relied on the constructor to reject bad claims before
storing them somewhere must call `token.validate_issued_claims()`
explicitly.
- Subclasses that overrode `__post_init__` to add issue-time checks
should override `validate_issued_claims` instead (call `super()` first).
Before:
with pytest.raises(ValueError):
JWToken(sub='1', exp=past)
After:
from dmr.security.jwt.token import JWTokenError
with pytest.raises(JWTokenError):
JWToken(sub='1', exp=past).encode(secret, algorithm)
---
### `JWToken.extras` no longer accepts dataclass instances
Values in `extras` must be json-native (or handled by the serializer),
dataclass instances are not converted with `dataclasses.asdict` anymore.
Find every `JWToken(..., extras={...})` where a value is a dataclass
instance and convert it before passing:
Before:
JWToken(sub='1', exp=exp, extras={'profile': Profile(...)})
After:
import dataclasses
JWToken(sub='1', exp=exp, extras={'profile': dataclasses.asdict(Profile(...))})
Note that with `msgspec` installed tokens are now encoded with `msgspec`,
only json-native values in `extras` are guaranteed to encode identically
with and without it.
---
### Throttling cache keys are now hashed
The cache key used by throttles changed from a long `::`-joined string
to `f'{cache_key.name}::{sha256(...).hexdigest()}'`.
- Any code that builds, inspects, deletes, or asserts on raw throttle
cache keys (custom backends, admin tooling, tests reading the cache
directly) must be updated to the new format, or better, to go through
the throttle's own key method instead of reproducing the format.
- Existing counters stored under the old keys are orphaned after deploy:
clients get a fresh throttling window once. Flush the throttling cache
on deploy if that matters.
- Custom `BaseThrottleCacheKey` subclasses must define a `name` attribute,
it is the human-readable prefix of the hashed key.
---
### HTTP Basic Auth credentials are no longer URL-decoded
`HttpBasicSyncAuth` and `HttpBasicAsyncAuth` used to `unquote` the decoded
`username:password` pair, so `user%40example.com` became `user@example.com`.
Percent-encoded characters are now preserved as-is.
- Update clients and test fixtures that percent-encoded credentials
to send the raw characters instead.
- If the project subclassed the basic auth and applied `unquote` itself,
drop that, otherwise credentials containing a literal `%` break.
---
### `HttpBasicSyncAuth` and `HttpBasicAsyncAuth` require the `auth_scheme` prefix
The header value must now start with the `auth_scheme` prefix
(`Basic` by default), matched exactly and case-sensitively:
`Authorization: Basic <base64>`. A header without the prefix is treated
as belonging to another auth in the chain and is ignored.
Find every test fixture, client, or docs example that sent the bare base64
value and add the prefix:
Before:
client.get(url, headers={'Authorization': base64_credentials})
After:
client.get(url, headers={'Authorization': f'Basic {base64_credentials}'})
If the API must keep accepting prefixless credentials, pass
`auth_scheme=''` to the auth instance:
HttpBasicSyncAuth(auth_scheme='')
A custom prefix is also possible: `HttpBasicSyncAuth(auth_scheme='Token')`.
---
### Undecodable HTTP Basic credentials now raise `NotAuthenticatedError`
When the header has the right `auth_scheme` prefix but the credentials
cannot be base64-decoded or split into `username:password`,
the request now fails with `401` immediately. Previously the next auth
in the chain was tried.
Review endpoints with several auth instances where basic auth
is not the last one: a malformed `Basic ...` header no longer falls
through to the next auth. Update tests that relied on that fallback.
---
### `ToJsonKwargs` and `ToModelKwargs` are now `closed=True` typed dicts
The `to_json_kwargs` and `to_model_kwargs` class attributes
of `PydanticSerializer` and `MsgspecSerializer` (and subclasses)
only accept the declared keys now, extra keys are a type error.
Find every serializer subclass that sets these dicts with keys that
are not declared in `dmr.plugins.pydantic.serializer.ToJsonKwargs`,
`dmr.plugins.pydantic.serializer.ToModelKwargs`,
`dmr.plugins.msgspec.serializer.ToJsonKwargs`,
or `dmr.plugins.msgspec.serializer.ToModelKwargs`, and remove the extra keys.
If a non-declared kwarg is truly needed, override `serialize` / `deserialize`
(or `from_python` / `to_python`) on the serializer subclass
and pass it there explicitly.
Run the type checker afterwards, it reports the offending keys.New Contributors
- @SammySN-car made their first contribution in #1301
- @dghnts made their first contribution in #1278
- @markparonyan made their first contribution in #1319
- @Shecspi made their first contribution in #1351
- @AndreyGatsuk made their first contribution in #1365
- @Ryota-Di made their first contribution in #1379
- @panguss made their first contribution in #1363
- @vedmetskii made their first contribution in #1387
- @MasoNord made their first contribution in #1388
- @Izcarmt95 made their first contribution in #1282
- @amasen02 made their first contribution in #1400
- @fwh888 made their first contribution in #1373
- @aryansinha1908 made their first contribution in #1404
- @eoan-ermine made their first contribution in #1405
- @AbduazizZiyodov made their first contribution in #1406
- @MaxFreedomPollard made their first contribution in #1407
- @Shaisolaris made their first contribution in #1355
Full Changelog: 0.14.0...0.15.0