Skip to content
Merged
14 changes: 7 additions & 7 deletions app/integration_test/powersync_books_integration_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../test/support/disposable_sync_accounts.dart';
import 'dart:io';
import 'dart:typed_data';

Expand Down Expand Up @@ -168,8 +169,9 @@ void main() {

testWidgets('two clients sync books, reconnect offline writes, and isolate users', (tester) async {
final config = PapyrusApiConfig.fromEnvironment();
final email = 'powersync-${DateTime.now().microsecondsSinceEpoch}@example.com';
const password = 'SecureP@ss123';
requireDisposableSyncAccounts();
const email = syncOwnerEmail;
const password = syncPassword;
final root = await Directory.systemTemp.createTemp('papyrus-e2e-');

AuthRepository repository(String label) {
Expand All @@ -189,21 +191,19 @@ void main() {

final firstAuth = repository('first');

final firstTokens = await firstAuth.register(
final firstTokens = await firstAuth.login(
email: email,
password: password,
displayName: 'PowerSync Test',
clientType: 'desktop',
);

final secondAuth = repository('second');
await secondAuth.login(email: email, password: password, clientType: 'desktop');
final otherAuth = repository('other');

final otherTokens = await otherAuth.register(
email: 'other-$email',
final otherTokens = await otherAuth.login(
email: syncOtherEmail,
password: password,
displayName: 'Other User',
clientType: 'desktop',
);

Expand Down
14 changes: 7 additions & 7 deletions app/integration_test/powersync_timer_integration_test.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../test/support/disposable_sync_accounts.dart';
import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
Expand Down Expand Up @@ -42,8 +43,9 @@ void main() {
(tester) async {
final config = PapyrusApiConfig.fromEnvironment();
final root = await Directory.systemTemp.createTemp('papyrus-timer-sync-');
final email = 'timer-${DateTime.now().microsecondsSinceEpoch}@example.com';
const password = 'SecureP@ss123';
requireDisposableSyncAccounts();
const email = syncOwnerEmail;
const password = syncPassword;
final accounts = <AuthRepository>[];
final services = <PapyrusPowerSyncService>[];
final stores = <DataStore>[];
Expand Down Expand Up @@ -82,20 +84,18 @@ void main() {

try {
final browserAuth = auth();
final owner = await browserAuth.register(
final owner = await browserAuth.login(
email: email,
password: password,
displayName: 'Timer test',
clientType: 'desktop',
);
accounts.add(browserAuth);
final phoneAuth = auth();
await phoneAuth.login(email: email, password: password, clientType: 'desktop');
final otherAuth = auth();
final otherOwner = await otherAuth.register(
email: 'other-$email',
final otherOwner = await otherAuth.login(
email: syncOtherEmail,
password: password,
displayName: 'Other',
clientType: 'desktop',
);
accounts.add(otherAuth);
Expand Down
63 changes: 56 additions & 7 deletions app/lib/auth/auth_api_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ class AuthApiException implements Exception {
final String? code;
final Duration? retryAfter;
final AccountDeletionAuthorization? recoveryAuthorization;
final PendingVerification? pendingVerification;

const AuthApiException({
required this.statusCode,
required this.message,
this.code,
this.retryAfter,
this.recoveryAuthorization,
this.pendingVerification,
});

@override
Expand All @@ -29,6 +31,7 @@ class AuthApiException implements Exception {

class AuthApiClient {
static const serverUnavailableMessage = 'Unable to connect. Please try again.';
static const verificationUnavailableMessage = 'Couldn’t send the verification email. Please try again.';
static const rateLimitMessage = 'Too many requests. Please try again later.';

final PapyrusApiConfig config;
Expand Down Expand Up @@ -63,7 +66,7 @@ class AuthApiClient {
return config.endpoint('/auth/oauth/google/start', {'redirect_uri': redirectUri});
}

Future<AuthTokens> register({
Future<PendingVerification> register({
required String email,
required String password,
required String displayName,
Expand All @@ -81,7 +84,26 @@ class AuthApiClient {
},
);

return AuthTokens.fromJson(json);
if (json['status'] == 'verification_required' &&
json['email'] is String &&
json['message'] is String &&
!json.containsKey('access_token') &&
!json.containsKey('refresh_token')) {
return PendingVerification.fromJson(json);
}

// Revoke unexpected credentials without installing an authenticated session.
final accessToken = json['access_token'];

if (accessToken is String && accessToken.isNotEmpty) {
try {
await logout(accessToken);
} catch (_) {
// Never install the rejected session, even when revocation is unavailable.
}
}

throw const FormatException('Expected pending registration');
}

Future<AuthTokens> login({
Expand Down Expand Up @@ -199,12 +221,18 @@ class AuthApiClient {

Future<String> verifyEmail(String token) async {
final json = await _postJson(config.endpoint('/auth/verify-email'), body: {'token': token});
return json['message'] as String? ?? 'Email verified successfully';
final message = json['message'];

if (message is! String || message.isEmpty) {
throw const FormatException('Invalid verification response');
}

return message;
}

Future<String> resendVerification(String email) async {
Future<VerificationDelivery> resendVerification(String email) async {
final json = await _postJson(config.endpoint('/auth/resend-verification'), body: {'email': email});
return json['message'] as String? ?? 'If the email is registered, a verification link has been sent';
return VerificationDelivery.fromJson(json);
}

Future<PowerSyncToken> powerSyncToken(String accessToken) async {
Expand Down Expand Up @@ -314,11 +342,26 @@ class AuthApiClient {

Map<String, dynamic> _decodeResponse(http.Response response) {
if (response.statusCode == 429) {
String? retryHeader = response.headers['retry-after'];

if (retryHeader == null) {
try {
final body = jsonDecode(response.body);
final seconds = body['error']['details']['retry_after'];

if (seconds is int && seconds >= 0) {
retryHeader = '$seconds';
}
} catch (_) {
// Proxies may return a non-JSON rate-limit response.
}
}

throw AuthApiException(
statusCode: 429,
code: 'RATE_LIMIT_EXCEEDED',
message: rateLimitMessage,
retryAfter: _retryAfter(response.headers['retry-after']),
retryAfter: _retryAfter(retryHeader),
);
}

Expand All @@ -334,10 +377,16 @@ class AuthApiClient {
throw AuthApiException(
statusCode: response.statusCode,
code: error['code'] as String?,
retryAfter: response.headers.containsKey('retry-after') ? _retryAfter(response.headers['retry-after']) : null,
pendingVerification: error['code'] == 'EMAIL_VERIFICATION_REQUIRED'
? PendingVerification.fromJson(error['details'] as Map<String, dynamic>)
: null,
recoveryAuthorization: error['code'] == 'ACCOUNT_DELETION_PENDING'
? AccountDeletionAuthorization.fromJson(error['details']['authorization'] as Map<String, dynamic>)
: null,
message: error['message'] as String? ?? 'Authentication request failed',
message: error['code'] == 'VERIFICATION_UNAVAILABLE'
? verificationUnavailableMessage
: error['message'] as String? ?? 'Authentication request failed',
);
}

Expand Down
36 changes: 36 additions & 0 deletions app/lib/auth/auth_models.dart
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,39 @@ class AccountDeletionAuthorization {
);
}
}

class PendingVerification {
final String? email;
final int resendAfter;

const PendingVerification({required this.email, required this.resendAfter});

factory PendingVerification.fromJson(Map<String, dynamic> json) {
final email = json['email'];
final seconds = json['resend_after'];

if ((email != null && (email is! String || email.isEmpty)) || seconds is! int || seconds < 0) {
throw const FormatException('Invalid verification response');
}

return PendingVerification(email: email as String?, resendAfter: seconds);
}
}

class VerificationDelivery {
final String message;
final int resendAfter;

const VerificationDelivery({required this.message, required this.resendAfter});

factory VerificationDelivery.fromJson(Map<String, dynamic> json) {
final message = json['message'];
final seconds = json['resend_after'];

if (message is! String || message.isEmpty || seconds is! int || seconds < 0) {
throw const FormatException('Invalid verification delivery response');
}

return VerificationDelivery(message: message, resendAfter: seconds);
}
}
Loading
Loading