Skip to content

Commit 004dae2

Browse files
carsonp6Lightspark Eng
authored andcommitted
[js] gga example app: real WebAuthn ceremony + OTP-session caching; env-driven grid URL (#28470)
## What Wire the grid-global-accounts example app to a **real WebAuthn ceremony** (Touch ID register/sign) with OTP-session-key caching, and make the dev backend URL **env-driven** (`GRID_URL` env var, defaulting to production) instead of a hardcoded dev host. ## Why P4 example app, PR 0 in `40-example-app-design.md` §5/§6. The branch carried uncommitted real-WebAuthn-ceremony + OTP-session-caching work plus a config hazard: `vite.config.ts` had `PROD_GRID_URL` pointed at a dev host (`api.dev.dev.sparkinfra.net`), a local convenience that must not land in git. This PR commits the in-flight work and un-breaks the config so dev pointing is local-only (`GRID_URL=... yarn dev`) and never committed. ## Place in the stack Base: `06-02-_js_gga_example_app_add_v3_secure_otp_e2e_flow` (the branch holding the uncommitted V3-OTP e2e work). First PR of the **P4 example-app** stack. ## Notable points - `PROD_GRID_URL` now resolves from `process.env.GRID_URL ?? "https://api.lightspark.com"` — production by default, dev override never persisted. - No behavior change beyond what was already live on the branch; manual test tool (no automated UI tests). Type gate: `yarn workspace ... build` (tsc + vite) + `yarn lint && yarn format`. --- Part of the Turnkey login-family migration program. See `sparkcore/sparkcore/grid/docs/login-migration/00-program-plan.md`. GitOrigin-RevId: 8c7d9891683ccf6739b7045c753e2c4b0456803e
1 parent d256b2d commit 004dae2

3 files changed

Lines changed: 228 additions & 5 deletions

File tree

apps/examples/grid-global-accounts-example-app/index.html

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,24 @@ <h3>Create credential</h3>
652652
id="passkey-create-nickname"
653653
value="Sandbox Passkey"
654654
/>
655+
<label for="passkey-rp-id"
656+
>RP ID (blank = this page's hostname; must match the sub-org's)</label
657+
>
658+
<input
659+
type="text"
660+
id="passkey-rp-id"
661+
placeholder="defaults to current hostname (e.g. localhost)"
662+
/>
663+
<p class="note">
664+
Click below to register a <strong>real</strong> passkey on this
665+
device (Touch ID) — it fills the attestation fields. Use it for both
666+
"Create" and "Add additional" against real Turnkey. The sub-org's RP
667+
ID must match this page's origin.
668+
</p>
669+
<button id="btn-passkey-webauthn-create" class="secondary">
670+
📱 Create real passkey (Touch ID)
671+
</button>
672+
<div id="passkey-webauthn-create-status"></div>
655673
<label for="passkey-create-challenge">WebAuthn Challenge</label>
656674
<input
657675
type="text"
@@ -741,6 +759,14 @@ <h3>Verify → session</h3>
741759
id="passkey-verify-client-data-json"
742760
value="c2FuZGJveC1jbGllbnQtZGF0YQ"
743761
/>
762+
<p class="note">
763+
Click below to sign the issued challenge with your real passkey
764+
(Touch ID) — it fills the assertion fields above.
765+
</p>
766+
<button id="btn-passkey-webauthn-get" class="secondary">
767+
📱 Sign with passkey (Touch ID)
768+
</button>
769+
<div id="passkey-webauthn-get-status"></div>
744770
<button id="btn-passkey-verify">Verify</button>
745771
<div id="passkey-verify-status"></div>
746772
</div>
@@ -749,6 +775,11 @@ <h3>Verify → session</h3>
749775
<h3>
750776
Add additional PASSKEY via signed retry
751777
</h3>
778+
<p class="note">
779+
First click "📱 Create real passkey" in the
780+
<em>Create credential</em> section above — that registers the new
781+
passkey whose attestation is added here. Then run steps 1 and 2.
782+
</p>
752783
<label for="passkey-add-nickname">Nickname</label>
753784
<input type="text" id="passkey-add-nickname" value="Second Passkey" />
754785
<button id="btn-passkey-add-issue">1. Issue add challenge</button>

apps/examples/grid-global-accounts-example-app/src/main.ts

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ function rememberEncryptedSessionSigningKey(value: unknown): void {
7373
}
7474
}
7575

