diff --git a/packages/clerk_auth/lib/src/clerk_api/api.dart b/packages/clerk_auth/lib/src/clerk_api/api.dart index b3e06f87..7a50e02b 100644 --- a/packages/clerk_auth/lib/src/clerk_api/api.dart +++ b/packages/clerk_auth/lib/src/clerk_api/api.dart @@ -164,6 +164,7 @@ class Api with Logging { } Future _delete(String path, {bool requiresSessionId = false}) async { + _tokenCache.clear(); try { final headers = _headers(method: HttpMethod.delete); final resp = await _fetch( @@ -173,7 +174,6 @@ class Api with Logging { withSession: requiresSessionId, ); if (resp.statusCode == 200) { - _tokenCache.clear(); return true; } else { logSevere('HTTP error on DELETE $path: ${resp.statusCode}', resp.body); diff --git a/packages/clerk_auth/test/test_support/src/mock_http_service.dart b/packages/clerk_auth/test/test_support/src/mock_http_service.dart index 679a997c..1ec826f8 100644 --- a/packages/clerk_auth/test/test_support/src/mock_http_service.dart +++ b/packages/clerk_auth/test/test_support/src/mock_http_service.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:clerk_auth/clerk_auth.dart'; import 'package:http/http.dart' show ByteStream, Response; @@ -7,6 +8,10 @@ import 'package:http/http.dart' show ByteStream, Response; class MockHttpService implements HttpService { MockHttpService(); + /// When true, [send] throws a [SocketException] and [ping] returns false, + /// simulating a device with no network connectivity. + bool isOffline = false; + final List calls = []; final List responses = []; int _responseIndex = 0; @@ -135,6 +140,7 @@ class MockHttpService implements HttpService { } void reset() { + isOffline = false; calls.clear(); responses.clear(); _responseIndex = 0; @@ -147,7 +153,8 @@ class MockHttpService implements HttpService { void terminate() {} @override - Future ping(Uri uri, {required Duration timeout}) => Future.value(true); + Future ping(Uri uri, {required Duration timeout}) => + Future.value(!isOffline); @override Future send( @@ -157,6 +164,7 @@ class MockHttpService implements HttpService { Map? params, String? body, }) async { + if (isOffline) throw SocketException('No network'); calls.add(MockHttpCall( method: method, uri: uri, @@ -197,6 +205,7 @@ class MockHttpCall { this.headers, this.params, this.body}); + final HttpMethod method; final Uri uri; final Map? headers; @@ -207,6 +216,7 @@ class MockHttpCall { class MockHttpResponse { MockHttpResponse( {required this.body, this.statusCode = 200, this.headers = const {}}); + final String body; final int statusCode; final Map headers; @@ -496,4 +506,38 @@ extension MockHttpServiceExtensions on MockHttpService { 'last_active_at': DateTime.now().millisecondsSinceEpoch, 'delete_self_enabled': true, }; + + /// Add a client-with-session response that includes an Authorization response + /// header carrying [clientToken]. This mirrors what the real Clerk API returns + /// after a successful authentication: the token cache reads the client token + /// from the Authorization header via [TokenCache.updateFrom], so subsequent + /// requests from this [Auth] instance will be sent with credentials. + void addAuthenticatedClientWithSessionResponse({ + String clientToken = 'test_client_token', + String clientId = 'client_123', + String sessionId = 'sess_123', + String userId = 'user_123', + }) { + final sessionJson = _createSessionJson( + sessionId: sessionId, + userId: userId, + ); + + final clientJson = { + 'object': 'client', + 'id': clientId, + 'sessions': [sessionJson], + 'last_active_session_id': sessionId, + 'sign_in': null, + 'sign_up': null, + 'created_at': DateTime.now().millisecondsSinceEpoch, + 'updated_at': DateTime.now().millisecondsSinceEpoch, + }; + + addResponse(MockHttpResponse( + body: jsonEncode({'response': clientJson}), + statusCode: 200, + headers: {HttpHeaders.authorizationHeader: clientToken}, + )); + } } diff --git a/packages/clerk_auth/test/test_support/src/test_auth_config.dart b/packages/clerk_auth/test/test_support/src/test_auth_config.dart index e3918805..1b1ded19 100644 --- a/packages/clerk_auth/test/test_support/src/test_auth_config.dart +++ b/packages/clerk_auth/test/test_support/src/test_auth_config.dart @@ -2,9 +2,13 @@ import 'package:clerk_auth/clerk_auth.dart'; import 'package:http/http.dart' show ByteStream, Response; class TestAuthConfig extends AuthConfig { + // A valid publishable key whose base64 payload decodes to 'somedomain.com\n'. + static const kPublishableKey = 'pk_test_c29tZWRvbWFpbi5jb20K'; + const TestAuthConfig({ required super.publishableKey, super.httpService = const _NoneHttpService(), + super.retryOptions = const RetryOptions(maxAttempts: 1), }) : super( sessionTokenPolling: false, localesLookup: _localesLookup, diff --git a/packages/clerk_auth/test/unit/clerk_auth/sign_out_offline_test.dart b/packages/clerk_auth/test/unit/clerk_auth/sign_out_offline_test.dart new file mode 100644 index 00000000..2ad4c32b --- /dev/null +++ b/packages/clerk_auth/test/unit/clerk_auth/sign_out_offline_test.dart @@ -0,0 +1,91 @@ +import 'dart:io'; + +import 'package:clerk_auth/clerk_auth.dart'; + +import '../../test_helpers.dart'; + +void main() { + group('signOut offline behaviour', () { + late MockHttpService mockHttp; + late Auth auth; + + setUp(() async { + mockHttp = MockHttpService(); + auth = Auth( + config: TestAuthConfig( + publishableKey: TestAuthConfig.kPublishableKey, + httpService: mockHttp, + ), + ); + + // initialize() calls POST /client then GET /environment. Queue an + // authenticated client response (Authorization header seeds the token + // cache via TokenCache.updateFrom) so the session is active after init. + mockHttp.addAuthenticatedClientWithSessionResponse(); + mockHttp.addEnvironmentResponse(); + await auth.initialize(); + }); + + tearDown(() { + auth.terminate(); + }); + + test('auth is signed in after initialization with active session', () { + expect(auth.isSignedIn, true); + }); + + test( + 'signOut clears local auth state immediately even when network is unavailable', + () async { + mockHttp.isOffline = true; + + await auth.signOut(); + + expect(auth.isSignedIn, false); + }, + ); + + // This test documents the core bug: after an offline signOut the token + // cache retains credentials. When connectivity returns and the background + // refresh fires, those credentials are sent in the Authorization header, + // the server recognises the still-active session and the user is silently + // re-authenticated. + // + // Expected behaviour: signOut() must clear the local token cache regardless + // of network outcome, so a subsequent refresh carries no credentials and + // the server returns an empty client. + test( + 'after offline signOut, background client refresh does not re-authenticate the user', + () async { + mockHttp.isOffline = true; + await auth.signOut(); + expect(auth.isSignedIn, false); + + // Connectivity returns. Without credentials the real Clerk server returns + // an empty client with no active sessions. + mockHttp.isOffline = false; + mockHttp.addClientResponse(); + + // Simulate the background _clientTimer firing. + await auth.refreshClient(); + + expect(auth.isSignedIn, false); + }, + ); + + test( + 'after offline signOut, subsequent client refresh request carries no auth credentials', + () async { + mockHttp.isOffline = true; + await auth.signOut(); + + mockHttp.isOffline = false; + mockHttp.addClientResponse(); + await auth.refreshClient(); + + final refreshCall = mockHttp.calls.last; + expect(refreshCall.headers?[HttpHeaders.authorizationHeader], isNull); + }, + ); + }); +} diff --git a/packages/clerk_flutter/example/integration_test/phone_number_auth_test.dart b/packages/clerk_flutter/example/integration_test/phone_number_auth_test.dart index a444481d..af9d9f92 100644 --- a/packages/clerk_flutter/example/integration_test/phone_number_auth_test.dart +++ b/packages/clerk_flutter/example/integration_test/phone_number_auth_test.dart @@ -49,13 +49,13 @@ void main() { group( 'ClerkAuthentication: Phone-only configuration', - () { + () { // ----------------------------------------------------------------------- // Primary regression guard // ----------------------------------------------------------------------- testWidgets( 'should show the phone input field and hide email fields', - (tester) async { + (tester) async { final authState = await buildAuthState(); await tester.pumpWidget( @@ -105,8 +105,7 @@ void main() { expect( phoneFieldRect.height, greaterThan(0), - reason: - 'Phone input field height must be > 0. ' + reason: 'Phone input field height must be > 0. ' 'A Closeable(closed: true) sets heightFactor to 0, making ' 'the identifier section appear completely empty.', ); @@ -131,7 +130,7 @@ void main() { // ----------------------------------------------------------------------- testWidgets( 'should use ClerkPhoneNumberFormField', - (tester) async { + (tester) async { final authState = await buildAuthState(); await tester.pumpWidget(