From 938fa745cce14239e0d8d5e83de6e7a99d0b94d2 Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:24:14 -0800 Subject: [PATCH 01/11] feat: add CovesSession model for simplified backend OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplified session model that works with the Coves backend's sealed token architecture. The backend handles all OAuth complexity (DPoP, PKCE, token refresh) and gives us an opaque AES-256-GCM encrypted token. Key features: - Parse session from OAuth callback URI (RFC 8252 private-use scheme) - JSON serialization for secure storage persistence - Immutable with copyWithToken for refresh operations - Proper redaction of sensitive data in toString() 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/models/coves_session.dart | 112 ++++ test/models/coves_session_test.dart | 799 ++++++++++++++++++++++++++++ 2 files changed, 911 insertions(+) create mode 100644 lib/models/coves_session.dart create mode 100644 test/models/coves_session_test.dart diff --git a/lib/models/coves_session.dart b/lib/models/coves_session.dart new file mode 100644 index 0000000..0b69730 --- /dev/null +++ b/lib/models/coves_session.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +/// Coves Session Model +/// +/// Simplified session model for the backend OAuth flow. +/// The backend handles all the complexity (DPoP, PKCE, token refresh) +/// and gives us a sealed token that's opaque to the client. +/// +/// This replaces the complex TokenSet + DPoP keys from atproto_oauth_flutter. +class CovesSession { + const CovesSession({ + required this.token, + required this.did, + required this.sessionId, + this.handle, + }); + + /// Create a session from OAuth callback parameters + /// + /// Expected URL format (RFC 8252 private-use URI scheme): + /// `social.coves:/callback?token=...&did=...&session_id=...&handle=...` + factory CovesSession.fromCallbackUri(Uri uri) { + final token = uri.queryParameters['token']; + final did = uri.queryParameters['did']; + final sessionId = uri.queryParameters['session_id']; + final handle = uri.queryParameters['handle']; + + if (token == null || token.isEmpty) { + throw const FormatException('Missing required parameter: token'); + } + if (did == null || did.isEmpty) { + throw const FormatException('Missing required parameter: did'); + } + if (sessionId == null || sessionId.isEmpty) { + throw const FormatException('Missing required parameter: session_id'); + } + + return CovesSession( + token: Uri.decodeComponent(token), + did: did, + sessionId: sessionId, + handle: handle, + ); + } + + /// Create a session from JSON (for storage restoration) + factory CovesSession.fromJson(Map json) { + return CovesSession( + token: json['token'] as String, + did: json['did'] as String, + sessionId: json['session_id'] as String, + handle: json['handle'] as String?, + ); + } + + /// Create a session from a JSON string + factory CovesSession.fromJsonString(String jsonString) { + return CovesSession.fromJson( + jsonDecode(jsonString) as Map, + ); + } + + /// The sealed session token (AES-256-GCM encrypted by backend) + /// + /// This token is opaque to the client - we just store and send it. + /// Use in Authorization header: `Authorization: Bearer $token` + final String token; + + /// User's DID (decentralized identifier) + /// + /// Example: did:plc:abc123 + final String did; + + /// Session ID for refresh operations + /// + /// The backend uses this to identify the session for token refresh. + final String sessionId; + + /// User's handle (optional) + /// + /// Example: alice.bsky.social + /// May be null if the backend didn't include it in the callback. + final String? handle; + + /// Convert to JSON for storage + Map toJson() { + return { + 'token': token, + 'did': did, + 'session_id': sessionId, + if (handle != null) 'handle': handle, + }; + } + + /// Convert to JSON string for storage + String toJsonString() => jsonEncode(toJson()); + + /// Create a copy with updated token (for refresh) + CovesSession copyWithToken(String newToken) { + return CovesSession( + token: newToken, + did: did, + sessionId: sessionId, + handle: handle, + ); + } + + @override + String toString() { + return 'CovesSession(did: $did, handle: $handle, sessionId: $sessionId)'; + } +} diff --git a/test/models/coves_session_test.dart b/test/models/coves_session_test.dart new file mode 100644 index 0000000..0badb4f --- /dev/null +++ b/test/models/coves_session_test.dart @@ -0,0 +1,799 @@ +import 'dart:convert'; + +import 'package:coves_flutter/models/coves_session.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('CovesSession.fromCallbackUri()', () { + test('should parse valid URI with all parameters', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=did:plc:test123&session_id=sess456&handle=test.user', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, 'test.user'); + }); + + test('should parse valid URI without optional handle', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, null); + }); + + test('should throw FormatException when token is missing', () { + final uri = Uri.parse( + 'social.coves:/callback?did=did:plc:test123&session_id=sess456', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: token', + ), + ), + ); + }); + + test('should throw FormatException when did is missing', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&session_id=sess456', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: did', + ), + ), + ); + }); + + test('should throw FormatException when session_id is missing', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=did:plc:test123', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: session_id', + ), + ), + ); + }); + + test('should throw FormatException when token is empty', () { + final uri = Uri.parse( + 'social.coves:/callback?token=&did=did:plc:test123&session_id=sess456', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: token', + ), + ), + ); + }); + + test('should throw FormatException when did is empty', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=&session_id=sess456', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: did', + ), + ), + ); + }); + + test('should throw FormatException when session_id is empty', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=did:plc:test123&session_id=', + ); + + expect( + () => CovesSession.fromCallbackUri(uri), + throwsA( + isA().having( + (e) => e.message, + 'message', + 'Missing required parameter: session_id', + ), + ), + ); + }); + + test('should decode URL-encoded token values', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc%2B123%2F456%3D&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, 'abc+123/456='); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + }); + + test('should handle URL-encoded spaces in token', () { + final uri = Uri.parse( + 'social.coves:/callback?token=token%20with%20spaces&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, 'token with spaces'); + }); + + test('should ignore extra/unknown parameters', () { + final uri = Uri.parse( + 'social.coves:/callback?token=abc123&did=did:plc:test123&session_id=sess456&extra=ignored&unknown=also_ignored', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + }); + + test('should handle complex token values', () { + final uri = Uri.parse( + 'social.coves:/callback?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect( + session.token, + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U', + ); + }); + }); + + group('CovesSession.fromJson()', () { + test('should parse valid JSON with all fields', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + 'handle': 'test.user', + }; + + final session = CovesSession.fromJson(json); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, 'test.user'); + }); + + test('should parse valid JSON without optional handle', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + }; + + final session = CovesSession.fromJson(json); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, null); + }); + + test('should parse JSON with null handle', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + 'handle': null, + }; + + final session = CovesSession.fromJson(json); + + expect(session.handle, null); + }); + + test('should throw when token has wrong type', () { + final json = { + 'token': 123, // Should be String + 'did': 'did:plc:test123', + 'session_id': 'sess456', + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw when did has wrong type', () { + final json = { + 'token': 'abc123', + 'did': 123, // Should be String + 'session_id': 'sess456', + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw when session_id has wrong type', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 123, // Should be String + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw when token field is missing', () { + final json = { + 'did': 'did:plc:test123', + 'session_id': 'sess456', + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw when did field is missing', () { + final json = { + 'token': 'abc123', + 'session_id': 'sess456', + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw when session_id field is missing', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + }; + + expect( + () => CovesSession.fromJson(json), + throwsA(isA()), + ); + }); + + test('should handle extra fields in JSON', () { + final json = { + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + 'extra_field': 'ignored', + 'another_field': 123, + }; + + final session = CovesSession.fromJson(json); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + }); + }); + + group('CovesSession.fromJsonString()', () { + test('should parse valid JSON string', () { + final jsonString = jsonEncode({ + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + 'handle': 'test.user', + }); + + final session = CovesSession.fromJsonString(jsonString); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, 'test.user'); + }); + + test('should parse valid JSON string without handle', () { + final jsonString = jsonEncode({ + 'token': 'abc123', + 'did': 'did:plc:test123', + 'session_id': 'sess456', + }); + + final session = CovesSession.fromJsonString(jsonString); + + expect(session.token, 'abc123'); + expect(session.did, 'did:plc:test123'); + expect(session.sessionId, 'sess456'); + expect(session.handle, null); + }); + + test('should throw on invalid JSON string', () { + const invalidJson = '{invalid json}'; + + expect( + () => CovesSession.fromJsonString(invalidJson), + throwsA(isA()), + ); + }); + + test('should throw on empty string', () { + const emptyString = ''; + + expect( + () => CovesSession.fromJsonString(emptyString), + throwsA(isA()), + ); + }); + + test('should throw on non-JSON string', () { + const notJson = 'not a json string'; + + expect( + () => CovesSession.fromJsonString(notJson), + throwsA(isA()), + ); + }); + + test('should throw on JSON array instead of object', () { + const jsonArray = '["token", "did", "session_id"]'; + + expect( + () => CovesSession.fromJsonString(jsonArray), + throwsA(isA()), + ); + }); + + test('should throw on null JSON', () { + const nullJson = 'null'; + + expect( + () => CovesSession.fromJsonString(nullJson), + throwsA(isA()), + ); + }); + }); + + group('toJson() / toJsonString()', () { + test('should serialize to JSON with all fields', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final json = session.toJson(); + + expect(json['token'], 'abc123'); + expect(json['did'], 'did:plc:test123'); + expect(json['session_id'], 'sess456'); + expect(json['handle'], 'test.user'); + }); + + test('should serialize to JSON without handle when null', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + final json = session.toJson(); + + expect(json['token'], 'abc123'); + expect(json['did'], 'did:plc:test123'); + expect(json['session_id'], 'sess456'); + expect(json.containsKey('handle'), false); + }); + + test('should serialize to JSON string', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final jsonString = session.toJsonString(); + final decoded = jsonDecode(jsonString) as Map; + + expect(decoded['token'], 'abc123'); + expect(decoded['did'], 'did:plc:test123'); + expect(decoded['session_id'], 'sess456'); + expect(decoded['handle'], 'test.user'); + }); + + test('should round-trip: create, serialize, deserialize, compare', () { + const original = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final json = original.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.token, original.token); + expect(restored.did, original.did); + expect(restored.sessionId, original.sessionId); + expect(restored.handle, original.handle); + }); + + test('should round-trip with JSON string', () { + const original = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final jsonString = original.toJsonString(); + final restored = CovesSession.fromJsonString(jsonString); + + expect(restored.token, original.token); + expect(restored.did, original.did); + expect(restored.sessionId, original.sessionId); + expect(restored.handle, original.handle); + }); + + test('should round-trip without handle', () { + const original = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + final json = original.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.token, original.token); + expect(restored.did, original.did); + expect(restored.sessionId, original.sessionId); + expect(restored.handle, null); + }); + + test('should handle special characters in serialization', () { + const session = CovesSession( + token: 'token+with/special=chars', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'user.with.dots', + ); + + final jsonString = session.toJsonString(); + final restored = CovesSession.fromJsonString(jsonString); + + expect(restored.token, session.token); + expect(restored.handle, session.handle); + }); + }); + + group('copyWithToken()', () { + test('should create new session with updated token', () { + const original = CovesSession( + token: 'old_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final updated = original.copyWithToken('new_token'); + + expect(updated.token, 'new_token'); + expect(updated.did, original.did); + expect(updated.sessionId, original.sessionId); + expect(updated.handle, original.handle); + }); + + test('should preserve null handle when copying with new token', () { + const original = CovesSession( + token: 'old_token', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + final updated = original.copyWithToken('new_token'); + + expect(updated.token, 'new_token'); + expect(updated.did, original.did); + expect(updated.sessionId, original.sessionId); + expect(updated.handle, null); + }); + + test('should not modify original session', () { + const original = CovesSession( + token: 'old_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final updated = original.copyWithToken('new_token'); + + expect(original.token, 'old_token'); + expect(updated.token, 'new_token'); + }); + + test('should handle empty string token', () { + const original = CovesSession( + token: 'old_token', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + final updated = original.copyWithToken(''); + + expect(updated.token, ''); + expect(updated.did, original.did); + }); + + test('should handle complex token values', () { + const original = CovesSession( + token: 'old_token', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + const newToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'; + final updated = original.copyWithToken(newToken); + + expect(updated.token, newToken); + }); + }); + + group('toString()', () { + test('should not expose token in string representation', () { + const session = CovesSession( + token: 'secret_token_abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect(stringRep, isNot(contains('secret_token_abc123'))); + expect(stringRep, isNot(contains('token'))); + }); + + test('should include did in string representation', () { + const session = CovesSession( + token: 'secret_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect(stringRep, contains('did:plc:test123')); + }); + + test('should include handle in string representation', () { + const session = CovesSession( + token: 'secret_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect(stringRep, contains('test.user')); + }); + + test('should include sessionId in string representation', () { + const session = CovesSession( + token: 'secret_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect(stringRep, contains('sess456')); + }); + + test('should handle null handle in string representation', () { + const session = CovesSession( + token: 'secret_token', + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + final stringRep = session.toString(); + + expect(stringRep, contains('did:plc:test123')); + expect(stringRep, contains('sess456')); + expect(stringRep, contains('null')); + }); + + test('should follow expected format', () { + const session = CovesSession( + token: 'secret_token', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect( + stringRep, + 'CovesSession(did: did:plc:test123, handle: test.user, sessionId: sess456)', + ); + }); + }); + + group('Edge cases', () { + test('should handle very long token values', () { + final longToken = 'a' * 10000; + final session = CovesSession( + token: longToken, + did: 'did:plc:test123', + sessionId: 'sess456', + ); + + expect(session.token.length, 10000); + + final json = session.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.token, longToken); + }); + + test('should handle unicode characters in handle', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.用户.bsky.social', + ); + + final json = session.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.handle, 'test.用户.bsky.social'); + }); + + test('should handle DID with different methods', () { + const session = CovesSession( + token: 'abc123', + did: 'did:web:example.com', + sessionId: 'sess456', + ); + + final json = session.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.did, 'did:web:example.com'); + }); + + test('should handle session with colons in sessionId', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess:456:789', + ); + + final json = session.toJson(); + final restored = CovesSession.fromJson(json); + + expect(restored.sessionId, 'sess:456:789'); + }); + + test('should handle empty handle string', () { + const session = CovesSession( + token: 'abc123', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: '', + ); + + final json = session.toJson(); + + expect(json['handle'], ''); + }); + + test('should handle whitespace in token from callback URI', () { + final uri = Uri.parse( + 'social.coves:/callback?token=%20abc123%20&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + expect(session.token, ' abc123 '); + }); + + test('should handle multiple URL encoding passes', () { + // Token that's been double-encoded + final uri = Uri.parse( + 'social.coves:/callback?token=abc%252B123&did=did:plc:test123&session_id=sess456', + ); + + final session = CovesSession.fromCallbackUri(uri); + + // Uri.queryParameters decodes once, Uri.decodeComponent decodes again + expect(session.token, 'abc+123'); + }); + }); + + group('Security', () { + test('toString should not leak sensitive token data', () { + const session = CovesSession( + token: 'super_secret_encrypted_token_12345', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + // Verify the entire token is not present + expect(stringRep, isNot(contains('super_secret_encrypted_token_12345'))); + // Verify even partial token data is not present + expect(stringRep, isNot(contains('secret'))); + expect(stringRep, isNot(contains('encrypted'))); + expect(stringRep, isNot(contains('12345'))); + }); + + test('toString should be safe for logging', () { + const session = CovesSession( + token: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9', + did: 'did:plc:test123', + sessionId: 'sess456', + handle: 'test.user', + ); + + final stringRep = session.toString(); + + expect(stringRep, isNot(contains('Bearer'))); + expect(stringRep, isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))); + }); + }); +} -- 2.51.2 From 7ba3c240a6974078c2b81da5dd4ebb6aa67111c1 Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:24:46 -0800 Subject: [PATCH 02/11] feat: add CovesAuthService for backend-delegated OAuth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New authentication service that delegates OAuth complexity to the Coves backend. Instead of managing DPoP keys, PKCE, and token exchange client-side, the backend handles everything and returns sealed tokens. Key features: - Browser-based OAuth via flutter_web_auth_2 - Secure token storage per environment (prevents cross-env token reuse) - Mutex pattern for concurrent token refresh handling - Handle/DID validation with Bluesky profile URL extraction - Singleton pattern with test instance creation The backend's /oauth/mobile/login endpoint handles: - Handle → DID resolution - PDS discovery - PKCE/DPoP key generation - Token exchange and sealing (AES-256-GCM) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/services/coves_auth_service.dart | 549 ++++++++ .../coves_auth_service_environment_test.dart | 187 +++ .../coves_auth_service_redaction_test.dart | 177 +++ .../coves_auth_service_singleton_test.dart | 203 +++ test/services/coves_auth_service_test.dart | 929 ++++++++++++++ .../coves_auth_service_test.mocks.dart | 1102 +++++++++++++++++ .../coves_auth_service_validation_test.dart | 540 ++++++++ 7 files changed, 3687 insertions(+) create mode 100644 lib/services/coves_auth_service.dart create mode 100644 test/services/coves_auth_service_environment_test.dart create mode 100644 test/services/coves_auth_service_redaction_test.dart create mode 100644 test/services/coves_auth_service_singleton_test.dart create mode 100644 test/services/coves_auth_service_test.dart create mode 100644 test/services/coves_auth_service_test.mocks.dart create mode 100644 test/services/coves_auth_service_validation_test.dart diff --git a/lib/services/coves_auth_service.dart b/lib/services/coves_auth_service.dart new file mode 100644 index 0000000..d13b5f2 --- /dev/null +++ b/lib/services/coves_auth_service.dart @@ -0,0 +1,549 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; + +import '../config/environment_config.dart'; +import '../config/oauth_config.dart'; +import '../models/coves_session.dart'; + +/// Coves Authentication Service +/// +/// Simplified OAuth service that uses the Coves backend's mobile OAuth flow. +/// The backend handles all the complexity: +/// - PKCE generation +/// - DPoP key management +/// - Token exchange with PDS +/// - Token sealing (AES-256-GCM encryption) +/// - CSRF protection +/// +/// This client just needs to: +/// 1. Open browser to backend's /oauth/mobile/login +/// 2. Receive sealed token via Universal Link / custom scheme +/// 3. Store and use the sealed token +/// 4. Call /oauth/refresh when needed +/// 5. Call /oauth/logout to sign out +class CovesAuthService { + factory CovesAuthService({ + Dio? dio, + FlutterSecureStorage? storage, + }) { + _instance ??= CovesAuthService._internal(dio: dio, storage: storage); + return _instance!; + } + + CovesAuthService._internal({ + Dio? dio, + FlutterSecureStorage? storage, + }) : _storage = storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ) { + // Initialize Dio if provided, otherwise it will be initialized in initialize() + if (dio != null) { + _dio = dio; + } + } + + static CovesAuthService? _instance; + + /// Reset the singleton instance (for testing only) + @visibleForTesting + static void resetInstance() { + _instance = null; + } + + /// Create a new instance for testing with injected dependencies + @visibleForTesting + static CovesAuthService createTestInstance({ + required Dio dio, + required FlutterSecureStorage storage, + }) { + return CovesAuthService._internal(dio: dio, storage: storage); + } + + // Secure storage for session data + final FlutterSecureStorage _storage; + + // Storage key is namespaced per environment to prevent token reuse across dev/prod + // This ensures switching between builds doesn't send prod tokens to dev servers + String get _storageKey => + 'coves_session_${EnvironmentConfig.current.environment.name}'; + + // HTTP client for API calls + late final Dio _dio; + + // Current session (cached in memory) + CovesSession? _session; + + // Completer to track in-flight token refresh operations + // Ensures only one refresh happens at a time, even with concurrent calls + Completer? _refreshCompleter; + + /// Get the current session (if any) + CovesSession? get session => _session; + + /// Check if user is authenticated + bool get isAuthenticated => _session != null; + + /// Initialize the auth service + Future initialize() async { + // Set up Dio with base URL if not already provided (e.g., for testing) + // This check is necessary because _dio is late final + try { + // Try to access _dio - if it's already initialized, this won't throw + _dio.options; + } catch (_) { + // Not initialized yet, so initialize it now + _dio = Dio( + BaseOptions( + baseUrl: EnvironmentConfig.current.apiUrl, + connectTimeout: const Duration(seconds: 30), + receiveTimeout: const Duration(seconds: 30), + ), + ); + } + + if (kDebugMode) { + print('CovesAuthService initialized'); + print(' API URL: ${EnvironmentConfig.current.apiUrl}'); + print(' Redirect URI: ${OAuthConfig.redirectUri}'); + } + } + + /// Sign in with an atProto handle + /// + /// Opens the system browser to the backend's mobile OAuth endpoint. + /// The backend handles the complete OAuth flow with the user's PDS. + /// On success, redirects back to the app with sealed token parameters. + /// + /// Returns the new session on success. + /// Throws on error or user cancellation. + Future signIn(String handle) async { + try { + final normalizedHandle = validateAndNormalizeHandle(handle); + + if (kDebugMode) { + print('Starting sign-in for: $normalizedHandle'); + } + + // Build the OAuth login URL + final loginUrl = _buildLoginUrl(normalizedHandle); + + if (kDebugMode) { + print('Opening browser: $loginUrl'); + print('Callback scheme: ${OAuthConfig.callbackScheme}'); + } + + // Open browser for OAuth flow + // Backend redirects to custom scheme: social.coves:/callback + final resultUrl = await FlutterWebAuth2.authenticate( + url: loginUrl, + callbackUrlScheme: OAuthConfig.callbackScheme, + options: const FlutterWebAuth2Options( + preferEphemeral: true, // Don't persist browser session + timeout: 300, // 5 minutes + ), + ); + + if (kDebugMode) { + final redactedUrl = _redactSensitiveParams(resultUrl); + print('Received callback URL: $redactedUrl'); + } + + // Parse the callback URL to extract session data + final callbackUri = Uri.parse(resultUrl); + final session = CovesSession.fromCallbackUri(callbackUri); + + if (kDebugMode) { + print('Session created: $session'); + } + + // Store the session securely + await _saveSession(session); + + // Cache in memory + _session = session; + + if (kDebugMode) { + print('Sign-in successful!'); + print(' DID: ${session.did}'); + print(' Handle: ${session.handle}'); + } + + return session; + } on Exception catch (e) { + if (kDebugMode) { + print('Sign-in failed: $e'); + } + + // Check for user cancellation + if (e.toString().contains('CANCELED') || + e.toString().contains('cancelled')) { + throw Exception('Sign in cancelled by user'); + } + + throw Exception('Sign in failed: $e'); + } + } + + /// Restore a previous session from secure storage + /// + /// Returns the session if found and valid, null otherwise. + Future restoreSession() async { + try { + final jsonString = await _storage.read(key: _storageKey); + + if (jsonString == null) { + if (kDebugMode) { + print('No stored session found'); + } + return null; + } + + final session = CovesSession.fromJsonString(jsonString); + + if (kDebugMode) { + print('Session restored: $session'); + } + + // Cache in memory + _session = session; + + return session; + } catch (e) { + // Catch all errors including TypeError from malformed JSON + if (kDebugMode) { + print('Failed to restore session: $e'); + } + + // Clear corrupted data + await _storage.delete(key: _storageKey); + return null; + } + } + + /// Refresh the current session token + /// + /// Calls the backend's /oauth/refresh endpoint to get a new sealed token. + /// The backend handles the actual token refresh with the PDS. + /// + /// Uses a mutex pattern to ensure only one refresh operation is in-flight + /// at a time. If multiple callers request a refresh simultaneously, they + /// will all wait for and receive the same refreshed session. + /// + /// Returns the updated session on success. + /// Throws on error (caller should handle by signing out). + Future refreshToken() async { + if (_session == null) { + throw StateError('No session to refresh'); + } + + // If a refresh is already in progress, wait for it and return its result + if (_refreshCompleter != null) { + if (kDebugMode) { + print('Token refresh already in progress, waiting...'); + } + return _refreshCompleter!.future; + } + + // Start a new refresh operation + _refreshCompleter = Completer(); + + try { + if (kDebugMode) { + print('Refreshing token...'); + } + + // Build request body per backend API contract + // Backend expects: {"did": "...", "session_id": "...", "sealed_token": "..."} + final requestBody = { + 'did': _session!.did, + 'session_id': _session!.sessionId, + 'sealed_token': _session!.token, + }; + + final response = await _dio.post>( + '/oauth/refresh', + data: requestBody, + ); + + // Backend returns: {"sealed_token": "...", "access_token": "..."} + // We use the new sealed_token (which already contains everything we need) + final newToken = response.data?['sealed_token'] as String?; + + if (newToken == null || newToken.isEmpty) { + throw Exception('Invalid refresh response: missing sealed_token'); + } + + // Create updated session with new token + final updatedSession = _session!.copyWithToken(newToken); + + // Save and cache + await _saveSession(updatedSession); + _session = updatedSession; + + if (kDebugMode) { + print('Token refreshed successfully'); + } + + // Complete the future with the updated session + _refreshCompleter!.complete(updatedSession); + return updatedSession; + } on DioException catch (e) { + if (kDebugMode) { + print('Token refresh failed: ${e.message}'); + print('Status code: ${e.response?.statusCode}'); + } + + // 401 means session is invalid/expired - caller should sign out + if (e.response?.statusCode == 401) { + final error = Exception('Session expired'); + _refreshCompleter!.completeError(error); + // Return the future to rethrow the error (don't throw directly) + return _refreshCompleter!.future; + } + + final error = Exception('Token refresh failed: ${e.message}'); + _refreshCompleter!.completeError(error); + // Return the future to rethrow the error (don't throw directly) + return _refreshCompleter!.future; + } catch (e) { + // Catch any other errors and propagate them to all waiters + _refreshCompleter!.completeError(e); + // Return the future to rethrow the error (don't rethrow directly) + return _refreshCompleter!.future; + } finally { + // Clear the completer so future calls can start a new refresh + _refreshCompleter = null; + } + } + + /// Sign out and revoke the session + /// + /// Calls the backend's /oauth/logout endpoint to revoke the session. + /// The backend handles token revocation with the PDS. + /// Always clears local storage even if server call fails. + Future signOut() async { + try { + if (_session != null) { + if (kDebugMode) { + print('Signing out...'); + } + + // Best-effort server-side revocation + try { + await _dio.post( + '/oauth/logout', + options: Options( + headers: { + 'Authorization': 'Bearer ${_session!.token}', + }, + ), + ); + + if (kDebugMode) { + print('Server-side logout successful'); + } + } on DioException catch (e) { + // Log but don't fail - we still want to clear local state + if (kDebugMode) { + print('Server-side logout failed: ${e.message}'); + } + } + } + } finally { + // Always clear local state + await _clearSession(); + _session = null; + + if (kDebugMode) { + print('Local session cleared'); + } + } + } + + /// Get the current access token + /// + /// Returns the sealed token for use in API requests. + /// Returns null if not authenticated. + String? getToken() { + return _session?.token; + } + + /// Validate and normalize an atProto handle or DID + /// + /// Accepts: + /// - Handles: alice.bsky.social, @alice.bsky.social + /// - DIDs: did:plc:abc123, did:web:example.com + /// - URLs: https://bsky.app/profile/alice.bsky.social (extracts handle) + /// + /// Returns the normalized handle/DID. + /// Throws ArgumentError if invalid. + @visibleForTesting + String validateAndNormalizeHandle(String handle) { + // Trim whitespace + var normalized = handle.trim(); + + // Check for empty input + if (normalized.isEmpty) { + throw ArgumentError('Handle cannot be empty'); + } + + // Extract handle from Bluesky profile URLs + // e.g., https://bsky.app/profile/alice.bsky.social -> alice.bsky.social + final urlPattern = RegExp( + r'^https?://(?:www\.)?bsky\.app/profile/([^/?#]+)', + caseSensitive: false, + ); + final urlMatch = urlPattern.firstMatch(normalized); + if (urlMatch != null) { + normalized = urlMatch.group(1)!; + } + + // Strip leading @ if present (common user input) + if (normalized.startsWith('@')) { + normalized = normalized.substring(1); + } + + // Check maximum length (atProto spec: 253 characters for handles) + if (normalized.length > 253) { + throw ArgumentError( + 'Handle too long (max 253 characters, got ${normalized.length})', + ); + } + + // Validate DID format + if (normalized.startsWith('did:')) { + return _validateDid(normalized); + } + + // Validate handle format + return _validateHandle(normalized); + } + + /// Validate a DID (Decentralized Identifier) + /// + /// Supports: + /// - did:plc:abc123 + /// - did:web:example.com + /// + /// Throws ArgumentError if invalid. + String _validateDid(String did) { + // DID format: did:method:identifier + // method: lowercase alphanumeric + // identifier: method-specific, but generally alphanumeric with some special chars + final didPattern = RegExp(r'^did:[a-z0-9]+:[a-zA-Z0-9._:%-]+$'); + + if (!didPattern.hasMatch(did)) { + throw ArgumentError( + 'Invalid DID format. Expected format: did:method:identifier', + ); + } + + return did; + } + + /// Validate a handle (domain name format) + /// + /// Handles must: + /// - Contain only alphanumeric characters, hyphens, and periods + /// - Not start or end with a hyphen or period + /// - Have at least one period (domain format) + /// - Each segment between periods must be valid (1-63 chars) + /// - TLD (final segment) cannot start with a digit (per atProto spec) + /// - Numeric segments are allowed in all positions except the TLD + /// + /// Throws ArgumentError if invalid. + String _validateHandle(String handle) { + // Handle must contain at least one period (domain format) + if (!handle.contains('.')) { + throw ArgumentError( + 'Invalid handle format. Handles must be in domain format (e.g., alice.bsky.social)', + ); + } + + // Handle format: alphanumeric, hyphens, and periods only + // No leading/trailing hyphens or periods + final handlePattern = RegExp( + r'^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$', + ); + + if (!handlePattern.hasMatch(handle)) { + throw ArgumentError( + 'Invalid handle format. Handles can only contain letters, numbers, hyphens, ' + 'and periods. Each segment must start and end with a letter or number.', + ); + } + + // Validate each segment (part between periods) + final segments = handle.split('.'); + for (int i = 0; i < segments.length; i++) { + final segment = segments[i]; + if (segment.isEmpty) { + throw ArgumentError('Handle cannot have empty segments'); + } + + // Each segment must not exceed 63 characters (DNS label limit) + if (segment.length > 63) { + throw ArgumentError( + 'Handle segment "$segment" too long (max 63 characters)', + ); + } + + // TLD (last segment) cannot start with a digit (to avoid confusion with IP addresses) + // Per atProto spec: numeric segments are allowed in all positions except the TLD + if (i == segments.length - 1 && RegExp(r'^\d').hasMatch(segment)) { + throw ArgumentError( + 'Handle TLD (final segment) cannot start with a digit (got: "$segment")', + ); + } + } + + return handle.toLowerCase(); + } + + /// Build the OAuth login URL + String _buildLoginUrl(String handle) { + final baseUrl = EnvironmentConfig.current.apiUrl; + final redirectUri = OAuthConfig.redirectUri; + + return '$baseUrl/oauth/mobile/login' + '?handle=${Uri.encodeComponent(handle)}' + '&redirect_uri=${Uri.encodeComponent(redirectUri)}'; + } + + /// Save session to secure storage + Future _saveSession(CovesSession session) async { + await _storage.write( + key: _storageKey, + value: session.toJsonString(), + ); + } + + /// Clear session from secure storage + Future _clearSession() async { + await _storage.delete(key: _storageKey); + } + + /// Redact sensitive parameters from URLs for safe logging + /// + /// Replaces token values with [REDACTED] to prevent leaking + /// sealed tokens in debug logs. + /// + /// Non-sensitive params like DID, handle, and session_id are preserved + /// as they're useful for debugging without being security-sensitive. + String _redactSensitiveParams(String url) { + // Replace token=xxx with token=[REDACTED] + // Matches token= followed by any non-whitespace, non-ampersand characters + return url.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + } +} diff --git a/test/services/coves_auth_service_environment_test.dart b/test/services/coves_auth_service_environment_test.dart new file mode 100644 index 0000000..3f64d9f --- /dev/null +++ b/test/services/coves_auth_service_environment_test.dart @@ -0,0 +1,187 @@ +import 'package:coves_flutter/config/environment_config.dart'; +import 'package:coves_flutter/models/coves_session.dart'; +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'coves_auth_service_test.mocks.dart'; + +/// Test suite to verify that sessions are namespaced per environment. +/// +/// This prevents a critical bug where switching between dev/prod builds +/// could send prod tokens to dev servers (or vice versa), causing 401 loops. +@GenerateMocks([Dio, FlutterSecureStorage]) +void main() { + late MockDio mockDio; + late MockFlutterSecureStorage mockStorage; + late CovesAuthService authService; + + setUp(() { + CovesAuthService.resetInstance(); + mockDio = MockDio(); + mockStorage = MockFlutterSecureStorage(); + authService = CovesAuthService.createTestInstance( + dio: mockDio, + storage: mockStorage, + ); + }); + + tearDown(() { + CovesAuthService.resetInstance(); + }); + + group('CovesAuthService - Environment Isolation', () { + test('should use environment-specific storage keys', () { + // This test documents the expected storage key format + // The actual environment is determined at compile time via --dart-define + // In tests without specific environment configuration, it defaults to production + final currentEnv = EnvironmentConfig.current.environment.name; + final expectedKey = 'coves_session_$currentEnv'; + + // The storage key should include the environment name + expect(expectedKey, contains('coves_session_')); + expect(expectedKey, contains(currentEnv)); + + // For production environment (default in tests) + if (currentEnv == 'production') { + expect(expectedKey, 'coves_session_production'); + } else if (currentEnv == 'local') { + expect(expectedKey, 'coves_session_local'); + } + }); + + test('should isolate sessions between environments', () async { + // This test verifies that sessions stored in different environments + // are accessed via different storage keys, preventing cross-contamination + + // Get the current environment's storage key + final currentEnv = EnvironmentConfig.current.environment.name; + final storageKey = 'coves_session_$currentEnv'; + + // Arrange - Mock session data + const session = CovesSession( + token: 'test-token-123', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + + // Mock storage read for the environment-specific key + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + + // Act - Restore session + final result = await authService.restoreSession(); + + // Assert + expect(result, isNotNull); + expect(result!.token, 'test-token-123'); + + // Verify the correct environment-specific key was used + verify(mockStorage.read(key: storageKey)).called(1); + + // Verify no other keys were accessed + verifyNever(mockStorage.read(key: 'coves_session')); + }); + + test('should save sessions with environment-specific keys', () async { + // Get the current environment's storage key + final currentEnv = EnvironmentConfig.current.environment.name; + final storageKey = 'coves_session_$currentEnv'; + + // First restore a session to set up state + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock successful refresh + const newToken = 'new-refreshed-token'; + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token' + }, + )); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - Refresh token (which saves the updated session) + await authService.refreshToken(); + + // Assert - Verify environment-specific key was used for saving + verify(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .called(1); + + // Verify the generic key was never used + verifyNever(mockStorage.write(key: 'coves_session', value: anyNamed('value'))); + }); + + test('should delete sessions using environment-specific keys', () async { + // Get the current environment's storage key + final currentEnv = EnvironmentConfig.current.environment.name; + final storageKey = 'coves_session_$currentEnv'; + + // First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock logout + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + )); + + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); + + // Act - Sign out + await authService.signOut(); + + // Assert - Verify environment-specific key was used for deletion + verify(mockStorage.delete(key: storageKey)).called(1); + + // Verify the generic key was never used + verifyNever(mockStorage.delete(key: 'coves_session')); + }); + + test('should document storage key format for both environments', () { + // This test serves as documentation for the storage key format + // Production key + expect('coves_session_production', 'coves_session_production'); + + // Local development key + expect('coves_session_local', 'coves_session_local'); + + // This ensures: + // 1. Production tokens are stored in 'coves_session_production' + // 2. Local dev tokens are stored in 'coves_session_local' + // 3. Switching between prod/dev builds doesn't cause token conflicts + // 4. Each environment maintains its own session independently + }); + }); +} diff --git a/test/services/coves_auth_service_redaction_test.dart b/test/services/coves_auth_service_redaction_test.dart new file mode 100644 index 0000000..7bbb928 --- /dev/null +++ b/test/services/coves_auth_service_redaction_test.dart @@ -0,0 +1,177 @@ +import 'package:coves_flutter/models/coves_session.dart'; +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'coves_auth_service_test.mocks.dart'; + +/// Tests for sensitive data redaction in CovesAuthService +/// +/// Verifies that sensitive parameters (tokens) are properly redacted +/// from debug logs while preserving useful debugging information. +@GenerateMocks([Dio, FlutterSecureStorage]) +void main() { + late CovesAuthService service; + late MockDio mockDio; + late MockFlutterSecureStorage mockStorage; + + setUp(() { + mockDio = MockDio(); + mockStorage = MockFlutterSecureStorage(); + + // Create a test instance + service = CovesAuthService.createTestInstance( + dio: mockDio, + storage: mockStorage, + ); + }); + + tearDown(() { + CovesAuthService.resetInstance(); + }); + + group('_redactSensitiveParams', () { + test('should redact token parameter from callback URL', () { + const testUrl = + 'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social'; + + // Use reflection to call private method + // Since we can't directly call private methods, we'll test the behavior + // through the public signIn method which logs the redacted URL + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect( + redacted, + 'social.coves:/callback?token=[REDACTED]&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social', + ); + }); + + test('should preserve non-sensitive parameters (DID, handle, session_id)', + () { + const testUrl = + 'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, contains('did=did:plc:test123')); + expect(redacted, contains('session_id=sess-456')); + expect(redacted, contains('handle=alice.bsky.social')); + expect(redacted, isNot(contains('sealed_token_abc123'))); + }); + + test('should handle token as first parameter', () { + const testUrl = + 'social.coves:/callback?token=first_token&did=did:plc:test'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + }); + + test('should handle token as last parameter', () { + const testUrl = 'social.coves:/callback?did=did:plc:test&token=last_token'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, 'social.coves:/callback?did=did:plc:test&token=[REDACTED]'); + }); + + test('should handle token as only parameter', () { + const testUrl = 'social.coves:/callback?token=only_token'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, 'social.coves:/callback?token=[REDACTED]'); + }); + + test('should handle URL-encoded token values', () { + const testUrl = + 'social.coves:/callback?token=encoded%2Btoken%3D123&did=did:plc:test'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + expect(redacted, isNot(contains('encoded%2Btoken%3D123'))); + }); + + test('should handle long token values', () { + const longToken = + 'very_long_sealed_token_with_many_characters_1234567890abcdef'; + final testUrl = 'social.coves:/callback?token=$longToken&did=did:plc:test'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + expect(redacted, isNot(contains(longToken))); + }); + + test('should handle URL without token parameter', () { + const testUrl = 'social.coves:/callback?did=did:plc:test&handle=alice.bsky.social'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + // Should remain unchanged if no token present + expect(redacted, testUrl); + }); + + test('should handle malformed URLs gracefully', () { + const testUrl = 'social.coves:/callback?token='; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + // Empty token value - regex won't match, URL stays the same + expect(redacted, testUrl); + }); + }); + + group('CovesSession.toString()', () { + test('should not expose token in toString output', () { + const testUrl = + 'social.coves:/callback?token=secret_token_123&did=did:plc:test&session_id=sess-456&handle=alice.bsky.social'; + + final uri = Uri.parse(testUrl); + // Create a CovesSession from the callback URI + final session = CovesSession.fromCallbackUri(uri); + + // Convert session to string (as would happen in debug logs) + final sessionString = session.toString(); + + // The session's toString() should NOT contain the token + // It should only contain DID, handle, and sessionId + expect(sessionString, isNot(contains('secret_token_123'))); + expect(sessionString, contains('did:plc:test')); + expect(sessionString, contains('sess-456')); + expect(sessionString, contains('alice.bsky.social')); + }); + }); +} diff --git a/test/services/coves_auth_service_singleton_test.dart b/test/services/coves_auth_service_singleton_test.dart new file mode 100644 index 0000000..11956c8 --- /dev/null +++ b/test/services/coves_auth_service_singleton_test.dart @@ -0,0 +1,203 @@ +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'coves_auth_service_test.mocks.dart'; + +@GenerateMocks([Dio, FlutterSecureStorage]) +void main() { + late MockDio mockDio; + late MockFlutterSecureStorage mockStorage; + + // Storage key is environment-specific to prevent token reuse across dev/prod + // Tests run in production environment by default + const storageKey = 'coves_session_production'; + + setUp(() { + CovesAuthService.resetInstance(); + mockDio = MockDio(); + mockStorage = MockFlutterSecureStorage(); + }); + + tearDown(() { + CovesAuthService.resetInstance(); + }); + + group('CovesAuthService - Singleton Pattern', () { + test('should return the same instance on multiple factory calls', () { + // Act - Create multiple instances using the factory + final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); + final instance2 = CovesAuthService(); + final instance3 = CovesAuthService(); + + // Assert - All should be the exact same instance + expect(identical(instance1, instance2), isTrue, + reason: 'instance1 and instance2 should be identical'); + expect(identical(instance2, instance3), isTrue, + reason: 'instance2 and instance3 should be identical'); + expect(identical(instance1, instance3), isTrue, + reason: 'instance1 and instance3 should be identical'); + }); + + test('should share in-memory session across singleton instances', () async { + // Arrange + final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); + + // Mock storage to return a valid session + const sessionJson = '{' + '"token": "test-token", ' + '"did": "did:plc:test123", ' + '"session_id": "session-123", ' + '"handle": "alice.bsky.social"' + '}'; + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => sessionJson); + + // Act - Restore session using first instance + await instance1.restoreSession(); + + // Get a second "instance" (should be the same singleton) + final instance2 = CovesAuthService(); + + // Assert - Both instances should have the same in-memory session + expect(instance2.session?.token, 'test-token'); + expect(instance2.session?.did, 'did:plc:test123'); + expect(instance2.isAuthenticated, isTrue); + + // Verify storage was only read once (by instance1) + verify(mockStorage.read(key: storageKey)).called(1); + }); + + test('should share refresh mutex across singleton instances', () async { + // Arrange + final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); + + // Mock storage to return a valid session + const sessionJson = '{' + '"token": "old-token", ' + '"did": "did:plc:test123", ' + '"session_id": "session-123", ' + '"handle": "alice.bsky.social"' + '}'; + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => sessionJson); + + await instance1.restoreSession(); + + // Mock refresh with delay + const newToken = 'refreshed-token'; + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'access-token'}, + ); + }); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - Start refresh from first instance + final refreshFuture1 = instance1.refreshToken(); + + // Get second instance and immediately try to refresh + final instance2 = CovesAuthService(); + final refreshFuture2 = instance2.refreshToken(); + + // Wait for both + final results = await Future.wait([refreshFuture1, refreshFuture2]); + + // Assert - Both should get the same result from a single API call + expect(results[0].token, newToken); + expect(results[1].token, newToken); + + // Verify only one API call was made (mutex protected) + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(1); + }); + + test('resetInstance() should clear the singleton', () { + // Arrange + final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); + + // Act + CovesAuthService.resetInstance(); + + // Create new instance with different dependencies + final mockDio2 = MockDio(); + final mockStorage2 = MockFlutterSecureStorage(); + final instance2 = CovesAuthService(dio: mockDio2, storage: mockStorage2); + + // Assert - Should be different instances (new singleton created) + // Note: We can't directly test if they're different objects easily, + // but we can verify that resetInstance() allows a fresh start + expect(instance2, isNotNull); + expect(instance2.isAuthenticated, isFalse); + }); + + test('createTestInstance() should bypass singleton', () { + // Arrange + final singletonInstance = CovesAuthService(dio: mockDio, storage: mockStorage); + + // Act - Create a test instance with different dependencies + final mockDio2 = MockDio(); + final mockStorage2 = MockFlutterSecureStorage(); + final testInstance = CovesAuthService.createTestInstance( + dio: mockDio2, + storage: mockStorage2, + ); + + // Assert - Test instance should be different from singleton + expect(identical(singletonInstance, testInstance), isFalse, + reason: 'Test instance should not be the singleton'); + + // Test instance should not affect singleton + final singletonCheck = CovesAuthService(); + expect(identical(singletonInstance, singletonCheck), isTrue, + reason: 'Singleton should remain unchanged'); + }); + + test('should avoid state loss when service is requested from multiple entry points', () async { + // Arrange + final authProvider = CovesAuthService(dio: mockDio, storage: mockStorage); + + const sessionJson = '{' + '"token": "test-token", ' + '"did": "did:plc:test123", ' + '"session_id": "session-123"' + '}'; + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => sessionJson); + + // Act - Simulate different parts of the app requesting the service + await authProvider.restoreSession(); + + final apiService = CovesAuthService(); + final voteService = CovesAuthService(); + final feedService = CovesAuthService(); + + // Assert - All should have access to the same session state + expect(apiService.isAuthenticated, isTrue); + expect(voteService.isAuthenticated, isTrue); + expect(feedService.isAuthenticated, isTrue); + expect(apiService.getToken(), 'test-token'); + expect(voteService.getToken(), 'test-token'); + expect(feedService.getToken(), 'test-token'); + + // Storage should only be read once + verify(mockStorage.read(key: storageKey)).called(1); + }); + }); +} diff --git a/test/services/coves_auth_service_test.dart b/test/services/coves_auth_service_test.dart new file mode 100644 index 0000000..dd1bd0b --- /dev/null +++ b/test/services/coves_auth_service_test.dart @@ -0,0 +1,929 @@ +import 'package:coves_flutter/models/coves_session.dart'; +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'coves_auth_service_test.mocks.dart'; + +@GenerateMocks([Dio, FlutterSecureStorage]) +void main() { + late MockDio mockDio; + late MockFlutterSecureStorage mockStorage; + late CovesAuthService authService; + + // Storage key is environment-specific to prevent token reuse across dev/prod + // Tests run in production environment by default + const storageKey = 'coves_session_production'; + + setUp(() { + CovesAuthService.resetInstance(); + mockDio = MockDio(); + mockStorage = MockFlutterSecureStorage(); + authService = CovesAuthService.createTestInstance( + dio: mockDio, + storage: mockStorage, + ); + }); + + tearDown(() { + CovesAuthService.resetInstance(); + }); + + group('CovesAuthService', () { + group('signIn()', () { + test('should throw ArgumentError when handle is empty', () async { + expect( + () => authService.signIn(''), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError when handle is whitespace-only', + () async { + expect( + () => authService.signIn(' '), + throwsA(isA()), + ); + }); + + test('should throw appropriate error when user cancels sign-in', + () async { + // Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method + // This test documents expected behavior when authentication is cancelled + // In practice, this would throw with CANCELED/cancelled in the message + // The actual implementation catches this and rethrows with user-friendly message + + // This test would require integration testing or a wrapper around FlutterWebAuth2 + // Skipping for now as it requires more complex mocking infrastructure + }); + + test('should throw Exception when network error occurs during OAuth', + () async { + // Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock + // This test documents expected behavior + // The actual implementation catches exceptions and rethrows with context + }); + + test('should trim handle before processing', () async { + // This test verifies the handle trimming logic + // The actual OAuth flow is tested via integration tests + const handle = ' alice.bsky.social '; + expect(handle.trim(), 'alice.bsky.social'); + }); + }); + + group('restoreSession()', () { + test('should successfully restore valid session from storage', () async { + // Arrange + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + final jsonString = session.toJsonString(); + + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => jsonString); + + // Act + final result = await authService.restoreSession(); + + // Assert + expect(result, isNotNull); + expect(result!.token, 'test-token'); + expect(result.did, 'did:plc:test123'); + expect(result.sessionId, 'session-123'); + expect(result.handle, 'alice.bsky.social'); + verify(mockStorage.read(key: storageKey)).called(1); + }); + + test('should return null when no stored session exists', () async { + // Arrange + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => null); + + // Act + final result = await authService.restoreSession(); + + // Assert + expect(result, isNull); + verify(mockStorage.read(key: storageKey)).called(1); + }); + + test('should handle corrupted storage data gracefully', () async { + // Arrange + const corruptedJson = 'not-valid-json{]'; + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => corruptedJson); + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + final result = await authService.restoreSession(); + + // Assert + expect(result, isNull); + verify(mockStorage.read(key: storageKey)).called(1); + verify(mockStorage.delete(key: storageKey)).called(1); + }); + + test('should handle session JSON with missing required fields gracefully', + () async { + // Arrange + const invalidJson = '{"token": "test"}'; // Missing required fields (did, session_id) + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => invalidJson); + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + final result = await authService.restoreSession(); + + // Assert + // Should return null and clear corrupted storage + expect(result, isNull); + verify(mockStorage.read(key: storageKey)).called(1); + verify(mockStorage.delete(key: storageKey)).called(1); + }); + + test('should handle storage read errors gracefully', () async { + // Arrange + when(mockStorage.read(key: storageKey)) + .thenThrow(Exception('Storage error')); + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + final result = await authService.restoreSession(); + + // Assert + expect(result, isNull); + verify(mockStorage.delete(key: storageKey)).called(1); + }); + }); + + group('refreshToken()', () { + test('should throw StateError when no session exists', () async { + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA(isA()), + ); + }); + + test('should successfully refresh token and return updated session', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + // Mock successful refresh response + const newToken = 'new-refreshed-token'; + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + )); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act + final result = await authService.refreshToken(); + + // Assert + expect(result.token, newToken); + expect(result.did, 'did:plc:test123'); + expect(result.sessionId, 'session-123'); + expect(result.handle, 'alice.bsky.social'); + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(1); + verify(mockStorage.write( + key: storageKey, + value: anyNamed('value'), + )).called(1); + }); + + test('should throw "Session expired" on 401 response', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock 401 response + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/oauth/refresh'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 401, + ), + ), + ); + + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate((e) => + e is Exception && e.toString().contains('Session expired')), + ), + ); + }); + + test('should throw Exception on network error during refresh', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock network error + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/oauth/refresh'), + type: DioExceptionType.connectionError, + message: 'Connection failed', + ), + ); + + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate((e) => + e is Exception && + e.toString().contains('Token refresh failed')), + ), + ); + }); + + test('should throw Exception when response is missing sealed_token', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock response without sealed_token + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'access_token': 'some-token'}, // No sealed_token field + )); + + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate((e) => + e is Exception && + e.toString().contains('Invalid refresh response')), + ), + ); + }); + + test('should throw Exception when response sealed_token is empty', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock response with empty sealed_token + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': '', 'access_token': 'some-token'}, // Empty sealed_token + )); + + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate((e) => + e is Exception && + e.toString().contains('Invalid refresh response')), + ), + ); + }); + }); + + group('signOut()', () { + test('should clear session and storage on successful server-side logout', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock successful logout + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + )); + + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + await authService.signOut(); + + // Assert + expect(authService.session, isNull); + expect(authService.isAuthenticated, isFalse); + verify(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).called(1); + verify(mockStorage.delete(key: storageKey)).called(1); + }); + + test('should clear local state even when server revocation fails', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock server error + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/oauth/logout'), + type: DioExceptionType.connectionError, + message: 'Connection failed', + ), + ); + + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + await authService.signOut(); + + // Assert + expect(authService.session, isNull); + expect(authService.isAuthenticated, isFalse); + verify(mockStorage.delete(key: storageKey)).called(1); + }); + + test('should work even when no session exists', () async { + // Arrange + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + await authService.signOut(); + + // Assert + expect(authService.session, isNull); + expect(authService.isAuthenticated, isFalse); + verify(mockStorage.delete(key: storageKey)).called(1); + verifyNever(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )); + }); + + test('should clear local state even when storage delete fails', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + )); + + when(mockStorage.delete(key: storageKey)) + .thenThrow(Exception('Storage error')); + + // Act & Assert - Should not throw + expect(() => authService.signOut(), throwsA(isA())); + + // Note: The session is cleared in memory even if storage fails + // This is because the finally block sets _session = null + }); + }); + + group('getToken()', () { + test('should return token when authenticated', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Act + final token = authService.getToken(); + + // Assert + expect(token, 'test-token'); + }); + + test('should return null when not authenticated', () { + // Act + final token = authService.getToken(); + + // Assert + expect(token, isNull); + }); + + test('should return null after sign out', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + )); + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + await authService.signOut(); + final token = authService.getToken(); + + // Assert + expect(token, isNull); + }); + }); + + group('isAuthenticated', () { + test('should return false when no session exists', () { + // Assert + expect(authService.isAuthenticated, isFalse); + }); + + test('should return true when session exists', () async { + // Arrange - Restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Assert + expect(authService.isAuthenticated, isTrue); + }); + + test('should return false after sign out', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + when(mockDio.post( + '/oauth/logout', + options: anyNamed('options'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + )); + when(mockStorage.delete(key: storageKey)) + .thenAnswer((_) async => {}); + + // Act + await authService.signOut(); + + // Assert + expect(authService.isAuthenticated, isFalse); + }); + }); + + group('session caching', () { + test('should cache session in memory after restore', () async { + // Arrange + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + + // Act + await authService.restoreSession(); + + // Assert - Accessing session property should not read from storage again + expect(authService.session?.token, 'test-token'); + expect(authService.session?.did, 'did:plc:test123'); + verify(mockStorage.read(key: storageKey)).called(1); + }); + + test('should update cached session after token refresh', () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + const newToken = 'new-token'; + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + )); + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act + await authService.refreshToken(); + + // Assert - Cached session should have new token + expect(authService.session?.token, newToken); + expect(authService.getToken(), newToken); + }); + }); + + group('refreshToken() - Concurrency Protection', () { + test('should only make one API request for concurrent refresh calls', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + const newToken = 'new-refreshed-token'; + + // Mock refresh response with a delay to simulate network latency + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + ); + }); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - Launch 3 concurrent refresh calls + final results = await Future.wait([ + authService.refreshToken(), + authService.refreshToken(), + authService.refreshToken(), + ]); + + // Assert - All calls should return the same refreshed session + expect(results.length, 3); + expect(results[0].token, newToken); + expect(results[1].token, newToken); + expect(results[2].token, newToken); + + // Verify only one API call was made + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(1); + + // Verify only one storage write was made + verify(mockStorage.write( + key: storageKey, + value: anyNamed('value'), + )).called(1); + }); + + test('should propagate errors to all concurrent waiters', () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock 401 response with delay + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + throw DioException( + requestOptions: RequestOptions(path: '/oauth/refresh'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 401, + ), + ); + }); + + // Act - Start concurrent refresh calls + final futures = [ + authService.refreshToken(), + authService.refreshToken(), + authService.refreshToken(), + ]; + + // Assert - All should throw the same error + var errorCount = 0; + for (final future in futures) { + try { + await future; + fail('Expected exception to be thrown'); + } catch (e) { + expect(e, isA()); + expect(e.toString(), contains('Session expired')); + errorCount++; + } + } + + expect(errorCount, 3); + + // Verify only one API call was made + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(1); + }); + + test('should allow new refresh after previous one completes', () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + const newToken1 = 'new-token-1'; + const newToken2 = 'new-token-2'; + + // Mock first refresh + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken1, 'access_token': 'some-access-token'}, + )); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - First refresh + final result1 = await authService.refreshToken(); + + // Assert first refresh + expect(result1.token, newToken1); + + // Now update the mock for the second refresh + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken2, 'access_token': 'some-access-token'}, + )); + + // Act - Second refresh (should be allowed since first completed) + final result2 = await authService.refreshToken(); + + // Assert second refresh + expect(result2.token, newToken2); + + // Verify two separate API calls were made + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(2); + }); + + test('should allow new refresh after previous one fails', () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + // Mock first refresh to fail + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/oauth/refresh'), + type: DioExceptionType.connectionError, + message: 'Connection failed', + ), + ); + + // Act - First refresh should fail + Object? caughtError; + try { + await authService.refreshToken(); + fail('Expected exception to be thrown'); + } catch (e) { + caughtError = e; + } + + // Assert first refresh failed with correct error + expect(caughtError, isNotNull); + expect(caughtError, isA()); + expect(caughtError.toString(), contains('Token refresh failed')); + + // Now mock a successful second refresh + const newToken = 'new-token-after-retry'; + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + )); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - Second refresh (should be allowed and succeed) + final result = await authService.refreshToken(); + + // Assert + expect(result.token, newToken); + + // Verify two separate API calls were made + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(2); + }); + + test( + 'should handle concurrent calls where one arrives after refresh completes', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when(mockStorage.read(key: storageKey)) + .thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + const newToken1 = 'new-token-1'; + const newToken2 = 'new-token-2'; + + var callCount = 0; + + // Mock refresh with different responses + when(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).thenAnswer((_) async { + callCount++; + await Future.delayed(const Duration(milliseconds: 50)); + return Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': callCount == 1 ? newToken1 : newToken2, 'access_token': 'some-access-token'}, + ); + }); + + when(mockStorage.write(key: storageKey, value: anyNamed('value'))) + .thenAnswer((_) async => {}); + + // Act - Start first refresh + final future1 = authService.refreshToken(); + + // Wait for it to complete + final result1 = await future1; + + // Start second refresh after first completes + final result2 = await authService.refreshToken(); + + // Assert + expect(result1.token, newToken1); + expect(result2.token, newToken2); + + // Verify two separate API calls were made + verify(mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + )).called(2); + }); + }); + }); +} diff --git a/test/services/coves_auth_service_test.mocks.dart b/test/services/coves_auth_service_test.mocks.dart new file mode 100644 index 0000000..d6d1e49 --- /dev/null +++ b/test/services/coves_auth_service_test.mocks.dart @@ -0,0 +1,1102 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in coves_flutter/test/services/coves_auth_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i9; + +import 'package:dio/src/adapter.dart' as _i4; +import 'package:dio/src/cancel_token.dart' as _i10; +import 'package:dio/src/dio.dart' as _i7; +import 'package:dio/src/dio_mixin.dart' as _i3; +import 'package:dio/src/options.dart' as _i2; +import 'package:dio/src/response.dart' as _i6; +import 'package:dio/src/transformer.dart' as _i5; +import 'package:flutter/foundation.dart' as _i11; +import 'package:flutter_secure_storage/flutter_secure_storage.dart' as _i8; +import 'package:mockito/mockito.dart' as _i1; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class +// ignore_for_file: invalid_use_of_internal_member + +class _FakeBaseOptions_0 extends _i1.SmartFake implements _i2.BaseOptions { + _FakeBaseOptions_0(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeInterceptors_1 extends _i1.SmartFake implements _i3.Interceptors { + _FakeInterceptors_1(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeHttpClientAdapter_2 extends _i1.SmartFake + implements _i4.HttpClientAdapter { + _FakeHttpClientAdapter_2(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeTransformer_3 extends _i1.SmartFake implements _i5.Transformer { + _FakeTransformer_3(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeResponse_4 extends _i1.SmartFake implements _i6.Response { + _FakeResponse_4(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeDio_5 extends _i1.SmartFake implements _i7.Dio { + _FakeDio_5(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeIOSOptions_6 extends _i1.SmartFake implements _i8.IOSOptions { + _FakeIOSOptions_6(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeAndroidOptions_7 extends _i1.SmartFake + implements _i8.AndroidOptions { + _FakeAndroidOptions_7(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeLinuxOptions_8 extends _i1.SmartFake implements _i8.LinuxOptions { + _FakeLinuxOptions_8(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWindowsOptions_9 extends _i1.SmartFake + implements _i8.WindowsOptions { + _FakeWindowsOptions_9(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeWebOptions_10 extends _i1.SmartFake implements _i8.WebOptions { + _FakeWebOptions_10(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +class _FakeMacOsOptions_11 extends _i1.SmartFake implements _i8.MacOsOptions { + _FakeMacOsOptions_11(Object parent, Invocation parentInvocation) + : super(parent, parentInvocation); +} + +/// A class which mocks [Dio]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockDio extends _i1.Mock implements _i7.Dio { + MockDio() { + _i1.throwOnMissingStub(this); + } + + @override + _i2.BaseOptions get options => + (super.noSuchMethod( + Invocation.getter(#options), + returnValue: _FakeBaseOptions_0(this, Invocation.getter(#options)), + ) + as _i2.BaseOptions); + + @override + _i3.Interceptors get interceptors => + (super.noSuchMethod( + Invocation.getter(#interceptors), + returnValue: _FakeInterceptors_1( + this, + Invocation.getter(#interceptors), + ), + ) + as _i3.Interceptors); + + @override + _i4.HttpClientAdapter get httpClientAdapter => + (super.noSuchMethod( + Invocation.getter(#httpClientAdapter), + returnValue: _FakeHttpClientAdapter_2( + this, + Invocation.getter(#httpClientAdapter), + ), + ) + as _i4.HttpClientAdapter); + + @override + _i5.Transformer get transformer => + (super.noSuchMethod( + Invocation.getter(#transformer), + returnValue: _FakeTransformer_3( + this, + Invocation.getter(#transformer), + ), + ) + as _i5.Transformer); + + @override + set options(_i2.BaseOptions? value) => super.noSuchMethod( + Invocation.setter(#options, value), + returnValueForMissingStub: null, + ); + + @override + set httpClientAdapter(_i4.HttpClientAdapter? value) => super.noSuchMethod( + Invocation.setter(#httpClientAdapter, value), + returnValueForMissingStub: null, + ); + + @override + set transformer(_i5.Transformer? value) => super.noSuchMethod( + Invocation.setter(#transformer, value), + returnValueForMissingStub: null, + ); + + @override + void close({bool? force = false}) => super.noSuchMethod( + Invocation.method(#close, [], {#force: force}), + returnValueForMissingStub: null, + ); + + @override + _i9.Future<_i6.Response> head( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #head, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> headUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #headUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> get( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #get, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> getUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #getUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> post( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #post, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> postUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #postUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> put( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #put, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> putUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #putUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> patch( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patch, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> patchUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #patchUri, + [uri], + { + #data: data, + #options: options, + #cancelToken: cancelToken, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> delete( + String? path, { + Object? data, + Map? queryParameters, + _i2.Options? options, + _i10.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #delete, + [path], + { + #data: data, + #queryParameters: queryParameters, + #options: options, + #cancelToken: cancelToken, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> deleteUri( + Uri? uri, { + Object? data, + _i2.Options? options, + _i10.CancelToken? cancelToken, + }) => + (super.noSuchMethod( + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #deleteUri, + [uri], + {#data: data, #options: options, #cancelToken: cancelToken}, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> download( + String? urlPath, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + Map? queryParameters, + _i10.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #download, + [urlPath, savePath], + { + #onReceiveProgress: onReceiveProgress, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> downloadUri( + Uri? uri, + dynamic savePath, { + _i2.ProgressCallback? onReceiveProgress, + _i10.CancelToken? cancelToken, + bool? deleteOnError = true, + _i2.FileAccessMode? fileAccessMode = _i2.FileAccessMode.write, + String? lengthHeader = 'content-length', + Object? data, + _i2.Options? options, + }) => + (super.noSuchMethod( + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #downloadUri, + [uri, savePath], + { + #onReceiveProgress: onReceiveProgress, + #cancelToken: cancelToken, + #deleteOnError: deleteOnError, + #fileAccessMode: fileAccessMode, + #lengthHeader: lengthHeader, + #data: data, + #options: options, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> request( + String? url, { + Object? data, + Map? queryParameters, + _i10.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #request, + [url], + { + #data: data, + #queryParameters: queryParameters, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> requestUri( + Uri? uri, { + Object? data, + _i10.CancelToken? cancelToken, + _i2.Options? options, + _i2.ProgressCallback? onSendProgress, + _i2.ProgressCallback? onReceiveProgress, + }) => + (super.noSuchMethod( + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method( + #requestUri, + [uri], + { + #data: data, + #cancelToken: cancelToken, + #options: options, + #onSendProgress: onSendProgress, + #onReceiveProgress: onReceiveProgress, + }, + ), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i9.Future<_i6.Response> fetch(_i2.RequestOptions? requestOptions) => + (super.noSuchMethod( + Invocation.method(#fetch, [requestOptions]), + returnValue: _i9.Future<_i6.Response>.value( + _FakeResponse_4( + this, + Invocation.method(#fetch, [requestOptions]), + ), + ), + ) + as _i9.Future<_i6.Response>); + + @override + _i7.Dio clone({ + _i2.BaseOptions? options, + _i3.Interceptors? interceptors, + _i4.HttpClientAdapter? httpClientAdapter, + _i5.Transformer? transformer, + }) => + (super.noSuchMethod( + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + returnValue: _FakeDio_5( + this, + Invocation.method(#clone, [], { + #options: options, + #interceptors: interceptors, + #httpClientAdapter: httpClientAdapter, + #transformer: transformer, + }), + ), + ) + as _i7.Dio); +} + +/// A class which mocks [FlutterSecureStorage]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFlutterSecureStorage extends _i1.Mock + implements _i8.FlutterSecureStorage { + MockFlutterSecureStorage() { + _i1.throwOnMissingStub(this); + } + + @override + _i8.IOSOptions get iOptions => + (super.noSuchMethod( + Invocation.getter(#iOptions), + returnValue: _FakeIOSOptions_6(this, Invocation.getter(#iOptions)), + ) + as _i8.IOSOptions); + + @override + _i8.AndroidOptions get aOptions => + (super.noSuchMethod( + Invocation.getter(#aOptions), + returnValue: _FakeAndroidOptions_7( + this, + Invocation.getter(#aOptions), + ), + ) + as _i8.AndroidOptions); + + @override + _i8.LinuxOptions get lOptions => + (super.noSuchMethod( + Invocation.getter(#lOptions), + returnValue: _FakeLinuxOptions_8( + this, + Invocation.getter(#lOptions), + ), + ) + as _i8.LinuxOptions); + + @override + _i8.WindowsOptions get wOptions => + (super.noSuchMethod( + Invocation.getter(#wOptions), + returnValue: _FakeWindowsOptions_9( + this, + Invocation.getter(#wOptions), + ), + ) + as _i8.WindowsOptions); + + @override + _i8.WebOptions get webOptions => + (super.noSuchMethod( + Invocation.getter(#webOptions), + returnValue: _FakeWebOptions_10( + this, + Invocation.getter(#webOptions), + ), + ) + as _i8.WebOptions); + + @override + _i8.MacOsOptions get mOptions => + (super.noSuchMethod( + Invocation.getter(#mOptions), + returnValue: _FakeMacOsOptions_11( + this, + Invocation.getter(#mOptions), + ), + ) + as _i8.MacOsOptions); + + @override + void registerListener({ + required String? key, + required _i11.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#registerListener, [], {#key: key, #listener: listener}), + returnValueForMissingStub: null, + ); + + @override + void unregisterListener({ + required String? key, + required _i11.ValueChanged? listener, + }) => super.noSuchMethod( + Invocation.method(#unregisterListener, [], { + #key: key, + #listener: listener, + }), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListenersForKey({required String? key}) => + super.noSuchMethod( + Invocation.method(#unregisterAllListenersForKey, [], {#key: key}), + returnValueForMissingStub: null, + ); + + @override + void unregisterAllListeners() => super.noSuchMethod( + Invocation.method(#unregisterAllListeners, []), + returnValueForMissingStub: null, + ); + + @override + _i9.Future write({ + required String? key, + required String? value, + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#write, [], { + #key: key, + #value: value, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future read({ + required String? key, + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#read, [], { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future containsKey({ + required String? key, + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#containsKey, [], { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future.value(false), + ) + as _i9.Future); + + @override + _i9.Future delete({ + required String? key, + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#delete, [], { + #key: key, + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future> readAll({ + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#readAll, [], { + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future>.value( + {}, + ), + ) + as _i9.Future>); + + @override + _i9.Future deleteAll({ + _i8.IOSOptions? iOptions, + _i8.AndroidOptions? aOptions, + _i8.LinuxOptions? lOptions, + _i8.WebOptions? webOptions, + _i8.MacOsOptions? mOptions, + _i8.WindowsOptions? wOptions, + }) => + (super.noSuchMethod( + Invocation.method(#deleteAll, [], { + #iOptions: iOptions, + #aOptions: aOptions, + #lOptions: lOptions, + #webOptions: webOptions, + #mOptions: mOptions, + #wOptions: wOptions, + }), + returnValue: _i9.Future.value(), + returnValueForMissingStub: _i9.Future.value(), + ) + as _i9.Future); + + @override + _i9.Future isCupertinoProtectedDataAvailable() => + (super.noSuchMethod( + Invocation.method(#isCupertinoProtectedDataAvailable, []), + returnValue: _i9.Future.value(), + ) + as _i9.Future); +} diff --git a/test/services/coves_auth_service_validation_test.dart b/test/services/coves_auth_service_validation_test.dart new file mode 100644 index 0000000..f019f31 --- /dev/null +++ b/test/services/coves_auth_service_validation_test.dart @@ -0,0 +1,540 @@ +import 'package:coves_flutter/services/coves_auth_service.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'coves_auth_service_test.mocks.dart'; + +@GenerateMocks([Dio, FlutterSecureStorage]) +void main() { + late MockDio mockDio; + late MockFlutterSecureStorage mockStorage; + late CovesAuthService authService; + + setUp(() { + CovesAuthService.resetInstance(); + mockDio = MockDio(); + mockStorage = MockFlutterSecureStorage(); + authService = CovesAuthService.createTestInstance( + dio: mockDio, + storage: mockStorage, + ); + }); + + tearDown(() { + CovesAuthService.resetInstance(); + }); + + group('Handle Validation', () { + group('Valid inputs', () { + test('should accept standard handle format', () { + final result = + authService.validateAndNormalizeHandle('alice.bsky.social'); + expect(result, 'alice.bsky.social'); + }); + + test('should accept handle with @ prefix and strip it', () { + final result = + authService.validateAndNormalizeHandle('@alice.bsky.social'); + expect(result, 'alice.bsky.social'); + }); + + test('should accept handle with leading/trailing whitespace and trim', () { + final result = + authService.validateAndNormalizeHandle(' alice.bsky.social '); + expect(result, 'alice.bsky.social'); + }); + + test('should accept handle with hyphen in segment', () { + final result = + authService.validateAndNormalizeHandle('alice-bob.bsky.social'); + expect(result, 'alice-bob.bsky.social'); + }); + + test('should accept handle with multiple hyphens', () { + final result = authService + .validateAndNormalizeHandle('alice-bob-charlie.bsky-app.social'); + expect(result, 'alice-bob-charlie.bsky-app.social'); + }); + + test('should accept handle with multiple subdomains', () { + final result = authService + .validateAndNormalizeHandle('alice.subdomain.example.com'); + expect(result, 'alice.subdomain.example.com'); + }); + + test('should accept handle with numbers', () { + final result = + authService.validateAndNormalizeHandle('user123.bsky.social'); + expect(result, 'user123.bsky.social'); + }); + + test('should convert handle to lowercase', () { + final result = + authService.validateAndNormalizeHandle('Alice.Bsky.Social'); + expect(result, 'alice.bsky.social'); + }); + + test('should extract and validate handle from Bluesky profile URL (HTTP)', () { + final result = authService.validateAndNormalizeHandle( + 'http://bsky.app/profile/alice.bsky.social'); + expect(result, 'alice.bsky.social'); + }); + + test('should extract and validate handle from Bluesky profile URL (HTTPS)', () { + final result = authService.validateAndNormalizeHandle( + 'https://bsky.app/profile/alice.bsky.social'); + expect(result, 'alice.bsky.social'); + }); + + test('should extract and validate handle from Bluesky profile URL with www', () { + final result = authService.validateAndNormalizeHandle( + 'https://www.bsky.app/profile/alice.bsky.social'); + expect(result, 'alice.bsky.social'); + }); + + test('should accept DID with plc method', () { + final result = + authService.validateAndNormalizeHandle('did:plc:abc123def456'); + expect(result, 'did:plc:abc123def456'); + }); + + test('should accept DID with web method', () { + final result = + authService.validateAndNormalizeHandle('did:web:example.com'); + expect(result, 'did:web:example.com'); + }); + + test('should accept DID with complex identifier', () { + final result = authService + .validateAndNormalizeHandle('did:plc:z72i7hdynmk6r22z27h6tvur'); + expect(result, 'did:plc:z72i7hdynmk6r22z27h6tvur'); + }); + + test('should accept DID with periods and colons in identifier', () { + final result = authService + .validateAndNormalizeHandle('did:web:example.com:user:alice'); + expect(result, 'did:web:example.com:user:alice'); + }); + + test('should accept short handle', () { + final result = authService.validateAndNormalizeHandle('a.b'); + expect(result, 'a.b'); + }); + + test('should normalize handle with @ prefix and whitespace', () { + final result = + authService.validateAndNormalizeHandle(' @Alice.Bsky.Social '); + expect(result, 'alice.bsky.social'); + }); + + test('should accept handle with numeric first segment', () { + final result = + authService.validateAndNormalizeHandle('123.bsky.social'); + expect(result, '123.bsky.social'); + }); + + test('should accept handle with numeric middle segment', () { + final result = + authService.validateAndNormalizeHandle('alice.456.social'); + expect(result, 'alice.456.social'); + }); + + test('should accept handle with multiple numeric segments', () { + final result = + authService.validateAndNormalizeHandle('42.example.com'); + expect(result, '42.example.com'); + }); + + test('should accept handle similar to 4chan.org', () { + final result = authService.validateAndNormalizeHandle('4chan.org'); + expect(result, '4chan.org'); + }); + + test('should accept handle with numeric and alpha mixed', () { + final result = + authService.validateAndNormalizeHandle('8.cn'); + expect(result, '8.cn'); + }); + + test('should accept handle like IP but with valid TLD', () { + final result = + authService.validateAndNormalizeHandle('120.0.0.1.com'); + expect(result, '120.0.0.1.com'); + }); + }); + + group('Invalid inputs', () { + test('should throw ArgumentError when handle is empty', () { + expect( + () => authService.validateAndNormalizeHandle(''), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError when handle is whitespace-only', () { + expect( + () => authService.validateAndNormalizeHandle(' '), + throwsA(isA()), + ); + }); + + test('should throw ArgumentError for handle without period', () { + expect( + () => authService.validateAndNormalizeHandle('alice'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('domain format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle starting with hyphen', () { + expect( + () => authService.validateAndNormalizeHandle('-alice.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle ending with hyphen', () { + expect( + () => authService.validateAndNormalizeHandle('alice-.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for segment with hyphen at end', () { + expect( + () => authService.validateAndNormalizeHandle('alice.bsky-.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle starting with period', () { + expect( + () => authService.validateAndNormalizeHandle('.alice.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle ending with period', () { + expect( + () => authService.validateAndNormalizeHandle('alice.bsky.social.'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with consecutive periods', () { + expect( + () => authService.validateAndNormalizeHandle('alice..bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + (e.message.toString().contains('empty segments') || + e.message.toString().contains('Invalid handle format')), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with spaces', () { + expect( + () => authService.validateAndNormalizeHandle('alice bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with @ in middle', () { + expect( + () => authService.validateAndNormalizeHandle('alice@bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with underscore', () { + expect( + () => authService.validateAndNormalizeHandle('alice_bob.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with exclamation mark', () { + expect( + () => authService.validateAndNormalizeHandle('alice!.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle with slash', () { + expect( + () => authService.validateAndNormalizeHandle('alice/bob.bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid handle format'), + ), + ), + ); + }); + + test('should throw ArgumentError for handle exceeding 253 characters', () { + // Create a handle that's 254 characters long + final longHandle = '${'a' * 240}.bsky.social'; + expect( + () => authService.validateAndNormalizeHandle(longHandle), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('too long'), + ), + ), + ); + }); + + test('should throw ArgumentError for segment exceeding 63 characters', () { + // DNS label limit is 63 characters per segment + final longSegment = '${'a' * 64}.bsky.social'; + expect( + () => authService.validateAndNormalizeHandle(longSegment), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('too long'), + ), + ), + ); + }); + + test('should throw ArgumentError for TLD starting with digit', () { + expect( + () => authService.validateAndNormalizeHandle('alice.bsky.123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('TLD') && + e.message.toString().contains('cannot start with a digit'), + ), + ), + ); + }); + + test('should throw ArgumentError for all-numeric TLD', () { + expect( + () => authService.validateAndNormalizeHandle('123.456.789'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('TLD') && + e.message.toString().contains('cannot start with a digit'), + ), + ), + ); + }); + + test('should throw ArgumentError for IPv4 address (TLD starts with digit)', () { + expect( + () => authService.validateAndNormalizeHandle('127.0.0.1'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('TLD') && + e.message.toString().contains('cannot start with a digit'), + ), + ), + ); + }); + + test('should throw ArgumentError for IPv4 address variant', () { + expect( + () => authService.validateAndNormalizeHandle('192.168.0.142'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('TLD') && + e.message.toString().contains('cannot start with a digit'), + ), + ), + ); + }); + }); + + group('DID Validation', () { + test('should accept valid plc DID', () { + final result = + authService.validateAndNormalizeHandle('did:plc:abc123'); + expect(result, 'did:plc:abc123'); + }); + + test('should accept valid web DID', () { + final result = + authService.validateAndNormalizeHandle('did:web:example.com'); + expect(result, 'did:web:example.com'); + }); + + test('should accept DID with underscores in identifier', () { + // Underscores are allowed in the DID pattern (part of [a-zA-Z0-9._:%-]+) + final result = + authService.validateAndNormalizeHandle('did:plc:abc_123'); + expect(result, 'did:plc:abc_123'); + }); + + test('should throw ArgumentError for invalid DID with @ special chars', () { + expect( + () => authService.validateAndNormalizeHandle('did:plc:abc@123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + + test('should throw ArgumentError for DID with uppercase method', () { + expect( + () => authService.validateAndNormalizeHandle('did:PLC:abc123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + + test('should throw ArgumentError for DID with spaces', () { + expect( + () => authService.validateAndNormalizeHandle('did:plc:abc 123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + + test('should throw ArgumentError for malformed DID (missing identifier)', () { + expect( + () => authService.validateAndNormalizeHandle('did:plc'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + + test('should throw ArgumentError for malformed DID (missing method)', () { + expect( + () => authService.validateAndNormalizeHandle('did::abc123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + + test('should throw ArgumentError for DID without prefix', () { + expect( + () => authService.validateAndNormalizeHandle('plc:abc123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('domain format'), + ), + ), + ); + }); + + test('should throw ArgumentError for DID with invalid method chars', () { + expect( + () => authService.validateAndNormalizeHandle('did:pl-c:abc123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), + ), + ); + }); + }); + }); +} -- 2.51.2 From ebf1711c2b051d01a60dced94c2f5124d27e678a Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:25:14 -0800 Subject: [PATCH 03/11] feat: update OAuth config for private-use URI scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch from development callback URL to proper atproto-compliant private-use URI scheme (RFC 8252): - Scheme: social.coves:/callback (single slash per spec) - Works on both Android and iOS without Universal Links complexity Platform changes: - Android: Update CallbackActivity intent filter scheme - iOS: Update CFBundleURLSchemes in Info.plist - Remove taskAffinity from MainActivity (not needed) Dependencies: - Add flutter_web_auth_2 for browser-based OAuth - Remove atproto_oauth_flutter path dependency (to be deleted) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- android/app/src/main/AndroidManifest.xml | 7 +-- ios/Runner/Info.plist | 2 +- lib/config/oauth_config.dart | 73 ++++++------------------ pubspec.lock | 17 +----- pubspec.yaml | 4 +- 5 files changed, 22 insertions(+), 81 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 443574d..65c8d4f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,7 +9,6 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" - android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" @@ -30,7 +29,7 @@ - + @@ -38,9 +37,7 @@ - - - + - - CFBundleURLName - com.example.myapp - - -``` - -**For HTTPS universal links** (production), also add: - -```xml -com.apple.developer.associated-domains - - applinks:example.com - -``` - -Then create an `apple-app-site-association` file on your server at `https://example.com/.well-known/apple-app-site-association`. - -### Android Configuration - -Add an intent filter to `android/app/src/main/AndroidManifest.xml`: - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -**For HTTPS universal links**, also create a `assetlinks.json` file at `https://example.com/.well-known/assetlinks.json`. - -### Verify Deep Linking - -Test that deep linking works: - -```bash -# iOS (simulator) -xcrun simctl openurl booted "myapp://oauth/callback?code=test" - -# Android (emulator or device) -adb shell am start -W -a android.intent.action.VIEW -d "myapp://oauth/callback?code=test" -``` - -If your app opens, deep linking is configured correctly. - -### Router Integration (go_router, auto_route, etc.) - -**⚠️ Important:** If you're using declarative routing packages like `go_router` or `auto_route`, you MUST configure them to ignore OAuth callback deep links. Otherwise, the router will intercept the callback and OAuth will fail with "User canceled login". - -#### Why This is Needed - -When the OAuth server redirects back to your app with the authorization code, your router may try to handle the deep link before `flutter_web_auth_2` can capture it. This causes the OAuth flow to fail. - -#### Solution: Use FlutterOAuthRouterHelper - -We provide a helper that makes router configuration easy: - -**With go_router** (Recommended approach): - -```dart -import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; -import 'package:go_router/go_router.dart'; - -final router = GoRouter( - routes: [ - // Your app routes... - ], - // Use the helper to automatically ignore OAuth callbacks - redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( - customSchemes: ['myapp'], // Your custom URL scheme(s) - ), -); -``` - -**Manual configuration** (if you need custom redirect logic): - -```dart -final router = GoRouter( - routes: [...], - redirect: (context, state) { - // Check if this is an OAuth callback - if (FlutterOAuthRouterHelper.isOAuthCallback( - state.uri, - customSchemes: ['myapp'], - )) { - return null; // Let flutter_web_auth_2 handle it - } - - // Your custom redirect logic here - if (!isAuthenticated) return '/login'; - - return null; // Normal routing - }, -); -``` - -**Extract scheme from your OAuth config:** - -```dart -final scheme = FlutterOAuthRouterHelper.extractScheme( - 'myapp://oauth/callback' -); -// Returns: 'myapp' - -// Use it in your router config -redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( - customSchemes: [scheme], -), -``` - -#### Other Routers - -The same concept applies to other routing packages: - -- **auto_route**: Use guards to ignore OAuth callback routes -- **beamer**: Configure `beamGuard` to skip OAuth URIs -- **fluro**: Add a custom route handler that ignores OAuth schemes - -The key is to **not process URIs with your custom OAuth scheme** - let `flutter_web_auth_2` handle them. - -## API Reference - -### FlutterOAuthClient (High-Level) - -**Recommended for most apps.** Provides a simplified API with sensible defaults. - -#### Constructor - -```dart -FlutterOAuthClient({ - required ClientMetadata clientMetadata, - OAuthResponseMode responseMode = OAuthResponseMode.query, - bool allowHttp = false, - FlutterSecureStorage? secureStorage, - Dio? dio, - String? plcDirectoryUrl, - String? handleResolverUrl, -}) -``` - -**Parameters:** - -- `clientMetadata` (required) - Client configuration (see [ClientMetadata](#clientmetadata)) -- `responseMode` - How OAuth parameters are returned: `query` (default, URL query string) or `fragment` (URL fragment) -- `allowHttp` - Allow HTTP connections for development (default: `false`, **never use in production**) -- `secureStorage` - Custom `FlutterSecureStorage` instance (optional) -- `dio` - Custom HTTP client (optional) -- `plcDirectoryUrl` - Custom PLC directory URL (default: `https://plc.directory`) -- `handleResolverUrl` - Custom handle resolver URL (default: `https://bsky.social`) - -#### Methods - -##### `signIn()` - -Complete OAuth sign-in flow (authorize + browser + callback). - -```dart -Future signIn( - String input, { - AuthorizeOptions? options, - CancelToken? cancelToken, -}) -``` - -**Parameters:** - -- `input` - Handle (e.g., `"alice.bsky.social"`), DID (e.g., `"did:plc:..."`), PDS URL, or auth server URL -- `options` - Additional OAuth parameters (optional, see [AuthorizeOptions](#authorizeoptions)) -- `cancelToken` - Dio cancellation token (optional) - -**Returns:** `OAuthSession` - Authenticated session - -**Throws:** -- `FormatException` - Invalid parameters -- `OAuthResolverError` - Identity/server resolution failed -- `OAuthCallbackError` - OAuth error from server -- `FlutterWebAuth2UserCanceled` - User cancelled browser flow - -**Example:** - -```dart -// Simple sign-in -final session = await client.signIn('alice.bsky.social'); - -// With custom state -final session = await client.signIn( - 'alice.bsky.social', - options: AuthorizeOptions(state: 'my-app-state'), -); -``` - -##### `restore()` - -Restore a stored session (automatically refreshes if expired). - -```dart -Future restore( - String sub, { - dynamic refresh = 'auto', - CancelToken? cancelToken, -}) -``` - -**Parameters:** - -- `sub` - User's DID (e.g., `"did:plc:abc123"`) -- `refresh` - Token refresh strategy: - - `'auto'` (default) - Refresh only if expired - - `true` - Force refresh even if not expired - - `false` - Use cached tokens even if expired -- `cancelToken` - Dio cancellation token (optional) - -**Returns:** `OAuthSession` - Restored session - -**Throws:** -- `Exception` - Session not found -- `TokenRefreshError` - Refresh failed -- `AuthMethodUnsatisfiableError` - Auth method not supported - -**Example:** - -```dart -// Auto-refresh if expired -final session = await client.restore('did:plc:abc123'); - -// Force refresh -final fresh = await client.restore('did:plc:abc123', refresh: true); -``` - -##### `revoke()` - -Revoke a session (sign out). - -```dart -Future revoke( - String sub, { - CancelToken? cancelToken, -}) -``` - -**Parameters:** - -- `sub` - User's DID -- `cancelToken` - Dio cancellation token (optional) - -**Behavior:** -- Calls server's token revocation endpoint (best-effort) -- Deletes session from local storage (always) -- Emits `deleted` event - -**Example:** - -```dart -await client.revoke('did:plc:abc123'); -``` - -#### Properties - -##### `onUpdated` - -Stream of session update events (token refresh, etc.). - -```dart -Stream get onUpdated -``` - -**Example:** - -```dart -client.onUpdated.listen((event) { - print('Session ${event.sub} updated'); -}); -``` - -##### `onDeleted` - -Stream of session deletion events (revoke, expiry, errors). - -```dart -Stream get onDeleted -``` - -**Example:** - -```dart -client.onDeleted.listen((event) { - print('Session ${event.sub} deleted: ${event.cause}'); - // Navigate to sign-in screen -}); -``` - ---- - -### OAuthClient (Core) - -**For advanced use cases.** Provides lower-level control over the OAuth flow. - -#### Constructor - -```dart -OAuthClient(OAuthClientOptions options) -``` - -See [OAuthClientOptions](#oauthclientoptions) for all parameters. - -#### Methods - -##### `authorize()` - -Start OAuth authorization flow (returns URL to open in browser). - -```dart -Future authorize( - String input, { - AuthorizeOptions? options, - CancelToken? cancelToken, -}) -``` - -**Parameters:** Same as `signIn()` but returns URL instead of completing flow. - -**Returns:** `Uri` - Authorization URL to open in browser - -**Throws:** Same as `signIn()` - -**Example:** - -```dart -final authUrl = await client.authorize('alice.bsky.social'); -// Open authUrl in browser yourself -``` - -##### `callback()` - -Handle OAuth callback after user authorization. - -```dart -Future callback( - Map params, { - CallbackOptions? options, - CancelToken? cancelToken, -}) -``` - -**Parameters:** - -- `params` - Query/fragment parameters from callback URL -- `options` - Callback options (see [CallbackOptions](#callbackoptions)) -- `cancelToken` - Dio cancellation token (optional) - -**Returns:** `CallbackResult` - Contains session and app state - -**Throws:** -- `OAuthCallbackError` - OAuth error or invalid callback - -**Example:** - -```dart -// Extract params from callback URL -final uri = Uri.parse(callbackUrl); -final params = uri.queryParameters; - -// Complete OAuth flow -final result = await client.callback(params); -print('Signed in: ${result.session.sub}'); -print('App state: ${result.state}'); -``` - -##### `restore()` and `revoke()` - -Same as `FlutterOAuthClient`. - -#### Static Methods - -##### `fetchMetadata()` - -Fetch client metadata from a discoverable client ID URL. - -```dart -static Future> fetchMetadata( - OAuthClientFetchMetadataOptions options, -) -``` - -**Parameters:** - -- `options.clientId` - HTTPS URL to client metadata JSON -- `options.dio` - Custom HTTP client (optional) -- `options.cancelToken` - Cancellation token (optional) - -**Returns:** Client metadata as JSON - -**Example:** - -```dart -final metadata = await OAuthClient.fetchMetadata( - OAuthClientFetchMetadataOptions( - clientId: 'https://example.com/client-metadata.json', - ), -); -``` - -#### Properties - -Same as `FlutterOAuthClient` (`onUpdated`, `onDeleted`). - ---- - -### Types - -#### ClientMetadata - -OAuth client configuration. - -```dart -class ClientMetadata { - final String? clientId; - final List redirectUris; - final List responseTypes; - final List grantTypes; - final String? scope; - final String tokenEndpointAuthMethod; - final String? tokenEndpointAuthSigningAlg; - final String? jwksUri; - final Map? jwks; - final String applicationType; - final String subjectType; - final String authorizationSignedResponseAlg; - final String? clientName; - final String? clientUri; - final String? policyUri; - final String? tosUri; - final String? logoUri; - final int? defaultMaxAge; - final bool? requireAuthTime; - final List? contacts; - final bool? dpopBoundAccessTokens; - final List? authorizationDetailsTypes; - - // ... more fields -} -``` - -**Key Fields:** - -- `clientId` - Client identifier: - - Discoverable: HTTPS URL to client metadata JSON (production) - - Loopback: `http://localhost` (development only) -- `redirectUris` - Array of valid redirect URIs (must match deep link configuration) -- `scope` - Requested scope (default: `"atproto"`, recommended: `"atproto transition:generic"`) -- `clientName` - Human-readable app name -- `dpopBoundAccessTokens` - Enable DPoP (recommended: `true`) - -**Example:** - -```dart -// Development (loopback client) -final metadata = ClientMetadata( - clientId: 'http://localhost', - redirectUris: ['myapp://oauth/callback'], - scope: 'atproto transition:generic', -); - -// Production (discoverable client) -final metadata = ClientMetadata( - clientId: 'https://example.com/client-metadata.json', - redirectUris: [ - 'myapp://oauth/callback', // Custom scheme - 'https://example.com/oauth/callback' // Universal link - ], - scope: 'atproto transition:generic', - clientName: 'My Awesome App', - clientUri: 'https://example.com', - dpopBoundAccessTokens: true, -); -``` - -#### AuthorizeOptions - -Additional parameters for `authorize()` / `signIn()`. - -```dart -class AuthorizeOptions { - final String? redirectUri; - final String? state; - final String? scope; - final String? nonce; - final String? display; - final String? prompt; - final int? maxAge; - final Map? claims; - final String? uiLocales; - final String? idTokenHint; - final Map? authorizationDetails; -} -``` - -**Key Fields:** - -- `redirectUri` - Override default redirect URI -- `state` - Application state to preserve (returned in callback) -- `scope` - Override default scope -- `display` - Display mode: `"touch"` (default for mobile), `"page"`, `"popup"` -- `prompt` - Prompt user: `"none"`, `"login"`, `"consent"`, `"select_account"` - -**Example:** - -```dart -final session = await client.signIn( - 'alice.bsky.social', - options: AuthorizeOptions( - state: jsonEncode({'returnTo': '/home'}), - prompt: 'login', // Force re-authentication - ), -); -``` - -#### CallbackOptions - -Options for `callback()`. - -```dart -class CallbackOptions { - final String? redirectUri; -} -``` - -**Note:** `redirectUri` must match the one used in `authorize()`. - -#### OAuthSession - -Authenticated session with token management. - -```dart -class OAuthSession { - final OAuthServerAgent server; - final String sub; // User's DID - - // Properties - String get did => sub; - Map get serverMetadata; - - // Methods - Future getTokenInfo([dynamic refresh = 'auto']); - Future signOut(); - Future fetchHandler( - String pathname, { - String method = 'GET', - Map? headers, - dynamic body, - }); -} -``` - -**Key Methods:** - -- `getTokenInfo()` - Get current token info (automatically refreshes if expired) -- `signOut()` - Revoke tokens and delete session -- `fetchHandler()` - Make authenticated HTTP request (with auto-refresh and DPoP) - -**Example:** - -```dart -final session = await client.signIn('alice.bsky.social'); - -// Get token info -final info = await session.getTokenInfo(); -print('Expires: ${info.expiresAt}'); -print('Scope: ${info.scope}'); - -// Make authenticated request -final response = await session.fetchHandler( - '/xrpc/com.atproto.repo.getRecord', - method: 'GET', -); -``` - -#### TokenInfo - -Information about the current access token. - -```dart -class TokenInfo { - final DateTime? expiresAt; - final bool? expired; - final String scope; - final String iss; // Issuer URL - final String aud; // Audience (PDS URL) - final String sub; // User's DID -} -``` - ---- - -### Errors - -All errors extend `Exception` and can be caught with standard try-catch. - -#### OAuthCallbackError - -OAuth error from server or invalid callback. - -```dart -class OAuthCallbackError implements Exception { - final String? error; // OAuth error code - final String? errorDescription; // Human-readable description - final String? errorUri; // URL with more info - final String? state; // App state from authorize - final Map params; // All callback parameters -} -``` - -**Common error codes:** -- `access_denied` - User denied authorization -- `invalid_request` - Invalid parameters -- `server_error` - Server error - -**Example:** - -```dart -try { - final session = await client.signIn('alice.bsky.social'); -} on OAuthCallbackError catch (e) { - if (e.error == 'access_denied') { - print('User cancelled sign-in'); - } else { - print('OAuth error: ${e.error} - ${e.errorDescription}'); - } -} -``` - -#### OAuthResolverError - -Failed to resolve identity or discover OAuth server. - -**When thrown:** -- Handle doesn't resolve -- DID document not found -- PDS URL missing from DID document -- OAuth server metadata not found - -#### TokenRefreshError - -Failed to refresh access token. - -**When thrown:** -- Refresh token expired -- Refresh token revoked -- Network error -- Server error - -#### TokenRevokedError - -Token was revoked (intentional sign-out). - -#### TokenInvalidError - -Token is invalid (rejected by resource server). - -#### AuthMethodUnsatisfiableError - -Client authentication method not supported. - ---- - -## Usage Guide - -### Sign In Flow - -Complete example with error handling: - -```dart -import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; - -Future signIn(String handle) async { - final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'http://localhost', - redirectUris: ['myapp://oauth/callback'], - scope: 'atproto transition:generic', - ), - ); - - try { - final session = await client.signIn(handle); - - print('✓ Signed in successfully!'); - print(' DID: ${session.sub}'); - - final info = await session.getTokenInfo(); - print(' Expires: ${info.expiresAt}'); - - } on OAuthCallbackError catch (e) { - if (e.error == 'access_denied') { - print('User denied authorization'); - } else { - print('OAuth error: ${e.error}'); - } - } catch (e) { - print('Unexpected error: $e'); - } -} -``` - -### Session Restoration - -Restore session when app restarts: - -```dart -Future restoreSession(FlutterOAuthClient client) async { - final did = await loadSavedDid(); - if (did == null) return null; - - try { - final session = await client.restore(did); - print('✓ Session restored for ${session.sub}'); - return session; - - } on TokenRefreshError catch (e) { - print('❌ Session refresh failed: ${e.message}'); - await clearSavedDid(); - return null; - } -} -``` - -### Token Refresh - -Tokens are refreshed **automatically**: - -```dart -// Auto-refresh (default) -final session = await client.restore(did); - -// Force refresh -final fresh = await client.restore(did, refresh: true); - -// Check token status -final info = await session.getTokenInfo(); -if (info.expired == true) { - print('Token will refresh on next API call'); -} -``` - -### Sign Out (Revoke) - -```dart -Future signOut(FlutterOAuthClient client, String did) async { - try { - await client.revoke(did); - print('✓ Signed out successfully'); - await clearSavedDid(); - } catch (e) { - print('⚠ Revoke failed: $e'); - await clearSavedDid(); - } -} -``` - -### Session Events - -```dart -void setupSessionListeners(FlutterOAuthClient client) { - client.onUpdated.listen((event) { - print('Session updated: ${event.sub}'); - }); - - client.onDeleted.listen((event) { - print('Session deleted: ${event.sub}'); - navigateToSignIn(); - }); -} -``` - ---- - -## Advanced Usage - -### Custom Storage Configuration - -```dart -final client = FlutterOAuthClient( - clientMetadata: metadata, - secureStorage: FlutterSecureStorage( - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock, - ), - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), - ), -); -``` - -### Direct OAuthClient Usage - -For full control over the OAuth flow: - -```dart -final client = OAuthClient( - OAuthClientOptions( - responseMode: OAuthResponseMode.query, - clientMetadata: metadata.toJson(), - stateStore: MyCustomStateStore(), - sessionStore: MyCustomSessionStore(), - runtimeImplementation: FlutterRuntime(), - ), -); - -// Manual flow -final authUrl = await client.authorize('alice.bsky.social'); -// Open browser yourself -final result = await client.callback(params); -``` - ---- - -## Decentralization Explained - -This is the **critical feature** that sets this package apart. - -### The Problem: Hardcoded Servers - -```dart -// ❌ BROKEN - Only works with bsky.social -const authServer = 'https://bsky.social'; // Hardcoded! -``` - -### The Solution: Dynamic Discovery - -```dart -// ✅ CORRECT - Discovers auth server dynamically -await client.signIn('bob.custom-pds.com'); - -// What happens: -// 1. Resolve handle → DID -// 2. Fetch DID document -// 3. Discover PDS URL -// 4. Fetch PDS metadata -// 5. Discover authorization server -// 6. Complete OAuth with correct server ✅ -``` - -### Why This Matters - -**atProto is decentralized.** Users can host their data on any PDS. Your app should work with ALL of them. - -### Real-World Example - -```dart -// Alice uses Bluesky -await client.signIn('alice.bsky.social'); -// → https://bsky.app - -// Bob runs his own -await client.signIn('bob.example.com'); -// → https://auth.example.com - -// All work! 🎉 -``` - ---- - -## Security Features - -### Secure Token Storage - -- **iOS:** Keychain with device encryption -- **Android:** EncryptedSharedPreferences (AES-256) - -### DPoP (Token Binding) - -- Binds tokens to cryptographic keys -- Prevents token theft -- Every request includes signed proof - -### PKCE (Code Protection) - -- SHA-256 challenge/verifier -- Prevents code interception - -### State Parameter - -- CSRF protection -- One-time use - ---- - -## OAuth Flows - -### Authorization Flow - -``` -App → Resolve identity → Discover servers → Generate PKCE/DPoP - → Open browser → User authenticates → Callback → Exchange code - → Store session → Return OAuthSession -``` - -### Token Refresh Flow - -``` -API call → Detect expiration → Acquire lock → Refresh tokens - → Update storage → Release lock → Retry API call -``` - ---- - -## Troubleshooting - -### Deep Linking Not Working - -1. Check platform configuration (Info.plist / AndroidManifest.xml) -2. Test manually: `xcrun simctl openurl booted "myapp://..."` -3. Verify URL scheme matches `redirectUris` - -### OAuth Errors - -- `invalid_request` - Check ClientMetadata -- `access_denied` - User cancelled -- `server_error` - Check server status - -### Token Refresh Failures - -- Token expired → User must re-authenticate -- Session auto-deleted on failure - ---- - -## Migration Guide - -### From `atproto_oauth` - -**Before (Broken):** -```dart -// Only works with bsky.social -final session = await client.signIn('bob.custom-pds.com'); // BROKEN -``` - -**After (Fixed):** -```dart -import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; - -final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'http://localhost', - redirectUris: ['myapp://oauth/callback'], - ), -); - -final session = await client.signIn('bob.custom-pds.com'); // WORKS! -``` - ---- - -## Architecture - -Built in **7 layers** matching TypeScript original: - -1. **Foundation** - Types, constants, utilities -2. **Runtime** - Crypto abstractions, PKCE, keys -3. **Identity Resolution** - DID/handle → PDS discovery (**critical for decentralization**) -4. **OAuth Discovery** - Dynamic server metadata fetching -5. **DPoP** - Token binding proofs -6. **OAuth Flow** - Authorization, tokens, sessions -7. **Flutter Platform** - Secure storage, crypto implementation - ---- - -## Examples - -See `example/flutter_oauth_example.dart` for complete examples. - -### Minimal Example - -```dart -final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'http://localhost', - redirectUris: ['myapp://oauth/callback'], - ), -); - -final session = await client.signIn('alice.bsky.social'); -print('Signed in: ${session.sub}'); -``` - ---- - -## Contributing - -Contributions welcome! Please: -1. Fork the repo -2. Create feature branch -3. Run `flutter analyze` -4. Submit PR - ---- - -## License - -MIT License - See LICENSE file - ---- - -## Credits - -- **Based on:** Official Bluesky [`@atproto/oauth-client`](https://github.com/bluesky-social/atproto/tree/main/packages/oauth/oauth-client) -- **Architecture:** 1:1 port maintaining API compatibility - ---- - -## Status - -**Version:** 0.1.0 -**Status:** ✅ Complete - Ready for Testing - -**Next:** -- Manual testing with real servers -- Unit/integration tests -- Publish to pub.dev - ---- - -**Made with ❤️ for the decentralized web** diff --git a/packages/atproto_oauth_flutter/example/flutter_oauth_example.dart b/packages/atproto_oauth_flutter/example/flutter_oauth_example.dart deleted file mode 100644 index bc137f3..0000000 --- a/packages/atproto_oauth_flutter/example/flutter_oauth_example.dart +++ /dev/null @@ -1,220 +0,0 @@ -/// Example usage of FlutterOAuthClient for atProto OAuth authentication. -/// -/// This demonstrates the complete OAuth flow for a Flutter application: -/// 1. Initialize the client -/// 2. Sign in with a handle -/// 3. Use the authenticated session -/// 4. Restore session on app restart -/// 5. Sign out (revoke session) - -import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; - -void main() async { - // ======================================================================== - // 1. Initialize the OAuth client - // ======================================================================== - - final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - // For development: use loopback client (no client metadata URL needed) - clientId: 'http://localhost', - - // For production: use discoverable client metadata - // clientId: 'https://example.com/client-metadata.json', - - // Redirect URIs for your app - // - Custom URL scheme: myapp://oauth/callback - // - Universal links: https://example.com/oauth/callback - redirectUris: ['myapp://oauth/callback'], - - // Scope: what permissions to request - // - 'atproto': Full atproto access - // - 'transition:generic': Additional permissions for legacy systems - scope: 'atproto transition:generic', - - // Client metadata - clientName: 'My Awesome App', - clientUri: 'https://example.com', - - // Token binding - dpopBoundAccessTokens: true, // Enable DPoP for security - ), - - // Response mode (query or fragment) - responseMode: OAuthResponseMode.query, - - // Allow HTTP only for development (never in production!) - allowHttp: false, - ); - - // ======================================================================== - // 2. Sign in with a handle - // ======================================================================== - - try { - print('Starting sign-in flow for alice.bsky.social...'); - - // This will: - // 1. Resolve the handle to find the authorization server - // 2. Generate PKCE code challenge/verifier - // 3. Generate DPoP key - // 4. Open browser for user authentication - // 5. Handle OAuth callback - // 6. Exchange authorization code for tokens - // 7. Store session securely - final session = await client.signIn('alice.bsky.social'); - - print('✓ Signed in successfully!'); - print(' DID: ${session.sub}'); - print(' Session info: ${session.info}'); - - // ======================================================================== - // 3. Use the authenticated session - // ======================================================================== - - // The session has a PDS client you can use for authenticated requests - // (This requires integrating with an atproto API client library) - // - // Example: - // final agent = session.pdsClient; - // final profile = await agent.getProfile(); - - print('Session is ready for API calls'); - } on OAuthCallbackError catch (e) { - // Handle OAuth errors (user cancelled, invalid state, etc.) - print('OAuth callback error: ${e.error}'); - print('Description: ${e.errorDescription}'); - return; - } catch (e) { - print('Sign-in error: $e'); - return; - } - - // ======================================================================== - // 4. Restore session on app restart - // ======================================================================== - - // Later, when the app restarts, restore the session: - try { - final did = 'did:plc:abc123'; // Get from storage or previous session - - print('Restoring session for $did...'); - - // This will: - // 1. Load session from secure storage - // 2. Check if tokens are expired - // 3. Automatically refresh if needed - // 4. Return authenticated session - final session = await client.restore(did); - - print('✓ Session restored!'); - print(' Access token expires: ${session.info['expiresAt']}'); - } catch (e) { - print('Failed to restore session: $e'); - // Session may have been revoked or expired - // Prompt user to sign in again - } - - // ======================================================================== - // 5. Sign out (revoke session) - // ======================================================================== - - try { - final did = 'did:plc:abc123'; - - print('Signing out $did...'); - - // This will: - // 1. Call token revocation endpoint (best effort) - // 2. Delete session from secure storage - // 3. Emit 'deleted' event - await client.revoke(did); - - print('✓ Signed out successfully'); - } catch (e) { - print('Sign out error: $e'); - // Session is still deleted locally even if revocation fails - } - - // ======================================================================== - // Advanced: Listen to session events - // ======================================================================== - - // Listen for session updates (token refresh, etc.) - client.onUpdated.listen((event) { - print('Session updated: ${event.sub}'); - print(' New access token received'); - }); - - // Listen for session deletions (revoked, expired, etc.) - client.onDeleted.listen((event) { - print('Session deleted: ${event.sub}'); - print(' Cause: ${event.cause}'); - // Handle session deletion (navigate to sign-in screen, etc.) - }); - - // ======================================================================== - // Advanced: Custom configuration - // ======================================================================== - - // You can customize storage, caching, and crypto: - final customClient = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'https://example.com/client-metadata.json', - redirectUris: ['myapp://oauth/callback'], - ), - - // Custom secure storage instance - secureStorage: const FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ), - - // Custom PLC directory URL (for private deployments) - plcDirectoryUrl: 'https://plc.example.com', - - // Custom handle resolver URL - handleResolverUrl: 'https://bsky.social', - ); - - print('Custom client initialized'); - - // ======================================================================== - // Platform configuration (iOS) - // ======================================================================== - - // iOS: Add URL scheme to Info.plist - // CFBundleURLTypes - // - // - // CFBundleURLSchemes - // - // myapp - // - // - // - - // ======================================================================== - // Platform configuration (Android) - // ======================================================================== - - // Android: Add intent filter to AndroidManifest.xml - // - // - // - // - // - // - - // ======================================================================== - // Security best practices - // ======================================================================== - - // ✓ Tokens stored in secure storage (Keychain/EncryptedSharedPreferences) - // ✓ DPoP binds tokens to cryptographic keys - // ✓ PKCE prevents authorization code interception - // ✓ State parameter prevents CSRF attacks - // ✓ Automatic token refresh with concurrency control - // ✓ Session cleanup on errors - - print('Example complete!'); -} diff --git a/packages/atproto_oauth_flutter/example/identity_resolver_example.dart b/packages/atproto_oauth_flutter/example/identity_resolver_example.dart deleted file mode 100644 index a52a9f3..0000000 --- a/packages/atproto_oauth_flutter/example/identity_resolver_example.dart +++ /dev/null @@ -1,104 +0,0 @@ -/// Example usage of the atProto identity resolution layer. -/// -/// This demonstrates the critical functionality for decentralization: -/// resolving handles and DIDs to find where user data is actually stored. - -import 'package:atproto_oauth_flutter/src/identity/identity.dart'; - -Future main() async { - print('=== atProto Identity Resolution Examples ===\n'); - - // Create an identity resolver - // The handleResolverUrl should point to an XRPC service that implements - // com.atproto.identity.resolveHandle (typically bsky.social for public resolution) - final resolver = AtprotoIdentityResolver.withDefaults( - handleResolverUrl: 'https://bsky.social', - ); - - print('Example 1: Resolve a Bluesky handle to find their PDS'); - print('--------------------------------------------------'); - try { - // This is the most common use case: find where a user's data lives - final pdsUrl = await resolver.resolveToPds('pfrazee.com'); - print('Handle: pfrazee.com'); - print('PDS URL: $pdsUrl'); - print('✓ This user hosts their data on: $pdsUrl\n'); - } catch (e) { - print('Error: $e\n'); - } - - print('Example 2: Get full identity information'); - print('--------------------------------------------------'); - try { - final info = await resolver.resolve('pfrazee.com'); - print('Handle: ${info.handle}'); - print('DID: ${info.did}'); - print('PDS URL: ${info.pdsUrl}'); - print('Has valid handle: ${info.hasValidHandle}'); - print('Also known as: ${info.didDoc.alsoKnownAs}'); - print('✓ Complete identity information retrieved\n'); - } catch (e) { - print('Error: $e\n'); - } - - print('Example 3: Resolve from a DID'); - print('--------------------------------------------------'); - try { - // You can also start from a DID - final info = await resolver.resolveFromDid( - 'did:plc:ragtjsm2j2vknwkz3zp4oxrd', - ); - print('DID: ${info.did}'); - print('Handle: ${info.handle}'); - print('PDS URL: ${info.pdsUrl}'); - print('✓ Resolved DID to handle and PDS\n'); - } catch (e) { - print('Error: $e\n'); - } - - print('Example 4: Custom domain handle (CRITICAL for decentralization)'); - print('--------------------------------------------------'); - try { - // This demonstrates why this code is essential: - // Users can use their own domains and host on their own PDS - final info = await resolver.resolve('jay.bsky.team'); - print('Handle: ${info.handle}'); - print('DID: ${info.did}'); - print('PDS URL: ${info.pdsUrl}'); - print('✓ Custom domain resolves to custom PDS (not hardcoded!)\n'); - } catch (e) { - print('Error: $e\n'); - } - - print('Example 5: Validation - Invalid handle'); - print('--------------------------------------------------'); - try { - await resolver.resolve('not-a-valid-handle'); - } catch (e) { - print('✓ Correctly rejected invalid handle: $e\n'); - } - - print('=== Why This Matters ==='); - print(''' -This identity resolution layer is THE CRITICAL PIECE for atProto decentralization: - -1. **No Hardcoded Servers**: Unlike broken implementations that hardcode bsky.social, - this correctly resolves each user's actual PDS location. - -2. **Custom Domains**: Users can use their own domains (e.g., alice.example.com) - and host on any PDS they choose. - -3. **Portability**: Users can change their PDS without losing their DID or identity. - The DID document always points to the current PDS location. - -4. **Bi-directional Validation**: We verify that: - - Handle → DID resolution works - - DID document contains the handle - - Both directions match (security!) - -5. **Caching**: Built-in caching prevents redundant lookups while respecting TTLs. - -Without this layer, apps are locked to centralized servers. With it, atProto -achieves true decentralization where users control their data location. -'''); -} diff --git a/packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart b/packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart deleted file mode 100644 index a018944..0000000 --- a/packages/atproto_oauth_flutter/lib/atproto_oauth_flutter.dart +++ /dev/null @@ -1,104 +0,0 @@ -/// atproto OAuth client for Flutter. -/// -/// This library provides OAuth authentication capabilities for AT Protocol -/// (atproto) applications on Flutter/Dart platforms. -/// -/// This is a 1:1 port of the TypeScript @atproto/oauth-client package to Dart. -/// -/// ## Quick Start -/// -/// ```dart -/// import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; -/// -/// // 1. Initialize client -/// final client = FlutterOAuthClient( -/// clientMetadata: ClientMetadata( -/// clientId: 'https://example.com/client-metadata.json', -/// redirectUris: ['myapp://oauth/callback'], -/// scope: 'atproto transition:generic', -/// ), -/// ); -/// -/// // 2. Sign in with handle -/// final session = await client.signIn('alice.bsky.social'); -/// print('Signed in as: ${session.sub}'); -/// -/// // 3. Use authenticated session -/// // (Integrate with your atproto API client) -/// -/// // 4. Later: restore session -/// final restored = await client.restore(session.sub); -/// -/// // 5. Sign out -/// await client.revoke(session.sub); -/// ``` -/// -/// ## Features -/// -/// - Full OAuth 2.0 + OIDC support with PKCE -/// - DPoP (Demonstrating Proof of Possession) for token security -/// - Automatic token refresh -/// - Secure session storage (flutter_secure_storage) -/// - Handle and DID resolution -/// - PAR (Pushed Authorization Request) support -/// - Works with any atProto PDS or authorization server -/// -/// ## Security -/// -/// - Tokens stored in device secure storage (Keychain/EncryptedSharedPreferences) -/// - DPoP binds tokens to cryptographic keys -/// - PKCE prevents authorization code interception -/// - Automatic session cleanup on errors -/// -library; - -// ============================================================================ -// Main API - Start here! -// ============================================================================ - -/// High-level Flutter OAuth client (recommended for most apps) -export 'src/platform/flutter_oauth_client.dart'; - -/// Router integration helpers (for go_router, auto_route, etc.) -export 'src/platform/flutter_oauth_router_helper.dart'; - -// ============================================================================ -// Core OAuth Client -// ============================================================================ - -/// Core OAuth client and types (for advanced use cases) -export 'src/client/oauth_client.dart'; - -// ============================================================================ -// Sessions -// ============================================================================ - -/// OAuth session types -export 'src/session/oauth_session.dart'; - -// ============================================================================ -// Types -// ============================================================================ - -/// Core types and options -export 'src/types.dart'; - -// ============================================================================ -// Platform Implementations (for custom configurations) -// ============================================================================ - -/// Storage implementations (for customization) -export 'src/platform/flutter_stores.dart'; - -/// Runtime implementation (cryptographic operations) -export 'src/platform/flutter_runtime.dart'; - -/// Key implementation (EC keys with pointycastle) -export 'src/platform/flutter_key.dart'; - -// ============================================================================ -// Errors -// ============================================================================ - -/// All OAuth error types -export 'src/errors/errors.dart'; diff --git a/packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart b/packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart deleted file mode 100644 index c064500..0000000 --- a/packages/atproto_oauth_flutter/lib/src/client/oauth_client.dart +++ /dev/null @@ -1,977 +0,0 @@ -import 'dart:async'; -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; - -import '../constants.dart'; -import '../dpop/fetch_dpop.dart' show InMemoryStore; -import '../errors/auth_method_unsatisfiable_error.dart'; -import '../errors/oauth_callback_error.dart'; -import '../errors/token_revoked_error.dart'; -import '../identity/constants.dart'; -import '../identity/did_helpers.dart' show assertAtprotoDid; -import '../identity/did_resolver.dart' show DidCache; -import '../identity/handle_resolver.dart' show HandleCache; -import '../identity/identity_resolver.dart'; -import '../oauth/authorization_server_metadata_resolver.dart' as auth_resolver; -import '../oauth/client_auth.dart'; -import '../oauth/oauth_resolver.dart'; -import '../oauth/oauth_server_agent.dart'; -import '../oauth/oauth_server_factory.dart'; -import '../oauth/protected_resource_metadata_resolver.dart'; -import '../oauth/validate_client_metadata.dart'; -import '../platform/flutter_key.dart'; -import '../runtime/runtime.dart' as runtime_lib; -import '../runtime/runtime_implementation.dart'; -import '../session/oauth_session.dart' - show OAuthSession, Session, SessionGetterInterface; -import '../session/session_getter.dart'; -import '../session/state_store.dart'; -import '../types.dart'; -import '../util.dart'; - -// Re-export types needed for OAuthClientOptions -export '../identity/did_resolver.dart' show DidCache, DidResolver; -export '../identity/handle_resolver.dart' show HandleCache, HandleResolver; -export '../identity/identity_resolver.dart' show IdentityResolver; -export '../oauth/authorization_server_metadata_resolver.dart' - show AuthorizationServerMetadataCache; -export '../oauth/oauth_server_agent.dart' show DpopNonceCache; -export '../oauth/protected_resource_metadata_resolver.dart' - show ProtectedResourceMetadataCache; -export '../runtime/runtime_implementation.dart' show RuntimeImplementation, Key; -export '../oauth/client_auth.dart' show Keyset; -export '../session/session_getter.dart' - show SessionStore, SessionUpdatedEvent, SessionDeletedEvent; -export '../session/state_store.dart' show StateStore, InternalStateData; -export '../types.dart' show ClientMetadata, AuthorizeOptions, CallbackOptions; - -/// OAuth response mode. -enum OAuthResponseMode { - /// Parameters in query string (default, most compatible) - query('query'), - - /// Parameters in URL fragment (for single-page apps) - fragment('fragment'); - - final String value; - const OAuthResponseMode(this.value); - - @override - String toString() => value; -} - -/// Options for constructing an OAuthClient. -/// -/// This includes all configuration, storage, and service dependencies -/// needed to implement the complete OAuth flow. -class OAuthClientOptions { - // Config - /// Response mode for OAuth (query or fragment) - final OAuthResponseMode responseMode; - - /// Client metadata (validated before use) - final Map clientMetadata; - - /// Optional keyset for confidential clients (private_key_jwt) - final Keyset? keyset; - - /// Whether to allow HTTP connections (for development only) - /// - /// This affects: - /// - OAuth authorization/resource servers - /// - did:web document fetching - /// - /// Note: PLC directory connections are controlled separately. - final bool allowHttp; - - // Stores - /// Storage for OAuth state during authorization flow - final StateStore stateStore; - - /// Storage for session tokens - final SessionStore sessionStore; - - /// Optional cache for authorization server metadata - final auth_resolver.AuthorizationServerMetadataCache? - authorizationServerMetadataCache; - - /// Optional cache for protected resource metadata - final ProtectedResourceMetadataCache? protectedResourceMetadataCache; - - /// Optional cache for DPoP nonces - final DpopNonceCache? dpopNonceCache; - - /// Optional cache for DID documents - final DidCache? didCache; - - /// Optional cache for handle → DID resolutions - final HandleCache? handleCache; - - // Services - /// Platform-specific cryptographic operations - final RuntimeImplementation runtimeImplementation; - - /// Optional HTTP client (Dio instance) - final Dio? dio; - - /// Optional custom identity resolver - final IdentityResolver? identityResolver; - - /// PLC directory URL (for DID resolution) - final String? plcDirectoryUrl; - - /// Handle resolver URL (for handle → DID resolution) - final String? handleResolverUrl; - - const OAuthClientOptions({ - required this.responseMode, - required this.clientMetadata, - this.keyset, - this.allowHttp = false, - required this.stateStore, - required this.sessionStore, - this.authorizationServerMetadataCache, - this.protectedResourceMetadataCache, - this.dpopNonceCache, - this.didCache, - this.handleCache, - required this.runtimeImplementation, - this.dio, - this.identityResolver, - this.plcDirectoryUrl, - this.handleResolverUrl, - }); -} - -/// Result of a successful OAuth callback. -class CallbackResult { - /// The authenticated session - final OAuthSession session; - - /// The application state from the original authorize call - final String? state; - - const CallbackResult({required this.session, this.state}); -} - -/// Options for fetching client metadata from a discoverable client ID. -class OAuthClientFetchMetadataOptions { - /// The discoverable client ID (HTTPS URL) - final String clientId; - - /// Optional HTTP client - final Dio? dio; - - /// Optional cancellation token - final CancelToken? cancelToken; - - const OAuthClientFetchMetadataOptions({ - required this.clientId, - this.dio, - this.cancelToken, - }); -} - -/// Main OAuth client for atProto OAuth flows. -/// -/// This is the primary class that developers interact with. It orchestrates: -/// - Authorization flow (authorize → callback) -/// - Session restoration (restore) -/// - Token revocation (revoke) -/// - Session lifecycle events -/// -/// Usage: -/// ```dart -/// final client = OAuthClient( -/// clientMetadata: { -/// 'client_id': 'https://example.com/client-metadata.json', -/// 'redirect_uris': ['myapp://oauth/callback'], -/// 'scope': 'atproto', -/// }, -/// responseMode: OAuthResponseMode.query, -/// stateStore: MyStateStore(), -/// sessionStore: MySessionStore(), -/// runtimeImplementation: MyRuntimeImplementation(), -/// ); -/// -/// // Start authorization -/// final authUrl = await client.authorize('alice.bsky.social'); -/// -/// // Handle callback -/// final result = await client.callback(callbackParams); -/// print('Signed in as: ${result.session.sub}'); -/// -/// // Restore session later -/// final session = await client.restore('did:plc:abc123'); -/// -/// // Revoke session -/// await client.revoke('did:plc:abc123'); -/// ``` -class OAuthClient extends CustomEventTarget> { - // Config - /// Validated client metadata - final ClientMetadata clientMetadata; - - /// OAuth response mode (query or fragment) - final OAuthResponseMode responseMode; - - /// Optional keyset for confidential clients - final Keyset? keyset; - - // Services - /// Runtime for cryptographic operations - final runtime_lib.Runtime runtime; - - /// HTTP client - final Dio dio; - - /// OAuth resolver for identity → metadata - final OAuthResolver oauthResolver; - - /// Factory for creating OAuth server agents - final OAuthServerFactory serverFactory; - - // Stores - /// Session management with automatic refresh - final SessionGetter _sessionGetter; - - /// OAuth state storage - final StateStore _stateStore; - - // Event streams - final StreamController _updatedController = - StreamController.broadcast(); - final StreamController _deletedController = - StreamController.broadcast(); - - /// Stream of session update events - Stream get onUpdated => _updatedController.stream; - - /// Stream of session deletion events - Stream get onDeleted => _deletedController.stream; - - /// Constructs an OAuthClient with the given options. - /// - /// Throws [FormatException] if client metadata is invalid. - /// Throws [TypeError] if keyset configuration is incorrect. - OAuthClient(OAuthClientOptions options) - : keyset = options.keyset, - responseMode = options.responseMode, - runtime = runtime_lib.Runtime(options.runtimeImplementation), - dio = options.dio ?? Dio(), - _stateStore = options.stateStore, - clientMetadata = validateClientMetadata( - options.clientMetadata, - options.keyset, - ), - oauthResolver = _createOAuthResolver(options), - serverFactory = _createServerFactory(options), - _sessionGetter = _createSessionGetter(options) { - // Proxy session events from SessionGetter - _sessionGetter.onUpdated.listen((event) { - _updatedController.add(event); - dispatchCustomEvent('updated', event); - }); - - _sessionGetter.onDeleted.listen((event) { - _deletedController.add(event); - dispatchCustomEvent('deleted', event); - }); - } - - /// Creates the OAuth resolver. - static OAuthResolver _createOAuthResolver(OAuthClientOptions options) { - final dio = options.dio ?? Dio(); - - return OAuthResolver( - identityResolver: - options.identityResolver ?? - AtprotoIdentityResolver.withDefaults( - handleResolverUrl: - options.handleResolverUrl ?? 'https://bsky.social', - plcDirectoryUrl: options.plcDirectoryUrl, - dio: dio, - didCache: options.didCache, - handleCache: options.handleCache, - ), - protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver( - options.protectedResourceMetadataCache ?? - InMemoryStore>(), - dio: dio, - config: OAuthProtectedResourceMetadataResolverConfig( - allowHttpResource: options.allowHttp, - ), - ), - authorizationServerMetadataResolver: - auth_resolver.OAuthAuthorizationServerMetadataResolver( - options.authorizationServerMetadataCache ?? - InMemoryStore>(), - dio: dio, - config: - auth_resolver.OAuthAuthorizationServerMetadataResolverConfig( - allowHttpIssuer: options.allowHttp, - ), - ), - ); - } - - /// Creates the OAuth server factory. - static OAuthServerFactory _createServerFactory(OAuthClientOptions options) { - return OAuthServerFactory( - clientMetadata: validateClientMetadata( - options.clientMetadata, - options.keyset, - ), - runtime: runtime_lib.Runtime(options.runtimeImplementation), - resolver: _createOAuthResolver(options), - dio: options.dio ?? Dio(), - keyset: options.keyset, - dpopNonceCache: options.dpopNonceCache ?? InMemoryStore(), - ); - } - - /// Creates the session getter. - static SessionGetter _createSessionGetter(OAuthClientOptions options) { - return SessionGetter( - sessionStore: options.sessionStore, - serverFactory: _createServerFactory(options), - runtime: runtime_lib.Runtime(options.runtimeImplementation), - ); - } - - /// Fetches client metadata from a discoverable client ID URL. - /// - /// This is a static helper method for fetching metadata before - /// constructing the OAuthClient. - /// - /// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ - static Future> fetchMetadata( - OAuthClientFetchMetadataOptions options, - ) async { - final dio = options.dio ?? Dio(); - final clientId = options.clientId; - - try { - final response = await dio.getUri>( - Uri.parse(clientId), - options: Options( - followRedirects: false, - validateStatus: (status) => status == 200, - responseType: ResponseType.json, - ), - cancelToken: options.cancelToken, - ); - - // Validate content type - final contentType = response.headers.value('content-type'); - final mime = contentType?.split(';')[0].trim(); - if (mime != 'application/json') { - throw FormatException('Invalid client metadata content type: $mime'); - } - - final data = response.data; - if (data == null) { - throw FormatException('Empty client metadata response'); - } - - return data; - } catch (e) { - if (e is DioException) { - throw Exception('Failed to fetch client metadata: ${e.message}'); - } - rethrow; - } - } - - /// Exposes the identity resolver for convenience. - IdentityResolver get identityResolver => oauthResolver.identityResolver; - - /// Returns the public JWKS for this client (for confidential clients). - /// - /// This is the JWKS that should be published at the client's jwks_uri - /// or included in the client metadata. - Map get jwks { - if (keyset == null) { - return {'keys': >[]}; - } - return keyset!.toJSON(); - } - - /// Initiates an OAuth authorization flow. - /// - /// This method: - /// 1. Resolves the input (handle, DID, or URL) to OAuth metadata - /// 2. Generates PKCE parameters - /// 3. Generates DPoP key - /// 4. Negotiates client authentication method - /// 5. Stores internal state - /// 6. Uses PAR (Pushed Authorization Request) if supported - /// 7. Returns the authorization URL to open in a browser - /// - /// The [input] can be: - /// - An atProto handle (e.g., "alice.bsky.social") - /// - A DID (e.g., "did:plc:...") - /// - A PDS URL (e.g., "https://pds.example.com") - /// - An authorization server URL (e.g., "https://auth.example.com") - /// - /// The [options] can specify: - /// - redirectUri: Override the default redirect URI - /// - state: Application state to preserve - /// - scope: Override the default scope - /// - Other OIDC parameters (prompt, display, etc.) - /// - /// Throws [FormatException] if parameters are invalid. - /// Throws [OAuthResolverError] if resolution fails. - Future authorize( - String input, { - AuthorizeOptions? options, - CancelToken? cancelToken, - }) async { - final opts = options ?? const AuthorizeOptions(); - - // Validate redirect URI - final redirectUri = opts.redirectUri ?? clientMetadata.redirectUris.first; - if (!clientMetadata.redirectUris.contains(redirectUri)) { - throw FormatException('Invalid redirect_uri: $redirectUri'); - } - - // Resolve input to OAuth metadata - final resolved = await oauthResolver.resolve( - input, - auth_resolver.GetCachedOptions(cancelToken: cancelToken), - ); - - final metadata = resolved.metadata; - - // Generate PKCE - final pkce = await runtime.generatePKCE(); - - // Generate DPoP key - final dpopAlgs = metadata['dpop_signing_alg_values_supported'] as List?; - final dpopKey = await runtime.generateKey( - dpopAlgs?.cast() ?? [fallbackAlg], - ); - - // Compute DPoP JWK thumbprint for authorization requests. - // Required by RFC 9449 §7 to bind the subsequently issued code to this key. - final bareJwk = dpopKey.bareJwk; - if (bareJwk == null) { - throw StateError('DPoP key must provide a public JWK representation'); - } - final generatedDpopJkt = await runtime.calculateJwkThumbprint(bareJwk); - - // Negotiate client authentication method - final authMethod = negotiateClientAuthMethod( - metadata, - clientMetadata, - keyset, - ); - - // Generate state parameter - final state = await runtime.generateNonce(); - - // Store internal state for callback validation - // IMPORTANT: Store the FULL private JWK, not just bareJwk (public key only) - // We need the private key to restore the DPoP key during token exchange - final dpopKeyJwk = (dpopKey as dynamic).privateJwk ?? dpopKey.bareJwk ?? {}; - - if (kDebugMode) { - print('🔑 Storing DPoP key for authorization flow'); - } - - await _stateStore.set( - state, - InternalStateData( - iss: metadata['issuer'] as String, - dpopKey: dpopKeyJwk, - authMethod: authMethod.toJson(), - verifier: pkce['verifier'] as String, - redirectUri: redirectUri, // Store the exact redirectUri used in PAR - appState: opts.state, - ), - ); - - // Build authorization request parameters - final parameters = { - 'client_id': clientMetadata.clientId!, - 'redirect_uri': redirectUri, - 'code_challenge': pkce['challenge'] as String, - 'code_challenge_method': pkce['method'] as String, - 'state': state, - 'response_mode': responseMode.value, - 'response_type': 'code', - 'scope': opts.scope ?? clientMetadata.scope ?? 'atproto', - 'dpop_jkt': opts.dpopJkt ?? generatedDpopJkt, - }; - - // Add login hint if we have identity info - if (resolved.identityInfo != null) { - final handle = resolved.identityInfo!.handle; - final did = resolved.identityInfo!.did; - if (handle != handleInvalid) { - parameters['login_hint'] = handle; - } else { - parameters['login_hint'] = did; - } - } - - // Add optional parameters from options - if (opts.nonce != null) parameters['nonce'] = opts.nonce!; - if (opts.display != null) parameters['display'] = opts.display!; - if (opts.prompt != null) parameters['prompt'] = opts.prompt!; - if (opts.maxAge != null) parameters['max_age'] = opts.maxAge.toString(); - if (opts.uiLocales != null) parameters['ui_locales'] = opts.uiLocales!; - if (opts.idTokenHint != null) { - parameters['id_token_hint'] = opts.idTokenHint!; - } - - // Build authorization URL - final authorizationUrl = Uri.parse( - metadata['authorization_endpoint'] as String, - ); - - // Validate authorization endpoint protocol - if (authorizationUrl.scheme != 'https' && - authorizationUrl.scheme != 'http') { - throw FormatException( - 'Invalid authorization endpoint protocol: ${authorizationUrl.scheme}', - ); - } - - // Use PAR (Pushed Authorization Request) if supported - final parEndpoint = - metadata['pushed_authorization_request_endpoint'] as String?; - final requiresPar = - metadata['require_pushed_authorization_requests'] as bool? ?? false; - - if (parEndpoint != null) { - // Server supports PAR, use it - final server = await serverFactory.fromMetadata( - metadata, - authMethod, - dpopKey, - ); - - final parResponse = await server.request( - 'pushed_authorization_request', - parameters, - ); - - final requestUri = parResponse['request_uri'] as String; - - // Return simplified URL with just request_uri - return authorizationUrl.replace( - queryParameters: { - 'client_id': clientMetadata.clientId!, - 'request_uri': requestUri, - }, - ); - } else if (requiresPar) { - throw Exception( - 'Server requires pushed authorization requests (PAR) but no PAR endpoint is available', - ); - } else { - // No PAR support, use direct authorization request - final fullUrl = authorizationUrl.replace(queryParameters: parameters); - - // Check URL length (2048 byte limit for some browsers) - final urlLength = fullUrl.toString().length; - if (urlLength >= 2048) { - throw Exception('Login URL too long ($urlLength bytes)'); - } - - return fullUrl; - } - } - - /// Handles the OAuth callback after user authorization. - /// - /// This method: - /// 1. Validates the state parameter - /// 2. Retrieves stored internal state - /// 3. Checks for error responses - /// 4. Validates issuer (if provided) - /// 5. Exchanges authorization code for tokens - /// 6. Creates and stores session - /// 7. Cleans up state - /// - /// The [params] should be the query parameters from the callback URL. - /// - /// The [options] can specify: - /// - redirectUri: Must match the one used in authorize() - /// - /// Returns a [CallbackResult] with the session and application state. - /// - /// Throws [OAuthCallbackError] if the callback contains errors or is invalid. - Future callback( - Map params, { - CallbackOptions? options, - CancelToken? cancelToken, - }) async { - final opts = options ?? const CallbackOptions(); - - // Check for JARM (not supported) - final responseJwt = params['response']; - if (responseJwt != null) { - throw OAuthCallbackError(params, message: 'JARM not supported'); - } - - // Extract parameters - final issuerParam = params['iss']; - final stateParam = params['state']; - final errorParam = params['error']; - final codeParam = params['code']; - - // Validate state parameter - if (stateParam == null) { - throw OAuthCallbackError(params, message: 'Missing "state" parameter'); - } - - // Retrieve internal state - final stateData = await _stateStore.get(stateParam); - if (stateData == null) { - throw OAuthCallbackError( - params, - message: 'Unknown authorization session "$stateParam"', - ); - } - - // Prevent replay attacks - delete state immediately - await _stateStore.del(stateParam); - - try { - // Check for error response - if (errorParam != null) { - throw OAuthCallbackError(params, state: stateData.appState); - } - - // Validate authorization code - if (codeParam == null) { - throw OAuthCallbackError( - params, - message: 'Missing "code" query param', - state: stateData.appState, - ); - } - - // Create OAuth server agent - final authMethod = - stateData.authMethod != null - ? ClientAuthMethod.fromJson( - stateData.authMethod as Map, - ) - : const ClientAuthMethod.none(); // Legacy fallback - - // Restore dpopKey from stored private JWK - // Restore DPoP key with error handling for corrupted JWK data - final FlutterKey dpopKey; - try { - dpopKey = FlutterKey.fromJwk(stateData.dpopKey as Map); - if (kDebugMode) { - print('🔓 DPoP key restored successfully for token exchange'); - } - } catch (e) { - throw Exception( - 'Failed to restore DPoP key from stored state: $e. ' - 'The stored key may be corrupted. Please try authenticating again.', - ); - } - - final server = await serverFactory.fromIssuer( - stateData.iss, - authMethod, - dpopKey, - auth_resolver.GetCachedOptions(cancelToken: cancelToken), - ); - - // Validate issuer if provided - if (issuerParam != null) { - if (server.issuer.isEmpty) { - throw OAuthCallbackError( - params, - message: 'Issuer not found in metadata', - state: stateData.appState, - ); - } - if (server.issuer != issuerParam) { - throw OAuthCallbackError( - params, - message: 'Issuer mismatch', - state: stateData.appState, - ); - } - } else if (server - .serverMetadata['authorization_response_iss_parameter_supported'] == - true) { - throw OAuthCallbackError( - params, - message: 'iss missing from the response', - state: stateData.appState, - ); - } - - // Exchange authorization code for tokens - // CRITICAL: Use the EXACT same redirectUri that was used during authorization - // The redirectUri in the token exchange MUST match the one in the PAR request - final redirectUriForExchange = - stateData.redirectUri ?? - opts.redirectUri ?? - clientMetadata.redirectUris.first; - - if (kDebugMode) { - print('🔄 Exchanging authorization code for tokens:'); - print(' Code: ${codeParam.substring(0, 20)}...'); - print( - ' Code verifier: ${stateData.verifier?.substring(0, 20) ?? "none"}...', - ); - print(' Redirect URI: $redirectUriForExchange'); - print( - ' Redirect URI source: ${stateData.redirectUri != null ? "stored" : "fallback"}', - ); - print(' Issuer: ${server.issuer}'); - } - - final tokenSet = await server.exchangeCode( - codeParam, - codeVerifier: stateData.verifier, - redirectUri: redirectUriForExchange, - ); - - try { - if (kDebugMode) { - print('💾 Storing session for: ${tokenSet.sub}'); - } - - // Store session - await _sessionGetter.setStored( - tokenSet.sub, - Session( - dpopKey: stateData.dpopKey, - authMethod: authMethod.toJson(), - tokenSet: tokenSet, - ), - ); - - if (kDebugMode) { - print('✅ Session stored successfully'); - print('🎯 Creating session wrapper...'); - } - - // Create session wrapper - final session = _createSession(server, tokenSet.sub); - - if (kDebugMode) { - print('✅ Session wrapper created'); - print('🎉 OAuth callback complete!'); - } - - return CallbackResult(session: session, state: stateData.appState); - } catch (err, stackTrace) { - // If session storage failed, revoke the tokens - if (kDebugMode) { - print('❌ Session storage/creation failed:'); - print(' Error: $err'); - print(' Stack trace: $stackTrace'); - } - await server.revoke(tokenSet.refreshToken ?? tokenSet.accessToken); - rethrow; - } - } catch (err, stackTrace) { - // Ensure appState is available in error - if (kDebugMode) { - print('❌ Callback error (outer catch):'); - print(' Error type: ${err.runtimeType}'); - print(' Error: $err'); - print(' Stack trace: $stackTrace'); - } - throw OAuthCallbackError.from(err, params, stateData.appState); - } - } - - /// Restores a stored session. - /// - /// This method: - /// 1. Retrieves session from storage - /// 2. Checks if tokens are expired - /// 3. Automatically refreshes tokens if needed (based on [refresh]) - /// 4. Creates OAuthServerAgent - /// 5. Returns live OAuthSession - /// - /// The [sub] is the user's DID. - /// - /// The [refresh] parameter controls token refresh: - /// - `true`: Force refresh even if not expired - /// - `false`: Use cached tokens even if expired - /// - `'auto'`: Refresh only if expired (default) - /// - /// Throws [Exception] if session doesn't exist. - /// Throws [TokenRefreshError] if refresh fails. - /// Throws [AuthMethodUnsatisfiableError] if auth method can't be satisfied. - Future restore( - String sub, { - dynamic refresh = 'auto', - CancelToken? cancelToken, - }) async { - // Validate DID format - assertAtprotoDid(sub); - - // Get session (automatically refreshes if needed based on refresh param) - final session = await _sessionGetter.getSession(sub, refresh); - - try { - // Determine auth method (with legacy fallback) - final authMethod = - session.authMethod != null - ? ClientAuthMethod.fromJson( - session.authMethod as Map, - ) - : const ClientAuthMethod.none(); // Legacy - - // Restore dpopKey from stored private JWK with error handling - // CRITICAL FIX: Use the stored key instead of generating a new one - // This ensures DPoP proofs match the token binding - final FlutterKey dpopKey; - try { - dpopKey = FlutterKey.fromJwk(session.dpopKey as Map); - } catch (e) { - // If key is corrupted, delete the session and force re-authentication - await _sessionGetter.delStored( - sub, - Exception('Corrupted DPoP key in stored session: $e'), - ); - throw Exception( - 'Failed to restore DPoP key for session. The stored key is corrupted. ' - 'Please authenticate again.', - ); - } - - // Create server agent - final server = await serverFactory.fromIssuer( - session.tokenSet.iss, - authMethod, - dpopKey, - auth_resolver.GetCachedOptions( - noCache: refresh == true, - allowStale: refresh == false, - cancelToken: cancelToken, - ), - ); - - return _createSession(server, sub); - } catch (err) { - // If auth method can't be satisfied, delete the session - if (err is AuthMethodUnsatisfiableError) { - await _sessionGetter.delStored(sub, err); - } - rethrow; - } - } - - /// Revokes a session. - /// - /// This method: - /// 1. Retrieves session from storage - /// 2. Calls token revocation endpoint - /// 3. Deletes session from storage - /// - /// The [sub] is the user's DID. - /// - /// Token revocation is best-effort - even if the revocation request fails, - /// the local session is still deleted. - Future revoke(String sub, {CancelToken? cancelToken}) async { - // Validate DID format - assertAtprotoDid(sub); - - // Get session (allow stale tokens for revocation) - final session = await _sessionGetter.get( - sub, - const GetCachedOptions(allowStale: true), - ); - - // Try to revoke tokens on the server - try { - final authMethod = - session.authMethod != null - ? ClientAuthMethod.fromJson( - session.authMethod as Map, - ) - : const ClientAuthMethod.none(); // Legacy - - // Restore dpopKey from stored private JWK with error handling - // CRITICAL FIX: Use the stored key instead of generating a new one - // This ensures DPoP proofs match the token binding - final FlutterKey dpopKey; - try { - dpopKey = FlutterKey.fromJwk(session.dpopKey as Map); - } catch (e) { - // If key is corrupted, skip server-side revocation - // The finally block will still delete the local session - if (kDebugMode) { - print('⚠️ Cannot revoke on server: corrupted DPoP key ($e)'); - print(' Local session will still be deleted'); - } - return; - } - - final server = await serverFactory.fromIssuer( - session.tokenSet.iss, - authMethod, - dpopKey, - auth_resolver.GetCachedOptions(cancelToken: cancelToken), - ); - - await server.revoke(session.tokenSet.accessToken); - } finally { - // Always delete local session, even if revocation failed - await _sessionGetter.delStored(sub, TokenRevokedError(sub)); - } - } - - /// Creates an OAuthSession wrapper. - /// - /// Internal helper for creating session objects from server agents. - OAuthSession _createSession(OAuthServerAgent server, String sub) { - // Create a wrapper that implements SessionGetterInterface - final sessionGetterWrapper = _SessionGetterWrapper(_sessionGetter); - - return OAuthSession( - server: server, - sub: sub, - sessionGetter: sessionGetterWrapper, - ); - } - - /// Disposes of resources used by this client. - /// - /// Call this when the client is no longer needed to prevent memory leaks. - @override - void dispose() { - _updatedController.close(); - _deletedController.close(); - _sessionGetter.dispose(); - super.dispose(); - } -} - -/// Wrapper to adapt SessionGetter to SessionGetterInterface -class _SessionGetterWrapper implements SessionGetterInterface { - final SessionGetter _getter; - - _SessionGetterWrapper(this._getter); - - @override - Future get(String sub, {bool? noCache, bool? allowStale}) async { - return _getter.get( - sub, - GetCachedOptions( - noCache: noCache ?? false, - allowStale: allowStale ?? false, - ), - ); - } - - @override - Future delStored(String sub, [Object? cause]) { - return _getter.delStored(sub, cause); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/constants.dart b/packages/atproto_oauth_flutter/lib/src/constants.dart deleted file mode 100644 index 43379e4..0000000 --- a/packages/atproto_oauth_flutter/lib/src/constants.dart +++ /dev/null @@ -1,2 +0,0 @@ -/// Per ATProto spec (OpenID uses RS256) -const String fallbackAlg = 'ES256'; diff --git a/packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart b/packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart deleted file mode 100644 index db09547..0000000 --- a/packages/atproto_oauth_flutter/lib/src/dpop/fetch_dpop.dart +++ /dev/null @@ -1,593 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart' hide Key; - -import '../runtime/runtime_implementation.dart'; - -/// A simple key-value store interface for storing DPoP nonces. -/// -/// This is a simplified Dart version of @atproto-labs/simple-store. -/// Implementations can use: -/// - In-memory Map (for testing) -/// - SharedPreferences (for persistence) -/// - Secure storage (for sensitive data) -abstract class SimpleStore { - /// Get a value by key. Returns null if not found. - FutureOr get(K key); - - /// Set a value for a key. - FutureOr set(K key, V value); - - /// Delete a value by key. - FutureOr del(K key); - - /// Clear all values (optional). - FutureOr clear(); -} - -/// In-memory implementation of SimpleStore for DPoP nonces. -/// -/// This is used as the default nonce store. Nonces are ephemeral and -/// don't need to be persisted across app restarts. -class InMemoryStore implements SimpleStore { - final Map _store = {}; - - @override - V? get(K key) => _store[key]; - - @override - void set(K key, V value) => _store[key] = value; - - @override - void del(K key) => _store.remove(key); - - @override - void clear() => _store.clear(); -} - -/// Options for configuring the DPoP fetch wrapper. -class DpopFetchWrapperOptions { - /// The cryptographic key used to sign DPoP proofs. - final Key key; - - /// Store for caching DPoP nonces per origin. - final SimpleStore nonces; - - /// List of algorithms supported by the server (optional). - /// If not provided, the key's first algorithm will be used. - final List? supportedAlgs; - - /// Function to compute SHA-256 hash (required for DPoP). - /// Should return base64url-encoded hash. - final Future Function(String input) sha256; - - /// Whether the target server is an authorization server (true) - /// or resource server (false). - /// - /// This affects how "use_dpop_nonce" errors are detected: - /// - Authorization servers return 400 with JSON error - /// - Resource servers return 401 with WWW-Authenticate header - /// - /// If null, both patterns will be checked. - final bool? isAuthServer; - - const DpopFetchWrapperOptions({ - required this.key, - required this.nonces, - this.supportedAlgs, - required this.sha256, - this.isAuthServer, - }); -} - -/// Creates a Dio interceptor that adds DPoP (Demonstrating Proof of Possession) -/// headers to HTTP requests. -/// -/// DPoP is a security mechanism that binds access tokens to cryptographic keys, -/// preventing token theft and replay attacks. It works by: -/// -/// 1. Creating a JWT proof signed with a private key -/// 2. Including the proof in a DPoP header -/// 3. Including the access token hash (ath) in the proof -/// 4. Handling nonce-based replay protection -/// -/// The interceptor automatically: -/// - Generates DPoP proofs for each request -/// - Caches and reuses server-provided nonces -/// - Retries requests when server requires a fresh nonce -/// - Handles both authorization and resource server error formats -/// -/// See: https://datatracker.ietf.org/doc/html/rfc9449 -/// -/// Example: -/// ```dart -/// final dio = Dio(); -/// final options = DpopFetchWrapperOptions( -/// key: myKey, -/// nonces: InMemoryStore(), -/// sha256: runtime.sha256, -/// ); -/// dio.interceptors.add(createDpopInterceptor(options)); -/// ``` -Interceptor createDpopInterceptor(DpopFetchWrapperOptions options) { - // Negotiate algorithm once at creation time - final alg = _negotiateAlg(options.key, options.supportedAlgs); - - return InterceptorsWrapper( - onRequest: (requestOptions, handler) async { - try { - // Extract authorization header for ath calculation - final authHeader = requestOptions.headers['Authorization'] as String?; - final String? ath; - if (authHeader != null && authHeader.startsWith('DPoP ')) { - ath = await options.sha256(authHeader.substring(5)); - } else { - ath = null; - } - - final uri = requestOptions.uri; - final origin = - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; - - final htm = requestOptions.method; - final htu = _buildHtu(uri.toString()); - - // Try to get cached nonce for this origin - String? initNonce; - try { - initNonce = await options.nonces.get(origin); - } catch (_) { - // Ignore nonce retrieval errors - } - - // Build and add DPoP proof - final initProof = await _buildProof( - options.key, - alg, - htm, - htu, - initNonce, - ath, - ); - requestOptions.headers['DPoP'] = initProof; - - handler.next(requestOptions); - } catch (e) { - handler.reject( - DioException( - requestOptions: requestOptions, - error: 'Failed to create DPoP proof: $e', - type: DioExceptionType.unknown, - ), - ); - } - }, - onResponse: (response, handler) async { - try { - final uri = response.requestOptions.uri; - - if (kDebugMode && uri.path.contains('/token')) { - print('🟢 DPoP interceptor onResponse triggered'); - print(' URL: ${uri.path}'); - print(' Status: ${response.statusCode}'); - } - - // Check for DPoP-Nonce header in response - final nextNonce = response.headers.value('dpop-nonce'); - - if (nextNonce != null) { - // Extract origin from request - final origin = - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; - - // Store the fresh nonce for future requests - try { - await options.nonces.set(origin, nextNonce); - if (kDebugMode && uri.path.contains('/token')) { - print(' Cached nonce: ${nextNonce.substring(0, 20)}...'); - } - } catch (_) { - // Ignore nonce storage errors - } - } else if (kDebugMode && uri.path.contains('/token')) { - print(' No nonce in response'); - } - - // Check for nonce errors in successful responses (when validateStatus: true) - // This handles the case where Dio returns 401 as a successful response - if (nextNonce != null && - await _isUseDpopNonceError(response, options.isAuthServer)) { - final isTokenEndpoint = - uri.path.contains('/token') || uri.path.endsWith('/token'); - - if (kDebugMode) { - print( - '⚠️ DPoP nonce error in response (status ${response.statusCode})', - ); - print(' Is token endpoint: $isTokenEndpoint'); - } - - if (isTokenEndpoint) { - // Don't retry token endpoint - just pass through with nonce cached - if (kDebugMode) { - print( - ' NOT retrying token endpoint (nonce cached for next attempt)', - ); - } - handler.next(response); - return; - } - - // For non-token endpoints, retry is safe - if (kDebugMode) { - print('🔄 Retrying request with fresh nonce'); - } - - try { - final authHeader = - response.requestOptions.headers['Authorization'] as String?; - final String? ath; - if (authHeader != null && authHeader.startsWith('DPoP ')) { - ath = await options.sha256(authHeader.substring(5)); - } else { - ath = null; - } - - final htm = response.requestOptions.method; - final htu = _buildHtu(uri.toString()); - - final nextProof = await _buildProof( - options.key, - alg, - htm, - htu, - nextNonce, - ath, - ); - - // Clone request options and update DPoP header - // Note: We preserve validateStatus to match original request behavior - final retryOptions = Options( - method: response.requestOptions.method, - headers: {...response.requestOptions.headers, 'DPoP': nextProof}, - validateStatus: response.requestOptions.validateStatus, - ); - - // DESIGN NOTE: We create a fresh Dio instance for retry to avoid - // re-triggering this interceptor (which would cause infinite loops). - // This means base options (timeouts, etc.) are not preserved, but - // this is acceptable for DPoP nonce retry scenarios which should be fast. - // If this becomes an issue, we could inject a Dio factory function. - final dio = Dio(); - final retryResponse = await dio.requestUri( - uri, - options: retryOptions, - data: response.requestOptions.data, - ); - - handler.resolve(retryResponse); - return; - } catch (retryError) { - if (kDebugMode) { - print('❌ Retry failed: $retryError'); - } - // If retry fails, return the original response - handler.next(response); - return; - } - } - - handler.next(response); - } catch (e) { - handler.reject( - DioException( - requestOptions: response.requestOptions, - response: response, - error: 'Failed to process DPoP nonce: $e', - type: DioExceptionType.unknown, - ), - ); - } - }, - onError: (error, handler) async { - final response = error.response; - if (response == null) { - handler.next(error); - return; - } - - final uri = response.requestOptions.uri; - - if (kDebugMode && uri.path.contains('/token')) { - print('🔴 DPoP interceptor onError triggered'); - print(' URL: ${uri.path}'); - print(' Status: ${response.statusCode}'); - print( - ' Has validateStatus: ${response.requestOptions.validateStatus != null}', - ); - } - - // Check for DPoP-Nonce in error response - final nextNonce = response.headers.value('dpop-nonce'); - - if (nextNonce != null) { - // Extract origin - final origin = - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; - - // Store the fresh nonce for future requests - try { - await options.nonces.set(origin, nextNonce); - if (kDebugMode && uri.path.contains('/token')) { - print(' Cached nonce: ${nextNonce.substring(0, 20)}...'); - } - } catch (_) { - // Ignore nonce storage errors - } - - // Check if this is a "use_dpop_nonce" error - final isNonceError = await _isUseDpopNonceError( - response, - options.isAuthServer, - ); - - if (kDebugMode && uri.path.contains('/token')) { - print(' Is use_dpop_nonce error: $isNonceError'); - } - - if (isNonceError) { - // IMPORTANT: Do NOT retry for token endpoint! - // Retrying the token exchange can consume the authorization code, - // causing "Invalid code" errors on the retry. - // - // Instead, we rely on pre-fetching the nonce before critical operations - // (like authorization code exchange) to ensure we have a valid nonce - // from the start. - // - // We still cache the nonce for future requests, but we don't retry - // this particular request. - final isTokenEndpoint = - uri.path.contains('/token') || uri.path.endsWith('/token'); - - if (kDebugMode && isTokenEndpoint) { - print('⚠️ DPoP nonce error on token endpoint - NOT retrying'); - print(' Cached fresh nonce for future requests'); - } - - if (isTokenEndpoint) { - // Don't retry - just pass through the error with the nonce cached - handler.next(error); - return; - } - - // For non-token endpoints, retry is safe - if (kDebugMode) { - print('🔄 DPoP retry for non-token endpoint: ${uri.path}'); - } - - try { - final authHeader = - response.requestOptions.headers['Authorization'] as String?; - final String? ath; - if (authHeader != null && authHeader.startsWith('DPoP ')) { - ath = await options.sha256(authHeader.substring(5)); - } else { - ath = null; - } - - final htm = response.requestOptions.method; - final htu = _buildHtu(uri.toString()); - - final nextProof = await _buildProof( - options.key, - alg, - htm, - htu, - nextNonce, - ath, - ); - - // Clone request options and update DPoP header - // Note: We preserve validateStatus to match original request behavior - final retryOptions = Options( - method: response.requestOptions.method, - headers: {...response.requestOptions.headers, 'DPoP': nextProof}, - validateStatus: response.requestOptions.validateStatus, - ); - - // DESIGN NOTE: We create a fresh Dio instance for retry to avoid - // re-triggering this interceptor (which would cause infinite loops). - // This means base options (timeouts, etc.) are not preserved, but - // this is acceptable for DPoP nonce retry scenarios which should be fast. - // If this becomes an issue, we could inject a Dio factory function. - final dio = Dio(); - final retryResponse = await dio.requestUri( - uri, - options: retryOptions, - data: response.requestOptions.data, - ); - - handler.resolve(retryResponse); - return; - } catch (retryError) { - // If retry fails, return the retry error - if (retryError is DioException) { - handler.next(retryError); - } else { - handler.next( - DioException( - requestOptions: response.requestOptions, - error: retryError, - type: DioExceptionType.unknown, - ), - ); - } - return; - } - } - } - - if (kDebugMode && uri.path.contains('/token')) { - print('🔴 DPoP interceptor passing error through (no retry)'); - } - - handler.next(error); - }, - ); -} - -/// Strips query string and fragment from URL. -/// -/// Per RFC 9449, the htu (HTTP URI) claim must not include query or fragment. -/// -/// See: https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6 -String _buildHtu(String url) { - final fragmentIndex = url.indexOf('#'); - final queryIndex = url.indexOf('?'); - - final int end; - if (fragmentIndex == -1) { - end = queryIndex; - } else if (queryIndex == -1) { - end = fragmentIndex; - } else { - end = fragmentIndex < queryIndex ? fragmentIndex : queryIndex; - } - - return end == -1 ? url : url.substring(0, end); -} - -/// Builds a DPoP proof JWT. -/// -/// The proof is a JWT with: -/// - Header: typ="dpop+jwt", alg, jwk (public key) -/// - Payload: iat, jti, htm, htu, nonce?, ath? -/// -/// See: https://datatracker.ietf.org/doc/html/rfc9449#section-4.2 -Future _buildProof( - Key key, - String alg, - String htm, - String htu, - String? nonce, - String? ath, -) async { - final jwk = key.bareJwk; - if (jwk == null) { - throw StateError('Only asymmetric keys can be used for DPoP proofs'); - } - - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - // Create header - final header = {'alg': alg, 'typ': 'dpop+jwt', 'jwk': jwk}; - - // Create payload - final payload = { - 'iat': now, - // Random jti to prevent replay attacks - // Any collision will cause server rejection, which is acceptable - 'jti': DateTime.now().microsecondsSinceEpoch.toString(), - 'htm': htm, - 'htu': htu, - if (nonce != null) 'nonce': nonce, - if (ath != null) 'ath': ath, - }; - - if (kDebugMode && htu.contains('/token')) { - print('🔐 Creating DPoP proof for token request:'); - print(' htm: $htm'); - print(' htu: $htu'); - print(' nonce: ${nonce ?? "none"}'); - print(' ath: ${ath ?? "none"}'); - print(' jwk keys: ${jwk?.keys.toList()}'); - } - - final jwt = await key.createJwt(header, payload); - - if (kDebugMode && htu.contains('/token')) { - print(' ✅ DPoP proof created: ${jwt.substring(0, 50)}...'); - } - - return jwt; -} - -/// Checks if a response indicates a "use_dpop_nonce" error. -/// -/// There are multiple error formats depending on server implementation: -/// -/// 1. Resource Server (RFC 6750): 401 with WWW-Authenticate header -/// WWW-Authenticate: DPoP error="use_dpop_nonce" -/// -/// 2. Authorization Server: 400 with JSON body -/// {"error": "use_dpop_nonce"} -/// -/// 3. Resource Server (JSON variant): 401 with JSON body -/// {"error": "use_dpop_nonce"} -/// -/// See: -/// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no -/// - https://datatracker.ietf.org/doc/html/rfc9449#name-authorization-server-provid -Future _isUseDpopNonceError(Response response, bool? isAuthServer) async { - // Check WWW-Authenticate header format (401 + header) - if (response.statusCode == 401) { - final wwwAuth = response.headers.value('www-authenticate'); - if (wwwAuth != null && wwwAuth.startsWith('DPoP')) { - if (wwwAuth.contains('error="use_dpop_nonce"')) { - return true; - } - } - } - - // Check JSON body format (400 or 401 + JSON) - // Some servers use 401 + JSON instead of WWW-Authenticate header - if (response.statusCode == 400 || response.statusCode == 401) { - try { - final data = response.data; - if (data is Map) { - return data['error'] == 'use_dpop_nonce'; - } else if (data is String) { - // Try to parse as JSON - final json = jsonDecode(data); - if (json is Map) { - return json['error'] == 'use_dpop_nonce'; - } - } - } catch (_) { - // Invalid JSON or response too large, not a use_dpop_nonce error - return false; - } - } - - return false; -} - -/// Negotiates the algorithm to use for DPoP proofs. -/// -/// If supportedAlgs is provided, uses the first algorithm that the key supports. -/// Otherwise, uses the key's first algorithm. -/// -/// Throws if the key doesn't support any of the server's algorithms. -String _negotiateAlg(Key key, List? supportedAlgs) { - if (supportedAlgs != null) { - // Use order of supportedAlgs as preference - for (final alg in supportedAlgs) { - if (key.algorithms.contains(alg)) { - return alg; - } - } - throw StateError( - 'Key does not match any algorithm supported by the server. ' - 'Key supports: ${key.algorithms}, server supports: $supportedAlgs', - ); - } - - // No server preference, use key's first algorithm - if (key.algorithms.isEmpty) { - throw StateError('Key does not support any algorithms'); - } - - return key.algorithms.first; -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart deleted file mode 100644 index 588f1e1..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/auth_method_unsatisfiable_error.dart +++ /dev/null @@ -1,14 +0,0 @@ -/// Exception thrown when the requested authentication method cannot be satisfied. -class AuthMethodUnsatisfiableError implements Exception { - final String? message; - - AuthMethodUnsatisfiableError([this.message]); - - @override - String toString() { - if (message != null) { - return 'AuthMethodUnsatisfiableError: $message'; - } - return 'AuthMethodUnsatisfiableError'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/errors.dart b/packages/atproto_oauth_flutter/lib/src/errors/errors.dart deleted file mode 100644 index 93f3196..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/errors.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// OAuth error types for the atproto_oauth_flutter package. -library; - -export 'auth_method_unsatisfiable_error.dart'; -export 'oauth_callback_error.dart'; -export 'oauth_resolver_error.dart'; -export 'oauth_response_error.dart'; -export 'token_invalid_error.dart'; -export 'token_refresh_error.dart'; -export 'token_revoked_error.dart'; diff --git a/packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart deleted file mode 100644 index 991db80..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/oauth_callback_error.dart +++ /dev/null @@ -1,51 +0,0 @@ -/// Error class for OAuth callback failures. -/// -/// This error is thrown when an OAuth authorization callback contains -/// error parameters or fails to parse correctly. -/// -/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1 -class OAuthCallbackError implements Exception { - /// The URL parameters from the callback - final Map params; - - /// The state parameter from the callback (if present) - final String? state; - - /// The error message - final String message; - - /// Optional underlying cause - final Object? cause; - - /// Creates an OAuth callback error from parameters. - /// - /// The [params] should contain the parsed query parameters from the callback URL. - /// The [message] defaults to the error_description from params, or a generic message. - OAuthCallbackError(this.params, {String? message, this.state, this.cause}) - : message = - message ?? params['error_description'] ?? 'OAuth callback error'; - - /// Creates an OAuthCallbackError from another error. - /// - /// If [err] is already an OAuthCallbackError, returns it unchanged. - /// Otherwise, wraps the error with the given params and state. - static OAuthCallbackError from( - Object err, - Map params, [ - String? state, - ]) { - if (err is OAuthCallbackError) return err; - final message = err is Exception ? err.toString() : null; - return OAuthCallbackError( - params, - message: message, - state: state, - cause: err, - ); - } - - @override - String toString() { - return 'OAuthCallbackError: $message'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart deleted file mode 100644 index dcc056d..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/oauth_resolver_error.dart +++ /dev/null @@ -1,47 +0,0 @@ -/// Error class for OAuth resolution failures. -/// -/// This error is thrown when OAuth metadata resolution fails, including: -/// - Authorization server metadata discovery -/// - Protected resource metadata discovery -/// - Identity resolution (handle → DID → PDS) -class OAuthResolverError implements Exception { - /// The error message - final String message; - - /// Optional underlying cause - final Object? cause; - - /// Creates an OAuth resolver error. - OAuthResolverError(this.message, {this.cause}); - - /// Creates an OAuthResolverError from another error. - /// - /// If [cause] is already an OAuthResolverError, returns it unchanged. - /// Otherwise, wraps the error with an appropriate message. - /// - /// For validation errors, extracts the first error details. - static OAuthResolverError from(Object cause, [String? message]) { - if (cause is OAuthResolverError) return cause; - - String? validationReason; - - // Check if it's a validation error (would be FormatException or similar in Dart) - if (cause is FormatException) { - validationReason = cause.message; - } - - final fullMessage = - (message ?? 'Unable to resolve OAuth metadata') + - (validationReason != null ? ' ($validationReason)' : ''); - - return OAuthResolverError(fullMessage, cause: cause); - } - - @override - String toString() { - if (cause != null) { - return 'OAuthResolverError: $message (caused by: $cause)'; - } - return 'OAuthResolverError: $message'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart deleted file mode 100644 index c2d592f..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/oauth_response_error.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../util.dart'; - -/// Error class for OAuth protocol errors returned by the server. -/// -/// OAuth servers return errors as JSON with standard fields: -/// - error: The error code (required) -/// - error_description: Human-readable description (optional) -/// - error_uri: URI with more information (optional) -/// -/// See: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 -class OAuthResponseError implements Exception { - /// The HTTP response that contained the error - final Response response; - - /// The parsed response body (usually JSON) - final dynamic payload; - - /// The OAuth error code (e.g., "invalid_request", "invalid_grant") - final String? error; - - /// The human-readable error description - final String? errorDescription; - - /// Creates an OAuth response error from a Dio response. - /// - /// Automatically extracts the error and error_description fields - /// from the response payload if it's a JSON object. - OAuthResponseError(this.response, this.payload) - : error = _extractError(payload), - errorDescription = _extractErrorDescription(payload); - - /// HTTP status code from the response - int get status => response.statusCode ?? 0; - - /// HTTP headers from the response - Headers get headers => response.headers; - - /// Extracts the error code from the payload - static String? _extractError(dynamic payload) { - if (payload is Map) { - return ifString(payload['error']); - } - return null; - } - - /// Extracts the error description from the payload - static String? _extractErrorDescription(dynamic payload) { - if (payload is Map) { - return ifString(payload['error_description']); - } - return null; - } - - @override - String toString() { - final errorCode = error ?? 'unknown'; - final description = errorDescription != null ? ': $errorDescription' : ''; - return 'OAuth "$errorCode" error$description'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart deleted file mode 100644 index e37498f..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/token_invalid_error.dart +++ /dev/null @@ -1,22 +0,0 @@ -/// Exception thrown when a token is invalid. -class TokenInvalidError implements Exception { - /// Subject identifier for the invalid token - final String sub; - - /// Error message - final String message; - - /// Optional cause of the error - final Object? cause; - - TokenInvalidError(this.sub, {String? message, this.cause}) - : message = message ?? 'The session for "$sub" is invalid'; - - @override - String toString() { - if (cause != null) { - return 'TokenInvalidError: $message (caused by: $cause)'; - } - return 'TokenInvalidError: $message'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart deleted file mode 100644 index bc72a06..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/token_refresh_error.dart +++ /dev/null @@ -1,21 +0,0 @@ -/// Exception thrown when a token refresh operation fails. -class TokenRefreshError implements Exception { - /// Subject identifier for the token that failed to refresh - final String sub; - - /// Error message - final String message; - - /// Optional cause of the error - final Object? cause; - - TokenRefreshError(this.sub, this.message, {this.cause}); - - @override - String toString() { - if (cause != null) { - return 'TokenRefreshError: $message (caused by: $cause)'; - } - return 'TokenRefreshError: $message'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart b/packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart deleted file mode 100644 index 2cdaa28..0000000 --- a/packages/atproto_oauth_flutter/lib/src/errors/token_revoked_error.dart +++ /dev/null @@ -1,22 +0,0 @@ -/// Exception thrown when a token has been successfully revoked. -class TokenRevokedError implements Exception { - /// Subject identifier for the revoked token - final String sub; - - /// Error message - final String message; - - /// Optional cause of the error - final Object? cause; - - TokenRevokedError(this.sub, {String? message, this.cause}) - : message = message ?? 'The session for "$sub" was successfully revoked'; - - @override - String toString() { - if (cause != null) { - return 'TokenRevokedError: $message (caused by: $cause)'; - } - return 'TokenRevokedError: $message'; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/README.md b/packages/atproto_oauth_flutter/lib/src/identity/README.md deleted file mode 100644 index a7032a0..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/README.md +++ /dev/null @@ -1,263 +0,0 @@ -# atProto Identity Resolution Layer - -## Overview - -This module implements the **critical identity resolution functionality** for atProto decentralization. It resolves atProto handles and DIDs to discover where user data is actually stored (their Personal Data Server). - -## Why This Matters - -**This is the most important code for decentralization in atProto.** - -Without this layer: -- Apps hardcode `bsky.social` as the only server -- Users can't use custom domains -- Self-hosting is impossible -- atProto becomes centralized - -With this layer: -- ✅ Users host data on any PDS they choose -- ✅ Custom domain handles work (e.g., `alice.example.com`) -- ✅ Identity is portable (change PDS without losing DID) -- ✅ True decentralization is achieved - -## Architecture - -### Resolution Flow - -``` -Handle/DID Input - ↓ -Is it a DID? ──Yes──→ DID Resolution - ↓ ↓ - No DID Document - ↓ ↓ -Handle Resolution Extract Handle - ↓ ↓ - DID Validate Handle ←→ DID - ↓ ↓ -DID Resolution Return IdentityInfo - ↓ -DID Document - ↓ -Validate Handle in Doc - ↓ -Extract PDS URL - ↓ -Return IdentityInfo -``` - -### Key Components - -#### 1. IdentityResolver -Main interface for resolving identities. Use `AtprotoIdentityResolver` for the standard implementation. - -```dart -final resolver = AtprotoIdentityResolver.withDefaults( - handleResolverUrl: 'https://bsky.social', -); - -// Resolve to PDS URL (most common use case) -final pdsUrl = await resolver.resolveToPds('alice.example.com'); - -// Get full identity info -final info = await resolver.resolve('alice.example.com'); -print('DID: ${info.did}'); -print('Handle: ${info.handle}'); -print('PDS: ${info.pdsUrl}'); -``` - -#### 2. HandleResolver -Resolves atProto handles (e.g., `alice.bsky.social`) to DIDs using XRPC. - -**Resolution Methods:** -- XRPC: Uses `com.atproto.identity.resolveHandle` endpoint -- DNS TXT record: Checks `_atproto.{handle}` (not implemented yet) -- .well-known: Checks `https://{handle}/.well-known/atproto-did` (not implemented yet) - -Current implementation uses XRPC, which works for all handles. - -#### 3. DidResolver -Resolves DIDs to DID documents. - -**Supported Methods:** -- `did:plc`: Queries PLC directory (https://plc.directory) -- `did:web`: Fetches from HTTPS URLs - -#### 4. DidDocument -Represents a W3C DID document with atProto-specific helpers: -- `extractPdsUrl()`: Gets the PDS endpoint -- `extractNormalizedHandle()`: Gets the validated handle - -### Bi-directional Resolution - -For security, we enforce **bi-directional resolution**: - -1. Handle → DID resolution must succeed -2. DID document must contain the original handle -3. Both directions must agree - -This prevents: -- Handle hijacking -- DID spoofing -- MITM attacks - -### Caching - -Built-in caching with configurable TTLs: -- **Handles**: 1 hour default (handles can change) -- **DIDs**: 24 hours default (DID docs are more stable) - -Caching is automatic but can be bypassed with `noCache: true`. - -## File Structure - -``` -identity/ -├── constants.dart # atProto constants -├── did_document.dart # DID document representation -├── did_helpers.dart # DID validation utilities -├── did_resolver.dart # DID → DID document resolution -├── handle_helpers.dart # Handle validation utilities -├── handle_resolver.dart # Handle → DID resolution -├── identity_resolver.dart # Main resolver (orchestrates everything) -├── identity_resolver_error.dart # Error types -├── identity.dart # Public exports -└── README.md # This file -``` - -## Usage Examples - -### Basic Resolution - -```dart -import 'package:atproto_oauth_flutter/src/identity/identity.dart'; - -final resolver = AtprotoIdentityResolver.withDefaults( - handleResolverUrl: 'https://bsky.social', -); - -// Simple PDS lookup -final pdsUrl = await resolver.resolveToPds('alice.bsky.social'); -print('PDS: $pdsUrl'); -``` - -### Custom Configuration - -```dart -// With custom caching and PLC directory -final resolver = AtprotoIdentityResolver.withDefaults( - handleResolverUrl: 'https://bsky.social', - plcDirectoryUrl: 'https://plc.directory/', - didCache: InMemoryDidCache(ttl: Duration(hours: 12)), - handleCache: InMemoryHandleCache(ttl: Duration(minutes: 30)), -); -``` - -### Manual Component Construction - -```dart -// Build your own resolver with custom components -final dio = Dio(); - -final didResolver = CachedDidResolver( - AtprotoDidResolver(dio: dio), -); - -final handleResolver = CachedHandleResolver( - XrpcHandleResolver('https://bsky.social', dio: dio), -); - -final resolver = AtprotoIdentityResolver( - didResolver: didResolver, - handleResolver: handleResolver, -); -``` - -### Error Handling - -```dart -try { - final info = await resolver.resolve('invalid-handle'); -} on InvalidHandleError catch (e) { - print('Invalid handle format: $e'); -} on HandleResolverError catch (e) { - print('Handle resolution failed: $e'); -} on DidResolverError catch (e) { - print('DID resolution failed: $e'); -} on IdentityResolverError catch (e) { - print('Identity resolution failed: $e'); -} -``` - -## Implementation Notes - -### Ported from TypeScript - -This implementation is a 1:1 port from the official atProto TypeScript packages: -- `@atproto-labs/identity-resolver` -- `@atproto-labs/did-resolver` -- `@atproto-labs/handle-resolver` - -Source: `/home/bretton/Code/atproto/packages/oauth/oauth-client/src/identity-resolver.ts` - -### Differences from TypeScript - -1. **No DNS Resolution**: Dart doesn't have built-in DNS TXT lookups. We use XRPC only. -2. **Simplified Caching**: In-memory only (TypeScript has more cache backends). -3. **Dio instead of Fetch**: Using Dio HTTP client instead of global fetch. -4. **Explicit Types**: Dart's type system is more explicit than TypeScript's. - -### Future Improvements - -- [ ] Add DNS-over-HTTPS for handle resolution -- [ ] Implement .well-known handle resolution -- [ ] Add persistent cache backends (SQLite, Hive) -- [ ] Support custom DID methods beyond plc/web -- [ ] Add metrics and observability -- [ ] Implement resolver timeouts and retries - -## Testing - -Test the implementation with real handles: - -```dart -// Test custom PDS -final pds1 = await resolver.resolveToPds('bretton.dev'); -assert(pds1.contains('pds.bretton.dev')); - -// Test Bluesky user -final pds2 = await resolver.resolveToPds('pfrazee.com'); -print('Paul Frazee PDS: $pds2'); - -// Test from DID -final info = await resolver.resolveFromDid('did:plc:ragtjsm2j2vknwkz3zp4oxrd'); -assert(info.handle == 'pfrazee.com'); -``` - -## Security Considerations - -1. **Bi-directional Validation**: Always enforced to prevent spoofing -2. **HTTPS Only**: All HTTP requests use HTTPS (except localhost for testing) -3. **No Redirects**: HTTP redirects are rejected to prevent attacks -4. **Input Validation**: All handles and DIDs are validated before use -5. **Cache Poisoning**: TTLs prevent stale data, noCache option available - -## Performance - -Typical resolution times (with cold cache): -- Handle → PDS: ~200-500ms (1 handle lookup + 1 DID fetch) -- DID → PDS: ~100-200ms (1 DID fetch only) -- Cached resolution: <1ms (in-memory lookup) - -For production apps: -- Enable caching (default) -- Use connection pooling (Dio does this) -- Consider warming cache for known users -- Monitor resolver errors and timeouts - -## References - -- [atProto DID Spec](https://atproto.com/specs/did) -- [atProto Handle Spec](https://atproto.com/specs/handle) -- [W3C DID Core](https://www.w3.org/TR/did-core/) -- [PLC Directory](https://plc.directory/) diff --git a/packages/atproto_oauth_flutter/lib/src/identity/constants.dart b/packages/atproto_oauth_flutter/lib/src/identity/constants.dart deleted file mode 100644 index 85b7d9a..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/constants.dart +++ /dev/null @@ -1,29 +0,0 @@ -/// Constants used in atProto identity resolution. -library; - -/// Placeholder handle used when handle is invalid or doesn't match DID. -const String handleInvalid = 'handle.invalid'; - -/// DID prefix for all decentralized identifiers. -const String didPrefix = 'did:'; - -/// DID PLC (Placeholder) prefix. -const String didPlcPrefix = 'did:plc:'; - -/// DID Web prefix. -const String didWebPrefix = 'did:web:'; - -/// Length of a complete did:plc identifier (including prefix). -const int didPlcLength = 32; - -/// Default PLC directory URL for resolving did:plc identifiers. -const String defaultPlcDirectoryUrl = 'https://plc.directory/'; - -/// Maximum length for a DID (per spec). -const int maxDidLength = 2048; - -/// atProto service type in DID documents. -const String atprotoServiceType = 'AtprotoPersonalDataServer'; - -/// atProto service ID prefix in DID documents. -const String atprotoServiceId = '#atproto_pds'; diff --git a/packages/atproto_oauth_flutter/lib/src/identity/did_document.dart b/packages/atproto_oauth_flutter/lib/src/identity/did_document.dart deleted file mode 100644 index 35a304b..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/did_document.dart +++ /dev/null @@ -1,156 +0,0 @@ -import 'constants.dart'; -import 'handle_helpers.dart'; - -/// Represents a DID document as defined by W3C DID Core spec. -/// -/// This is a simplified version focused on atProto needs. -/// See: https://www.w3.org/TR/did-core/ -class DidDocument { - /// The DID subject (the DID itself) - final String id; - - /// Alternative identifiers (used for atProto handles: at://handle) - final List? alsoKnownAs; - - /// Service endpoints (used to find PDS URL) - final List? service; - - /// Verification methods for authentication - final List? verificationMethod; - - /// Authentication methods - final List? authentication; - - /// Optional controller DIDs - final dynamic controller; // Can be String or List - - /// The @context field - final dynamic context; - - const DidDocument({ - required this.id, - this.alsoKnownAs, - this.service, - this.verificationMethod, - this.authentication, - this.controller, - this.context, - }); - - /// Parses a DID document from JSON. - factory DidDocument.fromJson(Map json) { - return DidDocument( - id: json['id'] as String, - alsoKnownAs: - (json['alsoKnownAs'] as List?) - ?.map((e) => e as String) - .toList(), - service: - (json['service'] as List?) - ?.map((e) => DidService.fromJson(e as Map)) - .toList(), - verificationMethod: json['verificationMethod'] as List?, - authentication: json['authentication'] as List?, - controller: json['controller'], - context: json['@context'], - ); - } - - /// Converts the DID document to JSON. - Map toJson() { - final map = {'id': id}; - - if (context != null) map['@context'] = context; - if (alsoKnownAs != null) map['alsoKnownAs'] = alsoKnownAs; - if (service != null) { - map['service'] = service!.map((s) => s.toJson()).toList(); - } - if (verificationMethod != null) { - map['verificationMethod'] = verificationMethod; - } - if (authentication != null) map['authentication'] = authentication; - if (controller != null) map['controller'] = controller; - - return map; - } - - /// Extracts the atProto PDS URL from the DID document. - /// - /// Returns null if no PDS service is found. - String? extractPdsUrl() { - if (service == null) return null; - - for (final s in service!) { - // Check for standard atproto_pds service - if (s.id == atprotoServiceId && s.type == atprotoServiceType) { - if (s.serviceEndpoint is String) { - return s.serviceEndpoint as String; - } - } - - // Also check if type matches (some implementations may vary on id) - if (s.type == atprotoServiceType && s.serviceEndpoint is String) { - return s.serviceEndpoint as String; - } - } - - return null; - } - - /// Extracts the raw atProto handle from the DID document. - /// - /// Returns null if no handle is found in alsoKnownAs. - String? extractAtprotoHandle() { - if (alsoKnownAs == null) return null; - - for (final aka in alsoKnownAs!) { - if (aka.startsWith('at://')) { - // Strip off "at://" prefix - return aka.substring(5); - } - } - - return null; - } - - /// Extracts a validated, normalized atProto handle from the DID document. - /// - /// Returns null if no valid handle is found. - String? extractNormalizedHandle() { - final handle = extractAtprotoHandle(); - if (handle == null) return null; - return asNormalizedHandle(handle); - } -} - -/// Represents a service endpoint in a DID document. -class DidService { - /// Service ID (e.g., "#atproto_pds") - final String id; - - /// Service type (e.g., "AtprotoPersonalDataServer") - final String type; - - /// Service endpoint URL - final dynamic serviceEndpoint; // Can be String, Map, or List - - const DidService({ - required this.id, - required this.type, - required this.serviceEndpoint, - }); - - /// Parses a service from JSON. - factory DidService.fromJson(Map json) { - return DidService( - id: json['id'] as String, - type: json['type'] as String, - serviceEndpoint: json['serviceEndpoint'], - ); - } - - /// Converts the service to JSON. - Map toJson() { - return {'id': id, 'type': type, 'serviceEndpoint': serviceEndpoint}; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart b/packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart deleted file mode 100644 index 14bd489..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/did_helpers.dart +++ /dev/null @@ -1,251 +0,0 @@ -import 'constants.dart'; -import 'identity_resolver_error.dart'; - -/// Checks if a string is a valid DID. -/// -/// A valid DID follows the format: did:method:method-specific-id -/// where method is lowercase alphanumeric and method-specific-id -/// contains only allowed characters. -bool isDid(String input) { - try { - assertDid(input); - return true; - } catch (e) { - if (e is IdentityResolverError) { - return false; - } - rethrow; - } -} - -/// Asserts that a string is a valid DID, throwing if not. -void assertDid(String input) { - if (input.length > maxDidLength) { - throw InvalidDidError(input, 'DID is too long ($maxDidLength chars max)'); - } - - if (!input.startsWith(didPrefix)) { - throw InvalidDidError(input, 'DID requires "$didPrefix" prefix'); - } - - final methodEndIndex = input.indexOf(':', didPrefix.length); - if (methodEndIndex == -1) { - throw InvalidDidError(input, 'Missing colon after method name'); - } - - _assertDidMethod(input, didPrefix.length, methodEndIndex); - _assertDidMsid(input, methodEndIndex + 1, input.length); -} - -/// Validates DID method name (lowercase alphanumeric). -void _assertDidMethod(String input, int start, int end) { - if (end == start) { - throw InvalidDidError(input, 'Empty method name'); - } - - for (int i = start; i < end; i++) { - final c = input.codeUnitAt(i); - if (!((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39))) { - // Not a-z or 0-9 - throw InvalidDidError( - input, - 'Invalid character at position $i in DID method name', - ); - } - } -} - -/// Validates DID method-specific identifier. -void _assertDidMsid(String input, int start, int end) { - if (end == start) { - throw InvalidDidError(input, 'DID method-specific id must not be empty'); - } - - for (int i = start; i < end; i++) { - final c = input.codeUnitAt(i); - - // Check for frequent chars first (a-z, A-Z, 0-9, ., -, _) - if ((c >= 0x61 && c <= 0x7a) || // a-z - (c >= 0x41 && c <= 0x5a) || // A-Z - (c >= 0x30 && c <= 0x39) || // 0-9 - c == 0x2e || // . - c == 0x2d || // - - c == 0x5f) { - // _ - continue; - } - - // ":" - if (c == 0x3a) { - if (i == end - 1) { - throw InvalidDidError(input, 'DID cannot end with ":"'); - } - continue; - } - - // pct-encoded: %HEXDIG HEXDIG - if (c == 0x25) { - // % - if (i + 2 >= end) { - throw InvalidDidError( - input, - 'Incomplete pct-encoded character at position $i', - ); - } - - i++; - final c1 = input.codeUnitAt(i); - if (!((c1 >= 0x30 && c1 <= 0x39) || (c1 >= 0x41 && c1 <= 0x46))) { - // Not 0-9 or A-F - throw InvalidDidError( - input, - 'Invalid pct-encoded character at position $i', - ); - } - - i++; - final c2 = input.codeUnitAt(i); - if (!((c2 >= 0x30 && c2 <= 0x39) || (c2 >= 0x41 && c2 <= 0x46))) { - // Not 0-9 or A-F - throw InvalidDidError( - input, - 'Invalid pct-encoded character at position $i', - ); - } - - continue; - } - - throw InvalidDidError(input, 'Disallowed character in DID at position $i'); - } -} - -/// Extracts the method name from a DID. -/// -/// Example: extractDidMethod('did:plc:abc123') returns 'plc' -String extractDidMethod(String did) { - final methodEndIndex = did.indexOf(':', didPrefix.length); - return did.substring(didPrefix.length, methodEndIndex); -} - -/// Checks if a string is a valid did:plc identifier. -bool isDidPlc(String input) { - if (input.length != didPlcLength) return false; - if (!input.startsWith(didPlcPrefix)) return false; - - // Check that all characters after prefix are base32 [a-z2-7] - for (int i = didPlcPrefix.length; i < didPlcLength; i++) { - if (!_isBase32Char(input.codeUnitAt(i))) return false; - } - - return true; -} - -/// Checks if a string is a valid did:web identifier. -bool isDidWeb(String input) { - if (!input.startsWith(didWebPrefix)) return false; - if (input.length <= didWebPrefix.length) return false; - - // Check if next char after prefix is ":" - if (input.codeUnitAt(didWebPrefix.length) == 0x3a) return false; - - try { - _assertDidMsid(input, didWebPrefix.length, input.length); - return true; - } catch (e) { - return false; - } -} - -/// Checks if a DID uses an atProto-blessed method (plc or web). -bool isAtprotoDid(String input) { - return isDidPlc(input) || isDidWeb(input); -} - -/// Asserts that a string is a valid atProto DID (did:plc or did:web). -/// -/// Throws [InvalidDidError] if the DID is not a valid atProto DID. -void assertAtprotoDid(String input) { - if (!isAtprotoDid(input)) { - throw InvalidDidError( - input, - 'DID must use atProto-blessed method (did:plc or did:web)', - ); - } -} - -/// Asserts that a string is a valid did:plc identifier. -void assertDidPlc(String input) { - if (!input.startsWith(didPlcPrefix)) { - throw InvalidDidError(input, 'Invalid did:plc prefix'); - } - - if (input.length != didPlcLength) { - throw InvalidDidError( - input, - 'did:plc must be $didPlcLength characters long', - ); - } - - for (int i = didPlcPrefix.length; i < didPlcLength; i++) { - if (!_isBase32Char(input.codeUnitAt(i))) { - throw InvalidDidError(input, 'Invalid character at position $i'); - } - } -} - -/// Asserts that a string is a valid did:web identifier. -void assertDidWeb(String input) { - if (!input.startsWith(didWebPrefix)) { - throw InvalidDidError(input, 'Invalid did:web prefix'); - } - - if (input.codeUnitAt(didWebPrefix.length) == 0x3a) { - throw InvalidDidError(input, 'did:web MSID must not start with a colon'); - } - - _assertDidMsid(input, didWebPrefix.length, input.length); -} - -/// Checks if a character code is a base32 character [a-z2-7]. -bool _isBase32Char(int c) => - (c >= 0x61 && c <= 0x7a) || (c >= 0x32 && c <= 0x37); - -/// Converts a did:web to an HTTPS URL. -/// -/// Example: -/// - did:web:example.com -> https://example.com -/// - did:web:example.com:user:alice -> https://example.com/user/alice -/// - did:web:localhost%3A3000 -> http://localhost:3000 -Uri didWebToUrl(String did) { - assertDidWeb(did); - - final hostIdx = didWebPrefix.length; - final pathIdx = did.indexOf(':', hostIdx); - - final hostEnc = - pathIdx == -1 ? did.substring(hostIdx) : did.substring(hostIdx, pathIdx); - final host = hostEnc.replaceAll('%3A', ':'); - final path = pathIdx == -1 ? '' : did.substring(pathIdx).replaceAll(':', '/'); - - // Use http for localhost, https for everything else - final proto = - host.startsWith('localhost') && - (host.length == 9 || host.codeUnitAt(9) == 0x3a) // ':' - ? 'http' - : 'https'; - - return Uri.parse('$proto://$host$path'); -} - -/// Converts an HTTPS URL to a did:web identifier. -/// -/// Example: -/// - https://example.com -> did:web:example.com -/// - https://example.com/user/alice -> did:web:example.com:user:alice -String urlToDidWeb(Uri url) { - final port = url.hasPort ? '%3A${url.port}' : ''; - final path = url.path == '/' ? '' : url.path.replaceAll('/', ':'); - - return '$didWebPrefix${url.host}$port$path'; -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart b/packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart deleted file mode 100644 index 5cb3a17..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/did_resolver.dart +++ /dev/null @@ -1,257 +0,0 @@ -import 'package:dio/dio.dart'; - -import 'constants.dart'; -import 'did_document.dart'; -import 'did_helpers.dart'; -import 'identity_resolver_error.dart'; - -/// Options for DID resolution. -class ResolveDidOptions { - /// Whether to bypass cache - final bool noCache; - - /// Cancellation token for the request - final CancelToken? cancelToken; - - const ResolveDidOptions({this.noCache = false, this.cancelToken}); -} - -/// Interface for resolving DIDs to DID documents. -abstract class DidResolver { - /// Resolves a DID to its DID document. - /// - /// Throws [DidResolverError] if resolution fails. - Future resolve(String did, [ResolveDidOptions? options]); -} - -/// DID resolver that supports both did:plc and did:web methods. -class AtprotoDidResolver implements DidResolver { - final DidPlcMethod _plcMethod; - final DidWebMethod _webMethod; - - AtprotoDidResolver({String? plcDirectoryUrl, Dio? dio}) - : _plcMethod = DidPlcMethod(plcDirectoryUrl: plcDirectoryUrl, dio: dio), - _webMethod = DidWebMethod(dio: dio); - - @override - Future resolve(String did, [ResolveDidOptions? options]) async { - if (isDidPlc(did)) { - return _plcMethod.resolve(did, options); - } else if (isDidWeb(did)) { - return _webMethod.resolve(did, options); - } else { - throw DidResolverError( - 'Unsupported DID method: ${extractDidMethod(did)}', - ); - } - } -} - -/// Resolver for did:plc identifiers using the PLC directory. -class DidPlcMethod { - final Uri plcDirectoryUrl; - final Dio dio; - - DidPlcMethod({String? plcDirectoryUrl, Dio? dio}) - : plcDirectoryUrl = Uri.parse(plcDirectoryUrl ?? defaultPlcDirectoryUrl), - dio = dio ?? Dio(); - - Future resolve(String did, [ResolveDidOptions? options]) async { - assertDidPlc(did); - - final url = plcDirectoryUrl.resolve('/${Uri.encodeComponent(did)}'); - - try { - final response = await dio.getUri( - url, - options: Options( - headers: { - 'Accept': 'application/did+ld+json,application/json', - if (options?.noCache ?? false) 'Cache-Control': 'no-cache', - }, - followRedirects: false, - validateStatus: (status) => status == 200, - ), - cancelToken: options?.cancelToken, - ); - - if (response.data is! Map) { - throw DidResolverError( - 'Invalid response format from PLC directory for $did', - ); - } - - return DidDocument.fromJson(response.data as Map); - } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) { - throw DidResolverError('DID resolution was cancelled'); - } - - if (e.response?.statusCode == 404) { - throw DidResolverError('DID not found: $did'); - } - - throw DidResolverError( - 'Failed to resolve DID from PLC directory: ${e.message}', - e, - ); - } catch (e) { - if (e is DidResolverError) rethrow; - - throw DidResolverError('Unexpected error resolving DID: $e', e); - } - } -} - -/// Resolver for did:web identifiers using HTTPS. -class DidWebMethod { - final Dio dio; - - DidWebMethod({Dio? dio}) : dio = dio ?? Dio(); - - Future resolve(String did, [ResolveDidOptions? options]) async { - assertDidWeb(did); - - final baseUrl = didWebToUrl(did); - - // Try /.well-known/did.json first, then /did.json - final urls = [ - baseUrl.resolve('/.well-known/did.json'), - baseUrl.resolve('/did.json'), - ]; - - DioException? lastError; - - for (final url in urls) { - try { - final response = await dio.getUri( - url, - options: Options( - headers: { - 'Accept': 'application/did+ld+json,application/json', - if (options?.noCache ?? false) 'Cache-Control': 'no-cache', - }, - followRedirects: false, - validateStatus: (status) => status == 200, - ), - cancelToken: options?.cancelToken, - ); - - if (response.data is! Map) { - throw DidResolverError( - 'Invalid response format from did:web for $did', - ); - } - - final doc = DidDocument.fromJson(response.data as Map); - - // Verify the DID in the document matches - if (doc.id != did) { - throw DidResolverError( - 'DID mismatch: expected $did but got ${doc.id}', - ); - } - - return doc; - } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) { - throw DidResolverError('DID resolution was cancelled'); - } - - // If not found, try the next URL - if (e.response?.statusCode == 404) { - lastError = e; - continue; - } - - // Any other error, throw immediately - throw DidResolverError('Failed to resolve did:web: ${e.message}', e); - } catch (e) { - if (e is DidResolverError) rethrow; - - throw DidResolverError('Unexpected error resolving did:web: $e', e); - } - } - - // If we get here, all URLs failed - throw DidResolverError('DID document not found for $did', lastError); - } -} - -/// Cached DID resolver that wraps another resolver with caching. -class CachedDidResolver implements DidResolver { - final DidResolver _resolver; - final DidCache _cache; - - CachedDidResolver(this._resolver, [DidCache? cache]) - : _cache = cache ?? InMemoryDidCache(); - - @override - Future resolve(String did, [ResolveDidOptions? options]) async { - // Check cache first unless noCache is set - if (!(options?.noCache ?? false)) { - final cached = await _cache.get(did); - if (cached != null) { - return cached; - } - } - - // Resolve and cache - final doc = await _resolver.resolve(did, options); - await _cache.set(did, doc); - - return doc; - } - - /// Clears the cache - Future clearCache() => _cache.clear(); -} - -/// Interface for caching DID documents. -abstract class DidCache { - Future get(String did); - Future set(String did, DidDocument document); - Future clear(); -} - -/// Simple in-memory DID cache with expiration. -class InMemoryDidCache implements DidCache { - final Map _cache = {}; - final Duration _ttl; - - InMemoryDidCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 24); - - @override - Future get(String did) async { - final entry = _cache[did]; - if (entry == null) return null; - - // Check if expired - if (DateTime.now().isAfter(entry.expiresAt)) { - _cache.remove(did); - return null; - } - - return entry.document; - } - - @override - Future set(String did, DidDocument document) async { - _cache[did] = _CacheEntry( - document: document, - expiresAt: DateTime.now().add(_ttl), - ); - } - - @override - Future clear() async { - _cache.clear(); - } -} - -class _CacheEntry { - final DidDocument document; - final DateTime expiresAt; - - _CacheEntry({required this.document, required this.expiresAt}); -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart b/packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart deleted file mode 100644 index 2c16a52..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/handle_helpers.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'identity_resolver_error.dart'; - -/// Normalizes a handle to lowercase. -String normalizeHandle(String handle) => handle.toLowerCase(); - -/// Checks if a handle is valid according to atProto spec. -/// -/// A valid handle must: -/// - Be between 1 and 253 characters -/// - Match the pattern: subdomain.domain.tld -/// - Each label must start and end with alphanumeric -/// - Labels can contain hyphens but not at boundaries -bool isValidHandle(String handle) { - if (handle.isEmpty || handle.length >= 254) return false; - - // Pattern: ([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])? - final pattern = RegExp( - r'^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$', - ); - - return pattern.hasMatch(handle); -} - -/// Returns a normalized handle if valid, null otherwise. -String? asNormalizedHandle(String input) { - final handle = normalizeHandle(input); - return isValidHandle(handle) ? handle : null; -} - -/// Asserts that a handle is valid. -void assertValidHandle(String handle) { - if (!isValidHandle(handle)) { - throw InvalidHandleError(handle, 'Invalid handle format'); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart b/packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart deleted file mode 100644 index 8886de3..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/handle_resolver.dart +++ /dev/null @@ -1,202 +0,0 @@ -import 'package:dio/dio.dart'; - -import 'did_helpers.dart'; -import 'identity_resolver_error.dart'; - -/// Options for handle resolution. -class ResolveHandleOptions { - /// Whether to bypass cache - final bool noCache; - - /// Cancellation token for the request - final CancelToken? cancelToken; - - const ResolveHandleOptions({this.noCache = false, this.cancelToken}); -} - -/// Interface for resolving atProto handles to DIDs. -abstract class HandleResolver { - /// Resolves an atProto handle to a DID. - /// - /// Returns null if the handle doesn't resolve to a DID (but no error occurred). - /// Throws [HandleResolverError] if an unexpected error occurs during resolution. - Future resolve(String handle, [ResolveHandleOptions? options]); -} - -/// XRPC-based handle resolver that uses com.atproto.identity.resolveHandle. -/// -/// This resolver makes HTTP requests to an atProto XRPC service (typically -/// a PDS or entryway service) to resolve handles. -class XrpcHandleResolver implements HandleResolver { - /// The base URL of the XRPC service - final Uri serviceUrl; - - /// HTTP client for making requests - final Dio dio; - - XrpcHandleResolver(String serviceUrl, {Dio? dio}) - : serviceUrl = Uri.parse(serviceUrl), - dio = dio ?? Dio(); - - @override - Future resolve( - String handle, [ - ResolveHandleOptions? options, - ]) async { - final url = serviceUrl.resolve('/xrpc/com.atproto.identity.resolveHandle'); - final uri = url.replace(queryParameters: {'handle': handle}); - - try { - final response = await dio.getUri( - uri, - options: Options( - headers: {if (options?.noCache ?? false) 'Cache-Control': 'no-cache'}, - validateStatus: (status) { - // Allow 400 and 200 status codes - return status == 200 || status == 400; - }, - ), - cancelToken: options?.cancelToken, - ); - - final data = response.data; - - // Handle 400 Bad Request (expected for invalid/unresolvable handles) - if (response.statusCode == 400) { - if (data is Map) { - final error = data['error'] as String?; - final message = data['message'] as String?; - - // Expected response for handle that doesn't exist - if (error == 'InvalidRequest' && - message == 'Unable to resolve handle') { - return null; - } - } - - throw HandleResolverError( - 'Invalid response from resolveHandle method: ${response.data}', - ); - } - - // Handle successful response - if (response.statusCode == 200) { - if (data is! Map) { - throw HandleResolverError( - 'Invalid response format from resolveHandle method', - ); - } - - final did = data['did']; - if (did is! String) { - throw HandleResolverError( - 'Missing or invalid DID in resolveHandle response', - ); - } - - // Validate that it's a proper atProto DID - if (!isAtprotoDid(did)) { - throw HandleResolverError( - 'Invalid DID returned from resolveHandle method: $did', - ); - } - - return did; - } - - throw HandleResolverError( - 'Unexpected status code from resolveHandle method: ${response.statusCode}', - ); - } on DioException catch (e) { - if (e.type == DioExceptionType.cancel) { - throw HandleResolverError('Handle resolution was cancelled'); - } - - throw HandleResolverError('Failed to resolve handle: ${e.message}', e); - } catch (e) { - if (e is HandleResolverError) rethrow; - - throw HandleResolverError('Unexpected error resolving handle: $e', e); - } - } -} - -/// Cached handle resolver that wraps another resolver with caching. -class CachedHandleResolver implements HandleResolver { - final HandleResolver _resolver; - final HandleCache _cache; - - CachedHandleResolver(this._resolver, [HandleCache? cache]) - : _cache = cache ?? InMemoryHandleCache(); - - @override - Future resolve( - String handle, [ - ResolveHandleOptions? options, - ]) async { - // Check cache first unless noCache is set - if (!(options?.noCache ?? false)) { - final cached = await _cache.get(handle); - if (cached != null) { - return cached; - } - } - - // Resolve and cache - final did = await _resolver.resolve(handle, options); - if (did != null) { - await _cache.set(handle, did); - } - - return did; - } - - /// Clears the cache - Future clearCache() => _cache.clear(); -} - -/// Interface for caching handle resolution results. -abstract class HandleCache { - Future get(String handle); - Future set(String handle, String did); - Future clear(); -} - -/// Simple in-memory handle cache with expiration. -class InMemoryHandleCache implements HandleCache { - final Map _cache = {}; - final Duration _ttl; - - InMemoryHandleCache({Duration? ttl}) : _ttl = ttl ?? const Duration(hours: 1); - - @override - Future get(String handle) async { - final entry = _cache[handle]; - if (entry == null) return null; - - // Check if expired - if (DateTime.now().isAfter(entry.expiresAt)) { - _cache.remove(handle); - return null; - } - - return entry.did; - } - - @override - Future set(String handle, String did) async { - _cache[handle] = _CacheEntry(did: did, expiresAt: DateTime.now().add(_ttl)); - } - - @override - Future clear() async { - _cache.clear(); - } -} - -class _CacheEntry { - final String did; - final DateTime expiresAt; - - _CacheEntry({required this.did, required this.expiresAt}); -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/identity.dart b/packages/atproto_oauth_flutter/lib/src/identity/identity.dart deleted file mode 100644 index 731c5a5..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/identity.dart +++ /dev/null @@ -1,47 +0,0 @@ -/// Identity resolution for atProto. -/// -/// This module provides the core identity resolution functionality for atProto, -/// enabling decentralized identity through handle and DID resolution. -/// -/// ## Key Components -/// -/// - **IdentityResolver**: Main interface for resolving handles/DIDs to identity info -/// - **HandleResolver**: Resolves atProto handles (e.g., "alice.bsky.social") to DIDs -/// - **DidResolver**: Resolves DIDs to DID documents -/// - **DidDocument**: Represents a DID document with services and handles -/// -/// ## Why This Matters for Decentralization -/// -/// This is the **most important module for atProto decentralization**. It enables: -/// 1. Users to host their data on any PDS, not just bsky.social -/// 2. Custom domain handles (e.g., "alice.example.com") -/// 3. Portable identity (change PDS without losing identity) -/// -/// ## Usage -/// -/// ```dart -/// // Create a resolver -/// final resolver = AtprotoIdentityResolver.withDefaults( -/// handleResolverUrl: 'https://bsky.social', -/// ); -/// -/// // Resolve a handle to find their PDS -/// final pdsUrl = await resolver.resolveToPds('alice.bsky.social'); -/// print('Alice\'s PDS: $pdsUrl'); -/// -/// // Get full identity info -/// final info = await resolver.resolve('alice.bsky.social'); -/// print('DID: ${info.did}'); -/// print('Handle: ${info.handle}'); -/// print('PDS: ${info.pdsUrl}'); -/// ``` -library; - -export 'constants.dart'; -export 'did_document.dart'; -export 'did_helpers.dart'; -export 'did_resolver.dart'; -export 'handle_helpers.dart'; -export 'handle_resolver.dart'; -export 'identity_resolver.dart'; -export 'identity_resolver_error.dart'; diff --git a/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart b/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart deleted file mode 100644 index b987950..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver.dart +++ /dev/null @@ -1,366 +0,0 @@ -import 'package:dio/dio.dart'; - -import 'constants.dart'; -import 'did_document.dart'; -import 'did_helpers.dart'; -import 'did_resolver.dart'; -import 'handle_helpers.dart'; -import 'handle_resolver.dart'; -import 'identity_resolver_error.dart'; - -/// Represents resolved identity information for an atProto user. -/// -/// This combines DID, DID document, and validated handle information. -class IdentityInfo { - /// The DID (Decentralized Identifier) for this identity - final String did; - - /// The complete DID document - final DidDocument didDoc; - - /// The validated handle, or 'handle.invalid' if handle validation failed - final String handle; - - const IdentityInfo({ - required this.did, - required this.didDoc, - required this.handle, - }); - - /// Whether the handle is valid (not 'handle.invalid') - bool get hasValidHandle => handle != handleInvalid; - - /// Extracts the PDS URL from the DID document. - /// - /// Returns null if no PDS service is found. - String? get pdsUrl => didDoc.extractPdsUrl(); -} - -/// Options for identity resolution. -class ResolveIdentityOptions { - /// Whether to bypass cache - final bool noCache; - - /// Cancellation token for the request - final CancelToken? cancelToken; - - const ResolveIdentityOptions({this.noCache = false, this.cancelToken}); -} - -/// Interface for resolving atProto identities (handles or DIDs) to complete identity info. -abstract class IdentityResolver { - /// Resolves an identifier (handle or DID) to complete identity information. - /// - /// The identifier can be either: - /// - An atProto handle (e.g., "alice.bsky.social") - /// - A DID (e.g., "did:plc:...") - /// - /// Returns [IdentityInfo] with DID, DID document, and validated handle. - Future resolve( - String identifier, [ - ResolveIdentityOptions? options, - ]); -} - -/// Implementation of the official atProto identity resolution strategy. -/// -/// This resolver: -/// 1. Determines if input is a handle or DID -/// 2. Resolves handle → DID (if needed) -/// 3. Fetches DID document -/// 4. Validates bi-directional resolution (handle in DID doc matches original) -/// 5. Extracts PDS URL from DID document -/// -/// This is the **critical piece for decentralization** - it ensures users can -/// host their data on any PDS, not just bsky.social. -class AtprotoIdentityResolver implements IdentityResolver { - final DidResolver didResolver; - final HandleResolver handleResolver; - - AtprotoIdentityResolver({ - required this.didResolver, - required this.handleResolver, - }); - - /// Factory constructor with defaults for typical usage. - /// - /// [handleResolverUrl] should point to an atProto XRPC service that - /// implements com.atproto.identity.resolveHandle. Typically this is - /// https://bsky.social for public resolution, or your own PDS. - factory AtprotoIdentityResolver.withDefaults({ - required String handleResolverUrl, - String? plcDirectoryUrl, - Dio? dio, - DidCache? didCache, - HandleCache? handleCache, - }) { - final dioInstance = dio ?? Dio(); - - final baseDidResolver = AtprotoDidResolver( - plcDirectoryUrl: plcDirectoryUrl, - dio: dioInstance, - ); - - final baseHandleResolver = XrpcHandleResolver( - handleResolverUrl, - dio: dioInstance, - ); - - return AtprotoIdentityResolver( - didResolver: CachedDidResolver(baseDidResolver, didCache), - handleResolver: CachedHandleResolver(baseHandleResolver, handleCache), - ); - } - - @override - Future resolve( - String identifier, [ - ResolveIdentityOptions? options, - ]) async { - return isDid(identifier) - ? resolveFromDid(identifier, options) - : resolveFromHandle(identifier, options); - } - - /// Resolves identity starting from a DID. - /// - /// This: - /// 1. Fetches the DID document - /// 2. Extracts the handle from alsoKnownAs - /// 3. Validates that the handle resolves back to the same DID - Future resolveFromDid( - String did, [ - ResolveIdentityOptions? options, - ]) async { - final document = await getDocumentFromDid(did, options); - - // We will only return the document's handle alias if it resolves to the - // same DID as the input (bi-directional validation) - final handle = document.extractNormalizedHandle(); - String? resolvedDid; - - if (handle != null) { - try { - resolvedDid = await handleResolver.resolve( - handle, - ResolveHandleOptions( - noCache: options?.noCache ?? false, - cancelToken: options?.cancelToken, - ), - ); - } catch (e) { - // Ignore errors (handle might be temporarily unavailable) - resolvedDid = null; - } - } - - return IdentityInfo( - did: document.id, - didDoc: document, - handle: handle != null && resolvedDid == did ? handle : handleInvalid, - ); - } - - /// Resolves identity starting from a handle. - /// - /// This: - /// 1. Resolves handle → DID - /// 2. Fetches DID document - /// 3. Validates that the DID document contains the original handle - Future resolveFromHandle( - String handle, [ - ResolveIdentityOptions? options, - ]) async { - final document = await getDocumentFromHandle(handle, options); - - // Bi-directional resolution is enforced in getDocumentFromHandle() - return IdentityInfo( - did: document.id, - didDoc: document, - handle: document.extractNormalizedHandle() ?? handleInvalid, - ); - } - - /// Fetches a DID document from a DID. - Future getDocumentFromDid( - String did, [ - ResolveIdentityOptions? options, - ]) async { - return didResolver.resolve( - did, - ResolveDidOptions( - noCache: options?.noCache ?? false, - cancelToken: options?.cancelToken, - ), - ); - } - - /// Fetches a DID document from a handle with bi-directional validation. - /// - /// This method: - /// 1. Normalizes and validates the handle - /// 2. Resolves handle → DID - /// 3. Fetches DID document - /// 4. Verifies the DID document contains the original handle - Future getDocumentFromHandle( - String input, [ - ResolveIdentityOptions? options, - ]) async { - final handle = asNormalizedHandle(input); - if (handle == null) { - throw InvalidHandleError(input, 'Invalid handle format'); - } - - final did = await handleResolver.resolve( - handle, - ResolveHandleOptions( - noCache: options?.noCache ?? false, - cancelToken: options?.cancelToken, - ), - ); - - if (did == null) { - throw IdentityResolverError('Handle "$handle" does not resolve to a DID'); - } - - // Fetch the DID document - final document = await didResolver.resolve( - did, - ResolveDidOptions( - noCache: options?.noCache ?? false, - cancelToken: options?.cancelToken, - ), - ); - - // Enforce bi-directional resolution - final docHandle = document.extractNormalizedHandle(); - if (handle != docHandle) { - throw IdentityResolverError( - 'DID document for "$did" does not include the handle "$handle" ' - '(found: ${docHandle ?? "none"})', - ); - } - - return document; - } - - /// Convenience method to resolve directly to PDS URL. - /// - /// This is the most common use case: given a handle or DID, find the PDS URL. - Future resolveToPds( - String identifier, [ - ResolveIdentityOptions? options, - ]) async { - final info = await resolve(identifier, options); - final pdsUrl = info.pdsUrl; - - if (pdsUrl == null) { - throw IdentityResolverError( - 'No PDS endpoint found in DID document for $identifier', - ); - } - - return pdsUrl; - } -} - -/// Options for creating an identity resolver. -class IdentityResolverOptions { - /// Custom identity resolver (if not provided, AtprotoIdentityResolver is used) - final IdentityResolver? identityResolver; - - /// Custom DID resolver - final DidResolver? didResolver; - - /// Custom handle resolver (or URL string for XRPC resolver) - final dynamic handleResolver; // HandleResolver, String, or Uri - - /// Custom DID cache - final DidCache? didCache; - - /// Custom handle cache - final HandleCache? handleCache; - - /// Custom Dio instance for HTTP requests - final Dio? dio; - - /// PLC directory URL (defaults to https://plc.directory/) - final String? plcDirectoryUrl; - - const IdentityResolverOptions({ - this.identityResolver, - this.didResolver, - this.handleResolver, - this.didCache, - this.handleCache, - this.dio, - this.plcDirectoryUrl, - }); -} - -/// Creates an identity resolver with the given options. -/// -/// This is the main entry point for creating an identity resolver. -/// It handles setting up default implementations with proper caching. -IdentityResolver createIdentityResolver(IdentityResolverOptions options) { - // If a custom identity resolver is provided, use it - if (options.identityResolver != null) { - return options.identityResolver!; - } - - final dioInstance = options.dio ?? Dio(); - - // Create DID resolver - final didResolver = _createDidResolver(options, dioInstance); - - // Create handle resolver - final handleResolver = _createHandleResolver(options, dioInstance); - - return AtprotoIdentityResolver( - didResolver: didResolver, - handleResolver: handleResolver, - ); -} - -DidResolver _createDidResolver(IdentityResolverOptions options, Dio dio) { - final didResolver = - options.didResolver ?? - AtprotoDidResolver(plcDirectoryUrl: options.plcDirectoryUrl, dio: dio); - - // Wrap with cache if not already cached - if (didResolver is CachedDidResolver && options.didCache == null) { - return didResolver; - } - - return CachedDidResolver(didResolver, options.didCache); -} - -HandleResolver _createHandleResolver(IdentityResolverOptions options, Dio dio) { - final handleResolverInput = options.handleResolver; - - if (handleResolverInput == null) { - throw ArgumentError( - 'handleResolver is required. Provide either a HandleResolver instance, ' - 'a URL string, or a Uri pointing to an XRPC service.', - ); - } - - HandleResolver baseResolver; - - if (handleResolverInput is HandleResolver) { - baseResolver = handleResolverInput; - } else if (handleResolverInput is String || handleResolverInput is Uri) { - baseResolver = XrpcHandleResolver(handleResolverInput.toString(), dio: dio); - } else { - throw ArgumentError( - 'handleResolver must be a HandleResolver, String, or Uri', - ); - } - - // Wrap with cache if not already cached - if (baseResolver is CachedHandleResolver && options.handleCache == null) { - return baseResolver; - } - - return CachedHandleResolver(baseResolver, options.handleCache); -} diff --git a/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart b/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart deleted file mode 100644 index b180c00..0000000 --- a/packages/atproto_oauth_flutter/lib/src/identity/identity_resolver_error.dart +++ /dev/null @@ -1,53 +0,0 @@ -/// Error thrown when identity resolution fails. -/// -/// This error is thrown when resolving an atProto handle or DID fails, -/// including cases such as: -/// - Invalid handle format -/// - Handle doesn't resolve to a DID -/// - DID document is malformed or missing required fields -/// - Bi-directional resolution fails (handle in DID doc doesn't match) -class IdentityResolverError extends Error { - /// The error message describing what went wrong - final String message; - - /// Optional underlying cause of the error - final Object? cause; - - IdentityResolverError(this.message, [this.cause]); - - @override - String toString() { - if (cause != null) { - return 'IdentityResolverError: $message\nCaused by: $cause'; - } - return 'IdentityResolverError: $message'; - } -} - -/// Error thrown when a DID is invalid or malformed. -class InvalidDidError extends IdentityResolverError { - /// The invalid DID that was provided - final String did; - - InvalidDidError(this.did, String message, [Object? cause]) - : super('Invalid DID "$did": $message', cause); -} - -/// Error thrown when a handle is invalid or malformed. -class InvalidHandleError extends IdentityResolverError { - /// The invalid handle that was provided - final String handle; - - InvalidHandleError(this.handle, String message, [Object? cause]) - : super('Invalid handle "$handle": $message', cause); -} - -/// Error thrown when handle resolution fails. -class HandleResolverError extends IdentityResolverError { - HandleResolverError(super.message, [super.cause]); -} - -/// Error thrown when DID resolution fails. -class DidResolverError extends IdentityResolverError { - DidResolverError(super.message, [super.cause]); -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart b/packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart deleted file mode 100644 index 305b97b..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/authorization_server_metadata_resolver.dart +++ /dev/null @@ -1,248 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../dpop/fetch_dpop.dart'; -import '../util.dart'; - -/// Options for getting cached values. -class GetCachedOptions { - /// Whether to bypass cache and force a fresh fetch - final bool noCache; - - /// Whether to allow returning stale cached values - final bool allowStale; - - /// Optional cancellation token - final CancelToken? cancelToken; - - const GetCachedOptions({ - this.noCache = false, - this.allowStale = true, - this.cancelToken, - }); -} - -/// Cache interface for authorization server metadata. -/// -/// Implementations should store metadata keyed by issuer URL. -typedef AuthorizationServerMetadataCache = - SimpleStore>; - -/// Configuration for the authorization server metadata resolver. -class OAuthAuthorizationServerMetadataResolverConfig { - /// Whether to allow HTTP (non-HTTPS) issuer URLs. - /// - /// Should only be true in development/test environments. - /// Production MUST use HTTPS. - final bool allowHttpIssuer; - - const OAuthAuthorizationServerMetadataResolverConfig({ - this.allowHttpIssuer = false, - }); -} - -/// Resolves OAuth Authorization Server Metadata via RFC 8414 discovery. -/// -/// This class: -/// 1. Validates issuer URLs (must be HTTPS in production) -/// 2. Fetches metadata from `{issuer}/.well-known/oauth-authorization-server` -/// 3. Validates the metadata against the spec -/// 4. Verifies issuer matches (prevents MIX-UP attacks) -/// 5. Ensures ATPROTO requirements (client_id_metadata_document) -/// 6. Caches metadata to avoid repeated fetches -/// -/// See: https://datatracker.ietf.org/doc/html/rfc8414 -class OAuthAuthorizationServerMetadataResolver { - final AuthorizationServerMetadataCache _cache; - final Dio _dio; - final bool _allowHttpIssuer; - - /// Creates a resolver with the given cache and HTTP client. - /// - /// [cache] is used to store fetched metadata. Use an in-memory store for - /// testing or a persistent store for production. - /// - /// [dio] is the HTTP client. If not provided, creates a default instance. - /// - /// [config] allows customizing behavior (e.g., allowing HTTP in tests). - OAuthAuthorizationServerMetadataResolver( - this._cache, { - Dio? dio, - OAuthAuthorizationServerMetadataResolverConfig? config, - }) : _dio = dio ?? Dio(), - _allowHttpIssuer = config?.allowHttpIssuer ?? false; - - /// Resolves authorization server metadata for the given issuer. - /// - /// The [input] should be a valid issuer identifier (typically an HTTPS URL). - /// - /// Returns the complete metadata as a Map. Throws if: - /// - Input is not a valid issuer URL - /// - HTTP is used in production (allowHttpIssuer = false) - /// - Network request fails - /// - Response is not valid JSON - /// - Metadata validation fails - /// - Issuer mismatch detected - /// - ATPROTO requirements not met - /// - /// Example: - /// ```dart - /// final resolver = OAuthAuthorizationServerMetadataResolver(cache); - /// final metadata = await resolver.get('https://pds.example.com'); - /// print(metadata['authorization_endpoint']); - /// ``` - Future> get( - String input, [ - GetCachedOptions? options, - ]) async { - // Validate and normalize issuer URL - final issuer = _validateIssuer(input); - - // Security check: disallow HTTP in production - if (!_allowHttpIssuer && issuer.startsWith('http:')) { - throw FormatException( - 'Unsecure issuer URL protocol only allowed in development and test environments', - ); - } - - // Check cache first (unless noCache is set) - if (options?.noCache != true) { - final cached = await _cache.get(issuer); - if (cached != null) { - return cached; - } - } - - // Fetch fresh metadata - final metadata = await _fetchMetadata(issuer, options); - - // Store in cache - await _cache.set(issuer, metadata); - - return metadata; - } - - /// Fetches metadata from the well-known endpoint. - Future> _fetchMetadata( - String issuer, - GetCachedOptions? options, - ) async { - final url = - Uri.parse( - issuer, - ).replace(path: '/.well-known/oauth-authorization-server').toString(); - - try { - final response = await _dio.get>( - url, - options: Options( - headers: {'accept': 'application/json'}, - followRedirects: false, // response must be 200 OK, no redirects - validateStatus: (status) => status == 200, - ), - cancelToken: options?.cancelToken, - ); - - // Verify content type - final contentType = contentMime( - response.headers.map.map((key, value) => MapEntry(key, value.first)), - ); - - if (contentType != 'application/json') { - throw DioException( - requestOptions: response.requestOptions, - response: response, - type: DioExceptionType.badResponse, - message: 'Unexpected content type for "$url"', - ); - } - - final metadata = response.data; - if (metadata == null) { - throw DioException( - requestOptions: response.requestOptions, - response: response, - type: DioExceptionType.badResponse, - message: 'Empty response body for "$url"', - ); - } - - // Validate metadata structure - _validateMetadata(metadata, issuer); - - return metadata; - } on DioException catch (e) { - if (e.response?.statusCode == 200) { - // Already handled above, rethrow - rethrow; - } - throw DioException( - requestOptions: e.requestOptions, - response: e.response, - type: e.type, - message: - 'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"', - error: e.error, - ); - } - } - - /// Validates an issuer identifier. - /// - /// Ensures the issuer is a valid URL without query or fragment. - /// Returns the normalized issuer. - String _validateIssuer(String input) { - final uri = Uri.tryParse(input); - if (uri == null) { - throw FormatException('Invalid issuer URL: $input'); - } - - // Issuer must not have query or fragment - if (uri.hasQuery || uri.hasFragment) { - throw FormatException( - 'Issuer URL must not contain query or fragment: $input', - ); - } - - // Normalize: remove trailing slash - final normalized = - input.endsWith('/') ? input.substring(0, input.length - 1) : input; - - return normalized; - } - - /// Validates authorization server metadata. - /// - /// Checks: - /// - Required fields are present - /// - Issuer matches expected value (MIX-UP attack prevention) - /// - ATPROTO requirement: client_id_metadata_document_supported = true - void _validateMetadata(Map metadata, String expectedIssuer) { - // Validate issuer field (critical for security - prevents MIX-UP attacks) - // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#name-mix-up-attacks - // https://datatracker.ietf.org/doc/html/rfc8414#section-2 - final issuer = metadata['issuer']; - if (issuer != expectedIssuer) { - throw FormatException( - 'Invalid issuer: expected "$expectedIssuer", got "$issuer"', - ); - } - - // ATPROTO requires client_id_metadata_document support - // https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ - final clientIdMetadataSupported = - metadata['client_id_metadata_document_supported']; - if (clientIdMetadataSupported != true) { - throw FormatException( - 'Authorization server "$issuer" does not support client_id_metadata_document', - ); - } - - // Validate required endpoints exist - if (metadata['authorization_endpoint'] == null) { - throw FormatException('Missing required field: authorization_endpoint'); - } - if (metadata['token_endpoint'] == null) { - throw FormatException('Missing required field: token_endpoint'); - } - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart b/packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart deleted file mode 100644 index c91cb9a..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/client_auth.dart +++ /dev/null @@ -1,285 +0,0 @@ -import '../constants.dart'; -import '../errors/auth_method_unsatisfiable_error.dart'; -import '../runtime/runtime.dart'; -import '../runtime/runtime_implementation.dart'; -import '../types.dart'; - -/// Represents a client authentication method. -/// -/// OAuth supports different ways for clients to authenticate with the -/// authorization server: -/// - 'none': Public client (no secret), only client_id -/// - 'private_key_jwt': Confidential client using JWT signed with private key -class ClientAuthMethod { - final String method; - final String? kid; // Key ID for private_key_jwt method - - const ClientAuthMethod.none() : method = 'none', kid = null; - - const ClientAuthMethod.privateKeyJwt(this.kid) : method = 'private_key_jwt'; - - @override - bool operator ==(Object other) { - if (identical(this, other)) return true; - return other is ClientAuthMethod && - other.method == method && - other.kid == kid; - } - - @override - int get hashCode => method.hashCode ^ kid.hashCode; - - Map toJson() { - return {'method': method, if (kid != null) 'kid': kid}; - } - - factory ClientAuthMethod.fromJson(Map json) { - final method = json['method'] as String; - if (method == 'none') { - return const ClientAuthMethod.none(); - } else if (method == 'private_key_jwt') { - return ClientAuthMethod.privateKeyJwt(json['kid'] as String); - } - throw FormatException('Unknown auth method: $method'); - } -} - -/// Credential payload to include in OAuth requests. -class OAuthClientCredentials { - /// Client identifier - final String clientId; - - /// Client assertion type (for private_key_jwt) - final String? clientAssertionType; - - /// Client assertion JWT (for private_key_jwt) - final String? clientAssertion; - - const OAuthClientCredentials({ - required this.clientId, - this.clientAssertionType, - this.clientAssertion, - }); - - Map toJson() { - final map = {'client_id': clientId}; - if (clientAssertionType != null) { - map['client_assertion_type'] = clientAssertionType; - } - if (clientAssertion != null) { - map['client_assertion'] = clientAssertion; - } - return map; - } -} - -/// Result of creating client credentials. -class ClientCredentialsResult { - /// Optional HTTP headers (e.g., Authorization header for client_secret_basic) - final Map? headers; - - /// Payload to include in the request body - final OAuthClientCredentials payload; - - const ClientCredentialsResult({this.headers, required this.payload}); -} - -/// Factory function that creates client credentials. -typedef ClientCredentialsFactory = Future Function(); - -/// Negotiates the client authentication method to use. -/// -/// This function: -/// 1. Checks that the server supports the client's auth method -/// 2. For private_key_jwt, finds a suitable key from the keyset -/// 3. Returns the negotiated auth method -/// -/// The ATPROTO spec requires that authorization servers support both -/// "none" and "private_key_jwt", and clients use one or the other. -/// -/// Throws: -/// - Error if server doesn't support client's auth method -/// - Error if private_key_jwt is used but no suitable key is found -ClientAuthMethod negotiateClientAuthMethod( - Map serverMetadata, - ClientMetadata clientMetadata, - Keyset? keyset, -) { - final method = clientMetadata.tokenEndpointAuthMethod; - - // Check that the server supports this method - final methods = _supportedMethods(serverMetadata); - if (!methods.contains(method)) { - throw StateError( - 'The server does not support "$method" authentication. ' - 'Supported methods are: ${methods.join(', ')}.', - ); - } - - if (method == 'private_key_jwt') { - // Invalid client configuration - if (keyset == null) { - throw StateError('A keyset is required for private_key_jwt'); - } - - final algs = _supportedAlgs(serverMetadata); - - // Find a suitable key - // We can't use keyset.findPrivateKey here because we need to ensure - // the key has a "kid" property (required for JWT headers) - for (final key in keyset.keys) { - if (key.kid != null && - key.usage == 'sign' && - key.algorithms.any((a) => algs.contains(a))) { - return ClientAuthMethod.privateKeyJwt(key.kid!); - } - } - - throw StateError( - algs.contains(fallbackAlg) - ? 'Client authentication method "$method" requires at least one "$fallbackAlg" signing key with a "kid" property' - : 'Authorization server requires "$method" authentication method, but does not support "$fallbackAlg" algorithm.', - ); - } - - if (method == 'none') { - return const ClientAuthMethod.none(); - } - - throw StateError( - 'The ATProto OAuth spec requires that client use either "none" or "private_key_jwt" authentication method.' + - (method == 'client_secret_basic' - ? ' You might want to explicitly set "token_endpoint_auth_method" to one of those values in the client metadata document.' - : ' You set "$method" which is not allowed.'), - ); -} - -/// Creates a factory that generates client credentials. -/// -/// The factory can be called multiple times to generate fresh credentials -/// (important for private_key_jwt which includes timestamps). -/// -/// Throws [AuthMethodUnsatisfiableError] if: -/// - Server no longer supports the auth method -/// - Key is no longer available in the keyset -ClientCredentialsFactory createClientCredentialsFactory( - ClientAuthMethod authMethod, - Map serverMetadata, - ClientMetadata clientMetadata, - Runtime runtime, - Keyset? keyset, -) { - // Ensure the AS still supports the auth method - if (!_supportedMethods(serverMetadata).contains(authMethod.method)) { - throw AuthMethodUnsatisfiableError( - 'Client authentication method "${authMethod.method}" no longer supported', - ); - } - - if (authMethod.method == 'none') { - return () async => ClientCredentialsResult( - payload: OAuthClientCredentials(clientId: clientMetadata.clientId!), - ); - } - - if (authMethod.method == 'private_key_jwt') { - try { - // Find the key - if (keyset == null) { - throw StateError('A keyset is required for private_key_jwt'); - } - - final key = keyset.keys.firstWhere( - (k) => - k.kid == authMethod.kid && - k.usage == 'sign' && - k.algorithms.any((a) => _supportedAlgs(serverMetadata).contains(a)), - orElse: () => throw StateError('Key not found: ${authMethod.kid}'), - ); - - final alg = key.algorithms.firstWhere( - (a) => _supportedAlgs(serverMetadata).contains(a), - orElse: () => throw StateError('No supported algorithm found'), - ); - - // https://www.rfc-editor.org/rfc/rfc7523.html#section-3 - return () async { - final jti = await runtime.generateNonce(); - final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; - - final jwt = await key.createJwt( - {'alg': alg}, - { - // Issuer: the client_id - 'iss': clientMetadata.clientId, - // Subject: the client_id - 'sub': clientMetadata.clientId, - // Audience: the authorization server - 'aud': serverMetadata['issuer'], - // JWT ID: unique identifier - 'jti': jti, - // Issued at - 'iat': now, - // Expiration: 1 minute from now - 'exp': now + 60, - }, - ); - - return ClientCredentialsResult( - payload: OAuthClientCredentials( - clientId: clientMetadata.clientId!, - clientAssertionType: - 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - clientAssertion: jwt, - ), - ); - }; - } catch (cause) { - throw AuthMethodUnsatisfiableError('Failed to load private key: $cause'); - } - } - - throw AuthMethodUnsatisfiableError( - 'Unsupported auth method: ${authMethod.method}', - ); -} - -/// Gets the list of supported authentication methods from server metadata. -List _supportedMethods(Map serverMetadata) { - final methods = serverMetadata['token_endpoint_auth_methods_supported']; - if (methods is List) { - return methods.map((m) => m.toString()).toList(); - } - return []; -} - -/// Gets the list of supported signing algorithms from server metadata. -List _supportedAlgs(Map serverMetadata) { - final algs = - serverMetadata['token_endpoint_auth_signing_alg_values_supported']; - if (algs is List) { - return algs.map((a) => a.toString()).toList(); - } - - // Default to ES256 as prescribed by the ATProto spec: - // > Clients and Authorization Servers currently must support the ES256 - // > cryptographic system [for client authentication]. - // https://atproto.com/specs/oauth#confidential-client-authentication - return [fallbackAlg]; -} - -/// Placeholder for Keyset class. -/// -/// In the full implementation, this would come from @atproto/jwk package. -/// For now, we use a simple implementation. -class Keyset { - final List keys; - - const Keyset(this.keys); - - int get size => keys.length; - - Map toJSON() { - return {'keys': keys.map((k) => k.bareJwk).toList()}; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart b/packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart deleted file mode 100644 index 482be4c..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_resolver.dart +++ /dev/null @@ -1,307 +0,0 @@ -import '../errors/oauth_resolver_error.dart'; -import '../identity/did_document.dart'; -import '../identity/identity_resolver.dart'; -import 'authorization_server_metadata_resolver.dart'; -import 'protected_resource_metadata_resolver.dart'; - -/// Complete result of OAuth resolution from an identity. -class ResolvedOAuthIdentityFromIdentity { - /// The resolved identity information - final IdentityInfo identityInfo; - - /// The authorization server metadata - final Map metadata; - - /// The PDS URL - final Uri pds; - - const ResolvedOAuthIdentityFromIdentity({ - required this.identityInfo, - required this.metadata, - required this.pds, - }); -} - -/// Result of OAuth resolution from a service URL. -class ResolvedOAuthIdentityFromService { - /// The authorization server metadata - final Map metadata; - - /// Optional identity info (only present if resolved from handle/DID) - final IdentityInfo? identityInfo; - - const ResolvedOAuthIdentityFromService({ - required this.metadata, - this.identityInfo, - }); -} - -/// Options for OAuth resolution. -typedef ResolveOAuthOptions = GetCachedOptions; - -/// Main OAuth resolver that combines identity and metadata resolution. -/// -/// This class orchestrates the complete OAuth discovery flow: -/// -/// 1. **From handle/DID** (resolveFromIdentity): -/// - Resolve handle → DID (if needed) -/// - Fetch DID document -/// - Extract PDS URL from DID document -/// - Fetch protected resource metadata from PDS -/// - Extract authorization server(s) from resource metadata -/// - Fetch authorization server metadata -/// - Verify PDS is protected by the authorization server -/// -/// 2. **From URL** (resolveFromService): -/// - Try as PDS URL (fetch protected resource metadata) -/// - Extract authorization server from metadata -/// - Fallback: try as authorization server directly -/// -/// This is the critical piece that enables decentralization - users can -/// host their data on any PDS, and we discover the OAuth server dynamically. -class OAuthResolver { - final IdentityResolver identityResolver; - final OAuthProtectedResourceMetadataResolver - protectedResourceMetadataResolver; - final OAuthAuthorizationServerMetadataResolver - authorizationServerMetadataResolver; - - OAuthResolver({ - required this.identityResolver, - required this.protectedResourceMetadataResolver, - required this.authorizationServerMetadataResolver, - }); - - /// Resolves OAuth metadata from an input (handle, DID, or URL). - /// - /// The [input] can be: - /// - An atProto handle (e.g., "alice.bsky.social") - /// - A DID (e.g., "did:plc:...") - /// - A PDS URL (e.g., "https://pds.example.com") - /// - An authorization server URL (e.g., "https://auth.example.com") - /// - /// Returns metadata for the authorization server. The identityInfo - /// is only present if input was a handle or DID. - Future resolve( - String input, [ - ResolveOAuthOptions? options, - ]) async { - // Detect if input is a URL (starts with http:// or https://) - if (RegExp(r'^https?://').hasMatch(input)) { - return resolveFromService(input, options); - } else { - final result = await resolveFromIdentity(input, options); - return ResolvedOAuthIdentityFromService( - metadata: result.metadata, - identityInfo: result.identityInfo, - ); - } - } - - /// Resolves OAuth metadata from a service URL (PDS or authorization server). - /// - /// This method: - /// 1. First tries to resolve as a PDS (protected resource) - /// 2. If that fails, tries to resolve as an authorization server directly - /// - /// This allows both "login with PDS URL" and "login with auth server URL" - /// flows, useful when users forget their handle or for compatibility. - Future resolveFromService( - String input, [ - ResolveOAuthOptions? options, - ]) async { - try { - // Assume first that input is a PDS URL (as required by ATPROTO) - final metadata = await getResourceServerMetadata(input, options); - return ResolvedOAuthIdentityFromService(metadata: metadata); - } catch (err) { - // Check if request was cancelled - note: Dio's CancelToken doesn't have throwIfCanceled() - // We rely on Dio throwing CancelError automatically - - if (err is OAuthResolverError) { - try { - // Fallback to trying to fetch as an issuer (Entryway/Authorization Server) - final issuerUri = Uri.tryParse(input); - if (issuerUri != null && issuerUri.hasScheme) { - final metadata = await getAuthorizationServerMetadata( - input, - options, - ); - return ResolvedOAuthIdentityFromService(metadata: metadata); - } - } catch (_) { - // Fallback failed, throw original error - } - } - - rethrow; - } - } - - /// Resolves OAuth metadata from a handle or DID. - /// - /// This is the primary OAuth discovery flow: - /// 1. Resolve handle → DID → DID document (via IdentityResolver) - /// 2. Extract PDS URL from DID document - /// 3. Get protected resource metadata from PDS - /// 4. Extract authorization server(s) - /// 5. Get authorization server metadata - /// 6. Verify PDS is protected by the auth server - Future resolveFromIdentity( - String input, [ - ResolveOAuthOptions? options, - ]) async { - final identityInfo = await resolveIdentity( - input, - options != null - ? ResolveIdentityOptions( - noCache: options.noCache, - cancelToken: options.cancelToken, - ) - : null, - ); - - final pds = _extractPdsUrl(identityInfo.didDoc); - - final metadata = await getResourceServerMetadata(pds, options); - - return ResolvedOAuthIdentityFromIdentity( - identityInfo: identityInfo, - metadata: metadata, - pds: pds, - ); - } - - /// Resolves an identity (handle or DID) to IdentityInfo. - /// - /// Wraps the IdentityResolver with proper error handling. - Future resolveIdentity( - String input, [ - ResolveIdentityOptions? options, - ]) async { - try { - return await identityResolver.resolve(input, options); - } catch (cause) { - throw OAuthResolverError.from( - cause, - 'Failed to resolve identity: $input', - ); - } - } - - /// Gets authorization server metadata for an issuer. - /// - /// Wraps the AuthorizationServerMetadataResolver with proper error handling. - Future> getAuthorizationServerMetadata( - String issuer, [ - GetCachedOptions? options, - ]) async { - try { - return await authorizationServerMetadataResolver.get(issuer, options); - } catch (cause) { - throw OAuthResolverError.from( - cause, - 'Failed to resolve OAuth server metadata for issuer: $issuer', - ); - } - } - - /// Gets authorization server metadata for a protected resource (PDS). - /// - /// This method: - /// 1. Fetches protected resource metadata - /// 2. Validates exactly one authorization server is listed (ATPROTO requirement) - /// 3. Fetches authorization server metadata - /// 4. Verifies the PDS is in the auth server's protected_resources list - Future> getResourceServerMetadata( - dynamic pdsUrl, [ - GetCachedOptions? options, - ]) async { - try { - final rsMetadata = await protectedResourceMetadataResolver.get( - pdsUrl, - options, - ); - - // ATPROTO requires exactly one authorization server - final authServers = rsMetadata['authorization_servers']; - if (authServers is! List || authServers.length != 1) { - throw OAuthResolverError( - authServers == null || (authServers as List).isEmpty - ? 'No authorization servers found for PDS: $pdsUrl' - : 'Unable to determine authorization server for PDS: $pdsUrl', - ); - } - - final issuer = authServers[0] as String; - - final asMetadata = await getAuthorizationServerMetadata(issuer, options); - - // Verify PDS is protected by this authorization server - // https://www.rfc-editor.org/rfc/rfc9728.html#section-4 - final protectedResources = asMetadata['protected_resources']; - if (protectedResources != null) { - final resource = rsMetadata['resource'] as String; - if (!(protectedResources as List).contains(resource)) { - throw OAuthResolverError( - 'PDS "$pdsUrl" not protected by issuer "$issuer"', - ); - } - } - - return asMetadata; - } catch (cause) { - throw OAuthResolverError.from( - cause, - 'Failed to resolve OAuth server metadata for resource: $pdsUrl', - ); - } - } - - /// Extracts the PDS URL from a DID document. - /// - /// Throws OAuthResolverError if no PDS URL is found. - Uri _extractPdsUrl(DidDocument document) { - // Find the atproto_pds service - final service = document.service?.firstWhere( - (s) => _isAtprotoPersonalDataServerService(s, document), - orElse: - () => - throw OAuthResolverError( - 'Identity "${document.id}" does not have a PDS URL', - ), - ); - - if (service == null) { - throw OAuthResolverError( - 'Identity "${document.id}" does not have a PDS URL', - ); - } - - try { - return Uri.parse(service.serviceEndpoint as String); - } catch (cause) { - throw OAuthResolverError( - 'Invalid PDS URL in DID document: ${service.serviceEndpoint}', - cause: cause, - ); - } - } - - /// Checks if a service is an AtprotoPersonalDataServer. - bool _isAtprotoPersonalDataServerService( - DidService service, - DidDocument document, - ) { - if (service.serviceEndpoint is! String) return false; - if (service.type != 'AtprotoPersonalDataServer') return false; - - // Check service ID - final id = service.id; - if (id.startsWith('#')) { - return id == '#atproto_pds'; - } else { - return id == '${document.id}#atproto_pds'; - } - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart b/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart deleted file mode 100644 index 3037ecc..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_agent.dart +++ /dev/null @@ -1,519 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart' hide Key; - -import '../dpop/fetch_dpop.dart'; -import '../errors/oauth_response_error.dart'; -import '../errors/token_refresh_error.dart'; -import '../runtime/runtime.dart'; -import '../runtime/runtime_implementation.dart'; -import '../types.dart'; -import 'authorization_server_metadata_resolver.dart' show GetCachedOptions; -import 'client_auth.dart'; -import 'oauth_resolver.dart'; - -/// Represents a token set returned from OAuth token endpoint. -class TokenSet { - /// Issuer (authorization server URL) - final String iss; - - /// Subject (DID of the user) - final String sub; - - /// Audience (PDS URL) - final String aud; - - /// Scope (space-separated list of scopes) - final String scope; - - /// Refresh token (optional) - final String? refreshToken; - - /// Access token - final String accessToken; - - /// Token type (must be "DPoP" for ATPROTO) - final String tokenType; - - /// Expiration time (ISO date string) - final String? expiresAt; - - const TokenSet({ - required this.iss, - required this.sub, - required this.aud, - required this.scope, - this.refreshToken, - required this.accessToken, - required this.tokenType, - this.expiresAt, - }); - - Map toJson() { - return { - 'iss': iss, - 'sub': sub, - 'aud': aud, - 'scope': scope, - if (refreshToken != null) 'refresh_token': refreshToken, - 'access_token': accessToken, - 'token_type': tokenType, - if (expiresAt != null) 'expires_at': expiresAt, - }; - } - - factory TokenSet.fromJson(Map json) { - return TokenSet( - iss: json['iss'] as String, - sub: json['sub'] as String, - aud: json['aud'] as String, - scope: json['scope'] as String, - refreshToken: json['refresh_token'] as String?, - accessToken: json['access_token'] as String, - tokenType: json['token_type'] as String, - expiresAt: json['expires_at'] as String?, - ); - } -} - -/// DPoP nonce cache type. -typedef DpopNonceCache = SimpleStore; - -/// Agent for interacting with an OAuth authorization server. -/// -/// This class handles: -/// - Token exchange (authorization code → tokens) -/// - Token refresh (refresh token → new tokens) -/// - Token revocation -/// - DPoP proof generation and nonce management -/// - Client authentication -/// -/// All token requests include DPoP proofs to bind tokens to keys. -class OAuthServerAgent { - final ClientAuthMethod authMethod; - final Key dpopKey; - final Map serverMetadata; - final ClientMetadata clientMetadata; - final DpopNonceCache dpopNonces; - final OAuthResolver oauthResolver; - final Runtime runtime; - final Keyset? keyset; - final Dio _dio; - final ClientCredentialsFactory _clientCredentialsFactory; - - /// Creates an OAuth server agent. - /// - /// Throws [AuthMethodUnsatisfiableError] if the auth method cannot be satisfied. - OAuthServerAgent({ - required this.authMethod, - required this.dpopKey, - required this.serverMetadata, - required this.clientMetadata, - required this.dpopNonces, - required this.oauthResolver, - required this.runtime, - this.keyset, - Dio? dio, - }) : // CRITICAL: Always create a NEW Dio instance to avoid duplicate interceptors - // If we reuse a shared Dio instance, each OAuthServerAgent will add its - // interceptors to the same instance, causing duplicate requests! - _dio = Dio(dio?.options ?? BaseOptions()), - _clientCredentialsFactory = createClientCredentialsFactory( - authMethod, - serverMetadata, - clientMetadata, - runtime, - keyset, - ) { - // Add debug logging interceptor (runs before DPoP interceptor) - if (kDebugMode) { - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - if (options.uri.path.contains('/token')) { - print( - '📤 [BEFORE DPoP] Request headers: ${options.headers.keys.toList()}', - ); - } - handler.next(options); - }, - ), - ); - } - - // Add DPoP interceptor - _dio.interceptors.add( - createDpopInterceptor( - DpopFetchWrapperOptions( - key: dpopKey, - nonces: dpopNonces, - sha256: runtime.sha256, - isAuthServer: true, - ), - ), - ); - - // Add final logging interceptor (runs after DPoP interceptor) - if (kDebugMode) { - _dio.interceptors.add( - InterceptorsWrapper( - onRequest: (options, handler) { - if (options.uri.path.contains('/token')) { - print( - '📤 [AFTER DPoP] Request headers: ${options.headers.keys.toList()}', - ); - if (options.headers.containsKey('dpop')) { - print( - ' DPoP header present: ${options.headers['dpop']?.toString().substring(0, 50)}...', - ); - } else if (options.headers.containsKey('DPoP')) { - print( - ' DPoP header present: ${options.headers['DPoP']?.toString().substring(0, 50)}...', - ); - } else { - print(' ⚠️ DPoP header MISSING!'); - } - } - handler.next(options); - }, - onError: (error, handler) { - if (error.requestOptions.uri.path.contains('/token')) { - print('📥 Token request error: ${error.message}'); - } - handler.next(error); - }, - ), - ); - } - } - - /// The issuer (authorization server URL). - String get issuer => serverMetadata['issuer'] as String; - - /// Revokes a token. - /// - /// Errors are silently ignored as revocation is best-effort. - Future revoke(String token) async { - try { - await _request('revocation', {'token': token}); - } catch (_) { - // Don't care if revocation fails - } - } - - /// Pre-fetches a DPoP nonce from the token endpoint. - /// - /// This is critical for authorization code exchange because: - /// 1. First token request without nonce → PDS consumes code + returns use_dpop_nonce error - /// 2. Retry with nonce → "Invalid code" because already consumed - /// - /// Solution: Get a nonce BEFORE attempting code exchange. - /// - /// We make a lightweight invalid request that will fail but return a nonce. - /// The server responds with a nonce in the DPoP-Nonce header, which the - /// interceptor automatically caches for subsequent requests. - Future _prefetchDpopNonce() async { - final tokenEndpoint = serverMetadata['token_endpoint'] as String?; - if (tokenEndpoint == null) return; - - final origin = Uri.parse(tokenEndpoint); - final originKey = - '${origin.scheme}://${origin.host}${origin.hasPort ? ':${origin.port}' : ''}'; - - // Clear any stale nonce from previous sessions - try { - await dpopNonces.del(originKey); - if (kDebugMode) { - print('🧹 Cleared stale DPoP nonce from cache'); - } - } catch (_) { - // Ignore deletion errors - } - - if (kDebugMode) { - print('⏱️ Pre-fetch starting at: ${DateTime.now().toIso8601String()}'); - } - - try { - // Make a minimal invalid request to trigger nonce response - // Use an invalid grant_type that will fail fast without side effects - await _dio.post>( - tokenEndpoint, - data: 'grant_type=invalid_prefetch', - options: Options( - headers: {'Content-Type': 'application/x-www-form-urlencoded'}, - validateStatus: (status) => true, // Accept any status - ), - ); - } catch (_) { - // Ignore all errors - we just want the nonce from the response headers - // The DPoP interceptor will have cached it in onError or onResponse - } - - if (kDebugMode) { - print('⏱️ Pre-fetch completed at: ${DateTime.now().toIso8601String()}'); - final cachedNonce = await dpopNonces.get(originKey); - print('🎫 DPoP nonce pre-fetch result:'); - print( - ' Cached nonce: ${cachedNonce != null ? "✅ ${cachedNonce.substring(0, 20)}..." : "❌ not found"}', - ); - } - } - - /// Exchanges an authorization code for tokens. - /// - /// This is called after the user completes authorization and you receive - /// the authorization code in the callback. - /// - /// [code] is the authorization code from the callback. - /// [codeVerifier] is the PKCE code verifier (if PKCE was used). - /// [redirectUri] is the redirect URI used in the authorization request. - /// - /// Returns a [TokenSet] with access token, optional refresh token, and metadata. - /// - /// IMPORTANT: This method verifies the issuer before returning tokens. - /// If verification fails, the access token is automatically revoked. - Future exchangeCode( - String code, { - String? codeVerifier, - String? redirectUri, - }) async { - // CRITICAL: DO NOT pre-fetch! Exchange immediately! - // The pre-fetch adds ~678ms delay, during which the browser re-navigates - // and invalidates the authorization code. We need to exchange within ~270ms. - // If we get a nonce error, we'll handle it via the interceptor (though PDS - // doesn't seem to require nonces for initial token exchange). - - final now = DateTime.now(); - - final tokenResponse = await _request('token', { - 'grant_type': 'authorization_code', - 'redirect_uri': redirectUri ?? clientMetadata.redirectUris.first, - 'code': code, - if (codeVerifier != null) 'code_verifier': codeVerifier, - }); - - try { - // CRITICAL: Verify issuer before trusting the sub - // The tokenResponse MUST always be valid before the "sub" can be trusted - // See: https://atproto.com/specs/oauth - final aud = await _verifyIssuer(tokenResponse['sub'] as String); - - return TokenSet( - aud: aud, - sub: tokenResponse['sub'] as String, - iss: issuer, - scope: tokenResponse['scope'] as String, - refreshToken: tokenResponse['refresh_token'] as String?, - accessToken: tokenResponse['access_token'] as String, - tokenType: tokenResponse['token_type'] as String, - expiresAt: - tokenResponse['expires_in'] != null - ? now - .add(Duration(seconds: tokenResponse['expires_in'] as int)) - .toIso8601String() - : null, - ); - } catch (err) { - // If verification fails, revoke the access token - await revoke(tokenResponse['access_token'] as String); - rethrow; - } - } - - /// Refreshes a token set using the refresh token. - /// - /// [tokenSet] is the current token set with a refresh_token. - /// - /// Returns a new [TokenSet] with fresh tokens. - /// - /// Throws [TokenRefreshError] if refresh fails or no refresh token is available. - /// - /// IMPORTANT: This method verifies the issuer before returning tokens. - Future refresh(TokenSet tokenSet) async { - if (tokenSet.refreshToken == null) { - throw TokenRefreshError(tokenSet.sub, 'No refresh token available'); - } - - // CRITICAL: Verify issuer BEFORE refresh to avoid unnecessary requests - // and ensure the sub is still valid for this issuer - final aud = await _verifyIssuer(tokenSet.sub); - - final now = DateTime.now(); - - final tokenResponse = await _request('token', { - 'grant_type': 'refresh_token', - 'refresh_token': tokenSet.refreshToken, - }); - - return TokenSet( - aud: aud, - sub: tokenSet.sub, - iss: issuer, - scope: tokenResponse['scope'] as String, - refreshToken: tokenResponse['refresh_token'] as String?, - accessToken: tokenResponse['access_token'] as String, - tokenType: tokenResponse['token_type'] as String, - expiresAt: - tokenResponse['expires_in'] != null - ? now - .add(Duration(seconds: tokenResponse['expires_in'] as int)) - .toIso8601String() - : null, - ); - } - - /// Verifies that the sub (DID) is indeed issued by this authorization server. - /// - /// This is CRITICAL for security. We must verify that the DID's PDS - /// is protected by this authorization server before trusting tokens. - /// - /// Returns the user's PDS URL (the resource server). - /// - /// Throws if: - /// - DID resolution fails - /// - Issuer mismatch (user may have switched PDS or attack detected) - Future _verifyIssuer(String sub) async { - final cancelToken = CancelToken(); - final resolved = await oauthResolver - .resolveFromIdentity( - sub, - GetCachedOptions( - noCache: true, - allowStale: false, - cancelToken: cancelToken, - ), - ) - .timeout( - const Duration(seconds: 10), - onTimeout: () { - cancelToken.cancel(); - throw TimeoutException('Issuer verification timed out'); - }, - ); - - if (issuer != resolved.metadata['issuer']) { - // Best case: user switched PDS - // Worst case: attack attempt - // Either way: MUST NOT allow this token to be used - throw FormatException('Issuer mismatch'); - } - - return resolved.pds.toString(); - } - - /// Makes a request to an OAuth endpoint (public API). - /// - /// This is a generic method for making OAuth endpoint requests with proper typing. - /// Currently supports: token, revocation, pushed_authorization_request. - /// - /// [endpoint] is the endpoint name. - /// [payload] is the request body parameters. - /// - /// Returns the parsed JSON response. - /// Throws [OAuthResponseError] if the server returns an error. - Future> request( - String endpoint, - Map payload, - ) async { - return _request(endpoint, payload); - } - - /// Makes a request to an OAuth endpoint (internal implementation). - /// - /// [endpoint] is the endpoint name (e.g., 'token', 'revocation', 'pushed_authorization_request'). - /// [payload] is the request body parameters. - /// - /// Returns the parsed JSON response. - /// Throws [OAuthResponseError] if the server returns an error. - Future> _request( - String endpoint, - Map payload, - ) async { - final url = serverMetadata['${endpoint}_endpoint']; - if (url == null) { - throw StateError('No $endpoint endpoint available'); - } - - final auth = await _clientCredentialsFactory(); - - final fullPayload = {...payload, ...auth.payload.toJson()}; - final encodedData = _wwwFormUrlEncode(fullPayload); - - if (kDebugMode && endpoint == 'token') { - print('🌐 Token exchange HTTP request:'); - print(' ⏱️ Request starting at: ${DateTime.now().toIso8601String()}'); - print(' URL: $url'); - print(' Payload keys: ${fullPayload.keys.toList()}'); - print(' grant_type: ${fullPayload['grant_type']}'); - print(' client_id: ${fullPayload['client_id']}'); - print(' redirect_uri: ${fullPayload['redirect_uri']}'); - print(' code: ${fullPayload['code']?.toString().substring(0, 20)}...'); - print( - ' code_verifier: ${fullPayload['code_verifier']?.toString().substring(0, 20)}...', - ); - print(' Headers: ${auth.headers?.keys.toList() ?? []}'); - } - - try { - final response = await _dio.post>( - url as String, - data: encodedData, - options: Options( - headers: { - if (auth.headers != null) ...auth.headers!, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - ), - ); - - final data = response.data; - if (data == null) { - throw OAuthResponseError(response, {'error': 'empty_response'}); - } - - if (kDebugMode && endpoint == 'token') { - print(' ✅ Token exchange successful!'); - } - - return data; - } on DioException catch (e) { - final response = e.response; - if (response != null) { - if (kDebugMode && endpoint == 'token') { - print(' ❌ Token exchange failed:'); - print(' Status: ${response.statusCode}'); - print(' Response: ${response.data}'); - } - throw OAuthResponseError(response, response.data); - } - rethrow; - } - } - - /// Encodes a map as application/x-www-form-urlencoded. - String _wwwFormUrlEncode(Map payload) { - final entries = payload.entries - .where((e) => e.value != null) - .map((e) => MapEntry(e.key, _stringifyValue(e.value))); - - return Uri(queryParameters: Map.fromEntries(entries)).query; - } - - /// Converts a value to string for form encoding. - String _stringifyValue(dynamic value) { - if (value is String) return value; - if (value is num) return value.toString(); - if (value is bool) return value.toString(); - // For complex types, use JSON encoding - return value.toString(); - } -} - -/// Timeout exception. -class TimeoutException implements Exception { - final String message; - TimeoutException(this.message); - - @override - String toString() => 'TimeoutException: $message'; -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart b/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart deleted file mode 100644 index cb34d14..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/oauth_server_factory.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../runtime/runtime.dart'; -import '../runtime/runtime_implementation.dart'; -import '../types.dart'; -import 'authorization_server_metadata_resolver.dart'; -import 'client_auth.dart'; -import 'oauth_resolver.dart'; -import 'oauth_server_agent.dart'; - -/// Factory for creating OAuth server agents. -/// -/// This factory: -/// 1. Stores common configuration (client metadata, runtime, resolver, etc.) -/// 2. Creates OAuthServerAgent instances for specific issuers -/// 3. Handles both new sessions and restored sessions (with legacy support) -/// -/// The factory pattern allows reusing configuration across multiple agents -/// and simplifies session restoration. -class OAuthServerFactory { - final ClientMetadata clientMetadata; - final Runtime runtime; - final OAuthResolver resolver; - final Dio dio; - final Keyset? keyset; - final DpopNonceCache dpopNonceCache; - - /// Creates a server factory with the given configuration. - /// - /// [clientMetadata] is the validated client metadata. - /// [runtime] provides cryptographic operations. - /// [resolver] handles OAuth metadata discovery. - /// [dio] is the HTTP client. - /// [keyset] is optional (only needed for confidential clients). - /// [dpopNonceCache] stores DPoP nonces per origin. - OAuthServerFactory({ - required this.clientMetadata, - required this.runtime, - required this.resolver, - required this.dio, - this.keyset, - required this.dpopNonceCache, - }); - - /// Creates an OAuth server agent from an issuer URL. - /// - /// This method: - /// 1. Fetches authorization server metadata for the issuer - /// 2. Uses the provided authMethod or negotiates one (for legacy sessions) - /// 3. Creates an OAuthServerAgent with the metadata - /// - /// [issuer] is the authorization server URL. - /// [authMethod] is the authentication method to use. - /// - For new sessions, pass the result of negotiateClientAuthMethod - /// - For legacy sessions (before authMethod was stored), pass 'legacy' - /// and the method will be negotiated automatically - /// [dpopKey] is the DPoP signing key. - /// [options] are optional cache/cancellation options. - /// - /// The 'legacy' authMethod is for backwards compatibility with sessions - /// created before we started storing the authMethod. Support for this - /// may be removed in the future. - /// - /// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied. - Future fromIssuer( - String issuer, - dynamic authMethod, // ClientAuthMethod or 'legacy' - Key dpopKey, [ - GetCachedOptions? options, - ]) async { - final serverMetadata = await resolver.getAuthorizationServerMetadata( - issuer, - options, - ); - - ClientAuthMethod finalAuthMethod; - if (authMethod == 'legacy') { - // Backwards compatibility: compute auth method from metadata - finalAuthMethod = negotiateClientAuthMethod( - serverMetadata, - clientMetadata, - keyset, - ); - } else { - finalAuthMethod = authMethod as ClientAuthMethod; - } - - return fromMetadata(serverMetadata, finalAuthMethod, dpopKey); - } - - /// Creates an OAuth server agent from authorization server metadata. - /// - /// This is useful when you already have the metadata cached. - /// - /// [serverMetadata] is the authorization server metadata. - /// [authMethod] is the authentication method to use. - /// [dpopKey] is the DPoP signing key. - /// - /// Throws [AuthMethodUnsatisfiableError] if auth method cannot be satisfied. - OAuthServerAgent fromMetadata( - Map serverMetadata, - ClientAuthMethod authMethod, - Key dpopKey, - ) { - return OAuthServerAgent( - authMethod: authMethod, - dpopKey: dpopKey, - serverMetadata: serverMetadata, - clientMetadata: clientMetadata, - dpopNonces: dpopNonceCache, - oauthResolver: resolver, - runtime: runtime, - keyset: keyset, - dio: dio, - ); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart b/packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart deleted file mode 100644 index ff0d364..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/protected_resource_metadata_resolver.dart +++ /dev/null @@ -1,196 +0,0 @@ -import 'package:dio/dio.dart'; - -import '../dpop/fetch_dpop.dart'; -import '../util.dart'; -import 'authorization_server_metadata_resolver.dart'; - -/// Cache interface for protected resource metadata. -/// -/// Implementations should store metadata keyed by origin (scheme://host:port). -typedef ProtectedResourceMetadataCache = - SimpleStore>; - -/// Configuration for the protected resource metadata resolver. -class OAuthProtectedResourceMetadataResolverConfig { - /// Whether to allow HTTP (non-HTTPS) resource URLs. - /// - /// Should only be true in development/test environments. - /// Production MUST use HTTPS. - final bool allowHttpResource; - - const OAuthProtectedResourceMetadataResolverConfig({ - this.allowHttpResource = false, - }); -} - -/// Resolves OAuth Protected Resource Metadata via RFC 9728 discovery. -/// -/// This class: -/// 1. Validates resource URLs (must be HTTPS in production) -/// 2. Fetches metadata from `{origin}/.well-known/oauth-protected-resource` -/// 3. Validates the metadata against the spec -/// 4. Verifies resource field matches origin -/// 5. Caches metadata to avoid repeated fetches -/// -/// See: https://www.rfc-editor.org/rfc/rfc9728.html -class OAuthProtectedResourceMetadataResolver { - final ProtectedResourceMetadataCache _cache; - final Dio _dio; - final bool _allowHttpResource; - - /// Creates a resolver with the given cache and HTTP client. - /// - /// [cache] is used to store fetched metadata. Use an in-memory store for - /// testing or a persistent store for production. - /// - /// [dio] is the HTTP client. If not provided, creates a default instance. - /// - /// [config] allows customizing behavior (e.g., allowing HTTP in tests). - OAuthProtectedResourceMetadataResolver( - this._cache, { - Dio? dio, - OAuthProtectedResourceMetadataResolverConfig? config, - }) : _dio = dio ?? Dio(), - _allowHttpResource = config?.allowHttpResource ?? false; - - /// Resolves protected resource metadata for the given resource URL. - /// - /// The [resource] can be a String URL or Uri. Only the origin is used. - /// - /// Returns the complete metadata as a Map. Throws if: - /// - Resource is not a valid URL - /// - Protocol is not HTTP/HTTPS - /// - HTTP is used in production (allowHttpResource = false) - /// - Network request fails - /// - Response is not valid JSON - /// - Metadata validation fails - /// - Resource mismatch detected - /// - /// Example: - /// ```dart - /// final resolver = OAuthProtectedResourceMetadataResolver(cache); - /// final metadata = await resolver.get('https://pds.example.com'); - /// print(metadata['authorization_servers']); - /// ``` - Future> get( - dynamic resource, [ - GetCachedOptions? options, - ]) async { - // Parse URL and extract origin - final uri = resource is Uri ? resource : Uri.parse(resource.toString()); - final protocol = uri.scheme; - final origin = - '${uri.scheme}://${uri.host}${uri.hasPort ? ':${uri.port}' : ''}'; - - // Validate protocol - if (protocol != 'https' && protocol != 'http') { - throw FormatException( - 'Invalid protected resource metadata URL protocol: $protocol', - ); - } - - // Security check: disallow HTTP in production - if (protocol == 'http' && !_allowHttpResource) { - throw FormatException( - 'Unsecure resource metadata URL ($protocol) only allowed in development and test environments', - ); - } - - // Check cache first (unless noCache is set) - if (options?.noCache != true) { - final cached = await _cache.get(origin); - if (cached != null) { - return cached; - } - } - - // Fetch fresh metadata - final metadata = await _fetchMetadata(origin, options); - - // Store in cache - await _cache.set(origin, metadata); - - return metadata; - } - - /// Fetches metadata from the well-known endpoint. - Future> _fetchMetadata( - String origin, - GetCachedOptions? options, - ) async { - final url = - Uri.parse( - origin, - ).replace(path: '/.well-known/oauth-protected-resource').toString(); - - try { - final response = await _dio.get>( - url, - options: Options( - headers: {'accept': 'application/json'}, - followRedirects: false, // response must be 200 OK, no redirects - validateStatus: (status) => status == 200, - ), - cancelToken: options?.cancelToken, - ); - - // Verify content type - final contentType = contentMime( - response.headers.map.map((key, value) => MapEntry(key, value.first)), - ); - - if (contentType != 'application/json') { - throw DioException( - requestOptions: response.requestOptions, - response: response, - type: DioExceptionType.badResponse, - message: 'Unexpected content type for "$url"', - ); - } - - final metadata = response.data; - if (metadata == null) { - throw DioException( - requestOptions: response.requestOptions, - response: response, - type: DioExceptionType.badResponse, - message: 'Empty response body for "$url"', - ); - } - - // Validate metadata - _validateMetadata(metadata, origin); - - return metadata; - } on DioException catch (e) { - if (e.response?.statusCode == 200) { - // Already handled above, rethrow - rethrow; - } - throw DioException( - requestOptions: e.requestOptions, - response: e.response, - type: e.type, - message: - 'Unexpected status code ${e.response?.statusCode ?? 'unknown'} for "$url"', - error: e.error, - ); - } - } - - /// Validates protected resource metadata. - /// - /// Checks: - /// - Resource field matches the expected origin - /// - Authorization servers list is present - void _validateMetadata(Map metadata, String expectedOrigin) { - // Validate resource field - // https://www.rfc-editor.org/rfc/rfc9728.html#section-3.3 - final resource = metadata['resource']; - if (resource != expectedOrigin) { - throw FormatException( - 'Invalid resource: expected "$expectedOrigin", got "$resource"', - ); - } - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart b/packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart deleted file mode 100644 index a87b578..0000000 --- a/packages/atproto_oauth_flutter/lib/src/oauth/validate_client_metadata.dart +++ /dev/null @@ -1,213 +0,0 @@ -import '../constants.dart'; -import '../types.dart'; -import 'client_auth.dart'; - -/// Validates client metadata for OAuth compliance. -/// -/// This function performs comprehensive validation of client metadata to ensure: -/// 1. Client ID is valid (either discoverable HTTPS or loopback) -/// 2. Required ATPROTO scope is present -/// 3. Required response_types and grant_types are present -/// 4. Authentication method is properly configured -/// 5. For private_key_jwt, keyset and JWKS are properly configured -/// -/// The validation enforces ATPROTO OAuth requirements on top of standard OAuth. -/// -/// Returns the validated ClientMetadata. -/// Throws TypeError if validation fails. -ClientMetadata validateClientMetadata( - Map input, - Keyset? keyset, -) { - // Allow passing a keyset and omitting jwks/jwks_uri - // The keyset will be serialized into the metadata - Map enrichedInput = input; - if (input['jwks'] == null && - input['jwks_uri'] == null && - keyset != null && - keyset.size > 0) { - enrichedInput = {...input, 'jwks': keyset.toJSON()}; - } - - // Parse into ClientMetadata - final metadata = ClientMetadata.fromJson(enrichedInput); - - // Validate client ID - final clientId = metadata.clientId; - if (clientId == null) { - throw FormatException('Client metadata must include client_id'); - } - - if (clientId.startsWith('http:')) { - // Loopback client ID (for development) - _assertOAuthLoopbackClientId(clientId); - } else { - // Discoverable client ID (production) - _assertOAuthDiscoverableClientId(clientId); - } - - // Validate scope includes "atproto" - final scopes = metadata.scope?.split(' ') ?? []; - if (!scopes.contains('atproto')) { - throw FormatException('Client metadata must include the "atproto" scope'); - } - - // Validate response_types - if (!metadata.responseTypes.contains('code')) { - throw FormatException('"response_types" must include "code"'); - } - - // Validate grant_types - if (!metadata.grantTypes.contains('authorization_code')) { - throw FormatException('"grant_types" must include "authorization_code"'); - } - - // Validate authentication method - final method = metadata.tokenEndpointAuthMethod; - final methodAlg = metadata.tokenEndpointAuthSigningAlg; - - switch (method) { - case 'none': - if (methodAlg != null) { - throw FormatException( - '"token_endpoint_auth_signing_alg" must not be provided when ' - '"token_endpoint_auth_method" is "$method"', - ); - } - break; - - case 'private_key_jwt': - if (methodAlg == null) { - throw FormatException( - '"token_endpoint_auth_signing_alg" must be provided when ' - '"token_endpoint_auth_method" is "$method"', - ); - } - - if (keyset == null) { - throw FormatException( - 'Client authentication method "$method" requires a keyset', - ); - } - - // Validate signing keys - final signingKeys = keyset.keys.where((key) => key.kid != null).toList(); - - if (signingKeys.isEmpty) { - throw FormatException( - 'Client authentication method "$method" requires at least one ' - 'active signing key with a "kid" property', - ); - } - - if (!signingKeys.any((key) => key.algorithms.contains(fallbackAlg))) { - throw FormatException( - 'Client authentication method "$method" requires at least one ' - 'active "$fallbackAlg" signing key', - ); - } - - // Validate JWKS - if (metadata.jwks != null) { - // Ensure all signing keys are in the JWKS - final jwksKeys = (metadata.jwks!['keys'] as List?) ?? []; - for (final key in signingKeys) { - final found = jwksKeys.any((k) { - if (k is! Map) return false; - final revoked = k['revoked'] as bool?; - return k['kid'] == key.kid && revoked != true; - }); - - if (!found) { - throw FormatException( - 'Missing or inactive key "${key.kid}" in jwks. ' - 'Make sure that every signing key of the Keyset is declared as ' - 'an active key in the Metadata\'s JWKS.', - ); - } - } - } else if (metadata.jwksUri != null) { - // JWKS URI is acceptable, but we can't validate it here - // (we don't want to download the file during validation) - } else { - throw FormatException( - 'Client authentication method "$method" requires a JWKS', - ); - } - break; - - default: - throw FormatException( - 'Unsupported "token_endpoint_auth_method" value: $method', - ); - } - - return metadata; -} - -/// Validates that a client ID is a valid discoverable client ID. -/// -/// A discoverable client ID must be an HTTPS URL that can be dereferenced -/// to get the client metadata document. -/// -/// See: https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/ -void _assertOAuthDiscoverableClientId(String clientId) { - final uri = Uri.tryParse(clientId); - - if (uri == null) { - throw FormatException('Invalid client_id URL: $clientId'); - } - - if (uri.scheme != 'https') { - throw FormatException('Discoverable client_id must use HTTPS: $clientId'); - } - - if (uri.hasFragment) { - throw FormatException( - 'Discoverable client_id must not contain a fragment: $clientId', - ); - } - - // Validate it's a valid URL - if (!uri.hasAuthority) { - throw FormatException('Invalid discoverable client_id URL: $clientId'); - } -} - -/// Validates that a client ID is a valid loopback client ID. -/// -/// A loopback client ID is used for development/testing and must be: -/// - An HTTP URL (not HTTPS) -/// - Using localhost or 127.0.0.1 -/// - Optionally with a port -/// -/// See: https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 -void _assertOAuthLoopbackClientId(String clientId) { - final uri = Uri.tryParse(clientId); - - if (uri == null) { - throw FormatException('Invalid client_id URL: $clientId'); - } - - if (uri.scheme != 'http') { - throw FormatException( - 'Loopback client_id must use HTTP (not HTTPS): $clientId', - ); - } - - final host = uri.host.toLowerCase(); - if (host != 'localhost' && - host != '127.0.0.1' && - host != '[::1]' && - host != '::1') { - throw FormatException( - 'Loopback client_id must use localhost or 127.0.0.1: $clientId', - ); - } - - if (uri.hasFragment) { - throw FormatException( - 'Loopback client_id must not contain a fragment: $clientId', - ); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/platform/README.md b/packages/atproto_oauth_flutter/lib/src/platform/README.md deleted file mode 100644 index 2d13469..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/README.md +++ /dev/null @@ -1,330 +0,0 @@ -# Flutter Platform Layer - -This directory contains Flutter-specific implementations of the atproto OAuth client. - -## Overview - -The platform layer provides concrete implementations of all the abstract interfaces needed for OAuth to work on Flutter: - -1. **Storage** (`flutter_stores.dart`) - Secure session storage and caching -2. **Cryptography** (`flutter_runtime.dart`) - Key generation, hashing, random values -3. **Key Management** (`flutter_key.dart`) - EC key implementation with pointycastle -4. **High-level API** (`flutter_oauth_client.dart`) - Easy-to-use Flutter OAuth client - -## Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ FlutterOAuthClient │ -│ (High-level API) │ -└─────────────────────────────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ OAuthClient │ -│ (Core OAuth logic) │ -└─────────────────────────────────────────────────────────────┘ - │ - ┌───────────────────┼───────────────────┐ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ Storage │ │ Runtime │ │ Key │ -│ (secure │ │ (crypto) │ │ (signing) │ -│ storage) │ │ │ │ │ -└─────────────┘ └─────────────┘ └─────────────┘ - │ │ │ - ▼ ▼ ▼ -┌─────────────┐ ┌─────────────┐ ┌─────────────┐ -│ flutter_ │ │ crypto/ │ │ pointycastle│ -│ secure_ │ │ Random. │ │ (ECDSA) │ -│ storage │ │ secure() │ │ │ -└─────────────┘ └─────────────┘ └─────────────┘ -``` - -## Files - -### `flutter_stores.dart` - -Implements storage and caching: - -- **FlutterSessionStore**: Persists OAuth sessions in secure storage - - iOS: Keychain - - Android: EncryptedSharedPreferences - - Stores tokens, DPoP keys, and auth methods - -- **FlutterStateStore**: Ephemeral OAuth state (in-memory) - - PKCE verifiers - - State parameters - - Application state - -- **Cache Implementations**: In-memory caches with TTL - - `InMemoryAuthorizationServerMetadataCache`: OAuth server metadata (1 min TTL) - - `InMemoryProtectedResourceMetadataCache`: Resource server metadata (1 min TTL) - - `InMemoryDpopNonceCache`: DPoP nonces (10 min TTL) - - `FlutterDidCache`: DID documents (1 min TTL) - - `FlutterHandleCache`: Handle → DID mappings (1 min TTL) - -### `flutter_runtime.dart` - -Implements cryptographic operations: - -- **FlutterRuntime**: Platform-specific crypto implementation - - `createKey`: EC key generation (ES256/ES384/ES512/ES256K) - - `digest`: SHA-256/384/512 hashing - - `getRandomValues`: Cryptographically secure random bytes - - `requestLock`: Local (in-memory) locking for token refresh - -Uses: -- `crypto` package for SHA hashing -- `Random.secure()` for randomness -- `utils/lock.dart` for concurrency control - -### `flutter_key.dart` - -Implements EC key management: - -- **FlutterKey**: Elliptic Curve key for JWT signing - - Supports ES256, ES384, ES512, ES256K - - Uses `pointycastle` for ECDSA operations - - Implements `Key` interface from runtime layer - - Serializable (for session storage) - -Features: -- Secure key generation with `FortunaRandom` -- JWT signing (compact format) -- JWK representation (public and private) -- Key reconstruction from JSON - -### `flutter_oauth_client.dart` - -High-level Flutter API: - -- **FlutterOAuthClient**: Easy-to-use OAuth client - - Pre-configured storage and caching - - Automatic FlutterWebAuth2 integration - - Simplified sign-in flow - - Session management helpers - -Key method: -```dart -// One-liner sign in! -final session = await client.signIn('alice.bsky.social'); -``` - -This handles: -1. Authorization URL generation -2. Browser launch (FlutterWebAuth2) -3. Callback handling -4. Token exchange -5. Session storage - -## Security Features - -### 1. Secure Storage - -Tokens are **never** stored in plain text: - -- **iOS**: Stored in Keychain with device encryption -- **Android**: EncryptedSharedPreferences with AES-256 - -### 2. DPoP (Demonstrating Proof of Possession) - -Tokens are cryptographically bound to EC keys: - -- Prevents token theft (stolen tokens are useless without the key) -- Keys stored alongside tokens in secure storage -- Every API request includes a signed DPoP proof - -### 3. PKCE (Proof Key for Code Exchange) - -Protects authorization codes from interception: - -- Random code verifier generated for each flow -- Challenge sent to server (SHA-256 hash of verifier) -- Verifier required to exchange code for tokens - -### 4. Concurrency Control - -Prevents race conditions in token refresh: - -- Local lock ensures only one refresh at a time -- Reduces chances of using refresh token twice -- Handles concurrent requests gracefully - -### 5. Automatic Cleanup - -Sessions are automatically deleted on errors: - -- Token refresh failures -- Invalid token errors -- Auth method unsatisfiable errors -- Revocation (local and remote) - -## Usage - -### Basic Usage - -```dart -import 'package:atproto_oauth_flutter/atproto_oauth_flutter.dart'; - -// Initialize -final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'https://example.com/client-metadata.json', - redirectUris: ['myapp://oauth/callback'], - ), -); - -// Sign in -final session = await client.signIn('alice.bsky.social'); - -// Use session -print('Signed in as: ${session.sub}'); - -// Restore later -final restored = await client.restore(session.sub); - -// Sign out -await client.revoke(session.sub); -``` - -### Custom Configuration - -```dart -final client = FlutterOAuthClient( - clientMetadata: ClientMetadata( - clientId: 'https://example.com/client-metadata.json', - redirectUris: ['myapp://oauth/callback'], - ), - - // Custom secure storage - secureStorage: FlutterSecureStorage( - aOptions: AndroidOptions( - encryptedSharedPreferences: true, - ), - ), - - // Development mode - allowHttp: true, - - // Custom PLC directory - plcDirectoryUrl: 'https://plc.example.com', -); -``` - -## Testing - -The platform layer is designed to be testable: - -1. **Mock Storage**: Provide test implementation of `SessionStore` -2. **Mock Runtime**: Provide test implementation of `RuntimeImplementation` -3. **Mock Keys**: Use fixed test keys instead of random generation - -Example: - -```dart -// Test storage that uses in-memory map -class TestSessionStore implements SessionStore { - final Map _store = {}; - - @override - Future get(String key, {CancellationToken? signal}) async { - return _store[key]; - } - - @override - Future set(String key, Session value) async { - _store[key] = value; - } - - // ... etc -} - -// Use in tests -final client = OAuthClient( - OAuthClientOptions( - // ... other options - sessionStore: TestSessionStore(), - ), -); -``` - -## Platform Setup - -### iOS - -Add URL scheme to `Info.plist`: - -```xml -CFBundleURLTypes - - - CFBundleURLSchemes - - myapp - - - -``` - -### Android - -Add intent filter to `AndroidManifest.xml`: - -```xml - - - - - - -``` - -## Dependencies - -- `flutter_secure_storage: ^9.2.2` - Secure token storage -- `flutter_web_auth_2: ^4.1.0` - Browser-based OAuth flow -- `pointycastle: ^3.9.1` - Elliptic Curve cryptography -- `crypto: ^3.0.3` - SHA hashing - -## Known Limitations - -### 1. Key Serialization - -Currently, DPoP keys are regenerated on each app restart. This works but has drawbacks: - -- Tokens bound to old keys become invalid (require refresh) -- Slight performance impact on session restoration - -**Fix**: Implement proper `Key` serialization in `flutter_key.dart`: -- Add `toJson()` method that includes private key components -- Add `fromJson()` factory that reconstructs the key -- Store serialized keys in session storage - -### 2. Local Lock Only - -The lock implementation is in-memory and doesn't work across: -- Multiple isolates -- Multiple processes -- Multiple app instances - -For most Flutter apps, this is fine. For advanced use cases, implement a platform-specific lock. - -### 3. Cache TTLs - -Cache TTLs are fixed (1 minute for most caches). Consider making these configurable if your app has different caching requirements. - -## Future Improvements - -1. **Key Persistence**: Implement proper key serialization (see above) -2. **Platform Locks**: Add iOS/Android native lock implementations -3. **Configurable TTLs**: Allow cache TTL customization -4. **Background Refresh**: Support token refresh in background -5. **Biometric Auth**: Optional biometric unlock for sessions -6. **Migration Helpers**: Tools for migrating from other OAuth libraries - -## See Also - -- [Example usage](../../example/flutter_oauth_example.dart) -- [Main library docs](../../atproto_oauth_flutter.dart) -- [Core OAuth client](../client/oauth_client.dart) diff --git a/packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart b/packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart deleted file mode 100644 index d61d701..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/flutter_key.dart +++ /dev/null @@ -1,435 +0,0 @@ -import 'dart:convert'; -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:pointycastle/export.dart' as pointycastle; - -import '../runtime/runtime_implementation.dart'; - -/// Flutter implementation of Key using pointycastle for cryptographic operations. -/// -/// Supports EC keys with the following algorithms: -/// - ES256 (P-256/secp256r1) -/// - ES384 (P-384/secp384r1) -/// - ES512 (P-521/secp521r1) - Note: P-521, not P-512 -/// - ES256K (secp256k1) -/// -/// This class handles: -/// - Key generation with secure randomness -/// - JWT signing (ES256/ES384/ES512/ES256K) -/// - JWK representation (public and private components) -/// - Serialization/deserialization for session storage -class FlutterKey implements Key { - /// The EC private key (contains both private and public components) - final pointycastle.ECPrivateKey privateKey; - - /// The EC public key - final pointycastle.ECPublicKey publicKey; - - /// The algorithm this key supports - final String algorithm; - - /// Optional key ID - final String? _kid; - - /// Creates a FlutterKey from EC key components. - FlutterKey({ - required this.privateKey, - required this.publicKey, - required this.algorithm, - String? kid, - }) : _kid = kid; - - @override - List get algorithms => [algorithm]; - - @override - String? get kid => _kid; - - @override - String get usage => 'sign'; - - @override - Map? get bareJwk { - // Return public key components only (no private key 'd') - final jwk = _ecPublicKeyToJwk(publicKey, algorithm); - if (_kid != null) { - jwk['kid'] = _kid; - } - return jwk; - } - - /// Full JWK including private key components. - /// - /// WARNING: This contains sensitive key material. Never log or expose. - /// Only use for secure storage. - Map get privateJwk { - final jwk = _ecPrivateKeyToJwk(privateKey, publicKey, algorithm); - if (_kid != null) { - jwk['kid'] = _kid; - } - return jwk; - } - - @override - Future createJwt( - Map header, - Map payload, - ) async { - // Build JWT header - final jwtHeader = { - 'typ': 'JWT', - 'alg': algorithm, - ...header, - }; - if (_kid != null) { - jwtHeader['kid'] = _kid; - } - - // Encode header and payload - final headerB64 = _base64UrlEncode(utf8.encode(json.encode(jwtHeader))); - final payloadB64 = _base64UrlEncode(utf8.encode(json.encode(payload))); - - // Create signing input - final signingInput = '$headerB64.$payloadB64'; - final signingBytes = utf8.encode(signingInput); - - // Sign with appropriate algorithm - final signature = _signEcdsa(signingBytes, privateKey, algorithm); - - // Encode signature - final signatureB64 = _base64UrlEncode(signature); - - // Return compact JWT - return '$signingInput.$signatureB64'; - } - - /// Generates a new FlutterKey for the given algorithms. - /// - /// Returns a key supporting the first compatible algorithm from the list. - /// - /// Throws [UnsupportedError] if no compatible algorithm is found. - static Future generate(List algs) async { - // Try algorithms in order - for (final alg in algs) { - switch (alg) { - case 'ES256': - return _generateECKey('ES256', 'P-256'); - case 'ES384': - return _generateECKey('ES384', 'P-384'); - case 'ES512': - return _generateECKey('ES512', 'P-521'); // Note: P-521, not P-512 - case 'ES256K': - return _generateECKey('ES256K', 'secp256k1'); - } - } - - throw UnsupportedError( - 'No supported algorithm found in: ${algs.join(", ")}', - ); - } - - /// Reconstructs a FlutterKey from serialized JWK data. - /// - /// This is used when restoring sessions from storage. - factory FlutterKey.fromJwk(Map jwk) { - final kty = jwk['kty'] as String?; - if (kty != 'EC') { - throw FormatException('Unsupported key type: $kty'); - } - - final crv = jwk['crv'] as String?; - final alg = jwk['alg'] as String?; - final kid = jwk['kid'] as String?; - - if (crv == null || alg == null) { - throw FormatException('Missing required JWK fields'); - } - - // Parse key components - final x = _base64UrlDecode(jwk['x'] as String); - final y = _base64UrlDecode(jwk['y'] as String); - final d = jwk['d'] != null ? _base64UrlDecode(jwk['d'] as String) : null; - - if (d == null) { - throw FormatException('Private key component (d) is required'); - } - - // Get curve - final curve = _getCurveForName(crv); - - // Reconstruct public key - final publicKey = pointycastle.ECPublicKey( - curve.curve.createPoint(_bytesToBigInt(x), _bytesToBigInt(y)), - curve, - ); - - // Reconstruct private key - final privateKey = pointycastle.ECPrivateKey(_bytesToBigInt(d), curve); - - return FlutterKey( - privateKey: privateKey, - publicKey: publicKey, - algorithm: alg, - kid: kid, - ); - } - - /// Serializes this key to JSON (for session storage). - /// - /// WARNING: Contains private key material. Store securely. - Map toJson() => privateJwk; - - // ============================================================================ - // Private helper methods - // ============================================================================ - - /// Generates an EC key pair for the given algorithm and curve. - static Future _generateECKey( - String algorithm, - String curveName, - ) async { - final curve = _getCurveForName(curveName); - - // Create secure random generator - final secureRandom = pointycastle.FortunaRandom(); - final random = Random.secure(); - final seeds = List.generate(32, (_) => random.nextInt(256)); - secureRandom.seed(pointycastle.KeyParameter(Uint8List.fromList(seeds))); - - // Generate key pair - final keyGen = pointycastle.ECKeyGenerator(); - keyGen.init( - pointycastle.ParametersWithRandom( - pointycastle.ECKeyGeneratorParameters(curve), - secureRandom, - ), - ); - - final keyPair = keyGen.generateKeyPair(); - final privateKey = keyPair.privateKey as pointycastle.ECPrivateKey; - final publicKey = keyPair.publicKey as pointycastle.ECPublicKey; - - return FlutterKey( - privateKey: privateKey, - publicKey: publicKey, - algorithm: algorithm, - ); - } - - /// Gets the EC domain parameters for a given curve name. - static pointycastle.ECDomainParameters _getCurveForName(String name) { - // Use pointycastle's standard curve implementations - switch (name) { - case 'P-256': - case 'prime256v1': - case 'secp256r1': - return pointycastle.ECCurve_secp256r1(); - case 'P-384': - case 'secp384r1': - return pointycastle.ECCurve_secp384r1(); - case 'P-521': - case 'secp521r1': - return pointycastle.ECCurve_secp521r1(); - case 'secp256k1': - return pointycastle.ECCurve_secp256k1(); - default: - throw UnsupportedError('Unsupported curve: $name'); - } - } - - /// Gets the curve name for JWK representation. - static String _getCurveName(String algorithm) { - switch (algorithm) { - case 'ES256': - return 'P-256'; - case 'ES384': - return 'P-384'; - case 'ES512': - return 'P-521'; - case 'ES256K': - return 'secp256k1'; - default: - throw UnsupportedError('Unsupported algorithm: $algorithm'); - } - } - - /// Gets the hash algorithm for signing. - static String _getHashAlgorithm(String algorithm) { - switch (algorithm) { - case 'ES256': - case 'ES256K': - return 'SHA-256'; - case 'ES384': - return 'SHA-384'; - case 'ES512': - return 'SHA-512'; - default: - throw UnsupportedError('Unsupported algorithm: $algorithm'); - } - } - - /// Signs data using ECDSA with deterministic signatures (RFC 6979). - /// - /// This uses deterministic ECDSA which doesn't require a source of randomness, - /// making it more secure and avoiding SecureRandom initialization issues. - static Uint8List _signEcdsa( - List data, - pointycastle.ECPrivateKey privateKey, - String algorithm, - ) { - // Get the appropriate hash algorithm for this signing algorithm - final hashAlg = _getHashAlgorithm(algorithm); - - // Build deterministic ECDSA signer name (e.g., "SHA-256/DET-ECDSA") - final signerName = '$hashAlg/DET-ECDSA'; - - // Use deterministic ECDSA signer (RFC 6979) - no randomness required! - final signer = pointycastle.Signer(signerName); - signer.init( - true, // signing mode - pointycastle.PrivateKeyParameter(privateKey), - ); - - // Sign the data (signer will hash it internally) - final signature = - signer.generateSignature(Uint8List.fromList(data)) - as pointycastle.ECSignature; - - // Encode as IEEE P1363 format (r || s) - final r = _bigIntToBytes(signature.r, _getSignatureLength(algorithm)); - final s = _bigIntToBytes(signature.s, _getSignatureLength(algorithm)); - - return Uint8List.fromList([...r, ...s]); - } - - /// Creates a pointycastle Digest for the given hash algorithm. - static pointycastle.Digest _createDigest(String algorithm) { - switch (algorithm) { - case 'SHA-256': - return pointycastle.SHA256Digest(); - case 'SHA-384': - return pointycastle.SHA384Digest(); - case 'SHA-512': - return pointycastle.SHA512Digest(); - default: - throw UnsupportedError('Unsupported hash: $algorithm'); - } - } - - /// Gets the signature length in bytes for the algorithm. - static int _getSignatureLength(String algorithm) { - switch (algorithm) { - case 'ES256': - case 'ES256K': - return 32; - case 'ES384': - return 48; - case 'ES512': - return 66; // P-521 uses 66 bytes per component - default: - throw UnsupportedError('Unsupported algorithm: $algorithm'); - } - } - - /// Converts an EC public key to JWK format. - static Map _ecPublicKeyToJwk( - pointycastle.ECPublicKey publicKey, - String algorithm, - ) { - final q = publicKey.Q!; - final curve = _getCurveName(algorithm); - - return { - 'kty': 'EC', - 'crv': curve, - 'x': _base64UrlEncode(_bigIntToBytes(q.x!.toBigInteger()!)), - 'y': _base64UrlEncode(_bigIntToBytes(q.y!.toBigInteger()!)), - 'alg': algorithm, - 'use': 'sig', - 'key_ops': ['sign'], - }; - } - - /// Converts an EC private key to JWK format (includes private component). - static Map _ecPrivateKeyToJwk( - pointycastle.ECPrivateKey privateKey, - pointycastle.ECPublicKey publicKey, - String algorithm, - ) { - final jwk = _ecPublicKeyToJwk(publicKey, algorithm); - jwk['d'] = _base64UrlEncode(_bigIntToBytes(privateKey.d!)); - return jwk; - } - - /// Converts a BigInt to bytes with optional padding. - static Uint8List _bigIntToBytes(BigInt number, [int? length]) { - var bytes = _encodeBigInt(number); - - if (length != null) { - if (bytes.length > length) { - // Remove leading zeros - bytes = bytes.sublist(bytes.length - length); - } else if (bytes.length < length) { - // Add leading zeros - final padded = Uint8List(length); - padded.setRange(length - bytes.length, length, bytes); - bytes = padded; - } - } - - return bytes; - } - - /// Encodes a BigInt as bytes (unsigned, big-endian). - static Uint8List _encodeBigInt(BigInt number) { - // Handle zero - if (number == BigInt.zero) { - return Uint8List.fromList([0]); - } - - // Handle negative (should not happen for EC keys) - if (number.isNegative) { - throw ArgumentError('Cannot encode negative BigInt'); - } - - // Convert to bytes - final bytes = []; - var n = number; - while (n > BigInt.zero) { - bytes.insert(0, (n & BigInt.from(0xff)).toInt()); - n = n >> 8; - } - - return Uint8List.fromList(bytes); - } - - /// Converts bytes to BigInt (unsigned, big-endian). - static BigInt _bytesToBigInt(List bytes) { - var result = BigInt.zero; - for (var byte in bytes) { - result = (result << 8) | BigInt.from(byte); - } - return result; - } - - /// Base64url encodes bytes (no padding). - static String _base64UrlEncode(List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); - } - - /// Base64url decodes a string. - static Uint8List _base64UrlDecode(String str) { - // Add padding if needed - var s = str; - switch (s.length % 4) { - case 2: - s += '=='; - break; - case 3: - s += '='; - break; - } - return base64Url.decode(s); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart b/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart deleted file mode 100644 index 317718b..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_client.dart +++ /dev/null @@ -1,302 +0,0 @@ -import 'package:dio/dio.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; -import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; - -import '../client/oauth_client.dart'; -import '../session/oauth_session.dart'; -import 'flutter_runtime.dart'; -import 'flutter_stores.dart'; - -/// Flutter-specific OAuth client with sensible defaults. -/// -/// This is a high-level wrapper around [OAuthClient] that provides: -/// - Automatic storage configuration (flutter_secure_storage) -/// - Platform-specific crypto (pointycastle + crypto package) -/// - In-memory caching with TTL -/// - Convenient sign-in flow (authorize + FlutterWebAuth2 + callback) -/// - Session management (restore, revoke) -/// -/// Example usage: -/// ```dart -/// // Initialize client -/// final client = FlutterOAuthClient( -/// clientMetadata: ClientMetadata( -/// clientId: 'https://example.com/client-metadata.json', -/// redirectUris: ['myapp://oauth/callback'], -/// scope: 'atproto transition:generic', -/// ), -/// ); -/// -/// // Sign in with handle -/// try { -/// final session = await client.signIn('alice.bsky.social'); -/// print('Signed in as: ${session.sub}'); -/// -/// // Use the session for authenticated requests -/// final agent = session.pdsClient; -/// // ... make API calls -/// } catch (e) { -/// print('Sign in failed: $e'); -/// } -/// -/// // Later: restore session -/// try { -/// final session = await client.restore('did:plc:abc123'); -/// print('Session restored'); -/// } catch (e) { -/// print('Session restoration failed: $e'); -/// } -/// -/// // Sign out -/// await client.revoke('did:plc:abc123'); -/// ``` -class FlutterOAuthClient extends OAuthClient { - /// Creates a FlutterOAuthClient with Flutter-specific defaults. - /// - /// Parameters: - /// - [clientMetadata]: Client configuration (required) - /// - [responseMode]: OAuth response mode (default: query) - /// - [allowHttp]: Allow HTTP for testing (default: false) - /// - [secureStorage]: Custom secure storage instance (optional) - /// - [dio]: Custom HTTP client (optional) - /// - [plcDirectoryUrl]: Custom PLC directory URL (optional) - /// - [handleResolverUrl]: Custom handle resolver URL (optional) - /// - /// Throws [FormatException] if client metadata is invalid. - FlutterOAuthClient({ - required ClientMetadata clientMetadata, - OAuthResponseMode responseMode = OAuthResponseMode.query, - bool allowHttp = false, - FlutterSecureStorage? secureStorage, - Dio? dio, - String? plcDirectoryUrl, - String? handleResolverUrl, - }) : super( - OAuthClientOptions( - // Config - responseMode: responseMode, - clientMetadata: clientMetadata.toJson(), - keyset: null, // Mobile apps are public clients - allowHttp: allowHttp, - - // Storage (Flutter-specific) - stateStore: FlutterStateStore(), - sessionStore: FlutterSessionStore(secureStorage), - - // Caches (in-memory with TTL) - authorizationServerMetadataCache: - InMemoryAuthorizationServerMetadataCache(), - protectedResourceMetadataCache: - InMemoryProtectedResourceMetadataCache(), - dpopNonceCache: InMemoryDpopNonceCache(), - didCache: FlutterDidCache(), - handleCache: FlutterHandleCache(), - - // Platform implementation - runtimeImplementation: const FlutterRuntime(), - - // HTTP client - dio: dio, - - // Optional overrides - plcDirectoryUrl: plcDirectoryUrl, - handleResolverUrl: handleResolverUrl, - ), - ); - - /// Sign in with an atProto handle, DID, or URL. - /// - /// This is a convenience method that: - /// 1. Initiates authorization flow ([authorize]) - /// 2. Opens browser with FlutterWebAuth2 - /// 3. Handles OAuth callback - /// 4. Returns authenticated session - /// - /// The [input] can be: - /// - An atProto handle: "alice.bsky.social" - /// - A DID: "did:plc:..." - /// - A PDS URL: "https://pds.example.com" - /// - An authorization server URL: "https://auth.example.com" - /// - /// The [options] can specify: - /// - redirectUri: Override default redirect URI - /// - state: Application state to preserve - /// - scope: Override default scope - /// - Other OIDC parameters (prompt, display, etc.) - /// - /// Returns an [OAuthSession] with authenticated access. - /// - /// Throws: - /// - [FormatException] if parameters are invalid - /// - [OAuthResolverError] if identity resolution fails - /// - [OAuthCallbackError] if authentication fails - /// - [Exception] if user cancels (flutter_web_auth_2 throws PlatformException) - /// - /// Example: - /// ```dart - /// // Simple sign in - /// final session = await client.signIn('alice.bsky.social'); - /// - /// // With custom state - /// final session = await client.signIn( - /// 'alice.bsky.social', - /// options: AuthorizeOptions(state: 'my-app-state'), - /// ); - /// ``` - Future signIn( - String input, { - AuthorizeOptions? options, - CancelToken? cancelToken, - }) async { - // CRITICAL: Use HTTPS redirect URI for OAuth (prevents browser retry) - // but listen for CUSTOM SCHEME in FlutterWebAuth2 (only custom schemes can be intercepted) - // The HTTPS page will redirect to custom scheme, triggering the callback - final redirectUri = - options?.redirectUri ?? clientMetadata.redirectUris.first; - - if (!clientMetadata.redirectUris.contains(redirectUri)) { - throw FormatException('Invalid redirect_uri: $redirectUri'); - } - - // Find the custom scheme redirect URI from the list - // FlutterWebAuth2 can ONLY intercept custom schemes, not HTTPS - final customSchemeUri = clientMetadata.redirectUris.firstWhere( - (uri) => !uri.startsWith('http://') && !uri.startsWith('https://'), - orElse: - () => redirectUri, // Fallback to primary if no custom scheme found - ); - - final callbackUrlScheme = _extractScheme(customSchemeUri); - - // Step 1: Start OAuth authorization flow - final authUrl = await authorize( - input, - options: - options != null - ? AuthorizeOptions( - redirectUri: redirectUri, - state: options.state, - scope: options.scope, - nonce: options.nonce, - dpopJkt: options.dpopJkt, - maxAge: options.maxAge, - claims: options.claims, - uiLocales: options.uiLocales, - idTokenHint: options.idTokenHint, - display: options.display ?? 'touch', // Mobile-friendly default - prompt: options.prompt, - authorizationDetails: options.authorizationDetails, - ) - : AuthorizeOptions( - redirectUri: redirectUri, - display: 'touch', // Mobile-friendly default - ), - cancelToken: cancelToken, - ); - - // Step 2: Open browser for user authentication - if (kDebugMode) { - print('🔐 Opening browser for OAuth...'); - print(' Auth URL: $authUrl'); - print(' OAuth redirect URI (PDS will redirect here): $redirectUri'); - print( - ' FlutterWebAuth2 callback scheme (listening for): $callbackUrlScheme', - ); - } - - String? callbackUrl; - try { - if (kDebugMode) { - print('📱 Calling FlutterWebAuth2.authenticate()...'); - } - - callbackUrl = await FlutterWebAuth2.authenticate( - url: authUrl.toString(), - callbackUrlScheme: callbackUrlScheme, - options: const FlutterWebAuth2Options( - // Use ephemeral session to force browser to close immediately - // This prevents browser retry that can invalidate the authorization code - preferEphemeral: true, - timeout: 300, // 5 minutes timeout - ), - ); - - if (kDebugMode) { - print('✅ FlutterWebAuth2 returned successfully!'); - print(' Callback URL: $callbackUrl'); - print( - ' ⏱️ Callback received at: ${DateTime.now().toIso8601String()}', - ); - } - } catch (e, stackTrace) { - if (kDebugMode) { - print('❌ FlutterWebAuth2.authenticate() threw an error:'); - print(' Error type: ${e.runtimeType}'); - print(' Error message: $e'); - print(' Stack trace: $stackTrace'); - } - rethrow; - } - - // Step 3: Parse callback URL parameters - final uri = Uri.parse(callbackUrl); - final params = - responseMode == OAuthResponseMode.fragment - ? _parseFragment(uri.fragment) - : Map.from(uri.queryParameters); - - if (kDebugMode) { - print('🔄 Parsing callback parameters...'); - print(' Response mode: $responseMode'); - print(' Callback params: $params'); - } - - // Step 4: Complete OAuth flow - if (kDebugMode) { - print('📞 Calling callback() to exchange code for tokens...'); - print(' Redirect URI: $redirectUri'); - } - - final result = await callback( - params, - options: CallbackOptions(redirectUri: redirectUri), - cancelToken: cancelToken, - ); - - if (kDebugMode) { - print('✅ Token exchange successful!'); - print(' Session DID: ${result.session.sub}'); - } - - return result.session; - } - - /// Extracts the URL scheme from a redirect URI. - /// - /// Examples: - /// - "myapp://oauth/callback" → "myapp" - /// - "https://example.com/callback" → "https" - String _extractScheme(String redirectUri) { - final uri = Uri.parse(redirectUri); - return uri.scheme; - } - - /// Parses URL fragment into a parameter map. - /// - /// The fragment may start with '#' which we strip. - Map _parseFragment(String fragment) { - // Remove leading '#' if present - final clean = fragment.startsWith('#') ? fragment.substring(1) : fragment; - if (clean.isEmpty) return {}; - - final params = {}; - for (final pair in clean.split('&')) { - final parts = pair.split('='); - if (parts.length == 2) { - params[Uri.decodeComponent(parts[0])] = Uri.decodeComponent(parts[1]); - } - } - return params; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart b/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart deleted file mode 100644 index 2c86488..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/flutter_oauth_router_helper.dart +++ /dev/null @@ -1,141 +0,0 @@ -/// Helper for configuring Flutter routers to work with OAuth callbacks. -/// -/// When using declarative routing packages (go_router, auto_route, etc.), -/// OAuth callback deep links may be intercepted before flutter_web_auth_2 -/// can handle them. This helper provides utilities to configure your router -/// to ignore OAuth callback URIs. -/// -/// ## go_router Example -/// -/// ```dart -/// final router = GoRouter( -/// routes: [...], -/// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( -/// customSchemes: ['com.example.myapp'], -/// ), -/// ); -/// ``` -/// -/// ## Manual Configuration -/// -/// ```dart -/// final router = GoRouter( -/// routes: [...], -/// redirect: (context, state) { -/// if (FlutterOAuthRouterHelper.isOAuthCallback( -/// state.uri, -/// customSchemes: ['com.example.myapp'], -/// )) { -/// return null; // Let flutter_web_auth_2 handle it -/// } -/// return null; // Normal routing -/// }, -/// ); -/// ``` -library; - -import 'dart:async'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; - -/// Helper class for configuring routers to work with OAuth callbacks. -class FlutterOAuthRouterHelper { - /// Checks if a URI is an OAuth callback that should be ignored by the router. - /// - /// Returns `true` if the URI uses a custom scheme from [customSchemes], - /// indicating it's an OAuth callback deep link that flutter_web_auth_2 - /// should handle. - /// - /// Example: - /// ```dart - /// if (FlutterOAuthRouterHelper.isOAuthCallback( - /// uri, - /// customSchemes: ['com.example.myapp'], - /// )) { - /// // This is an OAuth callback - don't route it - /// return null; - /// } - /// ``` - static bool isOAuthCallback(Uri uri, {required List customSchemes}) { - return customSchemes.contains(uri.scheme); - } - - /// Creates a redirect function for go_router that ignores OAuth callbacks. - /// - /// This is a convenience method that returns a redirect function you can - /// pass directly to GoRouter's `redirect` parameter. - /// - /// Parameters: - /// - [customSchemes]: List of custom URL schemes used for OAuth callbacks - /// (e.g., `['com.example.myapp']`) - /// - [fallbackRedirect]: Optional custom redirect logic for non-OAuth URIs - /// - /// Example: - /// ```dart - /// final router = GoRouter( - /// routes: [...], - /// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( - /// customSchemes: ['com.example.myapp'], - /// ), - /// ); - /// ``` - /// - /// With custom redirect logic: - /// ```dart - /// final router = GoRouter( - /// routes: [...], - /// redirect: FlutterOAuthRouterHelper.createGoRouterRedirect( - /// customSchemes: ['com.example.myapp'], - /// fallbackRedirect: (context, state) { - /// // Your custom auth redirect logic - /// if (!isAuthenticated) return '/login'; - /// return null; - /// }, - /// ), - /// ); - /// ``` - static FutureOr Function(BuildContext, dynamic) - createGoRouterRedirect({ - required List customSchemes, - FutureOr Function(BuildContext, dynamic)? fallbackRedirect, - }) { - return (BuildContext context, dynamic state) { - // Extract URI from the state object (works with any router's state object that has a 'uri' property) - final uri = (state as dynamic).uri as Uri; - - // Check if this is an OAuth callback - if (isOAuthCallback(uri, customSchemes: customSchemes)) { - // Let flutter_web_auth_2 handle OAuth callbacks - if (kDebugMode) { - print('🔀 RouterHelper: Detected OAuth callback - allowing through'); - print(' URI: $uri'); - } - return null; - } - - // Apply custom redirect logic if provided - if (fallbackRedirect != null) { - return fallbackRedirect(context, state); - } - - // No redirect needed - return null; - }; - } - - /// Extracts the scheme from a redirect URI. - /// - /// This is useful for getting the custom scheme from your OAuth configuration. - /// - /// Example: - /// ```dart - /// final scheme = FlutterOAuthRouterHelper.extractScheme( - /// 'com.example.myapp:/oauth/callback' - /// ); - /// // Returns: 'com.example.myapp' - /// ``` - static String extractScheme(String redirectUri) { - final uri = Uri.parse(redirectUri); - return uri.scheme; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart b/packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart deleted file mode 100644 index 2f2c762..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/flutter_runtime.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:math'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart' as crypto; - -import '../runtime/runtime_implementation.dart'; -import '../utils/lock.dart'; -import 'flutter_key.dart'; - -/// Flutter implementation of RuntimeImplementation. -/// -/// Provides cryptographic operations for OAuth flows using: -/// - pointycastle for EC key generation (via FlutterKey) -/// - crypto package for SHA hashing -/// - Random.secure() for cryptographically secure random values -/// - requestLocalLock for concurrency control -/// -/// This implementation supports: -/// - ES256, ES384, ES512, ES256K (Elliptic Curve algorithms) -/// - SHA-256, SHA-384, SHA-512 (Hash algorithms) -/// - Secure random number generation -/// - Local (in-memory) locking for token refresh -/// -/// Example: -/// ```dart -/// final runtime = FlutterRuntime(); -/// -/// // Generate a key -/// final key = await runtime.createKey(['ES256', 'ES384']); -/// -/// // Hash some data -/// final hash = await runtime.digest( -/// Uint8List.fromList([1, 2, 3]), -/// DigestAlgorithm.sha256(), -/// ); -/// -/// // Generate random bytes -/// final random = await runtime.getRandomValues(32); -/// ``` -class FlutterRuntime implements RuntimeImplementation { - /// Creates a FlutterRuntime instance. - const FlutterRuntime(); - - @override - RuntimeKeyFactory get createKey { - return (List algs) async { - return FlutterKey.generate(algs); - }; - } - - @override - RuntimeDigest get digest { - return (Uint8List bytes, DigestAlgorithm algorithm) async { - switch (algorithm.name) { - case 'sha256': - case 'SHA-256': - return Uint8List.fromList(crypto.sha256.convert(bytes).bytes); - - case 'sha384': - case 'SHA-384': - return Uint8List.fromList(crypto.sha384.convert(bytes).bytes); - - case 'sha512': - case 'SHA-512': - return Uint8List.fromList(crypto.sha512.convert(bytes).bytes); - - default: - throw UnsupportedError( - 'Unsupported digest algorithm: ${algorithm.name}', - ); - } - }; - } - - @override - RuntimeRandomValues get getRandomValues { - return (int length) async { - final random = Random.secure(); - return Uint8List.fromList( - List.generate(length, (_) => random.nextInt(256)), - ); - }; - } - - @override - RuntimeLock get requestLock { - // Use the local lock implementation from utils/lock.dart - // This prevents concurrent token refresh within a single isolate - return requestLocalLock; - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart b/packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart deleted file mode 100644 index eff26a7..0000000 --- a/packages/atproto_oauth_flutter/lib/src/platform/flutter_stores.dart +++ /dev/null @@ -1,341 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; - -import '../identity/did_document.dart'; -import '../identity/did_resolver.dart'; -import '../identity/handle_resolver.dart'; -import '../oauth/authorization_server_metadata_resolver.dart'; -import '../oauth/oauth_server_agent.dart'; -import '../oauth/protected_resource_metadata_resolver.dart'; -import '../session/oauth_session.dart'; -import '../session/session_getter.dart'; -import '../session/state_store.dart'; -import '../util.dart'; - -// ============================================================================ -// Session and State Storage (uses flutter_secure_storage) -// ============================================================================ - -/// Flutter implementation of SessionStore using flutter_secure_storage. -/// -/// This stores OAuth sessions (tokens and keys) in the device's secure storage: -/// - iOS: Keychain -/// - Android: EncryptedSharedPreferences -/// -/// Sessions are persisted across app restarts and are encrypted at rest. -/// -/// Example: -/// ```dart -/// final store = FlutterSessionStore(); -/// await store.set('did:plc:abc123', session); -/// final restored = await store.get('did:plc:abc123'); -/// ``` -class FlutterSessionStore implements SessionStore { - final FlutterSecureStorage _storage; - static const _prefix = 'atproto_session_'; - - FlutterSessionStore([FlutterSecureStorage? storage]) - : _storage = - storage ?? - const FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - ); - - @override - Future get(String key, {CancellationToken? signal}) async { - try { - final json = await _storage.read(key: _prefix + key); - if (json == null) return null; - - final data = jsonDecode(json) as Map; - return Session.fromJson(data); - } catch (e) { - return null; - } - } - - @override - Future set(String key, Session value) async { - final json = jsonEncode(value.toJson()); - await _storage.write(key: _prefix + key, value: json); - } - - @override - Future del(String key) async { - await _storage.delete(key: _prefix + key); - } - - @override - Future clear() async { - // Delete all session keys - final all = await _storage.readAll(); - for (final key in all.keys) { - if (key.startsWith(_prefix)) { - await _storage.delete(key: key); - } - } - } -} - -/// Flutter implementation of StateStore for ephemeral OAuth state. -/// -/// This stores temporary state data during the OAuth authorization flow. -/// State data includes PKCE verifiers, nonces, and application state. -/// -/// Uses in-memory storage since state is short-lived (only needed during the -/// authorization flow, which typically completes within minutes). -/// -/// Example: -/// ```dart -/// final store = FlutterStateStore(); -/// await store.set('state123', InternalStateData(...)); -/// final state = await store.get('state123'); -/// await store.del('state123'); // Clean up after use -/// ``` -class FlutterStateStore implements StateStore { - final Map _store = {}; - - @override - Future get(String key) async { - return _store[key]; - } - - @override - Future set(String key, InternalStateData data) async { - _store[key] = data; - } - - @override - Future del(String key) async { - _store.remove(key); - } - - @override - Future clear() async { - _store.clear(); - } -} - -// ============================================================================ -// In-Memory Caches with TTL -// ============================================================================ - -/// Base class for in-memory caches with time-to-live (TTL). -/// -/// This provides a generic caching mechanism with automatic expiration. -/// Cached items are stored with a timestamp and are considered stale -/// after the TTL period. -class _InMemoryCache { - final Map> _cache = {}; - final Duration _ttl; - - _InMemoryCache(this._ttl); - - Future get(String key) async { - final entry = _cache[key]; - if (entry == null) return null; - - // Check if expired - if (DateTime.now().isAfter(entry.expiresAt)) { - _cache.remove(key); - return null; - } - - return entry.value; - } - - Future set(String key, V value) async { - _cache[key] = _CacheEntry( - value: value, - expiresAt: DateTime.now().add(_ttl), - ); - } - - Future del(String key) async { - _cache.remove(key); - } - - Future clear() async { - _cache.clear(); - } - - /// Removes expired entries from the cache. - void purge() { - final now = DateTime.now(); - _cache.removeWhere((_, entry) => now.isAfter(entry.expiresAt)); - } -} - -/// Cache entry with expiration time. -class _CacheEntry { - final V value; - final DateTime expiresAt; - - _CacheEntry({required this.value, required this.expiresAt}); -} - -/// In-memory cache for OAuth Authorization Server metadata. -/// -/// Caches metadata fetched from /.well-known/oauth-authorization-server -/// to avoid redundant network requests. -/// -/// Default TTL: 1 minute (metadata rarely changes) -/// -/// Example: -/// ```dart -/// final cache = InMemoryAuthorizationServerMetadataCache(); -/// await cache.set('https://auth.example.com', metadata); -/// final cached = await cache.get('https://auth.example.com'); -/// ``` -class InMemoryAuthorizationServerMetadataCache - implements AuthorizationServerMetadataCache { - final _InMemoryCache> _cache; - - InMemoryAuthorizationServerMetadataCache({ - Duration ttl = const Duration(minutes: 1), - }) : _cache = _InMemoryCache(ttl); - - @override - Future?> get(String key, {CancellationToken? signal}) => - _cache.get(key); - - @override - Future set(String key, Map value) => - _cache.set(key, value); - - @override - Future del(String key) => _cache.del(key); - - @override - Future clear() => _cache.clear(); -} - -/// In-memory cache for OAuth Protected Resource metadata. -/// -/// Caches metadata fetched from /.well-known/oauth-protected-resource -/// to avoid redundant network requests. -/// -/// Default TTL: 1 minute (metadata rarely changes) -/// -/// Example: -/// ```dart -/// final cache = InMemoryProtectedResourceMetadataCache(); -/// await cache.set('https://pds.example.com', metadata); -/// ``` -class InMemoryProtectedResourceMetadataCache - implements ProtectedResourceMetadataCache { - final _InMemoryCache> _cache; - - InMemoryProtectedResourceMetadataCache({ - Duration ttl = const Duration(minutes: 1), - }) : _cache = _InMemoryCache(ttl); - - @override - Future?> get(String key, {CancellationToken? signal}) => - _cache.get(key); - - @override - Future set(String key, Map value) => - _cache.set(key, value); - - @override - Future del(String key) => _cache.del(key); - - @override - Future clear() => _cache.clear(); -} - -/// In-memory cache for DPoP nonces. -/// -/// DPoP nonces are server-provided values used for replay protection. -/// They're cached per authorization/resource server origin. -/// -/// Default TTL: 10 minutes (nonces typically have short lifetimes) -/// -/// Example: -/// ```dart -/// final cache = InMemoryDpopNonceCache(); -/// await cache.set('https://auth.example.com', 'nonce123'); -/// final nonce = await cache.get('https://auth.example.com'); -/// ``` -class InMemoryDpopNonceCache implements DpopNonceCache { - final _InMemoryCache _cache; - - InMemoryDpopNonceCache({Duration ttl = const Duration(minutes: 10)}) - : _cache = _InMemoryCache(ttl); - - @override - Future get(String key, {CancellationToken? signal}) => - _cache.get(key); - - @override - Future set(String key, String value) => _cache.set(key, value); - - @override - Future del(String key) => _cache.del(key); - - @override - Future clear() => _cache.clear(); -} - -/// In-memory cache for DID documents. -/// -/// Caches resolved DID documents (from DidDocument class) to avoid redundant -/// resolution requests. -/// -/// Default TTL: 1 minute (DID documents can change but not frequently) -/// -/// Note: DidDocument is a complex class, but it has toJson/fromJson methods. -/// We store the JSON representation and reconstruct on retrieval. -/// -/// Example: -/// ```dart -/// final cache = FlutterDidCache(); -/// await cache.set('did:plc:abc123', didDocument); -/// final doc = await cache.get('did:plc:abc123'); -/// ``` -class FlutterDidCache implements DidCache { - final _InMemoryCache _cache; - - FlutterDidCache({Duration ttl = const Duration(minutes: 1)}) - : _cache = _InMemoryCache(ttl); - - @override - Future get(String key) => _cache.get(key); - - @override - Future set(String key, DidDocument value) => _cache.set(key, value); - - @override - Future clear() => _cache.clear(); -} - -/// In-memory cache for handle → DID resolutions. -/// -/// Caches the resolution of atProto handles (e.g., "alice.bsky.social") to DIDs. -/// The cache stores simple string mappings (handle → DID). -/// -/// Default TTL: 1 minute (handles can be reassigned but not frequently) -/// -/// Example: -/// ```dart -/// final cache = FlutterHandleCache(); -/// await cache.set('alice.bsky.social', 'did:plc:abc123'); -/// final did = await cache.get('alice.bsky.social'); -/// ``` -class FlutterHandleCache implements HandleCache { - final _InMemoryCache _cache; - - FlutterHandleCache({Duration ttl = const Duration(minutes: 1)}) - : _cache = _InMemoryCache(ttl); - - @override - Future get(String key) => _cache.get(key); - - @override - Future set(String key, String value) => _cache.set(key, value); - - @override - Future clear() => _cache.clear(); -} diff --git a/packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart b/packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart deleted file mode 100644 index f6c208f..0000000 --- a/packages/atproto_oauth_flutter/lib/src/runtime/runtime.dart +++ /dev/null @@ -1,280 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import '../utils/lock.dart'; -import 'runtime_implementation.dart'; - -/// Main runtime class that wraps a RuntimeImplementation and provides -/// high-level cryptographic operations for OAuth. -/// -/// This class handles: -/// - Key generation with algorithm preference sorting -/// - SHA-256 hashing with base64url encoding -/// - Nonce generation -/// - PKCE (Proof Key for Code Exchange) generation -/// - JWK thumbprint calculation -/// -/// All operations use the underlying RuntimeImplementation for -/// platform-specific cryptographic primitives. -class Runtime { - final RuntimeImplementation _implementation; - - /// Whether the implementation provides a custom lock mechanism. - final bool hasImplementationLock; - - /// The lock function to use (either custom or local fallback). - final RuntimeLock usingLock; - - Runtime(this._implementation) - : hasImplementationLock = _implementation.requestLock != null, - usingLock = _implementation.requestLock ?? requestLocalLock; - - /// Generates a cryptographic key that supports the given algorithms. - /// - /// The algorithms are sorted by preference before being passed to the - /// key factory. This ensures consistent key selection across platforms. - /// - /// Algorithm preference order (most to least preferred): - /// 1. ES256K (secp256k1) - /// 2. ES256, ES384, ES512 (elliptic curve, shorter keys first) - /// 3. PS256, PS384, PS512 (RSA-PSS, shorter keys first) - /// 4. RS256, RS384, RS512 (RSA-PKCS1, shorter keys first) - /// 5. Other algorithms (maintain original order) - /// - /// Example: - /// ```dart - /// final key = await runtime.generateKey(['ES256', 'RS256', 'ES384']); - /// // Returns key supporting ES256 (preferred over RS256 and ES384) - /// ``` - Future generateKey(List algs) async { - final algsSorted = List.from(algs)..sort(_compareAlgos); - return _implementation.createKey(algsSorted); - } - - /// Computes the SHA-256 hash of the input text and returns it as base64url. - /// - /// This is used extensively in OAuth for: - /// - PKCE code challenge (S256 method) - /// - JWK thumbprint calculation - /// - DPoP access token hash (ath claim) - /// - /// Example: - /// ```dart - /// final hash = await runtime.sha256('hello world'); - /// // Returns base64url-encoded SHA-256 hash - /// ``` - Future sha256(String text) async { - final bytes = utf8.encode(text); - final digest = await _implementation.digest( - Uint8List.fromList(bytes), - const DigestAlgorithm.sha256(), - ); - return _base64UrlEncode(digest); - } - - /// Generates a cryptographically secure random nonce. - /// - /// The nonce is base64url-encoded and has the specified byte length - /// (default 16 bytes = 128 bits of entropy). - /// - /// Used for: - /// - OAuth state parameter - /// - OIDC nonce parameter - /// - DPoP jti (JWT ID) claim - /// - /// Example: - /// ```dart - /// final nonce = await runtime.generateNonce(); // 16 bytes - /// final longNonce = await runtime.generateNonce(32); // 32 bytes - /// ``` - Future generateNonce([int length = 16]) async { - final bytes = await _implementation.getRandomValues(length); - return _base64UrlEncode(bytes); - } - - /// Generates PKCE (Proof Key for Code Exchange) parameters. - /// - /// PKCE is a security extension for OAuth that prevents authorization code - /// interception attacks. It's required for public clients (mobile/desktop apps). - /// - /// Returns a map with: - /// - `verifier`: Random code verifier (base64url-encoded) - /// - `challenge`: SHA-256 hash of verifier (base64url-encoded) - /// - `method`: 'S256' (indicating SHA-256 hashing method) - /// - /// The verifier should be stored securely and sent during token exchange. - /// The challenge is sent during authorization. - /// - /// See: https://datatracker.ietf.org/doc/html/rfc7636 - /// - /// Example: - /// ```dart - /// final pkce = await runtime.generatePKCE(); - /// // Use pkce['challenge'] in authorization request - /// // Store pkce['verifier'] for token exchange - /// ``` - Future> generatePKCE([int? byteLength]) async { - final verifier = await _generateVerifier(byteLength); - final challenge = await sha256(verifier); - return {'verifier': verifier, 'challenge': challenge, 'method': 'S256'}; - } - - /// Calculates the JWK thumbprint (jkt) for a given JSON Web Key. - /// - /// The thumbprint is a hash of the key's essential components, used to - /// uniquely identify a key. For DPoP, this binds tokens to specific keys. - /// - /// The calculation follows RFC 7638: - /// 1. Extract required components based on key type (kty) - /// 2. Create canonical JSON representation - /// 3. Compute SHA-256 hash - /// 4. Base64url-encode the result - /// - /// Required components by key type: - /// - EC: crv, kty, x, y - /// - OKP: crv, kty, x - /// - RSA: e, kty, n - /// - oct: k, kty - /// - /// See: https://datatracker.ietf.org/doc/html/rfc7638 - /// - /// Example: - /// ```dart - /// final thumbprint = await runtime.calculateJwkThumbprint(jwk); - /// // Returns base64url-encoded SHA-256 hash of key components - /// ``` - Future calculateJwkThumbprint(Map jwk) async { - final components = _extractJktComponents(jwk); - final data = jsonEncode(components); - return sha256(data); - } - - /// Generates a PKCE code verifier. - /// - /// The verifier is a cryptographically random string that: - /// - Has length between 43-128 characters (32-96 bytes before encoding) - /// - Is base64url-encoded - /// - SHOULD be 32 bytes (43 chars) per RFC 7636 recommendations - /// - /// See: https://datatracker.ietf.org/doc/html/rfc7636#section-4.1 - Future _generateVerifier([int? byteLength]) async { - final length = byteLength ?? 32; - - if (length < 32 || length > 96) { - throw ArgumentError( - 'Invalid code_verifier length: must be between 32 and 96 bytes', - ); - } - - final bytes = await _implementation.getRandomValues(length); - return _base64UrlEncode(bytes); - } - - /// Base64url encodes a byte array without padding. - /// - /// Base64url encoding is standard base64 with URL-safe characters: - /// - '+' becomes '-' - /// - '/' becomes '_' - /// - Padding ('=') is removed - /// - /// This is the encoding used throughout OAuth and JWT specifications. - String _base64UrlEncode(Uint8List bytes) { - return base64Url.encode(bytes).replaceAll('=', ''); - } -} - -/// Extracts the required components from a JWK for thumbprint calculation. -/// -/// This follows RFC 7638 which specifies exactly which fields to include -/// in the thumbprint hash for each key type. -/// -/// The components are returned in a Map that will be serialized to JSON -/// in lexicographic order (Dart's jsonEncode naturally does this). -/// -/// Throws ArgumentError if: -/// - Required fields are missing -/// - Key type (kty) is unsupported -Map _extractJktComponents(Map jwk) { - String getRequired(String field) { - final value = jwk[field]; - if (value is! String || value.isEmpty) { - throw ArgumentError('"$field" parameter missing or invalid'); - } - return value; - } - - final kty = getRequired('kty'); - - switch (kty) { - case 'EC': - // Elliptic Curve keys (ES256, ES384, ES512, ES256K) - return { - 'crv': getRequired('crv'), - 'kty': kty, - 'x': getRequired('x'), - 'y': getRequired('y'), - }; - - case 'OKP': - // Octet Key Pair (EdDSA) - return {'crv': getRequired('crv'), 'kty': kty, 'x': getRequired('x')}; - - case 'RSA': - // RSA keys (RS256, RS384, RS512, PS256, PS384, PS512) - return {'e': getRequired('e'), 'kty': kty, 'n': getRequired('n')}; - - case 'oct': - // Symmetric keys (HS256, HS384, HS512) - return {'k': getRequired('k'), 'kty': kty}; - - default: - throw ArgumentError( - '"kty" (Key Type) parameter missing or unsupported: $kty', - ); - } -} - -/// Compares two algorithm strings for preference ordering. -/// -/// Algorithm preference order: -/// 1. ES256K (secp256k1) - always most preferred -/// 2. ES* (Elliptic Curve) - prefer shorter keys -/// - ES256 > ES384 > ES512 -/// 3. PS* (RSA-PSS) - prefer shorter keys -/// - PS256 > PS384 > PS512 -/// 4. RS* (RSA-PKCS1) - prefer shorter keys -/// - RS256 > RS384 > RS512 -/// 5. Other algorithms - maintain original order -/// -/// Returns: -/// - Negative if `a` is preferred over `b` -/// - Positive if `b` is preferred over `a` -/// - Zero if no preference (maintain order) -int _compareAlgos(String a, String b) { - // ES256K is always most preferred - if (a == 'ES256K') return -1; - if (b == 'ES256K') return 1; - - // Check algorithm families in preference order: ES > PS > RS - for (final prefix in ['ES', 'PS', 'RS']) { - if (a.startsWith(prefix)) { - if (b.startsWith(prefix)) { - // Both have same prefix, prefer shorter key length - // Extract the number (e.g., "256" from "ES256") - final aLen = int.tryParse(a.substring(2, 5)) ?? 0; - final bLen = int.tryParse(b.substring(2, 5)) ?? 0; - - // Prefer shorter keys (256 < 384 < 512) - return aLen - bLen; - } - // 'a' has the prefix, 'b' doesn't - prefer 'a' - return -1; - } else if (b.startsWith(prefix)) { - // 'b' has the prefix, 'a' doesn't - prefer 'b' - return 1; - } - } - - // No known preference, maintain original order - return 0; -} diff --git a/packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart b/packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart deleted file mode 100644 index 15d572b..0000000 --- a/packages/atproto_oauth_flutter/lib/src/runtime/runtime_implementation.dart +++ /dev/null @@ -1,167 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -/// Represents a cryptographic key that can sign and verify JWTs. -/// -/// This is a placeholder for the Key class from @atproto/jwk. -/// In the full implementation, this should be imported from the jwk package. -/// -/// The Key class contains: -/// - JWK representation (public and private) -/// - Supported algorithms -/// - createJwt() method for signing -/// - verifyJwt() method for verification -/// -/// ## Key Serialization (IMPLEMENTED) -/// -/// DPoP keys are fully serialized and persisted in session storage via: -/// -/// 1. FlutterKey.toJson() / FlutterKey.privateJwk: -/// - Serializes the full JWK including private key components -/// - Used when storing sessions to secure storage -/// -/// 2. FlutterKey.fromJwk(Map jwk): -/// - Reconstructs a Key from serialized JWK -/// - Validates JWK structure and throws on corruption -/// - Used when restoring sessions from storage -/// -/// This ensures DPoP keys persist across app restarts, maintaining -/// token binding consistency and avoiding unnecessary token refreshes. -abstract class Key { - /// Create a signed JWT with the given header and payload. - Future createJwt( - Map header, - Map payload, - ); - - /// The list of algorithms this key supports. - List get algorithms; - - /// The bare JWK (public key components only, for DPoP proofs). - /// Returns null for symmetric keys. - Map? get bareJwk; - - /// The key ID (kid) from the JWK. - /// Returns null if the key doesn't have a kid. - String? get kid; - - /// The usage of this key ('sign' or 'enc'). - String get usage; - - // TODO: Uncomment these when implementing serialization: - // Map toJson(); - // static Key fromJson(Map json); -} - -/// Factory function that creates a cryptographic key for the given algorithms. -/// -/// The key should support at least one of the provided algorithms. -/// Algorithms are typically in order of preference. -/// -/// Common algorithms: -/// - ES256, ES384, ES512 (Elliptic Curve) -/// - ES256K (secp256k1) -/// - RS256, RS384, RS512 (RSA) -/// - PS256, PS384, PS512 (RSA-PSS) -typedef RuntimeKeyFactory = FutureOr Function(List algs); - -/// Generates cryptographically secure random bytes. -/// -/// Returns a Uint8List of the specified length filled with random bytes. -/// Must use a cryptographically secure random number generator. -typedef RuntimeRandomValues = FutureOr Function(int length); - -/// Digest algorithm specification. -class DigestAlgorithm { - /// The hash algorithm name: 'sha256', 'sha384', or 'sha512'. - final String name; - - const DigestAlgorithm({required this.name}); - - const DigestAlgorithm.sha256() : name = 'sha256'; - const DigestAlgorithm.sha384() : name = 'sha384'; - const DigestAlgorithm.sha512() : name = 'sha512'; -} - -/// Computes a cryptographic hash (digest) of the input data. -/// -/// The algorithm specifies which hash function to use (SHA-256, SHA-384, SHA-512). -/// Returns the hash as a Uint8List. -typedef RuntimeDigest = - FutureOr Function(Uint8List data, DigestAlgorithm alg); - -/// Acquires a lock for the given name and executes the function while holding the lock. -/// -/// This ensures that only one execution of the function can run at a time for a given lock name. -/// This is critical for preventing race conditions during token refresh operations. -/// -/// Example: -/// ```dart -/// final result = await requestLock('token-refresh', () async { -/// // Critical section - only one execution at a time -/// return await refreshToken(); -/// }); -/// ``` -typedef RuntimeLock = - Future Function(String name, FutureOr Function() fn); - -/// Platform-specific runtime implementation for cryptographic operations. -/// -/// This interface defines the core cryptographic primitives needed for OAuth: -/// - Key generation (createKey) -/// - Random number generation (getRandomValues) -/// - Cryptographic hashing (digest) -/// - Optional locking mechanism (requestLock) -/// -/// Implementations must use secure cryptographic libraries: -/// - For Dart: pointycastle (ECDSA), crypto (SHA hashing) -/// - Random values must come from dart:math.Random.secure() -/// -/// Security considerations: -/// - Keys must be generated using cryptographically secure randomness -/// - Private keys must never be logged or exposed -/// - Hash functions must be collision-resistant (SHA-256 minimum) -/// - Lock implementation should prevent race conditions in token refresh -abstract class RuntimeImplementation { - /// Creates a cryptographic key that supports at least one of the given algorithms. - /// - /// The algorithms list is typically sorted by preference, with the most preferred first. - /// - /// For OAuth DPoP, common algorithm preferences are: - /// - ES256K (secp256k1) - preferred for atproto - /// - ES256, ES384, ES512 (NIST curves) - /// - PS256, PS384, PS512 (RSA-PSS) - /// - RS256, RS384, RS512 (RSA-PKCS1) - /// - /// Throws if no suitable key can be generated for any of the algorithms. - RuntimeKeyFactory get createKey; - - /// Generates cryptographically secure random bytes. - /// - /// MUST use a cryptographically secure random number generator. - /// In Dart, use Random.secure() from dart:math. - /// - /// Never use a regular Random() - this is a security vulnerability. - RuntimeRandomValues get getRandomValues; - - /// Computes a cryptographic hash of the input data. - /// - /// Supported algorithms: SHA-256, SHA-384, SHA-512 - /// - /// Implementation should use the crypto package's sha256, sha384, sha512. - RuntimeDigest get digest; - - /// Optional platform-specific lock implementation. - /// - /// If provided, this will be used to prevent concurrent token refresh operations. - /// If not provided, a local (in-memory) lock implementation will be used as fallback. - /// - /// The lock should be: - /// - Re-entrant safe (same isolate can acquire multiple times) - /// - Fair (FIFO order) - /// - Automatically released on error - /// - /// For Flutter apps, the default local lock is usually sufficient. - /// For multi-process scenarios, you may need a platform-specific implementation. - RuntimeLock? get requestLock; -} diff --git a/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart b/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart deleted file mode 100644 index ad8ae59..0000000 --- a/packages/atproto_oauth_flutter/lib/src/session/oauth_session.dart +++ /dev/null @@ -1,395 +0,0 @@ -import 'dart:async'; -import 'package:dio/dio.dart'; -import 'package:http/http.dart' as http; - -import '../dpop/fetch_dpop.dart'; -import '../errors/token_invalid_error.dart'; -import '../errors/token_revoked_error.dart'; -import '../oauth/oauth_server_agent.dart'; - -/// Type alias for AtprotoDid (user's DID) -typedef AtprotoDid = String; - -/// Type alias for AtprotoOAuthScope -typedef AtprotoOAuthScope = String; - -/// Placeholder for OAuthAuthorizationServerMetadata -/// Will be properly typed in later chunks -typedef OAuthAuthorizationServerMetadata = Map; - -/// Information about the current token. -class TokenInfo { - /// When the token expires (null if no expiration) - final DateTime? expiresAt; - - /// Whether the token is expired (null if no expiration) - final bool? expired; - - /// The scope of access granted - final AtprotoOAuthScope scope; - - /// The issuer URL - final String iss; - - /// The audience (resource server) - final String aud; - - /// The subject (user's DID) - final AtprotoDid sub; - - TokenInfo({ - this.expiresAt, - this.expired, - required this.scope, - required this.iss, - required this.aud, - required this.sub, - }); -} - -/// Abstract interface for session management. -/// -/// This will be implemented by SessionGetter in session_getter.dart. -/// We define it here to avoid circular dependencies. -abstract class SessionGetterInterface { - Future get(AtprotoDid sub, {bool? noCache, bool? allowStale}); - - Future delStored(AtprotoDid sub, [Object? cause]); -} - -/// Represents an active OAuth session. -/// -/// A session is created after successful authentication and provides methods -/// for making authenticated requests and managing the session lifecycle. -class Session { - /// The DPoP key used for this session (serialized as Map for storage) - final Map dpopKey; - - /// The client authentication method (serialized as Map or String for storage). - /// Can be: - /// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT - /// - A Map containing {method: 'none'} for no authentication - /// - A String 'legacy' for backwards compatibility - /// - null (defaults to 'legacy' when loading) - final dynamic authMethod; - - /// The token set containing access and refresh tokens - final TokenSet tokenSet; - - const Session({ - required this.dpopKey, - this.authMethod, - required this.tokenSet, - }); - - /// Creates a Session from JSON. - factory Session.fromJson(Map json) { - return Session( - dpopKey: json['dpopKey'] as Map, - authMethod: json['authMethod'], // Can be Map or String - tokenSet: TokenSet.fromJson(json['tokenSet'] as Map), - ); - } - - /// Converts this Session to JSON. - Map toJson() { - final json = { - 'dpopKey': dpopKey, - 'tokenSet': tokenSet.toJson(), - }; - - if (authMethod != null) json['authMethod'] = authMethod; - - return json; - } -} - -/// Represents an active OAuth session with methods for authenticated requests. -/// -/// This class wraps an OAuth session and provides: -/// - Automatic token refresh on expiry -/// - DPoP-protected requests -/// - Session lifecycle management (sign out) -/// -/// Example: -/// ```dart -/// final session = OAuthSession( -/// server: oauthServer, -/// sub: 'did:plc:abc123', -/// sessionGetter: sessionGetter, -/// ); -/// -/// // Make an authenticated request -/// final response = await session.fetchHandler('/api/posts'); -/// -/// // Get token information -/// final info = await session.getTokenInfo(); -/// print('Token expires at: ${info.expiresAt}'); -/// -/// // Sign out -/// await session.signOut(); -/// ``` -class OAuthSession { - /// The OAuth server agent - final OAuthServerAgent server; - - /// The subject (user's DID) - final AtprotoDid sub; - - /// The session getter for retrieving and refreshing tokens - final SessionGetterInterface sessionGetter; - - /// Dio instance with DPoP interceptor for authenticated requests - final Dio _dio; - - /// Creates a new OAuth session. - /// - /// Parameters: - /// - [server]: The OAuth server agent - /// - [sub]: The subject (user's DID) - /// - [sessionGetter]: The session getter for token management - OAuthSession({ - required this.server, - required this.sub, - required this.sessionGetter, - }) : _dio = Dio() { - // Add DPoP interceptor for authenticated requests to resource servers - _dio.interceptors.add( - createDpopInterceptor( - DpopFetchWrapperOptions( - key: server.dpopKey, - nonces: server.dpopNonces, - sha256: server.runtime.sha256, - isAuthServer: false, // Resource server requests (PDS) - ), - ), - ); - } - - /// Alias for [sub] - AtprotoDid get did => sub; - - /// The server metadata - OAuthAuthorizationServerMetadata get serverMetadata => server.serverMetadata; - - /// Gets the current token set. - /// - /// Parameters: - /// - [refresh]: When `true`, forces a token refresh even if not expired. - /// When `false`, uses cached tokens even if expired. - /// When `'auto'`, refreshes only if expired (default). - Future _getTokenSet(dynamic refresh) async { - final session = await sessionGetter.get( - sub, - noCache: refresh == true, - allowStale: refresh == false, - ); - - return session.tokenSet; - } - - /// Gets information about the current token. - /// - /// Parameters: - /// - [refresh]: When `true`, forces a token refresh even if not expired. - /// When `false`, uses cached tokens even if expired. - /// When `'auto'`, refreshes only if expired (default). - Future getTokenInfo([dynamic refresh = 'auto']) async { - final tokenSet = await _getTokenSet(refresh); - final expiresAtStr = tokenSet.expiresAt; - final expiresAt = - expiresAtStr != null ? DateTime.parse(expiresAtStr) : null; - - return TokenInfo( - expiresAt: expiresAt, - expired: - expiresAt != null - ? expiresAt.isBefore( - DateTime.now().subtract(Duration(seconds: 5)), - ) - : null, - scope: tokenSet.scope, - iss: tokenSet.iss, - aud: tokenSet.aud, - sub: tokenSet.sub, - ); - } - - /// Signs out the user. - /// - /// This revokes the access token and deletes the session from storage. - /// Even if revocation fails, the session is removed locally. - Future signOut() async { - try { - final tokenSet = await _getTokenSet(false); - await server.revoke(tokenSet.accessToken); - } finally { - await sessionGetter.delStored(sub, TokenRevokedError(sub)); - } - } - - /// Makes an authenticated HTTP request to the given pathname. - /// - /// This method: - /// 1. Automatically refreshes tokens if they're expired - /// 2. Adds DPoP and Authorization headers - /// 3. Retries once with a fresh token if the initial request fails with 401 - /// - /// Parameters: - /// - [pathname]: The pathname to request (relative to the audience URL) - /// - [method]: HTTP method (default: 'GET') - /// - [headers]: Additional headers to include - /// - [body]: Request body - /// - /// Returns the HTTP response. - /// - /// Example: - /// ```dart - /// final response = await session.fetchHandler( - /// '/xrpc/com.atproto.repo.createRecord', - /// method: 'POST', - /// headers: {'Content-Type': 'application/json'}, - /// body: jsonEncode({'repo': did, 'collection': 'app.bsky.feed.post', ...}), - /// ); - /// ``` - Future fetchHandler( - String pathname, { - String method = 'GET', - Map? headers, - dynamic body, - }) async { - // Try to refresh the token if it's known to be expired - final tokenSet = await _getTokenSet('auto'); - - final initialUrl = Uri.parse(tokenSet.aud).resolve(pathname); - final initialAuth = '${tokenSet.tokenType} ${tokenSet.accessToken}'; - - final initialHeaders = { - ...?headers, - 'Authorization': initialAuth, - }; - - // Make request with DPoP - the interceptor will automatically add DPoP header - final initialResponse = await _makeDpopRequest( - initialUrl, - method: method, - headers: initialHeaders, - body: body, - ); - - // If the token is not expired, we don't need to refresh it - if (!_isInvalidTokenResponse(initialResponse)) { - return initialResponse; - } - - // Token is invalid, try to refresh - TokenSet tokenSetFresh; - try { - // Force a refresh - tokenSetFresh = await _getTokenSet(true); - } catch (err) { - // If refresh fails, return the original response - return initialResponse; - } - - // Retry with fresh token - final finalAuth = '${tokenSetFresh.tokenType} ${tokenSetFresh.accessToken}'; - final finalUrl = Uri.parse(tokenSetFresh.aud).resolve(pathname); - - final finalHeaders = { - ...?headers, - 'Authorization': finalAuth, - }; - - final finalResponse = await _makeDpopRequest( - finalUrl, - method: method, - headers: finalHeaders, - body: body, - ); - - // The token was successfully refreshed, but is still not accepted by the - // resource server. This might be due to the resource server not accepting - // credentials from the authorization server (e.g. because some migration - // occurred). Any ways, there is no point in keeping the session. - if (_isInvalidTokenResponse(finalResponse)) { - await sessionGetter.delStored(sub, TokenInvalidError(sub)); - } - - return finalResponse; - } - - /// Makes an HTTP request with DPoP authentication. - /// - /// Uses Dio with DPoP interceptor which automatically adds: - /// - DPoP header with proof JWT - /// - Access token hash (ath) binding - /// - /// Throws [DioException] for network errors, timeouts, and cancellations. - Future _makeDpopRequest( - Uri url, { - required String method, - Map? headers, - dynamic body, - }) async { - try { - // Make request with Dio - interceptor will add DPoP header - final response = await _dio.requestUri( - url, - options: Options( - method: method, - headers: headers, - responseType: ResponseType.bytes, // Get raw bytes for compatibility - validateStatus: (status) => true, // Don't throw on any status code - ), - data: body, - ); - - // Convert Dio Response to http.Response for compatibility - return http.Response.bytes( - response.data as List, - response.statusCode!, - headers: response.headers.map.map( - (key, value) => MapEntry(key, value.join(', ')), - ), - reasonPhrase: response.statusMessage, - ); - } on DioException catch (e) { - // If we have a response (4xx/5xx), convert it to http.Response - if (e.response != null) { - final errorResponse = e.response!; - return http.Response.bytes( - errorResponse.data is List - ? errorResponse.data as List - : (errorResponse.data?.toString() ?? '').codeUnits, - errorResponse.statusCode!, - headers: errorResponse.headers.map.map( - (key, value) => MapEntry(key, value.join(', ')), - ), - reasonPhrase: errorResponse.statusMessage, - ); - } - // Network errors, timeouts, cancellations - rethrow - rethrow; - } - } - - /// Checks if a response indicates an invalid token. - /// - /// See: - /// - https://datatracker.ietf.org/doc/html/rfc6750#section-3 - /// - https://datatracker.ietf.org/doc/html/rfc9449#name-resource-server-provided-no - bool _isInvalidTokenResponse(http.Response response) { - if (response.statusCode != 401) return false; - - final wwwAuth = response.headers['www-authenticate']; - return wwwAuth != null && - (wwwAuth.startsWith('Bearer ') || wwwAuth.startsWith('DPoP ')) && - wwwAuth.contains('error="invalid_token"'); - } - - /// Disposes of resources used by this session. - void dispose() { - _dio.close(); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/session/session.dart b/packages/atproto_oauth_flutter/lib/src/session/session.dart deleted file mode 100644 index 01c2d16..0000000 --- a/packages/atproto_oauth_flutter/lib/src/session/session.dart +++ /dev/null @@ -1,42 +0,0 @@ -/// Session management layer for atproto OAuth. -/// -/// This module provides session storage, retrieval, and lifecycle management -/// for OAuth sessions. It includes: -/// -/// - [StateStore] - Stores ephemeral OAuth state during authorization -/// - [SessionStore] - Stores persistent session data -/// - [Session] - Represents an authenticated session with tokens -/// - [TokenSet] - Contains OAuth tokens and metadata -/// - [OAuthSession] - High-level API for authenticated requests -/// - [SessionGetter] - Manages session caching and token refresh -/// -/// Example: -/// ```dart -/// // Create a session store implementation -/// final sessionStore = MySessionStore(); -/// -/// // Create a session getter -/// final sessionGetter = SessionGetter( -/// sessionStore: sessionStore, -/// serverFactory: serverFactory, -/// runtime: runtime, -/// ); -/// -/// // Get a session (automatically refreshes if needed) -/// final session = await sessionGetter.getSession('did:plc:abc123'); -/// -/// // Create an OAuthSession for making requests -/// final oauthSession = OAuthSession( -/// server: server, -/// sub: 'did:plc:abc123', -/// sessionGetter: sessionGetter, -/// ); -/// -/// // Make authenticated requests -/// final response = await oauthSession.fetchHandler('/api/posts'); -/// ``` -library; - -export 'state_store.dart'; -export 'oauth_session.dart'; -export 'session_getter.dart'; diff --git a/packages/atproto_oauth_flutter/lib/src/session/session_getter.dart b/packages/atproto_oauth_flutter/lib/src/session/session_getter.dart deleted file mode 100644 index 99d90cd..0000000 --- a/packages/atproto_oauth_flutter/lib/src/session/session_getter.dart +++ /dev/null @@ -1,644 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:math' as math; - -import '../errors/auth_method_unsatisfiable_error.dart'; -import '../errors/token_invalid_error.dart'; -import '../errors/token_refresh_error.dart'; -import '../errors/token_revoked_error.dart'; -import '../oauth/client_auth.dart' show ClientAuthMethod; -import '../oauth/oauth_server_agent.dart'; -import '../oauth/oauth_server_factory.dart'; -import '../platform/flutter_key.dart'; -import '../runtime/runtime.dart'; -import '../util.dart'; -import 'oauth_session.dart'; - -/// Options for getting a cached value. -class GetCachedOptions { - /// Cancellation token for aborting the operation - final CancellationToken? signal; - - /// Do not use the cache to get the value. Always get a new value. - final bool? noCache; - - /// Allow returning stale values from the cache. - final bool? allowStale; - - const GetCachedOptions({this.signal, this.noCache, this.allowStale}); -} - -/// Abstract storage interface for values. -/// -/// This is a generic key-value store interface. -abstract class SimpleStore { - /// Gets a value from the store. - /// - /// Returns `null` if the key doesn't exist. - Future get(K key, {CancellationToken? signal}); - - /// Sets a value in the store. - Future set(K key, V value); - - /// Deletes a value from the store. - Future del(K key); - - /// Optionally clears all values from the store. - Future clear() async {} -} - -/// Type alias for session storage -typedef SessionStore = SimpleStore; - -/// Details of a session update event. -class SessionUpdatedEvent { - /// The subject (user's DID) - final String sub; - - /// The DPoP key - final Map dpopKey; - - /// The authentication method - final String? authMethod; - - /// The token set - final TokenSet tokenSet; - - const SessionUpdatedEvent({ - required this.sub, - required this.dpopKey, - this.authMethod, - required this.tokenSet, - }); -} - -/// Details of a session deletion event. -class SessionDeletedEvent { - /// The subject (user's DID) - final String sub; - - /// The cause of deletion - final Object cause; - - const SessionDeletedEvent({required this.sub, required this.cause}); -} - -/// Manages session retrieval, caching, and refreshing. -/// -/// The SessionGetter wraps a session store and provides: -/// - Automatic token refresh when tokens are stale/expired -/// - Caching to avoid redundant refresh operations -/// - Events for session updates and deletions -/// - Concurrency control to prevent multiple simultaneous refreshes -/// -/// This is a critical component that ensures at most one token refresh -/// is happening at a time for a given user, even across multiple tabs -/// or app instances. -/// -/// Example: -/// ```dart -/// final sessionGetter = SessionGetter( -/// sessionStore: mySessionStore, -/// serverFactory: myServerFactory, -/// runtime: myRuntime, -/// ); -/// -/// // Listen for session updates -/// sessionGetter.onUpdated.listen((event) { -/// print('Session updated for ${event.sub}'); -/// }); -/// -/// // Listen for session deletions -/// sessionGetter.onDeleted.listen((event) { -/// print('Session deleted for ${event.sub}: ${event.cause}'); -/// }); -/// -/// // Get a session (automatically refreshes if expired) -/// final session = await sessionGetter.getSession('did:plc:abc123'); -/// -/// // Force refresh -/// final freshSession = await sessionGetter.getSession('did:plc:abc123', true); -/// ``` -class SessionGetter extends CachedGetter { - final OAuthServerFactory _serverFactory; - final Runtime _runtime; - - final _eventTarget = CustomEventTarget>(); - final _updatedController = StreamController.broadcast(); - final _deletedController = StreamController.broadcast(); - - /// Stream of session update events. - Stream get onUpdated => _updatedController.stream; - - /// Stream of session deletion events. - Stream get onDeleted => _deletedController.stream; - - SessionGetter({ - required super.sessionStore, - required OAuthServerFactory serverFactory, - required Runtime runtime, - }) : _serverFactory = serverFactory, - _runtime = runtime, - super( - getter: null, // Will be set in _createGetter - options: CachedGetterOptions( - isStale: (sub, session) { - final tokenSet = session.tokenSet; - if (tokenSet.expiresAt == null) return false; - - final expiresAt = DateTime.parse(tokenSet.expiresAt!); - final now = DateTime.now(); - - // Add some lee way to ensure the token is not expired when it - // reaches the server (10 seconds) - // Add some randomness to reduce the chances of multiple - // instances trying to refresh the token at the same time (0-30 seconds) - final buffer = Duration( - milliseconds: - 10000 + (math.Random().nextDouble() * 30000).toInt(), - ); - - return expiresAt.isBefore(now.add(buffer)); - }, - onStoreError: (err, sub, session) async { - if (err is! AuthMethodUnsatisfiableError) { - // If the error was an AuthMethodUnsatisfiableError, there is no - // point in trying to call `fromIssuer`. - try { - // Parse authMethod - final authMethodValue = session.authMethod; - final authMethod = - authMethodValue is Map - ? ClientAuthMethod.fromJson(authMethodValue) - : (authMethodValue as String?) ?? 'legacy'; - - // Restore DPoP key from session for revocation - // CRITICAL FIX: Use the stored key instead of generating a new one - // This ensures DPoP proofs match the token binding - final dpopKey = FlutterKey.fromJwk( - session.dpopKey as Map, - ); - - // If the token data cannot be stored, let's revoke it - final server = await serverFactory.fromIssuer( - session.tokenSet.iss, - authMethod, - dpopKey, - ); - await server.revoke( - session.tokenSet.refreshToken ?? - session.tokenSet.accessToken, - ); - } catch (_) { - // Let the original error propagate - } - } - - throw err; - }, - deleteOnError: (err) async { - return err is TokenRefreshError || - err is TokenRevokedError || - err is TokenInvalidError || - err is AuthMethodUnsatisfiableError; - }, - ), - ) { - // Set the getter function after construction - _getter = _createGetter(); - } - - /// Creates the getter function for refreshing sessions. - Future Function(AtprotoDid, GetCachedOptions, Session?) - _createGetter() { - return (sub, options, storedSession) async { - // There needs to be a previous session to be able to refresh. If - // storedSession is null, it means that the store does not contain - // a session for the given sub. - if (storedSession == null) { - // Because the session is not in the store, delStored() method - // will not be called by the CachedGetter class (because there is - // nothing to delete). This would typically happen if there is no - // synchronization mechanism between instances of this class. Let's - // make sure an event is dispatched here if this occurs. - const msg = 'The session was deleted by another process'; - final cause = TokenRefreshError(sub, msg); - _dispatchDeletedEvent(sub, cause); - throw cause; - } - - // From this point forward, throwing a TokenRefreshError will result in - // delStored() being called, resulting in an event being dispatched, - // even if the session was removed from the store through a concurrent - // access (which, normally, should not happen if a proper runtime lock - // was provided). - - // authMethod can be a Map (serialized ClientAuthMethod) or String ('legacy') - final authMethodValue = storedSession.authMethod; - final authMethod = - authMethodValue is Map - ? ClientAuthMethod.fromJson(authMethodValue) - : (authMethodValue as String?) ?? 'legacy'; - final tokenSet = storedSession.tokenSet; - - if (sub != tokenSet.sub) { - // Fool-proofing (e.g. against invalid session storage) - throw TokenRefreshError(sub, 'Stored session sub mismatch'); - } - - if (tokenSet.refreshToken == null) { - throw TokenRefreshError(sub, 'No refresh token available'); - } - - // Since refresh tokens can only be used once, we might run into - // concurrency issues if multiple instances (e.g. browser tabs) are - // trying to refresh the same token simultaneously. The chances of this - // happening when multiple instances are started simultaneously is - // reduced by randomizing the expiry time (see isStale above). The - // best solution is to use a mutex/lock to ensure that only one instance - // is refreshing the token at a time (runtime.usingLock) but that is not - // always possible. If no lock implementation is provided, we will use - // the store to check if a concurrent refresh occurred. - - // Restore dpopKey from stored private JWK with error handling - // CRITICAL FIX: Use the stored key instead of generating a new one - // This ensures DPoP proofs match the token binding during refresh - final FlutterKey dpopKey; - try { - dpopKey = FlutterKey.fromJwk( - storedSession.dpopKey as Map, - ); - } catch (e) { - // If key is corrupted, the session is unusable - force re-authentication - throw TokenRefreshError( - sub, - 'Corrupted DPoP key in stored session: $e. Re-authentication required.', - ); - } - - final server = await _serverFactory.fromIssuer( - tokenSet.iss, - authMethod, - dpopKey, - ); - - // Because refresh tokens can only be used once, we must not use the - // "signal" to abort the refresh, or throw any abort error beyond this - // point. Any thrown error beyond this point will prevent the - // SessionGetter from obtaining, and storing, the new token set, - // effectively rendering the currently saved session unusable. - options.signal?.throwIfCancelled(); - - try { - final newTokenSet = await server.refresh(tokenSet); - - if (sub != newTokenSet.sub) { - // The server returned another sub. Was the tokenSet manipulated? - throw TokenRefreshError(sub, 'Token set sub mismatch'); - } - - // CRITICAL FIX: Preserve the stored DPoP key (full private JWK) - // This ensures the same key is used across token refreshes - return Session( - dpopKey: storedSession.dpopKey, - tokenSet: newTokenSet, - authMethod: server.authMethod.toJson(), - ); - } catch (cause) { - // If the refresh token is invalid, let's try to recover from - // concurrency issues, or make sure the session is deleted by throwing - // a TokenRefreshError. - if (cause is OAuthResponseError && - cause.status == 400 && - cause.error == 'invalid_grant') { - // In case there is no lock implementation in the runtime, we will - // wait for a short time to give the other concurrent instances a - // chance to finish their refreshing of the token. If a concurrent - // refresh did occur, we will pretend that this one succeeded. - if (!_runtime.hasImplementationLock) { - await Future.delayed(Duration(seconds: 1)); - - final stored = await getStored(sub); - if (stored == null) { - // A concurrent refresh occurred and caused the session to be - // deleted (for a reason we can't know at this point). - - // Using a distinct error message mainly for debugging - // purposes. Also, throwing a TokenRefreshError to trigger - // deletion through the deleteOnError callback. - const msg = 'The session was deleted by another process'; - throw TokenRefreshError(sub, msg, cause: cause); - } else if (stored.tokenSet.accessToken != tokenSet.accessToken || - stored.tokenSet.refreshToken != tokenSet.refreshToken) { - // A concurrent refresh occurred. Pretend this one succeeded. - return stored; - } else { - // There were no concurrent refresh. The token is (likely) - // simply no longer valid. - } - } - - // Make sure the session gets deleted from the store - final msg = cause.errorDescription ?? 'The session was revoked'; - throw TokenRefreshError(sub, msg, cause: cause); - } - - // Re-throw the original exception if it wasn't an invalid_grant error - if (cause is Exception) { - throw cause; - } else { - throw Exception('Token refresh failed: $cause'); - } - } - }; - } - - @override - Future setStored(String key, Session value) async { - // Prevent tampering with the stored value - if (key != value.tokenSet.sub) { - throw TypeError(); - } - - await super.setStored(key, value); - - // Serialize authMethod to String for the event - // authMethod can be Map, String, or null - String? authMethodString; - if (value.authMethod is Map) { - authMethodString = jsonEncode(value.authMethod); - } else if (value.authMethod is String) { - authMethodString = value.authMethod as String; - } else { - authMethodString = null; - } - - _dispatchUpdatedEvent(key, value.dpopKey, authMethodString, value.tokenSet); - } - - @override - Future delStored(AtprotoDid key, [Object? cause]) async { - await super.delStored(key, cause); - _dispatchDeletedEvent(key, cause ?? Exception('Session deleted')); - } - - /// Gets a session, optionally refreshing it. - /// - /// Parameters: - /// - [sub]: The subject (user's DID) - /// - [refresh]: When `true`, forces a token refresh even if not expired. - /// When `false`, uses cached tokens even if expired. - /// When `'auto'`, refreshes only if expired (default). - Future getSession(AtprotoDid sub, [dynamic refresh = 'auto']) { - return get( - sub, - GetCachedOptions(noCache: refresh == true, allowStale: refresh == false), - ); - } - - @override - Future get(AtprotoDid key, [GetCachedOptions? options]) async { - final session = await _runtime.usingLock( - '@atproto-oauth-client-$key', - () async { - // Make sure, even if there is no signal in the options, that the - // request will be cancelled after at most 30 seconds. - final timeoutToken = CancellationToken(); - final timeoutTimer = Timer(Duration(seconds: 30), () => timeoutToken.cancel()); - - final combinedSignal = - options?.signal != null - ? combineSignals([options!.signal, timeoutToken]) - : CombinedCancellationToken([timeoutToken]); - - try { - return await super.get( - key, - GetCachedOptions( - signal: CancellationToken(), // Use combined signal - noCache: options?.noCache, - allowStale: options?.allowStale, - ), - ); - } finally { - timeoutTimer.cancel(); // Cancel timer before disposing token - combinedSignal.dispose(); - timeoutToken.dispose(); - } - }, - ); - - if (key != session.tokenSet.sub) { - // Fool-proofing (e.g. against invalid session storage) - throw Exception('Token set does not match the expected sub'); - } - - return session; - } - - void _dispatchUpdatedEvent( - String sub, - Map dpopKey, - String? authMethod, - TokenSet tokenSet, - ) { - final event = SessionUpdatedEvent( - sub: sub, - dpopKey: dpopKey, - authMethod: authMethod, - tokenSet: tokenSet, - ); - - _updatedController.add(event); - _eventTarget.dispatchCustomEvent('updated', event); - } - - void _dispatchDeletedEvent(String sub, Object cause) { - final event = SessionDeletedEvent(sub: sub, cause: cause); - - _deletedController.add(event); - _eventTarget.dispatchCustomEvent('deleted', event); - } - - /// Disposes of resources used by this session getter. - void dispose() { - _updatedController.close(); - _deletedController.close(); - _eventTarget.dispose(); - } -} - -/// Placeholder for OAuthResponseError -/// Will be implemented in later chunks -class OAuthResponseError implements Exception { - final int status; - final String? error; - final String? errorDescription; - - OAuthResponseError({required this.status, this.error, this.errorDescription}); -} - -/// Options for the CachedGetter. -class CachedGetterOptions { - /// Function to determine if a cached value is stale - final bool Function(K key, V value)? isStale; - - /// Function called when storing a value fails - final Future Function(Object err, K key, V value)? onStoreError; - - /// Function to determine if a value should be deleted on error - final Future Function(Object err)? deleteOnError; - - const CachedGetterOptions({ - this.isStale, - this.onStoreError, - this.deleteOnError, - }); -} - -/// A pending item in the cache. -class _PendingItem { - final Future<({V value, bool isFresh})> future; - - _PendingItem(this.future); -} - -/// Wrapper utility that uses a store to speed up the retrieval of values. -/// -/// The CachedGetter ensures that at most one fresh call is ever being made -/// for a given key. It also contains logic for reading from the cache which, -/// if the cache is based on localStorage/indexedDB, will sync across multiple -/// tabs (for a given key). -/// -/// This is an abstract base class. Subclasses should provide the getter -/// function and any additional logic. -class CachedGetter { - final SimpleStore _store; - final CachedGetterOptions _options; - final Map> _pending = {}; - - late Future Function(K, GetCachedOptions, V?) _getter; - - CachedGetter({ - required SimpleStore sessionStore, - required Future Function(K, GetCachedOptions, V?)? getter, - required CachedGetterOptions options, - }) : _store = sessionStore, - _options = options { - if (getter != null) { - _getter = getter; - } - } - - Future get(K key, [GetCachedOptions? options]) async { - options ??= GetCachedOptions(); - final signal = options.signal; - final noCache = options.noCache ?? false; - final allowStale = options.allowStale ?? false; - - signal?.throwIfCancelled(); - - final isStale = _options.isStale; - final deleteOnError = _options.deleteOnError; - - // Determine if a stored value can be used - bool allowStored(V value) { - if (noCache) return false; // Never allow stored values - if (allowStale || isStale == null) return true; // Always allow - return !isStale(key, value); // Check if stale - } - - // As long as concurrent requests are made for the same key, only one - // request will be made to the getStored & getter functions at a time. - _PendingItem? previousExecutionFlow; - while ((previousExecutionFlow = _pending[key]) != null) { - try { - final result = await previousExecutionFlow!.future; - final isFresh = result.isFresh; - final value = result.value; - - // Use the concurrent request's result if it is fresh - if (isFresh) return value; - // Use the concurrent request's result if not fresh (loaded from the - // store), and matches the conditions for using a stored value. - if (allowStored(value)) return value; - } catch (_) { - // Ignore errors from previous execution flows (they will have been - // propagated by that flow). - } - - // Break the loop if the signal was cancelled - signal?.throwIfCancelled(); - } - - final currentExecutionFlow = _PendingItem( - Future(() async { - final storedValue = await getStored(key, signal: signal); - - if (storedValue != null && allowStored(storedValue)) { - // Use the stored value as return value for the current execution - // flow. Notify other concurrent execution flows that we got a value, - // but that it came from the store (isFresh = false). - return (value: storedValue, isFresh: false); - } - - return Future(() async { - return await _getter(key, options!, storedValue); - }) - .catchError((err) async { - if (storedValue != null) { - try { - if (deleteOnError != null && await deleteOnError(err)) { - await delStored(key, err); - } - } catch (error) { - throw Exception('Error while deleting stored value: $error'); - } - } - throw err; - }) - .then((value) async { - // The value should be stored even if the signal was cancelled. - await setStored(key, value); - return (value: value, isFresh: true); - }); - }).whenComplete(() { - _pending.remove(key); - }), - ); - - if (_pending.containsKey(key)) { - // This should never happen. There must not be any 'await' - // statement between this and the loop iteration check. - throw Exception('Concurrent request for the same key'); - } - - _pending[key] = currentExecutionFlow; - - final result = await currentExecutionFlow.future; - return result.value; - } - - Future getStored(K key, {CancellationToken? signal}) async { - try { - return await _store.get(key, signal: signal); - } catch (err) { - return null; - } - } - - Future setStored(K key, V value) async { - try { - await _store.set(key, value); - } catch (err) { - final onStoreError = _options.onStoreError; - if (onStoreError != null) { - await onStoreError(err, key, value); - } - } - } - - Future delStored(K key, [Object? cause]) async { - await _store.del(key); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/session/state_store.dart b/packages/atproto_oauth_flutter/lib/src/session/state_store.dart deleted file mode 100644 index 26f19f7..0000000 --- a/packages/atproto_oauth_flutter/lib/src/session/state_store.dart +++ /dev/null @@ -1,112 +0,0 @@ -/// Internal state data stored during OAuth authorization flow. -/// -/// This contains ephemeral data needed to complete the OAuth flow, -/// such as PKCE code verifiers, state parameters, and nonces. -class InternalStateData { - /// The OAuth issuer URL - final String iss; - - /// The DPoP key used for this authorization - final Map dpopKey; - - /// Client authentication method (serialized as Map or String) - /// - /// Can be: - /// - A Map containing {method: 'private_key_jwt', kid: '...'} for private key JWT - /// - A Map containing {method: 'none'} for no authentication - /// - A String 'legacy' for backwards compatibility - /// - null (defaults to 'legacy' when loading) - final dynamic authMethod; - - /// PKCE code verifier for authorization code flow - final String? verifier; - - /// The redirect URI used during authorization - /// MUST match exactly during token exchange - final String? redirectUri; - - /// Application state to preserve across the OAuth flow - final String? appState; - - const InternalStateData({ - required this.iss, - required this.dpopKey, - this.authMethod, - this.verifier, - this.redirectUri, - this.appState, - }); - - /// Creates an instance from a JSON map. - factory InternalStateData.fromJson(Map json) { - return InternalStateData( - iss: json['iss'] as String, - dpopKey: json['dpopKey'] as Map, - authMethod: json['authMethod'], // Can be Map or String - verifier: json['verifier'] as String?, - redirectUri: json['redirectUri'] as String?, - appState: json['appState'] as String?, - ); - } - - /// Converts this instance to a JSON map. - Map toJson() { - final json = {'iss': iss, 'dpopKey': dpopKey}; - - if (authMethod != null) json['authMethod'] = authMethod; - if (verifier != null) json['verifier'] = verifier; - if (redirectUri != null) json['redirectUri'] = redirectUri; - if (appState != null) json['appState'] = appState; - - return json; - } -} - -/// Abstract storage interface for OAuth state data. -/// -/// Implementations should store state data temporarily during the OAuth flow. -/// This data is typically short-lived and can be cleared after successful -/// authorization or timeout. -/// -/// Example implementation using in-memory storage: -/// ```dart -/// class MemoryStateStore implements StateStore { -/// final Map _store = {}; -/// -/// @override -/// Future get(String key) async => _store[key]; -/// -/// @override -/// Future set(String key, InternalStateData data) async { -/// _store[key] = data; -/// } -/// -/// @override -/// Future del(String key) async { -/// _store.remove(key); -/// } -/// } -/// ``` -abstract class StateStore { - /// Retrieves state data for the given key. - /// - /// Returns `null` if no data exists for the key. - Future get(String key); - - /// Stores state data for the given key. - /// - /// Overwrites any existing data for the key. - Future set(String key, InternalStateData data); - - /// Deletes state data for the given key. - /// - /// Does nothing if no data exists for the key. - Future del(String key); - - /// Optionally clears all state data. - /// - /// Implementations may choose not to implement this method. - Future clear() async { - // Default implementation does nothing - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/types.dart b/packages/atproto_oauth_flutter/lib/src/types.dart deleted file mode 100644 index 465b0f5..0000000 --- a/packages/atproto_oauth_flutter/lib/src/types.dart +++ /dev/null @@ -1,352 +0,0 @@ -// Note: These types are not prefixed with `OAuth` because they are not specific -// to OAuth. They are specific to this package. OAuth specific types will be in -// a separate oauth-types module or imported from an external package. - -// TODO: These types currently reference schemas from @atproto/oauth-types which -// need to be ported to Dart. For now, we're using Map as placeholders. -// These will be replaced with proper typed classes once oauth-types is ported. - -/// Options for initiating an authorization request. -/// -/// Omits client_id, response_mode, response_type, login_hint, -/// code_challenge, and code_challenge_method from OAuthAuthorizationRequestParameters -/// as these are managed internally. -class AuthorizeOptions { - /// Optional URI to redirect to after authorization - final String? redirectUri; - - /// Optional state parameter for CSRF protection - final String? state; - - /// Optional scope parameter defining requested permissions - final String? scope; - - /// Optional nonce parameter for replay protection - final String? nonce; - - /// Optional DPoP JKT (JSON Web Key Thumbprint) - final String? dpopJkt; - - /// Optional max age in seconds for authentication - final int? maxAge; - - /// Optional claims parameter - final Map? claims; - - /// Optional UI locales - final String? uiLocales; - - /// Optional ID token hint - final String? idTokenHint; - - /// Optional display mode - final String? display; - - /// Optional prompt value - final String? prompt; - - /// Optional authorization details - final Map? authorizationDetails; - - const AuthorizeOptions({ - this.redirectUri, - this.state, - this.scope, - this.nonce, - this.dpopJkt, - this.maxAge, - this.claims, - this.uiLocales, - this.idTokenHint, - this.display, - this.prompt, - this.authorizationDetails, - }); - - Map toJson() { - final map = {}; - if (redirectUri != null) map['redirect_uri'] = redirectUri; - if (state != null) map['state'] = state; - if (scope != null) map['scope'] = scope; - if (nonce != null) map['nonce'] = nonce; - if (dpopJkt != null) map['dpop_jkt'] = dpopJkt; - if (maxAge != null) map['max_age'] = maxAge; - if (claims != null) map['claims'] = claims; - if (uiLocales != null) map['ui_locales'] = uiLocales; - if (idTokenHint != null) map['id_token_hint'] = idTokenHint; - if (display != null) map['display'] = display; - if (prompt != null) map['prompt'] = prompt; - if (authorizationDetails != null) { - map['authorization_details'] = authorizationDetails; - } - return map; - } -} - -/// Options for handling OAuth callback. -class CallbackOptions { - /// Optional redirect URI that was used in the authorization request - final String? redirectUri; - - const CallbackOptions({this.redirectUri}); - - Map toJson() { - final map = {}; - if (redirectUri != null) map['redirect_uri'] = redirectUri; - return map; - } -} - -/// Client metadata for OAuth configuration. -/// -/// TODO: This extends the base oauthClientMetadataSchema with specific -/// client_id validation. Once oauth-types is ported, this will properly -/// validate client_id as either discoverable or loopback type. -class ClientMetadata { - /// Client identifier (either discoverable HTTPS URI or loopback URI) - final String? clientId; - - /// Array of redirect URIs - final List redirectUris; - - /// Response types supported by the client - final List responseTypes; - - /// Grant types supported by the client - final List grantTypes; - - /// Optional scope - final String? scope; - - /// Token endpoint authentication method - final String tokenEndpointAuthMethod; - - /// Optional token endpoint authentication signing algorithm - final String? tokenEndpointAuthSigningAlg; - - /// Optional userinfo signed response algorithm - final String? userinfoSignedResponseAlg; - - /// Optional userinfo encrypted response algorithm - final String? userinfoEncryptedResponseAlg; - - /// Optional JWKS URI - final String? jwksUri; - - /// Optional JWKS - final Map? jwks; - - /// Application type (web or native) - final String applicationType; - - /// Subject type (public or pairwise) - final String subjectType; - - /// Optional request object signing algorithm - final String? requestObjectSigningAlg; - - /// Optional ID token signed response algorithm - final String? idTokenSignedResponseAlg; - - /// Authorization signed response algorithm - final String authorizationSignedResponseAlg; - - /// Optional authorization encrypted response encoding - final String? authorizationEncryptedResponseEnc; - - /// Optional authorization encrypted response algorithm - final String? authorizationEncryptedResponseAlg; - - /// Optional client name - final String? clientName; - - /// Optional client URI - final String? clientUri; - - /// Optional policy URI - final String? policyUri; - - /// Optional terms of service URI - final String? tosUri; - - /// Optional logo URI - final String? logoUri; - - /// Optional default max age - final int? defaultMaxAge; - - /// Optional require auth time - final bool? requireAuthTime; - - /// Optional contact emails - final List? contacts; - - /// Optional TLS client certificate bound access tokens - final bool? tlsClientCertificateBoundAccessTokens; - - /// Optional DPoP bound access tokens - final bool? dpopBoundAccessTokens; - - /// Optional authorization details types - final List? authorizationDetailsTypes; - - const ClientMetadata({ - this.clientId, - required this.redirectUris, - this.responseTypes = const ['code'], - this.grantTypes = const ['authorization_code'], - this.scope, - this.tokenEndpointAuthMethod = 'client_secret_basic', - this.tokenEndpointAuthSigningAlg, - this.userinfoSignedResponseAlg, - this.userinfoEncryptedResponseAlg, - this.jwksUri, - this.jwks, - this.applicationType = 'web', - this.subjectType = 'public', - this.requestObjectSigningAlg, - this.idTokenSignedResponseAlg, - this.authorizationSignedResponseAlg = 'RS256', - this.authorizationEncryptedResponseEnc, - this.authorizationEncryptedResponseAlg, - this.clientName, - this.clientUri, - this.policyUri, - this.tosUri, - this.logoUri, - this.defaultMaxAge, - this.requireAuthTime, - this.contacts, - this.tlsClientCertificateBoundAccessTokens, - this.dpopBoundAccessTokens, - this.authorizationDetailsTypes, - }); - - Map toJson() { - final map = { - 'redirect_uris': redirectUris, - 'response_types': responseTypes, - 'grant_types': grantTypes, - 'token_endpoint_auth_method': tokenEndpointAuthMethod, - 'application_type': applicationType, - 'subject_type': subjectType, - 'authorization_signed_response_alg': authorizationSignedResponseAlg, - }; - - if (clientId != null) map['client_id'] = clientId; - if (scope != null) map['scope'] = scope; - if (tokenEndpointAuthSigningAlg != null) { - map['token_endpoint_auth_signing_alg'] = tokenEndpointAuthSigningAlg; - } - if (userinfoSignedResponseAlg != null) { - map['userinfo_signed_response_alg'] = userinfoSignedResponseAlg; - } - if (userinfoEncryptedResponseAlg != null) { - map['userinfo_encrypted_response_alg'] = userinfoEncryptedResponseAlg; - } - if (jwksUri != null) map['jwks_uri'] = jwksUri; - if (jwks != null) map['jwks'] = jwks; - if (requestObjectSigningAlg != null) { - map['request_object_signing_alg'] = requestObjectSigningAlg; - } - if (idTokenSignedResponseAlg != null) { - map['id_token_signed_response_alg'] = idTokenSignedResponseAlg; - } - if (authorizationEncryptedResponseEnc != null) { - map['authorization_encrypted_response_enc'] = - authorizationEncryptedResponseEnc; - } - if (authorizationEncryptedResponseAlg != null) { - map['authorization_encrypted_response_alg'] = - authorizationEncryptedResponseAlg; - } - if (clientName != null) map['client_name'] = clientName; - if (clientUri != null) map['client_uri'] = clientUri; - if (policyUri != null) map['policy_uri'] = policyUri; - if (tosUri != null) map['tos_uri'] = tosUri; - if (logoUri != null) map['logo_uri'] = logoUri; - if (defaultMaxAge != null) map['default_max_age'] = defaultMaxAge; - if (requireAuthTime != null) map['require_auth_time'] = requireAuthTime; - if (contacts != null) map['contacts'] = contacts; - if (tlsClientCertificateBoundAccessTokens != null) { - map['tls_client_certificate_bound_access_tokens'] = - tlsClientCertificateBoundAccessTokens; - } - if (dpopBoundAccessTokens != null) { - map['dpop_bound_access_tokens'] = dpopBoundAccessTokens; - } - if (authorizationDetailsTypes != null) { - map['authorization_details_types'] = authorizationDetailsTypes; - } - - return map; - } - - factory ClientMetadata.fromJson(Map json) { - return ClientMetadata( - clientId: json['client_id'] as String?, - redirectUris: - json['redirect_uris'] != null - ? (json['redirect_uris'] as List) - .map((e) => e as String) - .toList() - : [], - responseTypes: - json['response_types'] != null - ? (json['response_types'] as List) - .map((e) => e as String) - .toList() - : const ['code'], - grantTypes: - json['grant_types'] != null - ? (json['grant_types'] as List) - .map((e) => e as String) - .toList() - : const ['authorization_code'], - scope: json['scope'] as String?, - tokenEndpointAuthMethod: - json['token_endpoint_auth_method'] as String? ?? - 'client_secret_basic', - tokenEndpointAuthSigningAlg: - json['token_endpoint_auth_signing_alg'] as String?, - userinfoSignedResponseAlg: - json['userinfo_signed_response_alg'] as String?, - userinfoEncryptedResponseAlg: - json['userinfo_encrypted_response_alg'] as String?, - jwksUri: json['jwks_uri'] as String?, - jwks: json['jwks'] as Map?, - applicationType: json['application_type'] as String? ?? 'web', - subjectType: json['subject_type'] as String? ?? 'public', - requestObjectSigningAlg: json['request_object_signing_alg'] as String?, - idTokenSignedResponseAlg: json['id_token_signed_response_alg'] as String?, - authorizationSignedResponseAlg: - json['authorization_signed_response_alg'] as String? ?? 'RS256', - authorizationEncryptedResponseEnc: - json['authorization_encrypted_response_enc'] as String?, - authorizationEncryptedResponseAlg: - json['authorization_encrypted_response_alg'] as String?, - clientName: json['client_name'] as String?, - clientUri: json['client_uri'] as String?, - policyUri: json['policy_uri'] as String?, - tosUri: json['tos_uri'] as String?, - logoUri: json['logo_uri'] as String?, - defaultMaxAge: json['default_max_age'] as int?, - requireAuthTime: json['require_auth_time'] as bool?, - contacts: - json['contacts'] != null - ? (json['contacts'] as List) - .map((e) => e as String) - .toList() - : null, - tlsClientCertificateBoundAccessTokens: - json['tls_client_certificate_bound_access_tokens'] as bool?, - dpopBoundAccessTokens: json['dpop_bound_access_tokens'] as bool?, - authorizationDetailsTypes: - json['authorization_details_types'] != null - ? (json['authorization_details_types'] as List) - .map((e) => e as String) - .toList() - : null, - ); - } -} diff --git a/packages/atproto_oauth_flutter/lib/src/util.dart b/packages/atproto_oauth_flutter/lib/src/util.dart deleted file mode 100644 index 46bc309..0000000 --- a/packages/atproto_oauth_flutter/lib/src/util.dart +++ /dev/null @@ -1,195 +0,0 @@ -import 'dart:async'; - -/// Returns the input if it's a String, otherwise returns null. -String? ifString(V v) => v is String ? v : null; - -/// Extracts the MIME type from Content-Type header. -/// -/// Example: "application/json; charset=utf-8" -> "application/json" -String? contentMime(Map headers) { - final contentType = headers['content-type']; - if (contentType == null) return null; - return contentType.split(';')[0].trim(); -} - -/// Event detail map for custom event handling. -/// -/// This is a simplified version of TypeScript's CustomEvent pattern, -/// adapted for Dart using StreamController and typed events. -/// -/// Example: -/// ```dart -/// final target = CustomEventTarget(); -/// final subscription = target.addEventListener('myEvent', (String detail) { -/// print('Received: $detail'); -/// }); -/// -/// // Later, to remove the listener: -/// subscription.cancel(); -/// ``` -class CustomEventTarget { - final Map> _controllers = {}; - - /// Add an event listener for a specific event type. - /// - /// Returns a [StreamSubscription] that can be cancelled to remove the listener. - /// - /// Throws [TypeError] if an event type is already registered with a different type parameter. - /// - /// Example: - /// ```dart - /// final subscription = target.addEventListener('event', (detail) => print(detail)); - /// subscription.cancel(); // Remove this specific listener - /// ``` - StreamSubscription addEventListener( - String type, - void Function(T detail) callback, - ) { - final existingController = _controllers[type]; - - // Check if a controller already exists with a different type - if (existingController != null && - existingController is! StreamController) { - throw TypeError(); - } - - final controller = - _controllers.putIfAbsent(type, () => StreamController.broadcast()) - as StreamController; - - return controller.stream.listen(callback); - } - - /// Dispatch a custom event with detail data. - /// - /// Returns true if the event was dispatched successfully. - bool dispatchCustomEvent(String type, T detail) { - final controller = _controllers[type]; - if (controller == null) return false; - - (controller as StreamController).add(detail); - return true; - } - - /// Dispose of all stream controllers. - /// - /// Call this when the event target is no longer needed to prevent memory leaks. - void dispose() { - for (final controller in _controllers.values) { - controller.close(); - } - _controllers.clear(); - } -} - -/// Combines multiple cancellation tokens into a single cancellable operation. -/// -/// This is a Dart adaptation of the TypeScript combineSignals function. -/// Since Dart doesn't have AbortSignal/AbortController, we use CancellationToken -/// pattern with StreamController. -/// -/// The returned controller will be cancelled if any of the input tokens are cancelled. -class CombinedCancellationToken { - final StreamController _controller = StreamController.broadcast(); - final List> _subscriptions = []; - bool _isCancelled = false; - Object? _reason; - - CombinedCancellationToken(List tokens) { - for (final token in tokens) { - if (token != null) { - if (token.isCancelled) { - cancel(Exception('Operation was cancelled: ${token.reason}')); - return; - } - - final subscription = token.stream.listen((_) { - cancel(Exception('Operation was cancelled: ${token.reason}')); - }); - _subscriptions.add(subscription); - } - } - } - - /// Whether this operation has been cancelled. - bool get isCancelled => _isCancelled; - - /// The reason for cancellation, if any. - Object? get reason => _reason; - - /// Stream that emits when the operation is cancelled. - Stream get stream => _controller.stream; - - /// Cancel the operation with an optional reason. - void cancel([Object? reason]) { - if (_isCancelled) return; - - _isCancelled = true; - _reason = reason ?? Exception('Operation was cancelled'); - - _controller.add(null); - dispose(); - } - - /// Clean up resources. - void dispose() { - for (final subscription in _subscriptions) { - subscription.cancel(); - } - _subscriptions.clear(); - _controller.close(); - } -} - -/// Represents a cancellable operation. -/// -/// This is a Dart equivalent of AbortSignal in JavaScript. -class CancellationToken { - final StreamController _controller = StreamController.broadcast(); - bool _isCancelled = false; - Object? _reason; - - CancellationToken(); - - /// Whether this operation has been cancelled. - bool get isCancelled => _isCancelled; - - /// The reason for cancellation, if any. - Object? get reason => _reason; - - /// Stream that emits when the operation is cancelled. - Stream get stream => _controller.stream; - - /// Cancel the operation with an optional reason. - void cancel([Object? reason]) { - if (_isCancelled) return; - - _isCancelled = true; - _reason = reason ?? Exception('Operation was cancelled'); - - // Only add to stream if not already closed - if (!_controller.isClosed) { - _controller.add(null); - } - } - - /// Throw an exception if the operation has been cancelled. - void throwIfCancelled() { - if (_isCancelled) { - throw _reason ?? Exception('Operation was cancelled'); - } - } - - /// Dispose of the stream controller. - void dispose() { - _controller.close(); - } -} - -/// Combines multiple cancellation tokens into a single token. -/// -/// If any of the input tokens are cancelled, the returned token will also be cancelled. -/// The returned token should be disposed when no longer needed. -CombinedCancellationToken combineSignals(List signals) { - return CombinedCancellationToken(signals); -} diff --git a/packages/atproto_oauth_flutter/lib/src/utils/lock.dart b/packages/atproto_oauth_flutter/lib/src/utils/lock.dart deleted file mode 100644 index a69ec7f..0000000 --- a/packages/atproto_oauth_flutter/lib/src/utils/lock.dart +++ /dev/null @@ -1,100 +0,0 @@ -import 'dart:async'; - -import '../runtime/runtime_implementation.dart'; - -/// A map storing active locks by name. -/// -/// Each lock is represented as a Future that completes when the lock is released. -/// This allows queuing of operations waiting for the same lock. -final Map> _locks = {}; - -/// Acquires a lock for the given name. -/// -/// Returns a function that releases the lock when called. -/// The lock is automatically added to the queue of pending operations. -/// -/// This implements a fair (FIFO) mutex pattern where operations are executed -/// in the order they acquire the lock. -Future _acquireLocalLock(Object name) { - final completer = Completer(); - - // Get the previous lock in the queue (or a resolved promise if none) - final prev = _locks[name] ?? Future.value(); - - // Create a completer for the release function - final releaseCompleter = Completer(); - - // Chain onto the previous lock - final next = prev.then((_) { - // This runs when we've acquired the lock - return releaseCompleter.future; - }); - - // Store our lock as the new tail of the queue - _locks[name] = next; - - // Resolve the acquire promise with the release function - prev.then((_) { - void release() { - // Only delete the lock if it's still the current one - // (it might have been replaced by a newer lock) - if (_locks[name] == next) { - _locks.remove(name); - } - - // Complete the release, allowing the next operation to proceed - if (!releaseCompleter.isCompleted) { - releaseCompleter.complete(); - } - } - - completer.complete(release); - }); - - return completer.future; -} - -/// Executes a function while holding a named lock. -/// -/// This is a local (in-memory) lock implementation that prevents concurrent -/// execution of the same operation within a single isolate/process. -/// -/// The lock is automatically released when the function completes or throws an error. -/// -/// Example: -/// ```dart -/// final result = await requestLocalLock('my-operation', () async { -/// // Only one execution at a time for 'my-operation' -/// return await performCriticalOperation(); -/// }); -/// ``` -/// -/// Use cases: -/// - Token refresh (prevent multiple simultaneous refresh requests) -/// - Database transactions -/// - File operations -/// - Any operation that must not run concurrently with itself -/// -/// Note: This is an in-memory lock. It does not work across: -/// - Multiple isolates -/// - Multiple processes -/// - Multiple app instances -/// -/// For cross-process locking, implement a platform-specific RuntimeLock. -Future requestLocalLock(String name, FutureOr Function() fn) async { - // Acquire the lock and get the release function - final release = await _acquireLocalLock(name); - - try { - // Execute the function while holding the lock - return await fn(); - } finally { - // Always release the lock, even if the function throws - release(); - } -} - -/// Convenience getter that returns the requestLocalLock function as a RuntimeLock. -/// -/// This can be used as the default implementation for RuntimeImplementation.requestLock. -RuntimeLock get requestLocalLockImpl => requestLocalLock; diff --git a/packages/atproto_oauth_flutter/pubspec.lock b/packages/atproto_oauth_flutter/pubspec.lock deleted file mode 100644 index ac5da0d..0000000 --- a/packages/atproto_oauth_flutter/pubspec.lock +++ /dev/null @@ -1,530 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - async: - dependency: transitive - description: - name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 - url: "https://pub.dev" - source: hosted - version: "2.12.0" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - characters: - dependency: transitive - description: - name: characters - sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - clock: - dependency: transitive - description: - name: clock - sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" - source: hosted - version: "1.1.2" - collection: - dependency: "direct main" - description: - name: collection - sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" - source: hosted - version: "1.19.1" - convert: - dependency: "direct main" - description: - name: convert - sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - crypto: - dependency: "direct main" - description: - name: crypto - sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" - url: "https://pub.dev" - source: hosted - version: "3.0.6" - desktop_webview_window: - dependency: transitive - description: - name: desktop_webview_window - sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" - url: "https://pub.dev" - source: hosted - version: "0.2.3" - dio: - dependency: "direct main" - description: - name: dio - sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 - url: "https://pub.dev" - source: hosted - version: "5.9.0" - dio_web_adapter: - dependency: transitive - description: - name: dio_web_adapter - sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78" - url: "https://pub.dev" - source: hosted - version: "2.1.1" - fake_async: - dependency: transitive - description: - name: fake_async - sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" - source: hosted - version: "1.3.3" - ffi: - dependency: transitive - description: - name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_lints: - dependency: "direct dev" - description: - name: flutter_lints - sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" - source: hosted - version: "5.0.0" - flutter_secure_storage: - dependency: "direct main" - description: - name: flutter_secure_storage - sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" - url: "https://pub.dev" - source: hosted - version: "9.2.4" - flutter_secure_storage_linux: - dependency: transitive - description: - name: flutter_secure_storage_linux - sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 - url: "https://pub.dev" - source: hosted - version: "1.2.3" - flutter_secure_storage_macos: - dependency: transitive - description: - name: flutter_secure_storage_macos - sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" - url: "https://pub.dev" - source: hosted - version: "3.1.3" - flutter_secure_storage_platform_interface: - dependency: transitive - description: - name: flutter_secure_storage_platform_interface - sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 - url: "https://pub.dev" - source: hosted - version: "1.1.2" - flutter_secure_storage_web: - dependency: transitive - description: - name: flutter_secure_storage_web - sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 - url: "https://pub.dev" - source: hosted - version: "1.2.1" - flutter_secure_storage_windows: - dependency: transitive - description: - name: flutter_secure_storage_windows - sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 - url: "https://pub.dev" - source: hosted - version: "3.1.2" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_auth_2: - dependency: "direct main" - description: - name: flutter_web_auth_2 - sha256: "3c14babeaa066c371f3a743f204dd0d348b7d42ffa6fae7a9847a521aff33696" - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_web_auth_2_platform_interface: - dependency: transitive - description: - name: flutter_web_auth_2_platform_interface - sha256: c63a472c8070998e4e422f6b34a17070e60782ac442107c70000dd1bed645f4d - url: "https://pub.dev" - source: hosted - version: "4.1.0" - flutter_web_plugins: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - http: - dependency: "direct main" - description: - name: http - sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 - url: "https://pub.dev" - source: hosted - version: "1.5.0" - http_parser: - dependency: transitive - description: - name: http_parser - sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" - source: hosted - version: "4.1.2" - js: - dependency: transitive - description: - name: js - sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 - url: "https://pub.dev" - source: hosted - version: "0.6.7" - leak_tracker: - dependency: transitive - description: - name: leak_tracker - sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" - source: hosted - version: "11.0.2" - leak_tracker_flutter_testing: - dependency: transitive - description: - name: leak_tracker_flutter_testing - sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" - source: hosted - version: "3.0.10" - leak_tracker_testing: - dependency: transitive - description: - name: leak_tracker_testing - sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" - source: hosted - version: "3.0.2" - lints: - dependency: transitive - description: - name: lints - sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.dev" - source: hosted - version: "5.1.1" - matcher: - dependency: transitive - description: - name: matcher - sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 - url: "https://pub.dev" - source: hosted - version: "0.12.17" - material_color_utilities: - dependency: transitive - description: - name: material_color_utilities - sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec - url: "https://pub.dev" - source: hosted - version: "0.11.1" - meta: - dependency: transitive - description: - name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c - url: "https://pub.dev" - source: hosted - version: "1.16.0" - mime: - dependency: transitive - description: - name: mime - sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" - source: hosted - version: "2.0.0" - path: - dependency: transitive - description: - name: path - sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" - source: hosted - version: "1.9.1" - path_provider: - dependency: transitive - description: - name: path_provider - sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" - url: "https://pub.dev" - source: hosted - version: "2.1.5" - path_provider_android: - dependency: transitive - description: - name: path_provider_android - sha256: "3b4c1fc3aa55ddc9cd4aa6759984330d5c8e66aa7702a6223c61540dc6380c37" - url: "https://pub.dev" - source: hosted - version: "2.2.19" - path_provider_foundation: - dependency: transitive - description: - name: path_provider_foundation - sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd" - url: "https://pub.dev" - source: hosted - version: "2.4.2" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 - url: "https://pub.dev" - source: hosted - version: "2.2.1" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" - url: "https://pub.dev" - source: hosted - version: "2.1.2" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" - source: hosted - version: "2.3.0" - platform: - dependency: transitive - description: - name: platform - sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" - source: hosted - version: "3.1.6" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" - source: hosted - version: "2.1.8" - pointycastle: - dependency: "direct main" - description: - name: pointycastle - sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" - url: "https://pub.dev" - source: hosted - version: "3.9.1" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" - source_span: - dependency: transitive - description: - name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" - url: "https://pub.dev" - source: hosted - version: "1.10.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" - source: hosted - version: "1.12.1" - stream_channel: - dependency: transitive - description: - name: stream_channel - sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" - source: hosted - version: "2.1.4" - string_scanner: - dependency: transitive - description: - name: string_scanner - sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" - source: hosted - version: "1.4.1" - term_glyph: - dependency: transitive - description: - name: term_glyph - sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" - source: hosted - version: "1.2.2" - test_api: - dependency: transitive - description: - name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" - url: "https://pub.dev" - source: hosted - version: "0.7.6" - typed_data: - dependency: transitive - description: - name: typed_data - sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" - source: hosted - version: "1.4.0" - url_launcher: - dependency: transitive - description: - name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 - url: "https://pub.dev" - source: hosted - version: "6.3.2" - url_launcher_android: - dependency: transitive - description: - name: url_launcher_android - sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" - url: "https://pub.dev" - source: hosted - version: "6.3.20" - url_launcher_ios: - dependency: transitive - description: - name: url_launcher_ios - sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7 - url: "https://pub.dev" - source: hosted - version: "6.3.4" - url_launcher_linux: - dependency: transitive - description: - name: url_launcher_linux - sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935" - url: "https://pub.dev" - source: hosted - version: "3.2.1" - url_launcher_macos: - dependency: transitive - description: - name: url_launcher_macos - sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f - url: "https://pub.dev" - source: hosted - version: "3.2.3" - url_launcher_platform_interface: - dependency: transitive - description: - name: url_launcher_platform_interface - sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" - url: "https://pub.dev" - source: hosted - version: "2.3.2" - url_launcher_web: - dependency: transitive - description: - name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" - url: "https://pub.dev" - source: hosted - version: "2.4.1" - url_launcher_windows: - dependency: transitive - description: - name: url_launcher_windows - sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77" - url: "https://pub.dev" - source: hosted - version: "3.1.4" - vector_math: - dependency: transitive - description: - name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" - source: hosted - version: "2.2.0" - vm_service: - dependency: transitive - description: - name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" - url: "https://pub.dev" - source: hosted - version: "14.3.1" - web: - dependency: transitive - description: - name: web - sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" - source: hosted - version: "1.1.1" - win32: - dependency: transitive - description: - name: win32 - sha256: "329edf97fdd893e0f1e3b9e88d6a0e627128cc17cc316a8d67fda8f1451178ba" - url: "https://pub.dev" - source: hosted - version: "5.13.0" - window_to_front: - dependency: transitive - description: - name: window_to_front - sha256: "7aef379752b7190c10479e12b5fd7c0b9d92adc96817d9e96c59937929512aee" - url: "https://pub.dev" - source: hosted - version: "0.0.3" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" - source: hosted - version: "1.1.0" -sdks: - dart: ">=3.8.0-0 <4.0.0" - flutter: ">=3.29.0" diff --git a/packages/atproto_oauth_flutter/pubspec.yaml b/packages/atproto_oauth_flutter/pubspec.yaml deleted file mode 100644 index d258488..0000000 --- a/packages/atproto_oauth_flutter/pubspec.yaml +++ /dev/null @@ -1,24 +0,0 @@ -name: atproto_oauth_flutter -description: Official AT Protocol OAuth client for Flutter - 1:1 port of @atproto/oauth-client -version: 0.1.0 -publish_to: none - -environment: - sdk: ^3.7.2 - -dependencies: - flutter: - sdk: flutter - dio: ^5.9.0 - flutter_secure_storage: ^9.2.2 - flutter_web_auth_2: ^4.1.0 - crypto: ^3.0.3 - pointycastle: ^3.9.1 - convert: ^3.1.1 - collection: ^1.18.0 - http: ^1.2.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^5.0.0 diff --git a/packages/atproto_oauth_flutter/test/identity_resolver_test.dart b/packages/atproto_oauth_flutter/test/identity_resolver_test.dart deleted file mode 100644 index be68579..0000000 --- a/packages/atproto_oauth_flutter/test/identity_resolver_test.dart +++ /dev/null @@ -1,245 +0,0 @@ -/// Unit tests for the identity resolution layer. -/// -/// Note: These are basic validation tests. Real integration tests would -/// require network calls to live services. - -import 'package:flutter_test/flutter_test.dart'; -import 'package:atproto_oauth_flutter/src/identity/identity.dart'; - -void main() { - group('DID Validation', () { - test('isDidPlc validates did:plc correctly', () { - // did:plc must be exactly 32 chars total (8 prefix + 24 base32 [a-z2-7]) - expect(isDidPlc('did:plc:z72i7hdynmk6r22z27h6abc2'), isTrue); - expect(isDidPlc('did:plc:2222222222222222222222ab'), isTrue); - expect(isDidPlc('did:plc:abcdefgabcdefgabcdefgabc'), isTrue); - - // Wrong length - expect(isDidPlc('did:plc:short'), isFalse); - expect(isDidPlc('did:plc:toolonggggggggggggggggggggg'), isFalse); - - // Wrong prefix - expect(isDidPlc('did:web:example.com'), isFalse); - - // Invalid characters (not base32) - expect(isDidPlc('did:plc:0000000000000000000000'), isFalse); // has 0 - expect(isDidPlc('did:plc:1111111111111111111111'), isFalse); // has 1 - }); - - test('isDidWeb validates did:web correctly', () { - expect(isDidWeb('did:web:example.com'), isTrue); - expect(isDidWeb('did:web:example.com:user:alice'), isTrue); - expect(isDidWeb('did:web:localhost%3A3000'), isTrue); - - // Wrong prefix - expect(isDidWeb('did:plc:abc123xyz789abc123xyz789'), isFalse); - - // Can't start with colon after prefix - expect(isDidWeb('did:web::example.com'), isFalse); - }); - - test('isDid validates general DIDs', () { - expect(isDid('did:plc:abc123xyz789abc123xyz789'), isTrue); - expect(isDid('did:web:example.com'), isTrue); - expect( - isDid('did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK'), - isTrue, - ); - - // Invalid - expect(isDid('not-a-did'), isFalse); - expect(isDid('did:'), isFalse); - expect(isDid('did:method'), isFalse); - expect(isDid(''), isFalse); - }); - - test('extractDidMethod extracts method name', () { - expect(extractDidMethod('did:plc:abc123'), equals('plc')); - expect(extractDidMethod('did:web:example.com'), equals('web')); - expect(extractDidMethod('did:key:z6Mk...'), equals('key')); - }); - - test('didWebToUrl converts did:web to URL', () { - final url1 = didWebToUrl('did:web:example.com'); - expect(url1.toString(), equals('https://example.com')); - - final url2 = didWebToUrl('did:web:example.com:user:alice'); - expect(url2.toString(), equals('https://example.com/user/alice')); - - final url3 = didWebToUrl('did:web:localhost%3A3000'); - expect(url3.toString(), equals('http://localhost:3000')); - }); - - test('urlToDidWeb converts URL to did:web', () { - final did1 = urlToDidWeb(Uri.parse('https://example.com')); - expect(did1, equals('did:web:example.com')); - - final did2 = urlToDidWeb(Uri.parse('https://example.com/user/alice')); - expect(did2, equals('did:web:example.com:user:alice')); - }); - }); - - group('Handle Validation', () { - test('isValidHandle validates handles', () { - expect(isValidHandle('alice.example.com'), isTrue); - expect(isValidHandle('user.bsky.social'), isTrue); - expect(isValidHandle('sub.domain.example.com'), isTrue); - expect(isValidHandle('a.b'), isTrue); - - // Invalid - expect(isValidHandle(''), isFalse); - expect(isValidHandle('no-tld'), isFalse); - expect(isValidHandle('.starts-with-dot.com'), isFalse); - expect(isValidHandle('ends-with-dot.com.'), isFalse); - expect(isValidHandle('has..double-dot.com'), isFalse); - expect(isValidHandle('has spaces.com'), isFalse); - - // Too long (254+ chars) - final longHandle = '${'a' * 250}.com'; - expect(isValidHandle(longHandle), isFalse); - }); - - test('normalizeHandle converts to lowercase', () { - expect(normalizeHandle('Alice.Example.Com'), equals('alice.example.com')); - expect(normalizeHandle('USER.BSKY.SOCIAL'), equals('user.bsky.social')); - }); - - test('asNormalizedHandle validates and normalizes', () { - expect( - asNormalizedHandle('Alice.Example.Com'), - equals('alice.example.com'), - ); - expect(asNormalizedHandle('invalid'), isNull); - expect(asNormalizedHandle(''), isNull); - }); - }); - - group('DID Document', () { - test('DidDocument parses from JSON', () { - final json = { - 'id': 'did:plc:abc123xyz789abc123xyz789', - 'alsoKnownAs': ['at://alice.bsky.social'], - 'service': [ - { - 'id': '#atproto_pds', - 'type': 'AtprotoPersonalDataServer', - 'serviceEndpoint': 'https://pds.example.com', - }, - ], - }; - - final doc = DidDocument.fromJson(json); - - expect(doc.id, equals('did:plc:abc123xyz789abc123xyz789')); - expect(doc.alsoKnownAs, contains('at://alice.bsky.social')); - expect(doc.service?.length, equals(1)); - expect(doc.service?[0].type, equals('AtprotoPersonalDataServer')); - }); - - test('DidDocument extracts PDS URL', () { - final doc = DidDocument( - id: 'did:plc:test', - service: [ - DidService( - id: '#atproto_pds', - type: 'AtprotoPersonalDataServer', - serviceEndpoint: 'https://pds.example.com', - ), - ], - ); - - expect(doc.extractPdsUrl(), equals('https://pds.example.com')); - }); - - test('DidDocument extracts handle', () { - final doc = DidDocument( - id: 'did:plc:test', - alsoKnownAs: ['at://alice.bsky.social', 'https://example.com'], - ); - - expect(doc.extractAtprotoHandle(), equals('alice.bsky.social')); - expect(doc.extractNormalizedHandle(), equals('alice.bsky.social')); - }); - - test('DidDocument returns null for missing PDS', () { - final doc = DidDocument(id: 'did:plc:test'); - expect(doc.extractPdsUrl(), isNull); - }); - - test('DidDocument returns null for missing handle', () { - final doc = DidDocument(id: 'did:plc:test'); - expect(doc.extractAtprotoHandle(), isNull); - expect(doc.extractNormalizedHandle(), isNull); - }); - }); - - group('Cache', () { - test('InMemoryDidCache stores and retrieves', () async { - final cache = InMemoryDidCache(ttl: Duration(seconds: 1)); - final doc = DidDocument(id: 'did:plc:test'); - - await cache.set('did:plc:test', doc); - final retrieved = await cache.get('did:plc:test'); - - expect(retrieved?.id, equals('did:plc:test')); - }); - - test('InMemoryDidCache expires entries', () async { - final cache = InMemoryDidCache(ttl: Duration(milliseconds: 100)); - final doc = DidDocument(id: 'did:plc:test'); - - await cache.set('did:plc:test', doc); - - // Should exist immediately - expect(await cache.get('did:plc:test'), isNotNull); - - // Wait for expiration - await Future.delayed(Duration(milliseconds: 150)); - - // Should be expired - expect(await cache.get('did:plc:test'), isNull); - }); - - test('InMemoryHandleCache stores and retrieves', () async { - final cache = InMemoryHandleCache(ttl: Duration(seconds: 1)); - - await cache.set('alice.bsky.social', 'did:plc:test'); - final retrieved = await cache.get('alice.bsky.social'); - - expect(retrieved, equals('did:plc:test')); - }); - - test('Cache clears all entries', () async { - final cache = InMemoryDidCache(); - final doc = DidDocument(id: 'did:plc:test'); - - await cache.set('did:plc:test', doc); - expect(await cache.get('did:plc:test'), isNotNull); - - await cache.clear(); - expect(await cache.get('did:plc:test'), isNull); - }); - }); - - group('Error Types', () { - test('IdentityResolverError has message', () { - final error = IdentityResolverError('Test error'); - expect(error.message, equals('Test error')); - expect(error.toString(), contains('Test error')); - }); - - test('InvalidDidError includes DID', () { - final error = InvalidDidError('not:valid', 'Invalid format'); - expect(error.did, equals('not:valid')); - expect(error.toString(), contains('not:valid')); - expect(error.toString(), contains('Invalid format')); - }); - - test('InvalidHandleError includes handle', () { - final error = InvalidHandleError('invalid', 'Invalid format'); - expect(error.handle, equals('invalid')); - expect(error.toString(), contains('invalid')); - expect(error.toString(), contains('Invalid format')); - }); - }); -} -- 2.51.2 From 5882745cb4ec8c955d47d98e9cadae1c04f26a58 Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:28:32 -0800 Subject: [PATCH 09/11] chore: update remaining tests and iOS config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update feed_screen_test.dart for the new OAuth patterns. iOS changes: - Add Runner.entitlements for associated domains - Enable Universal Links (applinks:coves.social) - Reference entitlements in Xcode project 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ios/Runner.xcodeproj/project.pbxproj | 5 +++++ ios/Runner/Runner.entitlements | 10 ++++++++++ test/widgets/feed_screen_test.dart | 15 +++++++++++---- 3 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 ios/Runner/Runner.entitlements diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 58d8b7f..17d6a79 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + F2B3C8D12D0C8A5E00ABCDEF /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -113,6 +114,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, 97C147021CF9000F007C117D /* Info.plist */, + F2B3C8D12D0C8A5E00ABCDEF /* Runner.entitlements */, 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, @@ -361,6 +363,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -540,6 +543,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -562,6 +566,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..adc9d18 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.associated-domains + + applinks:coves.social + + + diff --git a/test/widgets/feed_screen_test.dart b/test/widgets/feed_screen_test.dart index 0504349..ac155cc 100644 --- a/test/widgets/feed_screen_test.dart +++ b/test/widgets/feed_screen_test.dart @@ -37,7 +37,6 @@ class FakeVoteProvider extends VoteProvider { voteService: VoteService( sessionGetter: () async => null, didGetter: () => null, - pdsUrlGetter: () => null, ), authProvider: FakeAuthProvider(), ); @@ -286,6 +285,7 @@ void main() { community: CommunityRef( did: 'did:plc:community', name: 'test-community', + handle: 'test-community.community.coves.social', ), createdAt: DateTime.now(), indexedAt: DateTime.now(), @@ -315,7 +315,8 @@ void main() { await tester.pumpWidget(createTestWidget()); - expect(find.text('c/test-community'), findsOneWidget); + // Check for community handle parts (displayed as !test-community@coves.social) + expect(find.textContaining('!test-community'), findsOneWidget); expect(find.text('@test.user'), findsOneWidget); }); @@ -338,7 +339,9 @@ void main() { // Verify post card exists (which contains Semantics wrapper) expect(find.text('Accessible Post'), findsOneWidget); - expect(find.text('c/test-community'), findsOneWidget); + // Check for community handle parts (displayed as !test-community@coves.social) + expect(find.textContaining('!test-community'), findsOneWidget); + expect(find.textContaining('@coves.social'), findsOneWidget); }); testWidgets('should properly dispose scroll controller', (tester) async { @@ -366,7 +369,11 @@ FeedViewPost _createMockPost(String title) { handle: 'test.user', displayName: 'Test User', ), - community: CommunityRef(did: 'did:plc:community', name: 'test-community'), + community: CommunityRef( + did: 'did:plc:community', + name: 'test-community', + handle: 'test-community.community.coves.social', + ), createdAt: DateTime.parse('2025-01-01T12:00:00Z'), indexedAt: DateTime.parse('2025-01-01T12:00:00Z'), text: 'Test body', -- 2.51.2 From c8dfae5cbc08177029e0948876891883f45857dc Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:30:41 -0800 Subject: [PATCH 10/11] style: apply dart format to all files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-format all Dart files per CODE_QUALITY_GUIDE.md standards. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- lib/services/coves_api_service.dart | 19 +- lib/services/coves_auth_service.dart | 33 +- lib/services/vote_service.dart | 32 +- lib/widgets/comment_card.dart | 1 - lib/widgets/icons/bluesky_icons.dart | 1 - lib/widgets/post_action_bar.dart | 4 +- lib/widgets/post_card.dart | 33 +- lib/widgets/post_card_actions.dart | 14 +- test/models/coves_session_test.dart | 50 +- test/providers/auth_provider_test.dart | 5 +- test/providers/comments_provider_test.dart | 19 +- .../coves_api_service_token_refresh_test.dart | 183 +-- .../coves_auth_service_environment_test.dart | 69 +- .../coves_auth_service_redaction_test.dart | 61 +- .../coves_auth_service_singleton_test.dart | 149 ++- test/services/coves_auth_service_test.dart | 1004 +++++++++-------- .../coves_auth_service_validation_test.dart | 314 +++--- .../vote_service_token_refresh_test.dart | 123 +- 18 files changed, 1139 insertions(+), 975 deletions(-) diff --git a/lib/services/coves_api_service.dart b/lib/services/coves_api_service.dart index b23c873..b9393f0 100644 --- a/lib/services/coves_api_service.dart +++ b/lib/services/coves_api_service.dart @@ -26,9 +26,9 @@ class CovesApiService { Future Function()? tokenRefresher, Future Function()? signOutHandler, Dio? dio, - }) : _tokenGetter = tokenGetter, - _tokenRefresher = tokenRefresher, - _signOutHandler = signOutHandler { + }) : _tokenGetter = tokenGetter, + _tokenRefresher = tokenRefresher, + _signOutHandler = signOutHandler { _dio = dio ?? Dio( @@ -78,8 +78,9 @@ class CovesApiService { } // Don't retry the refresh endpoint itself (avoid infinite loop) - final isRefreshEndpoint = - error.requestOptions.path.contains('/oauth/refresh'); + final isRefreshEndpoint = error.requestOptions.path.contains( + '/oauth/refresh', + ); if (isRefreshEndpoint) { if (kDebugMode) { debugPrint( @@ -160,7 +161,8 @@ class CovesApiService { } // Only sign out if we haven't already (avoid double sign-out) // Check if this is a DioException from a retried request - final isRetriedRequest = e is DioException && + final isRetriedRequest = + e is DioException && e.response?.statusCode == 401 && e.requestOptions.extra['retried'] == true; @@ -352,10 +354,7 @@ class CovesApiService { if (kDebugMode) { debugPrint('❌ Error parsing comments response: $e'); } - throw ApiException( - 'Failed to parse server response', - originalError: e, - ); + throw ApiException('Failed to parse server response', originalError: e); } } diff --git a/lib/services/coves_auth_service.dart b/lib/services/coves_auth_service.dart index d13b5f2..87da0b0 100644 --- a/lib/services/coves_auth_service.dart +++ b/lib/services/coves_auth_service.dart @@ -26,24 +26,20 @@ import '../models/coves_session.dart'; /// 4. Call /oauth/refresh when needed /// 5. Call /oauth/logout to sign out class CovesAuthService { - factory CovesAuthService({ - Dio? dio, - FlutterSecureStorage? storage, - }) { + factory CovesAuthService({Dio? dio, FlutterSecureStorage? storage}) { _instance ??= CovesAuthService._internal(dio: dio, storage: storage); return _instance!; } - CovesAuthService._internal({ - Dio? dio, - FlutterSecureStorage? storage, - }) : _storage = storage ?? - const FlutterSecureStorage( - aOptions: AndroidOptions(encryptedSharedPreferences: true), - iOptions: IOSOptions( - accessibility: KeychainAccessibility.first_unlock, - ), - ) { + CovesAuthService._internal({Dio? dio, FlutterSecureStorage? storage}) + : _storage = + storage ?? + const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock, + ), + ) { // Initialize Dio if provided, otherwise it will be initialized in initialize() if (dio != null) { _dio = dio; @@ -341,9 +337,7 @@ class CovesAuthService { await _dio.post( '/oauth/logout', options: Options( - headers: { - 'Authorization': 'Bearer ${_session!.token}', - }, + headers: {'Authorization': 'Bearer ${_session!.token}'}, ), ); @@ -520,10 +514,7 @@ class CovesAuthService { /// Save session to secure storage Future _saveSession(CovesSession session) async { - await _storage.write( - key: _storageKey, - value: session.toJsonString(), - ); + await _storage.write(key: _storageKey, value: session.toJsonString()); } /// Clear session from secure storage diff --git a/lib/services/vote_service.dart b/lib/services/vote_service.dart index 520da8f..df0eab8 100644 --- a/lib/services/vote_service.dart +++ b/lib/services/vote_service.dart @@ -35,7 +35,8 @@ class VoteService { _didGetter = didGetter, _tokenRefresher = tokenRefresher, _signOutHandler = signOutHandler { - _dio = dio ?? + _dio = + dio ?? Dio( BaseOptions( baseUrl: EnvironmentConfig.current.apiUrl, @@ -70,7 +71,9 @@ class VoteService { // Handle 401 errors with automatic token refresh if (error.response?.statusCode == 401 && _tokenRefresher != null) { if (kDebugMode) { - debugPrint('🔄 VoteService: 401 detected, attempting token refresh...'); + debugPrint( + '🔄 VoteService: 401 detected, attempting token refresh...', + ); } // Check if we already retried this request (prevent infinite loop) @@ -94,7 +97,9 @@ class VoteService { if (refreshSucceeded) { if (kDebugMode) { - debugPrint('✅ VoteService: Token refresh successful, retrying request'); + debugPrint( + '✅ VoteService: Token refresh successful, retrying request', + ); } // Get the new session @@ -128,7 +133,9 @@ class VoteService { // Refresh failed, sign out the user if (kDebugMode) { - debugPrint('❌ VoteService: Token refresh failed, signing out user'); + debugPrint( + '❌ VoteService: Token refresh failed, signing out user', + ); } if (_signOutHandler != null) { await _signOutHandler(); @@ -139,7 +146,8 @@ class VoteService { } // Only sign out if we haven't already (avoid double sign-out) // Check if this is a DioException from a retried request - final isRetriedRequest = e is DioException && + final isRetriedRequest = + e is DioException && e.response?.statusCode == 401 && e.requestOptions.extra['retried'] == true; @@ -258,10 +266,7 @@ class VoteService { if (isToggleOff) { // Delete existing vote - return _deleteVote( - session: session, - rkey: existingVoteRkey, - ); + return _deleteVote(session: session, rkey: existingVoteRkey); } // If switching direction, delete old vote first @@ -277,10 +282,7 @@ class VoteService { final response = await _dio.post>( '/xrpc/social.coves.feed.vote.create', data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': direction, }, ); @@ -338,9 +340,7 @@ class VoteService { // Note: Authorization header is added by the interceptor await _dio.post( '/xrpc/social.coves.feed.vote.delete', - data: { - 'rkey': rkey, - }, + data: {'rkey': rkey}, ); if (kDebugMode) { diff --git a/lib/widgets/comment_card.dart b/lib/widgets/comment_card.dart index d20a655..6c314dd 100644 --- a/lib/widgets/comment_card.dart +++ b/lib/widgets/comment_card.dart @@ -276,7 +276,6 @@ class CommentCard extends StatelessWidget { /// Custom painter for drawing comment depth indicator lines class _CommentDepthPainter extends CustomPainter { - _CommentDepthPainter({required this.depth}); final int depth; diff --git a/lib/widgets/icons/bluesky_icons.dart b/lib/widgets/icons/bluesky_icons.dart index e5e1be6..cc8a67a 100644 --- a/lib/widgets/icons/bluesky_icons.dart +++ b/lib/widgets/icons/bluesky_icons.dart @@ -4,7 +4,6 @@ import 'package:flutter_svg/flutter_svg.dart'; /// Bluesky-style navigation icons using SVG assets /// These icons match the design from Bluesky's social-app class BlueSkyIcon extends StatelessWidget { - const BlueSkyIcon({ required this.iconName, this.size = 28, diff --git a/lib/widgets/post_action_bar.dart b/lib/widgets/post_action_bar.dart index 2f1e6b8..3ea3308 100644 --- a/lib/widgets/post_action_bar.dart +++ b/lib/widgets/post_action_bar.dart @@ -49,9 +49,7 @@ class PostActionBar extends StatelessWidget { return Container( decoration: const BoxDecoration( color: AppColors.background, - border: Border( - top: BorderSide(color: AppColors.backgroundSecondary), - ), + border: Border(top: BorderSide(color: AppColors.backgroundSecondary)), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: SafeArea( diff --git a/lib/widgets/post_card.dart b/lib/widgets/post_card.dart index 9adc07a..274546d 100644 --- a/lib/widgets/post_card.dart +++ b/lib/widgets/post_card.dart @@ -161,21 +161,21 @@ class PostCard extends StatelessWidget { ), ) else - // Title when navigation is disabled - if (post.post.title != null) ...[ - Text( - post.post.title!, - style: TextStyle( - color: AppColors.textPrimary, - fontSize: titleFontSize, - fontWeight: titleFontWeight, - height: 1.3, - ), + // Title when navigation is disabled + if (post.post.title != null) ...[ + Text( + post.post.title!, + style: TextStyle( + color: AppColors.textPrimary, + fontSize: titleFontSize, + fontWeight: titleFontWeight, + height: 1.3, ), - if (post.post.embed?.external != null || - post.post.text.isNotEmpty) - const SizedBox(height: 8), - ], + ), + if (post.post.embed?.external != null || + post.post.text.isNotEmpty) + const SizedBox(height: 8), + ], // Embed (handles its own taps - not wrapped in InkWell) if (post.post.embed?.external != null) ...[ @@ -590,10 +590,7 @@ class _EmbedCardState extends State<_EmbedCard> { // For non-video embeds (images, link previews), make them tappable // to navigate to post detail if (widget.onImageTap != null) { - return GestureDetector( - onTap: widget.onImageTap, - child: thumbnailWidget, - ); + return GestureDetector(onTap: widget.onImageTap, child: thumbnailWidget); } // No tap handler provided, just return the thumbnail diff --git a/lib/widgets/post_card_actions.dart b/lib/widgets/post_card_actions.dart index 322c35f..0e8d2ee 100644 --- a/lib/widgets/post_card_actions.dart +++ b/lib/widgets/post_card_actions.dart @@ -120,19 +120,17 @@ class PostCardActions extends StatelessWidget { Icon( Icons.chat_bubble_outline, size: 20, - color: - AppColors.textPrimary.withValues( - alpha: 0.6, - ), + color: AppColors.textPrimary.withValues( + alpha: 0.6, + ), ), const SizedBox(width: 5), Text( DateTimeUtils.formatCount(count), style: TextStyle( - color: - AppColors.textPrimary.withValues( - alpha: 0.6, - ), + color: AppColors.textPrimary.withValues( + alpha: 0.6, + ), fontSize: 13, ), ), diff --git a/test/models/coves_session_test.dart b/test/models/coves_session_test.dart index 0badb4f..8b4c470 100644 --- a/test/models/coves_session_test.dart +++ b/test/models/coves_session_test.dart @@ -233,10 +233,7 @@ void main() { 'session_id': 'sess456', }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when did has wrong type', () { @@ -246,10 +243,7 @@ void main() { 'session_id': 'sess456', }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when session_id has wrong type', () { @@ -259,46 +253,25 @@ void main() { 'session_id': 123, // Should be String }; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when token field is missing', () { - final json = { - 'did': 'did:plc:test123', - 'session_id': 'sess456', - }; + final json = {'did': 'did:plc:test123', 'session_id': 'sess456'}; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when did field is missing', () { - final json = { - 'token': 'abc123', - 'session_id': 'sess456', - }; + final json = {'token': 'abc123', 'session_id': 'sess456'}; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should throw when session_id field is missing', () { - final json = { - 'token': 'abc123', - 'did': 'did:plc:test123', - }; + final json = {'token': 'abc123', 'did': 'did:plc:test123'}; - expect( - () => CovesSession.fromJson(json), - throwsA(isA()), - ); + expect(() => CovesSession.fromJson(json), throwsA(isA())); }); test('should handle extra fields in JSON', () { @@ -793,7 +766,10 @@ void main() { final stringRep = session.toString(); expect(stringRep, isNot(contains('Bearer'))); - expect(stringRep, isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'))); + expect( + stringRep, + isNot(contains('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9')), + ); }); }); } diff --git a/test/providers/auth_provider_test.dart b/test/providers/auth_provider_test.dart index 5f43ce1..5b80fc2 100644 --- a/test/providers/auth_provider_test.dart +++ b/test/providers/auth_provider_test.dart @@ -152,8 +152,9 @@ void main() { await authProvider.signIn('alice.bsky.social'); // Sign out with error - when(mockAuthService.signOut()) - .thenThrow(Exception('Revocation failed')); + when( + mockAuthService.signOut(), + ).thenThrow(Exception('Revocation failed')); await authProvider.signOut(); diff --git a/test/providers/comments_provider_test.dart b/test/providers/comments_provider_test.dart index 2270b6c..2444b97 100644 --- a/test/providers/comments_provider_test.dart +++ b/test/providers/comments_provider_test.dart @@ -89,10 +89,7 @@ void main() { }); test('should handle empty comments response', () async { - final mockResponse = CommentsResponse( - post: {}, - comments: [], - ); + final mockResponse = CommentsResponse(post: {}, comments: []); when( mockApiService.getComments( @@ -211,9 +208,7 @@ void main() { ), ).thenAnswer((_) async => secondResponse); - await commentsProvider.loadComments( - postUri: testPostUri, - ); + await commentsProvider.loadComments(postUri: testPostUri); expect(commentsProvider.comments.length, 2); expect(commentsProvider.comments[0].comment.uri, 'comment1'); @@ -425,10 +420,7 @@ void main() { test('should load vote state when authenticated', () async { final mockComments = [_createMockThreadComment('comment1')]; - final mockResponse = CommentsResponse( - post: {}, - comments: mockComments, - ); + final mockResponse = CommentsResponse(post: {}, comments: mockComments); when( mockApiService.getComments( @@ -494,10 +486,7 @@ void main() { test('should continue loading comments if vote loading fails', () async { final mockComments = [_createMockThreadComment('comment1')]; - final mockResponse = CommentsResponse( - post: {}, - comments: mockComments, - ); + final mockResponse = CommentsResponse(post: {}, comments: mockComments); when( mockApiService.getComments( diff --git a/test/services/coves_api_service_token_refresh_test.dart b/test/services/coves_api_service_token_refresh_test.dart index 34b3baa..a2652f8 100644 --- a/test/services/coves_api_service_token_refresh_test.dart +++ b/test/services/coves_api_service_token_refresh_test.dart @@ -60,8 +60,7 @@ void main() { apiService.dispose(); }); - test('should call token refresher on 401 response but only retry once', - () async { + test('should call token refresher on 401 response but only retry once', () async { // This test verifies the interceptor detects 401, calls the refresher, // and only retries ONCE to prevent infinite loops (even if retry returns 401). @@ -138,92 +137,100 @@ void main() { expect(signOutCallCount, 1); }); - test('should NOT retry refresh endpoint on 401 (avoid infinite loop)', - () async { - // This test verifies that the interceptor checks for /oauth/refresh - // in the path to avoid infinite loops. Due to limitations with mocking - // complex request/response cycles, we test this by verifying the - // signOutHandler gets called when refresh fails. - - // Set refresh to fail (simulates refresh endpoint returning 401) - shouldRefreshSucceed = false; - - const postUri = 'at://did:plc:test/social.coves.post.record/123'; - - dioAdapter.onGet( - '/xrpc/social.coves.community.comment.getComments', - (server) => server.reply(401, { - 'error': 'Unauthorized', - 'message': 'Token expired', - }), - queryParameters: { - 'post': postUri, - 'sort': 'hot', - 'depth': 10, - 'limit': 50, - }, - ); - - // Make the request and expect it to fail - expect( - () => apiService.getComments(postUri: postUri), - throwsA(isA()), - ); - - // Wait for async operations to complete - await Future.delayed(const Duration(milliseconds: 100)); - - // Verify user was signed out (no infinite loop) - expect(signOutCallCount, 1); - }); - - test('should sign out user if token refresh throws exception', () async { - // Skipped: causes retry loops with http_mock_adapter after disposal - // The core functionality is tested by the "should sign out user if token - // refresh fails" test above. - }, skip: 'Causes retry issues with http_mock_adapter'); - - test('should handle 401 gracefully when no refresher is provided', - () async { - // Create API service without refresh capability - final apiServiceNoRefresh = CovesApiService( - dio: dio, - tokenGetter: mockTokenGetter, - // No tokenRefresher provided - // No signOutHandler provided - ); - - const postUri = 'at://did:plc:test/social.coves.post.record/123'; - - // Request returns 401 - dioAdapter.onGet( - '/xrpc/social.coves.community.comment.getComments', - (server) => server.reply(401, { - 'error': 'Unauthorized', - 'message': 'Token expired', - }), - queryParameters: { - 'post': postUri, - 'sort': 'hot', - 'depth': 10, - 'limit': 50, - }, - ); - - // Make the request and expect it to fail with AuthenticationException - expect( - () => apiServiceNoRefresh.getComments(postUri: postUri), - throwsA(isA()), - ); - - // Verify refresh was NOT called (no refresher provided) - expect(tokenRefreshCallCount, 0); - - // Verify sign-out was NOT called (no handler provided) - expect(signOutCallCount, 0); - - apiServiceNoRefresh.dispose(); - }); + test( + 'should NOT retry refresh endpoint on 401 (avoid infinite loop)', + () async { + // This test verifies that the interceptor checks for /oauth/refresh + // in the path to avoid infinite loops. Due to limitations with mocking + // complex request/response cycles, we test this by verifying the + // signOutHandler gets called when refresh fails. + + // Set refresh to fail (simulates refresh endpoint returning 401) + shouldRefreshSucceed = false; + + const postUri = 'at://did:plc:test/social.coves.post.record/123'; + + dioAdapter.onGet( + '/xrpc/social.coves.community.comment.getComments', + (server) => server.reply(401, { + 'error': 'Unauthorized', + 'message': 'Token expired', + }), + queryParameters: { + 'post': postUri, + 'sort': 'hot', + 'depth': 10, + 'limit': 50, + }, + ); + + // Make the request and expect it to fail + expect( + () => apiService.getComments(postUri: postUri), + throwsA(isA()), + ); + + // Wait for async operations to complete + await Future.delayed(const Duration(milliseconds: 100)); + + // Verify user was signed out (no infinite loop) + expect(signOutCallCount, 1); + }, + ); + + test( + 'should sign out user if token refresh throws exception', + () async { + // Skipped: causes retry loops with http_mock_adapter after disposal + // The core functionality is tested by the "should sign out user if token + // refresh fails" test above. + }, + skip: 'Causes retry issues with http_mock_adapter', + ); + + test( + 'should handle 401 gracefully when no refresher is provided', + () async { + // Create API service without refresh capability + final apiServiceNoRefresh = CovesApiService( + dio: dio, + tokenGetter: mockTokenGetter, + // No tokenRefresher provided + // No signOutHandler provided + ); + + const postUri = 'at://did:plc:test/social.coves.post.record/123'; + + // Request returns 401 + dioAdapter.onGet( + '/xrpc/social.coves.community.comment.getComments', + (server) => server.reply(401, { + 'error': 'Unauthorized', + 'message': 'Token expired', + }), + queryParameters: { + 'post': postUri, + 'sort': 'hot', + 'depth': 10, + 'limit': 50, + }, + ); + + // Make the request and expect it to fail with AuthenticationException + expect( + () => apiServiceNoRefresh.getComments(postUri: postUri), + throwsA(isA()), + ); + + // Verify refresh was NOT called (no refresher provided) + expect(tokenRefreshCallCount, 0); + + // Verify sign-out was NOT called (no handler provided) + expect(signOutCallCount, 0); + + apiServiceNoRefresh.dispose(); + }, + ); // Skipped: http_mock_adapter cannot handle stateful request/response cycles diff --git a/test/services/coves_auth_service_environment_test.dart b/test/services/coves_auth_service_environment_test.dart index 3f64d9f..af6caa4 100644 --- a/test/services/coves_auth_service_environment_test.dart +++ b/test/services/coves_auth_service_environment_test.dart @@ -70,8 +70,9 @@ void main() { ); // Mock storage read for the environment-specific key - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); // Act - Restore session final result = await authService.restoreSession(); @@ -100,36 +101,42 @@ void main() { handle: 'alice.bsky.social', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Mock successful refresh const newToken = 'new-refreshed-token'; - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: { - 'sealed_token': newToken, - 'access_token': 'some-access-token' - }, - )); - - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + ), + ); + + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); // Act - Refresh token (which saves the updated session) await authService.refreshToken(); // Assert - Verify environment-specific key was used for saving - verify(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .called(1); + verify( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).called(1); // Verify the generic key was never used - verifyNever(mockStorage.write(key: 'coves_session', value: anyNamed('value'))); + verifyNever( + mockStorage.write(key: 'coves_session', value: anyNamed('value')), + ); }); test('should delete sessions using environment-specific keys', () async { @@ -144,18 +151,20 @@ void main() { sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Mock logout - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + ), + ); when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); diff --git a/test/services/coves_auth_service_redaction_test.dart b/test/services/coves_auth_service_redaction_test.dart index 7bbb928..553bb4a 100644 --- a/test/services/coves_auth_service_redaction_test.dart +++ b/test/services/coves_auth_service_redaction_test.dart @@ -52,21 +52,23 @@ void main() { ); }); - test('should preserve non-sensitive parameters (DID, handle, session_id)', - () { - const testUrl = - 'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social'; - - final redacted = testUrl.replaceAllMapped( - RegExp(r'token=([^&\s]+)'), - (match) => 'token=[REDACTED]', - ); - - expect(redacted, contains('did=did:plc:test123')); - expect(redacted, contains('session_id=sess-456')); - expect(redacted, contains('handle=alice.bsky.social')); - expect(redacted, isNot(contains('sealed_token_abc123'))); - }); + test( + 'should preserve non-sensitive parameters (DID, handle, session_id)', + () { + const testUrl = + 'social.coves:/callback?token=sealed_token_abc123&did=did:plc:test123&session_id=sess-456&handle=alice.bsky.social'; + + final redacted = testUrl.replaceAllMapped( + RegExp(r'token=([^&\s]+)'), + (match) => 'token=[REDACTED]', + ); + + expect(redacted, contains('did=did:plc:test123')); + expect(redacted, contains('session_id=sess-456')); + expect(redacted, contains('handle=alice.bsky.social')); + expect(redacted, isNot(contains('sealed_token_abc123'))); + }, + ); test('should handle token as first parameter', () { const testUrl = @@ -77,18 +79,25 @@ void main() { (match) => 'token=[REDACTED]', ); - expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + expect( + redacted, + 'social.coves:/callback?token=[REDACTED]&did=did:plc:test', + ); }); test('should handle token as last parameter', () { - const testUrl = 'social.coves:/callback?did=did:plc:test&token=last_token'; + const testUrl = + 'social.coves:/callback?did=did:plc:test&token=last_token'; final redacted = testUrl.replaceAllMapped( RegExp(r'token=([^&\s]+)'), (match) => 'token=[REDACTED]', ); - expect(redacted, 'social.coves:/callback?did=did:plc:test&token=[REDACTED]'); + expect( + redacted, + 'social.coves:/callback?did=did:plc:test&token=[REDACTED]', + ); }); test('should handle token as only parameter', () { @@ -111,26 +120,34 @@ void main() { (match) => 'token=[REDACTED]', ); - expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + expect( + redacted, + 'social.coves:/callback?token=[REDACTED]&did=did:plc:test', + ); expect(redacted, isNot(contains('encoded%2Btoken%3D123'))); }); test('should handle long token values', () { const longToken = 'very_long_sealed_token_with_many_characters_1234567890abcdef'; - final testUrl = 'social.coves:/callback?token=$longToken&did=did:plc:test'; + final testUrl = + 'social.coves:/callback?token=$longToken&did=did:plc:test'; final redacted = testUrl.replaceAllMapped( RegExp(r'token=([^&\s]+)'), (match) => 'token=[REDACTED]', ); - expect(redacted, 'social.coves:/callback?token=[REDACTED]&did=did:plc:test'); + expect( + redacted, + 'social.coves:/callback?token=[REDACTED]&did=did:plc:test', + ); expect(redacted, isNot(contains(longToken))); }); test('should handle URL without token parameter', () { - const testUrl = 'social.coves:/callback?did=did:plc:test&handle=alice.bsky.social'; + const testUrl = + 'social.coves:/callback?did=did:plc:test&handle=alice.bsky.social'; final redacted = testUrl.replaceAllMapped( RegExp(r'token=([^&\s]+)'), diff --git a/test/services/coves_auth_service_singleton_test.dart b/test/services/coves_auth_service_singleton_test.dart index 11956c8..a443fd4 100644 --- a/test/services/coves_auth_service_singleton_test.dart +++ b/test/services/coves_auth_service_singleton_test.dart @@ -34,12 +34,21 @@ void main() { final instance3 = CovesAuthService(); // Assert - All should be the exact same instance - expect(identical(instance1, instance2), isTrue, - reason: 'instance1 and instance2 should be identical'); - expect(identical(instance2, instance3), isTrue, - reason: 'instance2 and instance3 should be identical'); - expect(identical(instance1, instance3), isTrue, - reason: 'instance1 and instance3 should be identical'); + expect( + identical(instance1, instance2), + isTrue, + reason: 'instance1 and instance2 should be identical', + ); + expect( + identical(instance2, instance3), + isTrue, + reason: 'instance2 and instance3 should be identical', + ); + expect( + identical(instance1, instance3), + isTrue, + reason: 'instance1 and instance3 should be identical', + ); }); test('should share in-memory session across singleton instances', () async { @@ -47,15 +56,17 @@ void main() { final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); // Mock storage to return a valid session - const sessionJson = '{' + const sessionJson = + '{' '"token": "test-token", ' '"did": "did:plc:test123", ' '"session_id": "session-123", ' '"handle": "alice.bsky.social"' '}'; - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => sessionJson); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => sessionJson); // Act - Restore session using first instance await instance1.restoreSession(); @@ -77,24 +88,28 @@ void main() { final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); // Mock storage to return a valid session - const sessionJson = '{' + const sessionJson = + '{' '"token": "old-token", ' '"did": "did:plc:test123", ' '"session_id": "session-123", ' '"handle": "alice.bsky.social"' '}'; - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => sessionJson); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => sessionJson); await instance1.restoreSession(); // Mock refresh with delay const newToken = 'refreshed-token'; - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); return Response( requestOptions: RequestOptions(path: '/oauth/refresh'), @@ -103,8 +118,9 @@ void main() { ); }); - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); // Act - Start refresh from first instance final refreshFuture1 = instance1.refreshToken(); @@ -121,10 +137,12 @@ void main() { expect(results[1].token, newToken); // Verify only one API call was made (mutex protected) - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); }); test('resetInstance() should clear the singleton', () { @@ -148,7 +166,10 @@ void main() { test('createTestInstance() should bypass singleton', () { // Arrange - final singletonInstance = CovesAuthService(dio: mockDio, storage: mockStorage); + final singletonInstance = CovesAuthService( + dio: mockDio, + storage: mockStorage, + ); // Act - Create a test instance with different dependencies final mockDio2 = MockDio(); @@ -159,45 +180,59 @@ void main() { ); // Assert - Test instance should be different from singleton - expect(identical(singletonInstance, testInstance), isFalse, - reason: 'Test instance should not be the singleton'); + expect( + identical(singletonInstance, testInstance), + isFalse, + reason: 'Test instance should not be the singleton', + ); // Test instance should not affect singleton final singletonCheck = CovesAuthService(); - expect(identical(singletonInstance, singletonCheck), isTrue, - reason: 'Singleton should remain unchanged'); + expect( + identical(singletonInstance, singletonCheck), + isTrue, + reason: 'Singleton should remain unchanged', + ); }); - test('should avoid state loss when service is requested from multiple entry points', () async { - // Arrange - final authProvider = CovesAuthService(dio: mockDio, storage: mockStorage); - - const sessionJson = '{' - '"token": "test-token", ' - '"did": "did:plc:test123", ' - '"session_id": "session-123"' - '}'; - - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => sessionJson); - - // Act - Simulate different parts of the app requesting the service - await authProvider.restoreSession(); - - final apiService = CovesAuthService(); - final voteService = CovesAuthService(); - final feedService = CovesAuthService(); - - // Assert - All should have access to the same session state - expect(apiService.isAuthenticated, isTrue); - expect(voteService.isAuthenticated, isTrue); - expect(feedService.isAuthenticated, isTrue); - expect(apiService.getToken(), 'test-token'); - expect(voteService.getToken(), 'test-token'); - expect(feedService.getToken(), 'test-token'); + test( + 'should avoid state loss when service is requested from multiple entry points', + () async { + // Arrange + final authProvider = CovesAuthService( + dio: mockDio, + storage: mockStorage, + ); - // Storage should only be read once - verify(mockStorage.read(key: storageKey)).called(1); - }); + const sessionJson = + '{' + '"token": "test-token", ' + '"did": "did:plc:test123", ' + '"session_id": "session-123"' + '}'; + + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => sessionJson); + + // Act - Simulate different parts of the app requesting the service + await authProvider.restoreSession(); + + final apiService = CovesAuthService(); + final voteService = CovesAuthService(); + final feedService = CovesAuthService(); + + // Assert - All should have access to the same session state + expect(apiService.isAuthenticated, isTrue); + expect(voteService.isAuthenticated, isTrue); + expect(feedService.isAuthenticated, isTrue); + expect(apiService.getToken(), 'test-token'); + expect(voteService.getToken(), 'test-token'); + expect(feedService.getToken(), 'test-token'); + + // Storage should only be read once + verify(mockStorage.read(key: storageKey)).called(1); + }, + ); }); } diff --git a/test/services/coves_auth_service_test.dart b/test/services/coves_auth_service_test.dart index dd1bd0b..f6bb8d6 100644 --- a/test/services/coves_auth_service_test.dart +++ b/test/services/coves_auth_service_test.dart @@ -35,22 +35,20 @@ void main() { group('CovesAuthService', () { group('signIn()', () { test('should throw ArgumentError when handle is empty', () async { - expect( - () => authService.signIn(''), - throwsA(isA()), - ); + expect(() => authService.signIn(''), throwsA(isA())); }); - test('should throw ArgumentError when handle is whitespace-only', - () async { - expect( - () => authService.signIn(' '), - throwsA(isA()), - ); - }); + test( + 'should throw ArgumentError when handle is whitespace-only', + () async { + expect( + () => authService.signIn(' '), + throwsA(isA()), + ); + }, + ); - test('should throw appropriate error when user cancels sign-in', - () async { + test('should throw appropriate error when user cancels sign-in', () async { // Note: FlutterWebAuth2.authenticate is not easily mockable as it's a static method // This test documents expected behavior when authentication is cancelled // In practice, this would throw with CANCELED/cancelled in the message @@ -60,12 +58,14 @@ void main() { // Skipping for now as it requires more complex mocking infrastructure }); - test('should throw Exception when network error occurs during OAuth', - () async { - // Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock - // This test documents expected behavior - // The actual implementation catches exceptions and rethrows with context - }); + test( + 'should throw Exception when network error occurs during OAuth', + () async { + // Note: Similar to above, FlutterWebAuth2 static methods are difficult to mock + // This test documents expected behavior + // The actual implementation catches exceptions and rethrows with context + }, + ); test('should trim handle before processing', () async { // This test verifies the handle trimming logic @@ -86,8 +86,9 @@ void main() { ); final jsonString = session.toJsonString(); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => jsonString); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => jsonString); // Act final result = await authService.restoreSession(); @@ -103,8 +104,7 @@ void main() { test('should return null when no stored session exists', () async { // Arrange - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => null); + when(mockStorage.read(key: storageKey)).thenAnswer((_) async => null); // Act final result = await authService.restoreSession(); @@ -117,10 +117,10 @@ void main() { test('should handle corrupted storage data gracefully', () async { // Arrange const corruptedJson = 'not-valid-json{]'; - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => corruptedJson); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => corruptedJson); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); // Act final result = await authService.restoreSession(); @@ -131,31 +131,34 @@ void main() { verify(mockStorage.delete(key: storageKey)).called(1); }); - test('should handle session JSON with missing required fields gracefully', - () async { - // Arrange - const invalidJson = '{"token": "test"}'; // Missing required fields (did, session_id) - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => invalidJson); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); - - // Act - final result = await authService.restoreSession(); - - // Assert - // Should return null and clear corrupted storage - expect(result, isNull); - verify(mockStorage.read(key: storageKey)).called(1); - verify(mockStorage.delete(key: storageKey)).called(1); - }); + test( + 'should handle session JSON with missing required fields gracefully', + () async { + // Arrange + const invalidJson = + '{"token": "test"}'; // Missing required fields (did, session_id) + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => invalidJson); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); + + // Act + final result = await authService.restoreSession(); + + // Assert + // Should return null and clear corrupted storage + expect(result, isNull); + verify(mockStorage.read(key: storageKey)).called(1); + verify(mockStorage.delete(key: storageKey)).called(1); + }, + ); test('should handle storage read errors gracefully', () async { // Arrange - when(mockStorage.read(key: storageKey)) - .thenThrow(Exception('Storage error')); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when( + mockStorage.read(key: storageKey), + ).thenThrow(Exception('Storage error')); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); // Act final result = await authService.restoreSession(); @@ -169,56 +172,65 @@ void main() { group('refreshToken()', () { test('should throw StateError when no session exists', () async { // Act & Assert - expect( - () => authService.refreshToken(), - throwsA(isA()), - ); + expect(() => authService.refreshToken(), throwsA(isA())); }); - test('should successfully refresh token and return updated session', - () async { - // Arrange - First restore a session - const initialSession = CovesSession( - token: 'old-token', - did: 'did:plc:test123', - sessionId: 'session-123', - handle: 'alice.bsky.social', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); - await authService.restoreSession(); - - // Mock successful refresh response - const newToken = 'new-refreshed-token'; - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( + test( + 'should successfully refresh token and return updated session', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', + ); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + // Mock successful refresh response + const newToken = 'new-refreshed-token'; + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( requestOptions: RequestOptions(path: '/oauth/refresh'), statusCode: 200, - data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, - )); - - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); - - // Act - final result = await authService.refreshToken(); + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token', + }, + ), + ); - // Assert - expect(result.token, newToken); - expect(result.did, 'did:plc:test123'); - expect(result.sessionId, 'session-123'); - expect(result.handle, 'alice.bsky.social'); - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); - verify(mockStorage.write( - key: storageKey, - value: anyNamed('value'), - )).called(1); - }); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); + + // Act + final result = await authService.refreshToken(); + + // Assert + expect(result.token, newToken); + expect(result.did, 'did:plc:test123'); + expect(result.sessionId, 'session-123'); + expect(result.handle, 'alice.bsky.social'); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); + verify( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).called(1); + }, + ); test('should throw "Session expired" on 401 response', () async { // Arrange - First restore a session @@ -227,15 +239,18 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Mock 401 response - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.badResponse, @@ -250,8 +265,9 @@ void main() { expect( () => authService.refreshToken(), throwsA( - predicate((e) => - e is Exception && e.toString().contains('Session expired')), + predicate( + (e) => e is Exception && e.toString().contains('Session expired'), + ), ), ); }); @@ -263,15 +279,18 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Mock network error - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.connectionError, @@ -283,156 +302,183 @@ void main() { expect( () => authService.refreshToken(), throwsA( - predicate((e) => - e is Exception && - e.toString().contains('Token refresh failed')), + predicate( + (e) => + e is Exception && + e.toString().contains('Token refresh failed'), + ), ), ); }); - test('should throw Exception when response is missing sealed_token', () async { - // Arrange - First restore a session - const session = CovesSession( - token: 'old-token', - did: 'did:plc:test123', - sessionId: 'session-123', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); - await authService.restoreSession(); - - // Mock response without sealed_token - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( + test( + 'should throw Exception when response is missing sealed_token', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock response without sealed_token + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( requestOptions: RequestOptions(path: '/oauth/refresh'), statusCode: 200, data: {'access_token': 'some-token'}, // No sealed_token field - )); - - // Act & Assert - expect( - () => authService.refreshToken(), - throwsA( - predicate((e) => - e is Exception && - e.toString().contains('Invalid refresh response')), - ), - ); - }); + ), + ); - test('should throw Exception when response sealed_token is empty', () async { - // Arrange - First restore a session - const session = CovesSession( - token: 'old-token', - did: 'did:plc:test123', - sessionId: 'session-123', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); - await authService.restoreSession(); + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate( + (e) => + e is Exception && + e.toString().contains('Invalid refresh response'), + ), + ), + ); + }, + ); - // Mock response with empty sealed_token - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( + test( + 'should throw Exception when response sealed_token is empty', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock response with empty sealed_token + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( requestOptions: RequestOptions(path: '/oauth/refresh'), statusCode: 200, - data: {'sealed_token': '', 'access_token': 'some-token'}, // Empty sealed_token - )); + data: { + 'sealed_token': '', + 'access_token': 'some-token', + }, // Empty sealed_token + ), + ); - // Act & Assert - expect( - () => authService.refreshToken(), - throwsA( - predicate((e) => - e is Exception && - e.toString().contains('Invalid refresh response')), - ), - ); - }); + // Act & Assert + expect( + () => authService.refreshToken(), + throwsA( + predicate( + (e) => + e is Exception && + e.toString().contains('Invalid refresh response'), + ), + ), + ); + }, + ); }); group('signOut()', () { - test('should clear session and storage on successful server-side logout', - () async { - // Arrange - First restore a session - const session = CovesSession( - token: 'test-token', - did: 'did:plc:test123', - sessionId: 'session-123', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); - await authService.restoreSession(); - - // Mock successful logout - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( + test( + 'should clear session and storage on successful server-side logout', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock successful logout + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( requestOptions: RequestOptions(path: '/oauth/logout'), statusCode: 200, - )); + ), + ); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); - // Act - await authService.signOut(); + // Act + await authService.signOut(); - // Assert - expect(authService.session, isNull); - expect(authService.isAuthenticated, isFalse); - verify(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).called(1); - verify(mockStorage.delete(key: storageKey)).called(1); - }); + // Assert + expect(authService.session, isNull); + expect(authService.isAuthenticated, isFalse); + verify( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).called(1); + verify(mockStorage.delete(key: storageKey)).called(1); + }, + ); - test('should clear local state even when server revocation fails', - () async { - // Arrange - First restore a session - const session = CovesSession( - token: 'test-token', - did: 'did:plc:test123', - sessionId: 'session-123', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); - await authService.restoreSession(); - - // Mock server error - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenThrow( - DioException( - requestOptions: RequestOptions(path: '/oauth/logout'), - type: DioExceptionType.connectionError, - message: 'Connection failed', - ), - ); + test( + 'should clear local state even when server revocation fails', + () async { + // Arrange - First restore a session + const session = CovesSession( + token: 'test-token', + did: 'did:plc:test123', + sessionId: 'session-123', + ); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); + await authService.restoreSession(); + + // Mock server error + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenThrow( + DioException( + requestOptions: RequestOptions(path: '/oauth/logout'), + type: DioExceptionType.connectionError, + message: 'Connection failed', + ), + ); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); - // Act - await authService.signOut(); + // Act + await authService.signOut(); - // Assert - expect(authService.session, isNull); - expect(authService.isAuthenticated, isFalse); - verify(mockStorage.delete(key: storageKey)).called(1); - }); + // Assert + expect(authService.session, isNull); + expect(authService.isAuthenticated, isFalse); + verify(mockStorage.delete(key: storageKey)).called(1); + }, + ); test('should work even when no session exists', () async { // Arrange - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); // Act await authService.signOut(); @@ -441,10 +487,9 @@ void main() { expect(authService.session, isNull); expect(authService.isAuthenticated, isFalse); verify(mockStorage.delete(key: storageKey)).called(1); - verifyNever(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )); + verifyNever( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ); }); test('should clear local state even when storage delete fails', () async { @@ -454,20 +499,23 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + ), + ); - when(mockStorage.delete(key: storageKey)) - .thenThrow(Exception('Storage error')); + when( + mockStorage.delete(key: storageKey), + ).thenThrow(Exception('Storage error')); // Act & Assert - Should not throw expect(() => authService.signOut(), throwsA(isA())); @@ -485,8 +533,9 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Act @@ -511,19 +560,20 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + ), + ); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); // Act await authService.signOut(); @@ -547,8 +597,9 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Assert @@ -562,19 +613,20 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); - when(mockDio.post( - '/oauth/logout', - options: anyNamed('options'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/logout'), - statusCode: 200, - )); - when(mockStorage.delete(key: storageKey)) - .thenAnswer((_) async => {}); + when( + mockDio.post('/oauth/logout', options: anyNamed('options')), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/logout'), + statusCode: 200, + ), + ); + when(mockStorage.delete(key: storageKey)).thenAnswer((_) async => {}); // Act await authService.signOut(); @@ -592,8 +644,9 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); // Act await authService.restoreSession(); @@ -611,21 +664,30 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); await authService.restoreSession(); const newToken = 'new-token'; - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, - )); - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token', + }, + ), + ); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); // Act await authService.refreshToken(); @@ -637,62 +699,72 @@ void main() { }); group('refreshToken() - Concurrency Protection', () { - test('should only make one API request for concurrent refresh calls', - () async { - // Arrange - First restore a session - const initialSession = CovesSession( - token: 'old-token', - did: 'did:plc:test123', - sessionId: 'session-123', - handle: 'alice.bsky.social', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); - await authService.restoreSession(); - - const newToken = 'new-refreshed-token'; - - // Mock refresh response with a delay to simulate network latency - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { - await Future.delayed(const Duration(milliseconds: 100)); - return Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, + test( + 'should only make one API request for concurrent refresh calls', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', + handle: 'alice.bsky.social', ); - }); - - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); - - // Act - Launch 3 concurrent refresh calls - final results = await Future.wait([ - authService.refreshToken(), - authService.refreshToken(), - authService.refreshToken(), - ]); - - // Assert - All calls should return the same refreshed session - expect(results.length, 3); - expect(results[0].token, newToken); - expect(results[1].token, newToken); - expect(results[2].token, newToken); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); + + const newToken = 'new-refreshed-token'; + + // Mock refresh response with a delay to simulate network latency + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 100)); + return Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token', + }, + ); + }); + + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); + + // Act - Launch 3 concurrent refresh calls + final results = await Future.wait([ + authService.refreshToken(), + authService.refreshToken(), + authService.refreshToken(), + ]); + + // Assert - All calls should return the same refreshed session + expect(results.length, 3); + expect(results[0].token, newToken); + expect(results[1].token, newToken); + expect(results[2].token, newToken); + + // Verify only one API call was made + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); - // Verify only one API call was made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); - - // Verify only one storage write was made - verify(mockStorage.write( - key: storageKey, - value: anyNamed('value'), - )).called(1); - }); + // Verify only one storage write was made + verify( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).called(1); + }, + ); test('should propagate errors to all concurrent waiters', () async { // Arrange - First restore a session @@ -701,15 +773,18 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => session.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => session.toJsonString()); await authService.restoreSession(); // Mock 401 response with delay - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { await Future.delayed(const Duration(milliseconds: 100)); throw DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), @@ -744,10 +819,12 @@ void main() { expect(errorCount, 3); // Verify only one API call was made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(1); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(1); }); test('should allow new refresh after previous one completes', () async { @@ -757,25 +834,34 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); await authService.restoreSession(); const newToken1 = 'new-token-1'; const newToken2 = 'new-token-2'; // Mock first refresh - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': newToken1, 'access_token': 'some-access-token'}, - )); + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken1, + 'access_token': 'some-access-token', + }, + ), + ); - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); // Act - First refresh final result1 = await authService.refreshToken(); @@ -784,14 +870,21 @@ void main() { expect(result1.token, newToken1); // Now update the mock for the second refresh - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': newToken2, 'access_token': 'some-access-token'}, - )); + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken2, + 'access_token': 'some-access-token', + }, + ), + ); // Act - Second refresh (should be allowed since first completed) final result2 = await authService.refreshToken(); @@ -800,10 +893,12 @@ void main() { expect(result2.token, newToken2); // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(2); }); test('should allow new refresh after previous one fails', () async { @@ -813,15 +908,18 @@ void main() { did: 'did:plc:test123', sessionId: 'session-123', ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); await authService.restoreSession(); // Mock first refresh to fail - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenThrow( + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenThrow( DioException( requestOptions: RequestOptions(path: '/oauth/refresh'), type: DioExceptionType.connectionError, @@ -845,17 +943,25 @@ void main() { // Now mock a successful second refresh const newToken = 'new-token-after-retry'; - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async => Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': newToken, 'access_token': 'some-access-token'}, - )); + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer( + (_) async => Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': newToken, + 'access_token': 'some-access-token', + }, + ), + ); - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); // Act - Second refresh (should be allowed and succeed) final result = await authService.refreshToken(); @@ -864,66 +970,78 @@ void main() { expect(result.token, newToken); // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(2); }); test( - 'should handle concurrent calls where one arrives after refresh completes', - () async { - // Arrange - First restore a session - const initialSession = CovesSession( - token: 'old-token', - did: 'did:plc:test123', - sessionId: 'session-123', - ); - when(mockStorage.read(key: storageKey)) - .thenAnswer((_) async => initialSession.toJsonString()); - await authService.restoreSession(); - - const newToken1 = 'new-token-1'; - const newToken2 = 'new-token-2'; - - var callCount = 0; - - // Mock refresh with different responses - when(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).thenAnswer((_) async { - callCount++; - await Future.delayed(const Duration(milliseconds: 50)); - return Response( - requestOptions: RequestOptions(path: '/oauth/refresh'), - statusCode: 200, - data: {'sealed_token': callCount == 1 ? newToken1 : newToken2, 'access_token': 'some-access-token'}, + 'should handle concurrent calls where one arrives after refresh completes', + () async { + // Arrange - First restore a session + const initialSession = CovesSession( + token: 'old-token', + did: 'did:plc:test123', + sessionId: 'session-123', ); - }); - - when(mockStorage.write(key: storageKey, value: anyNamed('value'))) - .thenAnswer((_) async => {}); - - // Act - Start first refresh - final future1 = authService.refreshToken(); + when( + mockStorage.read(key: storageKey), + ).thenAnswer((_) async => initialSession.toJsonString()); + await authService.restoreSession(); - // Wait for it to complete - final result1 = await future1; + const newToken1 = 'new-token-1'; + const newToken2 = 'new-token-2'; - // Start second refresh after first completes - final result2 = await authService.refreshToken(); + var callCount = 0; - // Assert - expect(result1.token, newToken1); - expect(result2.token, newToken2); - - // Verify two separate API calls were made - verify(mockDio.post>( - '/oauth/refresh', - data: anyNamed('data'), - )).called(2); - }); + // Mock refresh with different responses + when( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).thenAnswer((_) async { + callCount++; + await Future.delayed(const Duration(milliseconds: 50)); + return Response( + requestOptions: RequestOptions(path: '/oauth/refresh'), + statusCode: 200, + data: { + 'sealed_token': callCount == 1 ? newToken1 : newToken2, + 'access_token': 'some-access-token', + }, + ); + }); + + when( + mockStorage.write(key: storageKey, value: anyNamed('value')), + ).thenAnswer((_) async => {}); + + // Act - Start first refresh + final future1 = authService.refreshToken(); + + // Wait for it to complete + final result1 = await future1; + + // Start second refresh after first completes + final result2 = await authService.refreshToken(); + + // Assert + expect(result1.token, newToken1); + expect(result2.token, newToken2); + + // Verify two separate API calls were made + verify( + mockDio.post>( + '/oauth/refresh', + data: anyNamed('data'), + ), + ).called(2); + }, + ); }); }); } diff --git a/test/services/coves_auth_service_validation_test.dart b/test/services/coves_auth_service_validation_test.dart index f019f31..d8004ad 100644 --- a/test/services/coves_auth_service_validation_test.dart +++ b/test/services/coves_auth_service_validation_test.dart @@ -30,92 +30,119 @@ void main() { group('Handle Validation', () { group('Valid inputs', () { test('should accept standard handle format', () { - final result = - authService.validateAndNormalizeHandle('alice.bsky.social'); + final result = authService.validateAndNormalizeHandle( + 'alice.bsky.social', + ); expect(result, 'alice.bsky.social'); }); test('should accept handle with @ prefix and strip it', () { - final result = - authService.validateAndNormalizeHandle('@alice.bsky.social'); + final result = authService.validateAndNormalizeHandle( + '@alice.bsky.social', + ); expect(result, 'alice.bsky.social'); }); - test('should accept handle with leading/trailing whitespace and trim', () { - final result = - authService.validateAndNormalizeHandle(' alice.bsky.social '); - expect(result, 'alice.bsky.social'); - }); + test( + 'should accept handle with leading/trailing whitespace and trim', + () { + final result = authService.validateAndNormalizeHandle( + ' alice.bsky.social ', + ); + expect(result, 'alice.bsky.social'); + }, + ); test('should accept handle with hyphen in segment', () { - final result = - authService.validateAndNormalizeHandle('alice-bob.bsky.social'); + final result = authService.validateAndNormalizeHandle( + 'alice-bob.bsky.social', + ); expect(result, 'alice-bob.bsky.social'); }); test('should accept handle with multiple hyphens', () { - final result = authService - .validateAndNormalizeHandle('alice-bob-charlie.bsky-app.social'); + final result = authService.validateAndNormalizeHandle( + 'alice-bob-charlie.bsky-app.social', + ); expect(result, 'alice-bob-charlie.bsky-app.social'); }); test('should accept handle with multiple subdomains', () { - final result = authService - .validateAndNormalizeHandle('alice.subdomain.example.com'); + final result = authService.validateAndNormalizeHandle( + 'alice.subdomain.example.com', + ); expect(result, 'alice.subdomain.example.com'); }); test('should accept handle with numbers', () { - final result = - authService.validateAndNormalizeHandle('user123.bsky.social'); + final result = authService.validateAndNormalizeHandle( + 'user123.bsky.social', + ); expect(result, 'user123.bsky.social'); }); test('should convert handle to lowercase', () { - final result = - authService.validateAndNormalizeHandle('Alice.Bsky.Social'); - expect(result, 'alice.bsky.social'); - }); - - test('should extract and validate handle from Bluesky profile URL (HTTP)', () { - final result = authService.validateAndNormalizeHandle( - 'http://bsky.app/profile/alice.bsky.social'); - expect(result, 'alice.bsky.social'); - }); - - test('should extract and validate handle from Bluesky profile URL (HTTPS)', () { final result = authService.validateAndNormalizeHandle( - 'https://bsky.app/profile/alice.bsky.social'); + 'Alice.Bsky.Social', + ); expect(result, 'alice.bsky.social'); }); - test('should extract and validate handle from Bluesky profile URL with www', () { - final result = authService.validateAndNormalizeHandle( - 'https://www.bsky.app/profile/alice.bsky.social'); - expect(result, 'alice.bsky.social'); - }); + test( + 'should extract and validate handle from Bluesky profile URL (HTTP)', + () { + final result = authService.validateAndNormalizeHandle( + 'http://bsky.app/profile/alice.bsky.social', + ); + expect(result, 'alice.bsky.social'); + }, + ); + + test( + 'should extract and validate handle from Bluesky profile URL (HTTPS)', + () { + final result = authService.validateAndNormalizeHandle( + 'https://bsky.app/profile/alice.bsky.social', + ); + expect(result, 'alice.bsky.social'); + }, + ); + + test( + 'should extract and validate handle from Bluesky profile URL with www', + () { + final result = authService.validateAndNormalizeHandle( + 'https://www.bsky.app/profile/alice.bsky.social', + ); + expect(result, 'alice.bsky.social'); + }, + ); test('should accept DID with plc method', () { - final result = - authService.validateAndNormalizeHandle('did:plc:abc123def456'); + final result = authService.validateAndNormalizeHandle( + 'did:plc:abc123def456', + ); expect(result, 'did:plc:abc123def456'); }); test('should accept DID with web method', () { - final result = - authService.validateAndNormalizeHandle('did:web:example.com'); + final result = authService.validateAndNormalizeHandle( + 'did:web:example.com', + ); expect(result, 'did:web:example.com'); }); test('should accept DID with complex identifier', () { - final result = authService - .validateAndNormalizeHandle('did:plc:z72i7hdynmk6r22z27h6tvur'); + final result = authService.validateAndNormalizeHandle( + 'did:plc:z72i7hdynmk6r22z27h6tvur', + ); expect(result, 'did:plc:z72i7hdynmk6r22z27h6tvur'); }); test('should accept DID with periods and colons in identifier', () { - final result = authService - .validateAndNormalizeHandle('did:web:example.com:user:alice'); + final result = authService.validateAndNormalizeHandle( + 'did:web:example.com:user:alice', + ); expect(result, 'did:web:example.com:user:alice'); }); @@ -125,26 +152,28 @@ void main() { }); test('should normalize handle with @ prefix and whitespace', () { - final result = - authService.validateAndNormalizeHandle(' @Alice.Bsky.Social '); + final result = authService.validateAndNormalizeHandle( + ' @Alice.Bsky.Social ', + ); expect(result, 'alice.bsky.social'); }); test('should accept handle with numeric first segment', () { - final result = - authService.validateAndNormalizeHandle('123.bsky.social'); + final result = authService.validateAndNormalizeHandle( + '123.bsky.social', + ); expect(result, '123.bsky.social'); }); test('should accept handle with numeric middle segment', () { - final result = - authService.validateAndNormalizeHandle('alice.456.social'); + final result = authService.validateAndNormalizeHandle( + 'alice.456.social', + ); expect(result, 'alice.456.social'); }); test('should accept handle with multiple numeric segments', () { - final result = - authService.validateAndNormalizeHandle('42.example.com'); + final result = authService.validateAndNormalizeHandle('42.example.com'); expect(result, '42.example.com'); }); @@ -154,14 +183,12 @@ void main() { }); test('should accept handle with numeric and alpha mixed', () { - final result = - authService.validateAndNormalizeHandle('8.cn'); + final result = authService.validateAndNormalizeHandle('8.cn'); expect(result, '8.cn'); }); test('should accept handle like IP but with valid TLD', () { - final result = - authService.validateAndNormalizeHandle('120.0.0.1.com'); + final result = authService.validateAndNormalizeHandle('120.0.0.1.com'); expect(result, '120.0.0.1.com'); }); }); @@ -259,19 +286,22 @@ void main() { ); }); - test('should throw ArgumentError for handle with consecutive periods', () { - expect( - () => authService.validateAndNormalizeHandle('alice..bsky.social'), - throwsA( - predicate( - (e) => - e is ArgumentError && - (e.message.toString().contains('empty segments') || - e.message.toString().contains('Invalid handle format')), + test( + 'should throw ArgumentError for handle with consecutive periods', + () { + expect( + () => authService.validateAndNormalizeHandle('alice..bsky.social'), + throwsA( + predicate( + (e) => + e is ArgumentError && + (e.message.toString().contains('empty segments') || + e.message.toString().contains('Invalid handle format')), + ), ), - ), - ); - }); + ); + }, + ); test('should throw ArgumentError for handle with spaces', () { expect( @@ -338,35 +368,41 @@ void main() { ); }); - test('should throw ArgumentError for handle exceeding 253 characters', () { - // Create a handle that's 254 characters long - final longHandle = '${'a' * 240}.bsky.social'; - expect( - () => authService.validateAndNormalizeHandle(longHandle), - throwsA( - predicate( - (e) => - e is ArgumentError && - e.message.toString().contains('too long'), + test( + 'should throw ArgumentError for handle exceeding 253 characters', + () { + // Create a handle that's 254 characters long + final longHandle = '${'a' * 240}.bsky.social'; + expect( + () => authService.validateAndNormalizeHandle(longHandle), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('too long'), + ), ), - ), - ); - }); - - test('should throw ArgumentError for segment exceeding 63 characters', () { - // DNS label limit is 63 characters per segment - final longSegment = '${'a' * 64}.bsky.social'; - expect( - () => authService.validateAndNormalizeHandle(longSegment), - throwsA( - predicate( - (e) => - e is ArgumentError && - e.message.toString().contains('too long'), + ); + }, + ); + + test( + 'should throw ArgumentError for segment exceeding 63 characters', + () { + // DNS label limit is 63 characters per segment + final longSegment = '${'a' * 64}.bsky.social'; + expect( + () => authService.validateAndNormalizeHandle(longSegment), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('too long'), + ), ), - ), - ); - }); + ); + }, + ); test('should throw ArgumentError for TLD starting with digit', () { expect( @@ -396,19 +432,22 @@ void main() { ); }); - test('should throw ArgumentError for IPv4 address (TLD starts with digit)', () { - expect( - () => authService.validateAndNormalizeHandle('127.0.0.1'), - throwsA( - predicate( - (e) => - e is ArgumentError && - e.message.toString().contains('TLD') && - e.message.toString().contains('cannot start with a digit'), + test( + 'should throw ArgumentError for IPv4 address (TLD starts with digit)', + () { + expect( + () => authService.validateAndNormalizeHandle('127.0.0.1'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('TLD') && + e.message.toString().contains('cannot start with a digit'), + ), ), - ), - ); - }); + ); + }, + ); test('should throw ArgumentError for IPv4 address variant', () { expect( @@ -427,36 +466,40 @@ void main() { group('DID Validation', () { test('should accept valid plc DID', () { - final result = - authService.validateAndNormalizeHandle('did:plc:abc123'); + final result = authService.validateAndNormalizeHandle('did:plc:abc123'); expect(result, 'did:plc:abc123'); }); test('should accept valid web DID', () { - final result = - authService.validateAndNormalizeHandle('did:web:example.com'); + final result = authService.validateAndNormalizeHandle( + 'did:web:example.com', + ); expect(result, 'did:web:example.com'); }); test('should accept DID with underscores in identifier', () { // Underscores are allowed in the DID pattern (part of [a-zA-Z0-9._:%-]+) - final result = - authService.validateAndNormalizeHandle('did:plc:abc_123'); + final result = authService.validateAndNormalizeHandle( + 'did:plc:abc_123', + ); expect(result, 'did:plc:abc_123'); }); - test('should throw ArgumentError for invalid DID with @ special chars', () { - expect( - () => authService.validateAndNormalizeHandle('did:plc:abc@123'), - throwsA( - predicate( - (e) => - e is ArgumentError && - e.message.toString().contains('Invalid DID format'), + test( + 'should throw ArgumentError for invalid DID with @ special chars', + () { + expect( + () => authService.validateAndNormalizeHandle('did:plc:abc@123'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), ), - ), - ); - }); + ); + }, + ); test('should throw ArgumentError for DID with uppercase method', () { expect( @@ -484,18 +527,21 @@ void main() { ); }); - test('should throw ArgumentError for malformed DID (missing identifier)', () { - expect( - () => authService.validateAndNormalizeHandle('did:plc'), - throwsA( - predicate( - (e) => - e is ArgumentError && - e.message.toString().contains('Invalid DID format'), + test( + 'should throw ArgumentError for malformed DID (missing identifier)', + () { + expect( + () => authService.validateAndNormalizeHandle('did:plc'), + throwsA( + predicate( + (e) => + e is ArgumentError && + e.message.toString().contains('Invalid DID format'), + ), ), - ), - ); - }); + ); + }, + ); test('should throw ArgumentError for malformed DID (missing method)', () { expect( diff --git a/test/services/vote_service_token_refresh_test.dart b/test/services/vote_service_token_refresh_test.dart index 172073a..dfac38f 100644 --- a/test/services/vote_service_token_refresh_test.dart +++ b/test/services/vote_service_token_refresh_test.dart @@ -90,10 +90,7 @@ void main() { 'message': 'Token expired', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -136,10 +133,7 @@ void main() { 'message': 'Token expired', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -164,59 +158,60 @@ void main() { expect(signOutCallCount, 1); }); - test('should handle 401 gracefully when no refresher is provided', - () async { - // Create a NEW dio instance to avoid sharing interceptors - final dioNoRefresh = Dio(BaseOptions(baseUrl: 'https://api.test.coves.social')); - final dioAdapterNoRefresh = DioAdapter(dio: dioNoRefresh); - - // Create vote service without refresh capability - final voteServiceNoRefresh = VoteService( - dio: dioNoRefresh, - sessionGetter: mockSessionGetter, - didGetter: mockDidGetter, - // No tokenRefresher provided - // No signOutHandler provided - ); - - const postUri = 'at://did:plc:test/social.coves.post.record/123'; - const postCid = 'bafy123'; + test( + 'should handle 401 gracefully when no refresher is provided', + () async { + // Create a NEW dio instance to avoid sharing interceptors + final dioNoRefresh = Dio( + BaseOptions(baseUrl: 'https://api.test.coves.social'), + ); + final dioAdapterNoRefresh = DioAdapter(dio: dioNoRefresh); + + // Create vote service without refresh capability + final voteServiceNoRefresh = VoteService( + dio: dioNoRefresh, + sessionGetter: mockSessionGetter, + didGetter: mockDidGetter, + // No tokenRefresher provided + // No signOutHandler provided + ); - // Request returns 401 - dioAdapterNoRefresh.onPost( - '/xrpc/social.coves.feed.vote.create', - (server) => server.reply(401, { - 'error': 'Unauthorized', - 'message': 'Token expired', - }), - data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, + const postUri = 'at://did:plc:test/social.coves.post.record/123'; + const postCid = 'bafy123'; + + // Request returns 401 + dioAdapterNoRefresh.onPost( + '/xrpc/social.coves.feed.vote.create', + (server) => server.reply(401, { + 'error': 'Unauthorized', + 'message': 'Token expired', + }), + data: { + 'subject': {'uri': postUri, 'cid': postCid}, + 'direction': 'up', }, - 'direction': 'up', - }, - ); + ); - // Make the request and expect it to fail - expect( - () => voteServiceNoRefresh.createVote( - postUri: postUri, - postCid: postCid, - direction: 'up', - ), - throwsA(isA()), - ); + // Make the request and expect it to fail + expect( + () => voteServiceNoRefresh.createVote( + postUri: postUri, + postCid: postCid, + direction: 'up', + ), + throwsA(isA()), + ); - // Wait for async operations - await Future.delayed(const Duration(milliseconds: 100)); + // Wait for async operations + await Future.delayed(const Duration(milliseconds: 100)); - // Verify refresh was NOT called (no refresher provided) - expect(tokenRefreshCallCount, 0); + // Verify refresh was NOT called (no refresher provided) + expect(tokenRefreshCallCount, 0); - // Verify sign-out was NOT called (no handler provided) - expect(signOutCallCount, 0); - }); + // Verify sign-out was NOT called (no handler provided) + expect(signOutCallCount, 0); + }, + ); test('should handle non-401 errors normally without refresh', () async { const postUri = 'at://did:plc:test/social.coves.post.record/123'; @@ -230,10 +225,7 @@ void main() { 'message': 'Database connection failed', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -268,9 +260,7 @@ void main() { 'error': 'Unauthorized', 'message': 'Token expired', }), - data: { - 'rkey': rkey, - }, + data: {'rkey': rkey}, ); // Create vote with existing vote (will trigger delete) @@ -338,10 +328,7 @@ void main() { 'cid': 'bafy456', }), data: { - 'subject': { - 'uri': postUri, - 'cid': postCid, - }, + 'subject': {'uri': postUri, 'cid': postCid}, 'direction': 'up', }, ); @@ -364,9 +351,7 @@ void main() { dioAdapter.onPost( '/xrpc/social.coves.feed.vote.delete', (server) => server.reply(200, {}), - data: { - 'rkey': 'xyz', - }, + data: {'rkey': 'xyz'}, ); // Make second request (delete vote) -- 2.51.2 From 89a73dae7eae0421d7e0f28cc46532200b621e37 Mon Sep 17 00:00:00 2001 From: Bretton Date: Tue, 2 Dec 2025 15:32:42 -0800 Subject: [PATCH 11/11] fix: resolve flutter analyze warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused imports in test files - Add assertion on unused variable in singleton test - Clean up redaction test file (remove unused mocks) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../coves_auth_service_redaction_test.dart | 21 ++----------------- .../coves_auth_service_singleton_test.dart | 1 + .../coves_auth_service_validation_test.dart | 1 - 3 files changed, 3 insertions(+), 20 deletions(-) diff --git a/test/services/coves_auth_service_redaction_test.dart b/test/services/coves_auth_service_redaction_test.dart index 553bb4a..4f8a790 100644 --- a/test/services/coves_auth_service_redaction_test.dart +++ b/test/services/coves_auth_service_redaction_test.dart @@ -1,32 +1,15 @@ import 'package:coves_flutter/models/coves_session.dart'; import 'package:coves_flutter/services/coves_auth_service.dart'; -import 'package:dio/dio.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'coves_auth_service_test.mocks.dart'; /// Tests for sensitive data redaction in CovesAuthService /// /// Verifies that sensitive parameters (tokens) are properly redacted /// from debug logs while preserving useful debugging information. -@GenerateMocks([Dio, FlutterSecureStorage]) void main() { - late CovesAuthService service; - late MockDio mockDio; - late MockFlutterSecureStorage mockStorage; - setUp(() { - mockDio = MockDio(); - mockStorage = MockFlutterSecureStorage(); - - // Create a test instance - service = CovesAuthService.createTestInstance( - dio: mockDio, - storage: mockStorage, - ); + // Reset singleton state before each test + CovesAuthService.resetInstance(); }); tearDown(() { diff --git a/test/services/coves_auth_service_singleton_test.dart b/test/services/coves_auth_service_singleton_test.dart index a443fd4..662a1c4 100644 --- a/test/services/coves_auth_service_singleton_test.dart +++ b/test/services/coves_auth_service_singleton_test.dart @@ -148,6 +148,7 @@ void main() { test('resetInstance() should clear the singleton', () { // Arrange final instance1 = CovesAuthService(dio: mockDio, storage: mockStorage); + expect(instance1, isNotNull); // Verify initial singleton exists // Act CovesAuthService.resetInstance(); diff --git a/test/services/coves_auth_service_validation_test.dart b/test/services/coves_auth_service_validation_test.dart index d8004ad..a027a96 100644 --- a/test/services/coves_auth_service_validation_test.dart +++ b/test/services/coves_auth_service_validation_test.dart @@ -3,7 +3,6 @@ import 'package:dio/dio.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; import 'coves_auth_service_test.mocks.dart'; -- 2.51.2