@test2doc/playwright-passkey-gen 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -0
  3. package/bin/generate-passkey +2 -0
  4. package/dist/src/generate-passkey/cli.d.ts +2 -0
  5. package/dist/src/generate-passkey/cli.d.ts.map +1 -0
  6. package/dist/src/generate-passkey/cli.js +32 -0
  7. package/dist/src/generate-passkey/cli.js.map +1 -0
  8. package/dist/src/generate-passkey/generateTestPasskey.test.d.ts +2 -0
  9. package/dist/src/generate-passkey/generateTestPasskey.test.d.ts.map +1 -0
  10. package/dist/src/generate-passkey/generateTestPasskey.test.js +143 -0
  11. package/dist/src/generate-passkey/generateTestPasskey.test.js.map +1 -0
  12. package/dist/src/generate-passkey/index.d.ts +10 -0
  13. package/dist/src/generate-passkey/index.d.ts.map +1 -0
  14. package/dist/src/generate-passkey/index.js +158 -0
  15. package/dist/src/generate-passkey/index.js.map +1 -0
  16. package/dist/src/generate-passkey/test-cli.d.ts +2 -0
  17. package/dist/src/generate-passkey/test-cli.d.ts.map +1 -0
  18. package/dist/src/generate-passkey/test-cli.js +31 -0
  19. package/dist/src/generate-passkey/test-cli.js.map +1 -0
  20. package/dist/src/index.d.ts +2 -0
  21. package/dist/src/index.d.ts.map +1 -0
  22. package/dist/src/index.js +2 -0
  23. package/dist/src/index.js.map +1 -0
  24. package/dist/src/passkey-util.d.ts +19 -0
  25. package/dist/src/passkey-util.d.ts.map +1 -0
  26. package/dist/src/passkey-util.js +58 -0
  27. package/dist/src/passkey-util.js.map +1 -0
  28. package/dist/src/scripts/client.d.ts +2 -0
  29. package/dist/src/scripts/client.d.ts.map +1 -0
  30. package/dist/src/scripts/client.js +97 -0
  31. package/dist/src/scripts/client.js.map +1 -0
  32. package/dist/src/scripts/server.d.ts +2 -0
  33. package/dist/src/scripts/server.d.ts.map +1 -0
  34. package/dist/src/scripts/server.js +224 -0
  35. package/dist/src/scripts/server.js.map +1 -0
  36. package/dist/src/scripts/testpasskey.d.ts +10 -0
  37. package/dist/src/scripts/testpasskey.d.ts.map +1 -0
  38. package/dist/src/scripts/testpasskey.js +88 -0
  39. package/dist/src/scripts/testpasskey.js.map +1 -0
  40. package/dist/tsconfig.tsbuildinfo +1 -0
  41. package/package.json +50 -0
  42. package/playwright-report/index.html +85 -0
  43. package/playwright.config.ts +80 -0
  44. package/src/generate-passkey/cli.ts +33 -0
  45. package/src/generate-passkey/generateTestPasskey.test.ts +165 -0
  46. package/src/generate-passkey/index.ts +228 -0
  47. package/src/generate-passkey/test-cli.ts +32 -0
  48. package/src/scripts/client.ts +136 -0
  49. package/src/scripts/server.ts +301 -0
  50. package/src/scripts/testpasskey.ts +87 -0
  51. package/test-passkey.js +87 -0
  52. package/test-passkey.ts +87 -0
  53. package/test-results/.last-run.json +4 -0
  54. package/tests/passkey.spec.ts +29 -0
  55. package/tsconfig.json +52 -0
  56. package/tsconfig.tsbuildinfo +1 -0
  57. package/vitest.config.ts +8 -0
