github wemake-services/django-modern-rest 0.15.0
Version 0.15.0

3 hours ago

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.CREATED

Every 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=False opts 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_type skips regex parsing for media types without parameters.
  • Throttling keys are hashed, backends expose lock, Redis skips it.

Hardened by default

  • @sensitive_variables on every auth view, request data no longer sits in the endpoint frame.
  • Tokens without jti no 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.
  • QueryTokenSyncAuth and QueryTokenAsyncAuth are removed.
  • HTTP Basic requires the Basic prefix and no longer URL-decodes credentials.
  • Bad exp, iat or subject returns 401, not 500.

What's Changed

Breaking changes

  • check_auth of RefreshTokenSyncController, RefreshTokenAsyncController,
    VerifyTokenSyncController, and VerifyTokenAsyncController now takes
    the decoded token as its second argument and types user
    as AbstractBaseUser instead of Any.
    It used to differ from check_auth of the auth classes,
    which is why JWTokenBlocklistSyncMixin and JWTokenBlocklistAsyncMixin
    could not be mixed into these controllers, #1290
  • dmr.security.jwt.views is now a package
    of base, body, and cookie modules.
    Every public name is still importable from dmr.security.jwt.views,
    only the private bases moved, #1290
  • JWToken.encode now raises JWTokenError (a token-layer semantic error)
    instead of the HTTP-layer InternalServerError when encoding fails.
    The error is converted back to InternalServerError at the HTTP boundary
    in BaseTokenController.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 original pyjwt
    cause is preserved in the traceback (no more from None).
  • Renamed json_dump to json_dumps in dmr.openapi.dump and dmr.internal.json
    to follow standard string-serialization conventions, #1399
  • Removed QueryTokenSyncAuth and QueryTokenAsyncAuth auth classes,
    because they were insecure, you can use older existing versions, #1288
  • Removed FileResponseSpec.file_body,
    use FileResponseSpec.return_type instead, #1278
  • Removed FileMetadataComponent.schema_metadata,
    now we use SupportsFileParsing.schema_metadata instead, #1278
  • SSEvent does not check id and event fields for null bytes
    and line breaks on creation anymore, this is now a part of the events
    validation pipeline, so it respects validate_events, #1329
  • check_event_field now raises ValidationError instead of ValueError,
    so a wrong field is streamed as an error event
    and does not break the whole stream, #1329
  • 401 responses now carry a WWW-Authenticate header as required
    by RFC 9110, when the endpoint's auth can express a challenge.
    Note that browsers show their native login prompt on a Basic challenge,
    pass www_authenticate=False to the auth instance to opt out, #1334
  • SyncAuth and AsyncAuth now have an abstract
    www_authenticate_challenge property, so custom auth classes
    must say what challenge they send, or return None
    when they cannot be expressed as one, #1334
  • Removed init-only leeway argument of JWToken,
    it is only used by JWToken.decode now, #1324
  • JWToken does not validate exp and iat on creation anymore,
    now JWToken.encode validates them instead, #1324
  • JWToken does not allow dataclass instances in extras anymore, #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 %40 are preserved as-is, #1363
  • HttpBasicSyncAuth and HttpBasicAsyncAuth now require
    the auth_scheme header prefix, it is Basic by default
    and is matched exactly, credentials sent without it
    are not accepted anymore.
    Pass auth_scheme='' to keep reading prefixless
    credentials like the older versions did, #1330
  • HttpBasicSyncAuth and HttpBasicAsyncAuth now raise
    NotAuthenticatedError when credentials have the right
    auth_scheme prefix, but cannot be decoded,
    previously the next auth in the chain was tried, #1330
  • ToJsonKwargs and ToModelKwargs are now closed=True typed dicts, #1430

