Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion infra/gotrue/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
version: '3'
services:
gotrue: # Signup enabled, autoconfirm on
image: supabase/auth:v2.151.0
image: supabase/auth:v2.175.0
ports:
- '9998:9998'
environment:
Expand All @@ -27,6 +27,8 @@ services:
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: http://localhost:9998/callback
GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: 'true'
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true'
GOTRUE_MFA_PHONE_ENROLL_ENABLED: 'true'
GOTRUE_MFA_PHONE_VERIFY_ENABLED: 'true'

depends_on:
- db
Expand Down
45 changes: 34 additions & 11 deletions packages/gotrue/lib/src/gotrue_mfa_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,39 +28,57 @@ class GoTrueMFAApi {

/// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor.
/// This method creates a new `unverified` factor.
/// To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.
///
/// The user has to enter the code from their authenticator app to verify it.
/// For TOTP: To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.
/// For Phone: The user will receive an SMS with a verification code.
///
/// The user has to enter the code from their authenticator app or SMS to verify it.
///
/// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
///
/// [factorType] : Type of factor being enrolled.
///
/// [issuer] : Domain which the user is enrolled with.
/// [issuer] : Domain which the user is enrolled with (TOTP only).
///
/// [friendlyName] : Human readable name assigned to the factor.
///
/// [phone] : Phone number to enroll for Phone factor type.
Future<AuthMFAEnrollResponse> enroll({
FactorType factorType = FactorType.totp,
String? issuer,
String? friendlyName,
String? phone,
}) async {
final session = _client.currentSession;

final body = <String, dynamic>{
'friendly_name': friendlyName,
'factor_type': factorType.name,
};

if (factorType == FactorType.totp) {
body['issuer'] = issuer;
} else if (factorType == FactorType.phone) {
if (phone == null) {
throw ArgumentError('Phone number is required for phone factor type');
}
body['phone'] = phone;
}

final data = await _fetch.request(
'${_client._url}/factors',
RequestMethodType.post,
options: GotrueRequestOptions(
headers: _client._headers,
body: {
'friendly_name': friendlyName,
'factor_type': factorType.name,
'issuer': issuer,
},
body: body,
jwt: session?.accessToken,
),
);

data['totp']['qr_code'] =
'data:image/svg+xml;utf-8,${data['totp']['qr_code']}';
if (factorType == FactorType.totp && data['totp'] != null) {
data['totp']['qr_code'] =
'data:image/svg+xml;utf-8,${data['totp']['qr_code']}';
}

final response = AuthMFAEnrollResponse.fromJson(data);
return response;
Expand Down Expand Up @@ -150,8 +168,13 @@ class GoTrueMFAApi {
factor.factorType == FactorType.totp &&
factor.status == FactorStatus.verified)
.toList();
final phone = factors
.where((factor) =>
factor.factorType == FactorType.phone &&
factor.status == FactorStatus.verified)
.toList();

return AuthMFAListFactorsResponse(all: factors, totp: totp);
return AuthMFAListFactorsResponse(all: factors, totp: totp, phone: phone);
}

/// Returns the Authenticator Assurance Level (AAL) for the active session.
Expand Down
61 changes: 52 additions & 9 deletions packages/gotrue/lib/src/types/mfa.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,33 @@ class AuthMFAEnrollResponse {
/// ID of the factor that was just enrolled (in an unverified state).
final String id;

/// Type of MFA factor. Only `[FactorType.totp] supported for now.
/// Type of MFA factor. Supports both `[FactorType.totp]` and `[FactorType.phone]`.
final FactorType type;

/// TOTP enrollment information.
final TOTPEnrollment totp;
/// TOTP enrollment information (only present when type is totp).
final TOTPEnrollment? totp;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this isn't great, but I think we can call it that it's a fix. Open to suggestions to avoid this though.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/// Phone enrollment information (only present when type is phone).
final PhoneEnrollment? phone;

const AuthMFAEnrollResponse({
required this.id,
required this.type,
required this.totp,
this.totp,
this.phone,
});

factory AuthMFAEnrollResponse.fromJson(Map<String, dynamic> json) {
final type = FactorType.values.firstWhere((e) => e.name == json['type']);
return AuthMFAEnrollResponse(
id: json['id'],
type: FactorType.values.firstWhere((e) => e.name == json['type']),
totp: TOTPEnrollment.fromJson(json['totp']),
type: type,
totp: type == FactorType.totp && json['totp'] != null
? TOTPEnrollment.fromJson(json['totp'])
: null,
phone: type == FactorType.phone && json['phone'] != null
? PhoneEnrollment._fromJsonValue(json['phone'])
: null,
);
}
}
Expand Down Expand Up @@ -54,6 +64,34 @@ class TOTPEnrollment {
}
}

class PhoneEnrollment {
/// The phone number that will receive the SMS OTP.
final String phone;

const PhoneEnrollment({
required this.phone,
});

factory PhoneEnrollment.fromJson(Map<String, dynamic> json) {
return PhoneEnrollment(
phone: json['phone'],
);
}

factory PhoneEnrollment._fromJsonValue(dynamic value) {
if (value is String) {
// Server returns phone number as a string directly
return PhoneEnrollment(phone: value);
} else if (value is Map<String, dynamic>) {
// Server returns phone data as an object
return PhoneEnrollment.fromJson(value);
} else {
throw ArgumentError(
'Invalid phone enrollment data type: ${value.runtimeType}');
}
}
}

