@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.
- package/README.md +1 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -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/models/token.d.ts +7 -0
- package/dist/react/components/internal.d.ts +5 -1
- package/dist/react/components/internal.js +9 -0
- package/dist/react/components/mfa-challenge-form.d.ts +9 -3
- package/dist/react/components/mfa-challenge-form.js +3 -3
- package/dist/react/components/mod.d.ts +1 -1
- package/dist/react/components/sign-in-form.d.ts +11 -3
- package/dist/react/components/sign-in-form.js +4 -4
- package/dist/react/components/types.d.ts +12 -0
- package/dist/server/_resource-indicator.d.ts +7 -0
- package/dist/server/_resource-indicator.js +67 -0
- package/dist/server/authorization-server.js +2 -0
- package/dist/server/grants/client-credentials.d.ts +6 -0
- package/dist/server/grants/client-credentials.js +22 -3
- package/dist/server/grants/grant.d.ts +4 -2
- package/dist/server/grants/grant.js +17 -3
- package/dist/server/grants/refresh-token.d.ts +2 -0
- package/dist/server/grants/refresh-token.js +8 -1
- package/dist/server/mod.d.ts +1 -1
- package/dist/server/mod.js +1 -1
- package/dist/server/services/token.d.ts +20 -2
- package/dist/server/services/token.js +1 -1
- package/dist/server/signing-keys.d.ts +3 -1
- package/dist/server/signing-keys.js +2 -2
- 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/contract/token.d.ts +7 -0
- package/dist/testing/contract/token.js +17 -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/dist/testing/services.js +4 -0
- package/package.json +7 -1
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* {@link SignInForm} — a default, accessible, unstyled sign-in form built on
|
|
3
3
|
* {@link useAuthForm}. Collects an identifier and password (optionally a
|
|
4
4
|
* "remember me" checkbox), renders "Continue with …" social buttons when
|
|
5
|
-
* providers are supplied
|
|
6
|
-
* `onSubmit`.
|
|
5
|
+
* providers are supplied and a passkey button when `passkey` is, and reports
|
|
6
|
+
* the outcome through the injected `onSubmit`.
|
|
7
7
|
*
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
import type { ReactNode } from "react";
|
|
11
11
|
import type { AuthFormState } from "./use-auth-form.ts";
|
|
12
|
-
import type { BaseAuthFormProps, SocialProvidersProps } from "./types.ts";
|
|
12
|
+
import type { BaseAuthFormProps, PasskeyOption, SocialProvidersProps } from "./types.ts";
|
|
13
13
|
/** Values collected by {@link SignInForm}. */
|
|
14
14
|
export type SignInValues = {
|
|
15
15
|
/** Email address or username. */
|
|
@@ -42,6 +42,14 @@ export interface SignInFormProps extends BaseAuthFormProps<SignInValues>, Social
|
|
|
42
42
|
forgotPasswordHref?: string;
|
|
43
43
|
/** Text overrides. */
|
|
44
44
|
labels?: SignInLabels;
|
|
45
|
+
/**
|
|
46
|
+
* Offer passkey sign-in: renders a "Sign in with a passkey" button and marks
|
|
47
|
+
* the identifier field `autocomplete="username webauthn"`, so a browser
|
|
48
|
+
* running a conditional-mediation request (`mediation: "conditional"`)
|
|
49
|
+
* offers saved passkeys in the field's autofill. Start that request
|
|
50
|
+
* yourself on mount; the form only provides the field.
|
|
51
|
+
*/
|
|
52
|
+
passkey?: PasskeyOption;
|
|
45
53
|
/**
|
|
46
54
|
* Render-prop escape hatch. When provided, you own the markup and receive
|
|
47
55
|
* the live {@link AuthFormState}; the component still owns state and submit.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useAuthForm } from "./use-auth-form.js";
|
|
3
|
-
import { AuthFormShell, SocialButtons, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
|
|
3
|
+
import { AuthFormShell, PasskeyButton, SocialButtons, SubmitButton, TextField, useFocusFirstError, } from "./internal.js";
|
|
4
4
|
import { useAuthFormClassNames } from "./class-names.js";
|
|
5
5
|
/**
|
|
6
6
|
* A ready-to-use sign-in form. Wire `onSubmit` to your auth endpoint; pass
|
|
@@ -26,7 +26,7 @@ import { useAuthFormClassNames } from "./class-names.js";
|
|
|
26
26
|
* ```
|
|
27
27
|
*/
|
|
28
28
|
export function SignInForm(props) {
|
|
29
|
-
const { onSubmit, className, socialProviders, onSocialSelect, socialHref, showRemember = false, forgotPasswordHref, labels, children, } = props;
|
|
29
|
+
const { onSubmit, className, socialProviders, onSocialSelect, socialHref, showRemember = false, forgotPasswordHref, labels, passkey, children, } = props;
|
|
30
30
|
const form = useAuthForm({
|
|
31
31
|
initialValues: { identifier: "", password: "", remember: false },
|
|
32
32
|
onSubmit,
|
|
@@ -35,5 +35,5 @@ export function SignInForm(props) {
|
|
|
35
35
|
useFocusFirstError(form);
|
|
36
36
|
const classNames = useAuthFormClassNames(props.classNames);
|
|
37
37
|
const rememberField = form.getFieldProps("remember");
|
|
38
|
-
return (_jsxs(AuthFormShell, { name: "sign-in", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, aboveForm: _jsx(SocialButtons, { socialProviders: socialProviders, onSocialSelect: onSocialSelect, socialHref: socialHref }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("identifier"), label: labels?.identifier ?? "Email or username", autoComplete: "username", required: true, error: form.errors.identifier }), _jsx(TextField, { fieldProps: form.getFieldProps("password"), label: labels?.password ?? "Password", type: "password", autoComplete: "current-password", required: true, error: form.errors.password, children: forgotPasswordHref ? (_jsx("a", { href: forgotPasswordHref, "data-oauth2-forgot-password": "", className: classNames.hint, children: labels?.forgotPassword ?? "Forgot password?" })) : null }), showRemember ? (_jsx("div", { "data-oauth2-field": "remember", className: classNames.field, children: _jsxs("label", { htmlFor: rememberField.id, "data-oauth2-label": "", className: classNames.label, children: [_jsx("input", { ...rememberField, type: "checkbox", "data-oauth2-input": "", className: classNames.input }), labels?.remember ?? "Remember me"] }) })) : null, _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Sign in", submittingLabel: labels?.submitting ?? "Signing in…" })] }));
|
|
38
|
+
return (_jsxs(AuthFormShell, { name: "sign-in", form: form, className: className, classNames: classNames, render: children, action: props.action, method: props.method, formRef: props.formRef, aboveForm: _jsxs(_Fragment, { children: [_jsx(PasskeyButton, { option: passkey, defaultLabel: "Sign in with a passkey" }), _jsx(SocialButtons, { socialProviders: socialProviders, onSocialSelect: onSocialSelect, socialHref: socialHref })] }), children: [_jsx(TextField, { fieldProps: form.getFieldProps("identifier"), label: labels?.identifier ?? "Email or username", autoComplete: passkey ? "username webauthn" : "username", required: true, error: form.errors.identifier }), _jsx(TextField, { fieldProps: form.getFieldProps("password"), label: labels?.password ?? "Password", type: "password", autoComplete: "current-password", required: true, error: form.errors.password, children: forgotPasswordHref ? (_jsx("a", { href: forgotPasswordHref, "data-oauth2-forgot-password": "", className: classNames.hint, children: labels?.forgotPassword ?? "Forgot password?" })) : null }), showRemember ? (_jsx("div", { "data-oauth2-field": "remember", className: classNames.field, children: _jsxs("label", { htmlFor: rememberField.id, "data-oauth2-label": "", className: classNames.label, children: [_jsx("input", { ...rememberField, type: "checkbox", "data-oauth2-input": "", className: classNames.input }), labels?.remember ?? "Remember me"] }) })) : null, _jsx(SubmitButton, { isSubmitting: form.isSubmitting, label: labels?.submit ?? "Sign in", submittingLabel: labels?.submitting ?? "Signing in…" })] }));
|
|
39
39
|
}
|
|
@@ -70,6 +70,18 @@ export interface SocialProvidersProps {
|
|
|
70
70
|
/** Builds the href for a provider; renders anchors instead of buttons. */
|
|
71
71
|
socialHref?: (provider: SocialProvider) => string;
|
|
72
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* A passkey button for a sign-in or challenge form. The component never runs
|
|
75
|
+
* the WebAuthn ceremony itself: `onSelect` fetches options from your server,
|
|
76
|
+
* calls `navigator.credentials.get()` (or `startAuthentication` from
|
|
77
|
+
* `@simplewebauthn/browser`), and posts the result back.
|
|
78
|
+
*/
|
|
79
|
+
export interface PasskeyOption {
|
|
80
|
+
/** Runs the passkey ceremony when the button is pressed. */
|
|
81
|
+
onSelect: () => void | Promise<void>;
|
|
82
|
+
/** Button text. Defaults to the form's own wording. */
|
|
83
|
+
label?: string;
|
|
84
|
+
}
|
|
73
85
|
/**
|
|
74
86
|
* The class hook every prebuilt component shares. Component-specific class-name
|
|
75
87
|
* maps ({@link AuthFormClassNames}, `UserMenuClassNames`) extend this, so `root`
|
|
@@ -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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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");
|
package/dist/server/mod.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/mod.js
CHANGED
|
@@ -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`, `
|
|
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;
|