76+
// OTP_LOGIN / STAMP_LOGIN model: there is no encryptedSessionSigningKey bundle
77+
// — the TEK private key *is* the session's API key once login registers it.
78+
// Cache it directly so turnkeyStamp() can authorize later signed retries
79+
// (e.g. adding a passkey) without the Verify-style clientKeyPair + bundle.
80+
function setSessionKeysFromTek(tek: {
81+
publicKey: string;
82+
privateKey: string;
83+
}): void {
84+
cachedSessionKeys = {
85+
apiPublicKey: tek.publicKey,
86+
apiPrivateKey: tek.privateKey,
87+
};
88+
}
89+
7690
function decryptSessionKeysOrThrow(): SessionKeys {
7791
if (cachedSessionKeys) return cachedSessionKeys;
7892
if (!clientKeyPair)
@@ -653,6 +667,10 @@ bindClick(
653667
...session,
654668
});
655669
if (session.id) setCtxSession(session.id as string);
670+
// The TEK is now the session's API key (OTP_LOGIN registered it). Cache it
671+
// as the active session signing key so later signed retries (add passkey,
672+
// quote execute, etc.) can stamp with this session via turnkeyStamp().
673+
if (leg2.status === 200) setSessionKeysFromTek(tek);
656674
// One bundle per challenge — force a fresh Challenge for the next run.
657675
v3TargetBundle = null;
658676
v3TargetBundleCredId = null;
@@ -811,6 +829,119 @@ bindClick(
811829
},
812830
);
813831

832+
// ----- WebAuthn ceremony helpers (real passkeys) -----
833+
//
834+
// The sandbox flows accept magic placeholder strings, but a real Turnkey
835+
// sub-org needs a genuine WebAuthn credential. These helpers drive the
836+
// browser's authenticator (Touch ID, etc.) and base64url-encode the results
837+
// into the same fields the sandbox flow uses, so Create / Add / Verify work
838+
// unchanged against production Turnkey.
839+
//
840+
// NOTE: WebAuthn binds a credential to an RP ID that must be a suffix of the
841+
// page origin — on localhost that means rpId="localhost". The Turnkey sub-org
842+
// must have been created with the SAME RP ID or verification will fail.
843+
844+
function bytesToB64Url(bytes: Uint8Array): string {
845+
let bin = "";
846+
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
847+
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
848+
}
849+
850+
function b64UrlToBytes(value: string): Uint8Array {
851+
const b64 = value.replace(/-/g, "+").replace(/_/g, "/");
852+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
853+
const bin = atob(padded);
854+
const bytes = new Uint8Array(bin.length);
855+
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
856+
return bytes;
857+
}
858+
859+
function passkeyRpId(): string {
860+
return el<HTMLInputElement>("passkey-rp-id").value.trim() || location.hostname;
861+
}
862+
863+
interface RealAttestation {
864+
challenge: string;
865+
credentialId: string;
866+
clientDataJson: string;
867+
attestationObject: string;
868+
}
869+
870+
// Real registration ceremony — produces the attestation that Create/Add send.
871+
async function createRealPasskey(nickname: string): Promise<RealAttestation> {
872+
const challenge = crypto.getRandomValues(new Uint8Array(32));
873+
const userId = crypto.getRandomValues(new Uint8Array(16));
874+
const credential = (await navigator.credentials.create({
875+
publicKey: {
876+
rp: { id: passkeyRpId(), name: "Grid Example App" },
877+
user: {
878+
id: userId,
879+
name: nickname || "grid-example-user",
880+
displayName: nickname || "Grid Example User",
881+
},
882+
challenge,
883+
pubKeyCredParams: [
884+
{ type: "public-key", alg: -7 },
885+
{ type: "public-key", alg: -257 },
886+
],
887+
authenticatorSelection: {
888+
residentKey: "preferred",
889+
userVerification: "preferred",
890+
},
891+
attestation: "none",
892+
timeout: 60000,
893+
},
894+
})) as PublicKeyCredential | null;
895+
if (!credential) throw new Error("Passkey creation returned no credential");
896+
const response = credential.response as AuthenticatorAttestationResponse;
897+
return {
898+
challenge: bytesToB64Url(challenge),
899+
credentialId: bytesToB64Url(new Uint8Array(credential.rawId)),
900+
clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)),
901+
attestationObject: bytesToB64Url(new Uint8Array(response.attestationObject)),
902+
};
903+
}
904+
905+
interface RealAssertion {
906+
credentialId: string;
907+
authenticatorData: string;
908+
clientDataJson: string;
909+
signature: string;
910+
}
911+
912+
// Real assertion ceremony — signs the issued session challenge.
913+
async function signWithPasskey(
914+
challengeValue: string,
915+
credentialId: string,
916+
): Promise<RealAssertion> {
917+
if (!challengeValue) {
918+
throw new Error("No challenge — issue a session challenge (step above) first.");
919+
}
920+
// PR #28427: Turnkey's WebAuthn challenge is the UTF-8 bytes of the
921+
// sha256-hex challenge string returned by /challenge — NOT base64url-decoded.
922+
const challenge = new TextEncoder().encode(challengeValue);
923+
const allowCredentials: PublicKeyCredentialDescriptor[] = credentialId
924+
? [{ type: "public-key", id: b64UrlToBytes(credentialId) as BufferSource }]
925+
: [];
926+
const credential = (await navigator.credentials.get({
927+
publicKey: {
928+
rpId: passkeyRpId(),
929+
challenge,
930+
allowCredentials,
931+
userVerification: "preferred",
932+
timeout: 60000,
933+
},
934+
})) as PublicKeyCredential | null;
935+
if (!credential) throw new Error("Passkey assertion returned no credential");
936+
const response = credential.response as AuthenticatorAssertionResponse;
937+
return {
938+
credentialId: bytesToB64Url(new Uint8Array(credential.rawId)),
939+
authenticatorData: bytesToB64Url(new Uint8Array(response.authenticatorData)),
940+
clientDataJson: bytesToB64Url(new Uint8Array(response.clientDataJSON)),
941+
signature: bytesToB64Url(new Uint8Array(response.signature)),
942+
};
943+
}
944+
814945
// ----- PASSKEY -----
815946

