Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/clerk_auth/lib/src/clerk_api/api.dart
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ class Api with Logging {
}

Future<bool> _delete(String path, {bool requiresSessionId = false}) async {
_tokenCache.clear();
try {
final headers = _headers(method: HttpMethod.delete);
final resp = await _fetch(
Expand All @@ -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);
Expand Down
46 changes: 45 additions & 1 deletion packages/clerk_auth/test/test_support/src/mock_http_service.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<MockHttpCall> calls = [];
final List<MockHttpResponse> responses = [];
int _responseIndex = 0;
Expand Down Expand Up @@ -135,6 +140,7 @@ class MockHttpService implements HttpService {
}

void reset() {
isOffline = false;
calls.clear();
responses.clear();
_responseIndex = 0;
Expand All @@ -147,7 +153,8 @@ class MockHttpService implements HttpService {
void terminate() {}

@override
Future<bool> ping(Uri uri, {required Duration timeout}) => Future.value(true);
Future<bool> ping(Uri uri, {required Duration timeout}) =>
Future.value(!isOffline);

@override
Future<Response> send(
Expand All @@ -157,6 +164,7 @@ class MockHttpService implements HttpService {
Map<String, dynamic>? params,
String? body,
}) async {
if (isOffline) throw SocketException('No network');
calls.add(MockHttpCall(
method: method,
uri: uri,
Expand Down Expand Up @@ -197,6 +205,7 @@ class MockHttpCall {
this.headers,
this.params,
this.body});

final HttpMethod method;
final Uri uri;
final Map<String, String>? headers;
Expand All @@ -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<String, String> headers;
Expand Down Expand Up @@ -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},
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
},
);
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.',
);
Expand All @@ -131,7 +130,7 @@ void main() {
// -----------------------------------------------------------------------
testWidgets(
'should use ClerkPhoneNumberFormField',
(tester) async {
(tester) async {
final authState = await buildAuthState();

await tester.pumpWidget(
Expand Down
Loading