From aae2e55e9c58b0b592570be098738177d30bdd9c Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Fri, 8 May 2026 13:29:58 -0700 Subject: [PATCH 01/20] android offline_access --- .../src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java | 1 + .../main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index 624c12cb94..95bdb63aab 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -263,6 +263,7 @@ public void onTokenRequestCompleted(@Nullable TokenResponse tokenResponse, successResponse.put("refreshToken", tokenResponse.refreshToken); successResponse.put("idToken", tokenResponse.idToken); successResponse.put("tokenType", tokenResponse.tokenType); + successResponse.put("scope", tokenResponse.scope); if (tokenResponse.accessTokenExpirationTime != null) { successResponse.put("expiresAt", tokenResponse.accessTokenExpirationTime); } diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java index 848b77eda3..8ef058c9b9 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java @@ -125,7 +125,7 @@ public void authenticate(PluginCall call) { ); authRequestBuilder - .setScope("openid profile") + .setScope("openid profile offline_access") .setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier()); AuthorizationRequest authRequest = authRequestBuilder.build(); @@ -135,7 +135,7 @@ public void authenticate(PluginCall call) { Log.d(TAG, "Authorization URL: " + authorizationBaseUrl); Log.d(TAG, "Token Endpoint: " + accessTokenEndpoint); Log.d(TAG, "Redirect URL: " + redirectUrl); - Log.d(TAG, "Scope: openid profile"); + Log.d(TAG, "Scope: openid profile offline_access"); // Store the call and request for later completion implementation.setCurrentCall(call); From 1e4592f398f9d9e0131b97a0a39309a7922ccb5d Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Fri, 8 May 2026 15:07:49 -0700 Subject: [PATCH 02/20] keychain --- .../KeycloakPlugin/KeycloakPlugin.swift | 90 +++++++++++++-- .../KeycloakPlugin/KeycloakServices.swift | 103 ++++++++++++++++++ .../KeycloakPluginTests.swift | 1 + 3 files changed, 182 insertions(+), 12 deletions(-) diff --git a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift index 97d288426a..1fef4905ff 100644 --- a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift +++ b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift @@ -38,6 +38,21 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { super.init() } + public override func load() { + super.load() + + guard let restoredAuthState = services.authStateStorageService.loadAuthState() else { + return + } + + if restoredAuthState.isAuthorized { + authState = restoredAuthState + startTokenRefreshManager(authState: restoredAuthState) + } else { + services.authStateStorageService.clearAuthState() + } + } + @objc func authenticate(_ call: CAPPluginCall) { // Validate parameters using the injected validator let validationResult = services.parameterValidator.validateAuthenticationParameters(call) @@ -51,6 +66,49 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { return } + if let existingAuthState = authState ?? services.authStateStorageService.loadAuthState() { + if existingAuthState.isAuthorized { + refreshExistingAuthState( + authState: existingAuthState, + call: call, + fallbackAuthenticationParameters: parameters + ) + return + } + + authState = nil + services.authStateStorageService.clearAuthState() + } + + performAuthentication(parameters: parameters, call: call) + } + + private func refreshExistingAuthState( + authState: OIDAuthState, + call: CAPPluginCall, + fallbackAuthenticationParameters parameters: AuthenticationParameters + ) { + services.tokenRefreshService.performTokenRefresh(authState: authState) { + [weak self] success, tokenResponse, _ in + guard let self = self else { return } + + if success { + self.authState = authState + self.services.authStateStorageService.saveAuthState(authState) + self.startTokenRefreshManager(authState: authState) + let response = + tokenResponse + ?? self.services.tokenResponseService.createTokenResponse(from: authState) + call.resolve(response) + } else { + self.authState = nil + self.services.authStateStorageService.clearAuthState() + self.performAuthentication(parameters: parameters, call: call) + } + } + } + + private func performAuthentication(parameters: AuthenticationParameters, call: CAPPluginCall) { services.authenticationFlowService.performAuthenticationFlow( parameters: parameters, call: call @@ -60,22 +118,30 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { self.authState = authState if let authState = authState { - self.services.tokenRefreshManagerService.startTokenRefreshManager( - authState: authState, - tokenRefreshThreshold: self.tokenRefreshThreshold, - onTokenRefreshed: { [weak self] tokenResponse in - // Notify JavaScript layer about token refresh - self?.notifyListeners("tokenRefresh", data: tokenResponse) - }, - onTokenRefreshFailed: { [weak self] error in - // Notify JavaScript layer about refresh failure - self?.notifyListeners("tokenRefreshFailed", data: ["error": error]) - } - ) + self.services.authStateStorageService.saveAuthState(authState) + self.startTokenRefreshManager(authState: authState) } } } + private func startTokenRefreshManager(authState: OIDAuthState) { + services.tokenRefreshManagerService.startTokenRefreshManager( + authState: authState, + tokenRefreshThreshold: tokenRefreshThreshold, + onTokenRefreshed: { [weak self, weak authState] tokenResponse in + if let authState = authState { + self?.services.authStateStorageService.saveAuthState(authState) + } + // notify JavaScript layer about token refresh + self?.notifyListeners("tokenRefresh", data: tokenResponse) + }, + onTokenRefreshFailed: { [weak self] error in + // notify JavaScript layer about refresh failure + self?.notifyListeners("tokenRefreshFailed", data: ["error": error]) + } + ) + } + deinit { services.tokenRefreshManagerService.stopTokenRefreshManager() } diff --git a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift index 66091cd261..bb271f8041 100644 --- a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift +++ b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift @@ -2,6 +2,7 @@ import AppAuth import Capacitor import Foundation import OSLog +import Security #if canImport(UIKit) import UIKit @@ -47,6 +48,13 @@ public protocol TokenResponseServiceProtocol { func createTokenResponse(from authState: OIDAuthState) -> [String: Any] } +/// Protocol for persisting AppAuth state +public protocol AuthStateStorageServiceProtocol { + func saveAuthState(_ authState: OIDAuthState) + func loadAuthState() -> OIDAuthState? + func clearAuthState() +} + /// Protocol for UI operations public protocol UIServiceProtocol { func getAppDelegate() -> KeycloakAppDelegate? @@ -294,6 +302,97 @@ public class DefaultTokenResponseService: TokenResponseServiceProtocol { } } +/// Keychain-backed implementation of AuthStateStorageServiceProtocol +public class KeychainAuthStateStorageService: AuthStateStorageServiceProtocol { + private let service: String + private let account: String + private let logger = Logger(subsystem: "com.bcgov.wps.keycloak", category: "auth-state-storage") + + public init( + service: String = "com.bcgov.wps.keycloak", + account: String = "oid-auth-state" + ) { + self.service = service + self.account = account + } + + public func saveAuthState(_ authState: OIDAuthState) { + do { + let data = try NSKeyedArchiver.archivedData( + withRootObject: authState, + requiringSecureCoding: true + ) + + let query = baseQuery() + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, + ] + + let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if updateStatus == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData as String] = data + addQuery[kSecAttrAccessible as String] = + kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + if addStatus != errSecSuccess { + logger.error("Failed to add auth state to keychain: \(addStatus)") + } + } else if updateStatus != errSecSuccess { + logger.error("Failed to update auth state in keychain: \(updateStatus)") + } + } catch { + logger.error("Failed to archive auth state: \(error.localizedDescription)") + } + } + + public func loadAuthState() -> OIDAuthState? { + var query = baseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess else { + if status != errSecItemNotFound { + logger.error("Failed to load auth state from keychain: \(status)") + } + return nil + } + + guard let data = result as? Data else { + logger.error("Stored auth state was not keychain data") + return nil + } + + do { + return try NSKeyedUnarchiver.unarchivedObject(ofClass: OIDAuthState.self, from: data) + } catch { + logger.error("Failed to unarchive auth state: \(error.localizedDescription)") + clearAuthState() + return nil + } + } + + public func clearAuthState() { + let status = SecItemDelete(baseQuery() as CFDictionary) + if status != errSecSuccess && status != errSecItemNotFound { + logger.error("Failed to clear auth state from keychain: \(status)") + } + } + + private func baseQuery() -> [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + } +} + /// Default implementation of UIServiceProtocol public class DefaultUIService: UIServiceProtocol { public init() {} @@ -546,6 +645,7 @@ public struct KeycloakServices { public let tokenRefreshService: TokenRefreshServiceProtocol public let tokenRefreshManagerService: TokenRefreshManagerServiceProtocol public let tokenResponseService: TokenResponseServiceProtocol + public let authStateStorageService: AuthStateStorageServiceProtocol public let uiService: UIServiceProtocol public let parameterValidator: AuthenticationParameterValidatorProtocol @@ -554,6 +654,8 @@ public struct KeycloakServices { tokenTimerService: TokenTimerServiceProtocol = DefaultTokenTimerService(), tokenRefreshService: TokenRefreshServiceProtocol = DefaultTokenRefreshService(), tokenResponseService: TokenResponseServiceProtocol = DefaultTokenResponseService(), + authStateStorageService: AuthStateStorageServiceProtocol = + KeychainAuthStateStorageService(), uiService: UIServiceProtocol = DefaultUIService(), parameterValidator: AuthenticationParameterValidatorProtocol = DefaultAuthenticationParameterValidator() @@ -571,6 +673,7 @@ public struct KeycloakServices { tokenRefreshService: tokenRefreshService ) self.tokenResponseService = tokenResponseService + self.authStateStorageService = authStateStorageService self.uiService = uiService self.parameterValidator = parameterValidator } diff --git a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift index f99274b94c..42a4ba529f 100644 --- a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift +++ b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift @@ -76,6 +76,7 @@ struct KeycloakPluginTests { #expect(services.tokenTimerService is DefaultTokenTimerService) #expect(services.tokenRefreshService is DefaultTokenRefreshService) #expect(services.tokenResponseService is DefaultTokenResponseService) + #expect(services.authStateStorageService is KeychainAuthStateStorageService) #expect(services.uiService is DefaultUIService) } From 45ed4d1c03809590e654c4b67a1967fd5d5300e4 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Fri, 8 May 2026 15:33:34 -0700 Subject: [PATCH 03/20] android plugin --- .../ca/bcgov/plugins/keycloak/Keycloak.java | 76 ++++++++++++++++--- .../plugins/keycloak/KeycloakPlugin.java | 9 +++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index 95bdb63aab..771e0be0a6 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -67,10 +67,68 @@ private void persistAuthState() { Log.d(TAG, "Auth state persisted"); } + private void clearAuthState() { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_AUTH_STATE) + .apply(); + authState = new AuthState(); + Log.d(TAG, "Auth state cleared"); + } + + private JSObject createSuccessResponse(@Nullable String accessToken, @Nullable String idToken, + @Nullable String tokenType, @Nullable Long expiresAt, + @Nullable String scope) { + JSObject successResponse = new JSObject(); + successResponse.put("isAuthenticated", true); + successResponse.put("accessToken", accessToken); + successResponse.put("refreshToken", authState.getRefreshToken()); + successResponse.put("idToken", idToken); + successResponse.put("tokenType", tokenType != null ? tokenType : "Bearer"); + successResponse.put("scope", scope); + if (expiresAt != null) { + successResponse.put("expiresAt", expiresAt); + } + return successResponse; + } + public void setTokenRefreshCallback(TokenRefreshCallback callback) { this.tokenRefreshCallback = callback; } + public boolean authenticateWithStoredState(PluginCall call, Runnable fallbackToBrowser) { + if (!authState.isAuthorized()) { + Log.d(TAG, "No authorized stored auth state available"); + return false; + } + + Log.d(TAG, "Refreshing stored auth state before launching browser"); + authState.performActionWithFreshTokens(authService, new AuthState.AuthStateAction() { + @Override + public void execute(@Nullable String accessToken, @Nullable String idToken, + @Nullable AuthorizationException exception) { + if (exception != null) { + Log.e(TAG, "Stored auth state refresh failed: " + exception.getMessage()); + clearAuthState(); + fallbackToBrowser.run(); + return; + } + + Log.d(TAG, "Stored auth state refresh successful"); + persistAuthState(); + call.resolve(createSuccessResponse( + accessToken, + idToken, + "Bearer", + authState.getAccessTokenExpirationTime(), + authState.getScope() + )); + } + }); + + return true; + } + private void setupAutomaticRefresh() { refreshCheckRunnable = new Runnable() { @Override @@ -126,6 +184,7 @@ public void execute(@Nullable String accessToken, @Nullable String idToken, tokens.put("idToken", idToken); tokens.put("refreshToken", authState.getRefreshToken()); tokens.put("tokenType", "Bearer"); + tokens.put("scope", authState.getScope()); if (authState.getAccessTokenExpirationTime() != null) { tokens.put("expiresAt", authState.getAccessTokenExpirationTime()); } @@ -257,16 +316,13 @@ public void onTokenRequestCompleted(@Nullable TokenResponse tokenResponse, persistAuthState(); Log.d(TAG, "Token exchange successful"); - JSObject successResponse = new JSObject(); - successResponse.put("isAuthenticated", true); - successResponse.put("accessToken", tokenResponse.accessToken); - successResponse.put("refreshToken", tokenResponse.refreshToken); - successResponse.put("idToken", tokenResponse.idToken); - successResponse.put("tokenType", tokenResponse.tokenType); - successResponse.put("scope", tokenResponse.scope); - if (tokenResponse.accessTokenExpirationTime != null) { - successResponse.put("expiresAt", tokenResponse.accessTokenExpirationTime); - } + JSObject successResponse = createSuccessResponse( + tokenResponse.accessToken, + tokenResponse.idToken, + tokenResponse.tokenType, + tokenResponse.accessTokenExpirationTime, + tokenResponse.scope + ); if (currentCall != null) { currentCall.resolve(successResponse); diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java index 8ef058c9b9..0df5fc45ca 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java @@ -109,6 +109,15 @@ public void authenticate(PluginCall call) { return; } + if (implementation.authenticateWithStoredState(call, new Runnable() { + @Override + public void run() { + authenticate(call); + } + })) { + return; + } + try { // Create service configuration AuthorizationServiceConfiguration serviceConfig = new AuthorizationServiceConfiguration( From 6b3ddff4af36038bec2242e877fb40a283c166b7 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Mon, 11 May 2026 11:17:47 -0700 Subject: [PATCH 04/20] log scope --- .../ios/Sources/KeycloakPlugin/KeycloakServices.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift index bb271f8041..d63e256f44 100644 --- a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift +++ b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakServices.swift @@ -276,6 +276,9 @@ public class DefaultTokenRefreshService: TokenRefreshServiceProtocol { completion(false, nil, error.localizedDescription) } else { self?.logger.debug("Token refresh successful") + self?.logger.debug( + "Token refresh scope: \(authState.lastTokenResponse?.scope ?? "nil")" + ) let tokenResponse = DefaultTokenResponseService().createTokenResponse( from: authState) @@ -558,6 +561,7 @@ public class DefaultAuthenticationFlowService: AuthenticationFlowServiceProtocol logger.debug( "Got authorization tokens. Access token: \(authState.lastTokenResponse?.accessToken ?? "nil", privacy: .private)" ) + logger.debug("Authorization scope: \(authState.lastTokenResponse?.scope ?? "nil")") completion(authState) } else { From 9fddbe1c2f7a14394d7e8c8b7d8bc3edb26f2ed0 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Mon, 11 May 2026 13:55:48 -0700 Subject: [PATCH 05/20] EncryptedSharedPreferences --- mobile/keycloak/android/build.gradle | 1 + .../ca/bcgov/plugins/keycloak/Keycloak.java | 76 ++++++++++++++++--- 2 files changed, 68 insertions(+), 9 deletions(-) diff --git a/mobile/keycloak/android/build.gradle b/mobile/keycloak/android/build.gradle index e47c75fc73..f42e87d6e2 100644 --- a/mobile/keycloak/android/build.gradle +++ b/mobile/keycloak/android/build.gradle @@ -72,6 +72,7 @@ dependencies { implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.browser:browser:1.10.0' + implementation 'androidx.security:security-crypto:1.1.0' implementation 'com.squareup.okhttp3:okhttp:5.3.2' implementation 'net.openid:appauth:0.11.1' testImplementation "junit:junit:$junitVersion" diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index 771e0be0a6..0b04510a5e 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -8,8 +8,12 @@ import android.os.Looper; import android.util.Log; import androidx.annotation.Nullable; +import androidx.security.crypto.EncryptedSharedPreferences; +import androidx.security.crypto.MasterKey; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; +import java.io.IOException; +import java.security.GeneralSecurityException; import net.openid.appauth.*; import org.json.JSONException; @@ -18,6 +22,7 @@ public class Keycloak { private static final long TOKEN_REFRESH_CHECK_INTERVAL = 60000; // Check every 60 seconds private static final long TOKEN_EXPIRY_BUFFER = 60000; // Refresh 1 minute before expiry private static final String PREFS_NAME = "KeycloakAuthState"; + private static final String ENCRYPTED_PREFS_NAME = "KeycloakEncryptedAuthState"; private static final String KEY_AUTH_STATE = "auth_state"; private Context context; @@ -42,8 +47,7 @@ public Keycloak(Context context) { } private AuthState restoreAuthState() { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - String stateJson = prefs.getString(KEY_AUTH_STATE, null); + String stateJson = readAuthStateJson(); if (stateJson != null) { try { @@ -60,20 +64,74 @@ private AuthState restoreAuthState() { } private void persistAuthState() { - SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - prefs.edit() - .putString(KEY_AUTH_STATE, authState.jsonSerializeString()) - .apply(); - Log.d(TAG, "Auth state persisted"); + try { + getEncryptedPreferences() + .edit() + .putString(KEY_AUTH_STATE, authState.jsonSerializeString()) + .apply(); + Log.d(TAG, "Auth state persisted to encrypted storage"); + } catch (GeneralSecurityException | IOException e) { + Log.e(TAG, "Failed to persist auth state to encrypted storage", e); + } } private void clearAuthState() { + try { + getEncryptedPreferences() + .edit() + .remove(KEY_AUTH_STATE) + .apply(); + } catch (GeneralSecurityException | IOException e) { + Log.e(TAG, "Failed to clear encrypted auth state", e); + } + clearLegacyAuthState(); + authState = new AuthState(); + Log.d(TAG, "Auth state cleared"); + } + + private String readAuthStateJson() { + try { + SharedPreferences encryptedPrefs = getEncryptedPreferences(); + String encryptedStateJson = encryptedPrefs.getString(KEY_AUTH_STATE, null); + if (encryptedStateJson != null) { + return encryptedStateJson; + } + + String legacyStateJson = context + .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_AUTH_STATE, null); + if (legacyStateJson != null) { + encryptedPrefs.edit().putString(KEY_AUTH_STATE, legacyStateJson).apply(); + clearLegacyAuthState(); + Log.d(TAG, "Migrated auth state to encrypted storage"); + } + return legacyStateJson; + } catch (GeneralSecurityException | IOException e) { + Log.e(TAG, "Failed to read auth state from encrypted storage", e); + return null; + } + } + + private SharedPreferences getEncryptedPreferences() + throws GeneralSecurityException, IOException { + MasterKey masterKey = new MasterKey.Builder(context) + .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) + .build(); + + return EncryptedSharedPreferences.create( + context, + ENCRYPTED_PREFS_NAME, + masterKey, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM + ); + } + + private void clearLegacyAuthState() { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) .edit() .remove(KEY_AUTH_STATE) .apply(); - authState = new AuthState(); - Log.d(TAG, "Auth state cleared"); } private JSObject createSuccessResponse(@Nullable String accessToken, @Nullable String idToken, From e82d29a88f39399c52020105eea4e05d009153ab Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Wed, 24 Jun 2026 11:14:27 -0700 Subject: [PATCH 06/20] rxjava & tink + tests --- mobile/keycloak/android/build.gradle | 3 +- .../plugins/keycloak/AuthStateStore.java | 149 +++++++++++++++ .../ca/bcgov/plugins/keycloak/Keycloak.java | 173 +++++++----------- .../plugins/keycloak/AuthStateStoreTest.java | 92 ++++++++++ 4 files changed, 309 insertions(+), 108 deletions(-) create mode 100644 mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/AuthStateStore.java create mode 100644 mobile/keycloak/android/src/test/java/ca/bcgov/plugins/keycloak/AuthStateStoreTest.java diff --git a/mobile/keycloak/android/build.gradle b/mobile/keycloak/android/build.gradle index f47c603fc2..5a5b7fca69 100644 --- a/mobile/keycloak/android/build.gradle +++ b/mobile/keycloak/android/build.gradle @@ -72,7 +72,8 @@ dependencies { implementation project(':capacitor-android') implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion" implementation 'androidx.browser:browser:1.10.0' - implementation 'androidx.security:security-crypto:1.1.0' + implementation 'androidx.datastore:datastore-preferences-rxjava3:1.2.1' + implementation 'com.google.crypto.tink:tink-android:1.18.0' implementation 'com.squareup.okhttp3:okhttp:5.4.0' implementation 'net.openid:appauth:0.11.1' testImplementation "junit:junit:$junitVersion" diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/AuthStateStore.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/AuthStateStore.java new file mode 100644 index 0000000000..d2913b9981 --- /dev/null +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/AuthStateStore.java @@ -0,0 +1,149 @@ +package ca.bcgov.plugins.keycloak; + +import android.content.Context; +import android.content.SharedPreferences; +import android.util.Base64; +import android.util.Log; +import androidx.annotation.Nullable; +import androidx.datastore.preferences.core.MutablePreferences; +import androidx.datastore.preferences.core.Preferences; +import androidx.datastore.preferences.core.PreferencesKeys; +import androidx.datastore.preferences.rxjava3.RxPreferenceDataStoreBuilder; +import androidx.datastore.rxjava3.RxDataStore; +import com.google.crypto.tink.Aead; +import com.google.crypto.tink.KeyTemplates; +import com.google.crypto.tink.RegistryConfiguration; +import com.google.crypto.tink.aead.AeadConfig; +import com.google.crypto.tink.integration.android.AndroidKeysetManager; +import io.reactivex.rxjava3.core.Single; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; + +class AuthStateStore { + + private static final String TAG = "Keycloak"; + private static final String DATASTORE_NAME = "keycloak_auth_state"; + private static final String KEY_AUTH_STATE = "auth_state"; + private static final String LEGACY_PREFS_NAME = "KeycloakAuthState"; + private static final String TINK_KEYSET_NAME = "keycloak_auth_state_keyset"; + private static final String TINK_KEYSET_PREFS_NAME = "keycloak_auth_state_keyset_prefs"; + private static final String MASTER_KEY_URI = "android-keystore://keycloak_auth_state_master_key"; + private static final Preferences.Key AUTH_STATE_KEY = PreferencesKeys.stringKey(KEY_AUTH_STATE); + private static final Object DATASTORE_LOCK = new Object(); + + private static RxDataStore dataStore; + + private final Context context; + private final Aead aead; + + AuthStateStore(Context context) throws GeneralSecurityException, IOException { + this.context = context.getApplicationContext(); + AeadConfig.register(); + this.aead = new AndroidKeysetManager.Builder() + .withSharedPref(this.context, TINK_KEYSET_NAME, TINK_KEYSET_PREFS_NAME) + .withKeyTemplate(KeyTemplates.get("AES256_GCM")) + .withMasterKeyUri(MASTER_KEY_URI) + .build() + .getKeysetHandle() + .getPrimitive(RegistryConfiguration.get(), Aead.class); + getDataStore(this.context); + } + + @Nullable + String read() { + try { + Preferences preferences = getDataStore(context).data().firstOrError().blockingGet(); + String encryptedState = preferences.get(AUTH_STATE_KEY); + + if (encryptedState != null) { + try { + return decrypt(encryptedState); + } catch (GeneralSecurityException | IllegalArgumentException e) { + Log.e(TAG, "Failed to decrypt stored auth state", e); + clear(); + return null; + } + } + + String legacyState = readLegacyAuthState(); + if (legacyState != null && write(legacyState)) { + clearLegacyAuthState(); + Log.d(TAG, "Migrated auth state to encrypted DataStore"); + } + return legacyState; + } catch (Exception e) { + Log.e(TAG, "Failed to read auth state from encrypted DataStore", e); + return null; + } + } + + boolean write(String stateJson) { + try { + String encryptedState = encrypt(stateJson); + getDataStore(context) + .updateDataAsync(preferences -> { + MutablePreferences mutablePreferences = preferences.toMutablePreferences(); + mutablePreferences.set(AUTH_STATE_KEY, encryptedState); + return Single.just(mutablePreferences); + }) + .blockingGet(); + Log.d(TAG, "Auth state persisted to encrypted DataStore"); + return true; + } catch (Exception e) { + Log.e(TAG, "Failed to persist auth state to encrypted DataStore", e); + return false; + } + } + + void clear() { + try { + getDataStore(context) + .updateDataAsync(preferences -> { + MutablePreferences mutablePreferences = preferences.toMutablePreferences(); + mutablePreferences.remove(AUTH_STATE_KEY); + return Single.just(mutablePreferences); + }) + .blockingGet(); + } catch (Exception e) { + Log.e(TAG, "Failed to clear encrypted auth state", e); + } + clearLegacyAuthState(); + } + + private static RxDataStore getDataStore(Context context) { + synchronized (DATASTORE_LOCK) { + if (dataStore == null) { + dataStore = new RxPreferenceDataStoreBuilder(context, DATASTORE_NAME).build(); + } + return dataStore; + } + } + + private String encrypt(String stateJson) throws GeneralSecurityException { + byte[] ciphertext = aead.encrypt(stateJson.getBytes(StandardCharsets.UTF_8), associatedData()); + return Base64.encodeToString(ciphertext, Base64.NO_WRAP); + } + + private String decrypt(String encryptedState) throws GeneralSecurityException { + byte[] ciphertext = Base64.decode(encryptedState, Base64.NO_WRAP); + byte[] plaintext = aead.decrypt(ciphertext, associatedData()); + return new String(plaintext, StandardCharsets.UTF_8); + } + + private byte[] associatedData() { + return (context.getPackageName() + ":" + DATASTORE_NAME + ":" + KEY_AUTH_STATE).getBytes( + StandardCharsets.UTF_8 + ); + } + + @Nullable + private String readLegacyAuthState() { + return context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE).getString(KEY_AUTH_STATE, null); + } + + private void clearLegacyAuthState() { + SharedPreferences preferences = context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE); + preferences.edit().remove(KEY_AUTH_STATE).apply(); + } +} diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index be49da2d20..11495fd825 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -2,14 +2,11 @@ import android.content.Context; import android.content.Intent; -import android.content.SharedPreferences; import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.util.Log; import androidx.annotation.Nullable; -import androidx.security.crypto.EncryptedSharedPreferences; -import androidx.security.crypto.MasterKey; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; import java.io.IOException; @@ -22,12 +19,9 @@ public class Keycloak { private static final String TAG = "Keycloak"; private static final long TOKEN_REFRESH_CHECK_INTERVAL = 60000; // Check every 60 seconds private static final long TOKEN_EXPIRY_BUFFER = 60000; // Refresh 1 minute before expiry - private static final String PREFS_NAME = "KeycloakAuthState"; - private static final String ENCRYPTED_PREFS_NAME = "KeycloakEncryptedAuthState"; - private static final String KEY_AUTH_STATE = "auth_state"; - private Context context; private AuthorizationService authService; + private AuthStateStore authStateStore; private PluginCall currentCall; private AuthorizationRequest currentAuthRequest; private AuthState authState; @@ -40,15 +34,15 @@ public interface TokenRefreshCallback { } public Keycloak(Context context) { - this.context = context; this.authService = new AuthorizationService(context); + this.authStateStore = createAuthStateStore(context); this.authState = restoreAuthState(); this.refreshHandler = new Handler(Looper.getMainLooper()); setupAutomaticRefresh(); } private AuthState restoreAuthState() { - String stateJson = readAuthStateJson(); + String stateJson = authStateStore != null ? authStateStore.read() : null; if (stateJson != null) { try { @@ -65,79 +59,35 @@ private AuthState restoreAuthState() { } private void persistAuthState() { - try { - getEncryptedPreferences() - .edit() - .putString(KEY_AUTH_STATE, authState.jsonSerializeString()) - .apply(); - Log.d(TAG, "Auth state persisted to encrypted storage"); - } catch (GeneralSecurityException | IOException e) { - Log.e(TAG, "Failed to persist auth state to encrypted storage", e); + if (authStateStore != null) { + authStateStore.write(authState.jsonSerializeString()); } } private void clearAuthState() { - try { - getEncryptedPreferences() - .edit() - .remove(KEY_AUTH_STATE) - .apply(); - } catch (GeneralSecurityException | IOException e) { - Log.e(TAG, "Failed to clear encrypted auth state", e); + if (authStateStore != null) { + authStateStore.clear(); } - clearLegacyAuthState(); authState = new AuthState(); Log.d(TAG, "Auth state cleared"); } - private String readAuthStateJson() { + private AuthStateStore createAuthStateStore(Context context) { try { - SharedPreferences encryptedPrefs = getEncryptedPreferences(); - String encryptedStateJson = encryptedPrefs.getString(KEY_AUTH_STATE, null); - if (encryptedStateJson != null) { - return encryptedStateJson; - } - - String legacyStateJson = context - .getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .getString(KEY_AUTH_STATE, null); - if (legacyStateJson != null) { - encryptedPrefs.edit().putString(KEY_AUTH_STATE, legacyStateJson).apply(); - clearLegacyAuthState(); - Log.d(TAG, "Migrated auth state to encrypted storage"); - } - return legacyStateJson; + return new AuthStateStore(context); } catch (GeneralSecurityException | IOException e) { - Log.e(TAG, "Failed to read auth state from encrypted storage", e); + Log.e(TAG, "Failed to initialize encrypted auth state storage", e); return null; } } - private SharedPreferences getEncryptedPreferences() - throws GeneralSecurityException, IOException { - MasterKey masterKey = new MasterKey.Builder(context) - .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) - .build(); - - return EncryptedSharedPreferences.create( - context, - ENCRYPTED_PREFS_NAME, - masterKey, - EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, - EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM - ); - } - - private void clearLegacyAuthState() { - context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) - .edit() - .remove(KEY_AUTH_STATE) - .apply(); - } - - private JSObject createSuccessResponse(@Nullable String accessToken, @Nullable String idToken, - @Nullable String tokenType, @Nullable Long expiresAt, - @Nullable String scope) { + private JSObject createSuccessResponse( + @Nullable String accessToken, + @Nullable String idToken, + @Nullable String tokenType, + @Nullable Long expiresAt, + @Nullable String scope + ) { JSObject successResponse = new JSObject(); successResponse.put("isAuthenticated", true); successResponse.put("accessToken", accessToken); @@ -162,28 +112,36 @@ public boolean authenticateWithStoredState(PluginCall call, Runnable fallbackToB } Log.d(TAG, "Refreshing stored auth state before launching browser"); - authState.performActionWithFreshTokens(authService, new AuthState.AuthStateAction() { - @Override - public void execute(@Nullable String accessToken, @Nullable String idToken, - @Nullable AuthorizationException exception) { - if (exception != null) { - Log.e(TAG, "Stored auth state refresh failed: " + exception.getMessage()); - clearAuthState(); - fallbackToBrowser.run(); - return; - } + authState.performActionWithFreshTokens( + authService, + new AuthState.AuthStateAction() { + @Override + public void execute( + @Nullable String accessToken, + @Nullable String idToken, + @Nullable AuthorizationException exception + ) { + if (exception != null) { + Log.e(TAG, "Stored auth state refresh failed: " + exception.getMessage()); + clearAuthState(); + fallbackToBrowser.run(); + return; + } - Log.d(TAG, "Stored auth state refresh successful"); - persistAuthState(); - call.resolve(createSuccessResponse( - accessToken, - idToken, - "Bearer", - authState.getAccessTokenExpirationTime(), - authState.getScope() - )); + Log.d(TAG, "Stored auth state refresh successful"); + persistAuthState(); + call.resolve( + createSuccessResponse( + accessToken, + idToken, + "Bearer", + authState.getAccessTokenExpirationTime(), + authState.getScope() + ) + ); + } } - }); + ); return true; } @@ -241,21 +199,22 @@ public void execute( // Persist the updated auth state persistAuthState(); - // Notify JavaScript about the refresh - if (tokenRefreshCallback != null) { - JSObject tokens = new JSObject(); - tokens.put("accessToken", accessToken); - tokens.put("idToken", idToken); - tokens.put("refreshToken", authState.getRefreshToken()); - tokens.put("tokenType", "Bearer"); - tokens.put("scope", authState.getScope()); - if (authState.getAccessTokenExpirationTime() != null) { - tokens.put("expiresAt", authState.getAccessTokenExpirationTime()); + // Notify JavaScript about the refresh + if (tokenRefreshCallback != null) { + JSObject tokens = new JSObject(); + tokens.put("accessToken", accessToken); + tokens.put("idToken", idToken); + tokens.put("refreshToken", authState.getRefreshToken()); + tokens.put("tokenType", "Bearer"); + tokens.put("scope", authState.getScope()); + if (authState.getAccessTokenExpirationTime() != null) { + tokens.put("expiresAt", authState.getAccessTokenExpirationTime()); + } + tokenRefreshCallback.onTokenRefreshed(tokens); } - tokenRefreshCallback.onTokenRefreshed(tokens); } } - }); + ); } /** @@ -385,14 +344,14 @@ public void onTokenRequestCompleted( // Persist auth state for future app restarts persistAuthState(); - Log.d(TAG, "Token exchange successful"); - JSObject successResponse = createSuccessResponse( - tokenResponse.accessToken, - tokenResponse.idToken, - tokenResponse.tokenType, - tokenResponse.accessTokenExpirationTime, - tokenResponse.scope - ); + Log.d(TAG, "Token exchange successful"); + JSObject successResponse = createSuccessResponse( + tokenResponse.accessToken, + tokenResponse.idToken, + tokenResponse.tokenType, + tokenResponse.accessTokenExpirationTime, + tokenResponse.scope + ); if (currentCall != null) { currentCall.resolve(successResponse); diff --git a/mobile/keycloak/android/src/test/java/ca/bcgov/plugins/keycloak/AuthStateStoreTest.java b/mobile/keycloak/android/src/test/java/ca/bcgov/plugins/keycloak/AuthStateStoreTest.java new file mode 100644 index 0000000000..1e5fdb82c6 --- /dev/null +++ b/mobile/keycloak/android/src/test/java/ca/bcgov/plugins/keycloak/AuthStateStoreTest.java @@ -0,0 +1,92 @@ +package ca.bcgov.plugins.keycloak; + +import static org.junit.Assert.*; + +import android.content.Context; +import android.content.SharedPreferences; +import androidx.datastore.preferences.core.Preferences; +import androidx.datastore.preferences.core.PreferencesKeys; +import androidx.datastore.rxjava3.RxDataStore; +import androidx.test.core.app.ApplicationProvider; +import java.lang.reflect.Field; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +@RunWith(RobolectricTestRunner.class) +@Config(sdk = 23, manifest = Config.NONE) +public class AuthStateStoreTest { + + private static final String KEY_AUTH_STATE = "auth_state"; + private static final String LEGACY_PREFS_NAME = "KeycloakAuthState"; + private static final Preferences.Key AUTH_STATE_KEY = PreferencesKeys.stringKey(KEY_AUTH_STATE); + + private Context context; + private AuthStateStore store; + + @Before + public void setUp() throws Exception { + context = ApplicationProvider.getApplicationContext(); + store = new AuthStateStore(context); + store.clear(); + legacyPreferences().edit().clear().commit(); + } + + @Test + public void testWriteAndRead_roundTripsAuthState() throws Exception { + String stateJson = "{\"authorized\":true,\"refreshToken\":\"refresh-token\"}"; + + assertTrue(store.write(stateJson)); + + assertEquals(stateJson, store.read()); + } + + @Test + public void testWrite_storesEncryptedAuthState() throws Exception { + String stateJson = "{\"authorized\":true,\"refreshToken\":\"refresh-token\"}"; + + assertTrue(store.write(stateJson)); + + String rawStoredValue = getRawStoredValue(); + assertNotNull(rawStoredValue); + assertNotEquals(stateJson, rawStoredValue); + } + + @Test + public void testRead_migratesLegacyAuthState() throws Exception { + String legacyStateJson = "{\"authorized\":true,\"refreshToken\":\"legacy-refresh-token\"}"; + legacyPreferences().edit().putString(KEY_AUTH_STATE, legacyStateJson).commit(); + + assertEquals(legacyStateJson, store.read()); + + assertNull(legacyPreferences().getString(KEY_AUTH_STATE, null)); + assertEquals(legacyStateJson, store.read()); + } + + @Test + public void testClear_removesStoredAndLegacyAuthState() throws Exception { + String stateJson = "{\"authorized\":true,\"refreshToken\":\"refresh-token\"}"; + assertTrue(store.write(stateJson)); + legacyPreferences().edit().putString(KEY_AUTH_STATE, stateJson).commit(); + + store.clear(); + + assertNull(store.read()); + assertNull(legacyPreferences().getString(KEY_AUTH_STATE, null)); + } + + private SharedPreferences legacyPreferences() { + return context.getSharedPreferences(LEGACY_PREFS_NAME, Context.MODE_PRIVATE); + } + + @SuppressWarnings("unchecked") + private String getRawStoredValue() throws Exception { + Field dataStoreField = AuthStateStore.class.getDeclaredField("dataStore"); + dataStoreField.setAccessible(true); + RxDataStore dataStore = (RxDataStore) dataStoreField.get(null); + Preferences preferences = dataStore.data().firstOrError().blockingGet(); + return preferences.get(AUTH_STATE_KEY); + } +} From d97681d2fc744e362c07c23dbdc7c5cbbccc062d Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 08:57:36 -0700 Subject: [PATCH 07/20] clear token on 401 --- .../src/slices/authenticationSlice.test.ts | 25 ++++- .../asa-go/src/slices/authenticationSlice.ts | 6 ++ .../asa-go/src/utils/axiosInterceptor.test.ts | 13 ++- mobile/asa-go/src/utils/axiosInterceptor.ts | 8 +- mobile/keycloak/README.md | 12 +++ .../ca/bcgov/plugins/keycloak/Keycloak.java | 36 +++++-- .../plugins/keycloak/KeycloakPlugin.java | 51 +++++++--- .../KeycloakPlugin/KeycloakPlugin.swift | 98 ++++++++++++++++++- .../KeycloakPluginTests.swift | 3 +- mobile/keycloak/src/definitions.ts | 4 + mobile/keycloak/src/web.ts | 2 + 11 files changed, 234 insertions(+), 24 deletions(-) diff --git a/mobile/asa-go/src/slices/authenticationSlice.test.ts b/mobile/asa-go/src/slices/authenticationSlice.test.ts index 8cbdedd533..303fc3db7f 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.test.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.test.ts @@ -77,16 +77,21 @@ describe('authenticationSlice', () => { const setupTokenRefreshListener = (store: ReturnType) => { let tokenRefreshCallback: (tokenResponse: TokenResponse) => void = () => {} + let tokenRefreshFailedCallback: () => void = () => {} ;(Keycloak.addListener as Mock).mockImplementation((event, callback) => { if (event === 'tokenRefresh') { tokenRefreshCallback = callback } + if (event === 'tokenRefreshFailed') { + tokenRefreshFailedCallback = callback + } }) return { store, - tokenRefreshCallback: (response: TokenResponse) => tokenRefreshCallback(response) + tokenRefreshCallback: (response: TokenResponse) => tokenRefreshCallback(response), + tokenRefreshFailedCallback: () => tokenRefreshFailedCallback() } } @@ -312,6 +317,7 @@ describe('authenticationSlice', () => { await store.dispatch(authenticate()) expect(Keycloak.addListener).toHaveBeenCalledWith('tokenRefresh', expect.any(Function)) + expect(Keycloak.addListener).toHaveBeenCalledWith('tokenRefreshFailed', expect.any(Function)) }) it('should handle token refresh callback correctly', async () => { @@ -348,6 +354,23 @@ describe('authenticationSlice', () => { expect(finalState.tokenRefreshed).toBe(initialState.tokenRefreshed) expect(finalState.token).toBe(initialState.token) }) + + it('should reset authentication when token refresh fails', async () => { + const store = setupStoreWithMockAuth(createSuccessfulAuthResult()) + const { tokenRefreshFailedCallback } = setupTokenRefreshListener(store) + + await store.dispatch(authenticate()) + + tokenRefreshFailedCallback() + + expectAuthState(store.getState().authentication, { + sessionMode: 'login', + token: undefined, + idToken: undefined, + idir: undefined + }) + expect(mockSetUser).toHaveBeenCalledWith(null) + }) }) }) }) diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index fcf7b3eb29..6782ec9df5 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -158,8 +158,14 @@ export const authenticate = (): AppThunk => dispatch => { } } + const handleTokenRefreshFailed = () => { + dispatch(resetAuthentication()) + Sentry.setUser(null) + } + // Set up event listener for token refresh events (works for both web and iOS) Keycloak.addListener('tokenRefresh', handleTokenRefresh) + Keycloak.addListener('tokenRefreshFailed', handleTokenRefreshFailed) } export const decodeUserDetails = (token: string | undefined) => { diff --git a/mobile/asa-go/src/utils/axiosInterceptor.test.ts b/mobile/asa-go/src/utils/axiosInterceptor.test.ts index 79d3934434..864636f6e4 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.test.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.test.ts @@ -36,6 +36,7 @@ const setup = async ({ } ) const resetAuthentication = vi.fn(() => resetAuthenticationAction) + const clearAuthState = vi.fn().mockResolvedValue(undefined) vi.doMock('@/api/axios', () => ({ default: { @@ -56,6 +57,11 @@ const setup = async ({ API_BASE_URL, API_PUBLIC_BASE_URL })) + vi.doMock('../../../keycloak/src', () => ({ + Keycloak: { + clearAuthState + } + })) const { configureApiInterceptors } = await import('@/utils/axiosInterceptor') @@ -64,6 +70,7 @@ const setup = async ({ requestUse, resetAuthentication, responseUse, + clearAuthState, resetAuthenticationAction, store } @@ -140,20 +147,22 @@ describe('configureApiInterceptors', () => { }) it('resets authentication on 401 responses', async () => { - const { responseUse, resetAuthenticationAction, store } = await configure() + const { clearAuthState, responseUse, resetAuthenticationAction, store } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 401) await expect(promise).rejects.toBe(error) + expect(clearAuthState).toHaveBeenCalled() expect(store.dispatch).toHaveBeenCalledWith(resetAuthenticationAction) }) it('does not reset authentication for non-401 responses', async () => { - const { responseUse, store } = await configure() + const { clearAuthState, responseUse, store } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 500) await expect(promise).rejects.toBe(error) + expect(clearAuthState).not.toHaveBeenCalled() expect(store.dispatch).not.toHaveBeenCalled() }) }) diff --git a/mobile/asa-go/src/utils/axiosInterceptor.ts b/mobile/asa-go/src/utils/axiosInterceptor.ts index a40c292825..b856cae7d5 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.ts @@ -4,6 +4,7 @@ import axios from '@/api/axios' import { resetAuthentication } from '@/slices/authenticationSlice' import { selectAuthentication, store } from '@/store' import { API_BASE_URL, API_PUBLIC_BASE_URL } from '@/utils/env' +import { Keycloak } from '../../../keycloak/src' let interceptorsConfigured = false @@ -32,8 +33,13 @@ export const configureApiInterceptors = () => { response => response, // If there is a 401 error we force re-authentication; otherwise we forward the error. - error => { + async error => { if (error?.response?.status === 401) { + try { + await Keycloak.clearAuthState() + } catch { + // keep resetting app auth even if native storage cleanup fails + } store.dispatch(resetAuthentication()) Sentry.setUser(null) } diff --git a/mobile/keycloak/README.md b/mobile/keycloak/README.md index b6a26a98a3..ed3d33c396 100644 --- a/mobile/keycloak/README.md +++ b/mobile/keycloak/README.md @@ -14,6 +14,7 @@ npx cap sync * [`authenticate(...)`](#authenticate) +* [`clearAuthState()`](#clearauthstate) * [`addListener(string, ...)`](#addlistenerstring-) * [Interfaces](#interfaces) @@ -39,6 +40,17 @@ Authenticate against a Keycloak provider. -------------------- +### clearAuthState() + +```typescript +clearAuthState() => Promise +``` + +Clear any stored native authentication state. + +-------------------- + + ### addListener(string, ...) ```typescript diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index 11495fd825..1057722a7f 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -31,6 +31,8 @@ public class Keycloak { public interface TokenRefreshCallback { void onTokenRefreshed(JSObject tokens); + + void onTokenRefreshFailed(String error); } public Keycloak(Context context) { @@ -146,6 +148,10 @@ public void execute( return true; } + public void clearStoredAuthState() { + clearAuthState(); + } + private void setupAutomaticRefresh() { refreshCheckRunnable = new Runnable() { @Override @@ -155,8 +161,26 @@ public void run() { refreshHandler.postDelayed(this, TOKEN_REFRESH_CHECK_INTERVAL); } }; - // Start checking after initial delay + resumeAutomaticRefresh(); + } + + public void pauseAutomaticRefresh() { + if (refreshHandler != null && refreshCheckRunnable != null) { + refreshHandler.removeCallbacks(refreshCheckRunnable); + Log.d(TAG, "Paused automatic token refresh"); + } + } + + public void resumeAutomaticRefresh() { + if (refreshHandler == null || refreshCheckRunnable == null) { + return; + } + + pauseAutomaticRefresh(); + checkAndRefreshToken(); + // schedule checks only while the app is in the foreground refreshHandler.postDelayed(refreshCheckRunnable, TOKEN_REFRESH_CHECK_INTERVAL); + Log.d(TAG, "Resumed automatic token refresh"); } private void checkAndRefreshToken() { @@ -191,6 +215,10 @@ public void execute( ) { if (exception != null) { Log.e(TAG, "Automatic token refresh failed: " + exception.getMessage()); + clearAuthState(); + if (tokenRefreshCallback != null) { + tokenRefreshCallback.onTokenRefreshFailed(exception.getLocalizedMessage()); + } return; } @@ -387,11 +415,7 @@ public void handleAuthCallback(Uri callbackUri) { * Clean up resources */ public void dispose() { - // Stop automatic refresh checking - if (refreshHandler != null && refreshCheckRunnable != null) { - refreshHandler.removeCallbacks(refreshCheckRunnable); - Log.d(TAG, "Stopped automatic token refresh"); - } + pauseAutomaticRefresh(); if (authService != null) { authService.dispose(); diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java index def58e5173..93924b7aaf 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java @@ -43,6 +43,14 @@ public void onTokenRefreshed(JSObject tokens) { Log.d(TAG, "Notifying JavaScript of token refresh"); notifyListeners("tokenRefresh", tokens); } + + @Override + public void onTokenRefreshFailed(String error) { + Log.d(TAG, "Notifying JavaScript of token refresh failure"); + JSObject errorResponse = new JSObject(); + errorResponse.put("error", error); + notifyListeners("tokenRefreshFailed", errorResponse); + } } ); @@ -70,9 +78,19 @@ public void handleAuthorizationResponse(Intent intent) { @Override protected void handleOnResume() { super.handleOnResume(); - // MainActivity now handles all authorization responses via onCreate/onNewIntent - // This handler is left empty to avoid duplicate processing - Log.d(TAG, "App resumed - MainActivity handles authorization responses"); + if (implementation != null) { + implementation.resumeAutomaticRefresh(); + } + Log.d(TAG, "App resumed - automatic token refresh resumed"); + } + + @Override + protected void handleOnPause() { + if (implementation != null) { + implementation.pauseAutomaticRefresh(); + } + Log.d(TAG, "App paused - automatic token refresh paused"); + super.handleOnPause(); } @Override @@ -83,6 +101,12 @@ protected void handleOnNewIntent(Intent intent) { Log.d(TAG, "New intent received - MainActivity handles authorization responses"); } + @PluginMethod + public void clearAuthState(PluginCall call) { + implementation.clearStoredAuthState(); + call.resolve(); + } + @PluginMethod public void authenticate(PluginCall call) { JSObject options = call.getData(); @@ -110,12 +134,17 @@ public void authenticate(PluginCall call) { return; } - if (implementation.authenticateWithStoredState(call, new Runnable() { - @Override - public void run() { - authenticate(call); - } - })) { + if ( + implementation.authenticateWithStoredState( + call, + new Runnable() { + @Override + public void run() { + authenticate(call); + } + } + ) + ) { return; } @@ -135,8 +164,8 @@ public void run() { ); authRequestBuilder - .setScope("openid profile offline_access") - .setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier()); + .setScope("openid profile offline_access") + .setCodeVerifier(CodeVerifierUtil.generateRandomCodeVerifier()); AuthorizationRequest authRequest = authRequestBuilder.build(); diff --git a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift index 1fef4905ff..ed700525e8 100644 --- a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift +++ b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift @@ -16,6 +16,8 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { private var authState: OIDAuthState? private let logger = Logger(subsystem: "com.bcgov.wps.keycloak", category: "authentication") private var tokenRefreshThreshold: TimeInterval = 60 // 1 minute before expiration + private var isAppInBackground = false + private var lifecycleObservers: [NSObjectProtocol] = [] // Services for dependency injection private let services: KeycloakServices @@ -23,7 +25,8 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { public let identifier = "KeycloakPlugin" public let jsName = "Keycloak" public let pluginMethods: [CAPPluginMethod] = [ - CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise) + CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "clearAuthState", returnType: CAPPluginReturnPromise), ] // Default initializer uses default services @@ -40,6 +43,7 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { public override func load() { super.load() + registerLifecycleObservers() guard let restoredAuthState = services.authStateStorageService.loadAuthState() else { return @@ -47,7 +51,9 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { if restoredAuthState.isAuthorized { authState = restoredAuthState - startTokenRefreshManager(authState: restoredAuthState) + if !isAppInBackground { + startTokenRefreshManager(authState: restoredAuthState) + } } else { services.authStateStorageService.clearAuthState() } @@ -83,6 +89,11 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { performAuthentication(parameters: parameters, call: call) } + @objc func clearAuthState(_ call: CAPPluginCall) { + clearStoredAuthState() + call.resolve() + } + private func refreshExistingAuthState( authState: OIDAuthState, call: CAPPluginCall, @@ -125,6 +136,10 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { } private func startTokenRefreshManager(authState: OIDAuthState) { + guard !isAppInBackground else { + return + } + services.tokenRefreshManagerService.startTokenRefreshManager( authState: authState, tokenRefreshThreshold: tokenRefreshThreshold, @@ -136,13 +151,92 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { self?.notifyListeners("tokenRefresh", data: tokenResponse) }, onTokenRefreshFailed: { [weak self] error in + self?.clearStoredAuthState() // notify JavaScript layer about refresh failure self?.notifyListeners("tokenRefreshFailed", data: ["error": error]) } ) } + private func registerLifecycleObservers() { + removeLifecycleObservers() + + let notificationCenter = NotificationCenter.default + lifecycleObservers = [ + notificationCenter.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleAppDidEnterBackground() + }, + notificationCenter.addObserver( + forName: UIApplication.didBecomeActiveNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.handleAppDidBecomeActive() + }, + ] + } + + private func removeLifecycleObservers() { + lifecycleObservers.forEach(NotificationCenter.default.removeObserver) + lifecycleObservers.removeAll() + } + + private func handleAppDidEnterBackground() { + isAppInBackground = true + services.tokenRefreshManagerService.stopTokenRefreshManager() + logger.debug("App entered background - automatic token refresh stopped") + } + + private func handleAppDidBecomeActive() { + isAppInBackground = false + refreshStoredAuthStateOnForeground() + } + + private func refreshStoredAuthStateOnForeground() { + guard let existingAuthState = authState ?? services.authStateStorageService.loadAuthState() + else { + return + } + + guard existingAuthState.isAuthorized else { + authState = nil + services.authStateStorageService.clearAuthState() + return + } + + services.tokenRefreshService.performTokenRefresh(authState: existingAuthState) { + [weak self] success, tokenResponse, error in + guard let self = self, !self.isAppInBackground else { return } + + if success { + self.authState = existingAuthState + self.services.authStateStorageService.saveAuthState(existingAuthState) + self.startTokenRefreshManager(authState: existingAuthState) + if let tokenResponse = tokenResponse { + self.notifyListeners("tokenRefresh", data: tokenResponse) + } + } else { + self.clearStoredAuthState() + self.notifyListeners( + "tokenRefreshFailed", + data: ["error": error ?? "Failed to refresh stored auth state"] + ) + } + } + } + + private func clearStoredAuthState() { + authState = nil + services.authStateStorageService.clearAuthState() + services.tokenRefreshManagerService.stopTokenRefreshManager() + } + deinit { + removeLifecycleObservers() services.tokenRefreshManagerService.stopTokenRefreshManager() } } diff --git a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift index 42a4ba529f..bf22c8ffe1 100644 --- a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift +++ b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift @@ -24,7 +24,8 @@ struct KeycloakPluginTests { @Test func testPluginMethods() { let methodNames = plugin.pluginMethods.map { $0.name } #expect(methodNames.contains("authenticate")) - #expect(plugin.pluginMethods.count == 1) + #expect(methodNames.contains("clearAuthState")) + #expect(plugin.pluginMethods.count == 2) } @Test func testPluginMethodReturnTypes() { diff --git a/mobile/keycloak/src/definitions.ts b/mobile/keycloak/src/definitions.ts index 3ba706418c..84e20a51a1 100644 --- a/mobile/keycloak/src/definitions.ts +++ b/mobile/keycloak/src/definitions.ts @@ -5,6 +5,10 @@ export interface KeycloakPlugin { * @returns {Promise} the authentication response */ authenticate(options: KeycloakOptions): Promise + /** + * Clear any stored native authentication state. + */ + clearAuthState(): Promise /** * Add a listener for plugin events, specifically for token refreshes. * @param {string} eventName diff --git a/mobile/keycloak/src/web.ts b/mobile/keycloak/src/web.ts index 42193782bf..3505637734 100644 --- a/mobile/keycloak/src/web.ts +++ b/mobile/keycloak/src/web.ts @@ -12,4 +12,6 @@ export class KeycloakWeb extends WebPlugin { errorDescription: 'Not implemented' } } + + async clearAuthState(): Promise {} } From c7c37acb27a2f0ac46dd2554b658d4c8a961c256 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 13:04:11 -0700 Subject: [PATCH 08/20] refresh --- .../src/slices/authenticationSlice.test.ts | 25 +----- .../asa-go/src/slices/authenticationSlice.ts | 6 -- .../asa-go/src/utils/axiosInterceptor.test.ts | 84 +++++++++++++++++-- mobile/asa-go/src/utils/axiosInterceptor.ts | 32 ++++++- mobile/keycloak/README.md | 14 ++++ .../ca/bcgov/plugins/keycloak/Keycloak.java | 47 ++++++++++- .../plugins/keycloak/KeycloakPlugin.java | 5 ++ .../KeycloakPlugin/KeycloakPlugin.swift | 66 ++++++++++++++- .../KeycloakPluginTests.swift | 3 +- mobile/keycloak/src/definitions.ts | 4 + mobile/keycloak/src/web.ts | 8 ++ 11 files changed, 249 insertions(+), 45 deletions(-) diff --git a/mobile/asa-go/src/slices/authenticationSlice.test.ts b/mobile/asa-go/src/slices/authenticationSlice.test.ts index 303fc3db7f..8cbdedd533 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.test.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.test.ts @@ -77,21 +77,16 @@ describe('authenticationSlice', () => { const setupTokenRefreshListener = (store: ReturnType) => { let tokenRefreshCallback: (tokenResponse: TokenResponse) => void = () => {} - let tokenRefreshFailedCallback: () => void = () => {} ;(Keycloak.addListener as Mock).mockImplementation((event, callback) => { if (event === 'tokenRefresh') { tokenRefreshCallback = callback } - if (event === 'tokenRefreshFailed') { - tokenRefreshFailedCallback = callback - } }) return { store, - tokenRefreshCallback: (response: TokenResponse) => tokenRefreshCallback(response), - tokenRefreshFailedCallback: () => tokenRefreshFailedCallback() + tokenRefreshCallback: (response: TokenResponse) => tokenRefreshCallback(response) } } @@ -317,7 +312,6 @@ describe('authenticationSlice', () => { await store.dispatch(authenticate()) expect(Keycloak.addListener).toHaveBeenCalledWith('tokenRefresh', expect.any(Function)) - expect(Keycloak.addListener).toHaveBeenCalledWith('tokenRefreshFailed', expect.any(Function)) }) it('should handle token refresh callback correctly', async () => { @@ -354,23 +348,6 @@ describe('authenticationSlice', () => { expect(finalState.tokenRefreshed).toBe(initialState.tokenRefreshed) expect(finalState.token).toBe(initialState.token) }) - - it('should reset authentication when token refresh fails', async () => { - const store = setupStoreWithMockAuth(createSuccessfulAuthResult()) - const { tokenRefreshFailedCallback } = setupTokenRefreshListener(store) - - await store.dispatch(authenticate()) - - tokenRefreshFailedCallback() - - expectAuthState(store.getState().authentication, { - sessionMode: 'login', - token: undefined, - idToken: undefined, - idir: undefined - }) - expect(mockSetUser).toHaveBeenCalledWith(null) - }) }) }) }) diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index 6782ec9df5..fcf7b3eb29 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -158,14 +158,8 @@ export const authenticate = (): AppThunk => dispatch => { } } - const handleTokenRefreshFailed = () => { - dispatch(resetAuthentication()) - Sentry.setUser(null) - } - // Set up event listener for token refresh events (works for both web and iOS) Keycloak.addListener('tokenRefresh', handleTokenRefresh) - Keycloak.addListener('tokenRefreshFailed', handleTokenRefreshFailed) } export const decodeUserDetails = (token: string | undefined) => { diff --git a/mobile/asa-go/src/utils/axiosInterceptor.test.ts b/mobile/asa-go/src/utils/axiosInterceptor.test.ts index 864636f6e4..85f09559f3 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.test.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.test.ts @@ -16,11 +16,15 @@ const setup = async ({ } = {}) => { vi.resetModules() + const axiosRequest = vi.fn().mockResolvedValue({ data: 'retried-response' }) const requestUse = vi.fn() const responseUse = vi.fn() const resetAuthenticationAction = { type: 'authentication/resetAuthentication' } + const authenticateFinishedAction = { + type: 'authentication/authenticateFinished' + } const store = { getState: vi.fn(() => ({ authentication: { sessionMode, token } })), dispatch: vi.fn() @@ -36,21 +40,30 @@ const setup = async ({ } ) const resetAuthentication = vi.fn(() => resetAuthenticationAction) + const authenticateFinished = vi.fn(() => authenticateFinishedAction) + const decodeUserDetails = vi.fn(() => ({ email: 'test@example.com' })) + const refreshAuthState = vi.fn().mockResolvedValue({ + isAuthenticated: true, + accessToken: 'new-token', + idToken: 'new-id-token' + }) const clearAuthState = vi.fn().mockResolvedValue(undefined) vi.doMock('@/api/axios', () => ({ - default: { + default: Object.assign(axiosRequest, { interceptors: { request: { use: requestUse }, response: { use: responseUse } } - } + }) })) vi.doMock('@/store', () => ({ store, selectAuthentication })) vi.doMock('@/slices/authenticationSlice', () => ({ + authenticateFinished, + decodeUserDetails, resetAuthentication })) vi.doMock('@/utils/env', () => ({ @@ -59,6 +72,7 @@ const setup = async ({ })) vi.doMock('../../../keycloak/src', () => ({ Keycloak: { + refreshAuthState, clearAuthState } })) @@ -67,9 +81,14 @@ const setup = async ({ return { configureApiInterceptors, + authenticateFinished, + authenticateFinishedAction, + axiosRequest, + decodeUserDetails, requestUse, resetAuthentication, responseUse, + refreshAuthState, clearAuthState, resetAuthenticationAction, store @@ -93,9 +112,19 @@ const runRequestInterceptor = ( } as InternalAxiosRequestConfig) } -const runErrorInterceptor = (responseUse: ReturnType, status: number) => { +const runErrorInterceptor = ( + responseUse: ReturnType, + status: number, + config: Partial = {} +) => { const errorInterceptor = responseUse.mock.calls[0][1] - const error = { response: { status } } + const error = { + config: { + headers: new AxiosHeaders(), + ...config + }, + response: { status } + } return { error, promise: errorInterceptor(error) } } @@ -146,22 +175,63 @@ describe('configureApiInterceptors', () => { expect(result.headers.get('Authorization')).toBeUndefined() }) - it('resets authentication on 401 responses', async () => { - const { clearAuthState, responseUse, resetAuthenticationAction, store } = await configure() + it('refreshes authentication and retries on 401 responses', async () => { + const { + authenticateFinishedAction, + axiosRequest, + clearAuthState, + refreshAuthState, + responseUse, + store + } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 401) + await expect(promise).resolves.toEqual({ data: 'retried-response' }) + + expect(refreshAuthState).toHaveBeenCalled() + expect(clearAuthState).not.toHaveBeenCalled() + expect(store.dispatch).toHaveBeenCalledWith(authenticateFinishedAction) + expect(error.config.headers.get('Authorization')).toBe('Bearer new-token') + expect(axiosRequest).toHaveBeenCalledWith(error.config) + }) + + it('resets authentication when refresh fails on 401 responses', async () => { + const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, store } = + await configure() + refreshAuthState.mockResolvedValue({ + isAuthenticated: false, + error: 'refresh_failed' + }) + const { error, promise } = runErrorInterceptor(responseUse, 401) + + await expect(promise).rejects.toBe(error) + + expect(refreshAuthState).toHaveBeenCalled() + expect(clearAuthState).toHaveBeenCalled() + expect(store.dispatch).toHaveBeenCalledWith(resetAuthenticationAction) + }) + + it('does not retry repeated 401 responses', async () => { + const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, store } = + await configure() + const { error, promise } = runErrorInterceptor(responseUse, 401, { + _retryAfterAuthRefresh: true + } as Partial) + await expect(promise).rejects.toBe(error) + expect(refreshAuthState).not.toHaveBeenCalled() expect(clearAuthState).toHaveBeenCalled() expect(store.dispatch).toHaveBeenCalledWith(resetAuthenticationAction) }) it('does not reset authentication for non-401 responses', async () => { - const { clearAuthState, responseUse, store } = await configure() + const { clearAuthState, refreshAuthState, responseUse, store } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 500) await expect(promise).rejects.toBe(error) + expect(refreshAuthState).not.toHaveBeenCalled() expect(clearAuthState).not.toHaveBeenCalled() expect(store.dispatch).not.toHaveBeenCalled() }) diff --git a/mobile/asa-go/src/utils/axiosInterceptor.ts b/mobile/asa-go/src/utils/axiosInterceptor.ts index b856cae7d5..ab63add1e3 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.ts @@ -1,13 +1,22 @@ import * as Sentry from '@sentry/capacitor' +import type { InternalAxiosRequestConfig } from 'axios' import { isNil } from 'lodash' import axios from '@/api/axios' -import { resetAuthentication } from '@/slices/authenticationSlice' +import { + authenticateFinished, + decodeUserDetails, + resetAuthentication +} from '@/slices/authenticationSlice' import { selectAuthentication, store } from '@/store' import { API_BASE_URL, API_PUBLIC_BASE_URL } from '@/utils/env' import { Keycloak } from '../../../keycloak/src' let interceptorsConfigured = false +interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig { + _retryAfterAuthRefresh?: boolean +} + export const configureApiInterceptors = () => { if (interceptorsConfigured) { return @@ -32,9 +41,28 @@ export const configureApiInterceptors = () => { // If there is a response we simply return it response => response, - // If there is a 401 error we force re-authentication; otherwise we forward the error. + // If there is a 401 error we try the offline token before forcing re-authentication. async error => { + const originalRequest = error?.config as RetriableAxiosRequestConfig | undefined if (error?.response?.status === 401) { + if (originalRequest && originalRequest._retryAfterAuthRefresh !== true) { + originalRequest._retryAfterAuthRefresh = true + const refreshResult = await Keycloak.refreshAuthState() + if (refreshResult.isAuthenticated && refreshResult.accessToken) { + store.dispatch( + authenticateFinished({ + token: refreshResult.accessToken, + idToken: refreshResult.idToken + }) + ) + const userDetails = decodeUserDetails(refreshResult.accessToken) + Sentry.setUser(userDetails ? { email: userDetails.email } : null) + originalRequest.baseURL = API_BASE_URL + originalRequest.headers.set('Authorization', `Bearer ${refreshResult.accessToken}`) + return axios(originalRequest) + } + } + try { await Keycloak.clearAuthState() } catch { diff --git a/mobile/keycloak/README.md b/mobile/keycloak/README.md index ed3d33c396..37e88af312 100644 --- a/mobile/keycloak/README.md +++ b/mobile/keycloak/README.md @@ -14,6 +14,7 @@ npx cap sync * [`authenticate(...)`](#authenticate) +* [`refreshAuthState()`](#refreshauthstate) * [`clearAuthState()`](#clearauthstate) * [`addListener(string, ...)`](#addlistenerstring-) * [Interfaces](#interfaces) @@ -40,6 +41,19 @@ Authenticate against a Keycloak provider. -------------------- +### refreshAuthState() + +```typescript +refreshAuthState() => Promise +``` + +Refresh the stored native authentication state without launching browser auth. + +**Returns:** Promise<KeycloakAuthResponse> + +-------------------- + + ### clearAuthState() ```typescript diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java index 1057722a7f..777b52c39b 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/Keycloak.java @@ -148,6 +148,52 @@ public void execute( return true; } + public void refreshStoredAuthState(PluginCall call) { + if (!authState.isAuthorized()) { + JSObject response = new JSObject(); + response.put("isAuthenticated", false); + response.put("error", "not_authenticated"); + response.put("errorDescription", "No authorized stored auth state available"); + call.resolve(response); + return; + } + + Log.d(TAG, "Refreshing stored auth state"); + authState.performActionWithFreshTokens( + authService, + new AuthState.AuthStateAction() { + @Override + public void execute( + @Nullable String accessToken, + @Nullable String idToken, + @Nullable AuthorizationException exception + ) { + if (exception != null) { + Log.e(TAG, "Stored auth state refresh failed: " + exception.getMessage()); + clearAuthState(); + JSObject response = new JSObject(); + response.put("isAuthenticated", false); + response.put("error", exception.error); + response.put("errorDescription", exception.getLocalizedMessage()); + call.resolve(response); + return; + } + + persistAuthState(); + call.resolve( + createSuccessResponse( + accessToken, + idToken, + "Bearer", + authState.getAccessTokenExpirationTime(), + authState.getScope() + ) + ); + } + } + ); + } + public void clearStoredAuthState() { clearAuthState(); } @@ -215,7 +261,6 @@ public void execute( ) { if (exception != null) { Log.e(TAG, "Automatic token refresh failed: " + exception.getMessage()); - clearAuthState(); if (tokenRefreshCallback != null) { tokenRefreshCallback.onTokenRefreshFailed(exception.getLocalizedMessage()); } diff --git a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java index 93924b7aaf..65a072c268 100644 --- a/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java +++ b/mobile/keycloak/android/src/main/java/ca/bcgov/plugins/keycloak/KeycloakPlugin.java @@ -107,6 +107,11 @@ public void clearAuthState(PluginCall call) { call.resolve(); } + @PluginMethod + public void refreshAuthState(PluginCall call) { + implementation.refreshStoredAuthState(call); + } + @PluginMethod public void authenticate(PluginCall call) { JSObject options = call.getData(); diff --git a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift index ed700525e8..0cd4878b3a 100644 --- a/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift +++ b/mobile/keycloak/ios/Sources/KeycloakPlugin/KeycloakPlugin.swift @@ -26,6 +26,7 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { public let jsName = "Keycloak" public let pluginMethods: [CAPPluginMethod] = [ CAPPluginMethod(name: "authenticate", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "refreshAuthState", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "clearAuthState", returnType: CAPPluginReturnPromise), ] @@ -94,6 +95,51 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { call.resolve() } + @objc func refreshAuthState(_ call: CAPPluginCall) { + guard let existingAuthState = authState ?? services.authStateStorageService.loadAuthState() + else { + call.resolve([ + "isAuthenticated": false, + "error": "not_authenticated", + "errorDescription": "No stored auth state available", + ]) + return + } + + guard existingAuthState.isAuthorized else { + clearStoredAuthState() + call.resolve([ + "isAuthenticated": false, + "error": "not_authenticated", + "errorDescription": "No authorized stored auth state available", + ]) + return + } + + services.tokenRefreshService.performTokenRefresh(authState: existingAuthState) { + [weak self] success, tokenResponse, error in + guard let self = self else { return } + + if success { + self.authState = existingAuthState + self.services.authStateStorageService.saveAuthState(existingAuthState) + self.startTokenRefreshManager(authState: existingAuthState) + call.resolve( + tokenResponse + ?? self.services.tokenResponseService.createTokenResponse( + from: existingAuthState) + ) + } else { + self.clearStoredAuthState() + call.resolve([ + "isAuthenticated": false, + "error": "refresh_failed", + "errorDescription": error ?? "Failed to refresh stored auth state", + ]) + } + } + } + private func refreshExistingAuthState( authState: OIDAuthState, call: CAPPluginCall, @@ -151,7 +197,6 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { self?.notifyListeners("tokenRefresh", data: tokenResponse) }, onTokenRefreshFailed: { [weak self] error in - self?.clearStoredAuthState() // notify JavaScript layer about refresh failure self?.notifyListeners("tokenRefreshFailed", data: ["error": error]) } @@ -193,10 +238,10 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { private func handleAppDidBecomeActive() { isAppInBackground = false - refreshStoredAuthStateOnForeground() + resumeStoredAuthStateOnForeground() } - private func refreshStoredAuthStateOnForeground() { + private func resumeStoredAuthStateOnForeground() { guard let existingAuthState = authState ?? services.authStateStorageService.loadAuthState() else { return @@ -208,6 +253,12 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { return } + if !shouldRefresh(authState: existingAuthState) { + authState = existingAuthState + startTokenRefreshManager(authState: existingAuthState) + return + } + services.tokenRefreshService.performTokenRefresh(authState: existingAuthState) { [weak self] success, tokenResponse, error in guard let self = self, !self.isAppInBackground else { return } @@ -220,7 +271,6 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { self.notifyListeners("tokenRefresh", data: tokenResponse) } } else { - self.clearStoredAuthState() self.notifyListeners( "tokenRefreshFailed", data: ["error": error ?? "Failed to refresh stored auth state"] @@ -229,6 +279,14 @@ public class KeycloakPlugin: CAPPlugin, CAPBridgedPlugin { } } + private func shouldRefresh(authState: OIDAuthState) -> Bool { + guard let expirationDate = authState.lastTokenResponse?.accessTokenExpirationDate else { + return false + } + + return expirationDate.timeIntervalSinceNow <= tokenRefreshThreshold + } + private func clearStoredAuthState() { authState = nil services.authStateStorageService.clearAuthState() diff --git a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift index bf22c8ffe1..393853e0fd 100644 --- a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift +++ b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift @@ -24,8 +24,9 @@ struct KeycloakPluginTests { @Test func testPluginMethods() { let methodNames = plugin.pluginMethods.map { $0.name } #expect(methodNames.contains("authenticate")) + #expect(methodNames.contains("refreshAuthState")) #expect(methodNames.contains("clearAuthState")) - #expect(plugin.pluginMethods.count == 2) + #expect(plugin.pluginMethods.count == 3) } @Test func testPluginMethodReturnTypes() { diff --git a/mobile/keycloak/src/definitions.ts b/mobile/keycloak/src/definitions.ts index 84e20a51a1..b32787534b 100644 --- a/mobile/keycloak/src/definitions.ts +++ b/mobile/keycloak/src/definitions.ts @@ -5,6 +5,10 @@ export interface KeycloakPlugin { * @returns {Promise} the authentication response */ authenticate(options: KeycloakOptions): Promise + /** + * Refresh the stored native authentication state without launching browser auth. + */ + refreshAuthState(): Promise /** * Clear any stored native authentication state. */ diff --git a/mobile/keycloak/src/web.ts b/mobile/keycloak/src/web.ts index 3505637734..29053868de 100644 --- a/mobile/keycloak/src/web.ts +++ b/mobile/keycloak/src/web.ts @@ -13,5 +13,13 @@ export class KeycloakWeb extends WebPlugin { } } + async refreshAuthState(): Promise { + return { + isAuthenticated: false, + error: 'not_implemented', + errorDescription: 'Not implemented' + } + } + async clearAuthState(): Promise {} } From 3897709450dc0195772d669fb29a59a0554b4c41 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 13:43:47 -0700 Subject: [PATCH 09/20] simplify --- mobile/asa-go/src/app.test.tsx | 1 - .../src/components/AuthWrapper.test.tsx | 3 - .../src/components/LoginActions.test.tsx | 1 - .../src/slices/authenticationSlice.test.ts | 72 +++++------------ .../asa-go/src/slices/authenticationSlice.ts | 80 ++++++------------- .../src/slices/pushNotificationSlice.test.ts | 2 - .../asa-go/src/utils/axiosInterceptor.test.ts | 16 ++-- mobile/asa-go/src/utils/axiosInterceptor.ts | 12 +-- mobile/keycloak/src/definitions.ts | 4 + 9 files changed, 62 insertions(+), 129 deletions(-) diff --git a/mobile/asa-go/src/app.test.tsx b/mobile/asa-go/src/app.test.tsx index 5b88ac32d8..1909de107d 100644 --- a/mobile/asa-go/src/app.test.tsx +++ b/mobile/asa-go/src/app.test.tsx @@ -312,7 +312,6 @@ describe('App', () => { authentication: { sessionMode: 'authenticated', authenticating: false, - tokenRefreshed: false, token: undefined, idToken: undefined, idir: undefined, diff --git a/mobile/asa-go/src/components/AuthWrapper.test.tsx b/mobile/asa-go/src/components/AuthWrapper.test.tsx index 703465d011..b2aaebc8e0 100644 --- a/mobile/asa-go/src/components/AuthWrapper.test.tsx +++ b/mobile/asa-go/src/components/AuthWrapper.test.tsx @@ -41,7 +41,6 @@ describe('AuthWrapper', () => { sessionMode: 'authenticated', authenticating: false, error: null, - tokenRefreshed: false, idToken: undefined, idir: undefined, token: 'test-token', @@ -58,7 +57,6 @@ describe('AuthWrapper', () => { sessionMode: 'login', authenticating: false, error: null, - tokenRefreshed: false, idToken: undefined, idir: undefined, token: 'test-token', @@ -79,7 +77,6 @@ describe('AuthWrapper', () => { sessionMode: 'login', authenticating: false, error: null, - tokenRefreshed: false, idToken: undefined, idir: undefined, token: undefined, diff --git a/mobile/asa-go/src/components/LoginActions.test.tsx b/mobile/asa-go/src/components/LoginActions.test.tsx index 6429949749..1c5803f1f1 100644 --- a/mobile/asa-go/src/components/LoginActions.test.tsx +++ b/mobile/asa-go/src/components/LoginActions.test.tsx @@ -26,7 +26,6 @@ const mockAuthState = (authenticating: boolean, error: string | null = null) => vi.spyOn(selectors, 'selectAuthentication').mockReturnValue({ authenticating, error, - tokenRefreshed: false, idToken: undefined, idir: undefined, token: undefined, diff --git a/mobile/asa-go/src/slices/authenticationSlice.test.ts b/mobile/asa-go/src/slices/authenticationSlice.test.ts index 8cbdedd533..4d8bc806a9 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.test.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.test.ts @@ -9,7 +9,6 @@ import authenticationSlice, { authenticateStart, continueAsGuest, initialState, - refreshTokenFinished, resetAuthentication } from '@/slices/authenticationSlice' import { createTestStore } from '@/testUtils' @@ -17,6 +16,7 @@ import { Keycloak } from '../../../keycloak/src' interface TokenResponse { accessToken: string + idToken?: string refreshToken?: string tokenType?: string expiresIn?: number @@ -61,6 +61,7 @@ describe('authenticationSlice', () => { const createTokenResponse = (overrides: Partial = {}): TokenResponse => ({ accessToken: mockValidToken, + idToken: 'new-id-token', refreshToken: 'new-refresh-token', tokenType: 'Bearer', expiresIn: 3600, @@ -157,7 +158,11 @@ describe('authenticationSlice', () => { it('should handle authenticateError', () => { const previousState = createAuthState({ authenticating: true, - sessionMode: 'authenticated' + sessionMode: 'authenticated', + token: 'existing-token', + idToken: 'existing-id-token', + idir: 'test-user', + email: 'test@example.com' }) const errorMessage = 'Authentication failed' @@ -166,66 +171,35 @@ describe('authenticationSlice', () => { expectAuthState(nextState, { sessionMode: 'login', authenticating: false, - error: errorMessage - }) - }) - - it('should handle refreshTokenFinished', () => { - const previousState = createAuthState({ - token: 'old-token', - idToken: 'old-id-token', - tokenRefreshed: false - }) - const payload = { - tokenRefreshed: true, - token: mockValidToken, - idToken: 'new-id-token' - } - - const nextState = authenticationSlice(previousState, refreshTokenFinished(payload)) - - expectAuthState(nextState, { - sessionMode: 'authenticated', - token: mockValidToken, - idToken: 'new-id-token', - tokenRefreshed: true - }) - }) - - it('should handle refreshTokenFinished with undefined tokens', () => { - const previousState = createAuthState({ - token: 'existing-token', - idToken: 'existing-id-token', - tokenRefreshed: false - }) - const payload = { - tokenRefreshed: false, + error: errorMessage, token: undefined, - idToken: undefined - } - - const nextState = authenticationSlice(previousState, refreshTokenFinished(payload)) - - expect(nextState.token).toBeUndefined() - expect(nextState.idToken).toBeUndefined() - expect(nextState.tokenRefreshed).toBe(false) + idToken: undefined, + idir: undefined, + email: undefined + }) }) it('should handle resetAuthentication', () => { const previousState = createAuthState({ sessionMode: 'authenticated', + authenticating: true, token: 'existing-token', idToken: 'existing-id-token', - idir: 'test-user' + idir: 'test-user', + email: 'test@example.com', + error: 'existing-error' }) const nextState = authenticationSlice(previousState, resetAuthentication()) expectAuthState(nextState, { sessionMode: 'login', + authenticating: false, token: undefined, idToken: undefined, - idir: undefined + idir: undefined, + email: undefined, + error: null }) }) }) @@ -325,10 +299,9 @@ describe('authenticationSlice', () => { tokenRefreshCallback(tokenResponse) expectAuthState(store.getState().authentication, { - tokenRefreshed: true, - token: mockValidToken + token: mockValidToken, + idToken: 'new-id-token' }) - expect(store.getState().authentication.idToken).toBeUndefined() expect(mockSetUser).toHaveBeenCalledWith({ email: 'john.doe@contact.com' }) }) @@ -345,7 +318,6 @@ describe('authenticationSlice', () => { tokenRefreshCallback(tokenResponse) const finalState = store.getState().authentication - expect(finalState.tokenRefreshed).toBe(initialState.tokenRefreshed) expect(finalState.token).toBe(initialState.token) }) }) diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index fcf7b3eb29..3a292add67 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -4,13 +4,13 @@ import { jwtDecode } from 'jwt-decode' import { isUndefined } from 'lodash' import type { AppThunk } from '@/store' import { Keycloak } from '../../../keycloak/src' +import type { KeycloakTokenResponse } from '../../../keycloak/src/definitions' export type AuthSessionMode = 'login' | 'guest' | 'authenticated' export interface AuthState { sessionMode: AuthSessionMode authenticating: boolean - tokenRefreshed: boolean token: string | undefined idToken: string | undefined idir: string | undefined @@ -21,7 +21,6 @@ export interface AuthState { export const initialState: AuthState = { sessionMode: 'login', authenticating: false, - tokenRefreshed: false, token: undefined, idToken: undefined, idir: undefined, @@ -33,13 +32,11 @@ const authSlice = createSlice({ name: 'authentication', initialState, reducers: { - continueAsGuest(state: AuthState) { - state.sessionMode = 'guest' - state.authenticating = false - state.token = undefined - state.idToken = undefined - state.idir = undefined - state.error = null + continueAsGuest() { + return { + ...initialState, + sessionMode: 'guest' as const + } }, authenticateStart(state: AuthState) { state.authenticating = true @@ -61,46 +58,20 @@ const authSlice = createSlice({ state.token = action.payload.token state.idToken = action.payload.idToken }, - authenticateError(state: AuthState, action: PayloadAction) { - state.authenticating = false - state.sessionMode = 'login' - state.error = action.payload - }, - refreshTokenFinished( - state: AuthState, - action: PayloadAction<{ - tokenRefreshed: boolean - token: string | undefined - idToken: string | undefined - }> - ) { - const userDetails = decodeUserDetails(action.payload.token) - state.idir = userDetails?.idir - state.email = userDetails?.email - state.token = action.payload.token - state.idToken = action.payload.idToken - state.tokenRefreshed = action.payload.tokenRefreshed - if (!isUndefined(action.payload.token)) { - state.sessionMode = 'authenticated' + authenticateError(_state: AuthState, action: PayloadAction) { + return { + ...initialState, + error: action.payload } }, - resetAuthentication(state: AuthState) { - state.sessionMode = 'login' - state.idToken = undefined - state.token = undefined - state.idir = undefined + resetAuthentication() { + return { ...initialState } } } }) -export const { - continueAsGuest, - authenticateStart, - authenticateFinished, - authenticateError, - refreshTokenFinished, - resetAuthentication -} = authSlice.actions +export const { continueAsGuest, authenticateStart, authenticateFinished, authenticateError, resetAuthentication } = + authSlice.actions export default authSlice.reducer @@ -126,8 +97,7 @@ export const authenticate = (): AppThunk => dispatch => { idToken: result.idToken }) ) - const userDetails = decodeUserDetails(result.accessToken) - Sentry.setUser(userDetails ? { email: userDetails.email } : null) + setSentryUserFromToken(result.accessToken) } else { dispatch(authenticateError(JSON.stringify(result.error))) } @@ -137,24 +107,15 @@ export const authenticate = (): AppThunk => dispatch => { }) // Handle token refresh callback function - const handleTokenRefresh = (tokenResponse: { - accessToken: string - idToken: string - refreshToken?: string - tokenType?: string - expiresIn?: number - scope?: string - }) => { + const handleTokenRefresh = (tokenResponse: KeycloakTokenResponse) => { if (tokenResponse.refreshToken) { dispatch( - refreshTokenFinished({ - tokenRefreshed: true, + authenticateFinished({ token: tokenResponse.accessToken, idToken: tokenResponse.idToken }) ) - const userDetails = decodeUserDetails(tokenResponse.accessToken) - Sentry.setUser(userDetails ? { email: userDetails.email } : null) + setSentryUserFromToken(tokenResponse.accessToken) } } @@ -175,3 +136,8 @@ export const decodeUserDetails = (token: string | undefined) => { return undefined } } + +export const setSentryUserFromToken = (token: string | undefined) => { + const userDetails = decodeUserDetails(token) + Sentry.setUser(userDetails ? { email: userDetails.email } : null) +} diff --git a/mobile/asa-go/src/slices/pushNotificationSlice.test.ts b/mobile/asa-go/src/slices/pushNotificationSlice.test.ts index 52a6e110bb..07388704b9 100644 --- a/mobile/asa-go/src/slices/pushNotificationSlice.test.ts +++ b/mobile/asa-go/src/slices/pushNotificationSlice.test.ts @@ -141,7 +141,6 @@ describe('pushNotificationSlice', () => { error: null, idir: 'test-user', authenticating: false, - tokenRefreshed: false, token: undefined, idToken: undefined, email: undefined @@ -212,7 +211,6 @@ describe('pushNotificationSlice', () => { error: null, idir: 'test-user', authenticating: false, - tokenRefreshed: false, token: undefined, idToken: undefined, email: undefined diff --git a/mobile/asa-go/src/utils/axiosInterceptor.test.ts b/mobile/asa-go/src/utils/axiosInterceptor.test.ts index 85f09559f3..a5893da285 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.test.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.test.ts @@ -41,7 +41,7 @@ const setup = async ({ ) const resetAuthentication = vi.fn(() => resetAuthenticationAction) const authenticateFinished = vi.fn(() => authenticateFinishedAction) - const decodeUserDetails = vi.fn(() => ({ email: 'test@example.com' })) + const setSentryUserFromToken = vi.fn() const refreshAuthState = vi.fn().mockResolvedValue({ isAuthenticated: true, accessToken: 'new-token', @@ -63,8 +63,8 @@ const setup = async ({ })) vi.doMock('@/slices/authenticationSlice', () => ({ authenticateFinished, - decodeUserDetails, - resetAuthentication + resetAuthentication, + setSentryUserFromToken })) vi.doMock('@/utils/env', () => ({ API_BASE_URL, @@ -84,10 +84,10 @@ const setup = async ({ authenticateFinished, authenticateFinishedAction, axiosRequest, - decodeUserDetails, requestUse, resetAuthentication, responseUse, + setSentryUserFromToken, refreshAuthState, clearAuthState, resetAuthenticationAction, @@ -182,6 +182,7 @@ describe('configureApiInterceptors', () => { clearAuthState, refreshAuthState, responseUse, + setSentryUserFromToken, store } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 401) @@ -191,12 +192,13 @@ describe('configureApiInterceptors', () => { expect(refreshAuthState).toHaveBeenCalled() expect(clearAuthState).not.toHaveBeenCalled() expect(store.dispatch).toHaveBeenCalledWith(authenticateFinishedAction) + expect(setSentryUserFromToken).toHaveBeenCalledWith('new-token') expect(error.config.headers.get('Authorization')).toBe('Bearer new-token') expect(axiosRequest).toHaveBeenCalledWith(error.config) }) it('resets authentication when refresh fails on 401 responses', async () => { - const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, store } = + const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, setSentryUserFromToken, store } = await configure() refreshAuthState.mockResolvedValue({ isAuthenticated: false, @@ -209,10 +211,11 @@ describe('configureApiInterceptors', () => { expect(refreshAuthState).toHaveBeenCalled() expect(clearAuthState).toHaveBeenCalled() expect(store.dispatch).toHaveBeenCalledWith(resetAuthenticationAction) + expect(setSentryUserFromToken).toHaveBeenCalledWith(undefined) }) it('does not retry repeated 401 responses', async () => { - const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, store } = + const { clearAuthState, refreshAuthState, responseUse, resetAuthenticationAction, setSentryUserFromToken, store } = await configure() const { error, promise } = runErrorInterceptor(responseUse, 401, { _retryAfterAuthRefresh: true @@ -223,6 +226,7 @@ describe('configureApiInterceptors', () => { expect(refreshAuthState).not.toHaveBeenCalled() expect(clearAuthState).toHaveBeenCalled() expect(store.dispatch).toHaveBeenCalledWith(resetAuthenticationAction) + expect(setSentryUserFromToken).toHaveBeenCalledWith(undefined) }) it('does not reset authentication for non-401 responses', async () => { diff --git a/mobile/asa-go/src/utils/axiosInterceptor.ts b/mobile/asa-go/src/utils/axiosInterceptor.ts index ab63add1e3..3cd3c68793 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.ts @@ -1,12 +1,7 @@ -import * as Sentry from '@sentry/capacitor' import type { InternalAxiosRequestConfig } from 'axios' import { isNil } from 'lodash' import axios from '@/api/axios' -import { - authenticateFinished, - decodeUserDetails, - resetAuthentication -} from '@/slices/authenticationSlice' +import { authenticateFinished, resetAuthentication, setSentryUserFromToken } from '@/slices/authenticationSlice' import { selectAuthentication, store } from '@/store' import { API_BASE_URL, API_PUBLIC_BASE_URL } from '@/utils/env' import { Keycloak } from '../../../keycloak/src' @@ -55,8 +50,7 @@ export const configureApiInterceptors = () => { idToken: refreshResult.idToken }) ) - const userDetails = decodeUserDetails(refreshResult.accessToken) - Sentry.setUser(userDetails ? { email: userDetails.email } : null) + setSentryUserFromToken(refreshResult.accessToken) originalRequest.baseURL = API_BASE_URL originalRequest.headers.set('Authorization', `Bearer ${refreshResult.accessToken}`) return axios(originalRequest) @@ -69,7 +63,7 @@ export const configureApiInterceptors = () => { // keep resetting app auth even if native storage cleanup fails } store.dispatch(resetAuthentication()) - Sentry.setUser(null) + setSentryUserFromToken(undefined) } return Promise.reject(error) } diff --git a/mobile/keycloak/src/definitions.ts b/mobile/keycloak/src/definitions.ts index b32787534b..aff494324a 100644 --- a/mobile/keycloak/src/definitions.ts +++ b/mobile/keycloak/src/definitions.ts @@ -115,6 +115,10 @@ export interface KeycloakTokenResponse { * The new access token */ accessToken: string + /** + * The ID token, if provided by the server. + */ + idToken?: string /** * The new refresh token (if provided) */ From 896567b908b5dc6a4a295b25c689bb089ead321d Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 14:10:00 -0700 Subject: [PATCH 10/20] test fix --- .../ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift index 393853e0fd..b366daa9c1 100644 --- a/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift +++ b/mobile/keycloak/ios/Tests/KeycloakPluginTests/KeycloakPluginTests.swift @@ -66,7 +66,7 @@ struct KeycloakPluginTests { #expect(pluginWithServices.identifier == "KeycloakPlugin") #expect(pluginWithServices.jsName == "Keycloak") - #expect(pluginWithServices.pluginMethods.count == 1) + #expect(pluginWithServices.pluginMethods.map { $0.name } == ["authenticate", "refreshAuthState", "clearAuthState"]) } @Test func testDefaultServicesInitialization() { From 7645bd4e2ef2b4783666c7d583382015915dc00f Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 14:24:25 -0700 Subject: [PATCH 11/20] simplify interceptor --- .../asa-go/src/slices/authenticationSlice.ts | 12 ++- mobile/asa-go/src/utils/axiosInterceptor.ts | 93 +++++++++++-------- 2 files changed, 62 insertions(+), 43 deletions(-) diff --git a/mobile/asa-go/src/slices/authenticationSlice.ts b/mobile/asa-go/src/slices/authenticationSlice.ts index 3a292add67..92c5b05b52 100644 --- a/mobile/asa-go/src/slices/authenticationSlice.ts +++ b/mobile/asa-go/src/slices/authenticationSlice.ts @@ -32,11 +32,13 @@ const authSlice = createSlice({ name: 'authentication', initialState, reducers: { - continueAsGuest() { - return { - ...initialState, - sessionMode: 'guest' as const - } + continueAsGuest(state: AuthState) { + state.sessionMode = 'guest' + state.authenticating = false + state.token = undefined + state.idToken = undefined + state.idir = undefined + state.error = null }, authenticateStart(state: AuthState) { state.authenticating = true diff --git a/mobile/asa-go/src/utils/axiosInterceptor.ts b/mobile/asa-go/src/utils/axiosInterceptor.ts index 3cd3c68793..1e3a9b73de 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.ts @@ -1,5 +1,4 @@ import type { InternalAxiosRequestConfig } from 'axios' -import { isNil } from 'lodash' import axios from '@/api/axios' import { authenticateFinished, resetAuthentication, setSentryUserFromToken } from '@/slices/authenticationSlice' import { selectAuthentication, store } from '@/store' @@ -12,6 +11,52 @@ interface RetriableAxiosRequestConfig extends InternalAxiosRequestConfig { _retryAfterAuthRefresh?: boolean } +const configureRequestBaseUrl = (config: InternalAxiosRequestConfig) => { + const { sessionMode, token } = selectAuthentication(store.getState()) + if (sessionMode === 'authenticated' && token != null) { + config.baseURL = API_BASE_URL + config.headers.set('Authorization', `Bearer ${token}`) + return config + } + + config.baseURL = `${API_PUBLIC_BASE_URL}/asa-go` + config.headers.delete('Authorization') + return config +} + +const retryWithRefreshedToken = async (request: RetriableAxiosRequestConfig | undefined) => { + if (!request || request._retryAfterAuthRefresh === true) { + return + } + + request._retryAfterAuthRefresh = true + const refreshResult = await Keycloak.refreshAuthState() + if (!refreshResult.isAuthenticated || !refreshResult.accessToken) { + return + } + + store.dispatch( + authenticateFinished({ + token: refreshResult.accessToken, + idToken: refreshResult.idToken + }) + ) + setSentryUserFromToken(refreshResult.accessToken) + request.baseURL = API_BASE_URL + request.headers.set('Authorization', `Bearer ${refreshResult.accessToken}`) + return axios(request) +} + +const resetStoredAuthentication = async () => { + try { + await Keycloak.clearAuthState() + } catch { + // keep resetting app auth even if native storage cleanup fails + } + store.dispatch(resetAuthentication()) + setSentryUserFromToken(undefined) +} + export const configureApiInterceptors = () => { if (interceptorsConfigured) { return @@ -19,18 +64,7 @@ export const configureApiInterceptors = () => { interceptorsConfigured = true - axios.interceptors.request.use(config => { - const { sessionMode, token } = selectAuthentication(store.getState()) - if (sessionMode === 'authenticated' && !isNil(token)) { - config.baseURL = API_BASE_URL - config.headers.set('Authorization', `Bearer ${token}`) - } else { - config.baseURL = `${API_PUBLIC_BASE_URL}/asa-go` - config.headers.delete('Authorization') - } - - return config - }) + axios.interceptors.request.use(configureRequestBaseUrl) axios.interceptors.response.use( // If there is a response we simply return it @@ -38,33 +72,16 @@ export const configureApiInterceptors = () => { // If there is a 401 error we try the offline token before forcing re-authentication. async error => { - const originalRequest = error?.config as RetriableAxiosRequestConfig | undefined - if (error?.response?.status === 401) { - if (originalRequest && originalRequest._retryAfterAuthRefresh !== true) { - originalRequest._retryAfterAuthRefresh = true - const refreshResult = await Keycloak.refreshAuthState() - if (refreshResult.isAuthenticated && refreshResult.accessToken) { - store.dispatch( - authenticateFinished({ - token: refreshResult.accessToken, - idToken: refreshResult.idToken - }) - ) - setSentryUserFromToken(refreshResult.accessToken) - originalRequest.baseURL = API_BASE_URL - originalRequest.headers.set('Authorization', `Bearer ${refreshResult.accessToken}`) - return axios(originalRequest) - } - } + if (error?.response?.status !== 401) { + return Promise.reject(error) + } - try { - await Keycloak.clearAuthState() - } catch { - // keep resetting app auth even if native storage cleanup fails - } - store.dispatch(resetAuthentication()) - setSentryUserFromToken(undefined) + const retryResponse = await retryWithRefreshedToken(error?.config as RetriableAxiosRequestConfig | undefined) + if (retryResponse) { + return retryResponse } + + await resetStoredAuthentication() return Promise.reject(error) } ) From e7a82cd1d8f4c7cf7297ab5ee88364cd0ac74174 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 14:30:24 -0700 Subject: [PATCH 12/20] iphone 17 --- .github/workflows/asa_go_integration.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/asa_go_integration.yml b/.github/workflows/asa_go_integration.yml index c49101ee28..6a0c6445ff 100644 --- a/.github/workflows/asa_go_integration.yml +++ b/.github/workflows/asa_go_integration.yml @@ -74,7 +74,7 @@ jobs: run: | xcodebuild test \ -scheme "Keycloak" \ - -destination "platform=iOS Simulator,name=iPhone 16 Pro" \ + -destination "platform=iOS Simulator,name=iPhone 17" \ -enableCodeCoverage YES \ -resultBundlePath ${{ runner.temp }}/keycloak.xcresult - name: Upload iOS coverage to Codecov From 334b8a62dc3ca4e05fe351fc1afaa43d5075463c Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Thu, 25 Jun 2026 14:31:11 -0700 Subject: [PATCH 13/20] smell --- mobile/asa-go/src/utils/axiosInterceptor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mobile/asa-go/src/utils/axiosInterceptor.ts b/mobile/asa-go/src/utils/axiosInterceptor.ts index 1e3a9b73de..8fc7aac51f 100644 --- a/mobile/asa-go/src/utils/axiosInterceptor.ts +++ b/mobile/asa-go/src/utils/axiosInterceptor.ts @@ -73,7 +73,7 @@ export const configureApiInterceptors = () => { // If there is a 401 error we try the offline token before forcing re-authentication. async error => { if (error?.response?.status !== 401) { - return Promise.reject(error) + throw error } const retryResponse = await retryWithRefreshedToken(error?.config as RetriableAxiosRequestConfig | undefined) @@ -82,7 +82,7 @@ export const configureApiInterceptors = () => { } await resetStoredAuthentication() - return Promise.reject(error) + throw error } ) } From e42a940b4203abd02b1ec7892be5f4c91dba2707 Mon Sep 17 00:00:00 2001 From: Brett Edwards Date: Fri, 26 Jun 2026 08:45:01 -0700 Subject: [PATCH 14/20] clearAuthState if guest --- .../src/components/PublicLoginButton.test.tsx | 8 ++-- .../src/components/PublicLoginButton.tsx | 4 +- .../src/slices/authenticationSlice.test.ts | 47 ++++++++++++++++++- .../asa-go/src/slices/authenticationSlice.ts | 13 ++++- .../asa-go/src/utils/axiosInterceptor.test.ts | 14 ++++++ mobile/asa-go/src/utils/axiosInterceptor.ts | 5 ++ 6 files changed, 82 insertions(+), 9 deletions(-) diff --git a/mobile/asa-go/src/components/PublicLoginButton.test.tsx b/mobile/asa-go/src/components/PublicLoginButton.test.tsx index a0195f150e..80f9967d48 100644 --- a/mobile/asa-go/src/components/PublicLoginButton.test.tsx +++ b/mobile/asa-go/src/components/PublicLoginButton.test.tsx @@ -3,7 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react' import { useDispatch } from 'react-redux' import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import PublicLoginButton from '@/components/PublicLoginButton' -import { continueAsGuest } from '@/slices/authenticationSlice' +import { continueAsGuestSession } from '@/slices/authenticationSlice' vi.mock('react-redux', async () => { const actual = await vi.importActual('react-redux') @@ -14,7 +14,7 @@ vi.mock('react-redux', async () => { }) vi.mock('@/slices/authenticationSlice', () => ({ - continueAsGuest: vi.fn(() => ({ type: 'CONTINUE_AS_GUEST' })) + continueAsGuestSession: vi.fn(() => ({ type: 'CONTINUE_AS_GUEST_SESSION' })) })) describe('PublicLoginButton', () => { @@ -39,11 +39,11 @@ describe('PublicLoginButton', () => { expect(screen.getByRole('button', { name: /continue as guest/i })).toBeInTheDocument() }) - it('dispatches continueAsGuest on click', () => { + it('dispatches continueAsGuestSession on click', () => { renderComponent() fireEvent.click(screen.getByRole('button', { name: /continue as guest/i })) - expect(mockDispatch).toHaveBeenCalledWith(continueAsGuest()) + expect(mockDispatch).toHaveBeenCalledWith(continueAsGuestSession()) }) }) diff --git a/mobile/asa-go/src/components/PublicLoginButton.tsx b/mobile/asa-go/src/components/PublicLoginButton.tsx index ca23e8d9d2..d39d16da65 100644 --- a/mobile/asa-go/src/components/PublicLoginButton.tsx +++ b/mobile/asa-go/src/components/PublicLoginButton.tsx @@ -1,12 +1,12 @@ import { Button } from '@mui/material' import { useDispatch } from 'react-redux' -import { continueAsGuest } from '@/slices/authenticationSlice' +import { continueAsGuestSession } from '@/slices/authenticationSlice' import type { AppDispatch } from '@/store' const PublicLoginButton = () => { const dispatch: AppDispatch = useDispatch() const handleClick = () => { - dispatch(continueAsGuest()) + dispatch(continueAsGuestSession()) } return (