From 9703c243a48a4216a9e431e160d889604254a0b0 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Fri, 24 Apr 2026 18:03:45 -0400 Subject: [PATCH] fix: don't assume client_id on dpop --- .../data/models/aip_session_response.dart | 15 ++++++-- .../core/auth/data/models/auth_snapshot.dart | 11 +++++- .../repositories/auth_repository_impl.dart | 7 ++++ .../models/aip_session_response_test.dart | 35 +++++++++++++++++++ .../auth_repository_impl_test.dart | 6 ++-- 5 files changed, 66 insertions(+), 8 deletions(-) diff --git a/lib/src/core/auth/data/models/aip_session_response.dart b/lib/src/core/auth/data/models/aip_session_response.dart index 03d8502a..7bf697ee 100644 --- a/lib/src/core/auth/data/models/aip_session_response.dart +++ b/lib/src/core/auth/data/models/aip_session_response.dart @@ -14,6 +14,7 @@ class AipAtprotocolSessionResponse { required this.scopes, required this.pdsEndpoint, required this.expiresAt, + this.clientId, this.dpopKey, this.dpopJwk, }); @@ -32,6 +33,7 @@ class AipAtprotocolSessionResponse { (json['expires_at'] as int) * 1000, isUtc: true, ), + clientId: json['client_id'] as String?, dpopKey: json['dpop_key'] as String?, dpopJwk: json['dpop_jwk'] == null ? null @@ -46,6 +48,7 @@ class AipAtprotocolSessionResponse { final List scopes; final String pdsEndpoint; final DateTime expiresAt; + final String? clientId; final String? dpopKey; final AipDpopJwk? dpopJwk; } @@ -88,6 +91,7 @@ class AipExportedSessionException implements Exception { PdsSessionCache buildPdsSessionCacheFromAipResponse( AipAtprotocolSessionResponse response, { String dpopNonce = '', + String? clientId, }) { final dpopJwk = response.dpopJwk; if (dpopJwk == null || dpopJwk.d == null || dpopJwk.d!.isEmpty) { @@ -96,8 +100,11 @@ PdsSessionCache buildPdsSessionCacheFromAipResponse( ); } - final clientId = extractClientIdFromAccessToken(response.accessToken); - if (clientId == null || clientId.isEmpty) { + final resolvedClientId = + response.clientId ?? + clientId ?? + extractClientIdFromAccessToken(response.accessToken); + if (resolvedClientId == null || resolvedClientId.isEmpty) { throw const AipExportedSessionException( 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', ); @@ -111,13 +118,15 @@ PdsSessionCache buildPdsSessionCacheFromAipResponse( pdsEndpoint: response.pdsEndpoint, scope: response.scopes.join(' '), dpopNonce: dpopNonce, + clientId: resolvedClientId, publicKey: encodeDpopPublicKey(dpopJwk.x, dpopJwk.y), privateKey: encodeDpopPrivateKey(dpopJwk.d!), ); } OAuthSession restorePdsOAuthSessionFromCache(PdsSessionCache cache) { - final clientId = extractClientIdFromAccessToken(cache.accessToken); + final clientId = + cache.clientId ?? extractClientIdFromAccessToken(cache.accessToken); if (clientId == null || clientId.isEmpty) { throw const AipExportedSessionException( 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', diff --git a/lib/src/core/auth/data/models/auth_snapshot.dart b/lib/src/core/auth/data/models/auth_snapshot.dart index 3b6ff8d2..e45f30b0 100644 --- a/lib/src/core/auth/data/models/auth_snapshot.dart +++ b/lib/src/core/auth/data/models/auth_snapshot.dart @@ -148,19 +148,22 @@ class PdsSessionCache { required this.pdsEndpoint, required this.scope, required this.dpopNonce, + this.clientId, required this.publicKey, required this.privateKey, }); factory PdsSessionCache.fromJson(Map json) { + final accessToken = json['accessToken'] as String; return PdsSessionCache( - accessToken: json['accessToken'] as String, + accessToken: accessToken, expiresAt: json['expiresAt'] as String, did: json['did'] as String, handle: json['handle'] as String, pdsEndpoint: json['pdsEndpoint'] as String, scope: json['scope'] as String, dpopNonce: json['dpopNonce'] as String? ?? '', + clientId: json['clientId'] as String?, publicKey: json['publicKey'] as String, privateKey: json['privateKey'] as String, ); @@ -173,6 +176,7 @@ class PdsSessionCache { final String pdsEndpoint; final String scope; final String dpopNonce; + final String? clientId; final String publicKey; final String privateKey; @@ -187,6 +191,7 @@ class PdsSessionCache { 'pdsEndpoint': pdsEndpoint, 'scope': scope, 'dpopNonce': dpopNonce, + 'clientId': clientId, 'publicKey': publicKey, 'privateKey': privateKey, }; @@ -200,6 +205,7 @@ class PdsSessionCache { String? pdsEndpoint, String? scope, String? dpopNonce, + Object? clientId = _missingValue, String? publicKey, String? privateKey, }) { @@ -211,6 +217,9 @@ class PdsSessionCache { pdsEndpoint: pdsEndpoint ?? this.pdsEndpoint, scope: scope ?? this.scope, dpopNonce: dpopNonce ?? this.dpopNonce, + clientId: identical(clientId, _missingValue) + ? this.clientId + : clientId as String?, publicKey: publicKey ?? this.publicKey, privateKey: privateKey ?? this.privateKey, ); diff --git a/lib/src/core/auth/data/repositories/auth_repository_impl.dart b/lib/src/core/auth/data/repositories/auth_repository_impl.dart index 66ff659c..86446d64 100644 --- a/lib/src/core/auth/data/repositories/auth_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/auth_repository_impl.dart @@ -238,6 +238,9 @@ class AuthRepositoryImpl implements AuthRepository { pdsEndpoint: pdsEndpoint, scope: oauthSession.scope, dpopNonce: oauthSession.$dPoPNonce, + clientId: + oauthSession.$clientId ?? + extractClientIdFromAccessToken(oauthSession.accessToken), publicKey: oauthSession.$publicKey, privateKey: oauthSession.$privateKey, ), @@ -661,6 +664,7 @@ class AuthRepositoryImpl implements AuthRepository { final pdsSession = buildPdsSessionCacheFromAipResponse( sessionResponse, dpopNonce: existingNonce, + clientId: _aipAtprotocolClientId, ); _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( @@ -683,6 +687,9 @@ class AuthRepositoryImpl implements AuthRepository { } } + String get _aipAtprotocolClientId => + _aipBaseUri.resolve('/oauth-client-metadata.json').toString(); + Future _refreshAuthState() async { final inFlight = _refreshInFlight; if (inFlight != null) { diff --git a/test/src/core/auth/data/models/aip_session_response_test.dart b/test/src/core/auth/data/models/aip_session_response_test.dart index f110fe01..cd9f11ee 100644 --- a/test/src/core/auth/data/models/aip_session_response_test.dart +++ b/test/src/core/auth/data/models/aip_session_response_test.dart @@ -51,6 +51,39 @@ void main() { expect(restored.$privateKey, normalizedCache.privateKey); }); + test('restores with exported client_id when token omits the claim', () { + final cache = buildPdsSessionCacheFromAipResponse( + _sessionResponse( + accessToken: _jwt(clientId: null), + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', + ), + ); + + final restored = restorePdsOAuthSessionFromCache(cache); + + expect( + restored.$clientId, + 'https://auth.sprk.so/oauth-client-metadata.json', + ); + }); + + test( + 'restores with caller-provided client_id when token omits the claim', + () { + final cache = buildPdsSessionCacheFromAipResponse( + _sessionResponse(accessToken: _jwt(clientId: null)), + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', + ); + + final restored = restorePdsOAuthSessionFromCache(cache); + + expect( + restored.$clientId, + 'https://auth.sprk.so/oauth-client-metadata.json', + ); + }, + ); + test('rejects responses without private DPoP key material', () { final response = _sessionResponse( accessToken: _jwt(clientId: 'spark-client'), @@ -88,6 +121,7 @@ void main() { AipAtprotocolSessionResponse _sessionResponse({ required String accessToken, + String? clientId, String? d = 'AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI', String x = 'AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE', String y = 'AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM', @@ -100,6 +134,7 @@ AipAtprotocolSessionResponse _sessionResponse({ scopes: const ['atproto'], pdsEndpoint: 'https://pds.sprk.so', expiresAt: DateTime.utc(2030, 1, 1), + clientId: clientId, dpopKey: 'did:key:test', dpopJwk: AipDpopJwk(kty: 'EC', crv: 'P-256', x: x, y: y, d: d), ); diff --git a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart index aa338c70..d859b8ab 100644 --- a/test/src/core/auth/data/repositories/auth_repository_impl_test.dart +++ b/test/src/core/auth/data/repositories/auth_repository_impl_test.dart @@ -688,9 +688,7 @@ void main() { case '/api/atprotocol/session': sessionCalls += 1; return http.Response( - json.encode( - _sessionResponseBody(_pdsJwt(clientId: 'client-1')), - ), + json.encode(_sessionResponseBody(_pdsJwt(clientId: null))), 200, ); default: @@ -884,7 +882,7 @@ Map _sessionResponseBody(String accessToken) { }; } -String _pdsJwt({required String clientId, DateTime? exp}) { +String _pdsJwt({required String? clientId, DateTime? exp}) { final payload = { 'sub': 'did:plc:test', 'exp': (exp ?? DateTime.utc(2030, 1, 1)).millisecondsSinceEpoch ~/ 1000, -- 2.51.2