816947
bindClick(
@@ -838,8 +969,31 @@ bindClick(
838969
},
839970
);
840971

972+
// Drive a real WebAuthn registration (Touch ID) and fill the attestation
973+
// fields above — used by both the "Create" and "Add additional" flows.
974+
bindClick(
975+
"btn-passkey-webauthn-create",
976+
"passkey-webauthn-create-status",
977+
"Passkey Register",
978+
"Waiting for authenticator (Touch ID)...",
979+
async () => {
980+
const nickname = el<HTMLInputElement>("passkey-create-nickname").value.trim();
981+
const att = await createRealPasskey(nickname);
982+
el<HTMLInputElement>("passkey-create-challenge").value = att.challenge;
983+
el<HTMLInputElement>("passkey-create-cred-id-raw").value = att.credentialId;
984+
el<HTMLInputElement>("passkey-create-client-data-json").value = att.clientDataJson;
985+
el<HTMLInputElement>("passkey-create-attestation-object").value =
986+
att.attestationObject;
987+
addLog("Passkey Registered (real)", att);
988+
return "Real passkey created — attestation fields filled. Now run Create or Add.";
989+
},
990+
);
991+
841992
wireGenKeyButton("btn-passkey-challenge-genkey", "passkey-challenge-pubkey");
842993
const passkeyVerifyRequestId = el<HTMLInputElement>("passkey-verify-request-id");
994+
// Captured from the session-challenge response so the real assertion ceremony
995+
// can sign the exact sha256-hex challenge Turnkey expects.
996+
let passkeySessionChallenge = "";
843997
bindClick(
844998
"btn-passkey-challenge",
845999
"passkey-challenge-status",
@@ -856,6 +1010,7 @@ bindClick(
8561010
addLog("PASSKEY Challenge", data);
8571011
const d = data as Record<string, unknown>;
8581012
if (d.requestId) passkeyVerifyRequestId.value = d.requestId as string;
1013+
if (typeof d.challenge === "string") passkeySessionChallenge = d.challenge;
8591014
return JSON.stringify(data, null, 2);
8601015
},
8611016
);
@@ -893,7 +1048,30 @@ bindClick(
8931048
},
8941049
);
8951050

