From 5a03681e11872937a3603c163e46f8e6eb7e978f Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Wed, 4 Nov 2026 23:57:48 +0000 Subject: [PATCH] fix(session): Reject non-user access tokens in session cookies Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/workos/android/helpers/Session.kt | 11 +++++++ .../kotlin/com/workos/android/SessionTest.kt | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/kotlin/com/workos/android/helpers/Session.kt b/src/main/kotlin/com/workos/android/helpers/Session.kt index 2804e6c..fb2e627 100644 --- a/src/main/kotlin/com/workos/android/helpers/Session.kt +++ b/src/main/kotlin/com/workos/android/helpers/Session.kt @@ -231,6 +231,7 @@ internal class JwksVerifier( // not-yet-valid token: DefaultJWTProcessor installs a claims // verifier that checks `exp`/`nbf`. val claims = Jwks.processor(baseUrl, clientId).process(SignedJWT.parse(accessToken), null) + if (!isUserAccessToken(claims)) return@withContext false val accepted = issuers ?: return@withContext true val iss = claims.issuer iss != null && iss in accepted @@ -238,6 +239,16 @@ internal class JwksVerifier( false } } + + // Other token types signed by the same environment key (agent and widget + // tokens) must not authenticate a session cookie. + private fun isUserAccessToken(claims: JWTClaimsSet): Boolean { + val subProfile = claims.getClaim("sub_profile") + return claims.getClaim("sid") is String && + claims.getClaim("auth_time") is Number && + (subProfile == null || subProfile == "user") && + claims.getClaim("sub_type") == null + } } /** diff --git a/src/test/kotlin/com/workos/android/SessionTest.kt b/src/test/kotlin/com/workos/android/SessionTest.kt index 9faa541..b3e5ab4 100644 --- a/src/test/kotlin/com/workos/android/SessionTest.kt +++ b/src/test/kotlin/com/workos/android/SessionTest.kt @@ -61,6 +61,8 @@ private fun signedJwt( .Builder() .subject("user_01ABC") .expirationTime(Date(System.currentTimeMillis() + expiresInSeconds * 1000)) + .claim("sid", "session_01ABC") + .claim("auth_time", System.currentTimeMillis() / 1000) for ((k, v) in claims) builder.claim(k, v) val jwt = SignedJWT(JWSHeader.Builder(JWSAlgorithm.RS256).keyID(key.keyID).build(), builder.build()) jwt.sign(RSASSASigner(key)) @@ -401,6 +403,37 @@ class SessionTest { } } + @Test + fun `JwksVerifier rejects tokens that are not user access tokens`() = + runTest { + jwksServer().use { server -> + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123") + val nonUserClaims = + listOf( + mapOf("sub_profile" to "ai_agent"), + mapOf("sub_type" to "authkit"), + mapOf("sub_type" to "standalone"), + mapOf("sid" to null), + mapOf("sid" to 123), + mapOf("auth_time" to null), + mapOf("auth_time" to "1700000000"), + ) + for (claims in nonUserClaims) { + assertFalse(verifier.isValid(signedJwt(claims = claims)), "accepted $claims") + } + } + } + + @Test + fun `JwksVerifier accepts user access tokens with or without sub_profile user`() = + runTest { + jwksServer().use { server -> + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123") + assertTrue(verifier.isValid(signedJwt())) + assertTrue(verifier.isValid(signedJwt(claims = mapOf("sub_profile" to "user")))) + } + } + @Test fun `JwksVerifier ignores the issuer when none is configured`() = runTest {