diff --git a/src/main/kotlin/com/workos/android/helpers/Session.kt b/src/main/kotlin/com/workos/android/helpers/Session.kt index 82cb99b..2804e6c 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 } @@ -414,6 +424,22 @@ public class Session internal constructor( 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 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?, ): SessionCookie { val clientId = requireNotNull(client.configuration.clientId) { @@ -424,7 +450,7 @@ 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), ) } @@ -432,7 +458,19 @@ public class Session internal constructor( public suspend fun authenticateWithSessionCookie( sessionData: String?, cookiePassword: String, - ): AuthenticateSessionResult = loadSealedSession(sessionData, cookiePassword).authenticate() + ): 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 issuer is not checked. See [loadSealedSession]. + */ + public suspend fun authenticateWithSessionCookie( + sessionData: String?, + cookiePassword: String, + issuers: List?, + ): 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 {