Skip to content
Draft
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
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-test-core-ktx = { module = "androidx.test:core-ktx", version.ref = "androidxTest" }
androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTest" }
coil = "io.coil-kt.coil3:coil-compose:3.5.0"
coil-okhttp = "io.coil-kt.coil3:coil-network-okhttp:3.5.0"
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
Expand All @@ -96,6 +97,7 @@ material3 = { module = "androidx.compose.material3:material3", version.ref = "ma
materialKolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
mockk = "io.mockk:mockk:1.14.11"
navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigationCompose" }
nimbus-jose-jwt = "com.nimbusds:nimbus-jose-jwt:10.10"
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
okhttp-mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver3", version.ref = "okhttp" }
Expand Down
3 changes: 2 additions & 1 deletion source/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -116,13 +116,14 @@ dependencies {
testImplementation(libs.kotlin.test.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk)
testImplementation(libs.nimbus.jose.jwt)
testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.okhttp.tls)
testImplementation(libs.robolectric)

androidTestImplementation(platform(libs.compose.bom))
androidTestImplementation("androidx.test:runner:${libs.versions.androidxTest.get()}")
androidTestImplementation(libs.androidx.compose.runtime)
androidTestImplementation(libs.androidx.test.runner)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.kotlin.test)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.clerk.api.dpop

import android.content.Context
import android.os.SystemClock
import android.util.Base64
import android.util.Log
import androidx.test.platform.app.InstrumentationRegistry
import com.clerk.api.devicekey.DeviceKey
import com.clerk.api.devicekey.DeviceKeyFactory
import java.io.File
import java.util.UUID
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import okhttp3.Headers.Companion.headersOf
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Assume.assumeTrue
import org.junit.Test

class DpopProofInstrumentedTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
private val arguments = InstrumentationRegistry.getArguments()
private val key: DeviceKey = DeviceKeyFactory.create(context)
private val clientUrl = "https://clerk.example.com/v1/client?_is_native=true".toHttpUrl()
private val directory = File(context.getExternalFilesDir(null), "dpop-proofs").apply { mkdirs() }

@Test
fun writesProofWithoutToken() {
write("without-token", listOf(proofCase(clientUrl, accessToken = null)))
}

@Test
fun writesProofWithToken() {
write("with-token", listOf(proofCase(clientUrl, accessToken = ACCESS_TOKEN)))
}

@Test
fun writesProofAfterRecordingDate() {
val serverDate = arguments.getString("serverDate")
assumeTrue("pass a server Date header value as serverDate", serverDate != null)
val serverTime = assertNotNull(headersOf("Date", serverDate!!).getDate("Date"))
val serverClock =
ServerClock(Clock.System).apply { record(Instant.fromEpochMilliseconds(serverTime.time)) }

report("serverDate=$serverDate local=${Clock.System.now()} server=${serverClock.now()}")
write("server-clock", listOf(proofCase(clientUrl, now = serverClock.now())))
write("local-clock", listOf(proofCase(clientUrl, now = Clock.System.now())))
}

@Test
fun writesTwoProofs() {
val proofs = List(2) { proofCase(clientUrl) }

assertNotEquals(proofs[0].claim("jti"), proofs[1].claim("jti"))
write("two", proofs)
}

@Test
fun writesProofForUrlWithQueryAndFragment() {
val url = "https://clerk.example.com/v1/client/sessions?_is_native=true&a=b#section".toHttpUrl()
val proof = proofCase(url)

assertEquals("https://clerk.example.com/v1/client/sessions", proof.claim("htu"))
write("query-and-fragment", listOf(proof))
}

@Test
fun writesThousandProofs() {
val proofs = List(PROOF_COUNT) { proofCase(clientUrl) }

assertEquals(PROOF_COUNT, proofs.map { it.claim("jti") }.toSet().size)
write("thousand", proofs)
}

@Test
fun writesJwk() {
File(directory, "jwk.json").writeText(key.publicJwk.toString())
File(directory, "thumbprint.txt").writeText(key.thumbprint)
report("thumbprint=${key.thumbprint}")
}

