@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.
- package/README.md +1 -0
- package/dist/identity/errors.d.ts +2 -2
- package/dist/identity/errors.js +2 -0
- package/dist/identity/events.d.ts +44 -2
- package/dist/identity/events.js +2 -1
- package/dist/identity/passkeys/mod.d.ts +55 -0
- package/dist/identity/passkeys/mod.js +55 -0
- package/dist/identity/passkeys/service.d.ts +469 -0
- package/dist/identity/passkeys/service.js +558 -0
- package/dist/react/components/internal.d.ts +5 -1
- package/dist/react/components/internal.js +9 -0
- package/dist/react/components/mfa-challenge-form.d.ts +9 -3
- package/dist/react/components/mfa-challenge-form.js +3 -3
- package/dist/react/components/mod.d.ts +1 -1
- package/dist/react/components/sign-in-form.d.ts +11 -3
- package/dist/react/components/sign-in-form.js +4 -4
- package/dist/react/components/types.d.ts +12 -0
- package/dist/testing/contract/mod.d.ts +5 -3
- package/dist/testing/contract/mod.js +5 -3
- package/dist/testing/contract/passkey-store.d.ts +66 -0
- package/dist/testing/contract/passkey-store.js +196 -0
- package/dist/testing/mod.d.ts +5 -0
- package/dist/testing/mod.js +5 -0
- package/dist/testing/passkey-authenticator.d.ts +86 -0
- package/dist/testing/passkey-authenticator.js +199 -0
- package/package.json +7 -1
|
@@ -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
|
|
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`, `
|
|
14
|
-
* `
|
|
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`, `
|
|
14
|
-
* `
|
|
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
|
+
}
|
package/dist/testing/mod.d.ts
CHANGED
|
@@ -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";
|
package/dist/testing/mod.js
CHANGED
|
@@ -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
|
+
}
|