Skip to content

feat(gotrue): Add phone mfa enrollment #1188

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
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
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;
}
Comment on lines +59 to +66
Copy link
Contributor

Choose a reason for hiding this comment

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

Lets validate that an issuer needs to be provided for the totp factor type also.

Suggested change
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;
}
if (factorType == FactorType.totp && issuer != null) {
body['issuer'] = issuer;
} else if (factorType == FactorType.phone && phone != null) {
body['phone'] = phone;
} else {
throw ArgumentError('Invalid arguments, expected an issuer for totp factor type or phone for phone factor. type');
}


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