Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
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
45 changes: 40 additions & 5 deletions packages/gotrue/lib/src/gotrue_mfa_api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,29 +38,64 @@ 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.
@Deprecated('Use enrollWithParams instead for better type safety')
Future<AuthMFAEnrollResponse> enroll({
FactorType factorType = FactorType.totp,
String? issuer,
String? phone,
String? friendlyName,
}) async {
if (factorType == FactorType.phone && phone != null) {
return enrollWithParams(
PhoneEnrollParams(phone: phone, friendlyName: friendlyName));
}

if (factorType == FactorType.totp && issuer != null) {
return enrollWithParams(
TOTPEnrollParams(issuer: issuer, friendlyName: friendlyName));
}

throw ArgumentError('Invalid arguments, expected either phone or issuer.');
}

/// 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.
///
/// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
///
/// [params] : Type-safe parameters for enrolling a new MFA factor.
Future<AuthMFAEnrollResponse> enrollWithParams(MFAEnrollParams params) async {
final session = _client.currentSession;
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,
'friendly_name': params.friendlyName,
'factor_type': switch (params) {
TOTPEnrollParams() => FactorType.totp.name,
PhoneEnrollParams() => FactorType.phone.name,
},
if (params is TOTPEnrollParams) 'issuer': params.issuer,
if (params is PhoneEnrollParams) 'phone': params.phone,
},
jwt: session?.accessToken,
),
);

data['totp']['qr_code'] =
'data:image/svg+xml;utf-8,${data['totp']['qr_code']}';
if (params is TOTPEnrollParams &&
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
79 changes: 75 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,55 @@ 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
Preview

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 +187,7 @@ class AuthMFAAdminDeleteFactorResponse {

enum FactorStatus { verified, unverified }

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

class Factor {
/// ID of the factor.
Expand All @@ -160,7 +196,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 Expand Up @@ -303,3 +339,38 @@ class AMREntry {
);
}
}

/// Parameters for enrolling a new MFA factor
sealed class MFAEnrollParams {
/// Friendly name of the factor, useful to disambiguate between multiple factors.
final String? friendlyName;

/// Type of factor being enrolled.
final FactorType factorType;

const MFAEnrollParams({this.friendlyName, required this.factorType});
}

/// Parameters for enrolling a TOTP factor
class TOTPEnrollParams extends MFAEnrollParams {
/// Domain which the user is enrolled with.
final String issuer;

const TOTPEnrollParams({
required this.issuer,
super.friendlyName,
super.factorType = FactorType.totp,
});
}

/// Parameters for enrolling a phone factor
class PhoneEnrollParams extends MFAEnrollParams {
/// Phone number of the MFA factor in E.164 format. Used to send messages.
final String phone;

const PhoneEnrollParams({
required this.phone,
super.friendlyName,
super.factorType = FactorType.phone,
});
}
96 changes: 86 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,92 @@ 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', () {
group('deprecated method', () {
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 invalid arguments are provided',
() async {
expect(
() => client.mfa.enroll(factorType: FactorType.phone),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
'Invalid arguments, expected either phone or issuer.',
)),
);

expect(
() => client.mfa.enroll(factorType: FactorType.totp),
throwsA(isA<ArgumentError>().having(
(e) => e.message,
'message',
'Invalid arguments, expected either phone or issuer.',
)),
);
});
});

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

final res = await client.mfa.enrollWithParams(
TOTPEnrollParams(
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.enrollWithParams(
PhoneEnrollParams(phone: '+1234567890'),
),
throwsA(isA<AuthApiException>().having(
(e) => e.message,
'message',
'MFA enroll is disabled for Phone',
)),
);
});
});
});

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