From b617c3a661f6d9e9a2335601967b775d4cd6f5fd Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 10 Sep 2026 18:51:50 +0000 Subject: [PATCH 1/2] Add optional issuers check to session authentication Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/workos/android/helpers/Session.kt | 41 +++++-- .../kotlin/com/workos/android/SessionTest.kt | 103 ++++++++++++++++++ 2 files changed, 137 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/workos/android/helpers/Session.kt b/src/main/kotlin/com/workos/android/helpers/Session.kt index 82cb99b..fdebf39 100644 --- a/src/main/kotlin/com/workos/android/helpers/Session.kt +++ b/src/main/kotlin/com/workos/android/helpers/Session.kt @@ -207,11 +207,19 @@ private object Jwks { } } -/** [JwtVerifier] backed by the live JWKS at `{baseUrl}/sso/jwks/{clientId}`. */ +/** + * [JwtVerifier] backed by the live JWKS at `{baseUrl}/sso/jwks/{clientId}`. + * + * When [issuers] is non-null the token's `iss` claim must exactly match one of + * its entries; when null the issuer is not checked. + */ internal class JwksVerifier( private val baseUrl: String, private val clientId: String, + issuers: List? = null, ) : JwtVerifier { + private val issuers: List? = issuers?.toList() + /** * The JWKS fetch is blocking, so it runs on [Dispatchers.IO] — calling this * from the main thread must not stall the UI. @@ -222,8 +230,10 @@ internal class JwksVerifier( // Rejects a bad signature, an unknown `kid`, and an expired or // not-yet-valid token: DefaultJWTProcessor installs a claims // verifier that checks `exp`/`nbf`. - Jwks.processor(baseUrl, clientId).process(SignedJWT.parse(accessToken), null) - true + val claims = Jwks.processor(baseUrl, clientId).process(SignedJWT.parse(accessToken), null) + val accepted = issuers ?: return@withContext true + val iss = claims.issuer + iss != null && iss in accepted } catch (_: Exception) { false } @@ -410,10 +420,21 @@ public class SessionCookie internal constructor( public class Session internal constructor( private val client: WorkOSClient, ) { - /** Wrap an inbound sealed-cookie value so it can be authenticated or refreshed. */ + /** + * Wrap an inbound sealed-cookie value so it can be authenticated or refreshed. + * + * @param issuers Accepted values for the access token's `iss` claim; when null + * (the default) the issuer is not checked. The match is exact + * (case-sensitive). The `iss` value WorkOS mints varies by environment — + * `https://api.workos.com`, `https://api.workos.com/user_management/`, + * or a custom auth domain — so pass the precise value(s) your tokens + * actually carry. Enforced by [SessionCookie.authenticate] only, not + * [SessionCookie.refresh]. + */ public fun loadSealedSession( sessionData: String?, cookiePassword: String, + issuers: List? = null, ): SessionCookie { val clientId = requireNotNull(client.configuration.clientId) { @@ -424,15 +445,21 @@ public class Session internal constructor( userManagement = client.userManagement, sessionData = sessionData, cookiePassword = cookiePassword, - verifier = JwksVerifier(client.configuration.baseUrl, clientId), + verifier = JwksVerifier(client.configuration.baseUrl, clientId, issuers), ) } - /** Authenticate a sealed cookie in one call. */ + /** + * Authenticate a sealed cookie in one call. + * + * @param issuers Accepted values for the access token's `iss` claim; when null + * (the default) the issuer is not checked. See [loadSealedSession]. + */ public suspend fun authenticateWithSessionCookie( sessionData: String?, cookiePassword: String, - ): AuthenticateSessionResult = loadSealedSession(sessionData, cookiePassword).authenticate() + issuers: List? = null, + ): AuthenticateSessionResult = loadSealedSession(sessionData, cookiePassword, issuers).authenticate() /** Refresh a sealed cookie in one call. */ public suspend fun refreshSession( diff --git a/src/test/kotlin/com/workos/android/SessionTest.kt b/src/test/kotlin/com/workos/android/SessionTest.kt index debe2c4..9faa541 100644 --- a/src/test/kotlin/com/workos/android/SessionTest.kt +++ b/src/test/kotlin/com/workos/android/SessionTest.kt @@ -401,6 +401,88 @@ class SessionTest { } } + @Test + fun `JwksVerifier ignores the issuer when none is configured`() = + runTest { + jwksServer().use { server -> + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123") + assertTrue(verifier.isValid(signedJwt(claims = mapOf("iss" to "https://issuer.example")))) + } + } + + @Test + fun `JwksVerifier accepts a token whose iss matches a configured issuer`() = + runTest { + jwksServer().use { server -> + val verifier = + JwksVerifier( + server.url("/").toString().trimEnd('/'), + "client_test_123", + issuers = listOf("https://issuer.example"), + ) + assertTrue(verifier.isValid(signedJwt(claims = mapOf("iss" to "https://issuer.example")))) + } + } + + @Test + fun `JwksVerifier accepts any issuer in the configured list`() = + runTest { + jwksServer().use { server -> + val issuers = listOf("https://api.workos.com", "https://api.workos.com/user_management/client_test_123") + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123", issuers) + assertTrue(verifier.isValid(signedJwt(claims = mapOf("iss" to issuers[0])))) + assertTrue(verifier.isValid(signedJwt(claims = mapOf("iss" to issuers[1])))) + } + } + + @Test + fun `JwksVerifier rejects a token whose iss does not match the configured issuer`() = + runTest { + jwksServer().use { server -> + val verifier = + JwksVerifier( + server.url("/").toString().trimEnd('/'), + "client_test_123", + issuers = listOf("https://issuer.example"), + ) + assertFalse(verifier.isValid(signedJwt(claims = mapOf("iss" to "https://other.example")))) + } + } + + @Test + fun `JwksVerifier rejects a token without iss when an issuer is configured`() = + runTest { + jwksServer().use { server -> + val verifier = + JwksVerifier( + server.url("/").toString().trimEnd('/'), + "client_test_123", + issuers = listOf("https://issuer.example"), + ) + assertFalse(verifier.isValid(signedJwt())) + } + } + + @Test + fun `JwksVerifier rejects every token when the issuer list is empty`() = + runTest { + jwksServer().use { server -> + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123", emptyList()) + assertFalse(verifier.isValid(signedJwt(claims = mapOf("iss" to "https://issuer.example")))) + } + } + + @Test + fun `JwksVerifier snapshots the issuer list it was given`() = + runTest { + jwksServer().use { server -> + val issuers = mutableListOf("https://issuer.example") + val verifier = JwksVerifier(server.url("/").toString().trimEnd('/'), "client_test_123", issuers) + issuers += "https://other.example" + assertFalse(verifier.isValid(signedJwt(claims = mapOf("iss" to "https://other.example")))) + } + } + // ---------------------------------------------------------------- refresh @Test @@ -699,6 +781,27 @@ class SessionTest { } } + @Test + fun `H05 authenticateWithSessionCookie enforces a configured issuer`() = + runTest { + routedServer().use { server -> + val client = clientAgainst(server) + val token = signedJwt(claims = mapOf("sid" to "session_01ISS", "iss" to "https://issuer.example")) + val sealed = Iron.seal(encodeSessionCookie(SessionCookieData(token, "tok_r")), PASSWORD) + + assertIs( + client.session.authenticateWithSessionCookie(sealed, PASSWORD, listOf("https://issuer.example")), + ) + assertEquals( + AuthenticateSessionResult.Failure(AuthenticateSessionFailureReason.INVALID_JWT), + client.session.authenticateWithSessionCookie(sealed, PASSWORD, listOf("https://other.example")), + ) + assertIs( + client.session.loadSealedSession(sealed, PASSWORD, listOf("https://issuer.example")).authenticate(), + ) + } + } + @Test fun `H05 authenticateWithSessionCookie reports a bad cookie as a typed failure`() = runTest { From 25efbb54f325b2d749404b7eeb5ca11b6a49e76b Mon Sep 17 00:00:00 2001 From: "madison.packer" Date: Thu, 10 Sep 2026 18:56:01 +0000 Subject: [PATCH 2/2] Preserve two-argument Session helper overloads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../com/workos/android/helpers/Session.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main/kotlin/com/workos/android/helpers/Session.kt b/src/main/kotlin/com/workos/android/helpers/Session.kt index fdebf39..2804e6c 100644 --- a/src/main/kotlin/com/workos/android/helpers/Session.kt +++ b/src/main/kotlin/com/workos/android/helpers/Session.kt @@ -420,21 +420,26 @@ public class SessionCookie internal constructor( public class Session internal constructor( private val client: WorkOSClient, ) { + /** Wrap an inbound sealed-cookie value so it can be authenticated or refreshed. */ + public fun loadSealedSession( + sessionData: String?, + cookiePassword: String, + ): SessionCookie = loadSealedSession(sessionData, cookiePassword, issuers = null) + /** * Wrap an inbound sealed-cookie value so it can be authenticated or refreshed. * * @param issuers Accepted values for the access token's `iss` claim; when null - * (the default) the issuer is not checked. The match is exact - * (case-sensitive). The `iss` value WorkOS mints varies by environment — - * `https://api.workos.com`, `https://api.workos.com/user_management/`, - * or a custom auth domain — so pass the precise value(s) your tokens - * actually carry. Enforced by [SessionCookie.authenticate] only, not - * [SessionCookie.refresh]. + * the issuer is not checked. The match is exact (case-sensitive). The `iss` + * value WorkOS mints varies by environment — `https://api.workos.com`, + * `https://api.workos.com/user_management/`, or a custom auth + * domain — so pass the precise value(s) your tokens actually carry. + * Enforced by [SessionCookie.authenticate] only, not [SessionCookie.refresh]. */ public fun loadSealedSession( sessionData: String?, cookiePassword: String, - issuers: List? = null, + issuers: List?, ): SessionCookie { val clientId = requireNotNull(client.configuration.clientId) { @@ -449,16 +454,22 @@ public class Session internal constructor( ) } + /** Authenticate a sealed cookie in one call. */ + public suspend fun authenticateWithSessionCookie( + sessionData: String?, + cookiePassword: String, + ): AuthenticateSessionResult = authenticateWithSessionCookie(sessionData, cookiePassword, issuers = null) + /** * Authenticate a sealed cookie in one call. * * @param issuers Accepted values for the access token's `iss` claim; when null - * (the default) the issuer is not checked. See [loadSealedSession]. + * the issuer is not checked. See [loadSealedSession]. */ public suspend fun authenticateWithSessionCookie( sessionData: String?, cookiePassword: String, - issuers: List? = null, + issuers: List?, ): AuthenticateSessionResult = loadSealedSession(sessionData, cookiePassword, issuers).authenticate() /** Refresh a sealed cookie in one call. */