From 10943761b991e4626a24820bef62d78b65b66466 Mon Sep 17 00:00:00 2001 From: NanoSpicer Date: Tue, 20 Oct 2026 12:21:36 +0200 Subject: [PATCH] fix: PublicClient sends no client_secret on code and refresh exchange (#13) --- .../workos/android/helpers/PublicClient.kt | 39 ++++++++++-- .../kotlin/com/workos/android/HelpersTest.kt | 62 +++++++++++++++++++ 2 files changed, 96 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/com/workos/android/helpers/PublicClient.kt b/src/main/kotlin/com/workos/android/helpers/PublicClient.kt index 50cc9d9..46af34e 100644 --- a/src/main/kotlin/com/workos/android/helpers/PublicClient.kt +++ b/src/main/kotlin/com/workos/android/helpers/PublicClient.kt @@ -3,6 +3,7 @@ package com.workos.android.helpers import com.workos.android.Configuration import com.workos.android.WorkOSClient +import com.workos.android.internal.JsonBody import com.workos.android.models.AuthenticateResponse import com.workos.android.resources.UserManagement import com.workos.android.userManagement @@ -15,7 +16,7 @@ import com.workos.android.userManagement * service API must not be reachable from a distributed app at all. This factory * builds a client with an **empty** API key and exposes only the operations that * are safe without a client secret — authorization-URL building, PKCE code - * exchange, and the PKCE helper itself. + * exchange, refresh-token exchange, and the PKCE helper itself. * * The empty key is deliberate: if a caller reaches past this facade into the full * generated surface, the server rejects the request rather than the mistake going @@ -60,14 +61,42 @@ public class PublicClient private constructor( /** * Exchange an authorization code plus its PKCE verifier for tokens. * - * No `client_secret` is sent — the generated `authenticateWithCode` takes - * `codeVerifier` from the spec, which is what makes the public-client flow - * possible without a secret. + * The body is built here, not by the generated `authenticateWithCode`: that one + * always sends `client_secret`, and the API rejects an empty one with a 422. */ public suspend fun authenticateWithCode( code: String, codeVerifier: String, - ): AuthenticateResponse = userManagement.authenticateWithCode(code = code, codeVerifier = codeVerifier) + ): AuthenticateResponse { + val payload = JsonBody() + payload.set("grant_type", "authorization_code") + payload.set("client_id", clientId) + payload.set("code", code) + payload.set("code_verifier", codeVerifier) + return authenticate(payload) + } + + /** Exchange a refresh token for new tokens, with no `client_secret`. */ + public suspend fun authenticateWithRefreshToken( + refreshToken: String, + organizationId: String? = null, + ): AuthenticateResponse { + val payload = JsonBody() + payload.set("grant_type", "refresh_token") + payload.set("client_id", clientId) + payload.set("refresh_token", refreshToken) + payload.set("organization_id", organizationId) + return authenticate(payload) + } + + private suspend fun authenticate(payload: JsonBody): AuthenticateResponse = + client.transport.request( + method = "POST", + path = "user_management/authenticate", + query = emptyList(), + body = payload, + options = null, + ) private val userManagement: UserManagement get() = client.userManagement diff --git a/src/test/kotlin/com/workos/android/HelpersTest.kt b/src/test/kotlin/com/workos/android/HelpersTest.kt index bd10906..53ffff7 100644 --- a/src/test/kotlin/com/workos/android/HelpersTest.kt +++ b/src/test/kotlin/com/workos/android/HelpersTest.kt @@ -158,6 +158,63 @@ class HelpersTest { assertEquals("Bearer", auth) } + @Test + fun `H19 authenticateWithCode sends no client_secret`() = + runTest { + val (public, server) = publicClientResponding(AUTHENTICATE_RESPONSE) + + val result = public.authenticateWithCode(code = "code_123", codeVerifier = "verifier_123") + + val request = server.awaitRequest() + assertEquals("POST", request.method) + assertEquals("/user_management/authenticate", request.pathOnly()) + val body = request.bodyJson() + assertEquals(setOf("grant_type", "client_id", "code", "code_verifier"), body.keys) + assertEquals("authorization_code", body["grant_type"]?.jsonPrimitive?.content) + assertEquals("client_123", body["client_id"]?.jsonPrimitive?.content) + assertEquals("code_123", body["code"]?.jsonPrimitive?.content) + assertEquals("verifier_123", body["code_verifier"]?.jsonPrimitive?.content) + assertEquals("access_123", result.accessToken) + } + + @Test + fun `H19 authenticateWithRefreshToken sends no client_secret`() = + runTest { + val (public, server) = publicClientResponding(AUTHENTICATE_RESPONSE) + + val result = public.authenticateWithRefreshToken(refreshToken = "refresh_123", organizationId = "org_123") + + val request = server.awaitRequest() + assertEquals("POST", request.method) + assertEquals("/user_management/authenticate", request.pathOnly()) + val body = request.bodyJson() + assertEquals(setOf("grant_type", "client_id", "refresh_token", "organization_id"), body.keys) + assertEquals("refresh_token", body["grant_type"]?.jsonPrimitive?.content) + assertEquals("client_123", body["client_id"]?.jsonPrimitive?.content) + assertEquals("refresh_123", body["refresh_token"]?.jsonPrimitive?.content) + assertEquals("org_123", body["organization_id"]?.jsonPrimitive?.content) + assertEquals("refresh_456", result.refreshToken) + } + + @Test + fun `H19 authenticateWithRefreshToken omits organization_id when absent`() = + runTest { + val (public, server) = publicClientResponding(AUTHENTICATE_RESPONSE) + + public.authenticateWithRefreshToken(refreshToken = "refresh_123") + + val body = server.awaitRequest().bodyJson() + assertEquals(setOf("grant_type", "client_id", "refresh_token"), body.keys) + } + + private fun publicClientResponding(body: String): Pair { + val server = MockWebServer() + server.start() + server.enqueue(MockResponse().setResponseCode(200).setBody(body)) + val public = PublicClient.create(clientId = "client_123", baseUrl = server.url("/").toString().trimEnd('/')) + return public to server + } + // --- Passwordless -------------------------------------------------------- @Test @@ -229,3 +286,8 @@ class HelpersTest { assertEquals(true, response.success) } } + +private const val AUTHENTICATE_RESPONSE = + """{"user":{"object":"user","id":"user_123","email":"a@b.com","email_verified":true,"metadata":{},""" + + """"created_at":"2026-01-15T12:00:00.000Z","updated_at":"2026-01-15T12:00:00.000Z"},""" + + """"access_token":"access_123","refresh_token":"refresh_456"}"""