@udibo/oauth2 0.41.0 → 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/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
  *
@@ -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
- 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(),
@@ -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);
@@ -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.41.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",