Skip to content

Commit a95d1c0

Browse files
grdsdevclaudeCopilot
authored
feat(auth): add optional accessToken parameter to setSession() (#1327)
* feat(auth): add optional accessToken parameter to setSession() Mirrors the JS SDK behaviour: when both tokens are supplied and the access token has not yet expired, the session is restored directly (via a getUser() call) without an extra /token refresh round-trip. If the access token is expired or omitted the existing _callRefreshToken path is preserved unchanged. - Valid, non-expired accessToken: calls getUser(accessToken), builds Session locally, fires AuthChangeEvent.signedIn. - Expired accessToken: falls back to _callRefreshToken(refreshToken). - Malformed accessToken: throws AuthInvalidJwtException. - Empty accessToken: throws AuthSessionMissingException. Linear: SDK-784 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: clarify setSession network behavior in doc comment (#1332) * Initial plan * docs: clarify setSession skips /token refresh but still calls /user Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> Agent-Logs-Url: https://github.com/supabase/supabase-flutter/sessions/db643f38-73ec-4b87-92e3-9d995d319a73 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> * fix(auth): apply expiryMargin buffer to hasExpired check in setSession() (#1331) * Initial plan * fix(auth): apply expiryMargin buffer to hasExpired check in setSession() Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> Agent-Logs-Url: https://github.com/supabase/supabase-flutter/sessions/80e8e775-47d3-4e08-aa75-07f715c27411 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: grdsdev <5923044+grdsdev@users.noreply.github.com> * fix(gotrue): address setSession review feedback - Use exp - iat for expiresIn (full token lifetime, not remaining seconds) - Deduplicate concurrent fast-path calls via _refreshTokenCompleter - Use idiomatic throwsA in error-throwing tests Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(gotrue): add edge case tests for setSession fast path - New set_session_test.dart with 10 mock-based tests covering: - Validation order (empty RT checked before AT) - Expiry margin boundary (exp within 30 s treated as expired) - JWT without exp claim falls back to refresh - expiresIn = exp - iat when both claims present - expiresIn = null when iat absent - expiresAt matches JWT exp - Preserved access/refresh token values - Fast path fires signedIn, fallback fires tokenRefreshed - Concurrent calls deduplicated to one /user request - signedIn fires exactly once under concurrent load - Fix remaining try/catch in client_test.dart to use throwsA Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test(gotrue): fix expiresIn tests by using raw base64url JWT crafting dart_jsonwebtoken auto-injects an iat claim when signing, which broke: - 'expiresIn equals exp minus iat' (iat value was overridden) - 'expiresIn is null when iat absent' (iat was auto-added) Replace dart_jsonwebtoken usage with _makeRawJwt(), which base64url-encodes the payload map directly (no auto-claims, no signature verification needed). Also removes the dart_jsonwebtoken import from the test file. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * simplify setSession * remove concurrent tests * docs: simplify doc string --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
1 parent 8966c6e commit a95d1c0

3 files changed

Lines changed: 360 additions & 9 deletions

File tree

packages/gotrue/lib/src/gotrue_client.dart

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -747,12 +747,58 @@ class GoTrueClient {
747747
return userResponse;
748748
}
749749

750-
/// Sets the session data from refresh_token and returns the current session.
751-
Future<AuthResponse> setSession(String refreshToken) async {
750+
/// Sets the session data from [refreshToken] and returns the current session.
751+
///
752+
/// If [accessToken] is provided and not yet expired, the session is restored
753+
/// directly from the supplied tokens, skipping the `/token` refresh round-trip.
754+
Future<AuthResponse> setSession(
755+
String refreshToken, {
756+
String? accessToken,
757+
}) async {
752758
if (refreshToken.isEmpty) {
753759
throw AuthSessionMissingException('Refresh token cannot be empty');
754760
}
755-
return await _callRefreshToken(refreshToken);
761+
762+
if (accessToken == null) {
763+
return await _callRefreshToken(refreshToken);
764+
}
765+
766+
if (accessToken.isEmpty) {
767+
throw AuthSessionMissingException('Access token cannot be empty');
768+
}
769+
770+
final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
771+
772+
// Throws AuthInvalidJwtException if the token is malformed.
773+
final decoded = decodeJwt(accessToken);
774+
final exp = decoded.payload.exp;
775+
final hasExpired =
776+
exp == null || exp <= timeNow + Constants.expiryMargin.inSeconds;
777+
778+
if (hasExpired) {
779+
return await _callRefreshToken(refreshToken);
780+
}
781+
782+
final userResponse = await getUser(accessToken);
783+
final user = userResponse.user;
784+
if (user == null) {
785+
throw AuthSessionMissingException();
786+
}
787+
788+
final iat = decoded.payload.iat;
789+
final session = Session(
790+
accessToken: accessToken,
791+
refreshToken: refreshToken,
792+
user: user,
793+
tokenType: 'bearer',
794+
expiresIn: (iat != null) ? exp - iat : null,
795+
);
796+
797+
_saveSession(session);
798+
notifyAllSubscribers(AuthChangeEvent.signedIn);
799+
800+
final response = AuthResponse(session: session);
801+
return response;
756802
}
757803

758804
/// Gets the session data from a magic link or oauth2 callback URL

packages/gotrue/test/client_test.dart

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -283,12 +283,98 @@ void main() {
283283
test(
284284
'Set session with an empty refresh token throws AuthSessionMissingException',
285285
() async {
286-
try {
287-
await client.setSession('');
288-
fail('setSession did not throw');
289-
} catch (error) {
290-
expect(error, isA<AuthSessionMissingException>());
291-
}
286+
await expectLater(
287+
() => client.setSession(''),
288+
throwsA(isA<AuthSessionMissingException>()),
289+
);
290+
});
291+
292+
test(
293+
'Set session with both access token and refresh token skips network refresh',
294+
() async {
295+
await client.signInWithPassword(email: email1, password: password);
296+
297+
final refreshToken = client.currentSession?.refreshToken ?? '';
298+
final accessToken = client.currentSession?.accessToken ?? '';
299+
expect(refreshToken, isNotEmpty);
300+
expect(accessToken, isNotEmpty);
301+
302+
final newClient = GoTrueClient(
303+
url: gotrueUrl,
304+
headers: {
305+
'apikey': anonToken,
306+
},
307+
);
308+
309+
expect(newClient.currentSession, isNull);
310+
311+
expect(
312+
newClient.onAuthStateChange,
313+
emits(predicate<AuthState>((s) => s.event == AuthChangeEvent.signedIn)),
314+
);
315+
316+
final response = await newClient.setSession(
317+
refreshToken,
318+
accessToken: accessToken,
319+
);
320+
321+
expect(response.session, isNotNull);
322+
expect(response.session?.accessToken, equals(accessToken));
323+
expect(response.session?.refreshToken, equals(refreshToken));
324+
expect(response.user, isNotNull);
325+
expect(newClient.currentSession?.accessToken, equals(accessToken));
326+
});
327+
328+
test('Set session with expired access token falls back to refresh token',
329+
() async {
330+
await client.signInWithPassword(email: email1, password: password);
331+
332+
final refreshToken = client.currentSession?.refreshToken ?? '';
333+
expect(refreshToken, isNotEmpty);
334+
335+
// A JWT that is syntactically valid but expired (exp in the past).
336+
// Header: {"alg":"HS256","typ":"JWT"}
337+
// Payload: {"sub":"user","exp":1} (epoch second 1 = Jan 1, 1970)
338+
// Signature: 3 zero bytes as valid base64url ("AAAA")
339+
const expiredAccessToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9'
340+
'.eyJzdWIiOiJ1c2VyIiwiZXhwIjoxfQ'
341+
'.AAAA';
342+
343+
final newClient = GoTrueClient(
344+
url: gotrueUrl,
345+
headers: {
346+
'apikey': anonToken,
347+
},
348+
);
349+
350+
// Should fall back to _callRefreshToken and succeed.
351+
final response = await newClient.setSession(
352+
refreshToken,
353+
accessToken: expiredAccessToken,
354+
);
355+
356+
expect(response.session, isNotNull);
357+
expect(response.session?.accessToken, isNot(equals(expiredAccessToken)));
358+
expect(newClient.currentSession?.accessToken, isNotEmpty);
359+
});
360+
361+
test(
362+
'Set session with empty access token throws AuthSessionMissingException',
363+
() async {
364+
await expectLater(
365+
() => client.setSession('some-refresh-token', accessToken: ''),
366+
throwsA(isA<AuthSessionMissingException>()),
367+
);
368+
});
369+
370+
test(
371+
'Set session with malformed access token throws AuthInvalidJwtException',
372+
() async {
373+
await expectLater(
374+
() => client.setSession('some-refresh-token',
375+
accessToken: 'not-a-valid-jwt'),
376+
throwsA(isA<AuthInvalidJwtException>()),
377+
);
292378
});
293379

294380
test('Refresh session with refreshToken when no current session exists',
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
4+
import 'package:gotrue/gotrue.dart';
5+
import 'package:http/http.dart';
6+
import 'package:test/test.dart';
7+
8+
import '../utils.dart';
9+
10+
// Minimal user payload accepted by User.fromJson.
11+
Map<String, dynamic> get _mockUserJson => {
12+
'id': 'mock-user-id',
13+
'aud': 'authenticated',
14+
'role': 'authenticated',
15+
'email': 'mock@example.com',
16+
'app_metadata': {
17+
'provider': 'email',
18+
'providers': ['email']
19+
},
20+
'user_metadata': {},
21+
'created_at': '2024-01-01T00:00:00.000Z',
22+
'updated_at': '2024-01-01T00:00:00.000Z',
23+
};
24+
25+
/// Mock HTTP client for setSession tests.
26+
///
27+
/// Handles `GET /user` (returns [_mockUserJson]) and
28+
/// `POST /token` (returns a fresh session via the refresh path).
29+
class _SetSessionMockClient extends BaseClient {
30+
int userCallCount = 0;
31+
32+
@override
33+
Future<StreamedResponse> send(BaseRequest request) async {
34+
if (request.url.path.endsWith('/user')) {
35+
userCallCount++;
36+
return StreamedResponse(
37+
Stream.value(utf8.encode(jsonEncode(_mockUserJson))),
38+
200,
39+
);
40+
}
41+
42+
if (request.url.path.contains('/token')) {
43+
// Refresh-token fallback response with a freshly minted access token.
44+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
45+
final iat = exp - 3600;
46+
final freshAt =
47+
_makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'});
48+
return StreamedResponse(
49+
Stream.value(utf8.encode(jsonEncode({
50+
'access_token': freshAt,
51+
'token_type': 'bearer',
52+
'expires_in': 3600,
53+
'refresh_token': 'new-refresh-token',
54+
'user': _mockUserJson,
55+
}))),
56+
200,
57+
);
58+
}
59+
60+
return StreamedResponse(Stream.empty(), 404);
61+
}
62+
}
63+
64+
/// Crafts a JWT by base64url-encoding [payload] directly.
65+
///
66+
/// Unlike using dart_jsonwebtoken, this gives exact control over every claim —
67+
/// no auto-injected `iat`, no claim overrides. The signature is a stub;
68+
/// [decodeJwt] does not verify signatures.
69+
String _makeRawJwt(Map<String, dynamic> payload) {
70+
final header =
71+
base64Url.encode(utf8.encode(jsonEncode({'alg': 'HS256', 'typ': 'JWT'})));
72+
final body = base64Url.encode(utf8.encode(jsonEncode(payload)));
73+
const sig = 'AAAA';
74+
return '$header.$body.$sig';
75+
}
76+
77+
void main() {
78+
late _SetSessionMockClient mockClient;
79+
late GoTrueClient client;
80+
81+
setUp(() {
82+
mockClient = _SetSessionMockClient();
83+
client = GoTrueClient(
84+
url: 'https://example.supabase.co',
85+
httpClient: mockClient,
86+
asyncStorage: TestAsyncStorage(),
87+
);
88+
});
89+
90+
group('setSession — validation edge cases', () {
91+
test(
92+
'empty refresh token with a non-null access token throws before '
93+
'inspecting the access token', () async {
94+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
95+
final at =
96+
_makeRawJwt({'exp': exp, 'iat': exp - 3600, 'sub': 'mock-user-id'});
97+
98+
await expectLater(
99+
() => client.setSession('', accessToken: at),
100+
throwsA(isA<AuthSessionMissingException>()),
101+
);
102+
// No network call should have been made.
103+
expect(mockClient.userCallCount, 0);
104+
});
105+
106+
test(
107+
'access token with exp within the 30-second expiry margin is treated '
108+
'as expired and falls back to the refresh-token path', () async {
109+
final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
110+
// exp is 20 s in the future, inside the 30 s Constants.expiryMargin.
111+
final at = _makeRawJwt(
112+
{'exp': timeNow + 20, 'iat': timeNow - 3580, 'sub': 'mock-user-id'});
113+
114+
final response =
115+
await client.setSession('some-refresh-token', accessToken: at);
116+
117+
expect(response.session, isNotNull);
118+
// The returned token must be the freshly refreshed one, not our near-expired JWT.
119+
expect(response.session?.accessToken, isNot(equals(at)));
120+
expect(mockClient.userCallCount, 0); // /user was NOT called
121+
});
122+
123+
test(
124+
'access token with no exp claim is treated as expired and falls back '
125+
'to the refresh-token path', () async {
126+
// JWT without an exp claim: decodeJwt succeeds but exp == null.
127+
final at = _makeRawJwt({'role': 'authenticated', 'sub': 'mock-user-id'});
128+
129+
final response =
130+
await client.setSession('some-refresh-token', accessToken: at);
131+
132+
expect(response.session, isNotNull);
133+
expect(response.session?.accessToken, isNot(equals(at)));
134+
expect(mockClient.userCallCount, 0);
135+
});
136+
});
137+
138+
group('setSession — fast path session fields', () {
139+
test('expiresIn equals exp minus iat when both claims are present',
140+
() async {
141+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
142+
final exp = iat + 3600;
143+
final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'});
144+
145+
final response =
146+
await client.setSession('some-refresh-token', accessToken: at);
147+
148+
// expiresIn should be the total token lifetime (exp - iat = 3600).
149+
expect(response.session?.expiresIn, equals(exp - iat));
150+
});
151+
152+
test('expiresIn is null when iat claim is absent', () async {
153+
final exp = DateTime.now().millisecondsSinceEpoch ~/ 1000 + 3600;
154+
// JWT without iat.
155+
final at = _makeRawJwt({'exp': exp, 'sub': 'mock-user-id'});
156+
157+
final response =
158+
await client.setSession('some-refresh-token', accessToken: at);
159+
160+
expect(response.session?.expiresIn, isNull);
161+
});
162+
163+
test('expiresAt matches the exp claim in the JWT', () async {
164+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
165+
final exp = iat + 3600;
166+
final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'});
167+
168+
final response =
169+
await client.setSession('some-refresh-token', accessToken: at);
170+
171+
// expiresAt is re-derived from the JWT's own exp, not from expiresIn.
172+
expect(response.session?.expiresAt, equals(exp));
173+
});
174+
175+
test('returned session preserves the supplied access and refresh tokens',
176+
() async {
177+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
178+
final exp = iat + 3600;
179+
const refreshToken = 'my-refresh-token';
180+
final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'});
181+
182+
final response = await client.setSession(refreshToken, accessToken: at);
183+
184+
expect(response.session?.accessToken, equals(at));
185+
expect(response.session?.refreshToken, equals(refreshToken));
186+
expect(response.session?.tokenType, equals('bearer'));
187+
});
188+
});
189+
190+
group('setSession — auth state events', () {
191+
test('fast path emits signedIn (not tokenRefreshed)', () async {
192+
final iat = DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60;
193+
final exp = iat + 3600;
194+
final at = _makeRawJwt({'exp': exp, 'iat': iat, 'sub': 'mock-user-id'});
195+
196+
expect(
197+
client.onAuthStateChange,
198+
emits(predicate<AuthState>((s) => s.event == AuthChangeEvent.signedIn)),
199+
);
200+
201+
await client.setSession('some-refresh-token', accessToken: at);
202+
});
203+
204+
test('expired-fallback path emits tokenRefreshed (not signedIn)', () async {
205+
final timeNow = DateTime.now().millisecondsSinceEpoch ~/ 1000;
206+
// Clearly expired token (exp well in the past).
207+
final at = _makeRawJwt(
208+
{'exp': timeNow - 100, 'iat': timeNow - 3700, 'sub': 'mock-user-id'});
209+
210+
expect(
211+
client.onAuthStateChange,
212+
emits(predicate<AuthState>(
213+
(s) => s.event == AuthChangeEvent.tokenRefreshed)),
214+
);
215+
216+
await client.setSession('some-refresh-token', accessToken: at);
217+
});
218+
});
219+
}

0 commit comments

Comments
 (0)