Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
feat(gotrue): Add support for phone factorType when enrolling MFA
  • Loading branch information
grdsdev committed Jun 5, 2025
commit 9b79c68e7c05c05f0c2d40ff091d16133e99dbd4
2 changes: 1 addition & 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 Down
22 changes: 19 additions & 3 deletions packages/gotrue/lib/src/gotrue_mfa_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,23 @@ class GoTrueMFAApi {
///
/// [issuer] : Domain which the user is enrolled with.
///
/// [phone] : Phone number of the MFA factor in E.164 format. Used to send messages.
///
/// [friendlyName] : Human readable name assigned to the factor.
Future<AuthMFAEnrollResponse> enroll({
FactorType factorType = FactorType.totp,
String? issuer,
String? phone,
String? friendlyName,
}) async {
if (factorType == FactorType.phone && phone == null) {
throw ArgumentError('Phone number is required for phone factor type');
}

if (factorType == FactorType.totp && issuer == null) {
throw ArgumentError('Issuer is required for totp factor type');
}

final session = _client.currentSession;
final data = await _fetch.request(
'${_client._url}/factors',
Expand All @@ -53,14 +64,19 @@ class GoTrueMFAApi {
body: {
'friendly_name': friendlyName,
'factor_type': factorType.name,
'issuer': issuer,
if (factorType == FactorType.totp) 'issuer': issuer,
if (factorType == FactorType.phone) 'phone': phone,
},
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'] != null) {
data['totp']['qr_code'] =
'data:image/svg+xml;utf-8,${data['totp']['qr_code']}';
}

final response = AuthMFAEnrollResponse.fromJson(data);
return response;
Expand Down
42 changes: 38 additions & 4 deletions packages/gotrue/lib/src/types/mfa.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,53 @@ class AuthMFAEnrollResponse {
final FactorType type;

/// TOTP enrollment information.
final TOTPEnrollment totp;
final TOTPEnrollment? totp;

/// Phone enrollment information.
final PhoneEnrollment? phone;

const AuthMFAEnrollResponse({
required this.id,
required this.type,
required this.totp,
required this.phone,
Copy link

Copilot AI Jun 5, 2025

Choose a reason for hiding this comment

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

[nitpick] The 'phone' field is marked as required in the constructor even though its type is nullable; consider reviewing whether it should be optional to better reflect scenarios where no phone enrollment occurs.

Suggested change
required this.phone,
this.phone,

Copilot uses AI. Check for mistakes.
});

factory AuthMFAEnrollResponse.fromJson(Map<String, dynamic> json) {
return AuthMFAEnrollResponse(
id: json['id'],
type: FactorType.values.firstWhere((e) => e.name == json['type']),
totp: TOTPEnrollment.fromJson(json['totp']),
totp: json['totp'] != null ? TOTPEnrollment.fromJson(json['totp']) : null,
phone: json['phone'] != null ? PhoneEnrollment.fromJson(json['phone']) : null,
);
}
}

class PhoneEnrollment {
/// ID of the factor that was just enrolled (in an unverified state).
final String id;

/// Type of MFA factor.
final FactorType type;

/// Phone number of the MFA factor in E.164 format. Used to send messages.
final String phone;

/// Friendly name of the factor, useful to disambiguate between multiple factors.
final String? friendlyName;

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

factory PhoneEnrollment.fromJson(Map<String, dynamic> json) {
return PhoneEnrollment(
id: json['id'],
type: FactorType.values.firstWhere((e) => e.name == json['type']),
phone: json['phone'],
friendlyName: json['friendly_name'],
);
}
}
Expand Down Expand Up @@ -151,7 +185,7 @@ class AuthMFAAdminDeleteFactorResponse {

enum FactorStatus { verified, unverified }

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

class Factor {
/// ID of the factor.
Expand All @@ -160,7 +194,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.
final FactorType factorType;

/// Factor's status.
Expand Down
61 changes: 51 additions & 10 deletions packages/gotrue/test/src/gotrue_mfa_api_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,57 @@ void main() {
);
});

test('enroll', () 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);

expect(res.type, FactorType.totp);
expect(uri.queryParameters['issuer'], 'MyFriend');
expect(uri.scheme, 'otpauth');
group('enroll', () {
test('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);

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

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

expect(
() => client.mfa
.enroll(factorType: FactorType.phone, phone: '+1234567890'),
throwsA(isA<AuthApiException>().having(
(e) => e.message,
'message',
'MFA enroll is disabled for Phone',
)),
);
});

test(
'throws ArgumentError when phone factor type is used without phone number',
() async {
expect(
() => client.mfa.enroll(factorType: FactorType.phone),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
'Phone number is required for phone factor type',
)),
);
});

test('throws ArgumentError when totp factor type is used without issuer',
() async {
expect(
() => client.mfa.enroll(factorType: FactorType.totp),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
'Issuer is required for totp factor type',
)),
);
});
});

test('challenge', () async {
Expand Down
Loading