@Test
fun timings() {
val unsignedKey =
object : DeviceKey by key {
override fun sign(data: ByteArray): ByteArray = ByteArray(SIGNATURE_SIZE)
}

val withoutSignMs = List(PROOF_COUNT) { timed { build(unsignedKey, clientUrl) } }.sorted()
val withSignMs = List(PROOF_COUNT) { timed { build(key, clientUrl) } }.sorted()

report(
"level=${key.securityLevel.wireName} " +
"build_without_sign_p50_ms=${withoutSignMs.median()} " +
"build_without_sign_p90_ms=${withoutSignMs.p90()} " +
"build_with_sign_p50_ms=${withSignMs.median()} " +
"build_with_sign_p90_ms=${withSignMs.p90()}"
)
}

private fun proofCase(
url: HttpUrl,
accessToken: String? = null,
now: Instant = Clock.System.now(),
): JsonObject = buildJsonObject {
put("method", "GET")
put("url", url.toString())
accessToken?.let { put("token", it) }
put("proof", build(key, url, accessToken, now))
put("thumbprint", key.thumbprint)
}

private fun build(
key: DeviceKey,
url: HttpUrl,
accessToken: String? = null,
now: Instant = Clock.System.now(),
): String =
DpopProof.build(
key = key,
request = BoundRequest(method = "GET", url = url, accessToken = accessToken),
now = now,
jti = UUID.randomUUID().toString(),
)

private fun JsonObject.claim(name: String): String {
val payload = getValue("proof").jsonPrimitive.content.split(".")[1]
val claims = Json.parseToJsonElement(String(Base64.decode(payload, Base64.URL_SAFE)))
return claims.jsonObject.getValue(name).jsonPrimitive.content
}

private fun write(name: String, proofs: List<JsonObject>) {
val file = File(directory, "$name.jsonl")
file.writeText(proofs.joinToString(separator = "\n", postfix = "\n"))
report("wrote ${proofs.size} proofs to ${file.absolutePath}")
}

private fun timed(block: () -> Unit): Double {
val start = SystemClock.elapsedRealtimeNanos()
block()
return (SystemClock.elapsedRealtimeNanos() - start) / NANOS_PER_MILLI
}

private fun List<Double>.median(): Double = this[size / 2]

private fun List<Double>.p90(): Double = this[size * 9 / 10]

private fun report(line: String) {
Log.i(TAG, line)
}

private companion object {
const val TAG = "DpopProofTest"
const val PROOF_COUNT = 1000
const val SIGNATURE_SIZE = 64
const val NANOS_PER_MILLI = 1_000_000.0
const val ACCESS_TOKEN = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU"
}
}
47 changes: 47 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/dpop/DpopProof.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.clerk.api.dpop

import com.clerk.api.devicekey.DeviceKey
import com.clerk.api.devicekey.Es256
import java.security.MessageDigest
import kotlin.time.Instant
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl

internal data class BoundRequest(val method: String, val url: HttpUrl, val accessToken: String?)

internal object DpopProof {
fun build(key: DeviceKey, request: BoundRequest, now: Instant, jti: String): String {
val (method, url, accessToken) = request
val header = buildJsonObject {
put("typ", "dpop+jwt")
put("alg", "ES256")
put("jwk", key.publicJwk)
}
val payload = buildJsonObject {
put("jti", jti)
put("htm", method)
put(
"htu",
url.newBuilder().username("").password("").query(null).fragment(null).build().toString(),
)
put("iat", now.epochSeconds)
if (accessToken != null) {
put("ath", accessTokenHash(accessToken))
}
put("clerk_sl", key.securityLevel.wireName)
}
val signingInput = "${header.base64UrlEncode()}.${payload.base64UrlEncode()}"
val signature = key.sign(signingInput.toByteArray(Charsets.US_ASCII))
return "$signingInput.${Es256.base64UrlEncode(signature)}"
}

private fun accessTokenHash(accessToken: String): String =
Es256.base64UrlEncode(
MessageDigest.getInstance("SHA-256").digest(accessToken.toByteArray(Charsets.UTF_8))
)

private fun JsonObject.base64UrlEncode(): String =
Es256.base64UrlEncode(toString().toByteArray(Charsets.UTF_8))
}
15 changes: 15 additions & 0 deletions source/api/src/main/kotlin/com/clerk/api/dpop/ServerClock.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.clerk.api.dpop

import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Instant

internal class ServerClock(private val localClock: Clock) : Clock {
@Volatile private var offset: Duration = Duration.ZERO

fun record(serverTime: Instant) {
offset = serverTime - localClock.now()
}

override fun now(): Instant = localClock.now() + offset
}
121 changes: 121 additions & 0 deletions source/api/src/test/java/com/clerk/api/dpop/DpopProofTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package com.clerk.api.dpop

