Skip to content

Conversation

@zzstoatzz
Copy link
Contributor

@zzstoatzz zzstoatzz commented Nov 14, 2025

overview

this PR adds OAuth 2.1 support to the atproto Python SDK, implementing the ATProto OAuth specification. this enables secure, password-free authentication for ATProto applications.


architecture & design decisions

new package: atproto_oauth

created a standalone package following the SDK's existing modular structure:

packages/atproto_oauth/
├── client.py          # main OAuth client
├── dpop.py            # DPoP proof generation (ES256)
├── pkce.py            # PKCE challenge/verifier (S256)
├── metadata.py        # server discovery
├── security.py        # SSRF protection, URL validation
├── models.py          # dataclasses for state/sessions
├── stores/            # pluggable storage backends
│   ├── base.py        # abstract interfaces
│   └── memory.py      # in-memory implementation
└── README.md          # comprehensive usage guide

why separate package? keeps OAuth concerns isolated, allows applications to use only what they need, and follows the pattern of atproto_identity, atproto_crypto, etc.

OAuth 2.1 compliance details

authorization code flow with PKCE

spec reference: RFC 7636, ATProto OAuth §3.2

  • only S256 challenge method (SHA256)
  • verifier: 128 bytes (within spec's 43-128 range)
  • challenge generated on authorization start, verified on token exchange

implementation: packages/atproto_oauth/pkce.py:25-43

def generate_pair(cls, verifier_length: int = 128) -> tuple[str, str]:
    verifier = cls.generate_verifier(verifier_length)
    challenge = base64.urlsafe_b64encode(
        hashlib.sha256(verifier.encode()).digest()
    ).decode().rstrip('=')
    return verifier, challenge

DPoP (demonstrating proof-of-possession)

spec reference: RFC 9449, ATProto OAuth §4.2

  • ES256 algorithm (ECDSA with P-256 curve)
  • unique jti per request (16-byte token)
  • includes ath claim (access token hash) when presenting tokens
  • automatic nonce rotation on server challenge

implementation: packages/atproto_oauth/dpop.py:94-143

nuance: DPoP nonces are tracked separately for auth server vs PDS, as they can issue different nonces. the client automatically handles nonce rotation when receiving use_dpop_nonce errors.

# packages/atproto_oauth/client.py:332-337
if DPoPManager.is_dpop_nonce_error(response):
    new_nonce = DPoPManager.extract_nonce_from_response(response)
    if new_nonce:
        session.dpop_pds_nonce = new_nonce
        # retry with new nonce...

pushed authorization requests (PAR)

spec reference: RFC 9126, ATProto OAuth §3.1.1

  • all authorization parameters sent to PAR endpoint first
  • returns request_uri used in actual authorization
  • prevents parameter injection and ensures server validates everything upfront

implementation: packages/atproto_oauth/client.py:204-241

nuance: PAR endpoint validation is mandatory - we check require_pushed_authorization_requests: true in server metadata (security.py:171-173).

ATProto-specific implementation

DID-based identity resolution

spec reference: ATProto OAuth §2.2

the OAuth flow begins with a handle or DID, not a static service URL:

  1. resolve handle → DID (via atproto_identity)
  2. resolve DID document → extract PDS URL
  3. discover auth server from PDS

implementation: packages/atproto_oauth/client.py:76-95

# resolve handle to DID
resolved_did = await self._id_resolver.handle.resolve(handle_or_did)

# get PDS from DID document
atproto_data = await self._id_resolver.did.resolve_atproto_data(did)
pds_url = atproto_data.pds

authorization server discovery

spec reference: ATProto OAuth §2.3

  • fetch /.well-known/oauth-protected-resource from PDS
  • extract authorization_servers array
  • fetch /.well-known/oauth-authorization-server from auth server

implementation: packages/atproto_oauth/metadata.py:10-131

nuance: we validate the auth server URL matches the issuer field in metadata to prevent issuer confusion attacks (security.py:117-132).

client identification

spec reference: ATProto OAuth §2.4

  • client_id must be HTTPS URL (or localhost for dev)
  • points to client metadata JSON document
  • server must support client_id_metadata_document_supported: true

implementation: client metadata hosting is left to applications (varies by deployment). the SDK validates server support (security.py:179-181).

security features

SSRF protection

spec reference: ATProto OAuth Security Considerations

all URLs validated before fetching:

  • blocks private IP ranges (10.x, 172.16-31.x, 192.168.x)
  • blocks cloud metadata endpoints (169.254.169.254, metadata.google.internal)
  • enforces HTTPS except localhost

implementation: packages/atproto_oauth/security.py:22-62

state parameter for CSRF protection

  • cryptographically random state tokens (22-byte urlsafe)
  • stored with TTL (10 minutes)
  • single-use (deleted after consumption)

implementation: packages/atproto_oauth/client.py:159-167

storage abstraction

pluggable stores

two abstract base classes for different storage needs:

StateStore - temporary OAuth state during authorization flow:

  • short-lived (10 minute TTL)
  • high write throughput during auth flows
  • requires atomic operations

SessionStore - long-lived OAuth sessions:

  • multi-week lifespan
  • requires encryption for token storage
  • may need locking for token refresh

implementation: packages/atproto_oauth/stores/base.py

built-in memory stores provided for development. production use requires implementing persistent stores.


production case study: plyr.fm

plyr.fm uses this OAuth implementation in production with custom storage backends.

postgres state store

implements StateStore with postgres for multi-instance deployments:

key decisions:

  • serializes DPoP private keys to PEM for database storage
  • lazy cleanup pattern: expired states cleaned on save/get operations
  • follows same TTL as memory store (10 minutes)

implementation: plyr.fm/backend/src/backend/_internal/oauth_stores/postgres.py

encrypted session storage

instead of using SessionStore directly, plyr.fm stores OAuth sessions in postgres with encryption:

approach:

  • Fernet symmetric encryption for OAuth tokens at rest
  • serializes entire OAuthSession to JSON, then encrypts
  • stores in UserSession table with 2-week expiration
  • decryption failure = invalid session (handles key rotation gracefully)

why not use SessionStore? needed tighter integration with app's session model (user profiles, artist status, etc.) and wanted control over encryption strategy.

implementation: plyr.fm/backend/src/backend/_internal/auth.py:63-133

# encrypt OAuth session before storage
encrypted_data = _encrypt_data(json.dumps(oauth_session))

user_session = UserSession(
    session_id=session_id,
    did=did,
    handle=handle,
    oauth_session_data=encrypted_data,
    expires_at=datetime.now(UTC) + timedelta(days=14),
)
token refresh pattern

plyr.fm doesn't currently implement automatic token refresh, relying on the 2-week session expiration. when needed, refresh would follow this pattern:

# deserialize stored session
oauth_session = OAuthSession(**stored_data)

# refresh tokens
refreshed_session = await oauth_client.refresh_session(oauth_session)

# re-encrypt and store
await update_session_tokens(session_id, refreshed_session)

testing

unit tests: 15 tests covering PKCE and DPoP functionality

uv run pytest tests/test_oauth_*.py -v

flask demo: working example application

uv run python examples/oauth_flask_demo/app.py

what's next (not in this PR)

this PR provides the core OAuth client. future enhancements could include:

  • integration with atproto_client.Client - add login_oauth() methods
  • additional store implementations - SQLite, Redis, etc.
  • automatic token refresh - background refresh before expiration
  • more comprehensive integration tests - currently only unit tests

feedback very welcome on the current implementation before exploring these additions.

zzstoatzz and others added 7 commits November 14, 2025 02:00
Implements complete OAuth 2.1 support following the ATProto OAuth specification
(https://atproto.com/specs/oauth), building on concepts from PR MarshalX#589.

Features:
- Full OAuth 2.1 authorization code flow with PKCE (S256) and DPoP (ES256)
- Pushed Authorization Requests (PAR)
- DID-based authentication with handle/DID resolution
- PDS endpoint and authorization server discovery
- Automatic DPoP nonce rotation
- Client assertions for confidential clients
- Pluggable state and session stores
- SSRF protection and security best practices

Components:
- New package: atproto_oauth with complete OAuth client
- PKCE manager for code challenge generation
- DPoP manager for JWT proof generation
- Security utilities with URL validation
- Metadata discovery for auth servers and PDS
- Abstract and in-memory stores for state/sessions
- 12 unit tests (all passing)
- Flask reference implementation
- Comprehensive documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
- Update exceptions to inherit from AtProtocolError for consistency
- Remove unused DPoPNonceError exception
- Clean up comment wording in revoke_session
- Add spec compliance documentation
- Update ruff ignore rules for intentional patterns

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
**Bug 1: Identity Resolution API**
- Fixed incorrect use of identity resolver API (client.py:77)
- Was trying to unpack tuple from did.resolve() which only returns Optional[DidDocument]
- Now properly resolves handles to DIDs first, then resolves DID document
- Uses resolve_atproto_data() to get PDS endpoint and handle

**Bug 2: DPoP Signature Format**
- Fixed ES256 signature encoding from DER to IEEE P1363 format (dpop.py:_sign_jwt)
- JWT signatures must use raw r|s concatenated (64 bytes), not DER encoding
- Added decode_dss_signature to convert signature format
- This was causing "invalid_dpop_proof" errors from auth servers

**Bug 3: DPoP htu Field**
- Fixed htu field to strip query and fragment per RFC 9449 (dpop.py:create_proof)
- Was including full URL with query params, violating DPoP spec
- Now properly parses URL and strips query/fragment

**Added Tests:**
- test_dpop_signature_format: Verifies 64-byte IEEE P1363 format
- test_dpop_htu_strips_query_and_fragment: Tests URL stripping
- test_dpop_jwk_format: Validates JWK structure in header

All 15 tests now passing (was 12 before).

Validated against official TypeScript SDK which had same htu bug.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
moved design docs and spec compliance to issue discussion. keeping implementation details in package README where they belong.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
- add missing type annotations for route handlers
- fix import ordering (flask imports after third-party)
- use double quotes for multiline strings
- add noqa comments for acceptable Exception catching in demo code
- add noqa for debug=True (acceptable in demo context)
- remove unused imports

all ruff checks now pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
- run ruff format on Flask demo app
- generate sphinx documentation for atproto_oauth package
- add atproto_oauth to docs module index

resolves codegen_check and ruff CI failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <[email protected]>
zzstoatzz and others added 4 commits December 12, 2025 21:58
#6)

per the ATProto OAuth spec, the aud claim in client assertion JWTs must
be the Authorization Server's issuer URL, not the token endpoint URL.

this was causing "unexpected aud claim value" errors when using
confidential clients with plyr.fm.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <[email protected]>
the ATProto OAuth spec requires client assertions to include the kid
(key ID) in the JWT header so the PDS knows which public key to use
for verification.

changes:
- add client_secret_kid parameter to OAuthClient.__init__
- include kid in JWT header in _create_client_assertion()
- add validation to require kid when using confidential client

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <[email protected]>
The PDS expands `include:namespace.permissionSet` scopes into
`repo?collection=...` format. The previous strict string comparison
failed when using permission sets.

Added `_scopes_are_equivalent()` to semantically compare scopes,
handling both direct `repo:` scopes and expanded permission sets.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
* feat: add prompt parameter to OAuth start_authorization

Adds support for the OAuth `prompt` parameter in the authorization flow,
enabling clients to control authorization server behavior:

- `login`: Force re-authentication, ignoring remembered sessions
- `select_account`: Show account selection UI instead of auto-selecting
- `consent`: Force consent screen even if previously approved
- `none`: Silent authentication (fails if interaction required)

This is essential for multi-account experiences where users need to
authenticate with a different account without the PDS auto-approving
based on a remembered session.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: extract PromptType constant and add unit tests

- Extract inline Literal type to module-level PromptType constant
- Export PromptType from atproto_oauth package
- Add comprehensive unit tests for prompt parameter:
  - Test PromptType values and export
  - Test prompt is passed through to _send_par_request
  - Test prompt is included in PAR params when provided
  - Test prompt is omitted from PAR params when None

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

---------

Co-authored-by: Claude Opus 4.5 <[email protected]>
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