Skip to content

Add Dynamic Client Registration behind initial access tokens#536

Open
jdelic wants to merge 3 commits into
developfrom
feature/dcr
Open

Add Dynamic Client Registration behind initial access tokens#536
jdelic wants to merge 3 commits into
developfrom
feature/dcr

Conversation

@jdelic

@jdelic jdelic commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Implements RFC 7591/7592 Dynamic Client Registration per .agent-docs/dcr-cimd-authz-iss/implementation-plan.rst section 3: registration at /o2/register/ is gated by an operator-minted "authserver:dcr-initial" bearer token (mailauth/dcr.py, InitialAccessTokenDCRPermission), managed via the new dcrtoken management command (create/list/revoke). Registered applications are bound to the Domain that signs for the request Host, so every DCR client can issue OIDC ID tokens; hosts without a signing domain are rejected with 400. RFC 7592 self-management (GET/PUT/DELETE) is mounted at the upstream-expected 'dcr-register-management' URL name.

Deviates from the plan's reference code in mailauth/dcr.py: the plan assigns domain to the application only after upstream's post() saves it, assuming application.full_clean() tolerates domain=None. In reality MNApplication.domain is null=True but not blank=True, so full_clean() always rejected a domain-less application before upstream could save anything. Since touching mailauth/models.py was out of scope, MNDynamicClientRegistrationView.post() inlines upstream's post() body (reusing its private helpers) and sets domain on the Application before full_clean() runs, instead of calling super().post().

Claude-Session: https://claude.ai/code/session_01AgWqnQ8ExYjdZofXbvJZEz

claude and others added 3 commits July 25, 2026 14:41
Implements RFC 7591/7592 Dynamic Client Registration per
.agent-docs/dcr-cimd-authz-iss/implementation-plan.rst section 3:
registration at /o2/register/ is gated by an operator-minted
"authserver:dcr-initial" bearer token (mailauth/dcr.py,
InitialAccessTokenDCRPermission), managed via the new `dcrtoken`
management command (create/list/revoke). Registered applications are
bound to the Domain that signs for the request Host, so every DCR
client can issue OIDC ID tokens; hosts without a signing domain are
rejected with 400. RFC 7592 self-management (GET/PUT/DELETE) is
mounted at the upstream-expected 'dcr-register-management' URL name.

Deviates from the plan's reference code in mailauth/dcr.py: the plan
assigns `domain` to the application only after upstream's post() saves
it, assuming application.full_clean() tolerates domain=None. In
reality MNApplication.domain is null=True but not blank=True, so
full_clean() always rejected a domain-less application before
upstream could save anything. Since touching mailauth/models.py was
out of scope, MNDynamicClientRegistrationView.post() inlines upstream's
post() body (reusing its private helpers) and sets `domain` on the
Application before full_clean() runs, instead of calling super().post().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AgWqnQ8ExYjdZofXbvJZEz
- check the initial access token before resolving the request host in
  MNDynamicClientRegistrationView, so an unauthenticated caller can no
  longer probe which vhosts have a signing domain (401 for all of them)
- require TLS on both registration endpoints (RFC 7591 section 5) and rate
  limit them by IP, like the other credential-carrying APIs in
  mailauth/views.py. The RFC 7592 endpoint gets its own subclass for that
- add `iss` to authorization *error* redirects, which django-oauth-toolkit
  builds in the view and which therefore never pass through MNOAuthLibCore
- stop advertising authorization_response_iss_parameter_supported in the
  RFC 8414 document served at the server root: it publishes issuer
  "https://<host>", which is not the "https://<host>/o2" we send as `iss`
- advertise the DCR registration endpoint in the OpenID Connect discovery
  document as well, upstream only publishes it in the RFC 8414 metadata
- rewrite the MNDynamicClientRegistrationView docstring: keep why the view
  vendors upstream's post() (MNApplication.domain isn't blank=True), drop
  the retelling of how the implementation plan got there
- dcrtoken: reject negative --expires-days, drop microseconds from the
  timestamps in `list` so the columns line up, document that a token is
  reusable until it expires
- tests: token-endpoint auth for a DCR-registered confidential client, a
  user access token rejected as an initial access token, plaintext and
  rate limit rejections, discovery documents, negative --expires-days
- smoke test dcrtoken create/list/revoke in the CI workflow

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V17VQLeK8vFDVLZ8uyiEGC
The document at the bare /.well-known/oauth-authorization-server named
"https://<host>" as its issuer while every endpoint in it lives under /o2
and both `iss` and the ID token `iss` claim say "https://<host>/o2". The
path-component route made it worse: <path:issuer_path> answered for any
path, so /.well-known/oauth-authorization-server/anything claimed to be
metadata for the issuer "https://<host>/anything".

- pin the path-insertion route to the one issuer path that exists, so
  every other path 404s like an unknown issuer should
- redirect the bare well-known URL to that document instead of inventing
  an issuer for it. Clients that don't implement RFC 8414 issuer paths
  still discover the server, and now learn its real issuer
- also serve the document in the issuer-suffix form under /o2, which is
  how OpenID Connect Discovery style clients build the URL. Registering
  that route inside the oauth2_provider namespace additionally makes
  django-oauth-toolkit's own RFC 9207 issuer helper resolve correctly
- with every document naming the same issuer, the discovery mixin can go
  back to advertising RFC 9207 support unconditionally
- the o2 mount path is a constant now, the metadata routes have to agree
  with it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V17VQLeK8vFDVLZ8uyiEGC
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant