[READ ONLY MIRROR] Open Source TikTok alternative built on AT Protocol github.com/sprksocial/client
flutter atproto video dart
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: cocoon pds auth

+350 -45
+93 -29
lib/src/core/auth/data/models/aip_session_response.dart
··· 100 100 ); 101 101 } 102 102 103 - final resolvedClientId = 104 - response.clientId ?? 105 - clientId ?? 106 - extractClientIdFromAccessToken(response.accessToken); 103 + final resolvedClientId = _firstNonEmpty(response.clientId, clientId); 107 104 if (resolvedClientId == null || resolvedClientId.isEmpty) { 108 105 throw const AipExportedSessionException( 109 106 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', 110 107 ); 111 108 } 109 + validateExportedAccessToken(response.accessToken); 112 110 113 111 return PdsSessionCache( 114 112 accessToken: response.accessToken, ··· 125 123 } 126 124 127 125 OAuthSession restorePdsOAuthSessionFromCache(PdsSessionCache cache) { 128 - final clientId = 129 - cache.clientId ?? extractClientIdFromAccessToken(cache.accessToken); 126 + final clientId = _firstNonEmpty(cache.clientId); 130 127 if (clientId == null || clientId.isEmpty) { 131 128 throw const AipExportedSessionException( 132 129 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', 133 130 ); 134 131 } 132 + validateExportedAccessToken(cache.accessToken); 135 133 136 - return restoreOAuthSession( 137 - accessToken: cache.accessToken, 138 - refreshToken: '', 139 - clientId: clientId, 140 - dPoPNonce: cache.dpopNonce, 141 - publicKey: normalizeDpopKeyEncoding(cache.publicKey), 142 - privateKey: normalizeDpopKeyEncoding(cache.privateKey), 143 - ); 134 + try { 135 + return restoreOAuthSession( 136 + accessToken: cache.accessToken, 137 + refreshToken: '', 138 + clientId: clientId, 139 + dPoPNonce: cache.dpopNonce, 140 + publicKey: normalizeDpopKeyEncoding(cache.publicKey), 141 + privateKey: normalizeDpopKeyEncoding(cache.privateKey), 142 + ); 143 + } on FormatException catch (error) { 144 + throw AipExportedSessionException( 145 + 'AIP /api/atprotocol/session returned an access_token JWT that could ' 146 + 'not be restored: ${error.message}.', 147 + ); 148 + } 144 149 } 145 150 146 151 String encodeDpopPublicKey(String x, String y) { ··· 162 167 return base64Url.normalize(value); 163 168 } 164 169 165 - String? extractClientIdFromAccessToken(String accessToken) { 166 - final payload = decodeJwtPayload(accessToken); 167 - final clientId = payload['client_id']; 168 - if (clientId is String && clientId.isNotEmpty) { 169 - return clientId; 170 + void validateExportedAccessToken(String accessToken) { 171 + final parts = accessToken.split('.'); 172 + if (parts.length != 3 || parts.any((part) => part.isEmpty)) { 173 + throw const AipExportedSessionException( 174 + 'AIP /api/atprotocol/session returned an access_token that is not a JWT. ' 175 + 'Direct-PDS mode requires AIP to export the PDS-issued JWT access token, ' 176 + 'not an opaque AIP bearer token.', 177 + ); 170 178 } 171 179 172 - return null; 180 + final Map<String, dynamic> payload; 181 + try { 182 + final decoded = json.decode( 183 + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), 184 + ); 185 + if (decoded is! Map<String, dynamic>) { 186 + throw const FormatException('JWT payload is not a JSON object.'); 187 + } 188 + payload = decoded; 189 + } catch (_) { 190 + throw const AipExportedSessionException( 191 + 'AIP /api/atprotocol/session returned a malformed access_token JWT. ' 192 + 'Direct-PDS mode requires a decodable PDS-issued JWT access token.', 193 + ); 194 + } 195 + 196 + _requireStringClaim(payload, 'sub'); 197 + _requireNumericDateClaim(payload, 'exp'); 198 + _requireNumericDateClaim(payload, 'iat'); 199 + _requireOptionalStringClaim(payload, 'aud'); 200 + _requireOptionalStringClaim(payload, 'jti'); 201 + _requireOptionalStringClaim(payload, 'client_id'); 202 + _requireOptionalStringClaim(payload, 'scope'); 173 203 } 174 204 175 - Map<String, dynamic> decodeJwtPayload(String token) { 176 - final parts = token.split('.'); 177 - if (parts.length < 2) { 178 - throw const AipExportedSessionException('Invalid exported JWT.'); 205 + void _requireStringClaim(Map<String, dynamic> payload, String claim) { 206 + final value = payload[claim]; 207 + if (value is String && value.isNotEmpty) { 208 + return; 209 + } 210 + 211 + final reason = value == null ? 'missing' : 'invalid'; 212 + throw AipExportedSessionException( 213 + 'AIP /api/atprotocol/session returned an access_token JWT with a $reason ' 214 + 'required "$claim" claim.', 215 + ); 216 + } 217 + 218 + void _requireNumericDateClaim(Map<String, dynamic> payload, String claim) { 219 + final value = payload[claim]; 220 + if (value is num) { 221 + return; 222 + } 223 + 224 + final reason = value == null ? 'missing' : 'invalid'; 225 + throw AipExportedSessionException( 226 + 'AIP /api/atprotocol/session returned an access_token JWT with a $reason ' 227 + 'required numeric "$claim" claim.', 228 + ); 229 + } 230 + 231 + void _requireOptionalStringClaim(Map<String, dynamic> payload, String claim) { 232 + final value = payload[claim]; 233 + if (value == null || value is String) { 234 + return; 179 235 } 180 236 181 - final payloadBytes = base64Url.decode(base64Url.normalize(parts[1])); 182 - final payload = json.decode(utf8.decode(payloadBytes)); 183 - if (payload is! Map<String, dynamic>) { 184 - throw const AipExportedSessionException('Invalid exported JWT payload.'); 237 + throw AipExportedSessionException( 238 + 'AIP /api/atprotocol/session returned an access_token JWT with an invalid ' 239 + '"$claim" claim; expected a string.', 240 + ); 241 + } 242 + 243 + String? _firstNonEmpty(String? first, [String? second]) { 244 + if (first != null && first.isNotEmpty) { 245 + return first; 246 + } 247 + if (second != null && second.isNotEmpty) { 248 + return second; 185 249 } 186 250 187 - return payload; 251 + return null; 188 252 }
+46 -12
lib/src/core/auth/data/repositories/auth_repository_impl.dart
··· 238 238 pdsEndpoint: pdsEndpoint, 239 239 scope: oauthSession.scope, 240 240 dpopNonce: oauthSession.$dPoPNonce, 241 - clientId: 242 - oauthSession.$clientId ?? 243 - extractClientIdFromAccessToken(oauthSession.accessToken), 241 + clientId: oauthSession.$clientId, 244 242 publicKey: oauthSession.$publicKey, 245 243 privateKey: oauthSession.$privateKey, 246 244 ), ··· 541 539 ); 542 540 await _saveSnapshot(); 543 541 544 - final bootstrapped = await _bootstrapPdsSession( 542 + final bootstrapResult = await _bootstrapPdsSession( 545 543 client.credentials, 546 544 authGeneration: _authGeneration, 547 545 ); 548 - if (!bootstrapped) { 546 + if (!bootstrapResult.success) { 549 547 _snapshot = previousSnapshot; 550 548 await _saveSnapshot(); 551 549 _resetInMemorySession(); 552 550 return LoginResult.failed( 553 - 'Failed to bootstrap a direct PDS session from AIP.', 551 + 'Failed to bootstrap a direct PDS session from AIP: ' 552 + '${bootstrapResult.error ?? 'unknown error'}.', 554 553 ); 555 554 } 556 555 ··· 647 646 ); 648 647 } 649 648 650 - Future<bool> _bootstrapPdsSession( 649 + Future<_BootstrapPdsSessionResult> _bootstrapPdsSession( 651 650 oauth2.Credentials credentials, { 652 651 required int authGeneration, 653 652 }) async { 654 653 try { 655 654 final sessionResponse = await _fetchAtprotocolSession(credentials); 656 655 if (!_isCurrentAuthGeneration(authGeneration)) { 657 - return false; 656 + return const _BootstrapPdsSessionResult.failure( 657 + 'auth state changed while fetching the PDS session', 658 + ); 658 659 } 659 660 660 661 final existingNonce = ··· 672 673 ); 673 674 await _saveSnapshot(); 674 675 if (!_isCurrentAuthGeneration(authGeneration)) { 675 - return false; 676 + return const _BootstrapPdsSessionResult.failure( 677 + 'auth state changed while saving the PDS session', 678 + ); 676 679 } 677 680 678 681 _applyCachedPdsSession(pdsSession); 679 - return true; 682 + return const _BootstrapPdsSessionResult.success(); 680 683 } catch (e, stackTrace) { 681 684 _logger.e( 682 685 'Failed to bootstrap direct PDS session from AIP', 683 686 error: e, 684 687 stackTrace: stackTrace, 685 688 ); 686 - return false; 689 + return _BootstrapPdsSessionResult.failure(_describeError(e)); 687 690 } 688 691 } 689 692 ··· 719 722 return false; 720 723 } 721 724 722 - return _bootstrapPdsSession(credentials, authGeneration: authGeneration); 725 + final bootstrapResult = await _bootstrapPdsSession( 726 + credentials, 727 + authGeneration: authGeneration, 728 + ); 729 + return bootstrapResult.success; 723 730 } catch (e, stackTrace) { 724 731 _logger.e( 725 732 'Failed to refresh auth state from AIP', ··· 863 870 } 864 871 865 872 return '${response.statusCode} ${response.reasonPhrase ?? ''}'.trim(); 873 + } 874 + 875 + String _describeError(Object error) { 876 + if (error is FormatException) { 877 + return error.message; 878 + } 879 + 880 + final message = error.toString(); 881 + return message.startsWith('Exception: ') 882 + ? message.substring('Exception: '.length) 883 + : message; 884 + } 885 + 886 + class _BootstrapPdsSessionResult { 887 + const _BootstrapPdsSessionResult._({ 888 + required this.success, 889 + required this.error, 890 + }); 891 + 892 + const _BootstrapPdsSessionResult.success() 893 + : this._(success: true, error: null); 894 + 895 + const _BootstrapPdsSessionResult.failure(String error) 896 + : this._(success: false, error: error); 897 + 898 + final bool success; 899 + final String? error; 866 900 } 867 901 868 902 class _AipOAuthMetadata {
+2 -2
pubspec.lock
··· 110 110 description: 111 111 path: "packages/atproto_core" 112 112 ref: oauth-client-id 113 - resolved-ref: "49d621c44b11b4b582b6cbb684212d8d25e53af5" 113 + resolved-ref: aafa5dfb0c67c4e7cbe2ea286500bf5891a9bfe6 114 114 url: "https://github.com/knotbin/atproto.dart.git" 115 115 source: git 116 116 version: "1.2.0" ··· 119 119 description: 120 120 path: "packages/atproto_oauth" 121 121 ref: oauth-client-id 122 - resolved-ref: "49d621c44b11b4b582b6cbb684212d8d25e53af5" 122 + resolved-ref: aafa5dfb0c67c4e7cbe2ea286500bf5891a9bfe6 123 123 url: "https://github.com/knotbin/atproto.dart.git" 124 124 source: git 125 125 version: "0.2.0"
+133 -2
test/src/core/auth/data/models/aip_session_response_test.dart
··· 11 11 final dBytes = List<int>.generate(32, (index) => index + 65); 12 12 final response = _sessionResponse( 13 13 accessToken: _jwt(clientId: 'spark-client'), 14 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 14 15 x: _base64UrlNoPadding(xBytes), 15 16 y: _base64UrlNoPadding(yBytes), 16 17 d: _base64UrlNoPadding(dBytes), ··· 24 25 25 26 test('accepts empty initial nonce and preserves later nonce updates', () { 26 27 final cache = buildPdsSessionCacheFromAipResponse( 27 - _sessionResponse(accessToken: _jwt(clientId: 'spark-client')), 28 + _sessionResponse( 29 + accessToken: _jwt(clientId: 'spark-client'), 30 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 31 + ), 28 32 ); 29 33 30 34 expect(cache.dpopNonce, isEmpty); ··· 38 42 39 43 test('normalizes legacy unpadded cached keys on restore', () { 40 44 final normalizedCache = buildPdsSessionCacheFromAipResponse( 41 - _sessionResponse(accessToken: _jwt(clientId: 'spark-client')), 45 + _sessionResponse( 46 + accessToken: _jwt(clientId: 'spark-client'), 47 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 48 + ), 42 49 ); 43 50 final legacyCache = normalizedCache.copyWith( 44 51 publicKey: normalizedCache.publicKey.replaceAll('=', ''), ··· 83 90 ); 84 91 }, 85 92 ); 93 + 94 + test('uses caller-provided client_id when exported client_id is empty', () { 95 + final cache = buildPdsSessionCacheFromAipResponse( 96 + _sessionResponse( 97 + accessToken: _jwtFromPayload({ 98 + 'sub': 'did:plc:test', 99 + 'exp': DateTime.utc(2030, 1, 1).millisecondsSinceEpoch ~/ 1000, 100 + 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, 101 + 'scope': 'atproto', 102 + 'cnf': 'legacy-key-binding', 103 + }), 104 + clientId: '', 105 + ), 106 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 107 + ); 108 + 109 + final restored = restorePdsOAuthSessionFromCache(cache); 110 + 111 + expect( 112 + restored.$clientId, 113 + 'https://auth.sprk.so/oauth-client-metadata.json', 114 + ); 115 + }); 86 116 87 117 test('rejects responses without private DPoP key material', () { 88 118 final response = _sessionResponse( 89 119 accessToken: _jwt(clientId: 'spark-client'), 120 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 90 121 d: null, 91 122 ); 92 123 ··· 102 133 ); 103 134 }); 104 135 136 + test('does not fall back to the token client_id claim', () { 137 + final response = _sessionResponse( 138 + accessToken: _jwt(clientId: 'spark-client'), 139 + ); 140 + 141 + expect( 142 + () => buildPdsSessionCacheFromAipResponse(response), 143 + throwsA( 144 + isA<AipExportedSessionException>().having( 145 + (error) => error.message, 146 + 'message', 147 + contains('missing client_id'), 148 + ), 149 + ), 150 + ); 151 + }); 152 + 105 153 test('rejects exported PDS access tokens without client_id', () { 106 154 final response = _sessionResponse(accessToken: _jwt(clientId: null)); 107 155 ··· 116 164 ), 117 165 ); 118 166 }); 167 + 168 + test('rejects opaque exported access tokens with direct-PDS context', () { 169 + final response = _sessionResponse( 170 + accessToken: 'opaque-aip-access-token', 171 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 172 + ); 173 + 174 + expect( 175 + () => buildPdsSessionCacheFromAipResponse(response), 176 + throwsA( 177 + isA<AipExportedSessionException>().having( 178 + (error) => error.message, 179 + 'message', 180 + contains('not a JWT'), 181 + ), 182 + ), 183 + ); 184 + }); 185 + 186 + test('rejects malformed exported access token JWTs with context', () { 187 + final response = _sessionResponse( 188 + accessToken: 'header.not-base64url.signature', 189 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 190 + ); 191 + 192 + expect( 193 + () => buildPdsSessionCacheFromAipResponse(response), 194 + throwsA( 195 + isA<AipExportedSessionException>().having( 196 + (error) => error.message, 197 + 'message', 198 + contains('malformed access_token JWT'), 199 + ), 200 + ), 201 + ); 202 + }); 203 + 204 + test('rejects exported access token JWTs missing required claims', () { 205 + final response = _sessionResponse( 206 + accessToken: _jwtFromPayload({ 207 + 'exp': DateTime.utc(2030, 1, 1).millisecondsSinceEpoch ~/ 1000, 208 + 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, 209 + }), 210 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 211 + ); 212 + 213 + expect( 214 + () => buildPdsSessionCacheFromAipResponse(response), 215 + throwsA( 216 + isA<AipExportedSessionException>().having( 217 + (error) => error.message, 218 + 'message', 219 + contains('missing required "sub" claim'), 220 + ), 221 + ), 222 + ); 223 + }); 224 + 225 + test('rejects exported access token JWTs with invalid claim types', () { 226 + final response = _sessionResponse( 227 + accessToken: _jwtFromPayload({ 228 + 'sub': 'did:plc:test', 229 + 'exp': '2030-01-01T00:00:00Z', 230 + 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, 231 + }), 232 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 233 + ); 234 + 235 + expect( 236 + () => buildPdsSessionCacheFromAipResponse(response), 237 + throwsA( 238 + isA<AipExportedSessionException>().having( 239 + (error) => error.message, 240 + 'message', 241 + contains('invalid required numeric "exp" claim'), 242 + ), 243 + ), 244 + ); 245 + }); 119 246 }); 120 247 } 121 248 ··· 149 276 'client_id': clientId, 150 277 }; 151 278 279 + return _jwtFromPayload(payload); 280 + } 281 + 282 + String _jwtFromPayload(Map<String, Object?> payload) { 152 283 return '${_base64UrlNoPadding(utf8.encode(json.encode({'alg': 'none', 'typ': 'JWT'})))}.${_base64UrlNoPadding(utf8.encode(json.encode(payload)))}.signature'; 153 284 } 154 285
+76
test/src/core/auth/data/repositories/auth_repository_impl_test.dart
··· 729 729 }, 730 730 ); 731 731 732 + test('completeOAuth surfaces the AIP bootstrap failure reason', () async { 733 + final storage = _InMemoryStorage(); 734 + 735 + final client = MockClient((request) async { 736 + switch (request.url.path) { 737 + case '/.well-known/oauth-authorization-server': 738 + return http.Response( 739 + json.encode({ 740 + 'authorization_endpoint': 741 + 'https://auth.sprk.so/oauth/authorize', 742 + 'token_endpoint': 'https://auth.sprk.so/oauth/token', 743 + 'registration_endpoint': 744 + 'https://auth.sprk.so/oauth/clients/register', 745 + }), 746 + 200, 747 + ); 748 + case '/oauth/clients/register': 749 + return http.Response( 750 + json.encode({ 751 + 'client_id': 'client-1', 752 + 'client_secret': 'secret-1', 753 + }), 754 + 201, 755 + ); 756 + case '/oauth/token': 757 + return http.Response( 758 + json.encode({ 759 + 'access_token': 'aip-access', 760 + 'refresh_token': 'aip-refresh', 761 + 'token_type': 'Bearer', 762 + 'expires_in': 3600, 763 + }), 764 + 200, 765 + headers: {'content-type': 'application/json'}, 766 + ); 767 + case '/api/atprotocol/session': 768 + return http.Response( 769 + json.encode({ 770 + 'error': 'invalid_dpop_proof', 771 + 'error_description': 'DPoP proof is missing ath', 772 + }), 773 + 400, 774 + ); 775 + default: 776 + return http.Response('unexpected request', 500); 777 + } 778 + }); 779 + 780 + final repository = AuthRepositoryImpl( 781 + secureStorage: storage, 782 + httpClient: client, 783 + logger: SparkLogger(name: 'AuthRepositoryTest'), 784 + ); 785 + 786 + await repository.initializationComplete; 787 + final authUri = Uri.parse(await repository.initiateOAuth('alice')); 788 + final callbackUrl = Uri.parse(_redirectUri) 789 + .replace( 790 + queryParameters: { 791 + 'code': 'code-123', 792 + 'state': authUri.queryParameters['state']!, 793 + }, 794 + ) 795 + .toString(); 796 + 797 + final result = await repository.completeOAuth(callbackUrl); 798 + 799 + expect(result.isSuccess, isFalse); 800 + expect( 801 + result.error, 802 + 'Failed to bootstrap a direct PDS session from AIP: ' 803 + 'AIP session request failed: DPoP proof is missing ath.', 804 + ); 805 + }); 806 + 732 807 test( 733 808 'initiateOAuth re-registers when the cached AIP client scope is stale', 734 809 () async { ··· 859 934 _sessionResponseBody(accessToken) 860 935 ..['expires_at'] = expiresAt.millisecondsSinceEpoch ~/ 1000, 861 936 ), 937 + clientId: 'https://auth.sprk.so/oauth-client-metadata.json', 862 938 ); 863 939 } 864 940