Features

  • Added @modify.lazy and @validate.lazy decorators
    for reusable controllers, #1409
  • Added exclude_validate_responses setting, controller attribute,
    and @modify / @validate argument to skip response validation
    for the given status codes, like 500, #1370
  • Added WWW-Authenticate support for auth classes that read
    the Authorization header: HttpBasicSyncAuth, HttpBasicAsyncAuth,
    HeaderJWTSyncAuth, HeaderJWTAsyncAuth, HeaderTokenSyncAuth,
    and HeaderTokenAsyncAuth. Cookie-based and custom-header auth
    send no challenge, because there is none to express.
    Configurable via the new www_authenticate= and realm= arguments
    and the SyncAuth.www_authenticate_challenge property, #1334
  • Added dmr.security.add_www_authenticate function to add
    the WWW-Authenticate header to a NotAuthenticatedError.
    global_error_handler calls it, so replacing that handler
    is how you change or drop this behavior, #1334
  • Added CookieJWTSyncAuth and CookieJWTAsyncAuth
    to read JWT tokens from cookies instead of headers, #1193
  • Added CookieObtainTokensSyncController,
    CookieObtainTokensAsyncController,
    CookieRefreshTokensSyncController,
    CookieRefreshTokensAsyncController,
    CookieLogoutSyncController, and CookieLogoutAsyncController
    to issue, rotate, and drop JWT tokens as cookies
    that CookieJWTSyncAuth and CookieJWTAsyncAuth read back.
    Cookies are httponly, secure, and samesite='lax' by default,
    the refresh cookie is scoped to the refresh endpoint,
    and refresh and logout enforce CSRF, #1290
  • Added DEFAULT_ACCESS_COOKIE and DEFAULT_REFRESH_COOKIE constants
    to dmr.security.jwt.auth.cookie, they are the default cookie names
    of both the cookie auth and the cookie views, #1290
  • Added NewCookie.from_spec to build a response cookie
    from its CookieSpec, so runtime cookies of @validate endpoints
    cannot drift away from the spec they are validated against, #1290
  • JWTokenBlocklistSyncMixin and JWTokenBlocklistAsyncMixin can 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_user to RefreshTokenSyncController
    and RefreshTokenAsyncController, the user lookup used to be inlined
    into refresh with no way to override it alone, #1290
  • Added response_headers and response_headers_spec
    to the cookie controllers, so validate_spec can be redefined
    without repeating the Cache-Control header by hand, #1290
  • CookieSpec.path and NewCookie.path now accept lazy strings,
    so a cookie can be scoped to a reverse_lazy url, #1290
  • Added HeaderJWTSyncAuth and HeaderJWTAsyncAuth,
    JWTSyncAuth and JWTAsyncAuth are kept as their aliases, #1193
  • Added XSessionTokenSyncAuth and XSessionTokenAsyncAuth
    to authenticate django-allauth headless session tokens,
    you would need to install
    django-allauth
    separately, #1193
  • Added query method support for PathItem OpenAPI 3.2.0 spec, #1300
  • Added Parser.validate method for import-time validation of parser
    configuration, #1304
  • Added Renderer.validate method for import-time validation of renderer
    configuration, #1306
  • Added Router.ignore_from_spec to exclude entire router subtrees
    from the generated OpenAPI specification, #1309
  • Added FileMetadata conditional types, #1278
  • Added SupportsFileParsing.schema_metadata method to customize
    file schema from the parser, #1278
  • Added validate_event_fields to the SSEStreamingValidator pipeline,
    it checks id and event fields of all event types,
    including custom ones, #1329
  • Added JWToken.validate_issued_claims method to customize
    the checks we run before signing a token, #1324
  • Added security.NO_STORE_HEADERS, all auth views we ship now
    return the Cache-Control: no-store header
    and document it in the OpenAPI schema, #1335
  • Added @modify.lazy support and modify_spec method
    to all all views we ship, #1423
  • JWT tokens are now encoded and decoded with msgspec
    when it is installed, which makes JWToken.encode about 1.3x
    and JWToken.decode about 1.15x faster.
    Note that only json-native values in JWToken.extras are guaranteed
    to be encoded identically with and without msgspec, #1390
  • Optimized JWToken encoding and decoding algorithms, #1408
  • Added BaseThrottleSyncBackend.lock and BaseThrottleAsyncBackend.lock
    to control the in-process lock for incr,
    SyncRedis and AsyncRedis skip it because Lua scripts are atomic, #1339
  • OpenAPI.convert() now caches and returns
    the same dictionary per instance, #1402
  • accepted_type and accepted_header are faster now,
    media types without parameters skip the regex based parsing
    of parameters and of the q weight entirely, #1407
  • dmr.openapi.OpenAPI now has cache_clear method
    to drop all cached internal state, #1431