import com.clerk.api.devicekey.DeviceKey
import com.clerk.api.devicekey.JvmDeviceKey
import com.nimbusds.jose.JOSEObjectType
import com.nimbusds.jose.JWSAlgorithm
import com.nimbusds.jose.crypto.ECDSAVerifier
import com.nimbusds.jose.jwk.ECKey
import com.nimbusds.jwt.SignedJWT
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Instant
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class DpopProofTest {
private val key: DeviceKey = JvmDeviceKey.getOrCreate()

@Test
fun `the embedded jwk verifies the signature`() {
val proof =
buildProof(
url = "https://clerk.example.com/v1/client?_is_native=true",
now = Clock.System.now(),
)
println("DPoP proof for GET https://clerk.example.com/v1/client: $proof")

val jwt = SignedJWT.parse(proof)
val jwk = assertIs<ECKey>(jwt.header.jwk)

assertTrue(jwt.verify(ECDSAVerifier(jwk)))
assertEquals(JOSEObjectType("dpop+jwt"), jwt.header.type)
assertEquals(JWSAlgorithm.ES256, jwt.header.algorithm)
assertFalse(jwk.isPrivate)
assertEquals(key.thumbprint, jwk.computeThumbprint().toString())
assertEquals("https://clerk.example.com/v1/client", jwt.jwtClaimsSet.getStringClaim("htu"))
assertEquals("GET", jwt.jwtClaimsSet.getStringClaim("htm"))
assertEquals("proof-id", jwt.jwtClaimsSet.jwtid)
assertEquals("software", jwt.jwtClaimsSet.getStringClaim("clerk_sl"))
}

@Test
fun `htu drops the query and fragment and keeps a proxy path`() {
val proof = buildProof(url = "https://app.example.com/__clerk/v1/client?_is_native=true#top")

assertEquals("https://app.example.com/__clerk/v1/client", claims(proof).getStringClaim("htu"))
}

@Test
fun `htu drops userinfo, which the server's verifier rejects`() {
val proof = buildProof(url = "https://user:secret@clerk.example.com/v1/client")

assertEquals("https://clerk.example.com/v1/client", claims(proof).getStringClaim("htu"))
}

@Test
fun `ath is the RFC 9449 hash of the access token`() {
val proof = buildProof(accessToken = "Kz~8mXK1EalYznwH-LC-1fBAo.4Ljp~zsPE_NeO.gxU")

assertEquals("fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo", claims(proof).getStringClaim("ath"))
}

@Test
fun `ath hashes the UTF-8 bytes of a non-ASCII access token`() {
val proof = buildProof(accessToken = "tökén-日本")

assertEquals("TNYs-KYciDl4w33VRjNr0j_SXJmNv8v1ZvJ5VQIPz68", claims(proof).getStringClaim("ath"))
}

@Test
fun `a proof without an access token has no ath claim`() {
val proof = buildProof(accessToken = null)

assertFalse("ath" in claims(proof).claims)
assertFalse("\"ath\"" in SignedJWT.parse(proof).payload.toString())
}

@Test
fun `iat is whole seconds`() {
val proof = buildProof(now = Instant.fromEpochMilliseconds(1_700_000_000_999))

assertEquals(JsonPrimitive(1_700_000_000L), payload(proof)["iat"])
}

@Test
fun `a proof for a long URL fits the server's size limit`() {
val proof =
buildProof(
url =
"https://clerk.example-with-a-long-name.com/v1/client/sessions/${"s".repeat(64)}/tokens"
)

assertTrue(proof.length < 8192, "proof is ${proof.length} bytes")
}

private fun buildProof(
url: String = "https://clerk.example.com/v1/client",
accessToken: String? = null,
now: Instant = Instant.fromEpochSeconds(1_700_000_000),
): String =
DpopProof.build(
key = key,
request = BoundRequest(method = "GET", url = url.toHttpUrl(), accessToken = accessToken),
now = now,
jti = "proof-id",
)

private fun claims(proof: String) = SignedJWT.parse(proof).jwtClaimsSet

private fun payload(proof: String) =
Json.parseToJsonElement(SignedJWT.parse(proof).payload.toString()).jsonObject
}
Loading
Loading