feat: add confidential OAuth client support for longer-lived sessions (#578)

* feat: add confidential OAuth client support for longer-lived sessions

adds support for ATProto OAuth confidential clients using private_key_jwt
authentication. when OAUTH_JWK is configured, the client authenticates
with a cryptographic key, earning 180-day refresh tokens (vs 2-week for
public clients).

changes:
- add OAUTH_JWK setting to AtprotoSettings for ES256 private key
- update OAuthClient to pass client_secret_key when configured
- add /.well-known/jwks.json endpoint for public key discovery
- update /oauth-client-metadata.json to include confidential client fields
- add scripts/gen_oauth_jwk.py utility to generate keys
- add tests for is_confidential_client() and get_public_jwks()

to enable:
1. run: uv run python scripts/gen_oauth_jwk.py
2. add output to .env as OAUTH_JWK='...'

closes #577

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* address review comments: remove deferred imports, add type annotations

- move imports to top level in main.py (is_confidential_client, get_public_jwks, HTTPException)
- add type annotation to metadata variable: dict[str, Any]
- update return types to dict[str, Any]

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add OAuth confidential client documentation

explains what confidential clients are, why they matter for plyr.fm,
and how the implementation works. includes sources and key rotation
guidance.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add token refresh mechanism and migration notes

- explain how token refresh works (trigger, detection, refresh, retry)
- document what gets refreshed (access vs refresh tokens)
- add observability notes (logfire log messages)
- document migration impact for existing sessions
- clarify that existing tokens can't be upgraded (2-week refresh limit)
- note that only 3 internal dev tokens affected in production

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub 8fc9fecf 7369c1d0

Changed files
+386 -7
backend
src
backend
tests
docs
scripts
+94 -1
backend/src/backend/_internal/auth.py
··· 5 5 import secrets 6 6 from dataclasses import dataclass 7 7 from datetime import UTC, datetime, timedelta 8 - from typing import Annotated, Any 8 + from typing import TYPE_CHECKING, Annotated, Any 9 9 10 10 from atproto_oauth import OAuthClient 11 11 from atproto_oauth.stores.memory import MemorySessionStore 12 12 from cryptography.fernet import Fernet 13 + from cryptography.hazmat.primitives.asymmetric import ec 14 + from cryptography.hazmat.primitives.serialization import load_pem_private_key 13 15 from fastapi import Cookie, Header, HTTPException 16 + from jose import jwk 14 17 from sqlalchemy import select 15 18 16 19 from backend._internal.oauth_stores import PostgresStateStore 17 20 from backend.config import settings 18 21 from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession 19 22 from backend.utilities.database import db_session 23 + 24 + if TYPE_CHECKING: 25 + from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey 20 26 21 27 logger = logging.getLogger(__name__) 22 28 ··· 69 75 _state_store = PostgresStateStore() 70 76 _session_store = MemorySessionStore() 71 77 78 + # confidential client key (loaded lazily) 79 + _client_secret_key: "EllipticCurvePrivateKey | None" = None 80 + _client_secret_key_loaded = False 81 + 82 + 83 + def _load_client_secret_key() -> "EllipticCurvePrivateKey | None": 84 + """load EC private key from OAUTH_JWK setting for confidential client. 85 + 86 + the key is expected to be a JSON-serialized JWK with ES256 (P-256) key. 87 + returns None if OAUTH_JWK is not configured (public client mode). 88 + """ 89 + global _client_secret_key, _client_secret_key_loaded 90 + 91 + if _client_secret_key_loaded: 92 + return _client_secret_key 93 + 94 + _client_secret_key_loaded = True 95 + 96 + if not settings.atproto.oauth_jwk: 97 + logger.info("OAUTH_JWK not configured, using public OAuth client") 98 + return None 99 + 100 + try: 101 + # parse JWK JSON 102 + jwk_data = json.loads(settings.atproto.oauth_jwk) 103 + 104 + # convert JWK to PEM format using python-jose 105 + key_obj = jwk.construct(jwk_data, algorithm="ES256") 106 + pem_bytes = key_obj.to_pem() 107 + 108 + # load as cryptography key 109 + _client_secret_key = load_pem_private_key(pem_bytes, password=None) 110 + 111 + if not isinstance(_client_secret_key, ec.EllipticCurvePrivateKey): 112 + raise ValueError("OAUTH_JWK must be an EC key (ES256)") 113 + 114 + logger.info("loaded confidential OAuth client key from OAUTH_JWK") 115 + return _client_secret_key 116 + 117 + except Exception as e: 118 + logger.error(f"failed to load OAUTH_JWK: {e}") 119 + raise RuntimeError(f"invalid OAUTH_JWK configuration: {e}") from e 120 + 121 + 122 + def get_public_jwks() -> dict | None: 123 + """get public JWKS for the /.well-known/jwks.json endpoint. 124 + 125 + returns None if confidential client is not configured. 126 + """ 127 + if not settings.atproto.oauth_jwk: 128 + return None 129 + 130 + try: 131 + # parse private JWK 132 + jwk_data = json.loads(settings.atproto.oauth_jwk) 133 + 134 + # construct key and extract public components 135 + key_obj = jwk.construct(jwk_data, algorithm="ES256") 136 + public_jwk = key_obj.to_dict() 137 + 138 + # remove private key components, keep only public 139 + public_jwk.pop("d", None) # private key scalar 140 + 141 + # ensure required fields for public key 142 + public_jwk["use"] = "sig" 143 + public_jwk["alg"] = "ES256" 144 + 145 + return {"keys": [public_jwk]} 146 + 147 + except Exception as e: 148 + logger.error(f"failed to generate public JWKS: {e}") 149 + return None 150 + 151 + 152 + def is_confidential_client() -> bool: 153 + """check if confidential OAuth client is configured.""" 154 + return bool(settings.atproto.oauth_jwk) 155 + 72 156 73 157 def get_oauth_client(include_teal: bool = False) -> OAuthClient: 74 158 """create an OAuth client with the appropriate scopes. 75 159 76 160 at ~17 OAuth flows/day, instantiation cost is negligible. 77 161 this eliminates the need for pre-instantiated bifurcated clients. 162 + 163 + if OAUTH_JWK is configured, creates a confidential client with 164 + private_key_jwt authentication (180-day refresh tokens). 165 + otherwise creates a public client (2-week refresh tokens). 78 166 """ 79 167 scope = ( 80 168 settings.atproto.resolved_scope_with_teal( ··· 83 171 if include_teal 84 172 else settings.atproto.resolved_scope 85 173 ) 174 + 175 + # load confidential client key if configured 176 + client_secret_key = _load_client_secret_key() 177 + 86 178 return OAuthClient( 87 179 client_id=settings.atproto.client_id, 88 180 redirect_uri=settings.atproto.redirect_uri, 89 181 scope=scope, 90 182 state_store=_state_store, 91 183 session_store=_session_store, 184 + client_secret_key=client_secret_key, 92 185 ) 93 186 94 187
+5
backend/src/backend/config.py
··· 357 357 validation_alias="OAUTH_ENCRYPTION_KEY", 358 358 description="Fernet encryption key for OAuth data at rest", 359 359 ) 360 + oauth_jwk: str = Field( 361 + default="", 362 + validation_alias="OAUTH_JWK", 363 + description="JSON-serialized ES256 private key for confidential OAuth client. Generate with: uv run python scripts/gen_oauth_jwk.py", 364 + ) 360 365 361 366 @computed_field 362 367 @property
+38 -6
backend/src/backend/main.py
··· 7 7 from contextlib import asynccontextmanager 8 8 from typing import Any 9 9 10 - from fastapi import FastAPI, Request, WebSocket 10 + from fastapi import FastAPI, HTTPException, Request, WebSocket 11 11 from fastapi.middleware.cors import CORSMiddleware 12 12 from fastapi.responses import ORJSONResponse 13 13 from slowapi import _rate_limit_exceeded_handler ··· 24 24 ) 25 25 26 26 from backend._internal import notification_service, queue_service 27 + from backend._internal.auth import get_public_jwks, is_confidential_client 27 28 from backend._internal.background import background_worker_lifespan 28 29 from backend.api import ( 29 30 account_router, ··· 239 240 240 241 241 242 @app.get("/oauth-client-metadata.json") 242 - async def client_metadata() -> dict: 243 - """serve OAuth client metadata.""" 244 - # Extract base URL from client_id for client_uri 243 + async def client_metadata() -> dict[str, Any]: 244 + """serve OAuth client metadata. 245 + 246 + returns metadata for public or confidential client depending on 247 + whether OAUTH_JWK is configured. 248 + """ 249 + # extract base URL from client_id for client_uri 245 250 client_uri = settings.atproto.client_id.replace("/oauth-client-metadata.json", "") 246 251 247 - return { 252 + metadata: dict[str, Any] = { 248 253 "client_id": settings.atproto.client_id, 249 254 "client_name": settings.app.name, 250 255 "client_uri": client_uri, ··· 254 259 ), 255 260 "grant_types": ["authorization_code", "refresh_token"], 256 261 "response_types": ["code"], 257 - "token_endpoint_auth_method": "none", 258 262 "application_type": "web", 259 263 "dpop_bound_access_tokens": True, 260 264 } 265 + 266 + if is_confidential_client(): 267 + # confidential client: use private_key_jwt authentication 268 + # this gives us 180-day refresh tokens instead of 2-week 269 + metadata["token_endpoint_auth_method"] = "private_key_jwt" 270 + metadata["token_endpoint_auth_signing_alg"] = "ES256" 271 + metadata["jwks_uri"] = f"{client_uri}/.well-known/jwks.json" 272 + else: 273 + # public client: no authentication 274 + metadata["token_endpoint_auth_method"] = "none" 275 + 276 + return metadata 277 + 278 + 279 + @app.get("/.well-known/jwks.json") 280 + async def jwks_endpoint() -> dict[str, Any]: 281 + """serve public JWKS for confidential client authentication. 282 + 283 + returns 404 if confidential client is not configured. 284 + """ 285 + jwks = get_public_jwks() 286 + if jwks is None: 287 + raise HTTPException( 288 + status_code=404, 289 + detail="JWKS not available - confidential client not configured", 290 + ) 291 + 292 + return jwks
+61
backend/tests/test_auth.py
··· 2 2 3 3 import json 4 4 from datetime import UTC, datetime, timedelta 5 + from unittest.mock import patch 5 6 6 7 from sqlalchemy import select 7 8 from sqlalchemy.ext.asyncio import AsyncSession ··· 13 14 create_exchange_token, 14 15 create_session, 15 16 delete_session, 17 + get_public_jwks, 16 18 get_session, 19 + is_confidential_client, 17 20 update_session_tokens, 18 21 ) 19 22 from backend.models import ExchangeToken, UserSession ··· 326 329 actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC) 327 330 diff = abs((expected_expiry - actual_expiry).total_seconds()) 328 331 assert diff < 60 # within 1 minute 332 + 333 + 334 + # confidential client tests 335 + 336 + 337 + def test_is_confidential_client_false_by_default(): 338 + """verify is_confidential_client returns False when OAUTH_JWK not set.""" 339 + # tests run without OAUTH_JWK configured 340 + assert is_confidential_client() is False 341 + 342 + 343 + def test_is_confidential_client_true_when_configured(): 344 + """verify is_confidential_client returns True when OAUTH_JWK is set.""" 345 + test_jwk = '{"kty":"EC","crv":"P-256","x":"test","y":"test","d":"test"}' 346 + 347 + with patch("backend._internal.auth.settings.atproto.oauth_jwk", test_jwk): 348 + assert is_confidential_client() is True 349 + 350 + 351 + def test_get_public_jwks_returns_none_without_config(): 352 + """verify get_public_jwks returns None when OAUTH_JWK not configured.""" 353 + # tests run without OAUTH_JWK configured 354 + assert get_public_jwks() is None 355 + 356 + 357 + def test_get_public_jwks_returns_public_key(): 358 + """verify get_public_jwks returns JWKS with public key only.""" 359 + # generate a test JWK 360 + from cryptography.hazmat.primitives import serialization 361 + from cryptography.hazmat.primitives.asymmetric import ec 362 + from jose import jwk as jose_jwk 363 + 364 + # generate test key 365 + private_key = ec.generate_private_key(ec.SECP256R1()) 366 + pem_bytes = private_key.private_bytes( 367 + encoding=serialization.Encoding.PEM, 368 + format=serialization.PrivateFormat.PKCS8, 369 + encryption_algorithm=serialization.NoEncryption(), 370 + ) 371 + key_obj = jose_jwk.construct(pem_bytes, algorithm="ES256") 372 + test_jwk = json.dumps(key_obj.to_dict()) 373 + 374 + with patch("backend._internal.auth.settings.atproto.oauth_jwk", test_jwk): 375 + jwks = get_public_jwks() 376 + 377 + assert jwks is not None 378 + assert "keys" in jwks 379 + assert len(jwks["keys"]) == 1 380 + 381 + public_key = jwks["keys"][0] 382 + # should NOT have private key component 383 + assert "d" not in public_key 384 + # should have public key components 385 + assert "x" in public_key 386 + assert "y" in public_key 387 + assert public_key["kty"] == "EC" 388 + assert public_key["alg"] == "ES256" 389 + assert public_key["use"] == "sig"
+138
docs/authentication.md
··· 461 461 - token names help identify which token is used where 462 462 - tokens require explicit OAuth consent at your PDS 463 463 464 + ## OAuth client types: public vs confidential 465 + 466 + ATProto OAuth distinguishes between two types of clients based on their ability to authenticate themselves to the authorization server. 467 + 468 + ### what is a confidential client? 469 + 470 + a **confidential client** is an OAuth client that can prove its identity to the authorization server using cryptographic keys. the term "confidential" means the client can keep a secret - specifically, an ES256 private key that never leaves the server. 471 + 472 + **public client** (default): 473 + - cannot authenticate itself (uses `token_endpoint_auth_method: "none"`) 474 + - anyone could impersonate your client_id 475 + - authorization server issues **2-week refresh tokens** 476 + 477 + **confidential client** (with `OAUTH_JWK`): 478 + - authenticates using `private_key_jwt` - signs a JWT with its private key 479 + - authorization server verifies signature against your `/.well-known/jwks.json` 480 + - proves the request actually came from your server 481 + - authorization server issues **180-day refresh tokens** 482 + 483 + ### why this matters for plyr.fm 484 + 485 + with public clients, the underlying ATProto refresh token expires after 2 weeks regardless of what we store in our database. users would need to re-authenticate with their PDS every 2 weeks. 486 + 487 + with confidential clients: 488 + - **developer tokens actually work long-term** - not limited to 2 weeks 489 + - **users don't get randomly kicked out** after 2 weeks of inactivity 490 + - **sessions last effectively forever** as long as tokens are refreshed within 180 days 491 + 492 + ### how it works 493 + 494 + 1. **key generation**: generate an ES256 (P-256) keypair 495 + ```bash 496 + uv run python scripts/gen_oauth_jwk.py 497 + ``` 498 + 499 + 2. **configuration**: set `OAUTH_JWK` env var with the private key JSON 500 + 501 + 3. **JWKS endpoint**: backend serves public key at `/.well-known/jwks.json` 502 + - authorization server fetches this to verify our signatures 503 + 504 + 4. **client metadata**: `/oauth-client-metadata.json` advertises: 505 + ```json 506 + { 507 + "token_endpoint_auth_method": "private_key_jwt", 508 + "token_endpoint_auth_signing_alg": "ES256", 509 + "jwks_uri": "https://plyr.fm/.well-known/jwks.json" 510 + } 511 + ``` 512 + 513 + 5. **token requests**: on every token request (initial AND refresh), the library: 514 + - creates a short-lived JWT (`client_assertion`) signed with our private key 515 + - includes `client_assertion_type` and `client_assertion` in the request 516 + - PDS verifies signature → issues long-lived tokens 517 + 518 + ### implementation details 519 + 520 + the confidential client support lives in our `atproto` fork (`zzstoatzz/atproto`): 521 + 522 + ```python 523 + # packages/atproto_oauth/client.py 524 + client = OAuthClient( 525 + client_id='https://plyr.fm/oauth-client-metadata.json', 526 + redirect_uri='https://plyr.fm/auth/callback', 527 + scope='atproto ...', 528 + state_store=state_store, 529 + session_store=session_store, 530 + client_secret_key=ec_private_key, # enables confidential client 531 + ) 532 + ``` 533 + 534 + when `client_secret_key` is set, `_make_token_request()` automatically adds client assertions to all token endpoint calls (initial exchange, refresh, revoke). 535 + 536 + ### key rotation 537 + 538 + for key rotation: 539 + 1. generate new key with different `kid` (key ID) 540 + 2. add both keys to JWKS (old and new) 541 + 3. deploy - new tokens use new key, old tokens still verify 542 + 4. after 180 days, remove old key from JWKS 543 + 544 + ### token refresh mechanism 545 + 546 + plyr.fm automatically refreshes ATProto tokens when they expire. here's how it works: 547 + 548 + 1. **trigger**: user makes a PDS request (upload, create record, etc.) 549 + 2. **detection**: PDS returns `401 Unauthorized` with `"exp"` in error message (access token expired) 550 + 3. **refresh**: `_refresh_session_tokens()` in `_internal/atproto/client.py`: 551 + - acquires per-session lock (prevents race conditions) 552 + - calls `OAuthClient.refresh_session()` with the refresh token 553 + - for confidential clients: signs a client assertion JWT 554 + - PDS verifies assertion → issues new tokens 555 + - saves new tokens to database 556 + 4. **retry**: original request retries with fresh tokens 557 + 558 + **what gets refreshed**: 559 + - **access token**: short-lived (~minutes), refreshed frequently 560 + - **refresh token**: long-lived (2 weeks public, 180 days confidential), rotated on each use 561 + 562 + **observability**: look for these log messages in logfire: 563 + - `"access token expired for did:plc:..., attempting refresh"` 564 + - `"refreshing access token for did:plc:..."` 565 + - `"successfully refreshed access token for did:plc:..."` 566 + 567 + ### migration: deploying confidential client 568 + 569 + when deploying confidential client support, **existing sessions continue to work** but have limitations: 570 + 571 + | aspect | existing sessions | new sessions (post-deploy) | 572 + |--------|-------------------|---------------------------| 573 + | plyr.fm session | unchanged | unchanged | 574 + | ATProto refresh token | 2-week lifetime (public client) | 180-day lifetime (confidential) | 575 + | behavior at 2 weeks | refresh fails → re-auth needed | continues working | 576 + 577 + **what happens to existing tokens**: 578 + 1. existing sessions were created as "public client" - the PDS issued 2-week refresh tokens 579 + 2. those refresh tokens cannot be upgraded - they have a fixed expiration 580 + 3. when the refresh token expires, the next PDS request will fail 581 + 4. users will need to re-authenticate to get new sessions with 180-day refresh tokens 582 + 583 + **timeline example**: 584 + - dec 8: user creates dev token (public client, 2-week refresh) 585 + - dec 22: ATProto refresh token expires 586 + - user tries to upload → refresh fails → 401 error 587 + - user creates new dev token → now gets 180-day refresh 588 + 589 + **production impact** (as of deployment): 590 + - most browser sessions expire within 14 days anyway (cookie `max_age`) 591 + - developer tokens are affected most - they have 90+ day session expiry but 2-week refresh 592 + - only 3 long-lived dev tokens in production (internal accounts) 593 + - new sessions will automatically get 180-day refresh tokens 594 + 595 + ### sources 596 + 597 + - [ATProto OAuth spec - tokens and session lifetime](https://atproto.com/specs/oauth#tokens-and-session-lifetime) 598 + - [RFC 7523 - JWT Bearer Client Authentication](https://datatracker.ietf.org/doc/html/rfc7523) 599 + - [bailey's ATProto SvelteKit template](https://tangled.org/baileytownsend.dev/atproto-sveltekit-template) - TypeScript reference implementation 600 + - PR #578: confidential OAuth client support 601 + 464 602 ## references 465 603 466 604 - [MDN: HttpOnly cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#security)
+50
scripts/gen_oauth_jwk.py
··· 1 + #!/usr/bin/env python3 2 + """generate ES256 JWK for OAuth confidential client. 3 + 4 + outputs a JSON string suitable for the OAUTH_JWK environment variable. 5 + 6 + usage: 7 + uv run python scripts/gen_oauth_jwk.py 8 + 9 + then add to your .env: 10 + OAUTH_JWK='{"kty":"EC","crv":"P-256",...}' 11 + """ 12 + 13 + import json 14 + import time 15 + 16 + from cryptography.hazmat.primitives import serialization 17 + from cryptography.hazmat.primitives.asymmetric import ec 18 + from jose import jwk 19 + 20 + 21 + def generate_jwk() -> str: 22 + """generate ES256 (P-256) JWK for OAuth client authentication.""" 23 + # generate P-256 (secp256r1) key pair 24 + private_key = ec.generate_private_key(ec.SECP256R1()) 25 + 26 + # serialize to PEM 27 + pem_bytes = private_key.private_bytes( 28 + encoding=serialization.Encoding.PEM, 29 + format=serialization.PrivateFormat.PKCS8, 30 + encryption_algorithm=serialization.NoEncryption(), 31 + ) 32 + 33 + # convert to JWK using python-jose 34 + key_obj = jwk.construct(pem_bytes, algorithm="ES256") 35 + jwk_dict = key_obj.to_dict() 36 + 37 + # add key ID based on timestamp (for key rotation) 38 + jwk_dict["kid"] = str(int(time.time())) 39 + jwk_dict["use"] = "sig" 40 + jwk_dict["alg"] = "ES256" 41 + 42 + return json.dumps(jwk_dict) 43 + 44 + 45 + if __name__ == "__main__": 46 + jwk_json = generate_jwk() 47 + print("generated ES256 JWK for OAuth confidential client:\n") 48 + print(jwk_json) 49 + print("\nadd to your .env file as:") 50 + print(f"OAUTH_JWK='{jwk_json}'")