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
39 changes: 34 additions & 5 deletions src/main/kotlin/com/workos/android/helpers/PublicClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
62 changes: 62 additions & 0 deletions src/test/kotlin/com/workos/android/HelpersTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<PublicClient, MockWebServer> {
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
Expand Down Expand Up @@ -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"}"""
Loading