@udibo/oauth2 0.42.0 → 0.44.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.
@@ -236,7 +236,6 @@ export class IdentityService {
236
236
  hook: "sendPasswordReset",
237
237
  to: normalized,
238
238
  subject: user.id,
239
- data: { email: normalized },
240
239
  ttlMs: this.#ttl.passwordReset,
241
240
  requested: {
242
241
  type: "password_reset.requested",
@@ -250,7 +249,10 @@ export class IdentityService {
250
249
  * was configured) revoke **all** of the user's sessions via
251
250
  * `revokeAllByUser`. Clears any failed-attempt lockout — the emailed token
252
251
  * proves the same account ownership the unlock flow does. Returns
253
- * `{ userId }`, or `null` for an invalid/expired/used token.
252
+ * `{ userId }`, or `null` for an invalid/expired/used token — including one
253
+ * whose address no longer finds this user through
254
+ * {@link IdentityUserStore.findByEmail}, because the account's email moved
255
+ * after the link was mailed. That refusal leaves the token unspent.
254
256
  *
255
257
  * A reset is the user's "lock everyone else out" action, so the outstanding
256
258
  * passwordless credentials for the same account are voided too: sign-in links
@@ -298,6 +300,7 @@ export class IdentityService {
298
300
  type: "password_reset.failed",
299
301
  reason: "invalid_token",
300
302
  }),
303
+ addressBound: true,
301
304
  });
302
305
  if (consumed.status !== "success")
303
306
  return null;
