@udibo/oauth2 0.41.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.
Files changed (47) hide show
  1. package/README.md +1 -0
  2. package/dist/errors.d.ts +5 -0
  3. package/dist/errors.js +9 -0
  4. package/dist/identity/errors.d.ts +2 -2
  5. package/dist/identity/errors.js +2 -0
  6. package/dist/identity/events.d.ts +44 -2
  7. package/dist/identity/events.js +2 -1
  8. package/dist/identity/passkeys/mod.d.ts +55 -0
  9. package/dist/identity/passkeys/mod.js +55 -0
  10. package/dist/identity/passkeys/service.d.ts +469 -0
  11. package/dist/identity/passkeys/service.js +558 -0
  12. package/dist/models/token.d.ts +7 -0
  13. package/dist/react/components/internal.d.ts +5 -1
  14. package/dist/react/components/internal.js +9 -0
  15. package/dist/react/components/mfa-challenge-form.d.ts +9 -3
  16. package/dist/react/components/mfa-challenge-form.js +3 -3
  17. package/dist/react/components/mod.d.ts +1 -1
  18. package/dist/react/components/sign-in-form.d.ts +11 -3
  19. package/dist/react/components/sign-in-form.js +4 -4
  20. package/dist/react/components/types.d.ts +12 -0
  21. package/dist/server/_resource-indicator.d.ts +7 -0
  22. package/dist/server/_resource-indicator.js +67 -0
  23. package/dist/server/authorization-server.js +2 -0
  24. package/dist/server/grants/client-credentials.d.ts +6 -0
  25. package/dist/server/grants/client-credentials.js +22 -3
  26. package/dist/server/grants/grant.d.ts +4 -2
  27. package/dist/server/grants/grant.js +17 -3
  28. package/dist/server/grants/refresh-token.d.ts +2 -0
  29. package/dist/server/grants/refresh-token.js +8 -1
  30. package/dist/server/mod.d.ts +1 -1
  31. package/dist/server/mod.js +1 -1
  32. package/dist/server/services/token.d.ts +20 -2
  33. package/dist/server/services/token.js +1 -1
  34. package/dist/server/signing-keys.d.ts +3 -1
  35. package/dist/server/signing-keys.js +2 -2
  36. package/dist/testing/contract/mod.d.ts +5 -3
  37. package/dist/testing/contract/mod.js +5 -3
  38. package/dist/testing/contract/passkey-store.d.ts +66 -0
  39. package/dist/testing/contract/passkey-store.js +196 -0
  40. package/dist/testing/contract/token.d.ts +7 -0
  41. package/dist/testing/contract/token.js +17 -0
  42. package/dist/testing/mod.d.ts +5 -0
  43. package/dist/testing/mod.js +5 -0
  44. package/dist/testing/passkey-authenticator.d.ts +86 -0
  45. package/dist/testing/passkey-authenticator.js +199 -0
  46. package/dist/testing/services.js +4 -0
  47. 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, 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`
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Read zero or one absolute resource URI without a fragment, preserving its
3
+ * exact spelling. Multiple values, including equal repeats, are refused.
4
+ * Call only after opting into the single-resource policy.
5
+ * @throws {InvalidTargetError} For an empty, repeated, non-string or malformed value.
6
+ */
7
+ export declare function resourceIndicatorOf(body: FormData): string | undefined;
@@ -0,0 +1,67 @@
1
+ /** Single-resource syntax for opted-in machine issuance. @module */
2
+ import { InvalidTargetError } from "../errors.js";
3
+ const PATH = /^(?:[A-Za-z0-9._~!$&'()*+,;=:@/-]|%[A-Fa-f0-9]{2})*$/;
4
+ const QUERY = /^(?:[A-Za-z0-9._~!$&'()*+,;=:@/?-]|%[A-Fa-f0-9]{2})*$/;
5
+ const USER_INFO = /^(?:[A-Za-z0-9._~!$&'()*+,;=:-]|%[A-Fa-f0-9]{2})*$/;
6
+ const REG_NAME = /^(?:[A-Za-z0-9._~!$&'()*+,;=-]|%[A-Fa-f0-9]{2})*$/;
7
+ const IP_FUTURE = /^v[0-9a-f]+\.[A-Za-z0-9._~!$&'()*+,;=:-]+$/i;
8
+ function validAuthority(authority) {
9
+ const at = authority.indexOf("@");
10
+ if (at !== authority.lastIndexOf("@"))
11
+ return false;
12
+ if (at !== -1 && !USER_INFO.test(authority.slice(0, at)))
13
+ return false;
14
+ const hostPort = authority.slice(at + 1);
15
+ if (hostPort.startsWith("[")) {
16
+ const end = hostPort.indexOf("]");
17
+ if (end === -1 || !/^(?::[0-9]*)?$/.test(hostPort.slice(end + 1)))
18
+ return false;
19
+ const literal = hostPort.slice(1, end);
20
+ if (IP_FUTURE.test(literal))
21
+ return true;
22
+ try {
23
+ // Native parsing validates an IPv6 literal; no lookup or request occurs.
24
+ return new URL(`http://[${literal}]`).hostname.startsWith("[");
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ const colon = hostPort.lastIndexOf(":");
31
+ return colon === -1
32
+ ? REG_NAME.test(hostPort)
33
+ : REG_NAME.test(hostPort.slice(0, colon)) &&
34
+ /^[0-9]*$/.test(hostPort.slice(colon + 1));
35
+ }
36
+ function absoluteUri(value) {
37
+ const uri = /^[A-Za-z][A-Za-z0-9+.-]*:([^?#]*)(?:\?([^#]*))?$/.exec(value);
38
+ if (!uri || (uri[2] !== undefined && !QUERY.test(uri[2])))
39
+ return false;
40
+ let path = uri[1];
41
+ if (path.startsWith("//")) {
42
+ const slash = path.indexOf("/", 2);
43
+ const authority = slash === -1 ? path.slice(2) : path.slice(2, slash);
44
+ if (!validAuthority(authority))
45
+ return false;
46
+ path = slash === -1 ? "" : path.slice(slash);
47
+ }
48
+ return PATH.test(path);
49
+ }
50
+ /**
51
+ * Read zero or one absolute resource URI without a fragment, preserving its
52
+ * exact spelling. Multiple values, including equal repeats, are refused.
53
+ * Call only after opting into the single-resource policy.
54
+ * @throws {InvalidTargetError} For an empty, repeated, non-string or malformed value.
55
+ */
56
+ export function resourceIndicatorOf(body) {
57
+ const values = body.getAll("resource");
58
+ if (values.length === 0)
59
+ return undefined;
60
+ const resource = values[0];
61
+ if (values.length !== 1 ||
62
+ typeof resource !== "string" ||
63
+ !absoluteUri(resource)) {
64
+ throw new InvalidTargetError("invalid resource");
65
+ }
66
+ return resource;
67
+ }
@@ -1162,6 +1162,8 @@ export class AuthorizationServer extends ResourceServer {
1162
1162
  if (tokenType === "access_token") {
1163
1163
  response.token_type = "Bearer";
1164
1164
  }
1165
+ if (token.resource !== undefined)
1166
+ response.aud = token.resource;
1165
1167
  if (token.scope) {
1166
1168
  response.scope = token.scope.toString();
1167
1169
  }
@@ -70,7 +70,13 @@ export declare class ClientCredentialsGrant<Client extends ClientInterface, User
70
70
  * `ClientServiceInterface.getUser` resolves for the client, and is left
71
71
  * unset when it resolves nothing — a user-less machine token, not a failure.
72
72
  *
73
+ * Resource indicators are opt-in through `tokenService.acceptedResource`:
74
+ * its presence validates one absolute resource URI and requires explicit
75
+ * policy acceptance. Its absence preserves legacy extension handling.
76
+ *
73
77
  * @throws {InvalidScopeError} If the requested scope is rejected.
78
+ * @throws {InvalidTargetError} If opted-in resource validation or policy refuses.
79
+ * @throws {ServerError} If storage drops or changes an accepted target.
74
80
  */
75
81
  token(request: Request, client: Client, body: FormData): Promise<Token<Client, User, S>>;
76
82
  }
@@ -3,7 +3,8 @@
3
3
  * RFC 6749 Section 4.4.
4
4
  * @module
5
5
  */
6
- import { InvalidClientError } from "../../errors.js";
6
+ import { InvalidClientError, InvalidTargetError, ServerError, } from "../../errors.js";
7
+ import { resourceIndicatorOf } from "../_resource-indicator.js";
7
8
  import { authenticateClientCredentials } from "../client-authentication.js";
8
9
  import { AbstractGrant, } from "./grant.js";
9
10
  /**
@@ -71,7 +72,13 @@ export class ClientCredentialsGrant extends AbstractGrant {
71
72
  * `ClientServiceInterface.getUser` resolves for the client, and is left
72
73
  * unset when it resolves nothing — a user-less machine token, not a failure.
73
74
  *
75
+ * Resource indicators are opt-in through `tokenService.acceptedResource`:
76
+ * its presence validates one absolute resource URI and requires explicit
77
+ * policy acceptance. Its absence preserves legacy extension handling.
78
+ *
74
79
  * @throws {InvalidScopeError} If the requested scope is rejected.
80
+ * @throws {InvalidTargetError} If opted-in resource validation or policy refuses.
81
+ * @throws {ServerError} If storage drops or changes an accepted target.
75
82
  */
76
83
  async token(request, client, body) {
77
84
  const { tokenService, clientService } = await this.resolveServices(request);
@@ -80,7 +87,19 @@ export class ClientCredentialsGrant extends AbstractGrant {
80
87
  const user = await clientService.getUser(client);
81
88
  const acceptedScope = await this.acceptedScope(client, user, scope, tokenService);
82
89
  scope = acceptedScope ?? undefined;
83
- const token = await this.generateToken(client, user, scope, tokenService);
84
- return await tokenService.save(token);
90
+ let resource;
91
+ const resourcePolicy = tokenService.acceptedResource;
92
+ if (resourcePolicy !== undefined) {
93
+ resource = resourceIndicatorOf(body);
94
+ if ((await resourcePolicy.call(tokenService, client, user, resource, scope)) !== true) {
95
+ throw new InvalidTargetError("invalid resource");
96
+ }
97
+ }
98
+ const token = await this.generateToken(client, user, scope, tokenService, undefined, undefined, resource);
99
+ const saved = await tokenService.save(token);
100
+ if (resourcePolicy !== undefined && saved.resource !== resource) {
101
+ throw new ServerError("token storage did not preserve resource");
102
+ }
103
+ return saved;
85
104
  }
86
105
  }
@@ -150,9 +150,11 @@ export declare abstract class AbstractGrant<Client extends ClientInterface, User
150
150
  * for tokens with no user. `familyExpiresAt` is the ceiling of an existing
151
151
  * rotation family the token joins (the refresh grant passes it); the
152
152
  * expiries are clamped to it before the access token string is generated,
153
- * so a signed token's `exp` matches the stored expiry.
153
+ * so a signed token's `exp` matches the stored expiry. `resource` is the
154
+ * exact URI accepted by the client-credentials resource policy; it is
155
+ * retained in the token and passed to audience-aware generation.
154
156
  */
155
- generateToken(client: Client, user: User | undefined, scope: Scope | null | undefined, tokenService: TokenServiceInterface<Client, User, Scope>, authenticationContext?: AuthenticationContext, familyExpiresAt?: Date): Promise<Token<Client, User, Scope>>;
157
+ generateToken(client: Client, user: User | undefined, scope: Scope | null | undefined, tokenService: TokenServiceInterface<Client, User, Scope>, authenticationContext?: AuthenticationContext, familyExpiresAt?: Date, resource?: string): Promise<Token<Client, User, Scope>>;
156
158
  /**
157
159
  * Authenticates the client presenting this grant's token request against the
158
160
  * grant's own `clientService`, from the credentials the request presents.
@@ -118,13 +118,17 @@ export class AbstractGrant {
118
118
  * for tokens with no user. `familyExpiresAt` is the ceiling of an existing
119
119
  * rotation family the token joins (the refresh grant passes it); the
120
120
  * expiries are clamped to it before the access token string is generated,
121
- * so a signed token's `exp` matches the stored expiry.
121
+ * so a signed token's `exp` matches the stored expiry. `resource` is the
122
+ * exact URI accepted by the client-credentials resource policy; it is
123
+ * retained in the token and passed to audience-aware generation.
122
124
  */
123
- async generateToken(client, user, scope, tokenService, authenticationContext, familyExpiresAt) {
125
+ async generateToken(client, user, scope, tokenService, authenticationContext, familyExpiresAt, resource) {
124
126
  const event = user === undefined
125
127
  ? undefined
126
128
  : snapshotAuthenticationContext(authenticationContext);
127
129
  const token = { accessToken: "", client };
130
+ if (resource !== undefined)
131
+ token.resource = resource;
128
132
  if (event)
129
133
  token.authenticationContext = event;
130
134
  if (user !== undefined)
@@ -162,7 +166,17 @@ export class AbstractGrant {
162
166
  }
163
167
  if (ceiling)
164
168
  this.capAtFamilyExpiry(result, ceiling);
165
- result.accessToken = await tokenService.generateAccessToken(client, user, scope, event, result.accessTokenExpiresAt);
169
+ const generation = [
170
+ client,
171
+ user,
172
+ scope,
173
+ event,
174
+ result.accessTokenExpiresAt,
175
+ ];
176
+ result.accessToken =
177
+ resource === undefined
178
+ ? await tokenService.generateAccessToken(...generation)
179
+ : await tokenService.generateAccessToken(...generation, resource);
166
180
  return result;
167
181
  }
168
182
  /**
@@ -115,6 +115,8 @@ export declare class RefreshTokenGrant<Client extends ClientInterface, User, S e
115
115
  * claimed it.
116
116
  * @throws {InvalidClientError} If the token was issued to another client.
117
117
  * @throws {InvalidScopeError} If the requested scope exceeds the original grant.
118
+ * @throws {InvalidTargetError} If a stored parent is resource-bound; refreshing
119
+ * such a grant is unsupported and refuses before spending the parent.
118
120
  */
119
121
  token(request: Request, client: Client, body: FormData): Promise<RefreshToken<Client, User, S>>;
120
122
  }
@@ -3,7 +3,7 @@
3
3
  * RFC 6749 Section 6.
4
4
  * @module
5
5
  */
6
- import { InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, } from "../../errors.js";
6
+ import { InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTargetError, } from "../../errors.js";
7
7
  import { AbstractGrant, } from "./grant.js";
8
8
  const REPLAY_GRACE_CLOCK_TOLERANCE_SECONDS = 300;
9
9
  /**
@@ -56,6 +56,8 @@ export class RefreshTokenGrant extends AbstractGrant {
56
56
  * claimed it.
57
57
  * @throws {InvalidClientError} If the token was issued to another client.
58
58
  * @throws {InvalidScopeError} If the requested scope exceeds the original grant.
59
+ * @throws {InvalidTargetError} If a stored parent is resource-bound; refreshing
60
+ * such a grant is unsupported and refuses before spending the parent.
59
61
  */
60
62
  async token(request, client, body) {
61
63
  const { tokenService } = await this.resolveServices(request);
@@ -92,6 +94,11 @@ export class RefreshTokenGrant extends AbstractGrant {
92
94
  if (client.id.toString() !== tokenClient.id.toString()) {
93
95
  throw new InvalidClientError("refresh_token was issued to another client");
94
96
  }
97
+ if (currentToken.resource !== undefined) {
98
+ // Resource-bound refresh is not implemented. Never spend the parent or
99
+ // issue a successor that silently loses its audience restriction.
100
+ throw new InvalidTargetError("resource-bound refresh is not supported");
101
+ }
95
102
  // RFC 6749 §6: a requested scope may only narrow, never exceed, the original grant.
96
103
  let nextScope = scope;
97
104
  const requestedScopeText = body.get("scope");
@@ -15,7 +15,7 @@ export { AbstractScope, BasicScope } from "../models/scope.ts";
15
15
  export type { ScopeConstructor } from "../models/scope.ts";
16
16
  export { Authorization, authorizationFromClaims, } from "../models/authorization.ts";
17
17
  export type { AuthorizationFailure, AuthorizationInit, OrganizationContext, RequireConditions, } from "../models/authorization.ts";
18
- export { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientPermissionsError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, isOAuth2Error, LoginRequiredError, OAuth2Error, ServerError, SlowDownError, TemporarilyUnavailableError, toOAuth2Error, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.ts";
18
+ export { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientPermissionsError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, InvalidTargetError, isOAuth2Error, LoginRequiredError, OAuth2Error, ServerError, SlowDownError, TemporarilyUnavailableError, toOAuth2Error, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.ts";
19
19
  export type { OAuth2ErrorClass, OAuth2ProblemDetailsExtensions, } from "../errors.ts";
20
20
  export { type AuthenticationOutcome, satisfiesAuthenticationRequest, } from "./authentication-request.ts";
21
21
  export type { TokenReaderInterface } from "./services/token.ts";
@@ -10,6 +10,6 @@
10
10
  */
11
11
  export { AbstractScope, BasicScope } from "../models/scope.js";
12
12
  export { Authorization, authorizationFromClaims, } from "../models/authorization.js";
13
- export { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientPermissionsError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, isOAuth2Error, LoginRequiredError, OAuth2Error, ServerError, SlowDownError, TemporarilyUnavailableError, toOAuth2Error, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
13
+ export { AccessDeniedError, AccountSelectionRequiredError, AuthorizationPendingError, ConsentRequiredError, ExpiredTokenError, InsufficientPermissionsError, InsufficientScopeError, InteractionRequiredError, InvalidClientError, InvalidGrantError, InvalidRequestError, InvalidScopeError, InvalidTokenError, InvalidTargetError, isOAuth2Error, LoginRequiredError, OAuth2Error, ServerError, SlowDownError, TemporarilyUnavailableError, toOAuth2Error, UnauthorizedClientError, UnsupportedGrantTypeError, UnsupportedResponseTypeError, UnsupportedTokenTypeError, } from "../errors.js";
14
14
  export { satisfiesAuthenticationRequest, } from "./authentication-request.js";
15
15
  export { checkRedirectUriPattern, isRedirectUriPattern, matchRedirectUri, } from "./redirect-uri.js";
@@ -49,6 +49,19 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
49
49
  * @returns The accepted scope, null/undefined to accept as-is, or false to reject.
50
50
  */
51
51
  acceptedScope(client: Client, user: User | undefined, scope?: Scope | null): Promise<Scope | null | undefined | false>;
52
+ /**
53
+ * Opts client-credentials issuance into one RFC 8707 resource per token.
54
+ * Called after client authentication and scope acceptance, even when no
55
+ * resource was supplied. Only `true` accepts; other results refuse with
56
+ * `invalid_target`. The URI is validated and passed unchanged; decide
57
+ * resource availability, client authority and scope compatibility here.
58
+ *
59
+ * Leave this method absent to preserve legacy extension-parameter behavior.
60
+ * Enabling it requires persisting `Token.resource` and forwarding the sixth
61
+ * `generateAccessToken` argument to signed-token generation. Other grants
62
+ * do not support resource indicators yet.
63
+ */
64
+ acceptedResource?(client: Client, user: User | undefined, resource: string | undefined, scope?: Scope | null): Promise<boolean>;
52
65
  /**
53
66
  * Generates an access token string. `user` is `undefined` for a client
54
67
  * acting as its own resource owner (client credentials) — a signed-token
@@ -60,8 +73,11 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
60
73
  * a signed token's `exp` never outlives what the server itself honors; the
61
74
  * grants always pass it, and a wrapper that drops it leaves offline
62
75
  * verifiers accepting the token past its stored expiry.
76
+ * `resource`, when supplied by an opted-in machine grant, is the accepted
77
+ * target URI. A signed token must restrict its audience to this value;
78
+ * dropping it can issue a token for a different API.
63
79
  */
64
- generateAccessToken(client: Client, user: User | undefined, scope?: Scope | null, authenticationContext?: AuthenticationContext, expiresAt?: Date): Promise<string>;
80
+ generateAccessToken(client: Client, user: User | undefined, scope?: Scope | null, authenticationContext?: AuthenticationContext, expiresAt?: Date, resource?: string): Promise<string>;
65
81
  /** Generates a refresh token string. */
66
82
  generateRefreshToken(client: Client, user: User, scope?: Scope | null): Promise<string | undefined>;
67
83
  /**
@@ -99,6 +115,8 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
99
115
  * Persists either token kind and returns the saved record. Preserve
100
116
  * `authenticationContext` on save and every read, including refresh and
101
117
  * revoked records; never reconstruct it from a user's current session.
118
+ * Preserve `resource` exactly too when resource policy is enabled. The
119
+ * client-credentials grant refuses a saved result that loses its binding.
102
120
  */
103
121
  save(token: Token<Client, User, Scope> | RefreshToken<Client, User, Scope>): Promise<Token<Client, User, Scope> | RefreshToken<Client, User, Scope>>;
104
122
  /** Revokes a token record. Resolves true if a token was revoked. */
@@ -342,7 +360,7 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
342
360
  */
343
361
  acceptedScope(_client: Client, _user: User | undefined, scope?: Scope | null): Promise<Scope | null | undefined | false>;
344
362
  /** Generates an opaque access token string. Override to issue a different format. */
345
- generateAccessToken(_client: Client, _user: User | undefined, _scope?: Scope | null, _authenticationContext?: AuthenticationContext, _expiresAt?: Date): Promise<string>;
363
+ generateAccessToken(_client: Client, _user: User | undefined, _scope?: Scope | null, _authenticationContext?: AuthenticationContext, _expiresAt?: Date, _resource?: string): Promise<string>;
346
364
  /** Generates an opaque refresh token string. Override to issue a different format. */
347
365
  generateRefreshToken(_client: Client, _user: User, _scope?: Scope | null): Promise<string | undefined>;
348
366
  /** Computes the access token expiry as now plus {@linkcode accessTokenLifetime}. */
@@ -52,7 +52,7 @@ export class AbstractTokenService {
52
52
  return Promise.resolve(scope);
53
53
  }
54
54
  /** Generates an opaque access token string. Override to issue a different format. */
55
- generateAccessToken(_client, _user, _scope, _authenticationContext, _expiresAt) {
55
+ generateAccessToken(_client, _user, _scope, _authenticationContext, _expiresAt, _resource) {
56
56
  return Promise.resolve(crypto.randomUUID());
57
57
  }
58
58
  /** Generates an opaque refresh token string. Override to issue a different format. */
@@ -180,6 +180,8 @@ export declare function createJwtAccessTokenGenerator(options: {
180
180
  * The `aud` claim — per RFC 9068 an identifier of the resource server(s)
181
181
  * the token is intended for (e.g. `https://api.example.com`). Defaults to
182
182
  * the client id, which single-resource validators must then accept.
183
+ * An accepted resource passed as the returned function's sixth argument
184
+ * overrides this default, restricting the JWT to that exact target.
183
185
  */
184
186
  audience?: string;
185
187
  /**
@@ -214,4 +216,4 @@ export declare function createJwtAccessTokenGenerator(options: {
214
216
  id: string;
215
217
  }, user: unknown, scope?: {
216
218
  toString(): string;
217
- } | null, authenticationContext?: AuthenticationContext, expiresAt?: Date) => Promise<string>;
219
+ } | null, authenticationContext?: AuthenticationContext, expiresAt?: Date, resource?: string) => Promise<string>;
@@ -212,7 +212,7 @@ export async function verifyJwt(jwt, publicJwk, options = {}) {
212
212
  export function createJwtAccessTokenGenerator(options) {
213
213
  const lifetime = options.lifetimeSeconds ?? 3600;
214
214
  const subjectOf = options.subjectOf ?? defaultSubjectOf;
215
- return async (client, user, scope, authenticationContext, expiresAt) => {
215
+ return async (client, user, scope, authenticationContext, expiresAt, resource) => {
216
216
  const event = user
217
217
  ? snapshotAuthenticationContext(authenticationContext)
218
218
  : undefined;
@@ -229,7 +229,7 @@ export function createJwtAccessTokenGenerator(options) {
229
229
  iss: options.issuer,
230
230
  sub: user ? subjectOf(user) : client.id,
231
231
  client_id: client.id,
232
- aud: options.audience ?? client.id,
232
+ aud: resource ?? options.audience ?? client.id,
233
233
  iat: now,
234
234
  exp: Math.min(now + lifetime, storedExp),
235
235
  jti: crypto.randomUUID(),
@@ -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;