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
2 changes: 2 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/Clerk.kt
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 @@ -751,6 +752,7 @@ object Clerk {
StorageHelper.deleteValue(StorageKey.DEVICE_TOKEN)
StorageHelper.deleteValue(StorageKey.SHARED_SESSION_SYNC_SNAPSHOT)
clearSessionAndUserState()
SessionTokenFetcher.shared.reset()
SessionTokensCache.clear()
SSOService.cancelPendingAuthentication()
ExternalAccountService.cancelPendingExternalAccountConnection()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import com.clerk.api.network.model.error.ClerkErrorResponse
import com.clerk.api.network.serialization.ClerkResult
import com.clerk.api.network.serialization.fold
import com.clerk.api.session.GetTokenOptions
import com.clerk.api.session.SessionTokenFetcher
import com.clerk.api.session.SessionTokensCache
import com.clerk.api.session.fetchToken
import com.clerk.api.sso.SSOService
Expand Down Expand Up @@ -348,6 +349,7 @@ internal class ConfigurationManager {
StorageHelper.deleteValue(StorageKey.DEVICE_TOKEN)
Clerk.updateClient(Client())
Clerk.clearSessionAndUserState()
SessionTokenFetcher.shared.reset()
SessionTokensCache.clear()

val result =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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
Expand Down Expand Up @@ -76,8 +77,12 @@ internal interface SessionApi {
* failure
*/
@POST(ApiPaths.Client.Sessions.TOKENS)
@FormUrlEncoded
suspend fun tokens(
@Path(ApiParams.ID) sessionId: String
@Path(ApiParams.ID) sessionId: String,
@Field("organization_id") organizationId: String = "",
@Field("token") token: String? = null,
@Field("force_origin") forceOrigin: String? = null,
): ClerkResult<TokenResource, ClerkErrorResponse>

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ 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 session token minting at the edge is enabled.
*/
@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 session token minting at the edge is enabled. */
@SerialName("session_minter") val sessionMinter: Boolean = false,
)
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ suspend fun Session.delete(): ClerkResult<Client, ClerkErrorResponse> {
suspend fun Session.fetchToken(
options: GetTokenOptions = GetTokenOptions()
): ClerkResult<TokenResource, ClerkErrorResponse> {
val token = SessionTokenFetcher().getToken(this, options)
val token = SessionTokenFetcher.shared.getToken(this, options)
return if (token != null) {
ClerkResult.success(token)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@ import kotlinx.coroutines.Deferred
* @param jwtManager The JWT manager used for token parsing and validation
*/
internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManagerImpl()) {
private companion object {
val sessionInvalidationErrorCodes =
internal companion object {
internal val shared: SessionTokenFetcher by lazy { SessionTokenFetcher() }

private val sessionInvalidationErrorCodes =
setOf(
"session_revoked",
"session_expired",
Expand All @@ -40,9 +42,23 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag
)
}

private data class FetchContext(
val session: Session,
val cacheKey: String,
val sessionMinterEnabled: Boolean,
)

/** Map of cache keys to deferred token fetch tasks for request deduplication */
private val tokenTasks = ConcurrentHashMap<String, Deferred<TokenResource?>>()

/**
* Releases deduplicated waiters and removes requests registered by the previous Clerk runtime.
*/
internal fun reset() {

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 session/SessionTokenFetcher.kt:57

reset() cancels the waiter Deferreds but not the in-flight fetchToken coroutine, so an ongoing token request can keep running after reset, write its JWT into the shared SessionTokensCache, and return a token from the old configuration to the caller. This stale credential can contaminate the new runtime. Consider cancelling or fencing the actual fetch work so an in-flight request cannot store results after reset.

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

`reset()` cancels the waiter `Deferred`s but not the in-flight `fetchToken` coroutine, so an ongoing token request can keep running after reset, write its JWT into the shared `SessionTokensCache`, and return a token from the old configuration to the caller. This stale credential can contaminate the new runtime. Consider cancelling or fencing the actual fetch work so an in-flight request cannot store results after reset.

tokenTasks.values.forEach { it.cancel() }
tokenTasks.clear()
}

/**
* Retrieves a token for the specified session with the given options.
*
Expand Down Expand Up @@ -76,31 +92,46 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag
session: Session,
options: GetTokenOptions,
): TokenResource? {
val cacheKey = session.tokenCacheKey(options.template)
val context = makeFetchContext(session, options.template)
ClerkLog.d(
"Fetching token for session ${session.id} with options: $options and cache key: $cacheKey"
"Fetching token for session ${context.session.id} with options: $options and cache key: " +
context.cacheKey
)

return tokenTasks[cacheKey]?.await()
if (options.skipCache) {
return fetchToken(context, options)
}

return tokenTasks[context.cacheKey]?.await()
?: run {
val deferred = CompletableDeferred<TokenResource?>()
val existingTask = tokenTasks.putIfAbsent(cacheKey, deferred)
val existingTask = tokenTasks.putIfAbsent(context.cacheKey, deferred)

existingTask?.await()
?: try {
fetchToken(session, options).also { deferred.complete(it) }
fetchToken(context, 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)
tokenTasks.remove(context.cacheKey, deferred)
}
}
}

private fun makeFetchContext(session: Session, template: String?): FetchContext {
val currentSession =
Clerk.clientFlow.value?.sessions?.firstOrNull { it.id == session.id } ?: session
return FetchContext(
session = currentSession,
cacheKey = currentSession.tokenCacheKey(template),
sessionMinterEnabled = Clerk.environment?.authConfig?.sessionMinter == true,
)
}

/**
* Internal method to fetch a token from cache or network.
*
Expand All @@ -112,8 +143,17 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag
* @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? {
val cacheKey = session.tokenCacheKey(options.template)
private suspend fun fetchToken(context: FetchContext, options: GetTokenOptions): TokenResource? {
val session = context.session
val cacheKey = context.cacheKey

if (options.template == null) {
session.lastActiveToken
?.takeIf {
TokenFreshness.matches(it, session.id, session.lastActiveOrganizationId)
}
?.let { SessionTokensCache.hydrate(cacheKey, it) }
}

// Check cache first (unless skipped)
if (!options.skipCache) {
Expand All @@ -134,12 +174,25 @@ internal class SessionTokenFetcher(private val jwtManager: JWTManager = JWTManag
if (options.template != null) {
ClerkApi.session.tokens(session.id, options.template)
} else {
ClerkApi.session.tokens(session.id)
val cachedToken = SessionTokensCache.getToken(cacheKey)
val previousToken =
cachedToken?.let {
TokenFreshness.pickFreshest(
existing = session.lastActiveToken,
incoming = it,
)
} ?: session.lastActiveToken
ClerkApi.session.tokens(
sessionId = session.id,
organizationId = session.lastActiveOrganizationId.orEmpty(),
token = previousToken?.jwt.takeIf { context.sessionMinterEnabled },
forceOrigin = "true".takeIf { context.sessionMinterEnabled && options.skipCache },
)
}

when (tokensRequest) {
is ClerkResult.Success -> {
SessionTokensCache.setToken(cacheKey, tokensRequest.value)
SessionTokensCache.storeIfFresher(cacheKey, tokensRequest.value)
tokensRequest.value
Comment on lines +195 to 196

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 session/SessionTokenFetcher.kt:195

fetchToken stores the response via SessionTokensCache.storeIfFresher(...) but then returns tokensRequest.value instead of the canonicalToken that call returns. When two skipCache requests complete out of order, the late, stale response is correctly prevented from replacing the cache, but that stale response's caller still receives the stale JWT rather than the fresher canonical token — defeating the freshness guarantee for the immediate result. Consider returning the StoreResult.canonicalToken instead of the raw response.

Suggested change
SessionTokensCache.storeIfFresher(cacheKey, tokensRequest.value)
tokensRequest.value
SessionTokensCache.storeIfFresher(cacheKey, tokensRequest.value).canonicalToken
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @source/api/src/main/kotlin/com/clerk/api/session/SessionTokenFetcher.kt around lines 195-196:

`fetchToken` stores the response via `SessionTokensCache.storeIfFresher(...)` but then returns `tokensRequest.value` instead of the `canonicalToken` that call returns. When two `skipCache` requests complete out of order, the late, stale response is correctly prevented from replacing the cache, but that stale response's caller still receives the stale JWT rather than the fresher canonical token — defeating the freshness guarantee for the immediate result. Consider returning the `StoreResult.canonicalToken` instead of the raw response.

}
is ClerkResult.Failure -> {
Expand Down Expand Up @@ -228,10 +281,11 @@ data class GetTokenOptions(
/**
* Extension function to generate a cache key for session tokens.
*
* This function creates a unique cache key based on the session ID and optional template name. This
* ensures that tokens for different templates are cached separately.
* This function creates a unique cache key based on the session ID and either its active
* organization or the optional template name.
*
* @param template Optional template name to include in the cache key
* @return A unique cache key string for the session and template combination
*/
internal fun Session.tokenCacheKey(template: String?): String = template?.let { "$id-$it" } ?: id
internal fun Session.tokenCacheKey(template: String?): String =
template?.let { "$id-template-$it" } ?: "$id-organization-${lastActiveOrganizationId.orEmpty()}"
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import java.util.concurrent.ConcurrentHashMap
internal object SessionTokensCache {
private val cache = ConcurrentHashMap<String, TokenResource>()

internal data class StoreResult(
val canonicalToken: TokenResource,
val didChangeCanonicalToken: Boolean,
)

/** Returns a session token for the given cache key. */
internal fun getToken(cacheKey: String): TokenResource? = cache[cacheKey]

Expand All @@ -14,6 +19,35 @@ internal object SessionTokensCache {
cache[cacheKey] = token
}

/** Reconciles a session snapshot without replacing an equally fresh canonical token. */
internal fun hydrate(cacheKey: String, token: TokenResource) {
cache.compute(cacheKey) { _, existing ->
TokenFreshness.pickFreshest(
existing = existing,
incoming = token,
tieBreaker = TokenFreshness.TieBreaker.EXISTING,
)
}
}

/** Atomically stores [token] unless the cache already contains a fresher token. */
internal fun storeIfFresher(
cacheKey: String,
token: TokenResource,
nowMillis: Long = System.currentTimeMillis(),
): StoreResult {
var didChangeCanonicalToken = false
val canonicalToken =
checkNotNull(
cache.compute(cacheKey) { _, existing ->
TokenFreshness.pickFreshest(existing, token, nowMillis).also { canonical ->
didChangeCanonicalToken = existing?.jwt != canonical.jwt
}
}
)
return StoreResult(canonicalToken, didChangeCanonicalToken)
}

/** Removes a session token for the given cache key. */
internal fun removeToken(cacheKey: String): TokenResource? = cache.remove(cacheKey)

Expand Down
Loading