From 6cd55bcb9cca3ad81992ca62b9e538e043e5adfc Mon Sep 17 00:00:00 2001 From: Johanan Lai Date: Thu, 16 Jul 2026 17:21:37 -0700 Subject: [PATCH 1/4] Generalize PolicyCache to also support Consumer --- .../com/stytch/java/common/BasePolicyCache.kt | 119 ++++++++ .../stytch/java/common/ConsumerPolicyCache.kt | 42 +++ .../com/stytch/java/common/PolicyCache.kt | 89 ++---- .../com/stytch/java/consumer/StytchClient.kt | 5 +- .../java/common/ConsumerPolicyCacheTest.kt | 284 ++++++++++++++++++ .../consumer/api/sessions/SessionsTest.kt | 1 + 6 files changed, 470 insertions(+), 70 deletions(-) create mode 100644 stytch/src/main/kotlin/com/stytch/java/common/BasePolicyCache.kt create mode 100644 stytch/src/main/kotlin/com/stytch/java/common/ConsumerPolicyCache.kt create mode 100644 stytch/src/test/kotlin/com/stytch/java/common/ConsumerPolicyCacheTest.kt diff --git a/stytch/src/main/kotlin/com/stytch/java/common/BasePolicyCache.kt b/stytch/src/main/kotlin/com/stytch/java/common/BasePolicyCache.kt new file mode 100644 index 00000000..6a4e6325 --- /dev/null +++ b/stytch/src/main/kotlin/com/stytch/java/common/BasePolicyCache.kt @@ -0,0 +1,119 @@ +package com.stytch.java.common + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.Instant + +public class PermissionException( + // Accepts either the B2B or consumer AuthorizationCheck; rendered via toString for the message. + authorizationCheck: Any, +) : RuntimeException("Permission denied for request $authorizationCheck") + +/** + * A normalized view of an RBAC Role used for local authorization checks. Both the B2B and consumer + * policy caches map their (distinct) generated Role types into this shape so the matching logic can + * be shared. + */ +internal data class RoleView( + val roleId: String, + val permissions: List, +) + +internal data class PermissionView( + val resourceId: String, + val actions: List, +) + +/** + * Returns true if any of the subject's roles grants the given action on the given resource. + * A permission matches when its resource matches and its actions contain the requested action or + * the "*" wildcard. + */ +internal fun hasRolePermission( + subjectRoles: List, + roles: List, + resourceId: String, + action: String, +): Boolean = + roles + .filter { it.roleId in subjectRoles } + .flatMap { it.permissions } + .any { + val hasMatchingAction = it.actions.contains("*") || it.actions.contains(action) + val hasMatchingResource = it.resourceId == resourceId + hasMatchingAction && hasMatchingResource + } + +/** + * Shared caching machinery for a project-level RBAC policy. Subclasses supply how to fetch the + * policy and may refresh additional caches (e.g. org policies) during the background refresh loop. + * + * The policy is fetched on first use and refreshed both lazily (when stale) and by a background + * coroutine on a fixed interval. + */ +internal abstract class BasePolicyCache

( + coroutineScope: CoroutineScope, +) { + private val job = SupervisorJob(coroutineScope.coroutineContext[Job]) + protected val scope: CoroutineScope = CoroutineScope(coroutineScope.coroutineContext + job) + private var cachedPolicy: P? = null + private var policyLastUpdate: Instant? = null + private var backgroundRefreshStarted = false + + companion object { + private const val CACHE_TTL_SECONDS = 3600L // 1 hour + private const val REFRESH_INTERVAL_MS = 3600000L // 1 hour in milliseconds + } + + /** Fetch the latest policy from the API, or null if the request failed. */ + protected abstract fun fetchPolicy(): P? + + /** Hook for subclasses to refresh any additional caches during the background refresh loop. */ + protected open fun refreshAdditionalCaches() {} + + protected fun getPolicy(invalidate: Boolean = false): P { + val isMissing = cachedPolicy == null || policyLastUpdate == null + val isStale = policyLastUpdate == null || Duration.between(policyLastUpdate, Instant.now()).seconds > CACHE_TTL_SECONDS + if (invalidate || isMissing || isStale) { + refreshPolicy() + } + + // Start background refresh after first successful fetch + if (!backgroundRefreshStarted && cachedPolicy != null) { + startBackgroundRefresh() + backgroundRefreshStarted = true + } + + return cachedPolicy ?: throw Exception("Error fetching the policy") + } + + private fun refreshPolicy() { + fetchPolicy()?.let { + cachedPolicy = it + policyLastUpdate = Instant.now() + } + } + + private fun startBackgroundRefresh() { + scope.launch { + while (isActive) { + delay(REFRESH_INTERVAL_MS) + refreshPolicy() + refreshAdditionalCaches() + } + } + } + + /** + * Cancels the background refresh job. + * This allows the refresh job to be stopped independently of the parent scope. + */ + fun cancelBackgroundRefresh() { + job.cancel() + } +} diff --git a/stytch/src/main/kotlin/com/stytch/java/common/ConsumerPolicyCache.kt b/stytch/src/main/kotlin/com/stytch/java/common/ConsumerPolicyCache.kt new file mode 100644 index 00000000..c9395726 --- /dev/null +++ b/stytch/src/main/kotlin/com/stytch/java/common/ConsumerPolicyCache.kt @@ -0,0 +1,42 @@ +package com.stytch.java.common + +import com.stytch.java.consumer.api.rbac.RBAC +import com.stytch.java.consumer.models.rbac.Policy +import com.stytch.java.consumer.models.rbac.PolicyRequest +import com.stytch.java.consumer.models.sessions.AuthorizationCheck +import kotlinx.coroutines.CoroutineScope + +/** + * Consumer (B2C) counterpart to [PolicyCache]. Unlike the B2B cache, consumer RBAC is not tenanted, + * so there is no org-specific policy and no tenancy check — authorization is evaluated purely + * against the project-level policy's roles. + */ +internal class ConsumerPolicyCache( + private val client: RBAC, + coroutineScope: CoroutineScope, +) : BasePolicyCache(coroutineScope) { + override fun fetchPolicy(): Policy? = + when (val result = client.policyCompletable(PolicyRequest()).get()) { + is StytchResult.Success -> result.value.policy + else -> null + } + + fun performAuthorizationCheck( + subjectRoles: List, + authorizationCheck: AuthorizationCheck, + ) { + val policy = getPolicy() + val roleViews = + policy.roles.map { role -> + RoleView( + roleId = role.roleId, + permissions = role.permissions.map { PermissionView(it.resourceId, it.actions) }, + ) + } + + if (hasRolePermission(subjectRoles, roleViews, authorizationCheck.resourceId, authorizationCheck.action)) { + return + } + throw PermissionException(authorizationCheck) + } +} diff --git a/stytch/src/main/kotlin/com/stytch/java/common/PolicyCache.kt b/stytch/src/main/kotlin/com/stytch/java/common/PolicyCache.kt index 83d2aea7..3e6e80a4 100644 --- a/stytch/src/main/kotlin/com/stytch/java/common/PolicyCache.kt +++ b/stytch/src/main/kotlin/com/stytch/java/common/PolicyCache.kt @@ -9,11 +9,6 @@ import com.stytch.java.b2b.models.rbac.PolicyRole import com.stytch.java.b2b.models.rbacorganizations.GetOrgPolicyRequest import com.stytch.java.b2b.models.sessions.AuthorizationCheck import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import java.time.Duration import java.time.Instant @@ -22,10 +17,6 @@ public class TenancyException( authCheckOrgId: String, ) : RuntimeException("Subject organizationId $subjectOrgId does not match authZ request organizationId $authCheckOrgId") -public class PermissionException( - authorizationCheck: AuthorizationCheck, -) : RuntimeException("Permission denied for request $authorizationCheck") - private data class CachedOrgPolicy( val orgPolicy: OrgPolicy, val lastUpdate: Instant, @@ -35,33 +26,24 @@ internal class PolicyCache( private val client: RBAC, coroutineScope: CoroutineScope, private val organizations: Organizations? = null, -) { - private val job = SupervisorJob(coroutineScope.coroutineContext[Job]) - private val scope = CoroutineScope(coroutineScope.coroutineContext + job) - private var cachedPolicy: Policy? = null +) : BasePolicyCache(coroutineScope) { private val cachedOrgPolicies: MutableMap = mutableMapOf() - private var policyLastUpdate: Instant? = null - private var backgroundRefreshStarted = false companion object { private const val CACHE_TTL_SECONDS = 3600L // 1 hour - private const val REFRESH_INTERVAL_MS = 3600000L // 1 hour in milliseconds } - private fun getPolicy(invalidate: Boolean = false): Policy { - val isMissing = cachedPolicy == null || policyLastUpdate == null - val isStale = policyLastUpdate == null || Duration.between(policyLastUpdate, Instant.now()).seconds > CACHE_TTL_SECONDS - if (invalidate || isMissing || isStale) { - refreshPolicy() + override fun fetchPolicy(): Policy? = + when (val result = client.policyCompletable(PolicyRequest()).get()) { + is StytchResult.Success -> result.value.policy + else -> null } - // Start background refresh after first successful fetch - if (!backgroundRefreshStarted && cachedPolicy != null) { - startBackgroundRefresh() - backgroundRefreshStarted = true + override fun refreshAdditionalCaches() { + // Refresh all cached org policies + cachedOrgPolicies.keys.toList().forEach { orgId -> + refreshOrgPolicy(orgId) } - - return cachedPolicy ?: throw Exception("Error fetching the policy") } private fun getOrgPolicy( @@ -92,38 +74,6 @@ internal class PolicyCache( } } - private fun refreshPolicy() { - when (val result = client.policyCompletable(PolicyRequest()).get()) { - is StytchResult.Success -> { - cachedPolicy = result.value.policy - policyLastUpdate = Instant.now() - } - - else -> {} - } - } - - private fun startBackgroundRefresh() { - scope.launch { - while (isActive) { - delay(REFRESH_INTERVAL_MS) - refreshPolicy() - // Refresh all cached org policies - cachedOrgPolicies.keys.toList().forEach { orgId -> - refreshOrgPolicy(orgId) - } - } - } - } - - /** - * Cancels the background refresh job. - * This allows the refresh job to be stopped independently of the parent scope. - */ - fun cancelBackgroundRefresh() { - job.cancel() - } - fun performAuthorizationCheck( subjectRoles: List, subjectOrgId: String, @@ -142,16 +92,17 @@ internal class PolicyCache( orgPolicy?.roles?.let { addAll(it) } } - val hasMatchingActionAndResource = - allRoles - .filter { it.roleId in subjectRoles } - .flatMap { it.permissions } - .filter { - val hasMatchingAction = it.actions.contains("*") || it.actions.contains(authorizationCheck.action) - val hasMatchingResource = it.resourceId == authorizationCheck.resourceId - return@filter hasMatchingAction && hasMatchingResource - }.isNotEmpty() - hasMatchingActionAndResource && return + val roleViews = + allRoles.map { role -> + RoleView( + roleId = role.roleId, + permissions = role.permissions.map { PermissionView(it.resourceId, it.actions) }, + ) + } + + if (hasRolePermission(subjectRoles, roleViews, authorizationCheck.resourceId, authorizationCheck.action)) { + return + } throw PermissionException(authorizationCheck) } } diff --git a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt index 6ec96150..9877852d 100644 --- a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt +++ b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt @@ -7,6 +7,7 @@ package com.stytch.java.consumer // !!! import com.stytch.java.common.BASE_LIVE_URL import com.stytch.java.common.BASE_TEST_URL +import com.stytch.java.common.ConsumerPolicyCache import com.stytch.java.common.JwksCache import com.stytch.java.common.JwtOptions import com.stytch.java.common.OptionalClientConfig @@ -74,6 +75,7 @@ public class StytchClient issuers = listOf("stytch.com/$projectId", baseUrl), type = "JWT", ) + private val policyCache: ConsumerPolicyCache = ConsumerPolicyCache(RBACImpl(httpClient, coroutineScope), coroutineScope) public val connectedApp: ConnectedApp = ConnectedAppImpl(httpClient, coroutineScope) public val cryptoWallets: CryptoWallets = CryptoWalletsImpl(httpClient, coroutineScope) @@ -88,7 +90,7 @@ public class StytchClient public val passwords: Passwords = PasswordsImpl(httpClient, coroutineScope) public val project: Project = ProjectImpl(httpClient, coroutineScope) public val rbac: RBAC = RBACImpl(httpClient, coroutineScope) - public val sessions: Sessions = SessionsImpl(httpClient, coroutineScope, httpsJwks, jwtOptions) + public val sessions: Sessions = SessionsImpl(httpClient, coroutineScope, httpsJwks, jwtOptions, policyCache) public val totps: TOTPs = TOTPsImpl(httpClient, coroutineScope) public val users: Users = UsersImpl(httpClient, coroutineScope) public val webauthn: WebAuthn = WebAuthnImpl(httpClient, coroutineScope) @@ -98,6 +100,7 @@ public class StytchClient // cancelBackgroundRefresh() is redundant once the scope is cancelled (child jobs // are parented off this scope), but called first for explicit intent. jwksCache.cancelBackgroundRefresh() + policyCache.cancelBackgroundRefresh() coroutineScope.cancel() httpClient.close() fraudHttpClient.close() diff --git a/stytch/src/test/kotlin/com/stytch/java/common/ConsumerPolicyCacheTest.kt b/stytch/src/test/kotlin/com/stytch/java/common/ConsumerPolicyCacheTest.kt new file mode 100644 index 00000000..6e8f4abc --- /dev/null +++ b/stytch/src/test/kotlin/com/stytch/java/common/ConsumerPolicyCacheTest.kt @@ -0,0 +1,284 @@ +package com.stytch.java.common + +import com.stytch.java.consumer.api.rbac.RBAC +import com.stytch.java.consumer.models.rbac.Policy +import com.stytch.java.consumer.models.rbac.PolicyResource +import com.stytch.java.consumer.models.rbac.PolicyResponse +import com.stytch.java.consumer.models.rbac.PolicyRole +import com.stytch.java.consumer.models.rbac.PolicyRolePermission +import com.stytch.java.consumer.models.rbac.PolicyScope +import com.stytch.java.consumer.models.rbac.PolicyScopePermission +import com.stytch.java.consumer.models.sessions.AuthorizationCheck +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.concurrent.atomic.AtomicInteger + +private val policy = + Policy( + resources = + listOf( + PolicyResource( + resourceId = "foo", + description = "Foo Resource", + actions = listOf("read", "write", "delete"), + ), + PolicyResource( + resourceId = "bar", + description = "Bar Resource", + actions = listOf("read", "write", "delete"), + ), + ), + roles = + listOf( + PolicyRole( + roleId = "admin", + description = "Admin", + permissions = + listOf( + PolicyRolePermission( + resourceId = "foo", + actions = listOf("*"), + ), + PolicyRolePermission( + resourceId = "bar", + actions = listOf("*"), + ), + ), + ), + PolicyRole( + roleId = "global_writer", + description = "Writer for all services", + permissions = + listOf( + PolicyRolePermission( + resourceId = "foo", + actions = listOf("read", "write"), + ), + PolicyRolePermission( + resourceId = "bar", + actions = listOf("read", "write"), + ), + ), + ), + PolicyRole( + roleId = "global_reader", + description = "Reader for all services", + permissions = + listOf( + PolicyRolePermission( + resourceId = "foo", + actions = listOf("read"), + ), + PolicyRolePermission( + resourceId = "bar", + actions = listOf("read"), + ), + ), + ), + PolicyRole( + roleId = "bar_writer", + description = "Writer for bar service", + permissions = + listOf( + PolicyRolePermission( + resourceId = "bar", + actions = listOf("read", "write"), + ), + ), + ), + ), + scopes = + listOf( + PolicyScope( + scope = "global", + description = "Global scope", + permissions = + listOf( + PolicyScopePermission( + resourceId = "bar", + actions = listOf("read", "write"), + ), + ), + ), + ), + ) + +internal class ConsumerPolicyCacheTest { + private lateinit var rbac: RBAC + private val testScope = CoroutineScope(Dispatchers.Unconfined) + + @Before + fun before() { + rbac = + mockk(relaxed = true, relaxUnitFun = true) { + every { policyCompletable(any()).get() } returns + StytchResult.Success( + PolicyResponse( + statusCode = 200, + requestId = "", + policy = policy, + ), + ) + } + } + + @Test(expected = PermissionException::class) + fun `throws PermissionException when subject does not have matching resource`() { + val policyCache = ConsumerPolicyCache(rbac, testScope) + policyCache.performAuthorizationCheck( + subjectRoles = listOf("bar_writer"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "write", + ), + ) + } + + @Test(expected = PermissionException::class) + fun `throws PermissionException when subject does not have matching action`() { + val policyCache = ConsumerPolicyCache(rbac, testScope) + policyCache.performAuthorizationCheck( + subjectRoles = listOf("global_writer"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "delete", + ), + ) + } + + @Test + fun `succeeds when subject has matching resource and action`() { + val policyCache = ConsumerPolicyCache(rbac, testScope) + policyCache.performAuthorizationCheck( + subjectRoles = listOf("global_writer"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "write", + ), + ) + } + + @Test + fun `succeeds when subject has matching resource and star action`() { + val policyCache = ConsumerPolicyCache(rbac, testScope) + policyCache.performAuthorizationCheck( + subjectRoles = listOf("admin"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "delete", + ), + ) + } + + @Test + fun `fetches policy on first authorization check`() { + val rbacMock = + mockk(relaxed = true, relaxUnitFun = true) { + every { policyCompletable(any()).get() } returns + StytchResult.Success( + PolicyResponse( + statusCode = 200, + requestId = "", + policy = policy, + ), + ) + } + + val policyCache = ConsumerPolicyCache(rbacMock, testScope) + + // First call should fetch the policy + policyCache.performAuthorizationCheck( + subjectRoles = listOf("admin"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "read", + ), + ) + + verify(exactly = 1) { rbacMock.policyCompletable(any()) } + } + + @Test + fun `uses cached policy on subsequent authorization checks`() { + val callCount = AtomicInteger(0) + val rbacMock = + mockk(relaxed = true, relaxUnitFun = true) { + every { policyCompletable(any()).get() } answers { + callCount.incrementAndGet() + StytchResult.Success( + PolicyResponse( + statusCode = 200, + requestId = "", + policy = policy, + ), + ) + } + } + + val policyCache = ConsumerPolicyCache(rbacMock, testScope) + + // First call fetches + policyCache.performAuthorizationCheck( + subjectRoles = listOf("admin"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "read", + ), + ) + + // Second call should use cache + policyCache.performAuthorizationCheck( + subjectRoles = listOf("admin"), + authorizationCheck = + AuthorizationCheck( + resourceId = "bar", + action = "read", + ), + ) + + // Should only have called the API once (second call used cache) + assertEquals(1, callCount.get()) + } + + @Test + fun `cancelBackgroundRefresh stops background refresh job`() { + val rbacMock = + mockk(relaxed = true, relaxUnitFun = true) { + every { policyCompletable(any()).get() } returns + StytchResult.Success( + PolicyResponse( + statusCode = 200, + requestId = "", + policy = policy, + ), + ) + } + + val policyCache = ConsumerPolicyCache(rbacMock, testScope) + + // Trigger initial fetch and start background refresh + policyCache.performAuthorizationCheck( + subjectRoles = listOf("admin"), + authorizationCheck = + AuthorizationCheck( + resourceId = "foo", + action = "read", + ), + ) + + // Cancel the background refresh job + policyCache.cancelBackgroundRefresh() + } +} diff --git a/stytch/src/test/kotlin/com/stytch/java/consumer/api/sessions/SessionsTest.kt b/stytch/src/test/kotlin/com/stytch/java/consumer/api/sessions/SessionsTest.kt index b92489d1..c4f2b06c 100644 --- a/stytch/src/test/kotlin/com/stytch/java/consumer/api/sessions/SessionsTest.kt +++ b/stytch/src/test/kotlin/com/stytch/java/consumer/api/sessions/SessionsTest.kt @@ -119,6 +119,7 @@ internal class SessionsTest { issuers = listOf("stytch.com/$projectId"), type = "JWT", ), + policyCache = mockk(relaxed = true, relaxUnitFun = true), ) } From cb4ad0e5215e95741828eadcbffd85d8d511bfeb Mon Sep 17 00:00:00 2001 From: Johanan Lai Date: Thu, 16 Jul 2026 17:22:33 -0700 Subject: [PATCH 2/4] Add authorization_check support for Consumer authenticateJwtLocal --- .../java/consumer/api/sessions/Sessions.kt | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/stytch/src/main/kotlin/com/stytch/java/consumer/api/sessions/Sessions.kt b/stytch/src/main/kotlin/com/stytch/java/consumer/api/sessions/Sessions.kt index a3b9734d..32af698b 100644 --- a/stytch/src/main/kotlin/com/stytch/java/consumer/api/sessions/Sessions.kt +++ b/stytch/src/main/kotlin/com/stytch/java/consumer/api/sessions/Sessions.kt @@ -9,6 +9,7 @@ package com.stytch.java.consumer.api.sessions import com.squareup.moshi.JsonAdapter import com.squareup.moshi.Moshi import com.squareup.moshi.Types +import com.stytch.java.common.ConsumerPolicyCache import com.stytch.java.common.InstantAdapter import com.stytch.java.common.JWTAuthResponse import com.stytch.java.common.JWTErrorResponse @@ -26,6 +27,7 @@ import com.stytch.java.consumer.models.sessions.AttestRequest import com.stytch.java.consumer.models.sessions.AttestResponse import com.stytch.java.consumer.models.sessions.AuthenticateRequest import com.stytch.java.consumer.models.sessions.AuthenticateResponse +import com.stytch.java.consumer.models.sessions.AuthorizationCheck import com.stytch.java.consumer.models.sessions.ExchangeAccessTokenRequest import com.stytch.java.consumer.models.sessions.ExchangeAccessTokenResponse import com.stytch.java.consumer.models.sessions.GetJWKSRequest @@ -287,6 +289,8 @@ public interface Sessions { // MANUAL(authenticateJWT_interface)(INTERFACE_METHOD) // ADDIMPORT: import com.stytch.java.consumer.models.sessions.Session + // ADDIMPORT: import com.stytch.java.consumer.models.sessions.AuthorizationCheck + // ADDIMPORT: import com.stytch.java.common.ConsumerPolicyCache // ADDIMPORT: import com.stytch.java.common.JWTException // ADDIMPORT: import com.stytch.java.common.ParseJWTClaimsOptions // ADDIMPORT: import com.stytch.java.common.StytchSessionClaim @@ -352,6 +356,7 @@ public interface Sessions { public suspend fun authenticateJwtLocal( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck? = null, leeway: Int = 0, ): StytchResult @@ -369,6 +374,7 @@ public interface Sessions { public fun authenticateJwtLocal( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck? = null, leeway: Int = 0, callback: (StytchResult) -> Unit, ) @@ -387,6 +393,7 @@ public interface Sessions { public fun authenticateJwtLocalCompletable( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck? = null, leeway: Int = 0, ): CompletableFuture> // ENDMANUAL(authenticateJWT_interface) @@ -397,6 +404,7 @@ internal class SessionsImpl( private val coroutineScope: CoroutineScope, private val jwksClient: HttpsJwks, private val jwtOptions: JwtOptions, + private val policyCache: ConsumerPolicyCache, ) : Sessions { private val moshi = Moshi.Builder().add(InstantAdapter()).build() @@ -626,6 +634,7 @@ internal class SessionsImpl( override suspend fun authenticateJwtLocal( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck?, leeway: Int, ): StytchResult { return try { @@ -647,6 +656,16 @@ internal class SessionsImpl( val adapter: JsonAdapter> = moshi.adapter(type) moshi.adapter(StytchSessionClaim::class.java).fromJson(adapter.toJson(it)) } ?: throw JWTException.JwtMissingClaims + if (authorizationCheck != null) { + if (stytchSessionClaim.roles == null) { + throw JWTException.MissingRolesClaim + } + + policyCache.performAuthorizationCheck( + subjectRoles = stytchSessionClaim.roles, + authorizationCheck = authorizationCheck, + ) + } return StytchResult.Success( Session( sessionId = stytchSessionClaim.id, @@ -672,22 +691,24 @@ internal class SessionsImpl( override fun authenticateJwtLocal( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck?, leeway: Int, callback: (StytchResult) -> Unit, ) { coroutineScope.launch { - callback(authenticateJwtLocal(jwt, maxTokenAgeSeconds, leeway)) + callback(authenticateJwtLocal(jwt, maxTokenAgeSeconds, authorizationCheck, leeway)) } } override fun authenticateJwtLocalCompletable( jwt: String, maxTokenAgeSeconds: Int?, + authorizationCheck: AuthorizationCheck?, leeway: Int, ): CompletableFuture> = coroutineScope .async { - authenticateJwtLocal(jwt, maxTokenAgeSeconds, leeway) + authenticateJwtLocal(jwt, maxTokenAgeSeconds, authorizationCheck, leeway) }.asCompletableFuture() // ENDMANUAL(authenticateJWT_impl) } From 359eadbb772950c1a6c68189cc321f64fdfd977e Mon Sep 17 00:00:00 2001 From: Johanan Lai Date: Thu, 16 Jul 2026 17:44:01 -0700 Subject: [PATCH 3/4] Include Consumer policy cache because of codegen --- stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt | 2 +- stytch/src/main/kotlin/com/stytch/java/consumer/api/idp/IDP.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt index 9877852d..0c7dffb2 100644 --- a/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt +++ b/stytch/src/main/kotlin/com/stytch/java/consumer/StytchClient.kt @@ -81,7 +81,7 @@ public class StytchClient public val cryptoWallets: CryptoWallets = CryptoWalletsImpl(httpClient, coroutineScope) public val debug: Debug = DebugImpl(httpClient, coroutineScope) public val fraud: Fraud = FraudImpl(fraudHttpClient, coroutineScope) - public val idp: IDP = IDPImpl(httpClient, coroutineScope, httpsJwks, jwtOptions) + public val idp: IDP = IDPImpl(httpClient, coroutineScope, httpsJwks, jwtOptions, policyCache) public val impersonation: Impersonation = ImpersonationImpl(httpClient, coroutineScope) public val m2m: M2M = M2MImpl(httpClient, coroutineScope, httpsJwks, jwtOptions) public val magicLinks: MagicLinks = MagicLinksImpl(httpClient, coroutineScope) diff --git a/stytch/src/main/kotlin/com/stytch/java/consumer/api/idp/IDP.kt b/stytch/src/main/kotlin/com/stytch/java/consumer/api/idp/IDP.kt index 8cb9476e..e371c552 100644 --- a/stytch/src/main/kotlin/com/stytch/java/consumer/api/idp/IDP.kt +++ b/stytch/src/main/kotlin/com/stytch/java/consumer/api/idp/IDP.kt @@ -7,6 +7,7 @@ package com.stytch.java.consumer.api.idp // !!! import com.squareup.moshi.Moshi +import com.stytch.java.common.ConsumerPolicyCache import com.stytch.java.common.InstantAdapter import com.stytch.java.common.JwtOptions import com.stytch.java.consumer.api.idpoauth.OAuth @@ -24,6 +25,7 @@ internal class IDPImpl( private val coroutineScope: CoroutineScope, private val jwksClient: HttpsJwks, private val jwtOptions: JwtOptions, + private val policyCache: ConsumerPolicyCache, ) : IDP { private val moshi = Moshi.Builder().add(InstantAdapter()).build() From 398a3c1e393cf8759dcc76291b95488654f7b04a Mon Sep 17 00:00:00 2001 From: Johanan Lai Date: Fri, 17 Jul 2026 14:10:46 -0700 Subject: [PATCH 4/4] bump minor version --- stytch/src/main/kotlin/com/stytch/java/common/Version.kt | 2 +- version.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/stytch/src/main/kotlin/com/stytch/java/common/Version.kt b/stytch/src/main/kotlin/com/stytch/java/common/Version.kt index 7665d39e..8976099f 100644 --- a/stytch/src/main/kotlin/com/stytch/java/common/Version.kt +++ b/stytch/src/main/kotlin/com/stytch/java/common/Version.kt @@ -1,3 +1,3 @@ package com.stytch.java.common -internal const val VERSION = "11.2.0" +internal const val VERSION = "11.3.0" diff --git a/version.gradle.kts b/version.gradle.kts index c6f7a566..e238c941 100644 --- a/version.gradle.kts +++ b/version.gradle.kts @@ -1 +1 @@ -version = "11.2.0" +version = "11.3.0"