From d1ba42fa5cabc65ff78af3e6a7766be427e2275d Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg Date: Mon, 25 May 2026 23:32:57 -0400 Subject: [PATCH] fix: restore opaque oauth sessions --- .../data/models/aip_session_response.dart | 84 ++----------------- .../core/auth/data/models/auth_snapshot.dart | 6 ++ .../repositories/auth_repository_impl.dart | 1 + pubspec.lock | 12 +-- pubspec.yaml | 2 +- .../models/aip_session_response_test.dart | 82 +++--------------- .../components/interactive_atoms_test.dart | 8 +- 7 files changed, 38 insertions(+), 157 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 0c8bc89..05179a7 100644 --- a/lib/src/core/auth/data/models/aip_session_response.dart +++ b/lib/src/core/auth/data/models/aip_session_response.dart @@ -105,10 +105,10 @@ PdsSessionCache buildPdsSessionCacheFromAipResponse( 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', ); } - validateExportedAccessToken(response.accessToken); return PdsSessionCache( accessToken: response.accessToken, + tokenType: response.tokenType, expiresAt: response.expiresAt.toIso8601String(), did: response.did, handle: response.handle, @@ -128,20 +128,23 @@ OAuthSession restorePdsOAuthSessionFromCache(PdsSessionCache cache) { 'AIP-exported token is incompatible with direct-PDS mode: missing client_id.', ); } - validateExportedAccessToken(cache.accessToken); - try { return restoreOAuthSession( accessToken: cache.accessToken, refreshToken: '', + tokenType: cache.tokenType, + scope: cache.scope, + expiresAt: cache.expiresAtDateTime, + sub: cache.did, clientId: clientId, + pdsEndpoint: cache.pdsEndpoint, dPoPNonce: cache.dpopNonce, publicKey: normalizeDpopKeyEncoding(cache.publicKey), privateKey: normalizeDpopKeyEncoding(cache.privateKey), ); } on FormatException catch (error) { throw AipExportedSessionException( - 'AIP /api/atprotocol/session returned an access_token JWT that could ' + 'AIP /api/atprotocol/session returned an access_token that could ' 'not be restored: ${error.message}.', ); } @@ -166,79 +169,6 @@ String normalizeDpopKeyEncoding(String value) { return base64Url.normalize(value); } -void validateExportedAccessToken(String accessToken) { - final parts = accessToken.split('.'); - if (parts.length != 3 || parts.any((part) => part.isEmpty)) { - throw const AipExportedSessionException( - 'AIP /api/atprotocol/session returned an access_token that is not a JWT. ' - 'Direct-PDS mode requires AIP to export the PDS-issued JWT access token, ' - 'not an opaque AIP bearer token.', - ); - } - - final Map payload; - try { - final decoded = json.decode( - utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), - ); - if (decoded is! Map) { - throw const FormatException('JWT payload is not a JSON object.'); - } - payload = decoded; - } catch (_) { - throw const AipExportedSessionException( - 'AIP /api/atprotocol/session returned a malformed access_token JWT. ' - 'Direct-PDS mode requires a decodable PDS-issued JWT access token.', - ); - } - - _requireStringClaim(payload, 'sub'); - _requireNumericDateClaim(payload, 'exp'); - _requireNumericDateClaim(payload, 'iat'); - _requireOptionalStringClaim(payload, 'aud'); - _requireOptionalStringClaim(payload, 'jti'); - _requireOptionalStringClaim(payload, 'client_id'); - _requireOptionalStringClaim(payload, 'scope'); -} - -void _requireStringClaim(Map payload, String claim) { - final value = payload[claim]; - if (value is String && value.isNotEmpty) { - return; - } - - final reason = value == null ? 'missing' : 'invalid'; - throw AipExportedSessionException( - 'AIP /api/atprotocol/session returned an access_token JWT with a $reason ' - 'required "$claim" claim.', - ); -} - -void _requireNumericDateClaim(Map payload, String claim) { - final value = payload[claim]; - if (value is num) { - return; - } - - final reason = value == null ? 'missing' : 'invalid'; - throw AipExportedSessionException( - 'AIP /api/atprotocol/session returned an access_token JWT with a $reason ' - 'required numeric "$claim" claim.', - ); -} - -void _requireOptionalStringClaim(Map payload, String claim) { - final value = payload[claim]; - if (value == null || value is String) { - return; - } - - throw AipExportedSessionException( - 'AIP /api/atprotocol/session returned an access_token JWT with an invalid ' - '"$claim" claim; expected a string.', - ); -} - String? _firstNonEmpty(String? first, [String? second]) { if (first != null && first.isNotEmpty) { return first; diff --git a/lib/src/core/auth/data/models/auth_snapshot.dart b/lib/src/core/auth/data/models/auth_snapshot.dart index e45f30b..d417586 100644 --- a/lib/src/core/auth/data/models/auth_snapshot.dart +++ b/lib/src/core/auth/data/models/auth_snapshot.dart @@ -142,6 +142,7 @@ class AipGrant { class PdsSessionCache { const PdsSessionCache({ required this.accessToken, + required this.tokenType, required this.expiresAt, required this.did, required this.handle, @@ -157,6 +158,7 @@ class PdsSessionCache { final accessToken = json['accessToken'] as String; return PdsSessionCache( accessToken: accessToken, + tokenType: json['tokenType'] as String? ?? 'DPoP', expiresAt: json['expiresAt'] as String, did: json['did'] as String, handle: json['handle'] as String, @@ -170,6 +172,7 @@ class PdsSessionCache { } final String accessToken; + final String tokenType; final String expiresAt; final String did; final String handle; @@ -185,6 +188,7 @@ class PdsSessionCache { Map toJson() { return { 'accessToken': accessToken, + 'tokenType': tokenType, 'expiresAt': expiresAt, 'did': did, 'handle': handle, @@ -199,6 +203,7 @@ class PdsSessionCache { PdsSessionCache copyWith({ String? accessToken, + String? tokenType, String? expiresAt, String? did, String? handle, @@ -211,6 +216,7 @@ class PdsSessionCache { }) { return PdsSessionCache( accessToken: accessToken ?? this.accessToken, + tokenType: tokenType ?? this.tokenType, expiresAt: expiresAt ?? this.expiresAt, did: did ?? this.did, handle: handle ?? this.handle, 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 d3617b2..9641c77 100644 --- a/lib/src/core/auth/data/repositories/auth_repository_impl.dart +++ b/lib/src/core/auth/data/repositories/auth_repository_impl.dart @@ -233,6 +233,7 @@ class AuthRepositoryImpl implements AuthRepository { _snapshot = (_snapshot ?? const AuthSnapshot()).copyWith( pdsSessionCache: PdsSessionCache( accessToken: oauthSession.accessToken, + tokenType: oauthSession.tokenType, expiresAt: oauthSession.expiresAt.toIso8601String(), did: did, handle: handle, diff --git a/pubspec.lock b/pubspec.lock index 75dfdb9..4e58bd1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1305,18 +1305,18 @@ packages: dependency: "direct main" description: name: poptart - sha256: "5631a6e4a1ffa25101db2aa76b82e2410941fa5fec4d4c18ea2c2c7d95b70f0b" + sha256: ad5ef1b70a1bad0fc3f236646d69ec83dd1cc9b329f080af3803e6296322443c url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" poptart_core: dependency: transitive description: name: poptart_core - sha256: "4401f47c4e7f6d1f5142a207aa7e543408f83c43b5aaabdf6d4f60ef8f00ad84" + sha256: "8f73d98f27bf30786131ccd3bc9d9d3855c788cfb6375c333f548ca29b2eb266" url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" poptart_lex: dependency: "direct main" description: @@ -1337,10 +1337,10 @@ packages: dependency: transitive description: name: poptart_oauth - sha256: e268a6947833aab9368dfd67069888962fdea91a0158609b1680faee95e4f8f0 + sha256: c772ad949f98266b328768aa6c835fe61dff3205921589f76627533077d8f35f url: "https://pub.dev" source: hosted - version: "0.1.0" + version: "0.1.1" poptart_primitives: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 77b4e1b..1c055d8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,7 +22,7 @@ dependencies: bluesky_poptart: ^0.1.1 ozone_poptart: ^0.1.1 plyr_poptart: ^0.1.0 - poptart: ^0.1.0 + poptart: ^0.1.1 poptart_lex: ^0.1.0 sprk_poptart: ^0.1.1 cached_network_image: ^3.4.1 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 0129d9e..f6a9aad 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 @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; +import 'package:poptart/poptart.dart'; import 'package:spark/src/core/auth/data/models/aip_session_response.dart'; void main() { @@ -19,6 +20,7 @@ void main() { final cache = buildPdsSessionCacheFromAipResponse(response); + expect(cache.tokenType, 'dpop'); expect(cache.publicKey, base64Url.encode([...xBytes, ...yBytes])); expect(cache.privateKey, base64Url.encode(dBytes)); }); @@ -165,82 +167,24 @@ void main() { ); }); - test('rejects opaque exported access tokens with direct-PDS context', () { + test('restores opaque exported access tokens with response metadata', () { final response = _sessionResponse( accessToken: 'opaque-aip-access-token', clientId: 'https://auth.sprk.so/oauth-client-metadata.json', ); - expect( - () => buildPdsSessionCacheFromAipResponse(response), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('not a JWT'), - ), - ), - ); - }); - - test('rejects malformed exported access token JWTs with context', () { - final response = _sessionResponse( - accessToken: 'header.not-base64url.signature', - clientId: 'https://auth.sprk.so/oauth-client-metadata.json', - ); - - expect( - () => buildPdsSessionCacheFromAipResponse(response), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('malformed access_token JWT'), - ), - ), - ); - }); - - test('rejects exported access token JWTs missing required claims', () { - final response = _sessionResponse( - accessToken: _jwtFromPayload({ - 'exp': DateTime.utc(2030, 1, 1).millisecondsSinceEpoch ~/ 1000, - 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, - }), - clientId: 'https://auth.sprk.so/oauth-client-metadata.json', - ); - - expect( - () => buildPdsSessionCacheFromAipResponse(response), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('missing required "sub" claim'), - ), - ), - ); - }); - - test('rejects exported access token JWTs with invalid claim types', () { - final response = _sessionResponse( - accessToken: _jwtFromPayload({ - 'sub': 'did:plc:test', - 'exp': '2030-01-01T00:00:00Z', - 'iat': DateTime.utc(2029, 1, 1).millisecondsSinceEpoch ~/ 1000, - }), - clientId: 'https://auth.sprk.so/oauth-client-metadata.json', - ); + final cache = buildPdsSessionCacheFromAipResponse(response); + final restored = restorePdsOAuthSessionFromCache(cache); + expect(restored.accessToken, 'opaque-aip-access-token'); + expect(restored.tokenType, 'dpop'); + expect(restored.scope, 'atproto'); + expect(restored.expiresAt, DateTime.utc(2030, 1, 1)); + expect(restored.sub, 'did:plc:test'); + expect(restored.atprotoPdsEndpoint, 'pds.sprk.so'); expect( - () => buildPdsSessionCacheFromAipResponse(response), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('invalid required numeric "exp" claim'), - ), - ), + restored.$clientId, + 'https://auth.sprk.so/oauth-client-metadata.json', ); }); }); diff --git a/test/src/core/design_system/components/interactive_atoms_test.dart b/test/src/core/design_system/components/interactive_atoms_test.dart index 61542d5..c81af38 100644 --- a/test/src/core/design_system/components/interactive_atoms_test.dart +++ b/test/src/core/design_system/components/interactive_atoms_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/semantics.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spark/src/core/design_system/components/atoms/buttons/interactive_pressable.dart'; @@ -48,10 +47,11 @@ void main() { await tester.tap(find.byType(InteractivePressable)); final node = tester.getSemantics(find.byType(InteractivePressable)); + final bool? isEnabled = node.flagsCollection.isEnabled.toBoolOrNull(); expect(tapCount, 0); - expect(node.hasFlag(SemanticsFlag.isButton), isTrue); - expect(node.hasFlag(SemanticsFlag.hasEnabledState), isTrue); - expect(node.hasFlag(SemanticsFlag.isEnabled), isFalse); + expect(node.flagsCollection.isButton, isTrue); + expect(isEnabled, isNotNull); + expect(isEnabled, isFalse); semanticsHandle.dispose(); }); -- 2.51.2