feat(oauth)!: hardened OAuth client with confidential-client support #132

Closed
opened 2026-09-24 12:39:09 +00:00 by Grandiras · 1 comment
Owner

Part of the v0.7.0 update cycle (wave 6). Harden the core OAuth client and add confidential-client support. The Server-side hosting move is a separate issue.

Transport and validation

  • One PostFormWithDpopAsync<T> for PAR, token, refresh and revoke. It rebuilds the form on each attempt, retries once on use_dpop_nonce, throws OAuthException on error bodies, remembers the nonce, and disposes responses. This replaces three copies; today no response is disposed and the PAR nonce isn't reused for the token exchange.
  • A process-wide DPoP nonce cache per origin, shared by XrpcClient, per-request clients, SpaceReader and the OAuth client. Today every per-request client pays an extra 401 use_dpop_nonce round trip on its first call.
  • Validate refresh responses: sub equals the session DID, scope contains atproto, and the DID's authorization server is still the issuer.
  • Tighten authorization-server metadata validation:
    • exact issuer origin (port and path included);
    • every endpoint an absolute https URL with no query or fragment;
    • require_pushed_authorization_requests, authorization_response_iss_parameter_supported and client_id_metadata_document_supported;
    • the protected-resource resource value matches the PDS.
  • Sign-in can start from an entryway/AS URL that serves no protected-resource metadata (upstream #4896).
  • Discovery: cache metadata per origin for a short TTL, and reuse the DID document resolved at start during completion.

Confidential clients

  • private_key_jwt: an ES256 client_assertion (iss = sub = client_id, aud = issuer, jti, short exp) on PAR, token, refresh and revoke. Persist the key id per session. Client metadata carries jwks/jwks_uri. Today the docs claim this works, but no assertion is ever sent, so sessions are capped at the public-client lifetime (2 weeks vs 180 days).

State and scopes

  • IOAuthStateStore for pending authorizations: in-memory by default with per-client limits and TTL, plus an IDistributedCache option. It replaces the global cap of 100, which lets anyone block every login for 10 minutes and breaks logins across restarts and instances. StartAuthorizationAsync returns a record instead of a tuple.
  • Scopes:
    • obsolete IdentityAction/identity:*?action=, which servers reject;
    • validate rpc/include aud as * or did#fragment;
    • add presets built on permission sets;
    • document transition:* as legacy;
    • use the conventional client-id path /oauth-client-metadata.json in the docs.
  • Remove dead API:
    • GetPendingAuthorizationState + OAuthAuthorizationState, a write-only export of the PKCE verifier;
    • AtProtoClientOptions.OAuth and OAuthOptions.DefaultPdsUrl, which nothing reads;
    • the unused nonce fields on the token data.

CHANGELOG Added (confidential clients, the state store), Security (validation, the state DoS), Breaking changes (removed or changed API).

Part of the v0.7.0 update cycle (wave 6). Harden the core OAuth client and add confidential-client support. The Server-side hosting move is a separate issue. ## Transport and validation - [ ] One `PostFormWithDpopAsync<T>` for PAR, token, refresh and revoke. It rebuilds the form on each attempt, retries once on `use_dpop_nonce`, throws `OAuthException` on error bodies, remembers the nonce, and disposes responses. This replaces three copies; today no response is disposed and the PAR nonce isn't reused for the token exchange. - [ ] A **process-wide DPoP nonce cache per origin**, shared by `XrpcClient`, per-request clients, `SpaceReader` and the OAuth client. Today every per-request client pays an extra `401 use_dpop_nonce` round trip on its first call. - [ ] Validate refresh responses: `sub` equals the session DID, `scope` contains `atproto`, and the DID's authorization server is still the issuer. - [ ] Tighten authorization-server metadata validation: - exact issuer origin (port and path included); - every endpoint an absolute `https` URL with no query or fragment; - `require_pushed_authorization_requests`, `authorization_response_iss_parameter_supported` and `client_id_metadata_document_supported`; - the protected-resource `resource` value matches the PDS. - [ ] Sign-in can start from an entryway/AS URL that serves no protected-resource metadata (upstream #4896). - [ ] Discovery: cache metadata per origin for a short TTL, and reuse the DID document resolved at start during completion. ## Confidential clients - [ ] `private_key_jwt`: an ES256 `client_assertion` (`iss` = `sub` = client_id, `aud` = issuer, `jti`, short `exp`) on PAR, token, refresh and revoke. Persist the key id per session. Client metadata carries `jwks`/`jwks_uri`. Today the docs claim this works, but no assertion is ever sent, so sessions are capped at the public-client lifetime (2 weeks vs 180 days). ## State and scopes - [ ] `IOAuthStateStore` for pending authorizations: in-memory by default with per-client limits and TTL, plus an `IDistributedCache` option. It replaces the global cap of 100, which lets anyone block every login for 10 minutes and breaks logins across restarts and instances. `StartAuthorizationAsync` returns a record instead of a tuple. - [ ] Scopes: - obsolete `IdentityAction`/`identity:*?action=`, which servers reject; - validate `rpc`/`include` `aud` as `*` or `did#fragment`; - add presets built on permission sets; - document `transition:*` as legacy; - use the conventional client-id path `/oauth-client-metadata.json` in the docs. - [ ] Remove dead API: - `GetPendingAuthorizationState` + `OAuthAuthorizationState`, a write-only export of the PKCE verifier; - `AtProtoClientOptions.OAuth` and `OAuthOptions.DefaultPdsUrl`, which nothing reads; - the unused nonce fields on the token data. CHANGELOG `Added` (confidential clients, the state store), `Security` (validation, the state DoS), `Breaking changes` (removed or changed API).
Author
Owner

Items deferred here by earlier PRs:

  • #120: The OAuth metadata fetches now follow the identity fetch policy (HTTPS, public addresses, no redirects, 64 KiB cap, OAuthOptions.AllowPrivateNetworks / MetadataHttpClient). PAR, token and revocation requests still go through the caller's HttpClient with no address check, to endpoints named in the validated AS metadata. Apply the same policy, or at least an https + public-address check, to those endpoints.
  • #124:
    • Validate the refresh response: sub must equal the session DID, scope must contain atproto, and the issuer must still be the DID's AS.
    • Add the process-wide per-origin DPoP nonce cache.
    • Move authorization and code exchange onto the shared form helper (PostFormWithDpopAsync) that refresh and revocation already use.
Items deferred here by earlier PRs: - **#120:** The OAuth *metadata* fetches now follow the identity fetch policy (HTTPS, public addresses, no redirects, 64 KiB cap, `OAuthOptions.AllowPrivateNetworks` / `MetadataHttpClient`). **PAR, token and revocation requests** still go through the caller's `HttpClient` with no address check, to endpoints named in the validated AS metadata. Apply the same policy, or at least an https + public-address check, to those endpoints. - **#124:** - Validate the refresh response: `sub` must equal the session DID, `scope` must contain `atproto`, and the issuer must still be the DID's AS. - Add the process-wide per-origin DPoP nonce cache. - Move authorization and code exchange onto the shared form helper (`PostFormWithDpopAsync`) that refresh and revocation already use.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/ATProto.NET#132
No description provided.