diff --git a/Sources/Auth/Internal/APIClient.swift b/Sources/Auth/Internal/APIClient.swift index 92412b7fc..3a5bae1b6 100644 --- a/Sources/Auth/Internal/APIClient.swift +++ b/Sources/Auth/Internal/APIClient.swift @@ -27,10 +27,26 @@ struct APIClient: Sendable { Dependencies[clientID].configuration } + var sessionManager: SessionManager { + Dependencies[clientID].sessionManager + } + + var eventEmitter: AuthStateChangeEventEmitter { + Dependencies[clientID].eventEmitter + } + var http: any HTTPClientType { Dependencies[clientID].http } + /// Error codes that should clean up local session. + private let sessionCleanupErrorCodes: [ErrorCode] = [ + .sessionNotFound, + .sessionExpired, + .refreshTokenNotFound, + .refreshTokenAlreadyUsed, + ] + func execute(_ request: Helpers.HTTPRequest) async throws -> Helpers.HTTPResponse { var request = request request.headers = HTTPFields(configuration.headers).merging(with: request.headers) @@ -42,7 +58,7 @@ struct APIClient: Sendable { let response = try await http.send(request) guard 200..<300 ~= response.statusCode else { - throw handleError(response: response) + throw await handleError(response: response) } return response @@ -62,7 +78,7 @@ struct APIClient: Sendable { return try await execute(request) } - func handleError(response: Helpers.HTTPResponse) -> AuthError { + func handleError(response: Helpers.HTTPResponse) async -> AuthError { guard let error = try? response.decoded( as: _RawAPIErrorResponse.self, @@ -98,7 +114,12 @@ struct APIClient: Sendable { message: error._getErrorMessage(), reasons: error.weakPassword?.reasons ?? [] ) - } else if errorCode == .sessionNotFound { + } else if let errorCode, sessionCleanupErrorCodes.contains(errorCode) { + // The `session_id` inside the JWT does not correspond to a row in the + // `sessions` table. This usually means the user has signed out, has been + // deleted, or their session has somehow been terminated. + await sessionManager.remove() + eventEmitter.emit(.signedOut, session: nil) return .sessionMissing } else { return .api( diff --git a/Tests/AuthTests/AuthClientTests.swift b/Tests/AuthTests/AuthClientTests.swift index 2fdab67d8..19f58bbbb 100644 --- a/Tests/AuthTests/AuthClientTests.swift +++ b/Tests/AuthTests/AuthClientTests.swift @@ -2151,6 +2151,82 @@ final class AuthClientTests: XCTestCase { ) } + func testRemoveSessionAndSignoutIfSessionNotFoundErrorReturned() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("user"), + statusCode: 403, + data: [ + .get: Data( + """ + { + "error_code": "session_not_found", + "message": "Session not found" + } + """.utf8 + ) + ] + ) + .register() + + Dependencies[sut.clientID].sessionStorage.store(.validSession) + + try await assertAuthStateChanges( + sut: sut, + action: { + do { + _ = try await sut.user() + XCTFail("Expected failure") + } catch { + XCTAssertEqual(error as? AuthError, .sessionMissing) + } + }, + expectedEvents: [.initialSession, .signedOut] + ) + + XCTAssertNil(Dependencies[sut.clientID].sessionStorage.get()) + } + + func testRemoveSessionAndSignoutIfRefreshTokenNotFoundErrorReturned() async throws { + let sut = makeSUT() + + Mock( + url: clientURL.appendingPathComponent("token").appendingQueryItems([ + URLQueryItem(name: "grant_type", value: "refresh_token") + ]), + statusCode: 403, + data: [ + .post: Data( + """ + { + "error_code": "refresh_token_not_found", + "message": "Invalid Refresh Token: Refresh Token Not Found" + } + """.utf8 + ) + ] + ) + .register() + + Dependencies[sut.clientID].sessionStorage.store(.expiredSession) + + try await assertAuthStateChanges( + sut: sut, + action: { + do { + _ = try await sut.session + XCTFail("Expected failure") + } catch { + XCTAssertEqual(error as? AuthError, .sessionMissing) + } + }, + expectedEvents: [.signedOut] + ) + + XCTAssertNil(Dependencies[sut.clientID].sessionStorage.get()) + } + private func makeSUT(flowType: AuthFlowType = .pkce) -> AuthClient { let sessionConfiguration = URLSessionConfiguration.default sessionConfiguration.protocolClasses = [MockingURLProtocol.self] @@ -2198,6 +2274,7 @@ final class AuthClientTests: XCTestCase { action: () async throws -> T, expectedEvents: [AuthChangeEvent], expectedSessions: [Session?]? = nil, + timeout: TimeInterval = 2, fileID: StaticString = #fileID, filePath: StaticString = #filePath, line: UInt = #line, @@ -2211,14 +2288,30 @@ final class AuthClientTests: XCTestCase { let result = try await action() - let authStateChanges = await eventsTask.value + let authStateChanges = try await withTimeout(interval: timeout) { + await eventsTask.value + } let events = authStateChanges.map(\.event) let sessions = authStateChanges.map(\.session) - expectNoDifference(events, expectedEvents, fileID: fileID, filePath: filePath, line: line, column: column) + expectNoDifference( + events, + expectedEvents, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) if let expectedSessions = expectedSessions { - expectNoDifference(sessions, expectedSessions, fileID: fileID, filePath: filePath, line: line, column: column) + expectNoDifference( + sessions, + expectedSessions, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) } return result