feat: add CovesSession model for simplified backend OAuth

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 <noreply@anthropic.com>

Changed files
+911
lib
test
+112
lib/models/coves_session.dart
···
···
+
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<String, dynamic> 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<String, dynamic>,
+
);
+
}
+
+
/// 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<String, dynamic> 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)';
+
}
+
}
+799
test/models/coves_session_test.dart
···
···
+
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<FormatException>().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<FormatException>().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<FormatException>().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<FormatException>().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<FormatException>().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<FormatException>().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<TypeError>()),
+
);
+
});
+
+
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<TypeError>()),
+
);
+
});
+
+
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<TypeError>()),
+
);
+
});
+
+
test('should throw when token field is missing', () {
+
final json = {
+
'did': 'did:plc:test123',
+
'session_id': 'sess456',
+
};
+
+
expect(
+
() => CovesSession.fromJson(json),
+
throwsA(isA<TypeError>()),
+
);
+
});
+
+
test('should throw when did field is missing', () {
+
final json = {
+
'token': 'abc123',
+
'session_id': 'sess456',
+
};
+
+
expect(
+
() => CovesSession.fromJson(json),
+
throwsA(isA<TypeError>()),
+
);
+
});
+
+
test('should throw when session_id field is missing', () {
+
final json = {
+
'token': 'abc123',
+
'did': 'did:plc:test123',
+
};
+
+
expect(
+
() => CovesSession.fromJson(json),
+
throwsA(isA<TypeError>()),
+
);
+
});
+
+
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<FormatException>()),
+
);
+
});
+
+
test('should throw on empty string', () {
+
const emptyString = '';
+
+
expect(
+
() => CovesSession.fromJsonString(emptyString),
+
throwsA(isA<FormatException>()),
+
);
+
});
+
+
test('should throw on non-JSON string', () {
+
const notJson = 'not a json string';
+
+
expect(
+
() => CovesSession.fromJsonString(notJson),
+
throwsA(isA<FormatException>()),
+
);
+
});
+
+
test('should throw on JSON array instead of object', () {
+
const jsonArray = '["token", "did", "session_id"]';
+
+
expect(
+
() => CovesSession.fromJsonString(jsonArray),
+
throwsA(isA<TypeError>()),
+
);
+
});
+
+
test('should throw on null JSON', () {
+
const nullJson = 'null';
+
+
expect(
+
() => CovesSession.fromJsonString(nullJson),
+
throwsA(isA<TypeError>()),
+
);
+
});
+
});
+
+
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<String, dynamic>;
+
+
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')));
+
});
+
});
+
}