diff --git a/packages/atproto_core/lib/src/clients/service_context.dart b/packages/atproto_core/lib/src/clients/service_context.dart index 0dc565e38..a218131ab 100644 --- a/packages/atproto_core/lib/src/clients/service_context.dart +++ b/packages/atproto_core/lib/src/clients/service_context.dart @@ -149,20 +149,27 @@ base class ServiceContext { } if (oAuthSession != null) { - final jwt = oAuthSession!.accessTokenJwt; + final oauthSession = oAuthSession!; + final clientId = oauthSession.clientId; + + if (clientId == null || clientId.isEmpty) { + throw const FormatException( + 'OAuth token is missing client_id and cannot build DPoP headers.', + ); + } + final dPoPHeader = getDPoPHeader( - clientId: jwt.clientId!, + clientId: clientId, endpoint: endpoint.toString(), method: method, - authorizationServer: jwt.iss, - accessToken: oAuthSession!.accessToken, - dPoPNonce: oAuthSession!.$dPoPNonce, - publicKey: oAuthSession!.$publicKey, - privateKey: oAuthSession!.$privateKey, + accessToken: oauthSession.accessToken, + dPoPNonce: oauthSession.$dPoPNonce, + publicKey: oauthSession.$publicKey, + privateKey: oauthSession.$privateKey, ); return { - 'Authorization': 'DPoP ${oAuthSession!.accessToken}', + 'Authorization': 'DPoP ${oauthSession.accessToken}', 'DPoP': dPoPHeader, ...header, }; diff --git a/packages/atproto_core/lib/src/types/oauth_session.dart b/packages/atproto_core/lib/src/types/oauth_session.dart index 8686949bb..a858fd7ec 100644 --- a/packages/atproto_core/lib/src/types/oauth_session.dart +++ b/packages/atproto_core/lib/src/types/oauth_session.dart @@ -13,6 +13,7 @@ import 'jwt.dart'; OAuthSession restoreOAuthSession({ required String accessToken, required String refreshToken, + String? clientId, String? dPoPNonce, required String publicKey, required String privateKey, @@ -26,6 +27,7 @@ OAuthSession restoreOAuthSession({ scope: jwt.scope ?? '', expiresAt: jwt.exp, sub: jwt.sub, + $clientId: clientId, $dPoPNonce: dPoPNonce ?? '', $publicKey: publicKey, $privateKey: privateKey, @@ -36,6 +38,19 @@ extension OauthSessionExtension on OAuthSession { /// Returns decoded [accessToken]. Jwt get accessTokenJwt => decodeJwt(accessToken); + /// Returns decoded [refreshToken]. + Jwt? get refreshTokenJwt { + try { + return decodeJwt(refreshToken); + } catch (_) { + return null; + } + } + + /// Returns the OAuth client identifier if it is present on either token. + String? get clientId => + $clientId ?? accessTokenJwt.clientId ?? refreshTokenJwt?.clientId; + /// Returns PDS endpoint like `porcini.us-east.host.bsky.network` dynamically /// based on this [OAuthSession]. String? get atprotoPdsEndpoint { diff --git a/packages/atproto_core/lib/src/utils/jwt_decoder.dart b/packages/atproto_core/lib/src/utils/jwt_decoder.dart index e336ed4c8..bb423b83e 100644 --- a/packages/atproto_core/lib/src/utils/jwt_decoder.dart +++ b/packages/atproto_core/lib/src/utils/jwt_decoder.dart @@ -11,11 +11,19 @@ import '../types/jwt.dart'; /// Returns the decoded [Jwt] based on [jwt]. Jwt decodeJwt(final String jwt) { try { - return Jwt.fromJson( - jsonDecode( - utf8.decode(base64.decode(base64.normalize(jwt.split('.')[1]))), - ), + final decoded = jsonDecode( + utf8.decode(base64.decode(base64.normalize(jwt.split('.')[1]))), ); + if (decoded is! Map) { + throw const FormatException('Invalid JWT payload.'); + } + + final cnf = decoded['cnf']; + if (cnf != null && cnf is! Map) { + decoded.remove('cnf'); + } + + return Jwt.fromJson(decoded); } catch (_) { throw const FormatException('Invalid JWT.'); } diff --git a/packages/atproto_core/test/src/clients/service_context_test.dart b/packages/atproto_core/test/src/clients/service_context_test.dart index e6701fa5d..15393d358 100644 --- a/packages/atproto_core/test/src/clients/service_context_test.dart +++ b/packages/atproto_core/test/src/clients/service_context_test.dart @@ -1,4 +1,16 @@ +// ignore_for_file: depend_on_referenced_packages + +// Dart imports: +import 'dart:convert'; + // Package imports: +import 'package:at_primitives/nsid.dart'; +import 'package:atproto_oauth/atproto_oauth.dart'; +import 'package:atproto_oauth/src/helper/helper.dart' show getKeyPair; +import 'package:atproto_oauth/src/helper/private_key.dart' + show encodePrivateKey; +import 'package:atproto_oauth/src/helper/public_key.dart' show encodePublicKey; +import 'package:http/http.dart' as http; import 'package:test/test.dart'; // Project imports: @@ -153,4 +165,127 @@ void main() { expect(context.service, 'bsky.app'); }); }); + + group('.get', () { + test('generates JOSE-compatible DPoP proofs', () { + final keyPair = getKeyPair(); + final publicKey = encodePublicKey(keyPair.publicKey as dynamic); + final privateKey = encodePrivateKey(keyPair.privateKey as dynamic); + + final dPoPProof = getDPoPHeader( + clientId: 'https://sprk.so/oauth-client-metadata.json', + endpoint: 'https://pds.sprk.so/xrpc/com.atproto.server.describeServer', + method: 'GET', + dPoPNonce: 'nonce', + publicKey: publicKey, + privateKey: privateKey, + ); + + final segments = dPoPProof.split('.'); + + expect(segments, hasLength(3)); + + for (final segment in segments) { + expect(segment, isNotEmpty); + expect(segment, isNot(contains('+'))); + expect(segment, isNot(contains('/'))); + expect(segment, isNot(contains('='))); + } + + expect(base64Url.decode(base64Url.normalize(segments[2])), hasLength(64)); + + final payload = _decodeJwtPayload(dPoPProof); + expect(payload, isNot(contains('iss'))); + + final boundDPoPProof = getDPoPHeader( + clientId: 'https://sprk.so/oauth-client-metadata.json', + endpoint: 'https://pds.sprk.so/xrpc/com.atproto.server.describeServer', + method: 'GET', + dPoPNonce: 'nonce', + authorizationServer: 'https://auth.sprk.so', + accessToken: 'access-token', + publicKey: publicKey, + privateKey: privateKey, + ); + + final boundPayload = _decodeJwtPayload(boundDPoPProof); + expect(boundPayload['iss'], 'https://auth.sprk.so'); + }); + + test( + 'uses stored client id for DPoP headers when tokens omit client_id and iss', + () async { + final keyPair = getKeyPair(); + final publicKey = encodePublicKey(keyPair.publicKey as dynamic); + final privateKey = encodePrivateKey(keyPair.privateKey as dynamic); + Map? requestHeaders; + + final context = ServiceContext( + oAuthSession: OAuthSession( + accessToken: _jwt({ + 'sub': 'did:plc:testaccount', + 'scope': 'atproto transition:generic', + 'aud': 'did:web:pds.sprk.so', + 'exp': 1893456000, + 'iat': 1893452400, + }), + refreshToken: _jwt({ + 'sub': 'did:plc:testaccount', + 'exp': 1893542400, + 'iat': 1893452400, + }), + tokenType: 'DPoP', + scope: 'atproto transition:generic', + expiresAt: DateTime.utc(2030), + sub: 'did:plc:testaccount', + $clientId: 'https://sprk.so/oauth-client-metadata.json', + $dPoPNonce: 'nonce', + $publicKey: publicKey, + $privateKey: privateKey, + ), + getClient: (url, {headers}) async { + requestHeaders = headers; + + return http.Response( + '{}', + 200, + headers: {'content-type': 'application/json'}, + request: http.Request('GET', url), + ); + }, + ); + + await context.get>( + NSID.create('server.atproto.com', 'describeServer'), + to: (json) => json, + ); + + expect( + requestHeaders?['Authorization'], + 'DPoP ${context.oAuthSession!.accessToken}', + ); + expect(requestHeaders?['DPoP'], isNotEmpty); + + final payload = _decodeJwtPayload(requestHeaders!['DPoP']!); + expect(payload, contains('ath')); + expect(payload, isNot(contains('iss'))); + }, + ); + }); +} + +String _jwt(Map payload) { + final encodedPayload = base64Url + .encode(utf8.encode(jsonEncode(payload))) + .replaceAll('=', ''); + + return 'header.$encodedPayload.signature'; +} + +Map _decodeJwtPayload(String jwt) { + final parts = jwt.split('.'); + return jsonDecode( + utf8.decode(base64Url.decode(base64Url.normalize(parts[1]))), + ) + as Map; } diff --git a/packages/atproto_core/test/utils_test.dart b/packages/atproto_core/test/utils_test.dart index 068332812..a267ce266 100644 --- a/packages/atproto_core/test/utils_test.dart +++ b/packages/atproto_core/test/utils_test.dart @@ -1,8 +1,12 @@ +// Dart imports: +import 'dart:convert'; + // Package imports: import 'package:test/test.dart'; // Project imports: import 'package:atproto_core/src/utils.dart'; +import 'package:atproto_core/src/utils/jwt_decoder.dart'; void main() { group('.isValidAppPassword', () { @@ -54,4 +58,25 @@ void main() { expect(isValidAppPassword('han5-7%4r-t6j3-mit6w'), isFalse); }); }); + + group('.decodeJwt', () { + test('ignores non-object cnf claims', () { + final jwt = _jwt({ + 'sub': 'did:plc:testaccount', + 'cnf': 'legacy-key-binding', + 'exp': 1893456000, + 'iat': 1893452400, + }); + + expect(decodeJwt(jwt).cnf, isNull); + }); + }); +} + +String _jwt(Map payload) { + final encodedPayload = base64Url + .encode(utf8.encode(jsonEncode(payload))) + .replaceAll('=', ''); + + return 'header.$encodedPayload.signature'; } diff --git a/packages/atproto_oauth/lib/src/helper/helper.dart b/packages/atproto_oauth/lib/src/helper/helper.dart index 0ca1b7fc6..fa756ec69 100644 --- a/packages/atproto_oauth/lib/src/helper/helper.dart +++ b/packages/atproto_oauth/lib/src/helper/helper.dart @@ -113,7 +113,7 @@ AsymmetricKeyPair getKeyPair() { /// "jti": "random_unique_id", /// "iat": timestamp, /// "nonce": "dpop_nonce", -/// "iss": "client_id_or_auth_server", +/// "iss": "optional_authorization_server", /// "ath": "optional_access_token_hash" /// } /// ``` @@ -177,11 +177,12 @@ String getDPoPHeader({ 'nonce': dPoPNonce, }; - if (authorizationServer != null && accessToken != null) { + if (authorizationServer != null) { payload['iss'] = authorizationServer; + } + + if (accessToken != null) { payload['ath'] = hashS256(accessToken); - } else { - payload['iss'] = clientId; } final headerBase64 = base64UrlEncode( @@ -192,7 +193,9 @@ String getDPoPHeader({ ).replaceAll('=', ''); final jwtMessage = '$headerBase64.$payloadBase64'; - final jwtSignature = base64Encode(_sign(privateKey, jwtMessage)); + final jwtSignature = base64UrlEncode( + _sign(privateKey, jwtMessage), + ).replaceAll('=', ''); return '$headerBase64.$payloadBase64.$jwtSignature'; } diff --git a/packages/atproto_oauth/lib/src/oauth_client.dart b/packages/atproto_oauth/lib/src/oauth_client.dart index 8a99a507d..88463262a 100644 --- a/packages/atproto_oauth/lib/src/oauth_client.dart +++ b/packages/atproto_oauth/lib/src/oauth_client.dart @@ -285,6 +285,7 @@ final class OAuthClient { Duration(seconds: body['expires_in']), ), sub: body['sub'], + $clientId: metadata.clientId, $dPoPNonce: response.headers['dpop-nonce']!, $publicKey: publicKey, $privateKey: privateKey, @@ -378,6 +379,7 @@ final class OAuthClient { Duration(seconds: body['expires_in']), ), sub: body['sub'], + $clientId: metadata.clientId, $dPoPNonce: response.headers['dpop-nonce']!, $publicKey: session.$publicKey, $privateKey: session.$privateKey, diff --git a/packages/atproto_oauth/lib/src/types/session.dart b/packages/atproto_oauth/lib/src/types/session.dart index 7b102fe9a..74cdb1ced 100644 --- a/packages/atproto_oauth/lib/src/types/session.dart +++ b/packages/atproto_oauth/lib/src/types/session.dart @@ -53,6 +53,7 @@ final class OAuthSession { /// - [scope]: Space-delimited OAuth 2.0 scopes /// - [expiresAt]: Token expiration timestamp /// - [sub]: Subject identifier for token binding + /// - [$clientId]: OAuth client identifier used to mint the session /// - [$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 @@ -63,6 +64,7 @@ final class OAuthSession { required this.scope, required this.expiresAt, required this.sub, + this.$clientId, required this.$dPoPNonce, required this.$publicKey, required this.$privateKey, @@ -104,6 +106,12 @@ final class OAuthSession { /// to a specific user or entity. final String sub; + /// OAuth client identifier used to obtain this session. + /// + /// Some providers do not mirror `client_id` into issued tokens, so we keep + /// the original client identifier to continue building DPoP proofs later. + final String? $clientId; + /// Server-provided DPoP nonce. /// /// Used to ensure DPoP proof freshness and prevent