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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/Clerk.kt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

A stale token from the old configuration can survive reset() and remain usable after re-initialization. SessionTokenFetcher.resetForNewConfiguration() bumps the configuration epoch only after SessionTokensCache.clear() and ClerkApi.reset() have already run, so a concurrent getToken admitted after the cache clears but before the epoch bump captures the new cache generation with the old epoch. If that request reaches the old Retrofit service and completes before the bump, setTokenIfCurrent accepts its token (the generation still matches), and the later epoch bump never clears it. The stale token then persists into the next configuration. Move the resetForNewConfiguration() call before SessionTokensCache.clear() and ClerkApi.reset(), so any request that could still reach the old service captured the old epoch and is discarded.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @source/api/src/main/kotlin/com/clerk/api/Clerk.kt around line 763:

A stale token from the old configuration can survive `reset()` and remain usable after re-initialization. `SessionTokenFetcher.resetForNewConfiguration()` bumps the configuration epoch only after `SessionTokensCache.clear()` and `ClerkApi.reset()` have already run, so a concurrent `getToken` admitted after the cache clears but before the epoch bump captures the new cache generation with the old epoch. If that request reaches the old Retrofit service and completes before the bump, `setTokenIfCurrent` accepts its token (the generation still matches), and the later epoch bump never clears it. The stale token then persists into the next configuration. Move the `resetForNewConfiguration()` call before `SessionTokensCache.clear()` and `ClerkApi.reset()`, so any request that could still reach the old service captured the old epoch and is discarded.

Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
38 changes: 33 additions & 5 deletions source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Comment on lines +643 to 645

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Clear the removed session’s persisted token during the refresh.

refreshClientAfterSessionMutation() can install a stale Client.get() response after the successful removal. Because no droppedTokenSessionId is provided here, a reintroduced removed session can retain lastActiveToken and seed a later mint request. Pass sessionId and add a regression case where the refresh returns the removed session with a token.

Proposed fix
-            refreshClientAfterSessionMutation()
+            refreshClientAfterSessionMutation(droppedTokenSessionId = sessionId)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
SessionTokensCache.removeTokensForSession(sessionId)
removeSessionLocally(sessionId)
refreshClientAfterSessionMutation()
SessionTokensCache.removeTokensForSession(sessionId)
removeSessionLocally(sessionId)
refreshClientAfterSessionMutation(droppedTokenSessionId = sessionId)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@source/api/src/main/kotlin/com/clerk/api/auth/Auth.kt` around lines 643 -
645, Update the session-removal flow around removeSessionLocally and
refreshClientAfterSessionMutation to pass the removed sessionId as the
dropped-token session identifier. Add a regression case covering a refresh that
reintroduces the removed session with a token, and verify its persisted
lastActiveToken is cleared.

ClerkResult.success(Unit)
Expand Down Expand Up @@ -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?,
Expand Down Expand Up @@ -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,
Expand All @@ -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)
Expand All @@ -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<Session>.withUpdatedSession(
Expand Down
29 changes: 29 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/network/api/SessionApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,22 @@ 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
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.
Expand Down Expand Up @@ -80,12 +83,38 @@ internal interface SessionApi {
@Path(ApiParams.ID) sessionId: String
): ClerkResult<TokenResource, ClerkErrorResponse>

/**
* 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<TokenResource, ClerkErrorResponse>

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,20 @@ 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(
/**
* 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,
)
64 changes: 64 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/session/JwtFreshness.kt
Original file line number Diff line number Diff line change
@@ -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?)
}
Original file line number Diff line number Diff line change
@@ -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<String, Window>()

/** 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()
}
}
Loading