From b863b2096a2d1ba9a9cbb6b909ba79aed4f34915 Mon Sep 17 00:00:00 2001 From: Nikos Douvlis Date: Thu, 30 Jul 2026 13:57:18 +0300 Subject: [PATCH] Add Session Minter support to the token fetch path Native apps will feed their previous session token to Clerk's edge Session Minter, which mints the next token from it instead of reading the origin DB. For the edge to mint, the SDK has to send that previous token; today the tokens request is a bodyless POST that sends nothing the edge can use. The tokens request now attaches the previous session token as a token form field and maps skipCache to force_origin=true, both only when the environment's auth_config.session_minter flag is on, so the request is unchanged for instances that are not enrolled and for the templated route. The seed is the cached token for the session, falling back to the current client's lastActiveToken looked up by session id on a cold start, and is never sent empty. Because the previous token becomes the input to the next mint, a stale local token no longer self-corrects, and the pre-existing token cache had several races that only mattered once the token was a minting seed. The cache is made monotonic (an older oiat, or an unreadable JWT, never overwrites a newer entry, matching clerk-js pickFreshestJwt). Concurrent getToken calls dedupe through a shared task map, and a caller only joins an in-flight task started under the current invalidation generation, so a request begun before a sign-out or org switch can neither be joined nor write its result afterward. skipCache callers get their own task key and bypass the failure backoff, which is otherwise bounded and cleared on invalidation, all under the cache write lock so a stale outcome cannot open a window against post-switch state. A configuration epoch captured at admission and rechecked before caching discards a token minted under a configuration that reset or switchConfiguration has since replaced. The seed is kept out of debug body logs via a sensitive-request tag. --- .../src/main/kotlin/com/clerk/api/Clerk.kt | 13 + .../main/kotlin/com/clerk/api/Constants.kt | 2 + .../main/kotlin/com/clerk/api/auth/Auth.kt | 38 +- .../com/clerk/api/network/api/SessionApi.kt | 29 + .../network/model/environment/AuthConfig.kt | 10 +- .../com/clerk/api/session/JwtFreshness.kt | 64 ++ .../clerk/api/session/SessionTokenBackoff.kt | 69 ++ .../clerk/api/session/SessionTokenFetcher.kt | 322 ++++++-- .../clerk/api/session/SessionTokensCache.kt | 104 ++- .../com/clerk/api/signout/SignOutService.kt | 2 + .../test/java/com/clerk/api/auth/AuthTest.kt | 106 +++ .../clerk/api/network/api/SessionApiTest.kt | 173 ++++ .../AuthConfigSerializationTest.kt | 45 + .../api/session/SessionTokenBackoffTest.kt | 122 +++ .../api/session/SessionTokenFetcherTest.kt | 768 ++++++++++++++++-- .../api/session/SessionTokensCacheTest.kt | 313 +++++++ .../clerk/api/signout/SignOutServiceTest.kt | 29 + 17 files changed, 2097 insertions(+), 112 deletions(-) create mode 100644 source/api/src/main/kotlin/com/clerk/api/session/JwtFreshness.kt create mode 100644 source/api/src/main/kotlin/com/clerk/api/session/SessionTokenBackoff.kt create mode 100644 source/api/src/test/java/com/clerk/api/network/api/SessionApiTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/network/model/environment/AuthConfigSerializationTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/session/SessionTokenBackoffTest.kt create mode 100644 source/api/src/test/java/com/clerk/api/session/SessionTokensCacheTest.kt diff --git a/source/api/src/main/kotlin/com/clerk/api/Clerk.kt b/source/api/src/main/kotlin/com/clerk/api/Clerk.kt index a57637671..01543b5f2 100644 --- a/source/api/src/main/kotlin/com/clerk/api/Clerk.kt +++ b/source/api/src/main/kotlin/com/clerk/api/Clerk.kt @@ -31,6 +31,7 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.organizations.Organization import com.clerk.api.organizations.OrganizationMembership import com.clerk.api.session.Session +import com.clerk.api.session.SessionTokenFetcher import com.clerk.api.session.SessionTokensCache import com.clerk.api.sharedsession.SharedSessionSyncCoordinator import com.clerk.api.sharedsession.SharedSessionSyncProvider @@ -334,6 +335,14 @@ object Clerk { val deleteSelfIsEnabled: Boolean get() = environment?.userSettings?.actions?.deleteSelf ?: false + /** + * Whether the current environment reports that this instance is served by the session minter. + * + * Session token requests only carry the previous session token while this is true. + */ + internal val sessionMinterIsEnabled: Boolean + get() = environment?.authConfig?.sessionMinter ?: false + val organizationCreationDefaultsIsEnabled: Boolean get() = environment?.organizationSettings?.organizationCreationDefaults?.enabled ?: false @@ -757,6 +766,10 @@ object Clerk { DeviceAttestationHelper.clearCache() LocaleProvider.cleanup() ClerkApi.reset() + // Bump the configuration epoch and tear down the fetch scope last, after the Retrofit service + // is reset, so any in-flight token request that could still reach the old service captured the + // old epoch and has its result discarded instead of caching an old-configuration token. + SessionTokenFetcher.resetForNewConfiguration() updateClient(Client()) _clientFlow.value = null lastClientServerFetchAtMillis = null diff --git a/source/api/src/main/kotlin/com/clerk/api/Constants.kt b/source/api/src/main/kotlin/com/clerk/api/Constants.kt index dac348c46..57265e063 100644 --- a/source/api/src/main/kotlin/com/clerk/api/Constants.kt +++ b/source/api/src/main/kotlin/com/clerk/api/Constants.kt @@ -42,6 +42,8 @@ object Constants { const val EXPONENTIAL_BACKOFF_SHIFT = 1 const val DEFAULT_EXPIRATION_BUFFER = 1000L const val COMPRESSION_PERCENTAGE = 75 + const val TOKEN_FETCH_BACKOFF_BASE_MS = 5_000L + const val TOKEN_FETCH_BACKOFF_MAX_MS = 60_000L } /** URL and key prefixes */ diff --git a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt index 07ea04d40..c07237187 100644 --- a/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt +++ b/source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt @@ -31,6 +31,7 @@ import com.clerk.api.network.serialization.onFailure import com.clerk.api.passkeys.PasskeyService import com.clerk.api.session.GetTokenOptions import com.clerk.api.session.Session +import com.clerk.api.session.SessionTokensCache import com.clerk.api.session.fetchToken import com.clerk.api.session.revoke import com.clerk.api.signin.SignIn @@ -639,6 +640,7 @@ class Auth internal constructor() { if (sessionId != null) { when (val result = ClerkApi.session.removeSession(sessionId)) { is ClerkResult.Success -> { + SessionTokensCache.removeTokensForSession(sessionId) removeSessionLocally(sessionId) refreshClientAfterSessionMutation() ClerkResult.success(Unit) @@ -673,20 +675,38 @@ class Auth internal constructor() { private suspend fun refreshClientAfterSessionMutation( activeSessionFallbackId: String? = null, fallbackClient: Client? = null, + droppedTokenSessionId: String? = null, ) { when (val clientResult = Client.get()) { is ClerkResult.Success -> Clerk.updateClient( - clientResult.value.withActiveSessionFallback( - activeSessionFallbackId = activeSessionFallbackId, - fallbackClient = fallbackClient, - ) + clientResult.value + .withActiveSessionFallback( + activeSessionFallbackId = activeSessionFallbackId, + fallbackClient = fallbackClient, + ) + .withoutLastActiveToken(droppedTokenSessionId) ) is ClerkResult.Failure -> ClerkLog.w("Client refresh after session mutation failed: ${clientResult.errorMessage}") } } + /** + * Drops the persisted token of one session. + * + * Both merge paths above carry `lastActiveToken` forward from the pre-mutation session, and the + * token fetcher falls back to it when the cache is empty. Left in place after an org switch it + * would seed the next mint with the old organization scope, undoing the cache invalidation. + */ + private fun Client.withoutLastActiveToken(sessionId: String?): Client { + if (sessionId == null) return this + + val updatedSessions = + sessions.map { if (it.id == sessionId) it.copy(lastActiveToken = null) else it } + return if (updatedSessions == sessions) this else copy(sessions = updatedSessions) + } + private fun Client.withActiveSessionFallback( activeSessionFallbackId: String?, fallbackClient: Client?, @@ -773,6 +793,9 @@ class Auth internal constructor() { ) when (result) { is ClerkResult.Success -> { + // The cached token still carries the previous organization scope, and it seeds the next + // mint, so a stale entry would outlive the switch. + SessionTokensCache.removeTokensForSession(sessionId) setActiveSessionLocally( sessionId, activeSession = result.value, @@ -782,6 +805,7 @@ class Auth internal constructor() { refreshClientAfterSessionMutation( activeSessionFallbackId = sessionId, fallbackClient = if (Clerk.clientInitialized) Clerk.client else previousClient, + droppedTokenSessionId = sessionId, ) } is ClerkResult.Failure -> emitAuthError(result) @@ -799,7 +823,11 @@ class Auth internal constructor() { val sessions = client.sessions.withUpdatedSession(activeSession, activeOrganizationId) if (sessions.none { it.id == sessionId }) return - Clerk.updateClient(client.copy(sessions = sessions, lastActiveSessionId = sessionId)) + Clerk.updateClient( + client + .copy(sessions = sessions, lastActiveSessionId = sessionId) + .withoutLastActiveToken(sessionId) + ) } private fun List.withUpdatedSession( diff --git a/source/api/src/main/kotlin/com/clerk/api/network/api/SessionApi.kt b/source/api/src/main/kotlin/com/clerk/api/network/api/SessionApi.kt index d4efdb484..6249479c4 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/api/SessionApi.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/api/SessionApi.kt @@ -2,6 +2,7 @@ package com.clerk.api.network.api import com.clerk.api.network.ApiParams import com.clerk.api.network.ApiPaths +import com.clerk.api.network.middleware.SensitiveRequest import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.token.TokenResource @@ -9,12 +10,14 @@ import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.session.Session import com.clerk.api.session.SessionVerification import retrofit2.http.DELETE +import retrofit2.http.Field import retrofit2.http.FieldMap import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query +import retrofit2.http.Tag /** * Internal API interface for session management operations. @@ -80,12 +83,38 @@ internal interface SessionApi { @Path(ApiParams.ID) sessionId: String ): ClerkResult + /** + * Retrieves tokens for a specific session, carrying the session minter's form fields. + * + * Same route as [tokens], but form-encoded so the minter at the edge can read the seed. Only used + * when at least one field is actually populated, so a request with nothing to send stays byte for + * byte the request [tokens] has always made. + * + * @param sessionId The unique identifier of the session to get tokens for + * @param previousSessionToken The JWT this session last held, which the next token is minted + * from. Omitted rather than sent empty when there is no seed. + * @param forceOrigin Set to true when the caller asked to skip the cache, telling the minter to + * fall through to the origin. + * @return A [ClerkResult] containing the [TokenResource] on success, or a [ClerkErrorResponse] on + * failure + */ + @FormUrlEncoded + @POST(ApiPaths.Client.Sessions.TOKENS) + suspend fun mintTokens( + @Path(ApiParams.ID) sessionId: String, + @Field("token") previousSessionToken: String? = null, + @Field("force_origin") forceOrigin: Boolean? = null, + @Tag sensitiveRequest: SensitiveRequest = SensitiveRequest, + ): ClerkResult + /** * Retrieves tokens for a specific user and template type. * * This method fetches authentication tokens for a specific user using a template type, which * allows for customized token generation based on the template configuration. * + * Templated tokens are never minted at the edge, so this route never carries minter fields. + * * @param userId The unique identifier of the user * @param templateType The type of template to use for token generation * @return A [ClerkResult] containing the [TokenResource] on success, or a [ClerkErrorResponse] on diff --git a/source/api/src/main/kotlin/com/clerk/api/network/model/environment/AuthConfig.kt b/source/api/src/main/kotlin/com/clerk/api/network/model/environment/AuthConfig.kt index 365e50a8d..faa6bdee6 100644 --- a/source/api/src/main/kotlin/com/clerk/api/network/model/environment/AuthConfig.kt +++ b/source/api/src/main/kotlin/com/clerk/api/network/model/environment/AuthConfig.kt @@ -11,6 +11,8 @@ import kotlinx.serialization.Serializable * * @property singleSessionMode Whether the application is configured for single session mode. When * true, only one active session is allowed per user at a time. + * @property sessionMinter Whether the instance is served by the session minter. When true, session + * token requests carry the previous session token so the edge can mint the next one. */ @Serializable internal data class AuthConfig( @@ -18,5 +20,11 @@ internal data class AuthConfig( * Whether the application is configured for single session mode. When true, only one active * session is allowed per user at a time. */ - @SerialName("single_session_mode") val singleSessionMode: Boolean + @SerialName("single_session_mode") val singleSessionMode: Boolean, + + /** + * Whether the instance is served by the session minter. When true, session token requests carry + * the previous session token so the edge can mint the next one. + */ + @SerialName("session_minter") val sessionMinter: Boolean = false, ) diff --git a/source/api/src/main/kotlin/com/clerk/api/session/JwtFreshness.kt b/source/api/src/main/kotlin/com/clerk/api/session/JwtFreshness.kt new file mode 100644 index 000000000..b06cd963a --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/session/JwtFreshness.kt @@ -0,0 +1,64 @@ +package com.clerk.api.session + +import com.auth0.android.jwt.JWT +import java.util.concurrent.TimeUnit + +/** + * Orders two session JWTs by how recently their claims were assembled. + * + * Minted tokens carry `oiat` (original issued at) in the protected header. It survives re-minting, + * so it is the primary ordering claim; the payload's `iat` only breaks a tie between two tokens + * assembled at the same instant. A token without `oiat` comes from a pre-minter code path and is + * staler than any token that has one. + * + * This mirrors clerk-js `pickFreshestJwt`, so web and Android agree on which of two tokens wins. + */ +internal object JwtFreshness { + + private const val ORIGINAL_ISSUED_AT_HEADER = "oiat" + + /** Whether the JWT can be decoded at all. An undecodable JWT is never sent or ranked. */ + internal fun isReadable(jwt: String): Boolean = claimsOf(jwt) != null + + /** + * Whether [incoming] should replace [existing]. + * + * A full tie accepts [incoming]: two tokens assembled at the same instant can still differ in + * other claims, so only a strictly fresher [existing] suppresses the write. An unreadable + * [incoming] never wins over a readable [existing]. + */ + internal fun incomingWins(existing: String, incoming: String): Boolean { + val existingClaims = claimsOf(existing) + val incomingClaims = claimsOf(incoming) + return when { + existingClaims == null -> true + incomingClaims == null -> false + else -> incomingIsAtLeastAsFresh(existing = existingClaims, incoming = incomingClaims) + } + } + + private fun incomingIsAtLeastAsFresh(existing: Claims, incoming: Claims): Boolean { + val existingOriginalIssuedAt = existing.originalIssuedAt + val incomingOriginalIssuedAt = incoming.originalIssuedAt + return when { + existingOriginalIssuedAt == null && incomingOriginalIssuedAt == null -> true + incomingOriginalIssuedAt == null -> false + existingOriginalIssuedAt == null -> true + existingOriginalIssuedAt != incomingOriginalIssuedAt -> + existingOriginalIssuedAt < incomingOriginalIssuedAt + else -> (existing.issuedAt ?: 0L) <= (incoming.issuedAt ?: 0L) + } + } + + private fun claimsOf(jwt: String): Claims? = + runCatching { + val decoded = JWT(jwt) + Claims( + originalIssuedAt = decoded.header[ORIGINAL_ISSUED_AT_HEADER]?.toLongOrNull(), + issuedAt = decoded.issuedAt?.let { TimeUnit.MILLISECONDS.toSeconds(it.time) }, + ) + } + .getOrNull() + + private data class Claims(val originalIssuedAt: Long?, val issuedAt: Long?) +} diff --git a/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenBackoff.kt b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenBackoff.kt new file mode 100644 index 000000000..b2e7010ba --- /dev/null +++ b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenBackoff.kt @@ -0,0 +1,69 @@ +package com.clerk.api.session + +import com.clerk.api.Constants.Config.TOKEN_FETCH_BACKOFF_BASE_MS +import com.clerk.api.Constants.Config.TOKEN_FETCH_BACKOFF_MAX_MS +import java.util.concurrent.ConcurrentHashMap + +/** + * Per-cache-key failure backoff for session token requests. + * + * The SDK wakes every few seconds to refresh the active session token. Without a backoff a failing + * endpoint is re-POSTed on every wake for as long as the app stays in the foreground, so each + * consecutive failure doubles the window during which the fetch layer refuses to send another + * request. + * + * The failure count outlives the window it opened: an expired window means another request may go + * out, not that the endpoint recovered. Only a success or a deliberate state change (sign-out, org + * switch, SDK reset) resets the schedule. Dropping the count at the deadline instead would restart + * every key at five seconds and leave the wake loop hammering forever. + * + * @param clock Source of the current time in milliseconds. Injectable for tests. + */ +internal class SessionTokenBackoff(private val clock: () -> Long = System::currentTimeMillis) { + + private val windows = ConcurrentHashMap() + + /** Whether a request for [cacheKey] is still inside the window opened by its last failure. */ + internal fun isBackingOff(cacheKey: String): Boolean { + val window = windows[cacheKey] ?: return false + return clock() < window.retryAtMillis + } + + /** Opens the next, wider window for [cacheKey]. */ + internal fun recordFailure(cacheKey: String) { + windows.compute(cacheKey) { _, existing -> + val failures = (existing?.failures ?: 0) + 1 + Window(failures = failures, retryAtMillis = clock() + delayMillis(failures)) + } + } + + /** Closes the window for [cacheKey] and resets its schedule. */ + internal fun recordSuccess(cacheKey: String) { + windows.remove(cacheKey) + } + + /** Resets the schedule for a session's default and templated keys. */ + internal fun clearForSession(sessionId: String) { + windows.keys.removeAll { it == sessionId || it.startsWith("$sessionId-") } + } + + /** Resets every schedule. */ + internal fun clear() { + windows.clear() + } + + private fun delayMillis(failures: Int): Long { + val exponent = (failures - 1).coerceAtMost(EXPONENT_CEILING) + return (TOKEN_FETCH_BACKOFF_BASE_MS shl exponent).coerceAtMost(TOKEN_FETCH_BACKOFF_MAX_MS) + } + + private data class Window(val failures: Int, val retryAtMillis: Long) + + internal companion object { + /** Keeps the shift below Long overflow; the delay is capped well before this bites. */ + private const val EXPONENT_CEILING = 16 + + /** The schedule the SDK uses. Tests build their own with a fake clock. */ + internal val shared = SessionTokenBackoff() + } +} diff --git a/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenFetcher.kt b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenFetcher.kt index 462881d8b..de8da5a08 100644 --- a/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenFetcher.kt +++ b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokenFetcher.kt @@ -8,9 +8,15 @@ import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.token.TokenResource import com.clerk.api.network.serialization.ClerkResult import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.cancel /** * Internal service for fetching and managing session tokens. @@ -21,13 +27,30 @@ import kotlinx.coroutines.Deferred * network requests. * * The fetcher uses a concurrent task map to prevent multiple simultaneous requests for the same - * token, improving performance and reducing server load. + * token, improving performance and reducing server load. The map is shared across instances, + * because callers construct a fetcher per call. * * @param jwtManager The JWT manager used for token parsing and validation + * @param backoff Failure backoff shared across instances, keyed by token cache key + * @param fetchScope Scope that owns the shared request, so one caller walking away cannot cancel + * the request every other caller is waiting on */ -internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManagerImpl()) { - private companion object { - val sessionInvalidationErrorCodes = +internal class SessionTokenFetcher( + private val jwtManager: JWTManager = JWTManagerImpl(), + private val backoff: SessionTokenBackoff = SessionTokenBackoff.shared, + private val fetchScope: CoroutineScope = activeFetchScope(), +) { + /** An in-flight request together with the invalidation generation it was started under. */ + private class TokenTask( + val generation: SessionTokensCache.Generation, + val deferred: Deferred, + ) + + companion object { + /** Task-key prefix that keeps a forced request from joining a plain one already in flight. */ + private const val FORCED_TASK_KEY_PREFIX = "force:" + + private val sessionInvalidationErrorCodes = setOf( "session_revoked", "session_expired", @@ -38,10 +61,81 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag "session_invalid", "authentication_invalid", ) - } - /** Map of cache keys to deferred token fetch tasks for request deduplication */ - private val tokenTasks = ConcurrentHashMap>() + /** + * Map of task keys to deferred token fetch tasks for request deduplication. + * + * Shared state: a per-instance map would never deduplicate anything because every call site + * builds a new fetcher. + */ + private val tokenTasks = ConcurrentHashMap() + + private fun newFetchScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val scopeLock = Any() + + /** + * Monotonic counter bumped whenever the configuration is tectonically replaced (via + * [com.clerk.api.Clerk.reset] / switchConfiguration). A request captures it at admission and + * rechecks it before caching or returning its result: a token minted under a configuration that + * has since been replaced is discarded rather than leaked into the new one. + */ + private val configurationEpoch = AtomicLong() + + /** Scope that owns the shared request; replaced wholesale on [resetForNewConfiguration]. */ + private var sharedFetchScope = newFetchScope() + + /** The current scope, read under the swap lock so a caller never sees a torn value. */ + private fun activeFetchScope(): CoroutineScope = synchronized(scopeLock) { sharedFetchScope } + + /** + * Bumps the configuration epoch, drops the dedupe map, installs a fresh scope, then cancels the + * old one. + * + * Called from [com.clerk.api.Clerk.reset] as the final teardown step, after the Retrofit + * service is reset, so any request that could still reach the old service was admitted before + * this bump and therefore captured the old epoch: its result is discarded rather than cached. + * The epoch bump and the scope swap share [scopeLock], so a caller that observes the fresh + * scope also observes the new epoch. The fresh scope is published before the old one is + * cancelled, so a concurrent reader of [activeFetchScope] never lands on the cancelled scope. + */ + internal fun resetForNewConfiguration() { + tokenTasks.clear() + val previous = + synchronized(scopeLock) { + configurationEpoch.incrementAndGet() + val old = sharedFetchScope + sharedFetchScope = newFetchScope() + old + } + previous.cancel() + } + + /** The scope in-flight requests run on. Exposed for tests to observe the reset. */ + internal val currentFetchScope: CoroutineScope + get() = activeFetchScope() + + /** + * The JWT this session last held, which the minter mints the next token from. + * + * The cache is read with the same key the response will be written to, so a templated token + * can never seed the default request; the current client's persisted `lastActiveToken` covers + * a cold start. The fallback is looked up on the live client by session id, never on a + * [Session] instance the caller may have retained from before an org switch, whose token would + * carry the old scope past the same-org edge check. A blank or undecodable candidate is skipped + * rather than sent, and does not block the one behind it. + */ + private fun mintingSeed(sessionId: String, cacheKey: String): String? { + val currentSessionToken = + if (Clerk.clientInitialized) { + Clerk.client.sessions.firstOrNull { it.id == sessionId }?.lastActiveToken?.jwt + } else { + null + } + return listOfNotNull(SessionTokensCache.getToken(cacheKey)?.jwt, currentSessionToken) + .firstOrNull { it.isNotBlank() && JwtFreshness.isReadable(it) } + } + } /** * Retrieves a token for the specified session with the given options. @@ -77,28 +171,59 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag options: GetTokenOptions, ): TokenResource? { val cacheKey = session.tokenCacheKey(options.template) + val taskKey = if (options.skipCache) "$FORCED_TASK_KEY_PREFIX$cacheKey" else cacheKey + val generation = SessionTokensCache.generation(session.id) + val epoch = configurationEpoch.get() ClerkLog.d( "Fetching token for session ${session.id} with options: $options and cache key: $cacheKey" ) - return tokenTasks[cacheKey]?.await() - ?: run { - val deferred = CompletableDeferred() - val existingTask = tokenTasks.putIfAbsent(cacheKey, deferred) - - existingTask?.await() - ?: try { - fetchToken(session, options).also { deferred.complete(it) } - } catch (e: CancellationException) { - deferred.cancel(e) - throw e - } catch (t: Throwable) { - deferred.completeExceptionally(t) - throw t - } finally { - tokenTasks.remove(cacheKey, deferred) - } + // Awaiting rather than running the request inline: a caller that is cancelled while waiting + // detaches from the shared request instead of cancelling it for everyone else. + return sharedTask(taskKey, session, options, generation, epoch).await() + } + + /** + * Returns the in-flight request for [taskKey], or starts one. + * + * A caller only joins a running task whose generation still matches the generation read inside + * the atomic map update, not the caller's earlier-captured one: a task begun before a sign-out or + * org switch would return a token carrying the previous state, so a caller that reaches the map + * after the switch must start its own request. The caller's earlier-captured [generation] still + * fences the task it installs (its cache write and backoff record). + * + * A task whose deferred is cancelling is never joinable: it is checked with `isActive`, not + * `!isCompleted`, so a cancelling task left in the map by an in-progress reset cannot hand a + * fresh caller a [CancellationException]. + */ + private fun sharedTask( + taskKey: String, + session: Session, + options: GetTokenOptions, + generation: SessionTokensCache.Generation, + epoch: Long, + ): Deferred { + val started = + fetchScope.async(start = CoroutineStart.LAZY) { + fetchToken(session, options, generation, epoch) } + val newTask = TokenTask(generation, started) + val winner = + tokenTasks.compute(taskKey) { _, existing -> + val current = SessionTokensCache.generation(session.id) + val joinable = + existing != null && existing.generation == current && existing.deferred.isActive + if (joinable) existing else newTask + }!! + + if (winner !== newTask) { + started.cancel() + return winner.deferred + } + + started.invokeOnCompletion { tokenTasks.remove(taskKey, newTask) } + started.start() + return started } /** @@ -106,55 +231,146 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag * * This method first checks the token cache (unless skipCache is true) and validates any cached * token. If no valid cached token exists, it makes a network request to fetch a new token and - * caches the result. + * caches the result. Requests are suppressed while the cache key is inside the backoff window + * opened by an earlier failure, unless the caller asked to skip the cache: that is a deliberate + * recovery attempt, and refusing it for up to a minute is worse than the extra request. * * @param session The session to fetch the token for * @param options Options controlling the fetch behavior * @return The token resource, or null if the fetch failed */ - private suspend fun fetchToken(session: Session, options: GetTokenOptions): TokenResource? { + private suspend fun fetchToken( + session: Session, + options: GetTokenOptions, + generation: SessionTokensCache.Generation, + epoch: Long, + ): TokenResource? { val cacheKey = session.tokenCacheKey(options.template) + val cachedToken = if (options.skipCache) null else validCachedToken(session, cacheKey, options) - // Check cache first (unless skipped) - if (!options.skipCache) { - SessionTokensCache.getToken(cacheKey)?.let { token -> - ClerkLog.d("Found cached token for session ${session.id}") - if (isTokenValid(token, options.expirationBuffer)) { - ClerkLog.d("Cached token is still valid for session ${session.id}") - return token - } else { - ClerkLog.d("Cached token is expired for session ${session.id}") - } + return when { + cachedToken != null -> cachedToken + !options.skipCache && backoff.isBackingOff(cacheKey) -> { + ClerkLog.w("Not requesting a token for $cacheKey yet: backing off after repeated failures") + null } + else -> requestToken(session, options, cacheKey, generation, epoch) } + } - // Fetch from network - return try { - val tokensRequest = - if (options.template != null) { - ClerkApi.session.tokens(session.id, options.template) - } else { - ClerkApi.session.tokens(session.id) - } + private fun validCachedToken( + session: Session, + cacheKey: String, + options: GetTokenOptions, + ): TokenResource? { + val cachedToken = SessionTokensCache.getToken(cacheKey) ?: return null + val isValid = isTokenValid(cachedToken, options.expirationBuffer) + if (isValid) { + ClerkLog.d("Cached token is still valid for session ${session.id}") + } else { + ClerkLog.d("Cached token is expired for session ${session.id}") + } + return cachedToken.takeIf { isValid } + } - when (tokensRequest) { - is ClerkResult.Success -> { - SessionTokensCache.setToken(cacheKey, tokensRequest.value) - tokensRequest.value - } - is ClerkResult.Failure -> { - handleSessionInvalidationOnFailure(session, tokensRequest) - null + private suspend fun requestToken( + session: Session, + options: GetTokenOptions, + cacheKey: String, + generation: SessionTokensCache.Generation, + epoch: Long, + ): TokenResource? { + return try { + val tokensRequest = postTokens(session, options, cacheKey) + // The POST may already be out, but a token minted under a configuration that has since been + // torn down must never be cached, recorded, or returned into the configuration that replaced + // it. Cheap atomic read, no lock. + if (configurationEpoch.get() != epoch) { + ClerkLog.w("Discarding token for $cacheKey: the configuration was replaced mid-request") + null + } else { + when (tokensRequest) { + is ClerkResult.Success -> { + recordOutcome(cacheKey, session.id, generation, succeeded = true) + SessionTokensCache.setTokenIfCurrent( + cacheKey = cacheKey, + sessionId = session.id, + generation = generation, + token = tokensRequest.value, + ) + tokensRequest.value + } + is ClerkResult.Failure -> { + recordOutcome(cacheKey, session.id, generation, succeeded = false) + handleSessionInvalidationOnFailure(session, tokensRequest) + null + } } } } catch (e: CancellationException) { throw e } catch (e: Exception) { + if (configurationEpoch.get() == epoch) { + recordOutcome(cacheKey, session.id, generation, succeeded = false) + } ClerkLog.e("Failed to fetch token: ${e.message}") null } } + /** + * Records the fetch outcome into the failure backoff, unless the session was invalidated after + * this request started. A stale failure would open a window against the new state, and a stale + * success would clear a window the new state legitimately opened. The generation check and the + * backoff mutation run under the cache's write lock, the same lock invalidation bumps the + * generation and clears the window under, so the pair is atomic against a concurrent switch. + */ + private fun recordOutcome( + cacheKey: String, + sessionId: String, + generation: SessionTokensCache.Generation, + succeeded: Boolean, + ) { + SessionTokensCache.recordBackoffOutcomeIfCurrent( + cacheKey = cacheKey, + sessionId = sessionId, + generation = generation, + succeeded = succeeded, + backoff = backoff, + ) + } + + /** + * Posts the tokens request. + * + * Minter fields are only attached for instances the environment reports as minter-enabled, and + * never on the templated route, which the edge does not mint. With nothing to attach the request + * stays exactly the bodyless POST the SDK has always sent. + */ + private suspend fun postTokens( + session: Session, + options: GetTokenOptions, + cacheKey: String, + ): ClerkResult { + if (options.template != null) { + return ClerkApi.session.tokens(userId = session.id, templateType = options.template) + } + + val minterIsEnabled = Clerk.sessionMinterIsEnabled + val previousSessionToken = if (minterIsEnabled) mintingSeed(session.id, cacheKey) else null + val forceOrigin = if (minterIsEnabled && options.skipCache) true else null + + return if (previousSessionToken == null && forceOrigin == null) { + ClerkApi.session.tokens(sessionId = session.id) + } else { + ClerkApi.session.mintTokens( + sessionId = session.id, + previousSessionToken = previousSessionToken, + forceOrigin = forceOrigin, + ) + } + } + private fun handleSessionInvalidationOnFailure( session: Session, failure: ClerkResult.Failure, diff --git a/source/api/src/main/kotlin/com/clerk/api/session/SessionTokensCache.kt b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokensCache.kt index 7c2b9314a..5a2e5e468 100644 --- a/source/api/src/main/kotlin/com/clerk/api/session/SessionTokensCache.kt +++ b/source/api/src/main/kotlin/com/clerk/api/session/SessionTokensCache.kt @@ -1,24 +1,110 @@ package com.clerk.api.session +import com.clerk.api.log.ClerkLog import com.clerk.api.network.model.token.TokenResource import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong internal object SessionTokensCache { private val cache = ConcurrentHashMap() + private val sessionGenerations = ConcurrentHashMap() + private val globalGeneration = AtomicLong() + private val writeLock = Any() + + /** + * Invalidation counter for one session, captured before a request goes out and rechecked before + * its response is cached. + */ + internal data class Generation(val global: Long, val session: Long) + + /** Returns the current invalidation counter for [sessionId]. */ + internal fun generation(sessionId: String): Generation = + Generation(global = globalGeneration.get(), session = sessionGenerations[sessionId] ?: 0L) /** Returns a session token for the given cache key. */ internal fun getToken(cacheKey: String): TokenResource? = cache[cacheKey] - /** Sets a session token for the given cache key. */ + /** + * Sets a session token for the given cache key. + * + * Writes are monotonic: a token whose claims were assembled before the cached one's is dropped. + * Responses can arrive out of order, and a cached token also seeds the next mint, so an older JWT + * overwriting a newer one would chain backwards. + */ internal fun setToken(cacheKey: String, token: TokenResource) { - cache[cacheKey] = token + synchronized(writeLock) { cache[cacheKey] = freshest(cache[cacheKey], token, cacheKey) } + } + + /** + * Sets a session token only if the session was not invalidated since [generation] was captured. + * + * Sign-out and org switches clear the cache while a request is already in flight; without this + * fence that response lands afterwards and repopulates the entry that was just dropped. + */ + internal fun setTokenIfCurrent( + cacheKey: String, + sessionId: String, + generation: Generation, + token: TokenResource, + ) { + synchronized(writeLock) { + if (generation != generation(sessionId)) { + ClerkLog.d("Discarding token for $cacheKey: the cache was invalidated while in flight") + return + } + cache[cacheKey] = freshest(cache[cacheKey], token, cacheKey) + } } /** Removes a session token for the given cache key. */ internal fun removeToken(cacheKey: String): TokenResource? = cache.remove(cacheKey) - /** Clears all cached tokens. */ - internal fun clear() = cache.clear() + /** + * Removes the default and every templated token cached for the given session, and lets its next + * request through immediately by resetting the failure schedule for the same keys. + * + * The generation bump and the backoff reset share this object's write lock so that a token + * request recording its outcome through [recordBackoffOutcomeIfCurrent] cannot interleave between + * them. + */ + internal fun removeTokensForSession(sessionId: String) { + synchronized(writeLock) { + sessionGenerations[sessionId] = (sessionGenerations[sessionId] ?: 0L) + 1L + cache.keys.removeAll { it == sessionId || it.startsWith("$sessionId-") } + SessionTokenBackoff.shared.clearForSession(sessionId) + } + } + + /** Clears all cached tokens and every failure schedule. */ + internal fun clear() { + synchronized(writeLock) { + globalGeneration.incrementAndGet() + sessionGenerations.clear() + cache.clear() + SessionTokenBackoff.shared.clear() + } + } + + /** + * Records a token request's outcome into [backoff], but only while the session's generation still + * matches the one captured when the request started. + * + * The check and the mutation run under the same write lock invalidation uses, so a stale outcome + * can never open a window against the post-switch state, and a stale success can never clear a + * window the post-switch state legitimately opened. + */ + internal fun recordBackoffOutcomeIfCurrent( + cacheKey: String, + sessionId: String, + generation: Generation, + succeeded: Boolean, + backoff: SessionTokenBackoff, + ) { + synchronized(writeLock) { + if (generation != generation(sessionId)) return + if (succeeded) backoff.recordSuccess(cacheKey) else backoff.recordFailure(cacheKey) + } + } /** Returns the number of cached tokens. */ internal val size: Int @@ -26,4 +112,14 @@ internal object SessionTokensCache { /** Checks if a token exists for the given cache key. */ internal fun containsKey(cacheKey: String): Boolean = cache.containsKey(cacheKey) + + private fun freshest( + existing: TokenResource?, + incoming: TokenResource, + cacheKey: String, + ): TokenResource { + if (existing == null || JwtFreshness.incomingWins(existing.jwt, incoming.jwt)) return incoming + ClerkLog.d("Keeping the fresher cached session token for cache key $cacheKey") + return existing + } } diff --git a/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt b/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt index cfe4521e4..80c0c49b7 100644 --- a/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt +++ b/source/api/src/main/kotlin/com/clerk/api/signout/SignOutService.kt @@ -7,6 +7,7 @@ import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.network.serialization.errorMessage +import com.clerk.api.session.SessionTokensCache import com.clerk.api.storage.StorageHelper import com.clerk.api.storage.StorageKey @@ -45,6 +46,7 @@ internal object SignOutService { } finally { // Always clear local credentials regardless of server response StorageHelper.deleteValue(StorageKey.DEVICE_TOKEN) + SessionTokensCache.clear() Clerk.updateClient(Client()) // Best-effort refresh of the in-memory client while skipping current client id. diff --git a/source/api/src/test/java/com/clerk/api/auth/AuthTest.kt b/source/api/src/test/java/com/clerk/api/auth/AuthTest.kt index 7c1ce12e3..85f45dd8a 100644 --- a/source/api/src/test/java/com/clerk/api/auth/AuthTest.kt +++ b/source/api/src/test/java/com/clerk/api/auth/AuthTest.kt @@ -15,8 +15,10 @@ import com.clerk.api.network.model.environment.OrganizationSettings import com.clerk.api.network.model.environment.UserSettings import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.error.Error +import com.clerk.api.network.model.token.TokenResource import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.session.Session +import com.clerk.api.session.SessionTokensCache import com.clerk.api.signin.SignIn import com.clerk.api.signup.SignUp import com.clerk.api.sso.OAuthProvider @@ -41,6 +43,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue import org.junit.Before @@ -52,6 +56,7 @@ class AuthTest { fun setup() { Clerk.updateClient(Client()) setForceOrganizationSelection(false) + SessionTokensCache.clear() } @After @@ -59,6 +64,7 @@ class AuthTest { unmockkAll() Clerk.updateClient(Client()) setForceOrganizationSelection(false) + SessionTokensCache.clear() } @Test @@ -439,6 +445,106 @@ class AuthTest { assertEquals(secondSession, Clerk.sessionFlow.value) } + @Test + fun `signOut with session ID drops that session's cached tokens`() = runTest { + val firstSession = testSession("sess_1") + val secondSession = testSession("sess_2") + val sessionApi = mockk() + val clientApi = mockk() + mockkObject(ClerkApi) + every { ClerkApi.session } returns sessionApi + every { ClerkApi.client } returns clientApi + coEvery { sessionApi.removeSession(firstSession.id) } returns ClerkResult.success(firstSession) + coEvery { clientApi.get() } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList())) + Clerk.updateClient( + Client( + id = "client_123", + sessions = listOf(firstSession, secondSession), + lastActiveSessionId = firstSession.id, + ) + ) + cacheTokensFor(firstSession.id) + cacheTokensFor(secondSession.id) + + val result = Auth().signOut(sessionId = firstSession.id) + + assertTrue(result is ClerkResult.Success) + assertFalse(SessionTokensCache.containsKey("sess_1")) + assertFalse(SessionTokensCache.containsKey("sess_1-custom_template")) + assertTrue(SessionTokensCache.containsKey("sess_2")) + } + + @Test + fun `setActive drops the activated session's cached tokens`() = runTest { + val firstSession = testSession("sess_1") + val secondSession = testSession("sess_2") + val clientApi = mockk() + mockkObject(ClerkApi) + every { ClerkApi.client } returns clientApi + coEvery { clientApi.setActive(secondSession.id, "", SET_ACTIVE_INTENT_SELECT_ORG) } returns + ClerkResult.success(secondSession) + coEvery { clientApi.get() } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList())) + Clerk.updateClient( + Client( + id = "client_123", + sessions = listOf(firstSession, secondSession), + lastActiveSessionId = firstSession.id, + ) + ) + cacheTokensFor(firstSession.id) + cacheTokensFor(secondSession.id) + + val result = Auth().setActive(sessionId = secondSession.id) + + assertTrue(result is ClerkResult.Success) + assertFalse(SessionTokensCache.containsKey("sess_2")) + assertFalse(SessionTokensCache.containsKey("sess_2-custom_template")) + assertTrue(SessionTokensCache.containsKey("sess_1")) + } + + @Test + fun `setActive clears the activated session's persisted token`() = runTest { + val staleToken = TokenResource(jwt = "token.for.the.previous.org") + val firstSession = testSession("sess_1") + val secondSession = testSession("sess_2").copy(lastActiveToken = staleToken) + val clientApi = mockk() + mockkObject(ClerkApi) + every { ClerkApi.client } returns clientApi + coEvery { clientApi.setActive(secondSession.id, "org_2", SET_ACTIVE_INTENT_SELECT_ORG) } returns + ClerkResult.success(secondSession) + // The refresh echoes the pre-switch token back, which the merge would otherwise keep. + coEvery { clientApi.get() } returns + ClerkResult.success( + Client( + id = "client_123", + sessions = listOf(firstSession, secondSession), + lastActiveSessionId = secondSession.id, + ) + ) + Clerk.updateClient( + Client( + id = "client_123", + sessions = listOf(firstSession, secondSession), + lastActiveSessionId = firstSession.id, + ) + ) + + val result = Auth().setActive(sessionId = secondSession.id, organizationId = "org_2") + + assertTrue(result is ClerkResult.Success) + assertNull(Clerk.client.sessions.single { it.id == "sess_2" }.lastActiveToken) + } + + private fun cacheTokensFor(sessionId: String) { + SessionTokensCache.setToken(sessionId, TokenResource(jwt = "$sessionId.cached.jwt")) + SessionTokensCache.setToken( + "$sessionId-custom_template", + TokenResource(jwt = "$sessionId.templated.jwt"), + ) + } + private fun setForceOrganizationSelection(enabled: Boolean) { Clerk.updateEnvironment( Environment( diff --git a/source/api/src/test/java/com/clerk/api/network/api/SessionApiTest.kt b/source/api/src/test/java/com/clerk/api/network/api/SessionApiTest.kt new file mode 100644 index 000000000..481e27cfb --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/network/api/SessionApiTest.kt @@ -0,0 +1,173 @@ +package com.clerk.api.network.api + +import com.clerk.api.network.ClerkApi +import com.clerk.api.network.middleware.SensitiveRequest +import com.clerk.api.network.serialization.ClerkApiResultCallAdapterFactory +import com.clerk.api.network.serialization.ClerkApiResultConverterFactory +import com.clerk.api.network.serialization.ClerkResult +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import kotlinx.coroutines.test.runTest +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Retrofit +import retrofit2.converter.kotlinx.serialization.asConverterFactory + +class SessionApiTest { + + @Test + fun `tokens posts no body at all`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + val result = api.tokens(sessionId = "sess_123") + + assertTrue(result is ClerkResult.Success) + assertEquals("header.payload.signature", (result as ClerkResult.Success).value.jwt) + assertEquals("POST", interceptor.method) + assertEquals("/v1/client/sessions/sess_123/tokens", interceptor.path) + assertNull(interceptor.contentType) + assertEquals(0L, interceptor.contentLength) + } + + @Test + fun `templated tokens posts no body at all`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + val result = api.tokens(userId = "sess_123", templateType = "custom_template") + + assertTrue(result is ClerkResult.Success) + assertEquals("/v1/client/sessions/sess_123/tokens/custom_template", interceptor.path) + assertNull(interceptor.contentType) + assertEquals(0L, interceptor.contentLength) + } + + @Test + fun `mintTokens sends the previous session token and force origin when both are set`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + val result = + api.mintTokens( + sessionId = "sess_123", + previousSessionToken = "header.payload.signature", + forceOrigin = true, + ) + + assertTrue(result is ClerkResult.Success) + assertEquals("POST", interceptor.method) + assertEquals("/v1/client/sessions/sess_123/tokens", interceptor.path) + assertEquals("application/x-www-form-urlencoded", interceptor.contentType) + assertEquals( + mapOf("token" to "header.payload.signature", "force_origin" to "true"), + interceptor.formBody, + ) + } + + @Test + fun `mintTokens omits force origin when only the seed is set`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + api.mintTokens(sessionId = "sess_123", previousSessionToken = "header.payload.signature") + + assertEquals(mapOf("token" to "header.payload.signature"), interceptor.formBody) + assertFalse(interceptor.formBody.containsKey("force_origin")) + } + + @Test + fun `mintTokens omits the seed when only force origin is set`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + api.mintTokens(sessionId = "sess_123", forceOrigin = true) + + assertEquals(mapOf("force_origin" to "true"), interceptor.formBody) + assertFalse(interceptor.formBody.containsKey("token")) + } + + @Test + fun `mintTokens is tagged sensitive so debug logging never prints the seed`() = runTest { + val interceptor = CapturingInterceptor() + val api = sessionApi(interceptor) + + api.mintTokens(sessionId = "sess_123", previousSessionToken = "header.payload.signature") + + assertTrue(interceptor.sensitiveRequest) + } + + private fun sessionApi(interceptor: CapturingInterceptor): SessionApi { + return Retrofit.Builder() + .baseUrl("https://example.com/v1/") + .client(OkHttpClient.Builder().addInterceptor(interceptor).build()) + .addCallAdapterFactory(ClerkApiResultCallAdapterFactory) + .addConverterFactory(ClerkApiResultConverterFactory) + .addConverterFactory( + ClerkApi.json.asConverterFactory("application/json; charset=utf-8".toMediaType()) + ) + .build() + .create(SessionApi::class.java) + } + + private class CapturingInterceptor(private val responseBody: String = TOKEN_RESPONSE) : + Interceptor { + lateinit var method: String + lateinit var path: String + lateinit var formBody: Map + var contentType: String? = null + var contentLength: Long = -1L + var sensitiveRequest: Boolean = false + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + method = request.method + path = request.url.encodedPath + contentType = request.body?.contentType()?.let { "${it.type}/${it.subtype}" } + contentLength = request.body?.contentLength() ?: -1L + sensitiveRequest = request.tag(SensitiveRequest::class.java) != null + formBody = request.body.readFormBody() + + return Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .body(responseBody.toResponseBody("application/json".toMediaType())) + .build() + } + + private fun okhttp3.RequestBody?.readFormBody(): Map { + if (this == null) return emptyMap() + val buffer = Buffer() + writeTo(buffer) + return buffer + .readUtf8() + .split("&") + .filter { it.isNotEmpty() } + .associate { pair -> + val parts = pair.split("=", limit = 2) + val key = parts.first().urlDecode() + val value = parts.getOrElse(1) { "" }.urlDecode() + key to value + } + } + + private fun String.urlDecode(): String = URLDecoder.decode(this, StandardCharsets.UTF_8.name()) + } + + private companion object { + // TokenResource is excluded from client piggyback wrapping, so the body is the bare resource. + const val TOKEN_RESPONSE = """{"jwt":"header.payload.signature"}""" + } +} diff --git a/source/api/src/test/java/com/clerk/api/network/model/environment/AuthConfigSerializationTest.kt b/source/api/src/test/java/com/clerk/api/network/model/environment/AuthConfigSerializationTest.kt new file mode 100644 index 000000000..a53648cd0 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/network/model/environment/AuthConfigSerializationTest.kt @@ -0,0 +1,45 @@ +package com.clerk.api.network.model.environment + +import com.clerk.api.Clerk +import com.clerk.api.network.ClerkApi +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AuthConfigSerializationTest { + + @After + fun tearDown() { + Clerk.environment = null + } + + @Test + fun `session minter deserializes correctly`() { + val authConfig = + ClerkApi.json.decodeFromString(authConfigJson(sessionMinter = true)) + + assertTrue(authConfig.sessionMinter) + } + + @Test + fun `session minter defaults to false when the instance does not report it`() { + val authConfig = + ClerkApi.json.decodeFromString(authConfigJson(sessionMinter = null)) + + assertFalse(authConfig.sessionMinter) + } + + @Test + fun `the flag is off when no environment has been loaded`() { + Clerk.environment = null + + assertFalse(Clerk.sessionMinterIsEnabled) + } + + private fun authConfigJson(sessionMinter: Boolean?): String { + val sessionMinterJson = sessionMinter?.let { ""","session_minter":$it""" }.orEmpty() + + return """{"single_session_mode":false$sessionMinterJson}""" + } +} diff --git a/source/api/src/test/java/com/clerk/api/session/SessionTokenBackoffTest.kt b/source/api/src/test/java/com/clerk/api/session/SessionTokenBackoffTest.kt new file mode 100644 index 000000000..dd1097733 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/session/SessionTokenBackoffTest.kt @@ -0,0 +1,122 @@ +package com.clerk.api.session + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class SessionTokenBackoffTest { + + @Test + fun `a key with no failures is never backing off`() { + val backoff = SessionTokenBackoff { 0L } + + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `the first failure blocks requests for five seconds`() { + var now = 0L + val backoff = SessionTokenBackoff { now } + + backoff.recordFailure("sess_1") + + now = 4_999L + assertTrue(backoff.isBackingOff("sess_1")) + now = 5_000L + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `consecutive failures double the window even after each one expires`() { + var now = 0L + val backoff = SessionTokenBackoff { now } + + backoff.recordFailure("sess_1") + now = 5_000L + // Reading past the deadline is what the wake loop does; it must not reset the schedule. + assertFalse(backoff.isBackingOff("sess_1")) + backoff.recordFailure("sess_1") + + now = 14_999L + assertTrue(backoff.isBackingOff("sess_1")) + now = 15_000L + assertFalse(backoff.isBackingOff("sess_1")) + backoff.recordFailure("sess_1") + + now = 34_999L + assertTrue(backoff.isBackingOff("sess_1")) + now = 35_000L + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `the window is capped at sixty seconds`() { + var now = 0L + val backoff = SessionTokenBackoff { now } + + repeat(20) { backoff.recordFailure("sess_1") } + + now = 59_999L + assertTrue(backoff.isBackingOff("sess_1")) + now = 60_000L + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `a success closes the window and resets the schedule`() { + var now = 0L + val backoff = SessionTokenBackoff { now } + + backoff.recordFailure("sess_1") + backoff.recordFailure("sess_1") + backoff.recordSuccess("sess_1") + assertFalse(backoff.isBackingOff("sess_1")) + + backoff.recordFailure("sess_1") + now = 5_000L + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `windows are tracked per cache key`() { + val backoff = SessionTokenBackoff { 0L } + + backoff.recordFailure("sess_1") + + assertTrue(backoff.isBackingOff("sess_1")) + assertFalse(backoff.isBackingOff("sess_1-custom_template")) + assertFalse(backoff.isBackingOff("sess_2")) + } + + @Test + fun `clearForSession resets the schedule for the default and templated keys`() { + var now = 0L + val backoff = SessionTokenBackoff { now } + + backoff.recordFailure("sess_1") + backoff.recordFailure("sess_1") + backoff.recordFailure("sess_1-custom_template") + backoff.recordFailure("sess_2") + + backoff.clearForSession("sess_1") + + assertFalse(backoff.isBackingOff("sess_1")) + assertFalse(backoff.isBackingOff("sess_1-custom_template")) + assertTrue(backoff.isBackingOff("sess_2")) + + // The schedule restarted, so the next failure opens the first window again. + backoff.recordFailure("sess_1") + now = 5_000L + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `clear forgets every window`() { + val backoff = SessionTokenBackoff { 0L } + + backoff.recordFailure("sess_1") + backoff.clear() + + assertFalse(backoff.isBackingOff("sess_1")) + } +} diff --git a/source/api/src/test/java/com/clerk/api/session/SessionTokenFetcherTest.kt b/source/api/src/test/java/com/clerk/api/session/SessionTokenFetcherTest.kt index 7cdf31de1..48c62c561 100644 --- a/source/api/src/test/java/com/clerk/api/session/SessionTokenFetcherTest.kt +++ b/source/api/src/test/java/com/clerk/api/session/SessionTokenFetcherTest.kt @@ -1,9 +1,11 @@ package com.clerk.api.session +import android.util.Base64 import com.auth0.android.jwt.JWT import com.clerk.api.Clerk import com.clerk.api.network.ClerkApi import com.clerk.api.network.api.SessionApi +import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.error.ClerkErrorResponse import com.clerk.api.network.model.error.Error import com.clerk.api.network.model.token.TokenResource @@ -17,13 +19,25 @@ import io.mockk.slot import io.mockk.unmockkAll import io.mockk.verify import java.util.Date +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotSame import org.junit.Assert.assertNull import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,6 +52,12 @@ class SessionTokenFetcherTest { private lateinit var mockJWT: JWT private lateinit var mockJWTManager: JWTManager private lateinit var mockClerkApiService: SessionApi + private lateinit var backoff: SessionTokenBackoff + private lateinit var fetchScope: CoroutineScope + private lateinit var readableJwt: String + private val testScheduler = TestCoroutineScheduler() + private val testDispatcher = StandardTestDispatcher(testScheduler) + private var clockMillis = 0L @Before fun setup() { @@ -47,12 +67,18 @@ class SessionTokenFetcherTest { mockJWTManager = mockk(relaxed = true) mockClerkApiService = mockk(relaxed = true) - // Create SessionTokenFetcher with mocked JWTManager - sessionTokenFetcher = SessionTokenFetcher(mockJWTManager) + // Create SessionTokenFetcher with a mocked JWTManager, plus a backoff and a fetch scope + // isolated to this test. The scope shares the test scheduler, so the shared request runs on + // deterministic virtual time and its delays never touch the wall clock. + backoff = SessionTokenBackoff { clockMillis } + fetchScope = CoroutineScope(SupervisorJob() + testDispatcher) + readableJwt = buildJwt(oiat = 1_000L) + sessionTokenFetcher = SessionTokenFetcher(mockJWTManager, backoff, fetchScope) // Mock session properties every { mockSession.id } returns "session_123" every { mockSession.status } returns Session.SessionStatus.ACTIVE + every { mockSession.lastActiveToken } returns null // Mock JWT manager to return our mock JWT every { mockJWTManager.createFromString(any()) } returns mockJWT @@ -65,6 +91,9 @@ class SessionTokenFetcherTest { mockkObject(Clerk) every { Clerk.session } returns mockSession every { Clerk.clearSessionAndUserState() } returns Unit + every { Clerk.sessionMinterIsEnabled } returns false + // No current client by default, so the seed fallback is absent unless a test opts in. + every { Clerk.clientInitialized } returns false // Mock SessionTokensCache mockkObject(SessionTokensCache) @@ -72,11 +101,13 @@ class SessionTokenFetcherTest { @After fun tearDown() { + clockMillis = 0L + fetchScope.cancel() unmockkAll() } @Test - fun `getToken returns cached token if valid and cache not skipped`() = runTest { + fun `getToken returns cached token if valid and cache not skipped`() = runTest(testDispatcher) { // Given val cacheKey = "session_123" val futureTime = Date(System.currentTimeMillis() + 120000) // 2 minutes from now @@ -95,7 +126,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken fetches from network if cache is empty`() = runTest { + fun `getToken fetches from network if cache is empty`() = runTest(testDispatcher) { // Given val cacheKey = "session_123" val setTokenSlot = slot() @@ -103,7 +134,9 @@ class SessionTokenFetcherTest { coEvery { SessionTokensCache.getToken(cacheKey) } returns null coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, capture(setTokenSlot)) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), capture(setTokenSlot)) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession) @@ -112,12 +145,14 @@ class SessionTokenFetcherTest { assertEquals(mockTokenResource, result) coVerify { SessionTokensCache.getToken(cacheKey) } coVerify { mockClerkApiService.tokens("session_123") } - coVerify { SessionTokensCache.setToken(cacheKey, mockTokenResource) } + coVerify { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } assertEquals(mockTokenResource, setTokenSlot.captured) } @Test - fun `getToken fetches from network if cached token is expired`() = runTest { + fun `getToken fetches from network if cached token is expired`() = runTest(testDispatcher) { // Given val cacheKey = "session_123" val pastTime = Date(System.currentTimeMillis() - 60000) // 1 minute ago @@ -127,7 +162,9 @@ class SessionTokenFetcherTest { every { mockJWT.expiresAt } returns pastTime coEvery { SessionTokensCache.getToken(cacheKey) } returns mockTokenResource coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(freshToken) - coEvery { SessionTokensCache.setToken(cacheKey, freshToken) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), freshToken) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession) @@ -136,39 +173,45 @@ class SessionTokenFetcherTest { assertEquals(freshToken, result) coVerify { SessionTokensCache.getToken(cacheKey) } coVerify { mockClerkApiService.tokens("session_123") } - coVerify { SessionTokensCache.setToken(cacheKey, freshToken) } + coVerify { SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), freshToken) } } @Test - fun `getToken uses template in API call when provided`() = runTest { + fun `getToken uses template in API call when provided`() = runTest(testDispatcher) { // Given val template = "custom_template" val cacheKey = "session_123-custom_template" val options = GetTokenOptions(template = template) coEvery { SessionTokensCache.getToken(cacheKey) } returns null - coEvery { mockClerkApiService.tokens("session_123", template) } returns + coEvery { mockClerkApiService.tokens(userId = "session_123", templateType = template) } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession, options) // Then assertEquals(mockTokenResource, result) - coVerify { mockClerkApiService.tokens("session_123", template) } - coVerify { SessionTokensCache.setToken(cacheKey, mockTokenResource) } + coVerify { mockClerkApiService.tokens(userId = "session_123", templateType = template) } + coVerify { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } } @Test - fun `getToken skips cache when skipCache is true`() = runTest { + fun `getToken skips cache when skipCache is true`() = runTest(testDispatcher) { // Given val options = GetTokenOptions(skipCache = true) val cacheKey = "session_123" coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession, options) @@ -177,11 +220,13 @@ class SessionTokenFetcherTest { assertEquals(mockTokenResource, result) coVerify(exactly = 0) { SessionTokensCache.getToken(any()) } coVerify { mockClerkApiService.tokens("session_123") } - coVerify { SessionTokensCache.setToken(cacheKey, mockTokenResource) } + coVerify { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } } @Test - fun `getToken returns null when API call fails`() = runTest { + fun `getToken returns null when API call fails`() = runTest(testDispatcher) { // Given val error = Error( @@ -201,31 +246,32 @@ class SessionTokenFetcherTest { // Then assertNull(result) coVerify { mockClerkApiService.tokens("session_123") } - coVerify(exactly = 0) { SessionTokensCache.setToken(any(), any()) } + coVerify(exactly = 0) { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } verify(exactly = 0) { Clerk.clearSessionAndUserState() } } @Test - fun `getToken clears local session state when token endpoint returns unauthorized`() = runTest { - // Given - val error = Error(code = "session_revoked", message = "Session revoked") - val errorResponse = ClerkErrorResponse(errors = listOf(error), clerkTraceId = "trace_unauth") + fun `getToken clears local session state when token endpoint returns unauthorized`() = + runTest(testDispatcher) { + // Given + val error = Error(code = "session_revoked", message = "Session revoked") + val errorResponse = ClerkErrorResponse(errors = listOf(error), clerkTraceId = "trace_unauth") - coEvery { SessionTokensCache.getToken(any()) } returns null - coEvery { mockClerkApiService.tokens("session_123") } returns - ClerkResult.httpFailure(code = 401, error = errorResponse) + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.httpFailure(code = 401, error = errorResponse) - // When - val result = sessionTokenFetcher.getToken(mockSession) + // When + val result = sessionTokenFetcher.getToken(mockSession) - // Then - assertNull(result) - verify(exactly = 1) { Clerk.clearSessionAndUserState() } - } + // Then + assertNull(result) + verify(exactly = 1) { Clerk.clearSessionAndUserState() } + } @Test fun `getToken clears local session state when token endpoint returns authentication invalid`() = - runTest { + runTest(testDispatcher) { // Given val error = Error(code = "authentication_invalid", message = "Invalid authentication") val errorResponse = ClerkErrorResponse(errors = listOf(error), clerkTraceId = "trace_unauth") @@ -244,7 +290,7 @@ class SessionTokenFetcherTest { @Test fun `getToken does not clear local session state for non-session unauthorized errors`() = - runTest { + runTest(testDispatcher) { // Given val error = Error(code = "not_authorized", message = "Unauthorized") val errorResponse = ClerkErrorResponse(errors = listOf(error), clerkTraceId = "trace_unauth") @@ -262,7 +308,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken uses custom expiration buffer`() = runTest { + fun `getToken uses custom expiration buffer`() = runTest(testDispatcher) { // Given val customBuffer = 120L // 2 minutes val options = GetTokenOptions(expirationBuffer = customBuffer) @@ -275,7 +321,9 @@ class SessionTokenFetcherTest { coEvery { SessionTokensCache.getToken(cacheKey) } returns mockTokenResource coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession, options) @@ -287,7 +335,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken handles JWT parsing exception gracefully`() = runTest { + fun `getToken handles JWT parsing exception gracefully`() = runTest(testDispatcher) { // Given val cacheKey = "session_123" @@ -296,7 +344,9 @@ class SessionTokenFetcherTest { coEvery { SessionTokensCache.getToken(cacheKey) } returns mockTokenResource coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession) @@ -308,7 +358,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken handles concurrent requests properly`() = runTest { + fun `getToken handles concurrent requests properly`() = runTest(testDispatcher) { // Given val cacheKey = "session_123" @@ -318,7 +368,9 @@ class SessionTokenFetcherTest { delay(100) // Simulate network delay ClerkResult.success(mockTokenResource) } - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When - Launch multiple concurrent requests val deferred1 = async { sessionTokenFetcher.getToken(mockSession) } @@ -339,7 +391,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken handles API exception gracefully`() = runTest { + fun `getToken handles API exception gracefully`() = runTest(testDispatcher) { // Given coEvery { SessionTokensCache.getToken(any()) } returns null coEvery { mockClerkApiService.tokens("session_123") } throws RuntimeException("Network error") @@ -350,7 +402,7 @@ class SessionTokenFetcherTest { // Then assertNull(result) coVerify { mockClerkApiService.tokens("session_123") } - coVerify(exactly = 0) { SessionTokensCache.setToken(any(), any()) } + coVerify(exactly = 0) { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } } @Test @@ -379,7 +431,7 @@ class SessionTokenFetcherTest { } @Test - fun `different sessions get different cache keys`() = runTest { + fun `different sessions get different cache keys`() = runTest(testDispatcher) { // Given val session1 = mockk(relaxed = true) val session2 = mockk(relaxed = true) @@ -392,7 +444,7 @@ class SessionTokenFetcherTest { ClerkResult.success(mockTokenResource) coEvery { mockClerkApiService.tokens("session_2") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(any(), any()) } returns Unit + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit // When sessionTokenFetcher.getToken(session1) @@ -401,12 +453,16 @@ class SessionTokenFetcherTest { // Then coVerify { mockClerkApiService.tokens("session_1") } coVerify { mockClerkApiService.tokens("session_2") } - coVerify { SessionTokensCache.setToken("session_1", mockTokenResource) } - coVerify { SessionTokensCache.setToken("session_2", mockTokenResource) } + coVerify { + SessionTokensCache.setTokenIfCurrent("session_1", "session_1", any(), mockTokenResource) + } + coVerify { + SessionTokensCache.setTokenIfCurrent("session_2", "session_2", any(), mockTokenResource) + } } @Test - fun `getToken returns null for pending session`() = runTest { + fun `getToken returns null for pending session`() = runTest(testDispatcher) { // Given - a session with PENDING status every { mockSession.status } returns Session.SessionStatus.PENDING @@ -420,7 +476,7 @@ class SessionTokenFetcherTest { } @Test - fun `getToken proceeds normally for active session`() = runTest { + fun `getToken proceeds normally for active session`() = runTest(testDispatcher) { // Given - a session with ACTIVE status val cacheKey = "session_123" @@ -428,7 +484,9 @@ class SessionTokenFetcherTest { coEvery { SessionTokensCache.getToken(cacheKey) } returns null coEvery { mockClerkApiService.tokens("session_123") } returns ClerkResult.success(mockTokenResource) - coEvery { SessionTokensCache.setToken(cacheKey, mockTokenResource) } returns Unit + coEvery { + SessionTokensCache.setTokenIfCurrent(cacheKey, "session_123", any(), mockTokenResource) + } returns Unit // When val result = sessionTokenFetcher.getToken(mockSession) @@ -437,4 +495,616 @@ class SessionTokenFetcherTest { assertEquals(mockTokenResource, result) coVerify { mockClerkApiService.tokens("session_123") } } + + // region session minter wire contract + + @Test + fun `getToken posts the plain bodyless request when the minter is disabled`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns false + val cachedToken = mockk(relaxed = true) + every { cachedToken.jwt } returns readableJwt + coEvery { SessionTokensCache.getToken("session_123") } returns cachedToken + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession, GetTokenOptions(skipCache = true)) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + @Test + fun `getToken seeds the request with the cached token when the minter is enabled`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val cachedToken = mockk(relaxed = true) + every { cachedToken.jwt } returns readableJwt + coEvery { SessionTokensCache.getToken("session_123") } returns cachedToken + every { mockJWT.expiresAt } returns Date(System.currentTimeMillis() - 60_000) + coEvery { mockClerkApiService.mintTokens(any(), any(), any()) } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { + mockClerkApiService.mintTokens( + sessionId = "session_123", + previousSessionToken = readableJwt, + forceOrigin = null, + ) + } + } + + @Test + fun `getToken falls back to lastActiveToken when nothing is cached`() = runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + stubCurrentSessionToken(readableJwt) + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { mockClerkApiService.mintTokens(any(), any(), any()) } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { + mockClerkApiService.mintTokens( + sessionId = "session_123", + previousSessionToken = readableJwt, + forceOrigin = null, + ) + } + } + + @Test + fun `getToken skips a blank cached seed and falls through to lastActiveToken`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val blankToken = mockk(relaxed = true) + every { blankToken.jwt } returns "" + coEvery { SessionTokensCache.getToken("session_123") } returns blankToken + stubCurrentSessionToken(readableJwt) + coEvery { mockClerkApiService.mintTokens(any(), any(), any()) } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { + mockClerkApiService.mintTokens( + sessionId = "session_123", + previousSessionToken = readableJwt, + forceOrigin = null, + ) + } + } + + @Test + fun `getToken skips an undecodable cached seed and falls through to lastActiveToken`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val malformedToken = mockk(relaxed = true) + every { malformedToken.jwt } returns "not-a-jwt" + coEvery { SessionTokensCache.getToken("session_123") } returns malformedToken + stubCurrentSessionToken(readableJwt) + coEvery { mockClerkApiService.mintTokens(any(), any(), any()) } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { + mockClerkApiService.mintTokens( + sessionId = "session_123", + previousSessionToken = readableJwt, + forceOrigin = null, + ) + } + } + + @Test + fun `getToken posts the plain bodyless request when there is nothing to attach`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + every { mockSession.lastActiveToken } returns null + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + @Test + fun `getToken never sends an undecodable seed`() = runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val malformedToken = mockk(relaxed = true) + every { malformedToken.jwt } returns "not-a-jwt" + coEvery { SessionTokensCache.getToken("session_123") } returns malformedToken + every { mockSession.lastActiveToken } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + @Test + fun `getToken maps skipCache to force origin when the minter is enabled`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + every { mockSession.lastActiveToken } returns null + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { mockClerkApiService.mintTokens(any(), any(), any()) } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession, GetTokenOptions(skipCache = true)) + + coVerify(exactly = 1) { + mockClerkApiService.mintTokens( + sessionId = "session_123", + previousSessionToken = null, + forceOrigin = true, + ) + } + } + + @Test + fun `a cached template token never seeds the default request`() = runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + every { mockSession.lastActiveToken } returns null + val templateToken = mockk(relaxed = true) + every { templateToken.jwt } returns readableJwt + coEvery { SessionTokensCache.getToken("session_123-custom_template") } returns templateToken + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(mockSession) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + @Test + fun `the templated route never carries minter fields`() = runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val cachedToken = mockk(relaxed = true) + every { cachedToken.jwt } returns readableJwt + coEvery { SessionTokensCache.getToken(any()) } returns cachedToken + every { mockJWT.expiresAt } returns Date(System.currentTimeMillis() - 60_000) + coEvery { + mockClerkApiService.tokens(userId = any(), templateType = any()) + } returns ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken( + mockSession, + GetTokenOptions(template = "custom_template", skipCache = true), + ) + + coVerify(exactly = 1) { + mockClerkApiService.tokens(userId = "session_123", templateType = "custom_template") + } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + @Test + fun `the response is fenced with the generation captured before the request`() = + runTest(testDispatcher) { + val generationBeforeRequest = SessionTokensCache.Generation(global = 0L, session = 0L) + coEvery { SessionTokensCache.getToken(any()) } returns null + every { SessionTokensCache.generation("session_123") } returns generationBeforeRequest + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + // An org switch lands while the request is in flight; the fence must already be captured. + every { SessionTokensCache.generation("session_123") } returns + SessionTokensCache.Generation(global = 0L, session = 1L) + ClerkResult.success(mockTokenResource) + } + + assertEquals(mockTokenResource, sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 1) { + SessionTokensCache.setTokenIfCurrent( + "session_123", + "session_123", + generationBeforeRequest, + mockTokenResource, + ) + } + } + + @Test + fun `a caller after an invalidation does not join the pre-switch request`() = + runTest(testDispatcher) { + val preSwitch = SessionTokensCache.Generation(global = 0L, session = 0L) + val postSwitch = SessionTokensCache.Generation(global = 0L, session = 1L) + val preSwitchToken = mockk(relaxed = true) + val postSwitchToken = mockk(relaxed = true) + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + // Reads in order: first's caller-capture, first's in-compute join read, then second's + // caller-capture (still pre-switch), then second's in-compute read (post-switch) and every + // read after. The switch lands between second's caller-capture and its map entry, so a join + // decision keyed on the caller-capture would wrongly join, one keyed on the in-compute read + // must not. + every { SessionTokensCache.generation("session_123") } returnsMany + listOf(preSwitch, preSwitch, preSwitch, postSwitch) + var callCount = 0 + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + callCount += 1 + if (callCount == 1) { + delay(100) + ClerkResult.success(preSwitchToken) + } else { + ClerkResult.success(postSwitchToken) + } + } + + val first = async { sessionTokenFetcher.getToken(mockSession) } + runCurrent() + val second = async { sessionTokenFetcher.getToken(mockSession) } + + assertSame(postSwitchToken, second.await()) + assertSame(preSwitchToken, first.await()) + coVerify(exactly = 2) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `the seed ignores a retained stale session and reads current client state`() = + runTest(testDispatcher) { + every { Clerk.sessionMinterIsEnabled } returns true + val staleSession = mockk(relaxed = true) + every { staleSession.id } returns "session_123" + every { staleSession.status } returns Session.SessionStatus.ACTIVE + val staleToken = mockk(relaxed = true) + every { staleToken.jwt } returns buildJwt(oiat = 9_000L) + every { staleSession.lastActiveToken } returns staleToken + coEvery { SessionTokensCache.getToken("session_123") } returns null + // The current client's session was sanitized by the switch, so it has no token to seed with. + val currentSession = mockk(relaxed = true) + every { currentSession.id } returns "session_123" + every { currentSession.lastActiveToken } returns null + val client = mockk(relaxed = true) + every { client.sessions } returns listOf(currentSession) + every { Clerk.clientInitialized } returns true + every { Clerk.client } returns client + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + + sessionTokenFetcher.getToken(staleSession) + + // Seed absent because current state has none; the stale instance's token is never sent. + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + coVerify(exactly = 0) { mockClerkApiService.mintTokens(any(), any(), any()) } + } + + // endregion + + // region failure backoff + + @Test + fun `a failed request is not retried inside the backoff window`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + + assertNull(sessionTokenFetcher.getToken(mockSession)) + clockMillis = 4_999L + assertNull(sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a request is retried once the backoff window closes`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + + assertNull(sessionTokenFetcher.getToken(mockSession)) + clockMillis = 5_000L + assertNull(sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 2) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a thrown request also opens the backoff window`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { mockClerkApiService.tokens("session_123") } throws RuntimeException("Network error") + + assertNull(sessionTokenFetcher.getToken(mockSession)) + assertNull(sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a successful request clears the backoff window`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + + assertNull(sessionTokenFetcher.getToken(mockSession)) + + clockMillis = 5_000L + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.success(mockTokenResource) + assertEquals(mockTokenResource, sessionTokenFetcher.getToken(mockSession)) + + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + assertNull(sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 3) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `the backoff window is scoped to the cache key`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + coEvery { + mockClerkApiService.tokens(userId = "session_123", templateType = "custom_template") + } returns ClerkResult.success(mockTokenResource) + + assertNull(sessionTokenFetcher.getToken(mockSession)) + val templated = + sessionTokenFetcher.getToken(mockSession, GetTokenOptions(template = "custom_template")) + + assertEquals(mockTokenResource, templated) + } + + @Test + fun `a forced request is never refused by the backoff window`() = runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + + assertNull(sessionTokenFetcher.getToken(mockSession)) + assertNull(sessionTokenFetcher.getToken(mockSession, GetTokenOptions(skipCache = true))) + + coVerify(exactly = 2) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a forced request still widens the window that plain requests observe`() = + runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { mockClerkApiService.tokens("session_123") } returns + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + + assertNull(sessionTokenFetcher.getToken(mockSession, GetTokenOptions(skipCache = true))) + assertNull(sessionTokenFetcher.getToken(mockSession)) + + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a failure that lands after an invalidation does not open a window`() = + runTest(testDispatcher) { + val atStart = SessionTokensCache.Generation(global = 0L, session = 0L) + val afterInvalidation = SessionTokensCache.Generation(global = 0L, session = 1L) + coEvery { SessionTokensCache.getToken(any()) } returns null + every { SessionTokensCache.generation("session_123") } returns atStart + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + every { SessionTokensCache.generation("session_123") } returns afterInvalidation + ClerkResult.apiFailure(ClerkErrorResponse(errors = emptyList(), clerkTraceId = "trace")) + } + + assertNull(sessionTokenFetcher.getToken(mockSession)) + + // The failure belonged to the pre-invalidation state, so the new state is not penalized. + assertFalse(backoff.isBackingOff("session_123")) + } + + @Test + fun `a success that lands after an invalidation does not clear the new window`() = + runTest(testDispatcher) { + val atStart = SessionTokensCache.Generation(global = 0L, session = 0L) + val afterInvalidation = SessionTokensCache.Generation(global = 0L, session = 1L) + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + every { SessionTokensCache.generation("session_123") } returns atStart + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + // The new state opens its own failure window before the stale success returns. + every { SessionTokensCache.generation("session_123") } returns afterInvalidation + backoff.recordFailure("session_123") + ClerkResult.success(mockTokenResource) + } + + sessionTokenFetcher.getToken(mockSession) + + assertTrue(backoff.isBackingOff("session_123")) + } + + // endregion + + @Test + fun `concurrent requests through separate fetchers share one network call`() = + runTest(testDispatcher) { + val otherFetcher = SessionTokenFetcher(mockJWTManager, backoff, fetchScope) + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + delay(100) + ClerkResult.success(mockTokenResource) + } + + val first = async { sessionTokenFetcher.getToken(mockSession) } + val second = async { otherFetcher.getToken(mockSession) } + + assertSame(mockTokenResource, first.await()) + assertSame(mockTokenResource, second.await()) + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a forced request does not join a plain request already in flight`() = + runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + delay(100) + ClerkResult.success(mockTokenResource) + } + + val plain = async { sessionTokenFetcher.getToken(mockSession) } + runCurrent() + val forced = + async { sessionTokenFetcher.getToken(mockSession, GetTokenOptions(skipCache = true)) } + + assertSame(mockTokenResource, plain.await()) + assertSame(mockTokenResource, forced.await()) + coVerify(exactly = 2) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `a cancelled caller does not cancel the request the others are waiting on`() = + runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + delay(100) + ClerkResult.success(mockTokenResource) + } + + val abandoned = async { sessionTokenFetcher.getToken(mockSession) } + runCurrent() + val waiting = async { sessionTokenFetcher.getToken(mockSession) } + runCurrent() + abandoned.cancel() + + assertSame(mockTokenResource, waiting.await()) + coVerify(exactly = 1) { mockClerkApiService.tokens("session_123") } + } + + @Test + fun `resetForNewConfiguration cancels in-flight work and installs a fresh scope`() { + val oldScope = SessionTokenFetcher.currentFetchScope + val inFlight = oldScope.launch { awaitCancellation() } + assertTrue(inFlight.isActive) + + SessionTokenFetcher.resetForNewConfiguration() + + // Everything begun under the old configuration is cancelled, so it can never POST or write + // against the configuration installed next. + assertTrue(inFlight.isCancelled) + val newScope = SessionTokenFetcher.currentFetchScope + assertNotSame(oldScope, newScope) + assertTrue(newScope.isActive) + } + + @Test + fun `reset never exposes the cancelled scope and a later fetch runs on the fresh one`() { + val oldScope = SessionTokenFetcher.currentFetchScope + + SessionTokenFetcher.resetForNewConfiguration() + + // The field only ever holds an active scope: the fresh one is published before the old one is + // cancelled, so a caller reading it during reset never lands on a cancelled scope and its work + // is not cut with a spurious CancellationException. + val exposed = SessionTokenFetcher.currentFetchScope + assertNotSame(oldScope, exposed) + assertTrue(exposed.isActive) + + val afterReset = exposed.launch {} + assertFalse(afterReset.isCancelled) + } + + @Test + fun `a token minted under a replaced configuration is discarded`() = + runTest(testDispatcher) { + coEvery { SessionTokensCache.getToken(any()) } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + // The whole configuration is torn down while this request is in flight. + SessionTokenFetcher.resetForNewConfiguration() + ClerkResult.success(mockTokenResource) + } + + val result = sessionTokenFetcher.getToken(mockSession) + + // The POST went out, but its result belongs to the replaced configuration, so it is neither + // cached nor returned. + assertNull(result) + coVerify(exactly = 0) { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } + } + + @Test + fun `a fresh caller does not join a cancelling task left in the map`() = + runTest(testDispatcher) { + val cancellingScope = CoroutineScope(SupervisorJob() + testDispatcher) + val freshScope = CoroutineScope(SupervisorJob() + testDispatcher) + val cancellingFetcher = SessionTokenFetcher(mockJWTManager, backoff, cancellingScope) + val freshFetcher = SessionTokenFetcher(mockJWTManager, backoff, freshScope) + coEvery { SessionTokensCache.getToken("session_123") } returns null + coEvery { SessionTokensCache.setTokenIfCurrent(any(), any(), any(), any()) } returns Unit + coEvery { mockClerkApiService.tokens("session_123") } coAnswers + { + delay(100) + ClerkResult.success(mockTokenResource) + } + + val abandoned = async { cancellingFetcher.getToken(mockSession) } + runCurrent() + // The task's deferred is now cancelling (isActive false) but still discoverable in the map, + // the window a reset opens between clearing the map and the scope finishing cancelling. + cancellingScope.cancel() + + val result = freshFetcher.getToken(mockSession) + + // The fresh caller must start its own request rather than join the cancelling task and get + // its CancellationException. + assertSame(mockTokenResource, result) + coVerify(exactly = 2) { mockClerkApiService.tokens("session_123") } + + abandoned.cancel() + cancellingScope.cancel() + freshScope.cancel() + } + + private fun buildJwt(oiat: Long): String { + val header = """{"alg":"RS256","typ":"JWT","oiat":$oiat}""" + val payload = """{"sub":"user_1","iat":$oiat}""" + return "${header.base64Url()}.${payload.base64Url()}.signature" + } + + /** Installs a current client whose "session_123" session carries [jwt] as its lastActiveToken. */ + private fun stubCurrentSessionToken(jwt: String) { + val currentSession = mockk(relaxed = true) + every { currentSession.id } returns "session_123" + every { currentSession.lastActiveToken } returns TokenResource(jwt = jwt) + val client = mockk(relaxed = true) + every { client.sessions } returns listOf(currentSession) + every { Clerk.clientInitialized } returns true + every { Clerk.client } returns client + } + + private fun String.base64Url(): String = + Base64.encodeToString(toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } diff --git a/source/api/src/test/java/com/clerk/api/session/SessionTokensCacheTest.kt b/source/api/src/test/java/com/clerk/api/session/SessionTokensCacheTest.kt new file mode 100644 index 000000000..29ccc9036 --- /dev/null +++ b/source/api/src/test/java/com/clerk/api/session/SessionTokensCacheTest.kt @@ -0,0 +1,313 @@ +package com.clerk.api.session + +import android.util.Base64 +import com.clerk.api.network.model.token.TokenResource +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SessionTokensCacheTest { + + @Before + fun setup() { + SessionTokensCache.clear() + } + + @After + fun tearDown() { + SessionTokensCache.clear() + } + + @Test + fun `setToken stores the token when nothing is cached`() { + val token = tokenWith(oiat = 1_000L) + + SessionTokensCache.setToken("sess_1", token) + + assertEquals(token, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken replaces the cached token with a newer one`() { + val older = tokenWith(oiat = 1_000L) + val newer = tokenWith(oiat = 2_000L) + + SessionTokensCache.setToken("sess_1", older) + SessionTokensCache.setToken("sess_1", newer) + + assertEquals(newer, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken keeps the cached token when the incoming one is older`() { + val newer = tokenWith(oiat = 2_000L) + val older = tokenWith(oiat = 1_000L) + + SessionTokensCache.setToken("sess_1", newer) + SessionTokensCache.setToken("sess_1", older) + + assertEquals(newer, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken accepts an incoming token issued at the same instant`() { + val first = tokenWith(oiat = 2_000L, jwtId = "first") + val second = tokenWith(oiat = 2_000L, jwtId = "second") + + SessionTokensCache.setToken("sess_1", first) + SessionTokensCache.setToken("sess_1", second) + + assertEquals(second, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken accepts the incoming token when neither carries oiat`() { + // Matches clerk-js pickFreshestJwt: with no oiat on either side the guard cannot prove the + // cached token is fresher, so the incoming one wins regardless of iat. iat only breaks a tie + // under an equal oiat, never on its own. + val cached = tokenWith(iat = 2_000L) + val incoming = tokenWith(iat = 1_000L) + + SessionTokensCache.setToken("sess_1", cached) + SessionTokensCache.setToken("sess_1", incoming) + + assertEquals(incoming, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken prefers oiat over iat when ordering`() { + // A re-minted token can carry a fresh iat while its oiat still predates what is cached. + val cached = tokenWith(oiat = 2_000L, iat = 2_000L) + val incoming = tokenWith(oiat = 1_000L, iat = 9_000L) + + SessionTokensCache.setToken("sess_1", cached) + SessionTokensCache.setToken("sess_1", incoming) + + assertEquals(cached, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken keeps a readable cached token over an unreadable incoming one`() { + val cached = tokenWith(oiat = 2_000L) + val unreadable = TokenResource(jwt = "not-a-jwt") + + SessionTokensCache.setToken("sess_1", cached) + SessionTokensCache.setToken("sess_1", unreadable) + + assertEquals(cached, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken replaces an unreadable cached token with a readable one`() { + val unreadable = TokenResource(jwt = "not-a-jwt") + val readable = tokenWith(oiat = 2_000L) + + SessionTokensCache.setToken("sess_1", unreadable) + SessionTokensCache.setToken("sess_1", readable) + + assertEquals(readable, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken keeps a token carrying oiat over one that has none`() { + val minted = tokenWith(oiat = 1_000L, iat = 1_000L) + val preMinter = tokenWith(iat = 9_000L) + + SessionTokensCache.setToken("sess_1", minted) + SessionTokensCache.setToken("sess_1", preMinter) + + assertEquals(minted, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken accepts a token carrying oiat over a cached one without it`() { + val preMinter = tokenWith(iat = 9_000L) + val minted = tokenWith(oiat = 1_000L, iat = 1_000L) + + SessionTokensCache.setToken("sess_1", preMinter) + SessionTokensCache.setToken("sess_1", minted) + + assertEquals(minted, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken breaks an equal oiat by iat`() { + val fresherMint = tokenWith(oiat = 2_000L, iat = 5_000L) + val staleMint = tokenWith(oiat = 2_000L, iat = 4_000L) + + SessionTokensCache.setToken("sess_1", fresherMint) + SessionTokensCache.setToken("sess_1", staleMint) + + assertEquals(fresherMint, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setToken accepts a fresher iat under an equal oiat`() { + val staleMint = tokenWith(oiat = 2_000L, iat = 4_000L) + val fresherMint = tokenWith(oiat = 2_000L, iat = 5_000L) + + SessionTokensCache.setToken("sess_1", staleMint) + SessionTokensCache.setToken("sess_1", fresherMint) + + assertEquals(fresherMint, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setTokenIfCurrent writes when the session was not invalidated`() { + val generation = SessionTokensCache.generation("sess_1") + val token = tokenWith(oiat = 1_000L) + + SessionTokensCache.setTokenIfCurrent("sess_1", "sess_1", generation, token) + + assertEquals(token, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setTokenIfCurrent discards a response that raced a session invalidation`() { + val generation = SessionTokensCache.generation("sess_1") + SessionTokensCache.removeTokensForSession("sess_1") + + SessionTokensCache.setTokenIfCurrent("sess_1", "sess_1", generation, tokenWith(oiat = 1_000L)) + + assertNull(SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setTokenIfCurrent discards a response that raced a full clear`() { + val generation = SessionTokensCache.generation("sess_1") + SessionTokensCache.clear() + + SessionTokensCache.setTokenIfCurrent("sess_1", "sess_1", generation, tokenWith(oiat = 1_000L)) + + assertNull(SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `setTokenIfCurrent is unaffected by another session's invalidation`() { + val generation = SessionTokensCache.generation("sess_1") + SessionTokensCache.removeTokensForSession("sess_2") + val token = tokenWith(oiat = 1_000L) + + SessionTokensCache.setTokenIfCurrent("sess_1", "sess_1", generation, token) + + assertEquals(token, SessionTokensCache.getToken("sess_1")) + } + + @Test + fun `removeTokensForSession resets the shared failure schedule for that session`() { + SessionTokenBackoff.shared.recordFailure("sess_1") + SessionTokenBackoff.shared.recordFailure("sess_1-custom_template") + SessionTokenBackoff.shared.recordFailure("sess_2") + + SessionTokensCache.removeTokensForSession("sess_1") + + assertFalse(SessionTokenBackoff.shared.isBackingOff("sess_1")) + assertFalse(SessionTokenBackoff.shared.isBackingOff("sess_1-custom_template")) + assertTrue(SessionTokenBackoff.shared.isBackingOff("sess_2")) + } + + @Test + fun `clear resets every failure schedule`() { + SessionTokenBackoff.shared.recordFailure("sess_1") + + SessionTokensCache.clear() + + assertFalse(SessionTokenBackoff.shared.isBackingOff("sess_1")) + } + + @Test + fun `recordBackoffOutcomeIfCurrent records a failure while the generation still matches`() { + val backoff = SessionTokenBackoff { 0L } + val generation = SessionTokensCache.generation("sess_1") + + SessionTokensCache.recordBackoffOutcomeIfCurrent( + cacheKey = "sess_1", + sessionId = "sess_1", + generation = generation, + succeeded = false, + backoff = backoff, + ) + + assertTrue(backoff.isBackingOff("sess_1")) + } + + @Test + fun `recordBackoffOutcomeIfCurrent skips a failure once the session was invalidated`() { + val backoff = SessionTokenBackoff { 0L } + val generation = SessionTokensCache.generation("sess_1") + SessionTokensCache.removeTokensForSession("sess_1") + + SessionTokensCache.recordBackoffOutcomeIfCurrent( + cacheKey = "sess_1", + sessionId = "sess_1", + generation = generation, + succeeded = false, + backoff = backoff, + ) + + assertFalse(backoff.isBackingOff("sess_1")) + } + + @Test + fun `recordBackoffOutcomeIfCurrent skips a stale success so the new window survives`() { + val backoff = SessionTokenBackoff { 0L } + val staleGeneration = SessionTokensCache.generation("sess_1") + SessionTokensCache.removeTokensForSession("sess_1") + backoff.recordFailure("sess_1") + + SessionTokensCache.recordBackoffOutcomeIfCurrent( + cacheKey = "sess_1", + sessionId = "sess_1", + generation = staleGeneration, + succeeded = true, + backoff = backoff, + ) + + assertTrue(backoff.isBackingOff("sess_1")) + } + + @Test + fun `removeTokensForSession drops the default and templated entries for that session only`() { + val token = tokenWith(oiat = 1_000L) + SessionTokensCache.setToken("sess_1", token) + SessionTokensCache.setToken("sess_1-custom_template", token) + SessionTokensCache.setToken("sess_2", token) + + SessionTokensCache.removeTokensForSession("sess_1") + + assertNull(SessionTokensCache.getToken("sess_1")) + assertFalse(SessionTokensCache.containsKey("sess_1-custom_template")) + assertTrue(SessionTokensCache.containsKey("sess_2")) + assertEquals(1, SessionTokensCache.size) + } + + private fun tokenWith(oiat: Long? = null, iat: Long? = null, jwtId: String? = null) = + TokenResource(jwt = buildJwt(oiat = oiat, iat = iat, jwtId = jwtId)) + + private fun buildJwt(oiat: Long?, iat: Long?, jwtId: String?): String { + val header = + buildString { + append("""{"alg":"RS256","typ":"JWT"""") + if (oiat != null) append(""","oiat":$oiat""") + append("}") + } + val payload = + buildString { + append("""{"sub":"user_1"""") + if (iat != null) append(""","iat":$iat""") + if (jwtId != null) append(""","jti":"$jwtId"""") + append("}") + } + return "${header.base64Url()}.${payload.base64Url()}.signature" + } + + private fun String.base64Url(): String = + Base64.encodeToString(toByteArray(), Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) +} diff --git a/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt b/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt index 9b7133435..3ab6e1421 100644 --- a/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt +++ b/source/api/src/test/java/com/clerk/api/signout/SignOutServiceTest.kt @@ -9,8 +9,10 @@ import com.clerk.api.network.model.client.Client import com.clerk.api.network.model.environment.DisplayConfig import com.clerk.api.network.model.environment.Environment import com.clerk.api.network.model.environment.UserSettings +import com.clerk.api.network.model.token.TokenResource import com.clerk.api.network.serialization.ClerkResult import com.clerk.api.session.Session +import com.clerk.api.session.SessionTokensCache import com.clerk.api.storage.StorageHelper import com.clerk.api.storage.StorageKey import com.clerk.api.user.User @@ -27,6 +29,7 @@ import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -93,6 +96,7 @@ class SignOutServiceTest { Dispatchers.resetMain() unmockkAll() StorageHelper.reset(context) + SessionTokensCache.clear() } private fun setupActiveSession() { @@ -111,6 +115,31 @@ class SignOutServiceTest { Clerk.environment = mockEnvironment } + @Test + fun `signOut clears every cached session token`() = runTest { + setupActiveSession() + SessionTokensCache.setToken("test_session_id", TokenResource(jwt = "cached.jwt.token")) + SessionTokensCache.setToken("other_session_id", TokenResource(jwt = "other.jwt.token")) + coEvery { mockSessionApi.deleteSessions() } returns ClerkResult.success(Client()) + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(Client()) + + SignOutService.signOut() + + assertEquals(0, SessionTokensCache.size) + } + + @Test + fun `signOut clears cached session tokens even when server sign-out fails`() = runTest { + setupActiveSession() + SessionTokensCache.setToken("test_session_id", TokenResource(jwt = "cached.jwt.token")) + coEvery { mockSessionApi.deleteSessions() } throws Exception("Network error") + coEvery { mockClientApi.getSkippingClientId(any()) } returns ClerkResult.success(Client()) + + SignOutService.signOut() + + assertEquals(0, SessionTokensCache.size) + } + @Test fun `signOut clears device token on successful server sign-out`() = runTest { // Given - active session with device token stored