@@ -0,0 +1,80 @@
1
+ /// <reference types="node" />
2
+ import { defineConfig, devices } from "@playwright/test"
3
+
4
+ /**
5
+ * Read environment variables from file.
6
+ * https://github.com/motdotla/dotenv
7
+ */
8
+ // import dotenv from 'dotenv';
9
+ // import path from 'path';
10
+ // dotenv.config({ path: path.resolve(__dirname, '.env') });
11
+
12
+ /**
13
+ * See https://playwright.dev/docs/test-configuration.
14
+ */
15
+ export default defineConfig({
16
+ testDir: "./tests",
17
+ /* Run tests in files in parallel */
18
+ fullyParallel: true,
19
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
20
+ forbidOnly: !!process.env["CI"],
21
+ /* Retry on CI only */
22
+ retries: process.env["CI"] ? 2 : 0,
23
+ /* Opt out of parallel tests on CI. */
24
+ ...(process.env["CI"] ? { workers: 1 } : {}),
25
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
26
+ reporter: "html",
27
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
28
+ use: {
29
+ /* Base URL to use in actions like `await page.goto('')`. */
30
+ baseURL: "http://localhost:5173",
31
+
32
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
33
+ trace: "on-first-retry",
34
+ },
35
+
36
+ /* Configure projects for major browsers */
37
+ projects: [
38
+ {
39
+ name: "passkey tests",
40
+ use: { ...devices["Desktop Chrome"] },
41
+ },
42
+
43
+ // {
44
+ // name: 'firefox',
45
+ // use: { ...devices['Desktop Firefox'] },
46
+ // },
47
+
48
+ // {
49
+ // name: 'webkit',
50
+ // use: { ...devices['Desktop Safari'] },
51
+ // },
52
+
53
+ /* Test against mobile viewports. */
54
+ // {
55
+ // name: 'Mobile Chrome',
56
+ // use: { ...devices['Pixel 5'] },
57
+ // },
58
+ // {
59
+ // name: 'Mobile Safari',
60
+ // use: { ...devices['iPhone 12'] },
61
+ // },
62
+
63
+ /* Test against branded browsers. */
64
+ // {
65
+ // name: 'Microsoft Edge',
66
+ // use: { ...devices['Desktop Edge'], channel: 'msedge' },
67
+ // },
68
+ // {
69
+ // name: 'Google Chrome',
70
+ // use: { ...devices['Desktop Chrome'], channel: 'chrome' },
71
+ // },
72
+ ],
73
+
74
+ /* Run your local dev server before starting the tests */
75
+ webServer: {
76
+ command: "pnpm start",
77
+ url: "http://localhost:5173",
78
+ reuseExistingServer: !process.env["CI"],
79
+ },
80
+ })
@@ -0,0 +1,33 @@
1
+ import { spawn } from "child_process"
2
+
3
+ const server = spawn("pnpm", ["start"], { stdio: "inherit" })
4
+
5
+ const killServer = () => {
6
+ try {
7
+ if (server && !server.killed) server.kill()
8
+ } catch {
9
+ /* ignore */
10
+ }
11
+ }
12
+
13
+ ;["exit", "SIGINT", "SIGTERM", "uncaughtException"].forEach((ev) => {
14
+ process.on(ev, () => {
15
+ killServer()
16
+ if (ev !== "exit") process.exit(1)
17
+ })
18
+ })
19
+
20
+ // Run the built generator (make sure pnpm build has run first)
21
+ const generator = spawn("node", ["./dist/src/generate-passkey/index.js"], {
22
+ stdio: "inherit",
23
+ })
24
+
25
+ generator.on("close", (code) => {
26
+ killServer()
27
+ process.exit(code ?? 0)
28
+ })
29
+ generator.on("error", (err) => {
30
+ killServer()
31
+ console.error(err)
32
+ process.exit(1)
33
+ })
@@ -0,0 +1,165 @@
1
+ import { expect, test } from "vitest"
2
+ import { mkdir, rmdir, unlink, access } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { main } from "./index.js"
5
+
6
+ // Assertion helpers
7
+ const UUID_V4_REGEX =
8
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
9
+
10
+ const base64ToBase64url = (b64: string) =>
11
+ b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "")
12
+
13
+ test("generate a test passkey file", async () => {
14
+ const outputPath = join(process.cwd(), "test-passkey.ts")
15
+ // remove existing file if present
16
+ try {
17
+ await unlink(outputPath)
18
+ } catch {
19
+ // ignore if not present
20
+ }
21
+ await main()
22
+
23
+ const { TESTPASSKEY } = await import(outputPath)
24
+ expect(TESTPASSKEY.username).toBe("testuser")
25
+ expect(TESTPASSKEY.userId).toMatch(UUID_V4_REGEX)
26
+ expect(TESTPASSKEY.publicKey).toBeDefined()
27
+ expect(Array.isArray(TESTPASSKEY.publicKey)).toBe(true)
28
+ expect(TESTPASSKEY.signCount).toBe(1)
29
+
30
+ expect(TESTPASSKEY.credentialId).toBeDefined()
31
+ expect(TESTPASSKEY.credentialDbId).toBeDefined()
32
+ // assert credentialDbId is the base64url encoding of credentialId
33
+ expect(TESTPASSKEY.credentialDbId).toBe(
34
+ base64ToBase64url(TESTPASSKEY.credentialId),
35
+ )
36
+
37
+ // cleanup
38
+ try {
39
+ await unlink(outputPath)
40
+ } catch {}
41
+ })
42
+
43
+ test("generate a passkey to the output path specified", async () => {
44
+ const tempDir = "tmp-output"
45
+ const dir = join(process.cwd(), tempDir)
46
+ const fileName = "my-passkey.ts"
47
+ const outputPath = join(dir, fileName)
48
+
49
+ // ensure clean slate
50
+ try {
51
+ await unlink(outputPath)
52
+ } catch {}
53
+ try {
54
+ await rmdir(dir)
55
+ } catch {}
56
+
57
+ await mkdir(dir, { recursive: true })
58
+
59
+ await main({ output: join(tempDir, fileName) })
60
+
61
+ // assert file exists and has TESTPASSKEY
62
+ const { TESTPASSKEY } = await import(outputPath)
63
+
64
+ expect(TESTPASSKEY).toBeDefined()
65
+ expect(TESTPASSKEY.username).toBe("testuser")
66
+ expect(TESTPASSKEY.credentialId).toBeDefined()
67
+ expect(TESTPASSKEY.credentialDbId).toBeDefined()
68
+
69
+ // cleanup
70
+ try {
71
+ await unlink(outputPath)
72
+ await rmdir(dir)
73
+ } catch {}
74
+ })
75
+
76
+ test("generate JSON passkey file", async () => {
77
+ const outputPath = join(process.cwd(), "test-passkey.json")
78
+ // remove existing file if present
79
+ try {
80
+ await unlink(outputPath)
81
+ } catch {
82
+ // ignore if not present
83
+ }
84
+ await main({ type: "json" })
85
+
86
+ const fs = await import("fs/promises")
87
+ const content = await fs.readFile(outputPath, "utf-8")
88
+ const TESTPASSKEY = JSON.parse(content)
89
+
90
+ expect(TESTPASSKEY.username).toBe("testuser")
91
+ expect(TESTPASSKEY.userId).toMatch(UUID_V4_REGEX)
92
+ expect(TESTPASSKEY.publicKey).toBeDefined()
93
+ expect(Array.isArray(TESTPASSKEY.publicKey)).toBe(true)
94
+ expect(TESTPASSKEY.signCount).toBe(1)
95
+
96
+ expect(TESTPASSKEY.credentialId).toBeDefined()
97
+ expect(TESTPASSKEY.credentialDbId).toBeDefined()
98
+ // assert credentialDbId is the base64url encoding of credentialId
99
+ expect(TESTPASSKEY.credentialDbId).toBe(
100
+ base64ToBase64url(TESTPASSKEY.credentialId),
101
+ )
102
+
103
+ // cleanup
104
+ try {
105
+ await unlink(outputPath)
106
+ } catch {}
107
+ })
108
+
109
+ test("generate javascript passkey file and make the extension js", async () => {
110
+ const outputPath = join(process.cwd(), "test-passkey.json")
111
+ const expectedPath = join(process.cwd(), "test-passkey.js")
112
+ // remove existing file if present
113
+ try {
114
+ await unlink(outputPath)
115
+ await unlink(expectedPath)
116
+ } catch {
117
+ // ignore if not present
118
+ }
119
+ await main({ type: "javascript" })
120
+
121
+ await access(expectedPath)
122
+ const { TESTPASSKEY } = await import(expectedPath)
123
+
124
+ expect(TESTPASSKEY.username).toBe("testuser")
125
+ expect(TESTPASSKEY.userId).toMatch(UUID_V4_REGEX)
126
+ expect(TESTPASSKEY.publicKey).toBeDefined()
127
+ expect(Array.isArray(TESTPASSKEY.publicKey)).toBe(true)
128
+ expect(TESTPASSKEY.signCount).toBe(1)
129
+
130
+ expect(TESTPASSKEY.credentialId).toBeDefined()
131
+ expect(TESTPASSKEY.credentialDbId).toBeDefined()
132
+ // assert credentialDbId is the base64url encoding of credentialId
133
+ expect(TESTPASSKEY.credentialDbId).toBe(
134
+ base64ToBase64url(TESTPASSKEY.credentialId),
135
+ )
136
+
137
+ // cleanup
138
+ try {
139
+ await unlink(outputPath)
140
+ await unlink(expectedPath)
141
+ } catch {}
142
+ })
143
+
144
+ test("generate the correct extension if the output filename is missing the extension", async () => {
145
+ const outputPath = join(process.cwd(), "test-passkey")
146
+ const expectedPath = join(process.cwd(), "test-passkey.ts")
147
+ // remove existing file if present
148
+ try {
149
+ await unlink(expectedPath)
150
+ await unlink(outputPath)
151
+ } catch {
152
+ // ignore if not present
153
+ }
154
+ await main({ output: "test-passkey" })
155
+
156
+ await access(expectedPath)
157
+ const { TESTPASSKEY } = await import(expectedPath)
158
+ expect(TESTPASSKEY).toBeDefined()
159
+
160
+ // cleanup
161
+ try {
162
+ await unlink(expectedPath)
163
+ await unlink(outputPath)
164
+ } catch {}
165
+ })
@@ -0,0 +1,228 @@
1
+ import { chromium } from "@playwright/test"
2
+ import { writeFileSync } from "fs"
3
+ import { join } from "path"
4
+ import { verifyRegistrationResponse } from "@simplewebauthn/server"
5
+ import { Command, type OptionValues } from "commander"
6
+ import { fileURLToPath } from "url"
7
+ import type { TestPasskey } from "@test2doc/playwright-passkey"
8
+
9
+ interface VirtualAuthenticatorCredential {
10
+ credentialId: string
11
+ privateKey: string
12
+ userHandle: string
13
+ signCount: number
14
+ }
15
+
16
+ function isVirtualAuthenticatorCredential(
17
+ cred: unknown,
18
+ ): cred is VirtualAuthenticatorCredential {
19
+ return (
20
+ cred !== null &&
21
+ typeof cred === "object" &&
22
+ "credentialId" in cred &&
23
+ "privateKey" in cred &&
24
+ "userHandle" in cred &&
25
+ "signCount" in cred
26
+ )
27
+ }
28
+
29
+ export async function generateTestPasskey(
30
+ username: string,
31
+ userId: string,
32
+ ): Promise<TestPasskey> {
33
+ const browser = await chromium.launch()
34
+ const context = await browser.newContext()
35
+ const page = await context.newPage()
36
+ await page.goto("http://localhost:5173/")
37
+
38
+ // Set up virtual authenticator
39
+ const client = await context.newCDPSession(page)
40
+ await client.send("WebAuthn.enable")
41
+ const result = await client.send("WebAuthn.addVirtualAuthenticator", {
42
+ options: {
43
+ protocol: "ctap2",
44
+ transport: "internal",
45
+ hasResidentKey: true,
46
+ hasUserVerification: true,
47
+ isUserVerified: true,
48
+ automaticPresenceSimulation: true,
49
+ },
50
+ })
51
+
52
+ const authenticatorId = result.authenticatorId
53
+
54
+ // Generate a credential by simulating registration
55
+ const credentialResponse = await page.evaluate(
56
+ async ({ userId, username }) => {
57
+ try {
58
+ const credential = await navigator.credentials.create({
59
+ publicKey: {
60
+ challenge: new Uint8Array(32),
61
+ rp: {
62
+ name: "Test App",
63
+ id: "localhost",
64
+ },
65
+ user: {
66
+ id: new TextEncoder().encode(userId),
67
+ name: username,
68
+ displayName: username,
69
+ },
70
+ pubKeyCredParams: [{ alg: -7, type: "public-key" }],
71
+ },
72
+ })
73
+
74
+ // Type guards
75
+ function isPublicKeyCredential(
76
+ credential: Credential | null,
77
+ ): credential is PublicKeyCredential {
78
+ return credential !== null && credential.type === "public-key"
79
+ }
80
+ function isAuthenticatorAttestationResponse(
81
+ response: AuthenticatorResponse,
82
+ ): response is AuthenticatorAttestationResponse {
83
+ return "attestationObject" in response
84
+ }
85
+
86
+ if (!isPublicKeyCredential(credential)) {
87
+ throw new Error("Failed to create public key credential")
88
+ }
89
+ if (!isAuthenticatorAttestationResponse(credential.response)) {
90
+ throw new Error("Response is not an attestation response")
91
+ }
92
+
93
+ return {
94
+ id: credential.id,
95
+ rawId: Array.from(new Uint8Array(credential.rawId)),
96
+ response: {
97
+ clientDataJSON: Array.from(
98
+ new Uint8Array(credential.response.clientDataJSON),
99
+ ),
100
+ attestationObject: Array.from(
101
+ new Uint8Array(credential.response.attestationObject),
102
+ ),
103
+ },
104
+ clientExtensionResults: {},
105
+ transports: ["internal"],
106
+ type: "public-key",
107
+ }
108
+ } catch (err) {
109
+ throw new Error(`Credential creation failed: ${err}`)
110
+ }
111
+ },
112
+ { userId, username },
113
+ )
114
+
115
+ // Extract the credential from the virtual authenticator
116
+ const credentials = await client.send("WebAuthn.getCredentials", {
117
+ authenticatorId,
118
+ })
119
+
120
+ if (!credentials.credentials || credentials.credentials.length === 0) {
121
+ throw new Error("No credentials found in virtual authenticator")
122
+ }
123
+
124
+ const credential = credentials.credentials[0]
125
+ if (!isVirtualAuthenticatorCredential(credential)) {
126
+ throw new Error("Invalid credential structure from virtual authenticator")
127
+ }
128
+
129
+ const verification = await verifyRegistrationResponse({
130
+ response: {
131
+ id: credentialResponse.id,
132
+ rawId: Buffer.from(credentialResponse.rawId).toString("base64url"),
133
+ response: {
134
+ clientDataJSON: Buffer.from(
135
+ credentialResponse.response.clientDataJSON,
136
+ ).toString("base64url"),
137
+ attestationObject: Buffer.from(
138
+ credentialResponse.response.attestationObject,
139
+ ).toString("base64url"),
140
+ },
141
+ type: "public-key" as const,
142
+ clientExtensionResults: {},
143
+ },
144
+ expectedChallenge: Buffer.from(new Uint8Array(32)).toString("base64url"), // the challenge you used
145
+ expectedOrigin: "http://localhost:5173",
146
+ expectedRPID: "localhost",
147
+ })
148
+
149
+ if (verification.verified && verification.registrationInfo) {
150
+ const testPasskey: TestPasskey = {
151
+ username,
152
+ userId,
153
+ credentialId: Buffer.from(credential.credentialId, "base64").toString(
154
+ "base64",
155
+ ),
156
+ publicKey: Array.from(verification.registrationInfo.credential.publicKey),
157
+ privateKey: credential.privateKey,
158
+ credentialDbId: credentialResponse.id,
159
+ signCount: credential.signCount,
160
+ }
161
+
162
+ await browser.close()
163
+
164
+ return testPasskey
165
+ }
166
+ throw new Error("Failed to verify registration response")
167
+ }
168
+
169
+ interface Options extends OptionValues {
170
+ output: string
171
+ type: "json" | "ts" | "js" | "javascript" | "typescript"
172
+ }
173
+
174
+ export async function main({
175
+ output = "test-passkey.ts",
176
+ type = "ts",
177
+ }: Partial<Options> = {}) {
178
+ const username = "testuser"
179
+ const userId = crypto.randomUUID()
180
+
181
+ console.log("Generating test passkey...")
182
+ console.log(`Username: ${username}`)
183
+ console.log(`User ID: ${userId}`)
184
+
185
+ const passkey = await generateTestPasskey(username, userId)
186
+
187
+ // Map type to file extension
188
+ const extensionMap: Record<string, string> = {
189
+ json: ".json",
190
+ js: ".js",
191
+ javascript: ".js",
192
+ ts: ".ts",
193
+ typescript: ".ts",
194
+ }
195
+ const ext = extensionMap[type] || ".ts"
196
+ output = output.replace(/\.\w+$/, "") + ext
197
+
198
+ const outputPath = join(process.cwd(), output)
199
+ const stringifyPasskey = JSON.stringify(passkey, null, 2)
200
+ const content =
201
+ type === "json"
202
+ ? stringifyPasskey
203
+ : `export const TESTPASSKEY = ${stringifyPasskey}`
204
+
205
+ writeFileSync(outputPath, content)
206
+ console.log(`✓ Test passkey generated and saved to ${outputPath}`)
207
+ console.log("\nGenerated passkey:")
208
+ console.log(stringifyPasskey)
209
+ }
210
+
211
+ if (fileURLToPath(import.meta.url) === process.argv[1]) {
212
+ const program = new Command()
213
+ program
214
+ .option(
215
+ "-o, --output <path>",
216
+ "output path for generated passkey",
217
+ "test-passkey.ts",
218
+ )
219
+ .option(
220
+ "-t, --type <type>",
221
+ "output file type (json, ts, js, typescript, javascript)",
222
+ "ts",
223
+ )
224
+ .parse()
225
+
226
+ const opts = program.opts()
227
+ main(opts).catch(console.error)
228
+ }
@@ -0,0 +1,32 @@
1
+ import { spawn } from "child_process"
2
+
3
+ const server = spawn("pnpm", ["start"], { stdio: "inherit" })
4
+
5
+ const killServer = () => {
6
+ try {
7
+ if (server && !server.killed) server.kill()
8
+ } catch {
9
+ /* ignore */
10
+ }
11
+ }
12
+
13
+ ;["exit", "SIGINT", "SIGTERM", "uncaughtException"].forEach((ev) => {
14
+ process.on(ev, () => {
15
+ killServer()
16
+ // for signals/uncaught exceptions exit with non-zero
17
+ if (ev !== "exit") process.exit(1)
18
+ })
19
+ })
20
+
21
+ // run the local vitest via pnpm exec so it works in CI/Windows too
22
+ const tests = spawn("pnpm", ["exec", "vitest", "run"], { stdio: "inherit" })
23
+
24
+ tests.on("close", (code) => {
25
+ killServer()
26
+ process.exit(code ?? 0)
27
+ })
28
+ tests.on("error", (err) => {
29
+ killServer()
30
+ console.error(err)
31
+ process.exit(1)
32
+ })
@@ -0,0 +1,136 @@
1
+ // client.ts
2
+ function base64urlToBuffer(base64url: string): ArrayBuffer {
3
+ // Convert base64url to base64
4
+ const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
5
+ const padded = base64.padEnd(
6
+ base64.length + ((4 - (base64.length % 4)) % 4),
7
+ "=",
8
+ )
9
+
10
+ // Decode base64 to binary string
11
+ const binary = atob(padded)
12
+
13
+ // Convert to ArrayBuffer
14
+ const bytes = new Uint8Array(binary.length)
15
+ for (let i = 0; i < binary.length; i++) {
16
+ bytes[i] = binary.charCodeAt(i)
17
+ }
18
+ return bytes.buffer
19
+ }
20
+
21
+ function arrayBufferToBase64url(buffer: ArrayBuffer): string {
22
+ const bytes = new Uint8Array(buffer)
23
+ let binary = ""
24
+ for (const c of bytes) {
25
+ binary += String.fromCharCode(c)
26
+ }
27
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
28
+ }
29
+
30
+ function updateStatus(
31
+ statusDiv: HTMLElement,
32
+ message: string,
33
+ type: "loading" | "success" | "error",
34
+ ): void {
35
+ console.log(message)
36
+ statusDiv.textContent = message
37
+ statusDiv.className = `status ${type}`
38
+ }
39
+
40
+ async function testAuthentication(): Promise<void> {
41
+ const statusDiv = document.getElementById("status")
42
+ if (!statusDiv) {
43
+ throw new Error("Status div not found")
44
+ }
45
+
46
+ try {
47
+ updateStatus(statusDiv, "Starting authentication...", "loading")
48
+
49
+ const optionsResponse = await fetch("/authenticate/start", {
50
+ method: "POST",
51
+ headers: { "Content-Type": "application/json" },
52
+ })
53
+
54
+ if (!optionsResponse.ok) {
55
+ throw new Error("Failed to get authentication options")
56
+ }
57
+
58
+ const options = await optionsResponse.json()
59
+ updateStatus(statusDiv, "Got authentication options", "loading")
60
+
61
+ const assertion = await navigator.credentials.get({
62
+ publicKey: {
63
+ challenge: base64urlToBuffer(options.challenge),
64
+ rpId: options.rpId,
65
+ userVerification: "preferred",
66
+ allowCredentials: options.allowCredentials,
67
+ },
68
+ })
69
+
70
+ if (!assertion) {
71
+ throw new Error("User cancelled authentication")
72
+ }
73
+
74
+ function isPublicKeyCredential(
75
+ credential: Credential | null,
76
+ ): credential is PublicKeyCredential {
77
+ return credential !== null && credential.type === "public-key"
78
+ }
79
+ function isAuthenticatorAssertionResponse(
80
+ response: AuthenticatorResponse,
81
+ ): response is AuthenticatorAssertionResponse {
82
+ return "signature" in response && "authenticatorData" in response
83
+ }
84
+
85
+ if (!isPublicKeyCredential(assertion)) {
86
+ throw new Error("No valid PublicKeyCredential returned")
87
+ }
88
+ if (!isAuthenticatorAssertionResponse(assertion.response)) {
89
+ throw new Error("Response is not an assertion response")
90
+ }
91
+
92
+ updateStatus(statusDiv, "Got credential from authenticator", "loading")
93
+
94
+ const response = await fetch("/authenticate/finish", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({
98
+ credentialId: assertion.id,
99
+ clientDataJSON: arrayBufferToBase64url(
100
+ assertion.response.clientDataJSON,
101
+ ),
102
+ authenticatorData: arrayBufferToBase64url(
103
+ assertion.response.authenticatorData,
104
+ ),
105
+ signature: arrayBufferToBase64url(assertion.response.signature),
106
+ // signCount: assertion.response.signCount,
107
+ }),
108
+ })
109
+
110
+ if (!response.ok) {
111
+ const error = await response.json()
112
+ throw new Error(error.error || "Authentication failed")
113
+ }
114
+
115
+ const result = await response.json()
116
+ updateStatus(
117
+ statusDiv,
118
+ `✓ Authentication successful! User ID: ${result.userId}`,
119
+ "success",
120
+ )
121
+ } catch (error) {
122
+ updateStatus(
123
+ statusDiv,
124
+ `✗ Error: ${error instanceof Error ? error.message : String(error)}`,
125
+ "error",
126
+ )
127
+ console.error(error)
128
+ }
129
+ }
130
+
131
+ export const clientScript: string = `
132
+ ${base64urlToBuffer.toString()}
133
+ ${arrayBufferToBase64url.toString()}
134
+ ${updateStatus.toString()}
135
+ ${testAuthentication.toString()}
136
+ `