@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.
- package/LICENSE +201 -0
- package/README.md +167 -0
- package/bin/generate-passkey +2 -0
- package/dist/src/generate-passkey/cli.d.ts +2 -0
- package/dist/src/generate-passkey/cli.d.ts.map +1 -0
- package/dist/src/generate-passkey/cli.js +32 -0
- package/dist/src/generate-passkey/cli.js.map +1 -0
- package/dist/src/generate-passkey/generateTestPasskey.test.d.ts +2 -0
- package/dist/src/generate-passkey/generateTestPasskey.test.d.ts.map +1 -0
- package/dist/src/generate-passkey/generateTestPasskey.test.js +143 -0
- package/dist/src/generate-passkey/generateTestPasskey.test.js.map +1 -0
- package/dist/src/generate-passkey/index.d.ts +10 -0
- package/dist/src/generate-passkey/index.d.ts.map +1 -0
- package/dist/src/generate-passkey/index.js +158 -0
- package/dist/src/generate-passkey/index.js.map +1 -0
- package/dist/src/generate-passkey/test-cli.d.ts +2 -0
- package/dist/src/generate-passkey/test-cli.d.ts.map +1 -0
- package/dist/src/generate-passkey/test-cli.js +31 -0
- package/dist/src/generate-passkey/test-cli.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/passkey-util.d.ts +19 -0
- package/dist/src/passkey-util.d.ts.map +1 -0
- package/dist/src/passkey-util.js +58 -0
- package/dist/src/passkey-util.js.map +1 -0
- package/dist/src/scripts/client.d.ts +2 -0
- package/dist/src/scripts/client.d.ts.map +1 -0
- package/dist/src/scripts/client.js +97 -0
- package/dist/src/scripts/client.js.map +1 -0
- package/dist/src/scripts/server.d.ts +2 -0
- package/dist/src/scripts/server.d.ts.map +1 -0
- package/dist/src/scripts/server.js +224 -0
- package/dist/src/scripts/server.js.map +1 -0
- package/dist/src/scripts/testpasskey.d.ts +10 -0
- package/dist/src/scripts/testpasskey.d.ts.map +1 -0
- package/dist/src/scripts/testpasskey.js +88 -0
- package/dist/src/scripts/testpasskey.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +50 -0
- package/playwright-report/index.html +85 -0
- package/playwright.config.ts +80 -0
- package/src/generate-passkey/cli.ts +33 -0
- package/src/generate-passkey/generateTestPasskey.test.ts +165 -0
- package/src/generate-passkey/index.ts +228 -0
- package/src/generate-passkey/test-cli.ts +32 -0
- package/src/scripts/client.ts +136 -0
- package/src/scripts/server.ts +301 -0
- package/src/scripts/testpasskey.ts +87 -0
- package/test-passkey.js +87 -0
- package/test-passkey.ts +87 -0
- package/test-results/.last-run.json +4 -0
- package/tests/passkey.spec.ts +29 -0
- package/tsconfig.json +52 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
`
|