Skip to content

Commit 59d349e

Browse files
authored
feat(auth): send PKCE code_challenge in updateUser when changing email (#1601)
## Summary Under the PKCE flow, `updateUser({ email })` now generates a PKCE code verifier and sends the corresponding `code_challenge` / `code_challenge_method` in the request body, mirroring what `resend()` and `getSSOSignInUrl` already do. This lets an email change started under PKCE complete via `exchangeCodeForSession()`. The fields are only added when an email is being changed, so no public API surface or return type changes. Non-PKCE flows are unaffected (the challenge is `null`). ## Outcome implemented ## Reference - supabase-js parity, following the already-shipped Dart `resend()` PKCE work (SDK-1023). ## Compliance matrix - `auth.session.update_user` → `implemented` (was `partially_implemented`). ## Tests - Added mock tests in `packages/gotrue/test/otp_mock_test.dart`: PKCE email change includes the code challenge, non-email updates omit it, and implicit flow omits it. Closes SDK-1318
1 parent 558b034 commit 59d349e

5 files changed

Lines changed: 202 additions & 3 deletions

File tree

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -897,7 +897,17 @@ class GoTrueClient {
897897
throw AuthSessionMissingException();
898898
}
899899

900-
final body = attributes.toJson();
900+
final codeChallenge = attributes.email != null
901+
? await _generatePKCECodeChallenge()
902+
: null;
903+
904+
final body = {
905+
...attributes.toJson(),
906+
if (attributes.email != null) ...{
907+
'code_challenge': codeChallenge,
908+
'code_challenge_method': codeChallenge != null ? 's256' : null,
909+
},
910+
};
901911
final options = GotrueRequestOptions(
902912
headers: _headers,
903913
body: body,

packages/gotrue/test/client_test.dart

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,53 @@ void main() {
745745
expect(await emittedEvent, AuthChangeEvent.signedIn);
746746
},
747747
);
748+
749+
test(
750+
'updateUser email change under PKCE can be exchanged for a session',
751+
() async {
752+
await http.post(
753+
Uri.parse(
754+
'http://127.0.0.1:54421/rest/v1/rpc/reset_and_init_auth_data',
755+
),
756+
headers: {
757+
'x-forwarded-for': '127.0.0.1',
758+
'apikey': getServiceRoleToken(env),
759+
'Authorization': 'Bearer ${getServiceRoleToken(env)}',
760+
},
761+
);
762+
await http.delete(
763+
Uri.parse('http://127.0.0.1:54424/api/v1/messages'),
764+
);
765+
766+
final pkceClient = GoTrueClient(
767+
url: gotrueUrl,
768+
headers: {
769+
'Authorization': 'Bearer $anonToken',
770+
'apikey': anonToken,
771+
},
772+
asyncStorage: TestAsyncStorage(),
773+
flowType: AuthFlowType.pkce,
774+
autoRefreshToken: false,
775+
);
776+
777+
await pkceClient.signInWithPassword(
778+
email: email1,
779+
password: password,
780+
);
781+
782+
final updatedEmail = getNewEmail();
783+
final updateResponse = await pkceClient.updateUser(
784+
UserAttributes(email: updatedEmail),
785+
);
786+
expect(updateResponse.user?.newEmail, updatedEmail);
787+
788+
final code = await _pkceCodeFromEmailChange(updatedEmail);
789+
final exchanged = await pkceClient.exchangeCodeForSession(code);
790+
791+
expect(exchanged.session.user.email, updatedEmail);
792+
expect(exchanged.session.accessToken, isNotEmpty);
793+
},
794+
);
748795
});
749796

750797
group('Recovering an already refreshed session', () {
@@ -807,3 +854,51 @@ void main() {
807854
});
808855
});
809856
}
857+
858+
/// Reads the email-change confirmation link that GoTrue delivered to
859+
/// [toEmail] via the local Mailpit server, follows it, and returns the PKCE
860+
/// `code` from the redirect so it can be passed to [exchangeCodeForSession].
861+
Future<String> _pkceCodeFromEmailChange(String toEmail) async {
862+
Map<String, dynamic>? message;
863+
for (var attempt = 0; attempt < 20 && message == null; attempt++) {
864+
final search =
865+
jsonDecode(
866+
(await http.get(
867+
Uri.parse(
868+
'http://127.0.0.1:54424/api/v1/search?query=to:$toEmail',
869+
),
870+
)).body,
871+
)
872+
as Map<String, dynamic>;
873+
final messages = search['messages'] as List;
874+
if (messages.isNotEmpty) {
875+
message =
876+
jsonDecode(
877+
(await http.get(
878+
Uri.parse(
879+
'http://127.0.0.1:54424/api/v1/message/${messages.first['ID']}',
880+
),
881+
)).body,
882+
)
883+
as Map<String, dynamic>;
884+
} else {
885+
await Future.delayed(const Duration(milliseconds: 250));
886+
}
887+
}
888+
889+
final link = RegExp(r'http://127\.0\.0\.1:54421/auth/v1/verify\?\S+')
890+
.firstMatch(message!['Text'] as String)!
891+
.group(0)!
892+
.replaceAll(RegExp(r'[)>].*$'), '');
893+
894+
final verifyClient = http.Client();
895+
try {
896+
final request = http.Request('GET', Uri.parse(link))
897+
..followRedirects = false;
898+
final response = await verifyClient.send(request);
899+
final location = response.headers['location']!;
900+
return Uri.parse(location).queryParameters['code']!;
901+
} finally {
902+
verifyClient.close();
903+
}
904+
}