1051+
// Drive a real WebAuthn assertion (Touch ID) against the issued challenge and
1052+
// fill the assertion fields above for Verify.
1053+
bindClick(
1054+
"btn-passkey-webauthn-get",
1055+
"passkey-webauthn-get-status",
1056+
"Passkey Sign",
1057+
"Waiting for authenticator (Touch ID)...",
1058+
async () => {
1059+
const credId = el<HTMLInputElement>("passkey-create-cred-id-raw").value.trim();
1060+
const assertion = await signWithPasskey(passkeySessionChallenge, credId);
1061+
el<HTMLInputElement>("passkey-create-cred-id-raw").value = assertion.credentialId;
1062+
el<HTMLInputElement>("passkey-verify-client-data-json").value =
1063+
assertion.clientDataJson;
1064+
el<HTMLInputElement>("passkey-verify-auth-data").value =
1065+
assertion.authenticatorData;
1066+
el<HTMLInputElement>("passkey-verify-signature").value = assertion.signature;
1067+
addLog("Passkey Signed (real)", assertion);
1068+
return "Real assertion produced — verify fields filled. Now click Verify.";
1069+
},
1070+
);
1071+
8961072
const passkeyAddRequestId = el<HTMLInputElement>("passkey-add-request-id");
1073+
// Captured from the add-issue 202 so the retry can stamp the exact payload.
1074+
let passkeyAddPayloadToSign = "";
8971075
function buildPasskeyAddBody(): Record<string, unknown> {
8981076
return {
8991077
type: "PASSKEY",
@@ -917,6 +1095,7 @@ bindClick(
9171095
addLog("PASSKEY Add (issue)", data);
9181096
const d = data as Record<string, unknown>;
9191097
if (d.requestId) passkeyAddRequestId.value = d.requestId as string;
1098+
if (typeof d.payloadToSign === "string") passkeyAddPayloadToSign = d.payloadToSign;
9201099
return JSON.stringify(data, null, 2);
9211100
},
9221101
);
@@ -928,10 +1107,21 @@ bindClick(
9281107
async () => {
9291108
const requestId = passkeyAddRequestId.value.trim();
9301109
if (!requestId) throw new Error("Request-Id is required — run step 1 first.");
1110+
// Sandbox accepts the magic value, but real Turnkey requires the
1111+
// CREATE_AUTHENTICATORS payload to be stamped by an authorized credential —
1112+
// the active session's signing key. Establish a session (e.g. OTP login or
1113+
// passkey verify) first so the session signing key is available.
1114+
let signature = SANDBOX_SIG;
1115+
if (getMode() === "production") {
1116+
if (!passkeyAddPayloadToSign) {
1117+
throw new Error("Missing payloadToSign — run step 1 first.");
1118+
}
1119+
signature = await turnkeyStamp(passkeyAddPayloadToSign);
1120+
}
9311121
const { data } = await apiPost(
9321122
"/auth/credentials",
9331123
buildPasskeyAddBody(),
934-
{ "Grid-Wallet-Signature": SANDBOX_SIG, "Request-Id": requestId },
1124+
{ "Grid-Wallet-Signature": signature, "Request-Id": requestId },
9351125
);
9361126
addLog("PASSKEY Add (retry)", data);
9371127
return JSON.stringify(data, null, 2);

apps/examples/grid-global-accounts-example-app/vite.config.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { defineConfig } from "vite";
22
import settings from "../settings.json";
33

4-
// Production grid URL. The proxy strips the `/api` prefix and rewrites the
5-
// path to the versioned API channel. Credentials are entered manually in the
6-
// UI — never embedded here.
7-
const PROD_GRID_URL = "https://api.lightspark.com";
4+
// Grid API base for the dev proxy (strips the `/api` prefix and rewrites the
5+
// path to the versioned API channel). Defaults to production; override locally
6+
// for a dev backend via the GRID_URL env var, e.g.
7+
// GRID_URL=https://api.dev.dev.sparkinfra.net yarn dev
8+
// Credentials are entered manually in the UI — never embedded here.
9+
const PROD_GRID_URL = process.env.GRID_URL ?? "https://api.lightspark.com";
810

911
export default defineConfig({
1012
server: {

0 commit comments

Comments
 (0)