@udibo/oauth2 0.42.0 → 0.43.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.
@@ -2,14 +2,14 @@
2
2
  * {@link SignInForm} — a default, accessible, unstyled sign-in form built on
3
3
  * {@link useAuthForm}. Collects an identifier and password (optionally a
4
4
  * "remember me" checkbox), renders "Continue with …" social buttons when
5
- * providers are supplied, and reports the outcome through the injected
6
- * `onSubmit`.
5
+ * providers are supplied and a passkey button when `passkey` is, and reports
6
+ * the outcome through the injected `onSubmit`.
7
7
  *
8
8
  * @module
9
9
  */
10
10
  import type { ReactNode } from "react";
11
11
  import type { AuthFormState } from "./use-auth-form.ts";
12
- import type { BaseAuthFormProps, SocialProvidersProps } from "./types.ts";
12
+ import type { BaseAuthFormProps, PasskeyOption, SocialProvidersProps } from "./types.ts";
13
13
  /** Values collected by {@link SignInForm}. */
14
14
  export type SignInValues = {
15
15
  /** Email address or username. */
@@ -42,6 +42,14 @@ export interface SignInFormProps extends BaseAuthFormProps<SignInValues>, Social
42
42
  forgotPasswordHref?: string;
43
43
  /** Text overrides. */
44
44
  labels?: SignInLabels;
45
+ /**
46
+ * Offer passkey sign-in: renders a "Sign in with a passkey" button and marks
47
+ * the identifier field `autocomplete="username webauthn"`, so a browser
48
+ * running a conditional-mediation request (`mediation: "conditional"`)
49
+ * offers saved passkeys in the field's autofill. Start that request
50
+ * yourself on mount; the form only provides the field.
51
+ */
52
+ passkey?: PasskeyOption;
45
53
  /**
46
54
  * Render-prop escape hatch. When provided, you own the markup and receive
47
55
  * the live {@link AuthFormState}; the component still owns state and submit.
@@ -1,6 +1,6 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useAuthForm } from "./use-auth-form.js";
3
- import { AuthFormShell, SocialButtons, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
3
+ import { AuthFormShell, PasskeyButton, SocialButtons, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
4
4
  import { useAuthFormClassNames } from "./class-names.js";
5
5
  /**
6
6
  * A ready-to-use sign-in form. Wire `onSubmit` to your auth endpoint; pass
@@ -26,7 +26,7 @@ import { useAuthFormClassNames } from "./class-names.js";
26
26
  * ```
27
27
  */
28
28
  export function SignInForm(props) {
29
- const { onSubmit, className, socialProviders, onSocialSelect, socialHref, showRemember = false, forgotPasswordHref, labels, children, } = props;
29
+ const { onSubmit, className, socialProviders, onSocialSelect, socialHref, showRemember = false, forgotPasswordHref, labels, passkey, children, } = props;
30
30
  const form = useAuthForm({
31
31
  initialValues: { identifier: "", password: "", remember: false },
32
32
  onSubmit,
@@ -35,5 +35,5 @@ export function SignInForm(props) {
35
35
  useFocusFirstError(form);
36
36
  const classNames = useAuthFormClassNames(props.classNames);
37
37
  const rememberField = form.getFieldProps("remember");
38
- return (_jsxs(AuthFormShell, { name: "sign-in", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, aboveForm: _jsx(SocialButtons, { socialProviders: socialProviders, onSocialSelect: onSocialSelect, socialHref: socialHref }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("identifier"), label: labels?.identifier ?? "Email or username", autoComplete: "username", required: true, error: form.errors.identifier }), _jsx(TextField, { fieldProps: form.getFieldProps("password"), label: labels?.password ?? "Password", type: "password", autoComplete: "current-password", required: true, error: form.errors.password, children: forgotPasswordHref ? (_jsx("a", { href: forgotPasswordHref, "data-oauth2-forgot-password": "", className: classNames.hint, children: labels?.forgotPassword ?? "Forgot password?" })) : null }), showRemember ? (_jsx("div", { "data-oauth2-field": "remember", className: classNames.field, children: _jsxs("label", { htmlFor: rememberField.id, "data-oauth2-label": "", className: classNames.label, children: [_jsx("input", { ...rememberField, type: "checkbox", "data-oauth2-input": "", className: classNames.input }), labels?.remember ?? "Remember me"] }) })) : null, _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Sign in", submittingLabel: labels?.submitting ?? "Signing in…" })] }));
38
+ return (_jsxs(AuthFormShell, { name: "sign-in", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, aboveForm: _jsxs(_Fragment, { children: [_jsx(PasskeyButton, { option: passkey, defaultLabel: "Sign in with a passkey" }), _jsx(SocialButtons, { socialProviders: socialProviders, onSocialSelect: onSocialSelect, socialHref: socialHref })] }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("identifier"), label: labels?.identifier ?? "Email or username", autoComplete: passkey ? "username webauthn" : "username", required: true, error: form.errors.identifier }), _jsx(TextField, { fieldProps: form.getFieldProps("password"), label: labels?.password ?? "Password", type: "password", autoComplete: "current-password", required: true, error: form.errors.password, children: forgotPasswordHref ? (_jsx("a", { href: forgotPasswordHref, "data-oauth2-forgot-password": "", className: classNames.hint, children: labels?.forgotPassword ?? "Forgot password?" })) : null }), showRemember ? (_jsx("div", { "data-oauth2-field": "remember", className: classNames.field, children: _jsxs("label", { htmlFor: rememberField.id, "data-oauth2-label": "", className: classNames.label, children: [_jsx("input", { ...rememberField, type: "checkbox", "data-oauth2-input": "", className: classNames.input }), labels?.remember ?? "Remember me"] }) })) : null, _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Sign in", submittingLabel: labels?.submitting ?? "Signing in…" })] }));
39
39
  }
@@ -70,6 +70,18 @@ export interface SocialProvidersProps {
70
70
  /** Builds the href for a provider; renders anchors instead of buttons. */
71
71
  socialHref?: (provider: SocialProvider) => string;
72
72
  }
73
+ /**
74
+ * A passkey button for a sign-in or challenge form. The component never runs
75
+ * the WebAuthn ceremony itself: `onSelect` fetches options from your server,
76
+ * calls `navigator.credentials.get()` (or `startAuthentication` from
77
+ * `@simplewebauthn/browser`), and posts the result back.
78
+ */
79
+ export interface PasskeyOption {
80
+ /** Runs the passkey ceremony when the button is pressed. */
81
+ onSelect: () => void | Promise<void>;
82
+ /** Button text. Defaults to the form's own wording. */
83
+ label?: string;
84
+ }
73
85
  /**
74
86
  * The class hook every prebuilt component shares. Component-specific class-name
75
87
  * maps ({@link AuthFormClassNames}, `UserMenuClassNames`) extend this, so `root`
@@ -10,11 +10,12 @@
10
10
  * fixtures and shipped token readers internally, so consumers and the
11
11
  * library are tested by the exact same checks.
12
12
  *
13
- * The identity layer's storage seams — `MfaStore`, `TokenFlowStore`,
14
- * `OtpStore`, `RateLimitStore`, `LockoutStore` — and the client's
13
+ * The identity layer's storage seams — `MfaStore`, `PasskeyStore`,
14
+ * `PasskeyChallengeStore`, `TokenFlowStore`, `OtpStore`, `RateLimitStore`,
15
+ * `LockoutStore` — and the client's
15
16
  * `AuthRequestStorage` have runners of their own. Those interfaces document
16
17
  * methods that **must be atomic** (single-use recovery codes, single-use
17
- * links, the TOTP replay guard, the guess and failure budgets, the
18
+ * links and passkey challenges, the passkey cap and sign count, the TOTP replay guard, the guess and failure budgets, the
18
19
  * single-use claim of a login's `state`), and each suite checks that claim
19
20
  * with overlapping calls: a read-modify-write implementation passes every
20
21
  * sequential case and fails the concurrent one, which is the whole
@@ -50,6 +51,7 @@ export { runTokenReaderContractTests, type TokenReaderContractFixture, type Toke
50
51
  export { type AuthorizationCodeServiceContractOptions, runAuthorizationCodeServiceContractTests, } from "./authorization-code.ts";
51
52
  export { type DeviceAuthorizationServiceContractOptions, runDeviceAuthorizationServiceContractTests, } from "./device-authorization.ts";
52
53
  export { type MfaStoreContractOptions, runMfaStoreContractTests, } from "./mfa-store.ts";
54
+ export { type PasskeyChallengeStoreContractOptions, type PasskeyStoreContractOptions, runPasskeyChallengeStoreContractTests, runPasskeyStoreContractTests, } from "./passkey-store.ts";
53
55
  export { runTokenFlowStoreContractTests, type TokenFlowStoreContractOptions, } from "./token-flow-store.ts";
54
56
  export { type OtpStoreContractOptions, runOtpStoreContractTests, } from "./otp-store.ts";
55
57
  export { type RateLimitStoreContractOptions, runRateLimitStoreContractTests, } from "./rate-limit-store.ts";
@@ -10,11 +10,12 @@
10
10
  * fixtures and shipped token readers internally, so consumers and the
11
11
  * library are tested by the exact same checks.
12
12
  *
13
- * The identity layer's storage seams — `MfaStore`, `TokenFlowStore`,
14
- * `OtpStore`, `RateLimitStore`, `LockoutStore` — and the client's
13
+ * The identity layer's storage seams — `MfaStore`, `PasskeyStore`,
14
+ * `PasskeyChallengeStore`, `TokenFlowStore`, `OtpStore`, `RateLimitStore`,
15
+ * `LockoutStore` — and the client's
15
16
  * `AuthRequestStorage` have runners of their own. Those interfaces document
16
17
  * methods that **must be atomic** (single-use recovery codes, single-use
17
- * links, the TOTP replay guard, the guess and failure budgets, the
18
+ * links and passkey challenges, the passkey cap and sign count, the TOTP replay guard, the guess and failure budgets, the
18
19
  * single-use claim of a login's `state`), and each suite checks that claim
19
20
  * with overlapping calls: a read-modify-write implementation passes every
20
21
  * sequential case and fails the concurrent one, which is the whole
@@ -50,6 +51,7 @@ export { runTokenReaderContractTests, } from "./token-reader.js";
50
51
  export { runAuthorizationCodeServiceContractTests, } from "./authorization-code.js";
51
52
  export { runDeviceAuthorizationServiceContractTests, } from "./device-authorization.js";
52
53
  export { runMfaStoreContractTests, } from "./mfa-store.js";
54
+ export { runPasskeyChallengeStoreContractTests, runPasskeyStoreContractTests, } from "./passkey-store.js";
53
55
  export { runTokenFlowStoreContractTests, } from "./token-flow-store.js";
54
56
  export { runOtpStoreContractTests, } from "./otp-store.js";
55
57
  export { runRateLimitStoreContractTests, } from "./rate-limit-store.js";
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Contract test suites for {@link PasskeyStore} and
3
+ * {@link PasskeyChallengeStore} implementations.
4
+ *
5
+ * `PasskeyService` relies on three atomic operations: `addPasskey` (credential
6
+ * uniqueness and the per-user cap in one step), `recordUse` (compare-and-set
7
+ * on the sign count), and the challenge store's `take` (read-and-delete, which
8
+ * makes a challenge single-use). These suites check each with concurrent
9
+ * calls.
10
+ *
11
+ * @example Verify database-backed stores
12
+ * ```ts
13
+ * import {
14
+ * runPasskeyChallengeStoreContractTests,
15
+ * runPasskeyStoreContractTests,
16
+ * } from "@udibo/oauth2/testing/contract";
17
+ * import type {
18
+ * PasskeyChallengeStore,
19
+ * PasskeyStore,
20
+ * } from "@udibo/oauth2/identity/passkeys";
21
+ *
22
+ * declare function freshPasskeyStore(): Promise<PasskeyStore>;
23
+ * declare function freshChallengeStore(): Promise<PasskeyChallengeStore>;
24
+ *
25
+ * runPasskeyStoreContractTests({ makeStore: freshPasskeyStore });
26
+ * runPasskeyChallengeStoreContractTests({ makeStore: freshChallengeStore });
27
+ * ```
28
+ *
29
+ * @module
30
+ */
31
+ import type { PasskeyChallengeStore, PasskeyStore } from "../../identity/passkeys/service.ts";
32
+ /** Options for {@link runPasskeyStoreContractTests}. */
33
+ export interface PasskeyStoreContractOptions {
34
+ /** Returns a fresh, empty store for each test. */
35
+ makeStore(): Promise<PasskeyStore> | PasskeyStore;
36
+ /**
37
+ * The user the cases act on, in your application's id format; it must name
38
+ * a user that exists when your rows reference a users table. Defaults to
39
+ * `"user-1"`.
40
+ */
41
+ userId?: string;
42
+ /** A second, distinct user. Defaults to `"user-2"`. */
43
+ otherUserId?: string;
44
+ /** Overrides the outer `describe` name. Defaults to `"PasskeyStore contract"`. */
45
+ describeName?: string;
46
+ }
47
+ /** Options for {@link runPasskeyChallengeStoreContractTests}. */
48
+ export interface PasskeyChallengeStoreContractOptions {
49
+ /** Returns a fresh, empty store for each test. */
50
+ makeStore(): Promise<PasskeyChallengeStore> | PasskeyChallengeStore;
51
+ /**
52
+ * Overrides the outer `describe` name. Defaults to
53
+ * `"PasskeyChallengeStore contract"`.
54
+ */
55
+ describeName?: string;
56
+ }
57
+ /**
58
+ * Runs the {@link PasskeyStore} contract suite. Call it from your own test
59
+ * file; it registers `describe` / `it` blocks.
60
+ */
61
+ export declare function runPasskeyStoreContractTests(options: PasskeyStoreContractOptions): void;
62
+ /**
63
+ * Runs the {@link PasskeyChallengeStore} contract suite. Call it from your own
64
+ * test file; it registers `describe` / `it` blocks.
65
+ */
66
+ export declare function runPasskeyChallengeStoreContractTests(options: PasskeyChallengeStoreContractOptions): void;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Contract test suites for {@link PasskeyStore} and
3
+ * {@link PasskeyChallengeStore} implementations.
4
+ *
5
+ * `PasskeyService` relies on three atomic operations: `addPasskey` (credential
6
+ * uniqueness and the per-user cap in one step), `recordUse` (compare-and-set
7
+ * on the sign count), and the challenge store's `take` (read-and-delete, which
8
+ * makes a challenge single-use). These suites check each with concurrent
9
+ * calls.
10
+ *
11
+ * @example Verify database-backed stores
12
+ * ```ts
13
+ * import {
14
+ * runPasskeyChallengeStoreContractTests,
15
+ * runPasskeyStoreContractTests,
16
+ * } from "@udibo/oauth2/testing/contract";
17
+ * import type {
18
+ * PasskeyChallengeStore,
19
+ * PasskeyStore,
20
+ * } from "@udibo/oauth2/identity/passkeys";
21
+ *
22
+ * declare function freshPasskeyStore(): Promise<PasskeyStore>;
23
+ * declare function freshChallengeStore(): Promise<PasskeyChallengeStore>;
24
+ *
25
+ * runPasskeyStoreContractTests({ makeStore: freshPasskeyStore });
26
+ * runPasskeyChallengeStoreContractTests({ makeStore: freshChallengeStore });
27
+ * ```
28
+ *
29
+ * @module
30
+ */
31
+ import { beforeEach, describe, expect, it } from "vitest";
32
+ import { CONCURRENT_CALLERS, race } from "./_race.js";
33
+ function passkey(userId, credentialId, rpId = "login.example.com") {
34
+ return {
35
+ credentialId,
36
+ userId,
37
+ rpId,
38
+ publicKey: new Uint8Array([1, 2, 3, 4]),
39
+ signCount: 0,
40
+ transports: ["internal", "hybrid"],
41
+ aaguid: "00000000-0000-0000-0000-000000000000",
42
+ multiDevice: true,
43
+ backedUp: true,
44
+ name: "Laptop",
45
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
46
+ lastUsedAt: null,
47
+ };
48
+ }
49
+ /**
50
+ * Runs the {@link PasskeyStore} contract suite. Call it from your own test
51
+ * file; it registers `describe` / `it` blocks.
52
+ */
53
+ export function runPasskeyStoreContractTests(options) {
54
+ const USER = options.userId ?? "user-1";
55
+ const OTHER_USER = options.otherUserId ?? "user-2";
56
+ describe(options.describeName ?? "PasskeyStore contract", () => {
57
+ let store;
58
+ beforeEach(async () => {
59
+ store = await options.makeStore();
60
+ });
61
+ it("stores and returns a passkey with its fields intact", async () => {
62
+ expect(await store.addPasskey(passkey(USER, "cred-a"), 25)).toBe("added");
63
+ const found = await store.findPasskey("cred-a");
64
+ expect(found).toStrictEqual(passkey(USER, "cred-a"));
65
+ });
66
+ it("lists a user's passkeys oldest first, filtered by RP ID", async () => {
67
+ await store.addPasskey({
68
+ ...passkey(USER, "cred-a"),
69
+ createdAt: new Date("2026-01-01T00:00:00.000Z"),
70
+ }, 25);
71
+ await store.addPasskey({
72
+ ...passkey(USER, "cred-b", "other.example.com"),
73
+ createdAt: new Date("2026-01-02T00:00:00.000Z"),
74
+ }, 25);
75
+ await store.addPasskey(passkey(OTHER_USER, "cred-c"), 25);
76
+ const all = await store.listPasskeys(USER);
77
+ expect(all.map((stored) => stored.credentialId)).toStrictEqual([
78
+ "cred-a",
79
+ "cred-b",
80
+ ]);
81
+ const scoped = await store.listPasskeys(USER, "login.example.com");
82
+ expect(scoped.map((stored) => stored.credentialId)).toStrictEqual([
83
+ "cred-a",
84
+ ]);
85
+ });
86
+ it("refuses a credential id that is already registered", async () => {
87
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
88
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-a"), 25)).toBe("duplicate");
89
+ expect((await store.findPasskey("cred-a"))?.userId).toBe(USER);
90
+ });
91
+ it("treats a credential id as unique across relying parties", async () => {
92
+ await store.addPasskey(passkey(USER, "cred-a", "login.example.com"), 25);
93
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-a", "other.example.com"), 25)).toBe("duplicate");
94
+ expect((await store.findPasskey("cred-a"))?.rpId).toBe("login.example.com");
95
+ });
96
+ it("refuses to record a use of a removed passkey", async () => {
97
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
98
+ await store.removePasskey(USER, "cred-a");
99
+ expect(await store.recordUse("cred-a", {
100
+ previousSignCount: 0,
101
+ signCount: 1,
102
+ backedUp: true,
103
+ usedAt: new Date(),
104
+ })).toBe(false);
105
+ });
106
+ it("refuses a passkey over the per-user limit", async () => {
107
+ await store.addPasskey(passkey(USER, "cred-a"), 2);
108
+ await store.addPasskey(passkey(USER, "cred-b"), 2);
109
+ expect(await store.addPasskey(passkey(USER, "cred-c"), 2)).toBe("limit_reached");
110
+ expect(await store.addPasskey(passkey(OTHER_USER, "cred-d"), 2)).toBe("added");
111
+ });
112
+ it("holds the per-user limit under concurrent registration", async () => {
113
+ const results = await race((caller) => store.addPasskey(passkey(USER, `cred-${caller}`), 3));
114
+ expect(results.filter((result) => result === "added")).toHaveLength(3);
115
+ expect(await store.listPasskeys(USER)).toHaveLength(3);
116
+ });
117
+ it("adds one passkey when the same credential races itself", async () => {
118
+ const results = await race(() => store.addPasskey(passkey(USER, "cred-a"), 25));
119
+ expect(results.filter((result) => result === "added")).toHaveLength(1);
120
+ expect(results.filter((result) => result === "duplicate")).toHaveLength(CONCURRENT_CALLERS - 1);
121
+ });
122
+ it("records a use only while the sign count still matches", async () => {
123
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
124
+ const usedAt = new Date("2026-02-01T00:00:00.000Z");
125
+ expect(await store.recordUse("cred-a", {
126
+ previousSignCount: 0,
127
+ signCount: 5,
128
+ backedUp: false,
129
+ usedAt,
130
+ })).toBe(true);
131
+ const stored = await store.findPasskey("cred-a");
132
+ expect(stored?.signCount).toBe(5);
133
+ expect(stored?.backedUp).toBe(false);
134
+ expect(stored?.lastUsedAt).toStrictEqual(usedAt);
135
+ expect(await store.recordUse("cred-a", {
136
+ previousSignCount: 0,
137
+ signCount: 6,
138
+ backedUp: false,
139
+ usedAt,
140
+ })).toBe(false);
141
+ expect((await store.findPasskey("cred-a"))?.signCount).toBe(5);
142
+ });
143
+ it("lets one of several concurrent uses with the same counter win", async () => {
144
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
145
+ const results = await race((caller) => store.recordUse("cred-a", {
146
+ previousSignCount: 0,
147
+ signCount: caller + 1,
148
+ backedUp: true,
149
+ usedAt: new Date(),
150
+ }));
151
+ expect(results.filter(Boolean)).toHaveLength(1);
152
+ });
153
+ it("renames and removes only the owner's passkey", async () => {
154
+ await store.addPasskey(passkey(USER, "cred-a"), 25);
155
+ expect(await store.renamePasskey(OTHER_USER, "cred-a", "Mine")).toBe(false);
156
+ expect(await store.removePasskey(OTHER_USER, "cred-a")).toBe(false);
157
+ expect(await store.renamePasskey(USER, "cred-a", "Phone")).toBe(true);
158
+ expect((await store.findPasskey("cred-a"))?.name).toBe("Phone");
159
+ expect(await store.removePasskey(USER, "cred-a")).toBe(true);
160
+ expect(await store.findPasskey("cred-a")).toBe(undefined);
161
+ expect(await store.removePasskey(USER, "cred-a")).toBe(false);
162
+ });
163
+ });
164
+ }
165
+ /**
166
+ * Runs the {@link PasskeyChallengeStore} contract suite. Call it from your own
167
+ * test file; it registers `describe` / `it` blocks.
168
+ */
169
+ export function runPasskeyChallengeStoreContractTests(options) {
170
+ describe(options.describeName ?? "PasskeyChallengeStore contract", () => {
171
+ let store;
172
+ const challenge = {
173
+ challenge: "c2lnbi1tZQ",
174
+ ceremony: "authentication",
175
+ rpId: "login.example.com",
176
+ userId: "user-1",
177
+ expiresAt: Date.now() + 60_000,
178
+ };
179
+ beforeEach(async () => {
180
+ store = await options.makeStore();
181
+ });
182
+ it("returns a stored challenge once", async () => {
183
+ await store.put("ceremony-1", challenge);
184
+ expect(await store.take("ceremony-1")).toStrictEqual(challenge);
185
+ expect(await store.take("ceremony-1")).toBe(undefined);
186
+ });
187
+ it("returns undefined for an unknown ceremony", async () => {
188
+ expect(await store.take("missing")).toBe(undefined);
189
+ });
190
+ it("hands a challenge to exactly one of several concurrent takers", async () => {
191
+ await store.put("ceremony-1", challenge);
192
+ const results = await race(() => store.take("ceremony-1"));
193
+ expect(results.filter((result) => result !== undefined)).toHaveLength(1);
194
+ });
195
+ });
196
+ }
@@ -35,6 +35,10 @@
35
35
  * Identity tenant can test its own routes without running the identity
36
36
  * service.
37
37
  *
38
+ * - **A software passkey authenticator.** {@link TestPasskeyAuthenticator}
39
+ * answers `PasskeyService` options with real signed WebAuthn responses, so
40
+ * passkey registration and sign-in can be tested without a browser.
41
+ *
38
42
  * Nothing in this module is an OAuth2 primitive — only testing support.
39
43
  * Production code should never import from here.
40
44
  *
@@ -43,3 +47,4 @@
43
47
  export { MemoryAuthorizationCodeService, type MemoryAuthorizationCodeServiceOptions, MemoryClientService, MemoryDeviceAuthorizationService, type MemoryDeviceAuthorizationServiceOptions, MemoryTokenService, type MemoryTokenServiceOptions, MemoryUserService, type MemoryUserServiceOptions, type MemoryUserShape, } from "./services.ts";
44
48
  export { createMemoryAuthorizationServer, type CreateMemoryAuthorizationServerOptions, type MemoryAuthorizationServerClientSeed, type MemoryAuthorizationServerGrants, type MemoryAuthorizationServerHarness, type MemoryAuthorizationServerUserSeed, } from "./server.ts";
45
49
  export { createFakeTenant, type FakeTenant, type FakeTenantClient, type FakeTenantGrant, type FakeTenantLinkedAccount, type FakeTenantMachinePermission, type FakeTenantMembership, type FakeTenantOptions, type FakeTenantOrganization, type FakeTenantOrganizationRole, type FakeTenantSignIn, type FakeTenantSubject, type FakeTenantTokenRequest, type FakeTenantUser, } from "./tenant.ts";
50
+ export { type TestPasskeyAuthenticateOptions, TestPasskeyAuthenticator, type TestPasskeyAuthenticatorOptions, type TestPasskeyCredential, } from "./passkey-authenticator.ts";
@@ -35,6 +35,10 @@
35
35
  * Identity tenant can test its own routes without running the identity
36
36
  * service.
37
37
  *
38
+ * - **A software passkey authenticator.** {@link TestPasskeyAuthenticator}
39
+ * answers `PasskeyService` options with real signed WebAuthn responses, so
40
+ * passkey registration and sign-in can be tested without a browser.
41
+ *
38
42
  * Nothing in this module is an OAuth2 primitive — only testing support.
39
43
  * Production code should never import from here.
40
44
  *
@@ -43,3 +47,4 @@
43
47
  export { MemoryAuthorizationCodeService, MemoryClientService, MemoryDeviceAuthorizationService, MemoryTokenService, MemoryUserService, } from "./services.js";
44
48
  export { createMemoryAuthorizationServer, } from "./server.js";
45
49
  export { createFakeTenant, } from "./tenant.js";
50
+ export { TestPasskeyAuthenticator, } from "./passkey-authenticator.js";
@@ -0,0 +1,86 @@
1
+ /**
2
+ * A software WebAuthn authenticator for tests: it answers registration and
3
+ * authentication options with real, signed ES256 responses, the way a browser
4
+ * and a platform authenticator would.
5
+ *
6
+ * @module
7
+ */
8
+ import type { PasskeyAuthenticationOptions, PasskeyAuthenticationResponse, PasskeyRegistrationOptions, PasskeyRegistrationResponse } from "../identity/passkeys/service.ts";
9
+ /** Options for {@link TestPasskeyAuthenticator}. */
10
+ export interface TestPasskeyAuthenticatorOptions {
11
+ /** The origin the simulated browser reports, such as `https://login.example.com`. */
12
+ origin: string;
13
+ /** Whether the authenticator verifies the person (the UV flag). Defaults to `true`. */
14
+ userVerified?: boolean;
15
+ /**
16
+ * Whether passkeys are synced (backup-eligible and backed up), like a
17
+ * password manager's. Defaults to `true`. `false` behaves like a security key.
18
+ */
19
+ synced?: boolean;
20
+ /**
21
+ * Whether the signature counter increases on each use, like a security key.
22
+ * Defaults to `false`: synced passkeys report `0`.
23
+ */
24
+ countSignatures?: boolean;
25
+ }
26
+ /** Per-call overrides for {@link TestPasskeyAuthenticator.authenticate}. */
27
+ export interface TestPasskeyAuthenticateOptions {
28
+ /** Use this credential instead of the first one the options allow. */
29
+ credentialId?: string;
30
+ /** Report this origin instead of the authenticator's, as a phishing page would. */
31
+ origin?: string;
32
+ /** Override the UV flag for this assertion. */
33
+ userVerified?: boolean;
34
+ /** Report this sign count instead of the next one. */
35
+ signCount?: number;
36
+ }
37
+ /** A credential the {@link TestPasskeyAuthenticator} holds. */
38
+ export interface TestPasskeyCredential {
39
+ /** The credential id, base64url. */
40
+ credentialId: string;
41
+ /** The RP ID it was created for. */
42
+ rpId: string;
43
+ /** The user handle, base64url. */
44
+ userHandle: string;
45
+ /** The current signature counter. */
46
+ signCount: number;
47
+ }
48
+ /**
49
+ * A software authenticator for tests. {@link register} answers
50
+ * `PasskeyService.startRegistration` options and {@link authenticate} answers
51
+ * `startAuthentication` options with responses `PasskeyService` verifies like
52
+ * a browser's. Keys are P-256 (ES256) and the attestation format is `none`.
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * import { TestPasskeyAuthenticator } from "@udibo/oauth2/testing";
57
+ * import type { PasskeyRegistrationOptions } from "@udibo/oauth2/identity/passkeys";
58
+ *
59
+ * declare const options: PasskeyRegistrationOptions;
60
+ * const authenticator = new TestPasskeyAuthenticator({
61
+ * origin: "https://login.example.com",
62
+ * });
63
+ * const response = await authenticator.register(options);
64
+ * ```
65
+ */
66
+ export declare class TestPasskeyAuthenticator {
67
+ #private;
68
+ /** Creates an authenticator with no credentials. */
69
+ constructor(options: TestPasskeyAuthenticatorOptions);
70
+ /** The credentials created so far. */
71
+ get credentials(): TestPasskeyCredential[];
72
+ /**
73
+ * Create a credential for the options' RP ID and user, and return the
74
+ * response `navigator.credentials.create()` would.
75
+ */
76
+ register(options: PasskeyRegistrationOptions, overrides?: {
77
+ origin?: string;
78
+ }): Promise<PasskeyRegistrationResponse>;
79
+ /**
80
+ * Sign the options' challenge with a held credential and return the
81
+ * response `navigator.credentials.get()` would.
82
+ *
83
+ * @throws {Error} when no held credential matches the options.
84
+ */
85
+ authenticate(options: PasskeyAuthenticationOptions, overrides?: TestPasskeyAuthenticateOptions): Promise<PasskeyAuthenticationResponse>;
86
+ }