@@ -371,7 +374,6 @@ export class IdentityService {
371
374
  to: input.email,
372
375
  subject: input.userId,
373
376
  ttlMs: this.#ttl.emailVerification,
374
- data: { email: input.email },
375
377
  requested: {
376
378
  type: "email_verification.requested",
377
379
  userId: input.userId,
@@ -421,7 +423,10 @@ export class IdentityService {
421
423
  * Mint + deliver a single-use account-unlock link after a lockout. Call it
422
424
  * with the locked account's email (typically from your `lockout` event
423
425
  * handler — the service only knows opaque user ids). Requires `tokens` and
424
- * a `delivery.sendAccountUnlock` hook to actually send. With a `rateLimiter`
426
+ * a `delivery.sendAccountUnlock` hook to actually send. `email` must be an
427
+ * address {@link IdentityUserStore.findByEmail} resolves to `userId`:
428
+ * {@link IdentityService.unlockAccount} refuses the link once it does not.
429
+ * With a `rateLimiter`
425
430
  * (or a tighter `rateLimiters.accountUnlock`), requests throttle per user and
426
431
  * throw {@link IdentityError} `rate_limited` when exceeded.
427
432
  */
@@ -446,7 +451,9 @@ export class IdentityService {
446
451
  }
447
452
  /**
448
453
  * Consume an unlock token and clear the account's lockout state (failures
449
- * and lock). The self-service unlock path: the emailed link lands here.
454
+ * and lock). The self-service unlock path: the emailed link lands here. A
455
+ * link whose address no longer finds this user through
456
+ * {@link IdentityUserStore.findByEmail} reports `invalid` and stays unspent.
450
457
  */
451
458
  async unlockAccount(token) {
452
459
  const tokens = this.#requireTokens("unlockAccount");
@@ -455,6 +462,7 @@ export class IdentityService {
455
462
  purpose: TokenPurpose.AccountUnlock,
456
463
  token,
457
464
  failed: (reason) => ({ type: "account_unlock.failed", reason }),
465
+ addressBound: true,
458
466
  });
459
467
  if (result.status !== "success")
460
468
  return result;
@@ -527,7 +535,9 @@ export class IdentityService {
527
535
  * stays your app's job. So does re-resolving the user: a user deleted or
528
536
  * disabled since the link was minted must be treated as `invalid` by your
529
537
  * user store, not signed in. `expired` and `invalid` are distinguished so
530
- * the UI can offer a fresh link.
538
+ * the UI can offer a fresh link. A link whose address no longer finds this
539
+ * user through {@link IdentityUserStore.findByEmail} — the account's email
540
+ * moved after it was mailed — reports `invalid` and stays unspent.
531
541
  */
532
542
  async consumeSignInLink(token) {
533
543
  const tokens = this.#requireTokens("consumeSignInLink");
@@ -536,6 +546,7 @@ export class IdentityService {
536
546
  purpose: TokenPurpose.SignIn,
537
547
  token,
538
548
  failed: (reason) => ({ type: "signin_link.failed", reason }),
549
+ addressBound: true,
539
550
  });
540
551
  if (result.status !== "success")
541
552
  return result;
@@ -783,7 +794,7 @@ export class IdentityService {
783
794
  const { token, expiresAt } = await tokens.create({
784
795
  purpose: DELIVERY_PURPOSES[request.hook],
785
796
  subject: request.subject,
786
- data: request.data,
797
+ data: { email: request.to },
787
798
  ttlMs: request.ttlMs,
788
799
  invalidateExisting: true,
789
800
  });
@@ -797,7 +808,9 @@ export class IdentityService {
797
808
  await this.#emit(input.failed("expired"));
798
809
  return { status: "expired" };
799
810
  }
800
- if (status.status !== "valid") {
811
+ if (status.status !== "valid" ||
812
+ (input.addressBound &&
813
+ !(await this.#stillAddressedTo(status.subject, status.data)))) {
801
814
  await this.#emit(input.failed("invalid"));
802
815
  return { status: "invalid" };
803
816
  }
@@ -809,6 +822,12 @@ export class IdentityService {
809
822
  }
810
823
  return { status: "success", resolved };
811
824
  }
825
+ async #stillAddressedTo(subject, data) {
826
+ const email = emailFromData(data);
827
+ if (email === undefined)
828
+ return true;
829
+ return (await this.#users.findByEmail(email))?.id === subject;
830
+ }
812
831
  #emit(event) {
813
832
  return dispatchIdentityEvent(this.#onEvent, "identity", event);
814
833
  }
@@ -1,6 +1,6 @@
1
1
  import { type ReactNode, type Ref } from "react";
2
2
  import type { AuthFieldProps, AuthFormState } from "./use-auth-form.ts";
3
- import type { AuthFormClassNames, AuthFormValues, SocialProvidersProps } from "./types.ts";
3
+ import type { PasskeyOption, AuthFormClassNames, AuthFormValues, SocialProvidersProps } from "./types.ts";
4
4
  /**
5
5
  * What every default form component returns: the shared root, `<form>` and
6
6
  * error summary, or the caller's `render` output instead, with the resolved
@@ -47,5 +47,9 @@ export declare function ErrorSummary(props: {
47
47
  */
48
48
  export declare function useFocusFirstError<V extends AuthFormValues>(form: AuthFormState<V>): void;
49
49
  export declare function SocialButtons(props: SocialProvidersProps): ReactNode;
50
+ export declare function PasskeyButton(props: {
51
+ option: PasskeyOption | undefined;
52
+ defaultLabel: string;
53
+ }): ReactNode;
50
54
  export declare function combineClassNames(className: string | undefined, slot: string | undefined): string | undefined;
51
55
  export declare function displayName(user: Record<string, unknown>): string;
@@ -69,6 +69,15 @@ export function SocialButtons(props) {
69
69
  const label = (provider) => (_jsxs(_Fragment, { children: [provider.iconSlot ? (_jsx("span", { "data-oauth2-social-icon": "", children: provider.iconSlot })) : null, _jsxs("span", { "data-oauth2-social-label": "", children: ["Continue with ", provider.name] })] }));
70
70
  return (_jsxs(_Fragment, { children: [_jsx("div", { "data-oauth2-social": "", className: classNames.socialSection, children: socialProviders.map((provider) => socialHref ? (_jsx("a", { href: socialHref(provider), "data-oauth2-social-button": provider.id, className: classNames.socialButton, children: label(provider) }, provider.id)) : (_jsx("button", { type: "button", onClick: () => onSocialSelect?.(provider), "data-oauth2-social-button": provider.id, className: classNames.socialButton, children: label(provider) }, provider.id))) }), _jsx("div", { "data-oauth2-social-divider": "", "aria-hidden": "true", className: classNames.socialDivider })] }));
71
71
  }
72
+ export function PasskeyButton(props) {
73
+ const classNames = useAuthFormClassNames();
74
+ const { option } = props;
75
+ if (!option)
76
+ return null;
77
+ return (_jsx("button", { type: "button", onClick: () => {
78
+ void option.onSelect();
79
+ }, "data-oauth2-passkey-button": "", className: classNames.socialButton, children: option.label ?? props.defaultLabel }));
80
+ }
72
81
  export function combineClassNames(className, slot) {
73
82
  return [className, slot].filter(Boolean).join(" ") || undefined;
74
83
  }
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * {@link MfaChallengeForm} — a default, accessible, unstyled second-factor
3
3
  * challenge form built on {@link useAuthForm}. Collects a one-time code and,
4
- * when allowed, toggles to accepting a recovery code instead. The active
5
- * method is forwarded to `onSubmit` so your endpoint knows how to verify it.
4
+ * when allowed, toggles to accepting a recovery code instead, and offers a
5
+ * passkey when `passkey` is supplied. The active method is forwarded to
6
+ * `onSubmit` so your endpoint knows how to verify it.
6
7
  *
7
8
  * @module
8
9
  */
9
10
  import type { ReactNode } from "react";
10
11
  import type { AuthFormState } from "./use-auth-form.ts";
11
- import type { BaseAuthFormProps } from "./types.ts";
12
+ import type { BaseAuthFormProps, PasskeyOption } from "./types.ts";
12
13
  /** Which credential the user is submitting. */
13
14
  export type MfaChallengeMethod = "totp" | "recovery";
14
15
  /** Values collected by {@link MfaChallengeForm}. */
@@ -39,6 +40,11 @@ export interface MfaChallengeFormProps extends BaseAuthFormProps<MfaChallengeVal
39
40
  allowRecoveryCode?: boolean;
40
41
  /** Text overrides. */
41
42
  labels?: MfaChallengeLabels;
43
+ /**
44
+ * Offer a passkey as the second factor: renders a "Use a passkey" button
45
+ * whose `onSelect` runs the ceremony against the already-identified user.
46
+ */
47
+ passkey?: PasskeyOption;
42
48
  /**
43
49
  * Render-prop escape hatch. When provided, you own the markup and receive
44
50
  * the live {@link AuthFormState}. Toggle `method` with `setValue`.
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useAuthForm } from "./use-auth-form.js";
3
- import { AuthFormShell, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
3
+ import { AuthFormShell, PasskeyButton, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
4
4
  import { useAuthFormClassNames } from "./class-names.js";
5
5
  /**
6
6
  * A ready-to-use MFA challenge form. Wire `onSubmit` to your verification
@@ -21,7 +21,7 @@ import { useAuthFormClassNames } from "./class-names.js";
21
21
  * ```
22
22
  */
23
23
  export function MfaChallengeForm(props) {
24
- const { onSubmit, className, allowRecoveryCode = true, labels, children, } = props;
24
+ const { onSubmit, className, allowRecoveryCode = true, labels, passkey, children, } = props;
25
25
  const form = useAuthForm({
26
26
  initialValues: { code: "", method: "totp" },
27
27
  onSubmit,
@@ -36,7 +36,7 @@ export function MfaChallengeForm(props) {
36
36
  code: "",
37
37
  });
38
38
  };
39
- return (_jsxs(AuthFormShell, { name: "mfa-challenge", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, rootProps: { "data-oauth2-method": form.values.method }, children: [_jsx(TextField, { fieldProps: form.getFieldProps("code"), label: isRecovery
39
+ return (_jsxs(AuthFormShell, { name: "mfa-challenge", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, rootProps: { "data-oauth2-method": form.values.method }, aboveForm: _jsx(PasskeyButton, { option: passkey, defaultLabel: "Use a passkey" }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("code"), label: isRecovery
40
40
  ? (labels?.recoveryCode ?? "Recovery code")
41
41
  : (labels?.code ?? "Authentication code"), autoComplete: isRecovery ? "off" : "one-time-code", required: true, error: form.errors.code }), _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Verify", submittingLabel: labels?.submitting ?? "Verifying…" }), allowRecoveryCode ? (_jsx("button", { type: "button", onClick: toggleMethod, "data-oauth2-secondary": "", "data-oauth2-toggle-recovery": "", className: classNames.secondary, children: isRecovery
42
42
  ? (labels?.useAuthenticator ?? "Use your authenticator app")
@@ -85,7 +85,7 @@
85
85
  */
86
86
  export { useAuthForm } from "./use-auth-form.ts";
87
87
  export type { AuthFieldProps, AuthFormState, AuthFormStatus, UseAuthFormOptions, } from "./use-auth-form.ts";
88
- export type { AuthFormClassNames, AuthFormErrors, AuthFormResult, AuthFormValues, AuthSubmitHandler, BaseAuthFormProps, BaseClassNames, SocialProvider, SocialProvidersProps, } from "./types.ts";
88
+ export type { AuthFormClassNames, AuthFormErrors, AuthFormResult, AuthFormValues, AuthSubmitHandler, BaseAuthFormProps, BaseClassNames, PasskeyOption, SocialProvider, SocialProvidersProps, } from "./types.ts";
89
89
  export { AuthFormClassNamesProvider, useAuthFormClassNames, } from "./class-names.tsx";
90
90
  export type { AuthFormClassNamesProviderProps } from "./class-names.tsx";
91
91
  export { SignInForm } from "./sign-in-form.tsx";
@@ -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";