@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.
- 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/identity/service.d.ts +22 -5
- package/dist/identity/service.js +27 -8
- 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
package/dist/identity/service.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
5
|
-
*
|
|
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
|
|
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";
|