@udibo/oauth2 0.40.1 → 0.42.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/dist/adapters/hono/log.d.ts +17 -11
- package/dist/adapters/hono/log.js +54 -11
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +9 -0
- package/dist/models/token.d.ts +7 -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/token.d.ts +7 -0
- package/dist/testing/contract/token.js +17 -0
- package/dist/testing/services.js +4 -0
- package/package.json +1 -1
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Request logging that removes query values, including OAuth callback credentials.
|
|
3
|
-
*
|
|
4
|
-
* @module hono/log
|
|
5
|
-
*/
|
|
6
1
|
import type { MiddlewareHandler } from "hono";
|
|
7
2
|
/**
|
|
8
3
|
* Produces a request target with every query value replaced by `[redacted]`.
|
|
@@ -26,14 +21,24 @@ import type { MiddlewareHandler } from "hono";
|
|
|
26
21
|
*/
|
|
27
22
|
export declare function redactedRequestTarget(url: string | URL): string;
|
|
28
23
|
/**
|
|
29
|
-
* Logs Hono
|
|
30
|
-
*
|
|
31
|
-
* Logs method, redacted target,
|
|
32
|
-
* does not log headers or bodies.
|
|
24
|
+
* Logs each Hono request as one line, written when the response is ready,
|
|
25
|
+
* without any query values. Mount before authentication routes so OAuth
|
|
26
|
+
* callback codes and state stay out of logs. Logs method, redacted target,
|
|
27
|
+
* response status and elapsed milliseconds; does not log headers or bodies.
|
|
28
|
+
* Paths and parameter names remain visible. A request whose handler throws
|
|
29
|
+
* something Hono does not answer (a non-`Error`) is still logged, as `500`.
|
|
30
|
+
*
|
|
31
|
+
* A request this middleware sees while it is handling another one (the app
|
|
32
|
+
* calling its own `fetch` from a handler or a server-rendered page's loader) is
|
|
33
|
+
* folded into the outer request's line, in brackets, in completion order,
|
|
34
|
+
* instead of writing its own. One that completes after the outer response was
|
|
35
|
+
* ready gets its own line, marked `(in-process)`; that includes a request
|
|
36
|
+
* started later from a timer or promise the outer request created. Each
|
|
37
|
+
* `requestLogger()` call folds only the requests it sees itself, so a sub-app
|
|
38
|
+
* with its own logger writes to its own sink.
|
|
33
39
|
*
|
|
34
40
|
* @param print Receives each log line; defaults to `console.log`, resolved per line.
|
|
35
|
-
* @returns Middleware emitting
|
|
36
|
-
* a response line.
|
|
41
|
+
* @returns Middleware emitting one line per request after downstream completion.
|
|
37
42
|
*
|
|
38
43
|
* @example
|
|
39
44
|
* ```ts
|
|
@@ -43,6 +48,7 @@ export declare function redactedRequestTarget(url: string | URL): string;
|
|
|
43
48
|
* const app = new Hono();
|
|
44
49
|
* app.use(requestLogger());
|
|
45
50
|
* app.get("/", (c) => c.text("Hello"));
|
|
51
|
+
* // --> GET /?code=[redacted] 200 3ms
|
|
46
52
|
* ```
|
|
47
53
|
*/
|
|
48
54
|
export declare function requestLogger(print?: (line: string) => void): MiddlewareHandler;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request logging that removes query values, including OAuth callback credentials.
|
|
3
|
+
*
|
|
4
|
+
* @module hono/log
|
|
5
|
+
*/
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
1
7
|
/**
|
|
2
8
|
* Produces a request target with every query value replaced by `[redacted]`.
|
|
3
9
|
* Keeps the pathname and percent-encoded parameter names, including repeats,
|
|
@@ -28,15 +34,28 @@ export function redactedRequestTarget(url) {
|
|
|
28
34
|
.join("&");
|
|
29
35
|
return `${pathname}?${query}`;
|
|
30
36
|
}
|
|
37
|
+
function summary(method, url, status, started) {
|
|
38
|
+
return `${method} ${redactedRequestTarget(url)} ${status} ${Date.now() - started}ms`;
|
|
39
|
+
}
|
|
31
40
|
/**
|
|
32
|
-
* Logs Hono
|
|
33
|
-
*
|
|
34
|
-
* Logs method, redacted target,
|
|
35
|
-
* does not log headers or bodies.
|
|
41
|
+
* Logs each Hono request as one line, written when the response is ready,
|
|
42
|
+
* without any query values. Mount before authentication routes so OAuth
|
|
43
|
+
* callback codes and state stay out of logs. Logs method, redacted target,
|
|
44
|
+
* response status and elapsed milliseconds; does not log headers or bodies.
|
|
45
|
+
* Paths and parameter names remain visible. A request whose handler throws
|
|
46
|
+
* something Hono does not answer (a non-`Error`) is still logged, as `500`.
|
|
47
|
+
*
|
|
48
|
+
* A request this middleware sees while it is handling another one (the app
|
|
49
|
+
* calling its own `fetch` from a handler or a server-rendered page's loader) is
|
|
50
|
+
* folded into the outer request's line, in brackets, in completion order,
|
|
51
|
+
* instead of writing its own. One that completes after the outer response was
|
|
52
|
+
* ready gets its own line, marked `(in-process)`; that includes a request
|
|
53
|
+
* started later from a timer or promise the outer request created. Each
|
|
54
|
+
* `requestLogger()` call folds only the requests it sees itself, so a sub-app
|
|
55
|
+
* with its own logger writes to its own sink.
|
|
36
56
|
*
|
|
37
57
|
* @param print Receives each log line; defaults to `console.log`, resolved per line.
|
|
38
|
-
* @returns Middleware emitting
|
|
39
|
-
* a response line.
|
|
58
|
+
* @returns Middleware emitting one line per request after downstream completion.
|
|
40
59
|
*
|
|
41
60
|
* @example
|
|
42
61
|
* ```ts
|
|
@@ -46,15 +65,39 @@ export function redactedRequestTarget(url) {
|
|
|
46
65
|
* const app = new Hono();
|
|
47
66
|
* app.use(requestLogger());
|
|
48
67
|
* app.get("/", (c) => c.text("Hello"));
|
|
68
|
+
* // --> GET /?code=[redacted] 200 3ms
|
|
49
69
|
* ```
|
|
50
70
|
*/
|
|
51
71
|
export function requestLogger(print) {
|
|
52
72
|
const emit = print ?? ((line) => console.log(line));
|
|
73
|
+
const outerRequest = new AsyncLocalStorage();
|
|
53
74
|
return async (c, next) => {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
const started = Date.now();
|
|
76
|
+
const outer = outerRequest.getStore();
|
|
77
|
+
let status = 500;
|
|
78
|
+
if (outer) {
|
|
79
|
+
try {
|
|
80
|
+
await next();
|
|
81
|
+
status = c.res.status;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
const call = summary(c.req.method, c.req.url, status, started);
|
|
85
|
+
if (outer.answered)
|
|
86
|
+
emit(`--> (in-process) ${call}`);
|
|
87
|
+
else
|
|
88
|
+
outer.calls.push(call);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const request = { calls: [], answered: false };
|
|
93
|
+
try {
|
|
94
|
+
await outerRequest.run(request, next);
|
|
95
|
+
status = c.res.status;
|
|
96
|
+
}
|
|
97
|
+
finally {
|
|
98
|
+
request.answered = true;
|
|
99
|
+
const calls = request.calls.length === 0 ? "" : ` [${request.calls.join(", ")}]`;
|
|
100
|
+
emit(`--> ${summary(c.req.method, c.req.url, status, started)}${calls}`);
|
|
101
|
+
}
|
|
59
102
|
};
|
|
60
103
|
}
|
package/dist/errors.d.ts
CHANGED
|
@@ -159,6 +159,11 @@ export declare const InsufficientPermissionsError: OAuth2ErrorClass;
|
|
|
159
159
|
* @see https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1
|
|
160
160
|
*/
|
|
161
161
|
export declare const UnsupportedResponseTypeError: OAuth2ErrorClass;
|
|
162
|
+
/**
|
|
163
|
+
* The requested resource is missing, malformed or not allowed by the server's
|
|
164
|
+
* resource policy (RFC 8707 section 2). Descriptions should not echo targets.
|
|
165
|
+
*/
|
|
166
|
+
export declare const InvalidTargetError: OAuth2ErrorClass;
|
|
162
167
|
/**
|
|
163
168
|
* The requested scope is invalid, unknown, or malformed.
|
|
164
169
|
*
|
package/dist/errors.js
CHANGED
|
@@ -205,6 +205,15 @@ export const UnsupportedResponseTypeError = createHttpErrorClass({
|
|
|
205
205
|
error: "unsupported_response_type",
|
|
206
206
|
},
|
|
207
207
|
});
|
|
208
|
+
/**
|
|
209
|
+
* The requested resource is missing, malformed or not allowed by the server's
|
|
210
|
+
* resource policy (RFC 8707 section 2). Descriptions should not echo targets.
|
|
211
|
+
*/
|
|
212
|
+
export const InvalidTargetError = createHttpErrorClass({
|
|
213
|
+
name: "Invalid Target",
|
|
214
|
+
status: 400,
|
|
215
|
+
extensions: { error: "invalid_target" },
|
|
216
|
+
});
|
|
208
217
|
/**
|
|
209
218
|
* The requested scope is invalid, unknown, or malformed.
|
|
210
219
|
*
|
package/dist/models/token.d.ts
CHANGED
|
@@ -18,6 +18,13 @@ export interface Token<Client extends ClientInterface, User, Scope extends Abstr
|
|
|
18
18
|
client: Client;
|
|
19
19
|
/** The user this token was issued for (if applicable). */
|
|
20
20
|
user?: User;
|
|
21
|
+
/**
|
|
22
|
+
* Exact resource URI accepted by an opted-in client-credentials policy
|
|
23
|
+
* (RFC 8707). Persist and restore it on every token read; never derive it
|
|
24
|
+
* from a later request or the client's current configuration. Absent for
|
|
25
|
+
* legacy issuance. Resource authority still belongs to the application.
|
|
26
|
+
*/
|
|
27
|
+
resource?: string;
|
|
21
28
|
/** The scope granted to this token. */
|
|
22
29
|
scope?: Scope | null;
|
|
23
30
|
/**
|
|
@@ -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(),
|
|
@@ -54,6 +54,13 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
54
54
|
* vocabulary, for example `"openid"`. Defaults to `"read"`.
|
|
55
55
|
*/
|
|
56
56
|
supportedScope?: string;
|
|
57
|
+
/**
|
|
58
|
+
* A resource URI whose exact token save/read round trip to check. Supply it
|
|
59
|
+
* when this store supports opted-in resource-bound machine issuance; leave
|
|
60
|
+
* absent for stores that only support legacy issuance. This tests storage,
|
|
61
|
+
* not the application's resource authorization policy.
|
|
62
|
+
*/
|
|
63
|
+
supportedResource?: string;
|
|
57
64
|
/**
|
|
58
65
|
* Returns a new rotation-family id the store accepts as a
|
|
59
66
|
* {@linkcode RefreshToken.familyId}, distinct on every call. The family cases
|
|
@@ -89,6 +89,23 @@ export function runTokenServiceContractTests(options) {
|
|
|
89
89
|
assert(claimed);
|
|
90
90
|
await tokenService.save(next);
|
|
91
91
|
}
|
|
92
|
+
if (options.supportedResource !== undefined) {
|
|
93
|
+
for (const withUser of [false, true]) {
|
|
94
|
+
it(`preserves the exact resource through save and lookup with${withUser ? "" : "out"} a user`, async () => {
|
|
95
|
+
const resource = options.supportedResource;
|
|
96
|
+
const token = {
|
|
97
|
+
accessToken: await tokenService.generateAccessToken(client, withUser ? user : undefined),
|
|
98
|
+
client,
|
|
99
|
+
scope: makeScope(supportedScope),
|
|
100
|
+
resource,
|
|
101
|
+
...(withUser ? { user } : {}),
|
|
102
|
+
};
|
|
103
|
+
expect((await tokenService.save(token)).resource).toBe(resource);
|
|
104
|
+
const read = await tokenService.getToken(token.accessToken);
|
|
105
|
+
expect(read?.resource, "a store must retain the accepted target, not just return its input from save").toBe(resource);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
92
109
|
describe("generateAccessToken / generateRefreshToken", () => {
|
|
93
110
|
it("produces unique access tokens", async () => {
|
|
94
111
|
const a = await tokenService.generateAccessToken(client, user);
|
package/dist/testing/services.js
CHANGED
|
@@ -307,6 +307,8 @@ export class MemoryTokenService {
|
|
|
307
307
|
scope: stored.scope,
|
|
308
308
|
authenticationContext: snapshotAuthenticationContext(stored.authenticationContext),
|
|
309
309
|
};
|
|
310
|
+
if (stored.resource !== undefined)
|
|
311
|
+
token.resource = stored.resource;
|
|
310
312
|
if (stored.refreshTokenHash) {
|
|
311
313
|
return {
|
|
312
314
|
...token,
|
|
@@ -352,6 +354,8 @@ export class MemoryTokenService {
|
|
|
352
354
|
scope: token.scope,
|
|
353
355
|
authenticationContext: snapshotAuthenticationContext(token.authenticationContext),
|
|
354
356
|
};
|
|
357
|
+
if (token.resource !== undefined)
|
|
358
|
+
stored.resource = token.resource;
|
|
355
359
|
if (token.code)
|
|
356
360
|
stored.codeHash = await sha256Hash(token.code);
|
|
357
361
|
if (token.codeVerifierHash) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|