diff --git a/packages/poptart/CHANGELOG.md b/packages/poptart/CHANGELOG.md index db7d68790..213fa1f9b 100644 --- a/packages/poptart/CHANGELOG.md +++ b/packages/poptart/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Note +## v0.1.1 + +- Bump `poptart_core` and `poptart_oauth` for opaque OAuth token restore support. + ## v0.1.0 - Initial Poptart umbrella package release. diff --git a/packages/poptart/pubspec.yaml b/packages/poptart/pubspec.yaml index 244cd9ad1..0da1b25d9 100644 --- a/packages/poptart/pubspec.yaml +++ b/packages/poptart/pubspec.yaml @@ -1,6 +1,6 @@ name: poptart resolution: workspace -version: 0.1.0 +version: 0.1.1 description: Friendly Dart and Flutter SDK entrypoint for AT Protocol sessions, OAuth, XRPC, and core Poptart primitives. homepage: https://poptart.xyz documentation: https://poptart.xyz @@ -11,8 +11,8 @@ environment: sdk: ">=3.8.0 <4.0.0" dependencies: - poptart_core: ^0.1.0 - poptart_oauth: ^0.1.0 + poptart_core: ^0.1.1 + poptart_oauth: ^0.1.1 poptart_primitives: ^0.1.0 poptart_xrpc: ^0.1.0 diff --git a/packages/poptart_core/CHANGELOG.md b/packages/poptart_core/CHANGELOG.md index ec662d859..06685a75e 100644 --- a/packages/poptart_core/CHANGELOG.md +++ b/packages/poptart_core/CHANGELOG.md @@ -1,5 +1,10 @@ # Release Note +## v0.1.1 + +- Restore OAuth sessions from persisted metadata without requiring JWT access tokens. +- Preserve restored PDS endpoints so opaque-token sessions route to the correct service. + ## v0.1.0 - Initial Poptart fork release under the new `poptart_*` package name. diff --git a/packages/poptart_core/lib/src/types/oauth_session.dart b/packages/poptart_core/lib/src/types/oauth_session.dart index a6b32f6af..14cf281b8 100644 --- a/packages/poptart_core/lib/src/types/oauth_session.dart +++ b/packages/poptart_core/lib/src/types/oauth_session.dart @@ -9,21 +9,38 @@ import 'jwt.dart'; OAuthSession restoreOAuthSession({ required String accessToken, required String refreshToken, + String? tokenType, + String? scope, + DateTime? expiresAt, + String? sub, String? clientId, + String? pdsEndpoint, String? dPoPNonce, required String publicKey, required String privateKey, }) { - final jwt = decodeJwt(accessToken); + Jwt? jwt; + + if (scope == null || expiresAt == null || sub == null) { + try { + jwt = decodeJwt(accessToken); + } catch (_) { + throw const FormatException( + 'OAuth session restore requires scope, expiresAt, and sub when ' + 'accessToken is opaque.', + ); + } + } return OAuthSession( accessToken: accessToken, refreshToken: refreshToken, - tokenType: 'DPoP', - scope: jwt.scope ?? '', - expiresAt: jwt.exp, - sub: jwt.sub, + tokenType: tokenType ?? 'DPoP', + scope: scope ?? jwt?.scope ?? '', + expiresAt: expiresAt ?? jwt!.exp, + sub: sub ?? jwt!.sub, $clientId: clientId, + $pdsEndpoint: _normalizeAtprotoPdsEndpoint(pdsEndpoint), $dPoPNonce: dPoPNonce ?? '', $publicKey: publicKey, $privateKey: privateKey, @@ -44,12 +61,23 @@ extension OauthSessionExtension on OAuthSession { } /// Returns the OAuth client identifier if it is present on either token. - String? get clientId => - $clientId ?? accessTokenJwt.clientId ?? refreshTokenJwt?.clientId; + String? get clientId { + if ($clientId != null) return $clientId; + + try { + final accessTokenClientId = accessTokenJwt.clientId; + if (accessTokenClientId != null) return accessTokenClientId; + } catch (_) {} + + return refreshTokenJwt?.clientId; + } /// Returns PDS endpoint like `porcini.us-east.host.bsky.network` dynamically /// based on this [OAuthSession]. String? get atprotoPdsEndpoint { + final pdsEndpoint = _normalizeAtprotoPdsEndpoint($pdsEndpoint); + if (pdsEndpoint != null) return pdsEndpoint; + try { return accessTokenJwt.atprotoPdsEndpoint; } catch (_) { @@ -57,3 +85,17 @@ extension OauthSessionExtension on OAuthSession { } } } + +String? _normalizeAtprotoPdsEndpoint(String? endpoint) { + if (endpoint == null || endpoint.isEmpty) return null; + if (endpoint.startsWith('did:web:')) { + return endpoint.replaceFirst('did:web:', ''); + } + + final uri = Uri.tryParse(endpoint); + if (uri != null && uri.hasScheme && uri.host.isNotEmpty) { + return uri.host; + } + + return endpoint; +} diff --git a/packages/poptart_core/pubspec.yaml b/packages/poptart_core/pubspec.yaml index 251fb1aaf..56d34e909 100644 --- a/packages/poptart_core/pubspec.yaml +++ b/packages/poptart_core/pubspec.yaml @@ -1,7 +1,7 @@ name: poptart_core resolution: workspace description: Core library for clients and tools. This package is mainly used by https://atprotodart.com packages. -version: 0.1.0 +version: 0.1.1 homepage: https://poptart.xyz documentation: https://poptart.xyz repository: https://github.com/sprksocial/poptart @@ -25,7 +25,7 @@ dependencies: json_annotation: ^4.9.0 cbor: ^6.3.7 poptart_multiformats: ^0.1.0 - poptart_oauth: ^0.1.0 + poptart_oauth: ^0.1.1 nanoid: ^1.0.0 meta: ^1.17.0 diff --git a/packages/poptart_core/test/src/types/oauth_session_test.dart b/packages/poptart_core/test/src/types/oauth_session_test.dart new file mode 100644 index 000000000..b0bb4208a --- /dev/null +++ b/packages/poptart_core/test/src/types/oauth_session_test.dart @@ -0,0 +1,131 @@ +// Dart imports: +import 'dart:convert'; + +// Package imports: +import 'package:test/test.dart'; + +// Project imports: +import 'package:poptart_core/poptart_core.dart'; + +void main() { + group('restoreOAuthSession', () { + test('restores opaque OAuth tokens from persisted token metadata', () { + final expiresAt = DateTime.utc(2026, 1, 1); + + final session = restoreOAuthSession( + accessToken: 'opaque-access-token', + refreshToken: 'opaque-refresh-token', + tokenType: 'DPoP', + scope: 'atproto transition:generic', + expiresAt: expiresAt, + sub: 'did:plc:account', + clientId: 'https://example.com/oauth/client-metadata.json', + pdsEndpoint: 'https://cirrus.knotbin.net', + dPoPNonce: 'nonce', + publicKey: 'public-key', + privateKey: 'private-key', + ); + + expect(session.accessToken, 'opaque-access-token'); + expect(session.refreshToken, 'opaque-refresh-token'); + expect(session.tokenType, 'DPoP'); + expect(session.scope, 'atproto transition:generic'); + expect(session.expiresAt, expiresAt); + expect(session.sub, 'did:plc:account'); + expect( + session.clientId, + 'https://example.com/oauth/client-metadata.json', + ); + expect(session.atprotoPdsEndpoint, 'cirrus.knotbin.net'); + }); + + test('uses persisted PDS endpoint for restored opaque OAuth sessions', () { + final session = restoreOAuthSession( + accessToken: 'opaque-access-token', + refreshToken: 'opaque-refresh-token', + tokenType: 'DPoP', + scope: 'atproto', + expiresAt: DateTime.utc(2026, 1, 1), + sub: 'did:plc:account', + clientId: 'https://example.com/oauth/client-metadata.json', + pdsEndpoint: 'cirrus.knotbin.net', + publicKey: 'public-key', + privateKey: 'private-key', + ); + + expect(session.atprotoPdsEndpoint, 'cirrus.knotbin.net'); + expect( + PoptartClient.fromOAuthSession(session).service, + 'cirrus.knotbin.net', + ); + }); + + test( + 'throws a helpful error for opaque tokens without persisted metadata', + () { + expect( + () => restoreOAuthSession( + accessToken: 'opaque-access-token', + refreshToken: 'opaque-refresh-token', + publicKey: 'public-key', + privateKey: 'private-key', + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('accessToken is opaque'), + ), + ), + ); + }, + ); + + test( + 'reads client ID from refresh JWT when the access token is opaque', + () { + final session = restoreOAuthSession( + accessToken: 'opaque-access-token', + refreshToken: _jwt({ + 'sub': 'did:plc:account', + 'client_id': 'https://example.com/oauth/client-metadata.json', + 'exp': 1893456000, + 'iat': 1893452400, + }), + scope: 'atproto', + expiresAt: DateTime.utc(2026, 1, 1), + sub: 'did:plc:account', + publicKey: 'public-key', + privateKey: 'private-key', + ); + + expect( + session.clientId, + 'https://example.com/oauth/client-metadata.json', + ); + }, + ); + + test('returns null client ID when opaque tokens do not include one', () { + final session = restoreOAuthSession( + accessToken: 'opaque-access-token', + refreshToken: 'opaque-refresh-token', + scope: 'atproto', + expiresAt: DateTime.utc(2026, 1, 1), + sub: 'did:plc:account', + publicKey: 'public-key', + privateKey: 'private-key', + ); + + expect(session.clientId, isNull); + }); + }); +} + +String _jwt(Map payload) { + final encodedPayload = base64Url + .encode(utf8.encode(jsonEncode(payload))) + .replaceAll('=', ''); + + return 'header.$encodedPayload.signature'; +} diff --git a/packages/poptart_oauth/CHANGELOG.md b/packages/poptart_oauth/CHANGELOG.md index ff4e34c41..c5d84d99f 100644 --- a/packages/poptart_oauth/CHANGELOG.md +++ b/packages/poptart_oauth/CHANGELOG.md @@ -1,5 +1,9 @@ # Release Note +## v0.1.1 + +- Preserve PDS endpoint information on OAuth sessions for opaque access tokens. + ## v0.1.0 - Initial Poptart fork release under the new `poptart_*` package name. diff --git a/packages/poptart_oauth/lib/src/oauth_client.dart b/packages/poptart_oauth/lib/src/oauth_client.dart index 61d887d09..49b70e17f 100644 --- a/packages/poptart_oauth/lib/src/oauth_client.dart +++ b/packages/poptart_oauth/lib/src/oauth_client.dart @@ -282,6 +282,11 @@ final class OAuthClient { ), sub: body['sub'], $clientId: metadata.clientId, + $pdsEndpoint: _resolvePdsEndpoint( + body, + accessToken: body['access_token'], + fallback: service, + ), $dPoPNonce: response.headers['dpop-nonce']!, $publicKey: publicKey, $privateKey: privateKey, @@ -376,9 +381,53 @@ final class OAuthClient { ), sub: body['sub'], $clientId: metadata.clientId, + $pdsEndpoint: _resolvePdsEndpoint( + body, + accessToken: body['access_token'], + fallback: session.$pdsEndpoint ?? service, + ), $dPoPNonce: response.headers['dpop-nonce']!, $publicKey: session.$publicKey, $privateKey: session.$privateKey, ); } } + +String? _resolvePdsEndpoint( + Object? body, { + required Object? accessToken, + String? fallback, +}) { + if (body is Map) { + final pdsEndpoint = body['pds_endpoint']; + if (pdsEndpoint is String && pdsEndpoint.isNotEmpty) { + return pdsEndpoint; + } + } + + if (accessToken is String && _looksLikeJwt(accessToken)) { + return null; + } + + if (fallback != null && fallback.isNotEmpty) { + return fallback; + } + + return null; +} + +bool _looksLikeJwt(String token) { + final parts = token.split('.'); + if (parts.length != 3 || parts.any((part) => part.isEmpty)) { + return false; + } + + try { + final payload = jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ); + return payload is Map; + } catch (_) { + return false; + } +} diff --git a/packages/poptart_oauth/lib/src/types/session.dart b/packages/poptart_oauth/lib/src/types/session.dart index bf6366e1c..13894ad0c 100644 --- a/packages/poptart_oauth/lib/src/types/session.dart +++ b/packages/poptart_oauth/lib/src/types/session.dart @@ -32,6 +32,7 @@ /// scope: 'profile email', /// expiresAt: DateTime.now().add(Duration(hours: 1)), /// sub: '123456789', +/// $pdsEndpoint: 'porcini.us-east.host.bsky.network', /// $dPoPNonce: 'nonce-7654321', /// $publicKey: 'eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2Iiwia2lkIjoiMTI...', /// $privateKey: 'eyJrdHkiOiJFQyIsImNydiI6IlAtMjU2Iiwia2lkIjoi...', @@ -50,6 +51,7 @@ final class OAuthSession { /// - [expiresAt]: Token expiration timestamp /// - [sub]: Subject identifier for token binding /// - [$clientId]: OAuth client identifier used to mint the session + /// - [$pdsEndpoint]: Resource server endpoint for opaque tokens /// - [$dPoPNonce]: Server-provided nonce for DPoP proof freshness /// - [$publicKey]: Base64URL encoded public key for DPoP proof verification /// - [$privateKey]: Base64URL encoded private key for DPoP proof generation @@ -61,6 +63,7 @@ final class OAuthSession { required this.expiresAt, required this.sub, this.$clientId, + this.$pdsEndpoint, required this.$dPoPNonce, required this.$publicKey, required this.$privateKey, @@ -108,6 +111,12 @@ final class OAuthSession { /// the original client identifier to continue building DPoP proofs later. final String? $clientId; + /// Resource server endpoint used for AT Protocol XRPC requests. + /// + /// Providers that issue opaque access tokens do not expose the endpoint in + /// JWT claims, so restored sessions can keep the endpoint alongside the token. + final String? $pdsEndpoint; + /// Server-provided DPoP nonce. /// /// Used to ensure DPoP proof freshness and prevent diff --git a/packages/poptart_oauth/pubspec.yaml b/packages/poptart_oauth/pubspec.yaml index 6c31caa32..a48b0e31d 100644 --- a/packages/poptart_oauth/pubspec.yaml +++ b/packages/poptart_oauth/pubspec.yaml @@ -1,7 +1,7 @@ name: poptart_oauth resolution: workspace description: Provides tools to handle OAuth for AT Protocol and Bluesky Social. -version: 0.1.0 +version: 0.1.1 homepage: https://poptart.xyz documentation: https://poptart.xyz repository: https://github.com/sprksocial/poptart diff --git a/website/content/5.oauth-sign-in.md b/website/content/5.oauth-sign-in.md index 5e4bdd5b7..ac23aa367 100644 --- a/website/content/5.oauth-sign-in.md +++ b/website/content/5.oauth-sign-in.md @@ -121,7 +121,12 @@ Persist every field needed to refresh and sign future requests: final saved = { 'accessToken': session.accessToken, 'refreshToken': session.refreshToken, + 'tokenType': session.tokenType, + 'scope': session.scope, + 'expiresAt': session.expiresAt.toIso8601String(), + 'sub': session.sub, 'clientId': session.$clientId ?? '', + 'pdsEndpoint': session.atprotoPdsEndpoint ?? '', 'dpopNonce': session.$dPoPNonce, 'publicKey': session.$publicKey, 'privateKey': session.$privateKey, @@ -137,7 +142,12 @@ To restore a saved session, use `restoreOAuthSession` from the umbrella package: final restored = restoreOAuthSession( accessToken: saved['accessToken']!, refreshToken: saved['refreshToken']!, + tokenType: saved['tokenType'], + scope: saved['scope'], + expiresAt: DateTime.parse(saved['expiresAt']!), + sub: saved['sub'], clientId: saved['clientId'], + pdsEndpoint: saved['pdsEndpoint'], dPoPNonce: saved['dpopNonce'], publicKey: saved['publicKey']!, privateKey: saved['privateKey']!, @@ -146,8 +156,10 @@ final restored = restoreOAuthSession( final client = PoptartClient.fromOAuthSession(restored); ``` -`restoreOAuthSession` decodes the access token to recover the scope, -expiration, subject, and PDS endpoint information. +When the access token is a JWT, `restoreOAuthSession` can recover missing token +metadata from the token payload. For providers that issue opaque access tokens, +persist and restore `scope`, `expiresAt`, `sub`, and the PDS endpoint from the +original token response. ## Refresh Before The Access Token Expires