class AuthMFAChallengeResponse {
/// ID of the newly created challenge.
final String id;
Expand Down Expand Up @@ -120,8 +158,13 @@ class AuthMFAUnenrollResponse {
class AuthMFAListFactorsResponse {
final List<Factor> all;
final List<Factor> totp;
final List<Factor> phone;

AuthMFAListFactorsResponse({required this.all, required this.totp});
AuthMFAListFactorsResponse({
required this.all,
required this.totp,
required this.phone,
});
}

class AuthMFAAdminListFactorsResponse {
Expand Down Expand Up @@ -151,7 +194,7 @@ class AuthMFAAdminDeleteFactorResponse {

enum FactorStatus { verified, unverified }

enum FactorType { totp }
enum FactorType { totp, phone }

class Factor {
/// ID of the factor.
Expand All @@ -160,7 +203,7 @@ class Factor {
/// Friendly name of the factor, useful to disambiguate between multiple factors.
final String? friendlyName;

/// Type of factor. Only `totp` supported with this version but may change in future versions.
/// Type of factor. Supports both `totp` and `phone`.
final FactorType factorType;

/// Factor's status.
Expand Down
75 changes: 70 additions & 5 deletions packages/gotrue/test/src/gotrue_mfa_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,44 @@ void main() {
);
});

test('enroll', () async {
test('enroll totp', () async {
await client.signInWithPassword(password: password, email: email1);

final res = await client.mfa
.enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName');
final uri = Uri.parse(res.totp.uri);
final uri = Uri.parse(res.totp!.uri);

expect(res.type, FactorType.totp);
expect(uri.queryParameters['issuer'], 'MyFriend');
expect(uri.scheme, 'otpauth');
});

test('enroll phone', () async {
await client.signInWithPassword(password: password, email: email1);

final res = await client.mfa.enroll(
factorType: FactorType.phone,
phone: '+1234567890',
friendlyName: 'MyPhone',
);

expect(res.type, FactorType.phone);
expect(res.phone?.phone, '+1234567890');
expect(res.totp, isNull);
});

test('enroll phone requires phone number', () async {
await client.signInWithPassword(password: password, email: email1);

expect(
() async => await client.mfa.enroll(
factorType: FactorType.phone,
friendlyName: 'MyPhone',
),
throwsArgumentError,
);
});

test('challenge', () async {
await client.signInWithPassword(password: password, email: email1);

Expand All @@ -56,10 +82,11 @@ void main() {
test('verify', () async {
await client.signInWithPassword(password: password, email: email1);

final challengeId = 'b824ca10-cc13-4250-adba-20ee6e5e7dcd';
// Create a challenge first
final challengeRes = await client.mfa.challenge(factorId: factorId1);

final res = await client.mfa
.verify(factorId: factorId1, challengeId: challengeId, code: getTOTP());
final res = await client.mfa.verify(
factorId: factorId1, challengeId: challengeRes.id, code: getTOTP());

expect(client.currentSession?.accessToken, res.accessToken);
expect(client.currentUser, res.user);
Expand Down Expand Up @@ -95,6 +122,7 @@ void main() {
final res = await client.mfa.listFactors();

expect(res.totp.length, 1);
expect(res.phone.length, 0);
expect(res.all.length, 1);
expect(res.all.first.id, factorId2);
expect(res.all.first.status, FactorStatus.verified);
Expand All @@ -108,6 +136,43 @@ void main() {
true);
});

test('list factors with phone enrollment', () async {
await client.signInWithPassword(password: password, email: email1);

// First, enroll a phone factor
final enrollRes = await client.mfa.enroll(
factorType: FactorType.phone,
phone: '+1234567890',
friendlyName: 'TestPhone',
);

// Verify enrollment worked
expect(enrollRes.type, FactorType.phone);
expect(enrollRes.phone?.phone, '+1234567890');

// Now list factors and check that phone factor appears
final listRes = await client.mfa.listFactors();

// Should have 1 phone factor (unverified) and 0 verified phone factors
expect(listRes.all.length, greaterThanOrEqualTo(1));

// Find the phone factor we just enrolled
final phoneFactor = listRes.all.firstWhere(
(factor) => factor.factorType == FactorType.phone,
);

expect(phoneFactor.id, enrollRes.id);
expect(phoneFactor.factorType, FactorType.phone);
expect(phoneFactor.friendlyName, 'TestPhone');
expect(phoneFactor.status, FactorStatus.unverified);

// Verified phone factors should be empty since we haven't verified yet
expect(listRes.phone.length, 0);

// But the factor should appear in the all list
expect(listRes.all.any((f) => f.factorType == FactorType.phone), true);
});

test('aal1 for only password', () async {
await client.signInWithPassword(password: password, email: email2);
final res = client.mfa.getAuthenticatorAssuranceLevel();
Expand Down