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 {