Separate second-factor verification and delivery from auth (#40)

This commit is contained in:
Stephan D committed 2026-09-12 20:36:35 +02:00
1 parent 12a38cd654
commit ecb63b61c6
33 files changed
+1772 -123

No files matched your search

@@ -13,6 +13,8 @@ class BackendDTO {
'';
String get pendingToken =>
(data['pendingToken'] as Map<String, dynamic>)['token'] as String;
String get challengeId => data['challengeId'] as String? ?? '';
String get channel => data['channel'] as String? ?? '';
String get target => data['target'] as String;
DateTime get expiresAt => DateTime.parse(data['expiresAt'] as String);
DateTime get resendAt => DateTime.parse(data['resendAt'] as String);
@@ -9,15 +9,20 @@ abstract final class BackendMapper {
return AuthResult(userId: dto.userId, isSimulated: false);
}
static FactorChallenge challenge(BackendDTO dto, String id) =>
FactorChallenge(
id: id,
purpose: FactorPurpose.login,
operation: id,
target: dto.target,
expiresAt: dto.expiresAt,
resendAt: dto.resendAt,
);
static FactorChallenge challenge(BackendDTO dto, String id) {
if (dto.challengeId.isEmpty || dto.channel != 'email') {
throw const FactorException(FactorError.unavailable);
}
return FactorChallenge(
id: id,
purpose: FactorPurpose.login,
operation: id,
target: dto.target,
expiresAt: dto.expiresAt,
resendAt: dto.resendAt,
);
}
static AuthSession session(BackendDTO dto) {
if (dto.accessToken.isEmpty || dto.userId.isEmpty) {
throw const FormatException('Missing session');
@@ -48,7 +48,7 @@ class BackendAuthServices
final String clientId;
final http.Client _client;
final String _deviceId = _key();
final _pending = <String, String>{};
final _pending = <String, ({String token, String challengeId})>{};
final _grants = <String, AuthSession>{};
final _retryKeys = <String, String>{};
String? _queuedSignup;
@@ -125,7 +125,7 @@ class BackendAuthServices
});
final id = _key();
final challenge = BackendMapper.challenge(dto, id);
_pending[id] = dto.pendingToken;
_pending[id] = (token: dto.pendingToken, challengeId: dto.challengeId);
return AuthResult(userId: '', isSimulated: false, challenge: challenge);
}
@@ -185,11 +185,16 @@ class BackendAuthServices
if (pending == null) throw const FactorException(FactorError.expired);
final key = _retryKeys.putIfAbsent('login:$challengeId', _key);
final dto = await _post('login/resend', {
'pendingToken': pending,
'pendingToken': pending.token,
'challengeId': pending.challengeId,
'idempotencyKey': key,
}, factor: true);
final challenge = BackendMapper.challenge(dto, challengeId);
if (dto.challengeId != pending.challengeId) {
throw const FactorException(FactorError.unavailable);
}
_retryKeys.remove('login:$challengeId');
return BackendMapper.challenge(dto, challengeId);
return challenge;
}
@override
@@ -197,7 +202,8 @@ class BackendAuthServices
final pending = _pending[challengeId];
if (pending == null) throw const FactorException(FactorError.expired);
final dto = await _post('login/verify', {
'pendingToken': pending,
'pendingToken': pending.token,
'challengeId': pending.challengeId,
'code': code,
}, factor: true);
final session = BackendMapper.session(dto);
@@ -56,6 +56,8 @@ void main() {
return response({
'userId': 'user',
'pendingToken': {'token': 'signed-pending'},
'challengeId': 'server-challenge',
'channel': 'email',
'target': 'u***@example.com',
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
@@ -63,6 +65,7 @@ void main() {
}
expect(request.url.path, '/api/v1/accounts/login/verify');
expect(body['pendingToken'], 'signed-pending');
expect(body['challengeId'], 'server-challenge');
if (body['code'] != '123456')
return response({'error': 'invalid'}, 400);
verified = true;
@@ -145,4 +148,75 @@ void main() {
),
);
});
test(
'resend preserves server challenge and retries the same delivery key',
() async {
final now = DateTime.now();
final keys = <String>[];
Map<String, Object?> challenge() => {
'userId': 'user',
'pendingToken': {'token': 'pending'},
'challengeId': 'server-challenge',
'channel': 'email',
'target': 's***@example.com',
'expiresAt': now.add(const Duration(minutes: 10)).toIso8601String(),
'resendAt': now.add(const Duration(minutes: 1)).toIso8601String(),
};
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
if (request.url.path.endsWith('/login')) return response(challenge());
final body = jsonDecode(request.body);
expect(body['challengeId'], 'server-challenge');
expect(body['pendingToken'], 'pending');
keys.add(body['idempotencyKey'] as String);
if (keys.length == 1) return response({'error': 'unavailable'}, 503);
return response(challenge());
}),
);
addTearDown(service.close);
final result = await service.login(
const LoginRequest(
email: 'user@example.com',
password: 'password',
locale: 'en',
),
);
final id = result.challenge!.id;
await expectLater(service.resend(id), throwsA(isA<FactorException>()));
final resent = await service.resend(id);
expect(resent.id, id);
expect(resent.target, 's***@example.com');
expect(keys[0], keys[1]);
expect(service.session, isNull);
},
);
for (final channel in ['sms', '']) {
test(
'unsupported or missing channel $channel cannot create a login challenge',
() async {
final service = BackendAuthServices(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient(
(_) async =>
response({'challengeId': 'server', 'channel': channel}),
),
);
addTearDown(service.close);
await expectLater(
service.login(
const LoginRequest(
email: 'user@example.com',
password: 'password',
locale: 'en',
),
),
throwsA(isA<FactorException>()),
);
expect(service.session, isNull);
},
);
}
}