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