From 2bf47ea323a59e54f863a2d50171449c79146a6c Mon Sep 17 00:00:00 2001 From: Dhruv Pareek Date: Tue, 9 Jun 2026 12:45:05 -0700 Subject: [PATCH] [grid] fix passkey registration samples --- .../steps/embeddedWallet/RegisterPasskey.tsx | 31 +++++++++++++--- .../com/grid/sample/routes/AuthCredentials.kt | 37 ++++++++++++++++--- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx index a364ed38f..77034f3bb 100644 --- a/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx +++ b/samples/frontend/src/steps/embeddedWallet/RegisterPasskey.tsx @@ -9,9 +9,12 @@ interface Props { disabled: boolean } -// Two-phase: backend mints a WebAuthn challenge, client runs -// navigator.credentials.create(), client posts the attestation back, backend -// forwards to Grid as POST /auth/credentials. +const SANDBOX_MODE = (import.meta.env.VITE_SANDBOX_PASSKEY ?? '1') !== '0' +const SANDBOX_WALLET_SIGNATURE = 'sandbox-valid-signature' + +// Backend mints a WebAuthn challenge, client runs navigator.credentials.create(), +// client posts the attestation back, and Grid may require a signed retry before +// returning the new auth method. export default function RegisterPasskey({ walletAccountId, customerId, @@ -62,7 +65,7 @@ export default function RegisterPasskey({ (att as AuthenticatorAttestationResponse & { getTransports?: () => string[] }) .getTransports?.() ?? [] - const data = await apiPost>('/api/auth/credentials', { + const createBody = { accountId: walletAccountId, challenge: reg.challenge, nickname: 'Grid Global Account passkey', @@ -72,7 +75,16 @@ export default function RegisterPasskey({ attestationObject: bytesToBase64url(new Uint8Array(att.attestationObject)), transports, }, - }) + } + + let data = await apiPost>('/api/auth/credentials', createBody) + if (typeof data.payloadToSign === 'string' && typeof data.requestId === 'string') { + const signature = registrationSignature(data.payloadToSign) + data = await apiPost>('/api/auth/credentials', createBody, { + 'Grid-Wallet-Signature': signature, + 'Request-Id': data.requestId, + }) + } setResponse(JSON.stringify(data, null, 2)) const authMethodId = (data.id ?? data.authMethodId) as string | undefined @@ -105,6 +117,15 @@ export default function RegisterPasskey({ ) } +function registrationSignature(payloadToSign: string): string { + const configured = import.meta.env.VITE_REGISTRATION_GRID_WALLET_SIGNATURE + if (configured) return configured + if (SANDBOX_MODE) return SANDBOX_WALLET_SIGNATURE + throw new Error( + `Passkey registration requires a signed retry for payloadToSign: ${payloadToSign}`, + ) +} + function base64urlToBytes(s: string): ArrayBuffer { const pad = '='.repeat((4 - (s.length % 4)) % 4) const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/') diff --git a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt index 6f8d3fe4f..789ecfb97 100644 --- a/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt +++ b/samples/kotlin/src/main/kotlin/com/grid/sample/routes/AuthCredentials.kt @@ -1,5 +1,6 @@ package com.grid.sample.routes +import com.lightspark.grid.models.auth.credentials.CredentialCreateParams import com.lightspark.grid.models.auth.credentials.CredentialCreateParams.AuthCredentialCreateRequest.PasskeyCredentialCreateRequest import com.lightspark.grid.models.auth.credentials.CredentialResendChallengeParams import com.lightspark.grid.models.auth.credentials.CredentialVerifyParams @@ -34,6 +35,11 @@ private object RegistrationChallengeStore { val expiresAt = store.remove(challenge) ?: return false return System.currentTimeMillis() < expiresAt } + + fun isValid(challenge: String): Boolean { + val expiresAt = store[challenge] ?: return false + return System.currentTimeMillis() < expiresAt + } } fun Route.authCredentialRoutes() { @@ -89,7 +95,7 @@ fun Route.authCredentialRoutes() { val challenge = json.optText("challenge") ?: throw IllegalArgumentException("challenge is required") - if (!RegistrationChallengeStore.consume(challenge)) { + if (!RegistrationChallengeStore.isValid(challenge)) { return@post call.respondText( """{"error": "challenge is invalid or expired"}""", ContentType.Application.Json, @@ -114,13 +120,34 @@ fun Route.authCredentialRoutes() { json.optText("nickname")?.let { nickname(it) } } .build() + val gridWalletSignature = call.request.headers["Grid-Wallet-Signature"] + val requestId = call.request.headers["Request-Id"] + val params = CredentialCreateParams.builder() + .authCredentialCreateRequest(request) + .apply { + gridWalletSignature?.let { gridWalletSignature(it) } + requestId?.let { requestId(it) } + } + .build() - Log.gridRequest("auth.credentials.create", JsonUtils.prettyPrint(request)) - val response = GridClientBuilder.client.auth().credentials().create(request) - val responseJson = JsonUtils.prettyPrint(response) + Log.gridRequest( + "auth.credentials.create", + "signedRetry=${gridWalletSignature != null} body=${JsonUtils.prettyPrint(request)}", + ) + val response = GridClientBuilder.client.auth().credentials() + .withRawResponse() + .create(params) + val responseJson = response.body().bufferedReader().use { it.readText() } Log.gridResponse("auth.credentials.create", responseJson) + if (response.statusCode() != HttpStatusCode.Accepted.value) { + RegistrationChallengeStore.consume(challenge) + } - call.respondText(responseJson, ContentType.Application.Json, HttpStatusCode.Created) + call.respondText( + responseJson, + ContentType.Application.Json, + HttpStatusCode.fromValue(response.statusCode()), + ) } catch (e: Exception) { Log.gridError("auth.credentials.create", e) call.respondText(