Skip to content
Merged
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
48 changes: 43 additions & 5 deletions src/main/kotlin/com/workos/android/helpers/Session.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = null,
) : JwtVerifier {
private val issuers: List<String>? = issuers?.toList()

/**
* The JWKS fetch is blocking, so it runs on [Dispatchers.IO] — calling this
* from the main thread must not stall the UI.
Expand All @@ -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
}
Expand Down Expand Up @@ -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/<clientId>`, 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<String>?,
): SessionCookie {
val clientId =
requireNotNull(client.configuration.clientId) {
Expand All @@ -424,15 +450,27 @@ 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. */
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<String>?,
): AuthenticateSessionResult = loadSealedSession(sessionData, cookiePassword, issuers).authenticate()

/** Refresh a sealed cookie in one call. */
public suspend fun refreshSession(
Expand Down
103 changes: 103 additions & 0 deletions src/test/kotlin/com/workos/android/SessionTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AuthenticateSessionResult.Success>(
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<AuthenticateSessionResult.Success>(
client.session.loadSealedSession(sealed, PASSWORD, listOf("https://issuer.example")).authenticate(),
)
}
}

@Test
fun `H05 authenticateWithSessionCookie reports a bad cookie as a typed failure`() =
runTest {
Expand Down
Loading