@structured-id/ui-core 4.8.0 → 4.9.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/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/utils/index.ts +7 -0
- package/src/utils/passkey.test.ts +116 -0
- package/src/utils/passkey.ts +107 -0
package/package.json
CHANGED
package/src/index.ts
CHANGED
package/src/utils/index.ts
CHANGED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { PasskeyError, createPasskey, getPasskey } from "./passkey";
|
|
3
|
+
|
|
4
|
+
const bytes = (value: unknown) =>
|
|
5
|
+
new TextEncoder().encode(JSON.stringify(value));
|
|
6
|
+
const json = (data: Uint8Array) =>
|
|
7
|
+
JSON.parse(new TextDecoder().decode(data)) as Record<string, unknown>;
|
|
8
|
+
|
|
9
|
+
/** A browser credential whose JSON form is `form`. */
|
|
10
|
+
class FakeCredential {
|
|
11
|
+
constructor(private readonly form: unknown) {}
|
|
12
|
+
toJSON() {
|
|
13
|
+
return this.form;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const parseCreation = vi.fn((options: unknown) => ({ parsed: options }));
|
|
18
|
+
const parseRequest = vi.fn((options: unknown) => ({ parsed: options }));
|
|
19
|
+
const create = vi.fn();
|
|
20
|
+
const get = vi.fn();
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
Object.assign(FakeCredential, {
|
|
24
|
+
parseCreationOptionsFromJSON: parseCreation,
|
|
25
|
+
parseRequestOptionsFromJSON: parseRequest,
|
|
26
|
+
});
|
|
27
|
+
vi.stubGlobal("PublicKeyCredential", FakeCredential);
|
|
28
|
+
vi.stubGlobal("navigator", { credentials: { create, get } });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
vi.unstubAllGlobals();
|
|
33
|
+
vi.clearAllMocks();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
// The JSON the server reads is the browser's own form, clientExtensionResults
|
|
37
|
+
// included: SID's relying party refuses a response without that member
|
|
38
|
+
// (WebAuthn Level 3 §5.1.4), which hand-built responses left out.
|
|
39
|
+
const REGISTRATION = {
|
|
40
|
+
id: "Y3JlZA",
|
|
41
|
+
rawId: "Y3JlZA",
|
|
42
|
+
type: "public-key",
|
|
43
|
+
response: { clientDataJSON: "e30", attestationObject: "o2M" },
|
|
44
|
+
clientExtensionResults: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
describe("passkey creation", () => {
|
|
48
|
+
// The server wraps the options in `publicKey` (CredentialCreationOptions);
|
|
49
|
+
// reading members off the top level parsed nothing.
|
|
50
|
+
it("hands the browser the options inside publicKey", async () => {
|
|
51
|
+
create.mockResolvedValue(new FakeCredential(REGISTRATION));
|
|
52
|
+
const publicKey = { challenge: "Y2g", rp: { id: "sid.example.com" } };
|
|
53
|
+
const response = await createPasskey(bytes({ publicKey }));
|
|
54
|
+
expect(parseCreation).toHaveBeenCalledWith(publicKey);
|
|
55
|
+
expect(create).toHaveBeenCalledWith({ publicKey: { parsed: publicKey } });
|
|
56
|
+
expect(json(response)).toEqual(REGISTRATION);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("tells a cancellation and a known authenticator apart", async () => {
|
|
60
|
+
const options = bytes({ publicKey: { challenge: "Y2g" } });
|
|
61
|
+
create.mockRejectedValue(new DOMException("no", "NotAllowedError"));
|
|
62
|
+
await expect(createPasskey(options)).rejects.toMatchObject({
|
|
63
|
+
failure: "cancelled",
|
|
64
|
+
});
|
|
65
|
+
create.mockRejectedValue(new DOMException("held", "InvalidStateError"));
|
|
66
|
+
await expect(createPasskey(options)).rejects.toMatchObject({
|
|
67
|
+
failure: "already-registered",
|
|
68
|
+
});
|
|
69
|
+
create.mockRejectedValue(new Error("broken"));
|
|
70
|
+
await expect(createPasskey(options)).rejects.toMatchObject({
|
|
71
|
+
failure: "failed",
|
|
72
|
+
message: "broken",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("refuses options without publicKey and a missing credential", async () => {
|
|
77
|
+
await expect(
|
|
78
|
+
createPasskey(bytes({ challenge: "Y2g" })),
|
|
79
|
+
).rejects.toBeInstanceOf(PasskeyError);
|
|
80
|
+
create.mockResolvedValue(null);
|
|
81
|
+
await expect(
|
|
82
|
+
createPasskey(bytes({ publicKey: { challenge: "Y2g" } })),
|
|
83
|
+
).rejects.toMatchObject({ failure: "failed" });
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("passkey assertion", () => {
|
|
88
|
+
const ASSERTION = {
|
|
89
|
+
id: "Y3JlZA",
|
|
90
|
+
rawId: "Y3JlZA",
|
|
91
|
+
type: "public-key",
|
|
92
|
+
response: {
|
|
93
|
+
clientDataJSON: "e30",
|
|
94
|
+
authenticatorData: "YXV0aA",
|
|
95
|
+
signature: "c2ln",
|
|
96
|
+
userHandle: "dXNlcg",
|
|
97
|
+
},
|
|
98
|
+
clientExtensionResults: {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
it("answers the request options with the browser's JSON form", async () => {
|
|
102
|
+
get.mockResolvedValue(new FakeCredential(ASSERTION));
|
|
103
|
+
const publicKey = { challenge: "Y2g", rpId: "sid.example.com" };
|
|
104
|
+
const response = await getPasskey(bytes({ publicKey }));
|
|
105
|
+
expect(parseRequest).toHaveBeenCalledWith(publicKey);
|
|
106
|
+
expect(get).toHaveBeenCalledWith({ publicKey: { parsed: publicKey } });
|
|
107
|
+
expect(json(response)).toEqual(ASSERTION);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("reports a cancelled sign-in as cancelled", async () => {
|
|
111
|
+
get.mockRejectedValue(new DOMException("no", "NotAllowedError"));
|
|
112
|
+
await expect(
|
|
113
|
+
getPasskey(bytes({ publicKey: { challenge: "Y2g" } })),
|
|
114
|
+
).rejects.toMatchObject({ failure: "cancelled" });
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser side of SID's passkey ceremonies, whatever transport carries
|
|
3
|
+
* them: the options a start returns (`CredentialCreationOptions` /
|
|
4
|
+
* `CredentialRequestOptions` as JSON, `publicKey` inside) in, the
|
|
5
|
+
* `RegistrationResponseJSON` / `AuthenticationResponseJSON` a finish reads
|
|
6
|
+
* out (WebAuthn Level 3 §5.1.4), on the browser's own JSON forms.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** How a ceremony ended without a credential. */
|
|
10
|
+
export type PasskeyFailure =
|
|
11
|
+
/** The user cancelled, or the authenticator timed out (`NotAllowedError`). */
|
|
12
|
+
| "cancelled"
|
|
13
|
+
/** The authenticator already holds a passkey of this account (`InvalidStateError`). */
|
|
14
|
+
| "already-registered"
|
|
15
|
+
/** Anything else, the browser's message kept in `cause`. */
|
|
16
|
+
| "failed";
|
|
17
|
+
|
|
18
|
+
/** A ceremony that produced no credential, and why. */
|
|
19
|
+
export class PasskeyError extends Error {
|
|
20
|
+
constructor(
|
|
21
|
+
readonly failure: PasskeyFailure,
|
|
22
|
+
message: string,
|
|
23
|
+
options?: { cause?: unknown },
|
|
24
|
+
) {
|
|
25
|
+
super(message, options);
|
|
26
|
+
this.name = "PasskeyError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The `publicKey` member of the options JSON the server sent. */
|
|
31
|
+
function publicKeyOf<T>(options: Uint8Array): T {
|
|
32
|
+
const parsed = JSON.parse(new TextDecoder().decode(options)) as {
|
|
33
|
+
publicKey?: T;
|
|
34
|
+
};
|
|
35
|
+
if (!parsed.publicKey) {
|
|
36
|
+
throw new PasskeyError("failed", "The server sent no passkey options");
|
|
37
|
+
}
|
|
38
|
+
return parsed.publicKey;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Why the browser refused, as the page tells it. */
|
|
42
|
+
function refusal(error: unknown): PasskeyError {
|
|
43
|
+
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
|
44
|
+
return new PasskeyError(
|
|
45
|
+
"cancelled",
|
|
46
|
+
"The request was cancelled or timed out",
|
|
47
|
+
{
|
|
48
|
+
cause: error,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (error instanceof DOMException && error.name === "InvalidStateError") {
|
|
53
|
+
return new PasskeyError(
|
|
54
|
+
"already-registered",
|
|
55
|
+
"This authenticator already holds a passkey for this account",
|
|
56
|
+
{ cause: error },
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return new PasskeyError(
|
|
60
|
+
"failed",
|
|
61
|
+
error instanceof Error ? error.message : "The passkey ceremony failed",
|
|
62
|
+
{ cause: error },
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `credential` as the JSON bytes a finish reads. */
|
|
67
|
+
function responseBytes(credential: Credential | null): Uint8Array {
|
|
68
|
+
if (!(credential instanceof PublicKeyCredential)) {
|
|
69
|
+
throw new PasskeyError("failed", "The browser returned no passkey");
|
|
70
|
+
}
|
|
71
|
+
return new TextEncoder().encode(JSON.stringify(credential.toJSON()));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Create a passkey from a registration start's `options`; resolves to the
|
|
76
|
+
* response a registration finish takes.
|
|
77
|
+
*/
|
|
78
|
+
export async function createPasskey(options: Uint8Array): Promise<Uint8Array> {
|
|
79
|
+
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(
|
|
80
|
+
publicKeyOf<PublicKeyCredentialCreationOptionsJSON>(options),
|
|
81
|
+
);
|
|
82
|
+
let credential: Credential | null;
|
|
83
|
+
try {
|
|
84
|
+
credential = await navigator.credentials.create({ publicKey });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
throw refusal(error);
|
|
87
|
+
}
|
|
88
|
+
return responseBytes(credential);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Assert a passkey for a sign-in or step-up start's `options`; resolves to
|
|
93
|
+
* the response its finish takes. With no allowed credentials in `options`
|
|
94
|
+
* the browser offers the discoverable passkeys it holds for the RP.
|
|
95
|
+
*/
|
|
96
|
+
export async function getPasskey(options: Uint8Array): Promise<Uint8Array> {
|
|
97
|
+
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(
|
|
98
|
+
publicKeyOf<PublicKeyCredentialRequestOptionsJSON>(options),
|
|
99
|
+
);
|
|
100
|
+
let credential: Credential | null;
|
|
101
|
+
try {
|
|
102
|
+
credential = await navigator.credentials.get({ publicKey });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw refusal(error);
|
|
105
|
+
}
|
|
106
|
+
return responseBytes(credential);
|
|
107
|
+
}
|