Skip to content

Commit 9b79c68

Browse files
committed
feat(gotrue): Add support for phone factorType when enrolling MFA
1 parent 88ed5d8 commit 9b79c68

File tree

4 files changed

+109
-18
lines changed

4 files changed

+109
-18
lines changed

infra/gotrue/docker-compose.yml

Lines changed: 1 addition & 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:

packages/gotrue/lib/src/gotrue_mfa_api.dart

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,23 @@ class GoTrueMFAApi {
3838
///
3939
/// [issuer] : Domain which the user is enrolled with.
4040
///
41+
/// [phone] : Phone number of the MFA factor in E.164 format. Used to send messages.
42+
///
4143
/// [friendlyName] : Human readable name assigned to the factor.
4244
Future<AuthMFAEnrollResponse> enroll({
4345
FactorType factorType = FactorType.totp,
4446
String? issuer,
47+
String? phone,
4548
String? friendlyName,
4649
}) async {
50+
if (factorType == FactorType.phone && phone == null) {
51+
throw ArgumentError('Phone number is required for phone factor type');
52+
}
53+
54+
if (factorType == FactorType.totp && issuer == null) {
55+
throw ArgumentError('Issuer is required for totp factor type');
56+
}
57+
4758
final session = _client.currentSession;
4859
final data = await _fetch.request(
4960
'${_client._url}/factors',
@@ -53,14 +64,19 @@ class GoTrueMFAApi {
5364
body: {
5465
'friendly_name': friendlyName,
5566
'factor_type': factorType.name,
56-
'issuer': issuer,
67+
if (factorType == FactorType.totp) 'issuer': issuer,
68+
if (factorType == FactorType.phone) 'phone': phone,
5769
},
5870
jwt: session?.accessToken,
5971
),
6072
);
6173

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

6581
final response = AuthMFAEnrollResponse.fromJson(data);
6682
return response;

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

Lines changed: 38 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,53 @@ class AuthMFAEnrollResponse {
88
final FactorType type;
99

1010
/// TOTP enrollment information.
11-
final TOTPEnrollment totp;
11+
final TOTPEnrollment? totp;
12+
13+
/// Phone enrollment information.
14+
final PhoneEnrollment? phone;
1215

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

1923
factory AuthMFAEnrollResponse.fromJson(Map<String, dynamic> json) {
2024
return AuthMFAEnrollResponse(
2125
id: json['id'],
2226
type: FactorType.values.firstWhere((e) => e.name == json['type']),
23-
totp: TOTPEnrollment.fromJson(json['totp']),
27+
totp: json['totp'] != null ? TOTPEnrollment.fromJson(json['totp']) : null,
28+
phone: json['phone'] != null ? PhoneEnrollment.fromJson(json['phone']) : null,
29+
);
30+
}
31+
}
32+
33+
class PhoneEnrollment {
34+
/// ID of the factor that was just enrolled (in an unverified state).
35+
final String id;
36+
37+
/// Type of MFA factor.
38+
final FactorType type;
39+
40+
/// Phone number of the MFA factor in E.164 format. Used to send messages.
41+
final String phone;
42+
43+
/// Friendly name of the factor, useful to disambiguate between multiple factors.
44+
final String? friendlyName;
45+
46+
const PhoneEnrollment(
47+
{required this.id,
48+
required this.type,
49+
required this.phone,
50+
required this.friendlyName});
51+
52+
factory PhoneEnrollment.fromJson(Map<String, dynamic> json) {
53+
return PhoneEnrollment(
54+
id: json['id'],
55+
type: FactorType.values.firstWhere((e) => e.name == json['type']),
56+
phone: json['phone'],
57+
friendlyName: json['friendly_name'],
2458
);
2559
}
2660
}
@@ -151,7 +185,7 @@ class AuthMFAAdminDeleteFactorResponse {
151185

152186
enum FactorStatus { verified, unverified }
153187

154-
enum FactorType { totp }
188+
enum FactorType { totp, phone }
155189

156190
class Factor {
157191
/// ID of the factor.
@@ -160,7 +194,7 @@ class Factor {
160194
/// Friendly name of the factor, useful to disambiguate between multiple factors.
161195
final String? friendlyName;
162196

163-
/// Type of factor. Only `totp` supported with this version but may change in future versions.
197+
/// Type of factor.
164198
final FactorType factorType;
165199

166200
/// Factor's status.

packages/gotrue/test/src/gotrue_mfa_api_test.dart

Lines changed: 51 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -33,16 +33,57 @@ void main() {
3333
);
3434
});
3535

36-
test('enroll', () async {
37-
await client.signInWithPassword(password: password, email: email1);
38-
39-
final res = await client.mfa
40-
.enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName');
41-
final uri = Uri.parse(res.totp.uri);
42-
43-
expect(res.type, FactorType.totp);
44-
expect(uri.queryParameters['issuer'], 'MyFriend');
45-
expect(uri.scheme, 'otpauth');
36+
group('enroll', () {
37+
test('totp', () async {
38+
await client.signInWithPassword(password: password, email: email1);
39+
40+
final res = await client.mfa
41+
.enroll(issuer: 'MyFriend', friendlyName: 'MyFriendName');
42+
final uri = Uri.parse(res.totp!.uri);
43+
44+
expect(res.type, FactorType.totp);
45+
expect(uri.queryParameters['issuer'], 'MyFriend');
46+
expect(uri.scheme, 'otpauth');
47+
});
48+
49+
test('phone', () async {
50+
await client.signInWithPassword(password: password, email: email1);
51+
52+
expect(
53+
() => client.mfa
54+
.enroll(factorType: FactorType.phone, phone: '+1234567890'),
55+
throwsA(isA<AuthApiException>().having(
56+
(e) => e.message,
57+
'message',
58+
'MFA enroll is disabled for Phone',
59+
)),
60+
);
61+
});
62+
63+
test(
64+
'throws ArgumentError when phone factor type is used without phone number',
65+
() async {
66+
expect(
67+
() => client.mfa.enroll(factorType: FactorType.phone),
68+
throwsA(isA<ArgumentError>().having(
69+
(e) => e.message,
70+
'message',
71+
'Phone number is required for phone factor type',
72+
)),
73+
);
74+
});
75+
76+
test('throws ArgumentError when totp factor type is used without issuer',
77+
() async {
78+
expect(
79+
() => client.mfa.enroll(factorType: FactorType.totp),
80+
throwsA(isA<ArgumentError>().having(
81+
(e) => e.message,
82+
'message',
83+
'Issuer is required for totp factor type',
84+
)),
85+
);
86+
});
4687
});
4788

4889
test('challenge', () async {

0 commit comments

Comments
 (0)