Bugfixes

  • Fixed CookieSpec(max_age=0) never matching the response cookie
    it describes, 0 was treated as a missing value.
    It is how a cookie is dropped, so it could not be described at all, #1290
  • Fixed PydanticFastSerializer failing to validate an empty response body,
    so @validate endpoints that return 204 with it
    raised a serialization error instead of the response, #1290
  • Fixed @modify and @validate typing: passing async auth
    or throttling to a sync endpoint
    (and sync ones to an async endpoint) is now a type error,
    links is now also accepted by all @modify overloads, #1393
  • Fixed @validate return type inference for all the type-checkers,
    now it does not change the original HttpResponseBase subtype, #1409
  • Fixed EndpointMetadata.validate_responses being annotated
    as bool | None, it is always resolved
    from the settings, the controller, and the endpoint, #1370
  • Fixed responses of ObtainTokenSyncController,
    ObtainTokenAsyncController, DjangoSessionSyncController,
    and DjangoSessionAsyncController being narrowed
    to a fixed-size tuple, subclasses could not change it, #1371
  • Added missing @sensitive_variables decorator 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.encode raising a bare TypeError
    when extras cannot be serialized to json, #1373
  • JWT auth, refresh, and verify now return 401 instead of 500
    when the token subject cannot be a value of the user lookup field,
    for example a non-numeric sub with the default integer pk, #1284
  • Fixed DjangoSessionSyncAuth, DjangoSessionAsyncAuth,
    CookieTokenSyncAuth, and CookieTokenAsyncAuth to check CSRF only
    when this auth class is actually used and not skipped, #1289
  • Allow using lazy translations in many places,
    like Controller.summary, ResponseSpec.description,
    HeaderSpec.description, #1298
  • Fixed Router.include dropping tags and deprecated metadata, #1299
  • Fixed PathItem to support additionalOperations field for custom
    HTTP methods (like PURGE, LINK), #1300
  • Fixed a bug when non-file parsers were listed in the response schema
    for file responses, #1278
  • Fixed SimpleRate throttling reports with redis backends,
    it used to error on missing throttling stats, #1333
  • SSE events are not validated at all when validate_events is False,
    id and event fields used to be checked even then, #1329
  • Custom SSE event types now have their id and event fields
    validated just like SSEvent does, #1329
  • Fixed JWToken.decode validating exp and iat twice,
    now leeway, verify_exp, and verify_iat are respected
    and invalid tokens return 401 instead of 500, #1324
  • Fixed the JWT blocklist being silently bypassed by tokens without jti,
    JWTokenBlocklistSyncMixin and JWTokenBlocklistAsyncMixin
    now add jti to require_claims, so such tokens get 401.
    Blocklisting them returns 401 as well
    instead of failing with a database IntegrityError, #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, and HEAD, #1340
  • Media types with q=0 in the Accept header are not selected
    for the response anymore, q=0 means "not acceptable",
    so such requests now get a 406 response.
    This matches django.http.HttpRequest.accepted_types, #1407
  • Fixed Accept headers with out of range q values returning 500,
    q=inf used to raise OverflowError while sorting media types.
    Out of range weights are now discarded and treated as q=1,
    just like django.http.request.MediaType does, #1407

Misc

  • Documented that 500 must be described or excluded from validation,
    when running with validate_responses enabled, #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-auth agent skill to migrate dj-rest-auth
    installations to django-modern-rest and django-allauth headless, #1193
  • Documented why and how to remove expired BlocklistedJWToken
    and Token rows 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

Full Changelog: 0.14.0...0.15.0

Don't miss a new django-modern-rest release

NewReleases is sending notifications on new releases.