Skip to content

Commit 944afcd

Browse files
dshukertjrgrdsdev
andauthored
feat(gotrue): Add phone mfa enrollment (#1188)
* feat: phone mfa enrollment * fix json parsing * properly get a challenge id in test * format document * format mfa api * Update packages/gotrue/lib/src/gotrue_mfa_api.dart Co-authored-by: Guilherme Souza <[email protected]> * format document --------- Co-authored-by: Guilherme Souza <[email protected]>
1 parent e6c9420 commit 944afcd

File tree

4 files changed

+159
-26
lines changed

4 files changed

+159
-26
lines changed

infra/gotrue/docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
version: '3'
33
services:
44
gotrue: # Signup enabled, autoconfirm on
5-
image: supabase/auth:v2.151.0
5+
image: supabase/auth:v2.175.0
66
ports:
77
- '9998:9998'
88
environment:
@@ -27,6 +27,8 @@ services:
2727
GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: http://localhost:9998/callback
2828
GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: 'true'
2929
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true'
30+
GOTRUE_MFA_PHONE_ENROLL_ENABLED: 'true'
31+
GOTRUE_MFA_PHONE_VERIFY_ENABLED: 'true'
3032

3133
depends_on:
3234
- db

packages/gotrue/lib/src/gotrue_mfa_api.dart

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,39 +28,57 @@ class GoTrueMFAApi {
2828

2929
/// Starts the enrollment process for a new Multi-Factor Authentication (MFA) factor.
3030
/// This method creates a new `unverified` factor.
31-
/// To verify a factor, present the QR code or secret to the user and ask them to add it to their authenticator app.
3231
///
33-
/// The user has to enter the code from their authenticator app to verify it.
32+
/// 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.
33+
/// For Phone: The user will receive an SMS with a verification code.
34+
///
35+
/// The user has to enter the code from their authenticator app or SMS to verify it.
3436
///
3537
/// Upon verifying a factor, all other sessions are logged out and the current session's authenticator level is promoted to `aal2`.
3638
///
3739
/// [factorType] : Type of factor being enrolled.
3840
///
39-
/// [issuer] : Domain which the user is enrolled with.
41+
/// [issuer] : Domain which the user is enrolled with (TOTP only).
4042
///
4143
/// [friendlyName] : Human readable name assigned to the factor.
44+
///
45+
/// [phone] : Phone number to enroll for Phone factor type.
4246
Future<AuthMFAEnrollResponse> enroll({
4347
FactorType factorType = FactorType.totp,
4448
String? issuer,
4549
String? friendlyName,
50+
String? phone,
4651
}) async {
4752
final session = _client.currentSession;
53+
54+
final body = <String, dynamic>{
55+
'friendly_name': friendlyName,
56+
'factor_type': factorType.name,
57+
};
58+
59+
if (factorType == FactorType.totp && issuer != null) {
60+
body['issuer'] = issuer;
61+
} else if (factorType == FactorType.phone && phone != null) {
62+
body['phone'] = phone;
63+
} else {
64+
throw ArgumentError(
65+
'Invalid arguments, expected an issuer for totp factor type or phone for phone factor. type');
66+
}
67+
4868
final data = await _fetch.request(
4969
'${_client._url}/factors',
5070
RequestMethodType.post,
5171
options: GotrueRequestOptions(
5272
headers: _client._headers,
53-
body: {
54-
'friendly_name': friendlyName,
55-
'factor_type': factorType.name,
56-
'issuer': issuer,
57-
},
73+
body: body,
5874
jwt: session?.accessToken,
5975
),
6076
);
6177

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

6583
final response = AuthMFAEnrollResponse.fromJson(data);
6684
return response;
@@ -150,8 +168,13 @@ class GoTrueMFAApi {
150168
factor.factorType == FactorType.totp &&
151169
factor.status == FactorStatus.verified)
152170
.toList();
171+
final phone = factors
172+
.where((factor) =>
173+
factor.factorType == FactorType.phone &&
174+
factor.status == FactorStatus.verified)
175+
.toList();
153176

154-
return AuthMFAListFactorsResponse(all: factors, totp: totp);
177+
return AuthMFAListFactorsResponse(all: factors, totp: totp, phone: phone);
155178
}
156179

157180
/// Returns the Authenticator Assurance Level (AAL) for the active session.

packages/gotrue/lib/src/types/mfa.dart

Lines changed: 52 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,23 +4,33 @@ class AuthMFAEnrollResponse {
44
/// ID of the factor that was just enrolled (in an unverified state).
55
final String id;
66

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

10-
/// TOTP enrollment information.
11-
final TOTPEnrollment totp;
10+
/// TOTP enrollment information (only present when type is totp).
11+
final TOTPEnrollment? totp;
12+
13+
/// Phone enrollment information (only present when type is phone).
14+
final PhoneEnrollment? phone;
1215

1316
const AuthMFAEnrollResponse({
1417
required this.id,
1518
required this.type,
16-
required this.totp,
19+
this.totp,
20+
this.phone,
1721
});
1822

1923
factory AuthMFAEnrollResponse.fromJson(Map<String, dynamic> json) {
24+
final type = FactorType.values.firstWhere((e) => e.name == json['type']);
2025
return AuthMFAEnrollResponse(
2126
id: json['id'],
22-
type: FactorType.values.firstWhere((e) => e.name == json['type']),
23-
totp: TOTPEnrollment.fromJson(json['totp']),
27+
type: type,
28+
totp: type == FactorType.totp && json['totp'] != null
29+
? TOTPEnrollment.fromJson(json['totp'])
30+
: null,
31+
phone: type == FactorType.phone && json['phone'] != null
32+
? PhoneEnrollment._fromJsonValue(json['phone'])
33+
: null,
2434
);
2535
}
2636
}
@@ -54,6 +64,34 @@ class TOTPEnrollment {
5464
}
5565
}
5666

67+
class PhoneEnrollment {
68+
/// The phone number that will receive the SMS OTP.
69+
final String phone;
70+
71+
const PhoneEnrollment({
72+
required this.phone,
73+
});
74+
75+
factory PhoneEnrollment.fromJson(Map<String, dynamic> json) {
76+
return PhoneEnrollment(
77+
phone: json['phone'],
78+
);
79+
}
80+
81+
factory PhoneEnrollment._fromJsonValue(dynamic value) {
82+
if (value is String) {
83+
// Server returns phone number as a string directly
84+
return PhoneEnrollment(phone: value);
85+
} else if (value is Map<String, dynamic>) {
86+
// Server returns phone data as an object
87+
return PhoneEnrollment.fromJson(value);
88+
} else {
89+
throw ArgumentError(
90+
'Invalid phone enrollment data type: ${value.runtimeType}');
91+
}
92+
}
93+
}
94+
5795
class AuthMFAChallengeResponse {
5896
/// ID of the newly created challenge.
5997
final String id;
@@ -120,8 +158,13 @@ class AuthMFAUnenrollResponse {
120158
class AuthMFAListFactorsResponse {
121159
final List<Factor> all;
122160
final List<Factor> totp;
161+
final List<Factor> phone;
123162

124-
AuthMFAListFactorsResponse({required this.all, required this.totp});
163+
AuthMFAListFactorsResponse({
164+
required this.all,
165+
required this.totp,
166+
required this.phone,
167+
});
125168
}
126169

127170
class AuthMFAAdminListFactorsResponse {
@@ -151,7 +194,7 @@ class AuthMFAAdminDeleteFactorResponse {
151194

152195
enum FactorStatus { verified, unverified }
153196

154-
enum FactorType { totp }
197+
enum FactorType { totp, phone }
155198

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

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

166209
/// Factor's status.

packages/gotrue/test/src/gotrue_mfa_api_test.dart

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,18 +33,44 @@ void main() {
3333
);
3434
});
3535

36-
test('enroll', () async {
36+
test('enroll totp', () async {
3737
await client.signInWithPassword(password: password, email: email1);
3838

3939
final res = await client.mfa
4040
.enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName');
41-
final uri = Uri.parse(res.totp.uri);
41+
final uri = Uri.parse(res.totp!.uri);
4242

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

48+
test('enroll phone', () async {
49+
await client.signInWithPassword(password: password, email: email1);
50+
51+
final res = await client.mfa.enroll(
52+
factorType: FactorType.phone,
53+
phone: '+1234567890',
54+
friendlyName: 'MyPhone',
55+
);
56+
57+
expect(res.type, FactorType.phone);
58+
expect(res.phone?.phone, '+1234567890');
59+
expect(res.totp, isNull);
60+
});
61+
62+
test('enroll phone requires phone number', () async {
63+
await client.signInWithPassword(password: password, email: email1);
64+
65+
expect(
66+
() async => await client.mfa.enroll(
67+
factorType: FactorType.phone,
68+
friendlyName: 'MyPhone',
69+
),
70+
throwsArgumentError,
71+
);
72+
});
73+
4874
test('challenge', () async {
4975
await client.signInWithPassword(password: password, email: email1);
5076

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

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

61-
final res = await client.mfa
62-
.verify(factorId: factorId1, challengeId: challengeId, code: getTOTP());
88+
final res = await client.mfa.verify(
89+
factorId: factorId1, challengeId: challengeRes.id, code: getTOTP());
6390

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

97124
expect(res.totp.length, 1);
125+
expect(res.phone.length, 0);
98126
expect(res.all.length, 1);
99127
expect(res.all.first.id, factorId2);
100128
expect(res.all.first.status, FactorStatus.verified);
@@ -108,6 +136,43 @@ void main() {
108136
true);
109137
});
110138

139+
test('list factors with phone enrollment', () async {
140+
await client.signInWithPassword(password: password, email: email1);
141+
142+
// First, enroll a phone factor
143+
final enrollRes = await client.mfa.enroll(
144+
factorType: FactorType.phone,
145+
phone: '+1234567890',
146+
friendlyName: 'TestPhone',
147+
);
148+
149+
// Verify enrollment worked
150+
expect(enrollRes.type, FactorType.phone);
151+
expect(enrollRes.phone?.phone, '+1234567890');
152+
153+
// Now list factors and check that phone factor appears
154+
final listRes = await client.mfa.listFactors();
155+
156+
// Should have 1 phone factor (unverified) and 0 verified phone factors
157+
expect(listRes.all.length, greaterThanOrEqualTo(1));
158+
159+
// Find the phone factor we just enrolled
160+
final phoneFactor = listRes.all.firstWhere(
161+
(factor) => factor.factorType == FactorType.phone,
162+
);
163+
164+
expect(phoneFactor.id, enrollRes.id);
165+
expect(phoneFactor.factorType, FactorType.phone);
166+
expect(phoneFactor.friendlyName, 'TestPhone');
167+
expect(phoneFactor.status, FactorStatus.unverified);
168+
169+
// Verified phone factors should be empty since we haven't verified yet
170+
expect(listRes.phone.length, 0);
171+
172+
// But the factor should appear in the all list
173+
expect(listRes.all.any((f) => f.factorType == FactorType.phone), true);
174+
});
175+
111176
test('aal1 for only password', () async {
112177
await client.signInWithPassword(password: password, email: email2);
113178
final res = client.mfa.getAuthenticatorAssuranceLevel();

0 commit comments

Comments
 (0)