Split the registration request model from the registered-client record#3181
Split the registration request model from the registered-client record#3181maxisbey wants to merge 2 commits into
Conversation
OAuthClientInformationFull, the client's parse of the authorization server's Dynamic Client Registration response, inherited from OAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send. RFC 7591 3.2.1 says otherwise: the server may reject or replace any requested metadata value, and real servers echo an application_type outside OIDC Registration's web/native, an explicit null, an auth method the SDK does not implement, or an empty redirect_uris. Each raised ValidationError on a 2xx response, after the server had already provisioned the client, so the registration was discarded and orphaned. Make the two models siblings over a shared OAuthClientMetadataBase. The request keeps its strict types, so the SDK still refuses to send an unregistered application_type. The record accepts what a server may echo: application_type and token_endpoint_auth_method are str | None (with an echoed "" read as absent, as the optional URL fields already were), grant_types is list[str], and redirect_uris may be absent or empty. client_id is now required, as RFC 7591 3.2.1 makes it in the response. Whether a substituted value is usable is judged where it matters, not at parse: an auth method the client cannot apply is reported as an OAuthRegistrationError when the registration completes, before the record is stored or any interactive authorization begins, and prepare_token_auth reports the same for a stored record. The recognized set is derived from the one TokenEndpointAuthMethod type so the two cannot drift. The bundled registration endpoint now returns all registered metadata in its 201 response, building the record from the validated request's dump so a field can no longer be silently dropped from the echo; it previously omitted application_type, reporting the default in place of a client's "web".
📚 Documentation preview
|
There was a problem hiding this comment.
All reported issues were addressed across 11 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
There was a problem hiding this comment.
Beyond the inline nits, two candidate concerns were examined and ruled out: check_registration_usable accepting private_key_jwt (correct — PrivateKeyJWTOAuthProvider supplies the assertion, and the recognized set is derived from the same TokenEndpointAuthMethod literal the SDK sends, so the refresh path a private-key-JWT client inherits keeps working), and an empty-string client_id bypassing the new client_id-REQUIRED invariant on the record (not a practical issue — "" still parses but downstream guards treat a falsy client_id as absent, and no observed server mints an empty id).
Extended reasoning...
Bugs were found (three nits, posted inline), so the review body is limited to recording what else was examined this run. The two refuted candidates both target the new tolerance/usability mechanism in src/mcp/client/auth/oauth2.py and src/mcp/shared/auth.py; verifiers concluded neither is a real defect. This note is informational only — it is not an approval, and the PR (a breaking change across OAuth client/server auth code) should still get human review.
…boundary Two conflated questions shared one recognized-method set: whether the authorization-code registration flow can act on an assigned method, and whether a stored record may carry a method without the token step erroring. The flow authenticates with the minted secret and holds no key to sign a private_key_jwt assertion, so it now rejects that method at registration too, while prepare_token_auth still tolerates it for PrivateKeyJWTOAuthProvider's inherited refresh path. An explicit JSON null in the registration response is now read as an omitted key across the whole record, so grant_types and response_types (which pydantic rejects null for) parse like the other fields. The SDK-internal issuer binding is cleared after parsing, so a body echoing an "issuer" member cannot seed it. The bundled registration endpoint sets client_secret_expires_at to 0 rather than omitting it whenever a client_secret is issued with no expiry configured, as RFC 7591 3.2.1 requires. The migration entry now names both exception surfaces.
There was a problem hiding this comment.
1 issue found across 10 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="src/mcp/client/auth/utils.py">
<violation number="1" location="src/mcp/client/auth/utils.py:308">
P2: Registration still fails when a server includes a non-string `issuer` extension, because validation consumes it before this reset. Strip `issuer` from the decoded response before validating so this SDK-internal field cannot affect response parsing.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| # `issuer` is the SDK's own binding of these credentials to the server they were | ||
| # registered with (SEP-2352), stamped by the auth flow - never taken from the wire. | ||
| # A body echoing an "issuer" member must not seed the trust binding. | ||
| client_info.issuer = None |
There was a problem hiding this comment.
P2: Registration still fails when a server includes a non-string issuer extension, because validation consumes it before this reset. Strip issuer from the decoded response before validating so this SDK-internal field cannot affect response parsing.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mcp/client/auth/utils.py, line 308:
<comment>Registration still fails when a server includes a non-string `issuer` extension, because validation consumes it before this reset. Strip `issuer` from the decoded response before validating so this SDK-internal field cannot affect response parsing.</comment>
<file context>
@@ -300,9 +300,13 @@ async def handle_registration_response(response: Response) -> OAuthClientInforma
+ # `issuer` is the SDK's own binding of these credentials to the server they were
+ # registered with (SEP-2352), stamped by the auth flow - never taken from the wire.
+ # A body echoing an "issuer" member must not seed the trust binding.
+ client_info.issuer = None
+ return client_info
</file context>
|
Follow-up in 29a3942 addressing the review round; the six inline threads are replied to and resolved individually. Two further items came in without a thread, so recording them here: Reported: required Reported: the SDK-internal Also folded in from the inline threads: |
There was a problem hiding this comment.
Beyond this run's inline notes, two other candidates were examined and ruled out: (1) widening the record's token_endpoint_auth_method to str does not break client_auth.py — its unsupported-method branch raises a handled AuthenticationError (client_auth.py:94-97), not an asserted-unreachable, and SDK-registered records are still built from the strict request model; (2) the record's now-required client_id accepting "" is not a regression — pre-PR the field was optional and an absent client_id parsed as success, so requiring it strictly narrows what passes.
Extended reasoning...
Bugs were found and posted inline, so no approval or full deferral. This note records two candidate issues that were examined and refuted this run, so later review passes don't re-explore them from scratch: the widened auth-method type on the record versus the bundled AS middleware's unsupported-method branch (verified to be an explicit, handled error path), and the empty-string client_id parse (verified to be a strict improvement over the previous optional field). Informational only — not a guarantee of correctness.
| """Confirm a registration this flow completed is one it can act on. | ||
|
|
||
| RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to | ||
| the client to "check the values in the response to determine if the registration is | ||
| sufficient for use". The one substitution that makes the minted credentials unusable is a | ||
| token-endpoint auth method the authorization-code flow cannot apply - one it does not | ||
| implement, or `private_key_jwt`, whose assertion this flow has no key to sign - so it is | ||
| judged here, before the record is persisted or any interactive authorization begins, | ||
| rather than surfacing later as an opaque failure at the token endpoint. | ||
|
|
||
| Raises: | ||
| OAuthRegistrationError: The server registered the client with a | ||
| `token_endpoint_auth_method` this flow cannot apply. | ||
| """ | ||
| if client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: | ||
| raise OAuthRegistrationError( | ||
| "Authorization server registered the client with unsupported token_endpoint_auth_method " | ||
| f"{client_info.token_endpoint_auth_method!r}" | ||
| ) | ||
|
|
||
|
|
There was a problem hiding this comment.
🟡 docs/client/oauth-clients.md:134 still says "OAuthRegistrationError means the authorization server refused to register you", but this PR's new check_registration_usable() also raises OAuthRegistrationError after a successful 201 whose assigned token_endpoint_auth_method the flow cannot apply — the opposite of a refusal, and a condition where each retry orphans another minted registration server-side. Amend the "When it fails" sentence (and, if desired, the Recap bullet at line 145) so the exception is described as covering both a refused registration and a completed registration the flow cannot use.
Extended reasoning...
What the page says vs. what the code now does. docs/client/oauth-clients.md is the Clients-nav page documenting the client-side failure model. Its "When it fails" section (line 134) states: "OAuthRegistrationError means the authorization server refused to register you. OAuthTokenError means the token endpoint said no." This PR introduces check_registration_usable() (src/mcp/client/auth/oauth2.py:79-99), invoked from async_auth_flow immediately after handle_registration_response (oauth2.py:714), which raises OAuthRegistrationError when the server successfully registered the client — HTTP 201, client_id and client_secret minted — but assigned a token_endpoint_auth_method the authorization-code flow cannot apply (an unimplemented method such as client_secret_jwt, or private_key_jwt, whose assertion this flow has no key to sign). That is the opposite of "refused to register you": the server accepted; the SDK judged the registration unusable.\n\nThe code path. (1) Client POSTs its DCR request; server answers 201 with e.g. {"client_id": "unusable", "client_secret": "s3cr3t", "token_endpoint_auth_method": "client_secret_jwt"}. (2) handle_registration_response parses the now-tolerant OAuthClientInformationFull successfully. (3) check_registration_usable finds client_secret_jwt outside _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS and raises OAuthRegistrationError naming the method — before the record is persisted and before any /authorize or /token request. This is pinned by the PR's own interaction test, test_a_registration_assigning_an_unusable_auth_method_surfaces_as_a_registration_error (tests/interaction/auth/test_discovery.py), which asserts storage.client_info is None and no authorize/token traffic.\n\nWhy it matters beyond wording. The two conditions the exception now covers call for opposite handling. A genuine refusal (a 400 from /register) is something a user might address by adjusting OAuthClientMetadata and retrying. The new condition is a server-policy outcome where every retry mints and orphans another registration server-side — the exact orphaning problem the PR description highlights. A user who follows this page's guidance and models every OAuthRegistrationError as "the server refused; retry with different metadata" will handle the new failure mode incorrectly.\n\nWhy nothing else covers it. AGENTS.md's Documentation section requires: "When a change affects public API or user-visible behaviour, update the relevant page(s) under docs/ in the same PR ... Find the page covering the feature you touched in mkdocs.yml." The PR updates only docs/migration.md — the upgrade guide — while oauth-clients.md is the page the Clients nav points users at for the failure model, and it now contradicts shipped behavior introduced by this very PR. (The secondary condition — a 2xx body that is not valid client information → OAuthRegistrationError — existed pre-PR behind a # pragma: no cover, so the page was already slightly imprecise there; the usability gate is the genuinely new, tested semantic.)\n\nWhy this is a nit, not blocking. Nothing breaks at runtime: the exception type is unchanged, except OAuthFlowError: still covers everything as the page promises, and the fix is a one-sentence amendment. It does not justify blocking merge, but it is a repo-mandated doc update that should land.\n\nFix. Amend line 134 to something like: "OAuthRegistrationError means registration did not yield a usable client — the authorization server refused to register you, or it completed the registration with a token_endpoint_auth_method this flow cannot apply." The Recap bullet at line 145 only lists the subclass names, so it can stay, though a parallel touch-up would not hurt.
| client_id_issued_at: int | None = None | ||
| client_secret_expires_at: int | None = None | ||
| # SEP-2352: the issuer these credentials were registered with, recorded by the SDK (not an | ||
| # RFC 7591 field) to detect authorization-server migration and avoid cross-AS credential reuse. | ||
| issuer: str | None = None | ||
|
|
||
| @model_validator(mode="before") | ||
| @classmethod | ||
| def _explicit_null_reads_as_omitted(cls, data: object) -> object: | ||
| # Servers that serialize their client record dump unset members as null instead of | ||
| # omitting the keys; a null for a list field would otherwise fail the parse (and | ||
| # discard an already-provisioned registration). null and absent mean the same thing. | ||
| if isinstance(data, dict): | ||
| members = cast(dict[str, Any], data) | ||
| return {key: value for key, value in members.items() if value is not None} | ||
| return data | ||
|
|
There was a problem hiding this comment.
🟡 The record's placeholder tolerance is asymmetric between the two server dump styles it documents: _explicit_null_reads_as_omitted strips only None values and the ""-coercions cover only scalar fields, so an echoed "" for a list-typed member (redirect_uris, grant_types, response_types, contacts) still raises a list_type ValidationError — turning a 2xx into OAuthRegistrationError and discarding a registration whose client_id the server already minted, this PR's motivating failure mode. A one-line extension — treating a top-level "" like null in _explicit_null_reads_as_omitted — closes the gap; not a regression (main rejected these too), so nice-to-fix rather than blocking.
Extended reasoning...
The gap. OAuthClientInformationFull tolerates two documented classes of placeholder echoes from real authorization servers: null-dumping ("Servers that serialize their client record dump unset members as null", handled uniformly for every field by _explicit_null_reads_as_omitted at src/mcp/shared/auth.py:159-168) and empty-string-dumping ("Some authorization servers echo omitted metadata back as "" instead of dropping the key", src/mcp/shared/auth.py:17-20 — handled only for hand-picked scalar fields: the base's five URL fields via _empty_string_optional_url_to_none, plus token_endpoint_auth_method/application_type via _empty_string_optional_metadata_to_none at auth.py:170-175). No coercion covers a top-level "" on a list-typed member.\n\nStep-by-step proof (verified against a faithful replica of the record's validator structure, pydantic 2.13.4):\n\n1. A ""-placeholder server answers a DCR request with 201 and body {"client_id": "issued-id", "client_secret": "s", "redirect_uris": ""} (or "grant_types": "", "response_types": "", "contacts": "").\n2. handle_registration_response (src/mcp/client/auth/utils.py:296-306) sees the 2xx and calls OAuthClientInformationFull.model_validate_json(content).\n3. _explicit_null_reads_as_omitted runs first but its filter is value is not None — "" passes through untouched. No field validator applies (""-coercion is scalar-field-specific), so the value reaches the list[...] annotation and pydantic deterministically raises ValidationError (list_type: "Input should be a valid list"). Verified: all four list fields fail on "" while their null variants all parse.\n4. The except ValidationError branch converts it to OAuthRegistrationError("Invalid registration response: ...") — after the server has already minted the client_id, so the registration is discarded and orphaned server-side on every retry. That is the precise failure mode the PR was written to eliminate.\n\nWhy existing safeguards miss it. The null-stripper checks is not None only; the ""-coercion validators name scalar fields only; and the tolerance tests in tests/shared/test_auth.py cover "" just for token_endpoint_auth_method/application_type and null (not "") for the list fields — so the remaining strictness is silent.\n\nOn the objection that this is an implausible hypothetical / deliberate scope. It is true that the ""-placeholder servers actually observed emitted "" for scalar string/URL fields, and that no server has been seen sending "" in place of an array — that is why this is a nit and not blocking. But the same was true of the null variant: the motivating hostile body nulled only redirect_uris, yet when the null-for-list-fields gap was flagged mid-review the author generalized the fix uniformly across all fields via _explicit_null_reads_as_omitted rather than patching just the observed one. The "" handling stops at scalars, leaving the mechanism asymmetric between the two dump styles the record's own docstring and comments present as tolerated. Nor does the slippery-slope concern ({}, 0, false) apply: "" is the one placeholder the codebase itself documents as a real server behavior; the others are not. The tolerance boundary may be defensible, but nothing in the code or docstring records ""-on-a-list as deliberately out of scope — the docstring says broadly that "a member the server serializes as an explicit null is read as omitted" and "" "reads as absent", without the scalar-only caveat.\n\nImpact and severity. Not a regression — main rejected "" for these fields identically — and the trigger is a plausible-but-unobserved extension of a documented server class, so nothing that worked before breaks on merge. It is an incompleteness in the tolerance mechanism this PR builds, on lines this PR wrote; the same shape as the null-variant finding the author already accepted.\n\nFix. One line: extend _explicit_null_reads_as_omitted to also drop members whose value is "" (i.e. if value is not None and value != ""), or add ""-to-default mode="before" validators for the four list fields. The model-validator route keeps the two placeholder styles handled in one place; the existing scalar ""-coercions then become redundant but harmless.
| def check_registration_usable(client_info: OAuthClientInformationFull) -> None: | ||
| """Confirm a registration this flow completed is one it can act on. | ||
|
|
||
| RFC 7591 §3.2.1 lets the authorization server replace requested metadata and leaves it to | ||
| the client to "check the values in the response to determine if the registration is | ||
| sufficient for use". The one substitution that makes the minted credentials unusable is a | ||
| token-endpoint auth method the authorization-code flow cannot apply - one it does not | ||
| implement, or `private_key_jwt`, whose assertion this flow has no key to sign - so it is | ||
| judged here, before the record is persisted or any interactive authorization begins, | ||
| rather than surfacing later as an opaque failure at the token endpoint. | ||
|
|
||
| Raises: | ||
| OAuthRegistrationError: The server registered the client with a | ||
| `token_endpoint_auth_method` this flow cannot apply. | ||
| """ | ||
| if client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS: | ||
| raise OAuthRegistrationError( | ||
| "Authorization server registered the client with unsupported token_endpoint_auth_method " | ||
| f"{client_info.token_endpoint_auth_method!r}" | ||
| ) |
There was a problem hiding this comment.
🟡 check_registration_usable judges only the method name, so a 201 that assigns client_secret_basic or client_secret_post while issuing no client_secret (or an empty-string one — client_secret has no ""-coercion) passes the gate, is persisted, and the user completes the full interactive authorization before prepare_token_auth silently skips both secret branches and sends the token request unauthenticated, producing the exact authorize→token→401 invalid_client loop this gate's docstring says it prevents. Consider raising OAuthRegistrationError in check_registration_usable when the assigned method is client_secret_basic/client_secret_post and client_info.client_secret is falsy, so the failure surfaces before the record is persisted or the browser round-trip begins.
Extended reasoning...
The gap. check_registration_usable (src/mcp/client/auth/oauth2.py:78-97) tests only client_info.token_endpoint_auth_method not in _REGISTRATION_USABLE_TOKEN_ENDPOINT_AUTH_METHODS — the method name, not whether the credentials that method needs were actually issued. A secret-based method without a minted secret is a second RFC 7591 §3.2.1 substitution that makes the registration unusable, and it is judged nowhere: not at registration (the name is in the usable set) and not at prepare_token_auth (the method is known, so the falsy-secret fall-through is silent by design).
Step-by-step proof.
- The SDK registers; the server answers 201 with
{"client_id": "abc", "token_endpoint_auth_method": "client_secret_basic"}and noclient_secretmember — or, matching the motivating hostile-server class that echoes unset members as"", with"client_secret": "". The record parses fine: unliketoken_endpoint_auth_method/application_typeand the URL fields,client_secrethas no empty-string-to-None coercion, and""survives the null-stripping model validator. check_registration_usable(oauth2.py:93):"client_secret_basic"is in the usable set → the gate passes and the record is persisted viastorage.set_client_info.- The full interactive authorization runs — browser redirect, user consent, callback.
_exchange_token_authorization_code→prepare_token_auth(oauth2.py:246-262): the basic branch requiresself.client_info.client_id and self.client_info.client_secret— the secret is falsy, so it is skipped. The post branch does not match. The newelif auth_method not in _KNOWN_TOKEN_ENDPOINT_AUTH_METHODS(oauth2.py:259) does not fire either —client_secret_basicis a known method. The token request goes out with noAuthorizationheader and noclient_secret.- The AS, which registered the client for basic auth, rejects with 401
invalid_client→OAuthTokenError("Token exchange failed (401): …"). Because the record is already persisted, subsequent requests skip re-registration and loop authorize→token→fail against the same record.
Why the existing safeguards miss it. The gate's own docstring claims "the one substitution that makes the minted credentials unusable is a token-endpoint auth method the authorization-code flow cannot apply … judged here, before the record is persisted or any interactive authorization begins" — but this is a second such substitution, and it produces exactly the opaque post-authorization pathology the PR description says this mechanism eliminates ("looped into a full re-auth against the same record and failed at the token exchange anyway"). The new tests cover an unusable method name and a usable name with a secret; none covers a usable name without one.
Not a duplicate of cubic's P1. That comment concerns an omitted token_endpoint_auth_method being treated as no-auth instead of RFC 7591's client_secret_basic default. Its fix (normalize absent→basic) does nothing here — the method is explicitly present and recognized — and would wrongly reject public-client registrations that omit both method and secret. The gap here is that the usability judgment ignores whether the secret the method requires was minted.
Impact and severity. This is not a regression — v1's prepare_token_auth fell through identically — and the trigger requires a server that assigns a secret-based method while minting no secret, more off-spec than even the motivating hostile server (which did issue a secret). The failure is an explicit OAuthTokenError, not silent corruption. But the gate is new code in this PR, the incompleteness is in the contract those lines state for themselves, and the fix is small and local.
Fix. In check_registration_usable, additionally raise OAuthRegistrationError when the assigned method is client_secret_basic or client_secret_post and client_info.client_secret is falsy — before the record is persisted and before the user is sent through the browser.
OAuthClientInformationFull— the client's parse of the authorization server's Dynamic Client Registration response — inherited fromOAuthClientMetadata, the request the client sends. That typed the response as though it had to be a request this SDK would send, so any registered value the server substituted became aValidationErroron a 2xx. This splits the two into siblings so the record parses what a server may legitimately echo.Motivation and Context
We received a report of an authorization server whose registration response returned an
application_typevalue outside{"web", "native"}. The server answered 201 and provisioned the client; the SDK then raisedInvalid registration response: … Input should be 'web' or 'native' [type=literal_error]out of the auth flow, discarding a registration whoseclient_idhad already been minted (and orphaning it server-side on every retry).application_typeis never read again anywhere in the SDK — it's a purely cosmetic field killing an otherwise-valid registration.This isn't specific to that field. The response model is-a request model, so every tight type on the request re-arms the same failure on the response. The same closed-typing had already been widened piecemeal for
*_supportedmetadata,grant_types,response_types, and empty-string URLs — this is the fifth instance of one class, and the class is the inheritance.Spec. RFC 7591 §3.2.1: the server "MAY reject or replace any of the client's requested metadata values submitted during the registration and substitute them with suitable values", and "The client or developer can check the values in the response to determine if the registration is sufficient for use" — a substituted value is a judgment call for the client, not a parse failure. The MCP spec (SEP-837) constrains only what the client sends (
MUST specify an appropriate application_type); it says nothing about the response. The server here is also off-spec — OIDC Registration §2 defines onlynative/web— but that doesn't oblige the client to abort a successful registration over a field it doesn't use. Both are true.Cross-SDK. Every other MCP SDK constrains what it sends and parses
application_typepermissively — the TypeScript SDK types itz.string().optional()with a comment giving exactly this reasoning, go/rust/csharp/ruby likewise. This SDK was the only one that fails on it.What changed
OAuthClientMetadata(request) andOAuthClientInformationFull(registered-client record) are now siblings over a sharedOAuthClientMetadataBaseholding only the fields that are genuinely identical between them:application_type="browser"; the MCPMUSTon the wire is unchanged and enforced by the type.application_type/token_endpoint_auth_methodarestr | None(an echoed""reads as absent, matching the existing URL-field coercion),grant_typesislist[str],redirect_urismay be absent or empty.client_idis now required — RFC 7591 §3.2.1 makes it mandatory in the response, and a "registered client" record without one was never meaningful (a body without it should not parse as success).OAuthRegistrationErrornaming the method when the registration completes, before the record is persisted or any interactive authorization begins (per §3.2.1's "sufficient for use" model).prepare_token_authreports the same for a stored/pre-registered record. The recognized set is derived from oneTokenEndpointAuthMethodtype viaget_args, so send-side and recognize-side cannot drift (private_key_jwtremains recognized —PrivateKeyJWTOAuthProvider's refresh path is unaffected).application_type, so the SDK's own AS reported the default in place of a client's"web". The echo is now built from the validated request'smodel_dump(), and a test pins that every request field exists on the record — a field can no longer be silently omitted.except ValidationErrorinhandle_registration_responsecarried# pragma: no cover— the invalid-response branch was asserted unreachable, which is why this shipped untested. It's now covered and chains the cause (raise … from e).How Has This Been Tested?
null/emptyapplication_type, an unimplementedtoken_endpoint_auth_method, extragrant_types, emptyredirect_uris; the request still rejects an off-setapplication_typeand requiresredirect_uris.handle_registration_responsepath (the layer the failure occurred in) with a substituted body, and with a body that isn't client information at all./registerreturning a substituted 201 completes the full flow (tool call succeeds); a 201 assigning an unusable auth method surfacesOAuthRegistrationErrorwith no/authorizeor/tokenrequest and nothing persisted.application_typefor bothwebandnative."confidential",nullredirect URIs, extra grants) now completes with HTTP 200 wheremaindies with the exactliteral_errorabove; a""auth method reads as absent; aclient_secret_jwtassignment fails at registration before any browser round-trip.strict-no-coverclean.Breaking Changes
Yes — documented in
docs/migration.mdalongside the existing SEP-837 entry.OAuthClientInformationFullis no longer a subclass ofOAuthClientMetadata. Code relying onisinstance(info, OAuthClientMetadata), or passing a record where a request is annotated, must reference the record type directly.validate_scope()/validate_redirect_uri()moved with the record (they're what server code calls) and are no longer onOAuthClientMetadata.application_type/token_endpoint_auth_method→str | None,grant_types→list[str],redirect_urisoptional,client_idrequired. Reads are unaffected.ValidationErrorduring parsing.Conformance: no scenario puts these values in a 201 body, and SEP-837 defines no response-side requirement, so a send-strict / parse-tolerant client passes every existing check unchanged.
Types of changes
Checklist
Additional context
Two things I considered and want to name:
invalid_client.OAuthAuthorizationServerProvider.get_client,authorize). The split stops one class short of separating those, which is whyredirect_uriselements stayAnyUrl(the AS compares them) and why hand-built server records lose a couple of constructor invariants they previously borrowed from the request. Requiringclient_idrestores the one that matters; a fuller server-record / client-response separation is the next seam, deliberately left out of this change.AI Disclaimer