packages/gotrue/test/mocks/otp_mock_client.dart

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ class OtpMockClient extends BaseClient {
1111
final String refreshToken;
1212

1313
Map<String, dynamic>? lastResendBody;
14+
Map<String, dynamic>? lastUpdateUserBody;
1415

1516
OtpMockClient({
1617
this.phoneNumber = '+11234567890',
@@ -69,6 +70,11 @@ class OtpMockClient extends BaseClient {
6970
return _handleResend(requestBody);
7071
}
7172

73+
// Simulate updating the current user
74+
if (url.contains('/user') && method == 'PUT') {
75+
return _handleUpdateUser(requestBody);
76+
}
77+
7278
// Default response for unhandled requests
7379
return StreamedResponse(
7480
Stream.value(
@@ -312,6 +318,37 @@ class OtpMockClient extends BaseClient {
312318
request: null,
313319
);
314320
}
321+
322+
StreamedResponse _handleUpdateUser(Map<String, dynamic>? requestBody) {
323+
lastUpdateUserBody = requestBody;
324+
final now = DateTime.now().toIso8601String();
325+
326+
return StreamedResponse(
327+
Stream.value(
328+
utf8.encode(
329+
jsonEncode({
330+
'id': userId,
331+
'aud': 'authenticated',
332+
'role': 'authenticated',
333+
'email': requestBody?['email'] ?? email,
334+
'phone': requestBody?['phone'] ?? phoneNumber,
335+
'confirmed_at': now,
336+
'last_sign_in_at': now,
337+
'created_at': now,
338+
'updated_at': now,
339+
'app_metadata': {
340+
'provider': 'email',
341+
'providers': ['email'],
342+
},
343+
'user_metadata': {},
344+
'identities': [],
345+
}),
346+
),
347+
),
348+
200,
349+
request: null,
350+
);
351+
}
315352
}
316353

317354
/// A mock HTTP client that captures the channel parameter for testing

packages/gotrue/test/otp_mock_test.dart

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,64 @@ void main() {
333333
expect(mockClient.lastResendBody?['code_challenge_method'], isNull);
334334
});
335335

336+
test(
337+
'updateUser() with email in PKCE flow includes code challenge',
338+
() async {
339+
await client.verifyOTP(
340+
phone: testPhone,
341+
token: '123456',
342+
type: OtpType.sms,
343+
);
344+
345+
await client.updateUser(UserAttributes(email: testEmail));
346+
347+
expect(mockClient.lastUpdateUserBody?['code_challenge'], isNotNull);
348+
expect(mockClient.lastUpdateUserBody?['code_challenge_method'], 's256');
349+
},
350+
);
351+
352+
test('updateUser() without email omits code challenge', () async {
353+
await client.verifyOTP(
354+
phone: testPhone,
355+
token: '123456',
356+
type: OtpType.sms,
357+
);
358+
359+
await client.updateUser(UserAttributes(data: {'name': 'Test User'}));
360+
361+
expect(
362+
mockClient.lastUpdateUserBody?.containsKey('code_challenge'),
363+
isFalse,
364+
);
365+
expect(
366+
mockClient.lastUpdateUserBody?.containsKey('code_challenge_method'),
367+
isFalse,
368+
);
369+
});
370+
371+
test(
372+
'updateUser() with email in implicit flow omits code challenge',
373+
() async {
374+
final implicitClient = GoTrueClient(
375+
url: 'https://example.com',
376+
httpClient: mockClient,
377+
asyncStorage: asyncStorage,
378+
flowType: AuthFlowType.implicit,
379+
);
380+
381+
await implicitClient.verifyOTP(
382+
phone: testPhone,
383+
token: '123456',
384+
type: OtpType.sms,
385+
);
386+
387+
await implicitClient.updateUser(UserAttributes(email: testEmail));
388+
389+
expect(mockClient.lastUpdateUserBody?['code_challenge'], isNull);
390+
expect(mockClient.lastUpdateUserBody?['code_challenge_method'], isNull);
391+
},
392+
);
393+
336394
test('resend() with wrong type for phone throws', () async {
337395
await expectLater(
338396
client.resend(

sdk-compliance.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ features:
5959
auth.session.refresh_session: implemented
6060
auth.session.get_user: implemented
6161
auth.session.update_user:
62-
status: partially_implemented
63-
note: "Does not send a PKCE code_challenge when changing email under the PKCE flow."
62+
status: implemented
6463
symbols:
6564
- UserAttributes.currentPassword
6665
auth.session.get_claims: implemented

0 commit comments

Comments
 (0)