fix: pass kid to OAuthClient for client assertion JWT header (#582)

the ATProto OAuth spec requires client assertions to include the kid
in the JWT header so the PDS knows which public key to use for
verification.

changes:
- rename _load_client_secret_key() to _load_client_secret()
- return tuple of (key, kid) instead of just key
- validate that OAUTH_JWK includes kid field
- pass client_secret_kid to OAuthClient
- update atproto fork to v0.0.1.dev470 with kid support

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

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

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub c8c0c4ee e1e05abc

Changed files
+18 -11
backend
src
backend
_internal
+16 -9
backend/src/backend/_internal/auth.py
··· 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() ··· 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}") ··· 177 ) 178 179 # load confidential client key if configured 180 - client_secret_key = _load_client_secret_key() 181 182 return OAuthClient( 183 client_id=settings.atproto.client_id, ··· 186 state_store=_state_store, 187 session_store=_session_store, 188 client_secret_key=client_secret_key, 189 ) 190 191
··· 77 78 # confidential client key (loaded lazily) 79 _client_secret_key: "EllipticCurvePrivateKey | None" = None 80 + _client_secret_kid: str | None = None 81 _client_secret_key_loaded = False 82 83 84 + def _load_client_secret() -> tuple["EllipticCurvePrivateKey | None", str | None]: 85 + """load EC private key and kid from OAUTH_JWK setting for confidential client. 86 87 the key is expected to be a JSON-serialized JWK with ES256 (P-256) key. 88 + returns (None, None) if OAUTH_JWK is not configured (public client mode). 89 """ 90 + global _client_secret_key, _client_secret_kid, _client_secret_key_loaded 91 92 if _client_secret_key_loaded: 93 + return _client_secret_key, _client_secret_kid 94 95 _client_secret_key_loaded = True 96 97 if not settings.atproto.oauth_jwk: 98 logger.info("OAUTH_JWK not configured, using public OAuth client") 99 + return None, None 100 101 try: 102 # parse JWK JSON 103 jwk_data = json.loads(settings.atproto.oauth_jwk) 104 105 + # extract kid (required for client assertions) 106 + _client_secret_kid = jwk_data.get("kid") 107 + if not _client_secret_kid: 108 + raise ValueError("OAUTH_JWK must include 'kid' field") 109 + 110 # convert JWK to PEM format using python-jose 111 key_obj = jwk.construct(jwk_data, algorithm="ES256") 112 pem_bytes = key_obj.to_pem() ··· 117 if not isinstance(_client_secret_key, ec.EllipticCurvePrivateKey): 118 raise ValueError("OAUTH_JWK must be an EC key (ES256)") 119 120 + logger.info(f"loaded confidential OAuth client key (kid={_client_secret_kid})") 121 + return _client_secret_key, _client_secret_kid 122 123 except Exception as e: 124 logger.error(f"failed to load OAUTH_JWK: {e}") ··· 183 ) 184 185 # load confidential client key if configured 186 + client_secret_key, client_secret_kid = _load_client_secret() 187 188 return OAuthClient( 189 client_id=settings.atproto.client_id, ··· 192 state_store=_state_store, 193 session_store=_session_store, 194 client_secret_key=client_secret_key, 195 + client_secret_kid=client_secret_kid, 196 ) 197 198
+2 -2
backend/uv.lock
··· 287 288 [[package]] 289 name = "atproto" 290 - version = "0.0.1.dev469" 291 - source = { git = "https://github.com/zzstoatzz/atproto?rev=main#dfbaf0002fed21d5a0ca28a6219e2b6b42384259" } 292 dependencies = [ 293 { name = "click" }, 294 { name = "cryptography" },
··· 287 288 [[package]] 289 name = "atproto" 290 + version = "0.0.1.dev470" 291 + source = { git = "https://github.com/zzstoatzz/atproto?rev=main#6710edb64d144a0ff2757526ade34fa1625a46e6" } 292 dependencies = [ 293 { name = "click" }, 294 { name = "cryptography" },