@c9up/visa 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/dist/VisaManager.d.ts +42 -7
  2. package/dist/VisaManager.d.ts.map +1 -1
  3. package/dist/VisaManager.js +89 -3
  4. package/dist/VisaManager.js.map +1 -1
  5. package/dist/VisaProvider.d.ts.map +1 -1
  6. package/dist/VisaProvider.js +56 -0
  7. package/dist/VisaProvider.js.map +1 -1
  8. package/dist/authorize.d.ts +18 -0
  9. package/dist/authorize.d.ts.map +1 -1
  10. package/dist/authorize.js +6 -0
  11. package/dist/authorize.js.map +1 -1
  12. package/dist/clients.d.ts +1 -1
  13. package/dist/clients.d.ts.map +1 -1
  14. package/dist/clients.js +9 -1
  15. package/dist/clients.js.map +1 -1
  16. package/dist/config.d.ts +35 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js.map +1 -1
  19. package/dist/errors.d.ts +9 -3
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js.map +1 -1
  22. package/dist/guard.d.ts +21 -0
  23. package/dist/guard.d.ts.map +1 -1
  24. package/dist/guard.js +25 -1
  25. package/dist/guard.js.map +1 -1
  26. package/dist/index.d.ts +5 -2
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +3 -0
  29. package/dist/index.js.map +1 -1
  30. package/dist/introspect.d.ts +9 -1
  31. package/dist/introspect.d.ts.map +1 -1
  32. package/dist/introspect.js +18 -1
  33. package/dist/introspect.js.map +1 -1
  34. package/dist/protectedResource.d.ts +81 -0
  35. package/dist/protectedResource.d.ts.map +1 -0
  36. package/dist/protectedResource.js +124 -0
  37. package/dist/protectedResource.js.map +1 -0
  38. package/dist/register.d.ts +107 -0
  39. package/dist/register.d.ts.map +1 -0
  40. package/dist/register.js +203 -0
  41. package/dist/register.js.map +1 -0
  42. package/dist/resource.d.ts +50 -0
  43. package/dist/resource.d.ts.map +1 -0
  44. package/dist/resource.js +101 -0
  45. package/dist/resource.js.map +1 -0
  46. package/dist/store.d.ts +25 -0
  47. package/dist/store.d.ts.map +1 -1
  48. package/dist/stores/atlas.d.ts +7 -0
  49. package/dist/stores/atlas.d.ts.map +1 -1
  50. package/dist/stores/atlas.js +70 -16
  51. package/dist/stores/atlas.js.map +1 -1
  52. package/dist/stores/memory.d.ts +4 -0
  53. package/dist/stores/memory.d.ts.map +1 -1
  54. package/dist/stores/memory.js +25 -0
  55. package/dist/stores/memory.js.map +1 -1
  56. package/dist/token.d.ts +2 -0
  57. package/dist/token.d.ts.map +1 -1
  58. package/dist/token.js +21 -1
  59. package/dist/token.js.map +1 -1
  60. package/dist/types.d.ts +51 -0
  61. package/dist/types.d.ts.map +1 -1
  62. package/package.json +1 -1
  63. package/src/VisaManager.ts +122 -3
  64. package/src/VisaProvider.ts +64 -0
  65. package/src/authorize.ts +27 -0
  66. package/src/clients.ts +10 -0
  67. package/src/config.ts +35 -0
  68. package/src/errors.ts +16 -2
  69. package/src/guard.ts +41 -1
  70. package/src/index.ts +25 -0
  71. package/src/introspect.ts +17 -0
  72. package/src/protectedResource.ts +171 -0
  73. package/src/register.ts +335 -0
  74. package/src/resource.ts +123 -0
  75. package/src/store.ts +29 -0
  76. package/src/stores/atlas.ts +99 -15
  77. package/src/stores/memory.ts +33 -0
  78. package/src/token.ts +45 -1
  79. package/src/types.ts +52 -0
  80. package/stubs/database/migrations/9000_create_visa_tables.stub +9 -0
@@ -24,9 +24,18 @@ import {
24
24
  import { hashSecret, randomToken } from "./crypto.js";
25
25
  import { OAuthError, VisaError } from "./errors.js";
26
26
  import { introspect, revoke, verifyAccessToken } from "./introspect.js";
27
+ import {
28
+ type ClientRegistrationRequest,
29
+ type ClientRegistrationResponse,
30
+ type RegistrationOptions,
31
+ registrationResponse,
32
+ secretExpiryFor,
33
+ validateRegistration,
34
+ } from "./register.js";
27
35
  import type { VisaStore } from "./store.js";
28
36
  import { type TokenOptions, token } from "./token.js";
29
37
  import type {
38
+ AuthorizedApplication,
30
39
  Client,
31
40
  ClientAuthMethod,
32
41
  GrantType,
@@ -38,6 +47,8 @@ export interface VisaConfig extends AuthorizeOptions, TokenOptions {
38
47
  /** Where this server lives — `https://auth.example.com`. */
39
48
  issuer: string;
40
49
  store: VisaStore;
50
+ /** Whether clients may register themselves, and on what terms (RFC 7591). */
51
+ registration?: RegistrationOptions;
41
52
  }
42
53
 
43
54
  /** What `authorize()` decided. */
@@ -85,6 +96,8 @@ export class VisaManager {
85
96
  scopes?: string[];
86
97
  tokenEndpointAuthMethod?: ClientAuthMethod;
87
98
  trusted?: boolean;
99
+ /** When the issued secret stops working. Omitted means it does not. */
100
+ secretExpiresAt?: Date;
88
101
  }): Promise<{ client: Client; secret?: string }> {
89
102
  // Refused at registration, not at the first authorization request: a
90
103
  // URI that cannot be parsed would otherwise throw out of
@@ -102,6 +115,11 @@ export class VisaManager {
102
115
  tokenEndpointAuthMethod: method,
103
116
  ...(input.trusted === undefined ? {} : { trusted: input.trusted }),
104
117
  ...(secret === undefined ? {} : { secretHash: hashSecret(secret) }),
118
+ // Only alongside a secret: an expiry on a client that has none is a
119
+ // date nothing would ever read.
120
+ ...(secret === undefined || input.secretExpiresAt === undefined
121
+ ? {}
122
+ : { secretExpiresAt: input.secretExpiresAt }),
105
123
  };
106
124
  const store = this.#config.store;
107
125
  if (!("addClient" in store) || typeof store.addClient !== "function") {
@@ -117,6 +135,37 @@ export class VisaManager {
117
135
  return secret === undefined ? { client } : { client, secret };
118
136
  }
119
137
 
138
+ /**
139
+ * A client registering itself (RFC 7591).
140
+ *
141
+ * Refused unless the application turned registration on. What comes back is
142
+ * the 201 body, secret included — the one time it exists.
143
+ */
144
+ async register(
145
+ request: ClientRegistrationRequest,
146
+ presentedToken?: string,
147
+ now: Date = new Date(),
148
+ ): Promise<ClientRegistrationResponse> {
149
+ const options = this.#config.registration ?? {};
150
+ const metadata = validateRegistration(request, options, presentedToken);
151
+ // Computed once, here: the same value is stored on the client and
152
+ // answered in the 201 body, so the server cannot announce an expiry it
153
+ // does not enforce.
154
+ const secretExpiresAt = secretExpiryFor(now, options.secretTtlSeconds);
155
+ const { client, secret } = await this.registerClient({
156
+ // The id is ours to choose: a client naming itself could claim one
157
+ // that already exists and read another application's tokens.
158
+ id: randomToken(),
159
+ name: metadata.name,
160
+ redirectUris: metadata.redirectUris,
161
+ grantTypes: metadata.grantTypes,
162
+ scopes: metadata.scopes,
163
+ tokenEndpointAuthMethod: metadata.tokenEndpointAuthMethod,
164
+ ...(secretExpiresAt === undefined ? {} : { secretExpiresAt }),
165
+ });
166
+ return registrationResponse(client, now, secret);
167
+ }
168
+
120
169
  /**
121
170
  * Step one: check the request, and decide what happens to the user.
122
171
  *
@@ -229,12 +278,82 @@ export class VisaManager {
229
278
  return revoke(body, credentials, this.#config.store, now);
230
279
  }
231
280
 
232
- /** What a resource server calls on every request. */
281
+ /**
282
+ * What a resource server calls on every request.
283
+ *
284
+ * `resource` is this server's own identifier (RFC 8707): given, a token
285
+ * minted for a different one is refused here rather than honoured.
286
+ */
233
287
  async verify(
234
288
  presented: string,
235
289
  now: Date = new Date(),
236
- ): Promise<{ clientId: string; userId?: string; scopes: string[] } | null> {
237
- return verifyAccessToken(presented, this.#config.store, now);
290
+ resource?: string,
291
+ ): ReturnType<typeof verifyAccessToken> {
292
+ return verifyAccessToken(presented, this.#config.store, now, resource);
293
+ }
294
+
295
+ /**
296
+ * Every application this user has authorised.
297
+ *
298
+ * What a "connected applications" screen renders. Includes an application
299
+ * whose tokens have all expired: "last used" matters most when nothing is
300
+ * live any more, and hiding it would tell someone an application never
301
+ * touched their data when it did.
302
+ */
303
+ async listAuthorizations(
304
+ userId: string,
305
+ now: Date = new Date(),
306
+ ): Promise<AuthorizedApplication[]> {
307
+ const store = this.#config.store;
308
+ const consents = await store.listConsents(userId);
309
+ const tokens = await store.listAccessTokens(userId);
310
+
311
+ return Promise.all(
312
+ consents.map(async (consent) => {
313
+ const mine = tokens.filter(
314
+ (token) => token.clientId === consent.clientId,
315
+ );
316
+ const client = await store.findClient(consent.clientId);
317
+ const lastUsedAt = mine.reduce<Date | undefined>((latest, token) => {
318
+ if (token.lastUsedAt === undefined) return latest;
319
+ if (latest === undefined) return token.lastUsedAt;
320
+ return token.lastUsedAt > latest ? token.lastUsedAt : latest;
321
+ }, undefined);
322
+
323
+ return {
324
+ clientId: consent.clientId,
325
+ // A client that has since been deleted still shows, under its id:
326
+ // the grant is the user's and they must be able to end it.
327
+ name: client?.name ?? consent.clientId,
328
+ scopes: consent.scopes,
329
+ grantedAt: consent.grantedAt,
330
+ ...(lastUsedAt === undefined ? {} : { lastUsedAt }),
331
+ active: mine.some(
332
+ (token) => token.revokedAt === undefined && token.expiresAt > now,
333
+ ),
334
+ };
335
+ }),
336
+ );
337
+ }
338
+
339
+ /**
340
+ * End one application's access for one user.
341
+ *
342
+ * Tokens first, consent second, and deliberately in that order: someone
343
+ * revoking in a hurry wants the sessions dead, and a failure half-way
344
+ * through must not leave an application still holding live tokens with the
345
+ * grant already forgotten.
346
+ *
347
+ * Both kinds of token go — revoking only the refresh half would leave the
348
+ * access token it already bought alive for its full lifetime.
349
+ */
350
+ async revokeAuthorization(
351
+ userId: string,
352
+ clientId: string,
353
+ now: Date = new Date(),
354
+ ): Promise<void> {
355
+ await this.#config.store.revokeAccessFor(userId, clientId, now);
356
+ await this.#config.store.deleteConsent(userId, clientId);
238
357
  }
239
358
 
240
359
  /** Read credentials out of a request, both places the spec allows. */
@@ -19,6 +19,11 @@
19
19
  import "./augmentations.js";
20
20
  import type { VisaConfigInput } from "./config.js";
21
21
  import { OAuthError } from "./errors.js";
22
+ import {
23
+ PROTECTED_RESOURCE_PATH,
24
+ protectedResourceMetadata,
25
+ protectedResourceMetadataUrl,
26
+ } from "./protectedResource.js";
22
27
  import { clearVisa, getVisa, setVisa } from "./services/main.js";
23
28
  import { VisaManager } from "./VisaManager.js";
24
29
 
@@ -132,6 +137,24 @@ export default class VisaProvider {
132
137
  });
133
138
  });
134
139
 
140
+ // RFC 7591 — a client that registers itself. Mounted only when the
141
+ // application turned it on: an authorization server quietly accepting
142
+ // registrations is a thing nobody asked for.
143
+ if (config?.registration?.enabled === true) {
144
+ router.post(`${prefix}/register`, async (ctx) => {
145
+ await this.#answer(ctx, async () => {
146
+ const bearer = readBearer(ctx);
147
+ const created = await manager.register(ctx.request.all(), bearer);
148
+ // §3.2.1: 201, and the body carries a secret — no cache may
149
+ // keep it.
150
+ ctx.response.status(201);
151
+ ctx.response.header("cache-control", "no-store");
152
+ ctx.response.header("pragma", "no-cache");
153
+ return created;
154
+ });
155
+ });
156
+ }
157
+
135
158
  // RFC 8414 — what a client fetches to discover the endpoints.
136
159
  router.get("/.well-known/oauth-authorization-server", async (ctx) => {
137
160
  ctx.response.header("content-type", "application/json");
@@ -150,6 +173,9 @@ export default class VisaProvider {
150
173
  // S256 only unless the application turned `plain` back on.
151
174
  code_challenge_methods_supported:
152
175
  config?.allowPlainChallenge === true ? ["S256", "plain"] : ["S256"],
176
+ ...(config?.registration?.enabled === true
177
+ ? { registration_endpoint: `${manager.issuer}${prefix}/register` }
178
+ : {}),
153
179
  token_endpoint_auth_methods_supported: [
154
180
  "client_secret_basic",
155
181
  "client_secret_post",
@@ -157,6 +183,34 @@ export default class VisaProvider {
157
183
  ],
158
184
  });
159
185
  });
186
+
187
+ // RFC 9728 — what a client fetches after a 401 to learn where tokens
188
+ // come from. Mounted only when the application named its resource: a
189
+ // document announcing a resource nobody declared would point clients at
190
+ // something that does not exist.
191
+ const protectedResource = config?.protectedResource;
192
+ if (protectedResource !== undefined) {
193
+ const document = protectedResourceMetadata({
194
+ ...protectedResource,
195
+ authorizationServers: [manager.issuer],
196
+ });
197
+ router.get(PROTECTED_RESOURCE_PATH, async (ctx) => {
198
+ ctx.response.header("content-type", "application/json");
199
+ ctx.response.send(document);
200
+ });
201
+ // §3: the well-known segment goes BETWEEN the host and the resource's
202
+ // path, so a resource at `/mcp` publishes at `…/oauth-protected-
203
+ // resource/mcp`. Both are served: a client may ask for either.
204
+ const withPath = new URL(
205
+ protectedResourceMetadataUrl(protectedResource.resource),
206
+ ).pathname;
207
+ if (withPath !== PROTECTED_RESOURCE_PATH) {
208
+ router.get(withPath, async (ctx) => {
209
+ ctx.response.header("content-type", "application/json");
210
+ ctx.response.send(document);
211
+ });
212
+ }
213
+ }
160
214
  }
161
215
 
162
216
  async shutdown(): Promise<void> {
@@ -191,6 +245,16 @@ export default class VisaProvider {
191
245
  }
192
246
  }
193
247
 
248
+ /** The initial access token a registration may carry (RFC 7591 §3). */
249
+ function readBearer(ctx: VisaHttpContext): string | undefined {
250
+ const header = ctx.request.header("authorization");
251
+ if (header === undefined) return undefined;
252
+ const [scheme, value] = header.split(" ");
253
+ if (scheme?.toLowerCase() !== "bearer" || value === undefined)
254
+ return undefined;
255
+ return value;
256
+ }
257
+
194
258
  function readAuthorization(ctx: VisaHttpContext): { authorization?: string } {
195
259
  const header = ctx.request.header("authorization");
196
260
  return header === undefined ? {} : { authorization: header };
package/src/authorize.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  } from "./clients.js";
16
16
  import { hashSecret, randomToken } from "./crypto.js";
17
17
  import { OAuthError } from "./errors.js";
18
+ import { readResourceParameter, validateResources } from "./resource.js";
18
19
  import type { VisaStore } from "./store.js";
19
20
  import type { AuthorizationCode, Client } from "./types.js";
20
21
 
@@ -30,6 +31,13 @@ export interface AuthorizationRequest {
30
31
  nonce?: string;
31
32
  /** A client may ask to always see the consent screen. */
32
33
  prompt?: string;
34
+ /**
35
+ * Which resource the token is for (RFC 8707). One, or several.
36
+ *
37
+ * A parser hands repeated parameters over as an array, so both shapes are
38
+ * accepted rather than one being silently the only one that works.
39
+ */
40
+ resource?: string | string[];
33
41
  }
34
42
 
35
43
  /** A request that passed every check, with the values it resolved to. */
@@ -41,6 +49,8 @@ export interface ValidatedRequest {
41
49
  codeChallenge: string;
42
50
  codeChallengeMethod: "S256" | "plain";
43
51
  nonce: string | undefined;
52
+ /** The resources the token will be bound to (RFC 8707). Empty means unbound. */
53
+ resources: string[];
44
54
  }
45
55
 
46
56
  /**
@@ -61,6 +71,15 @@ export class UnredirectableError extends Error {
61
71
  export interface AuthorizeOptions {
62
72
  /** Lifetime of the code. Short on purpose — see `issueAuthorizationCode`. */
63
73
  codeTtlSeconds?: number;
74
+ /**
75
+ * The resources this server will issue tokens for (RFC 8707).
76
+ *
77
+ * Left out, any well-formed resource is accepted and carried — which is
78
+ * useful while a deployment finds its names, and worth replacing with the
79
+ * list once it has: an unknown resource should be refused with
80
+ * `invalid_target` rather than minted for.
81
+ */
82
+ resourcesSupported?: readonly string[];
64
83
  /**
65
84
  * Accept `plain` as a code challenge method.
66
85
  *
@@ -119,6 +138,10 @@ export async function validateAuthorizationRequest(
119
138
  options,
120
139
  );
121
140
  const scopes = resolveScopes(request.scope, client);
141
+ const resources = validateResources(
142
+ readResourceParameter(request.resource),
143
+ options.resourcesSupported,
144
+ );
122
145
 
123
146
  return {
124
147
  client,
@@ -128,6 +151,7 @@ export async function validateAuthorizationRequest(
128
151
  codeChallenge,
129
152
  codeChallengeMethod,
130
153
  nonce: request.nonce,
154
+ resources,
131
155
  };
132
156
  }
133
157
 
@@ -215,6 +239,9 @@ export async function issueAuthorizationCode(
215
239
  codeChallengeMethod: validated.codeChallengeMethod,
216
240
  expiresAt: new Date(now.getTime() + ttl * 1000),
217
241
  ...(validated.nonce === undefined ? {} : { nonce: validated.nonce }),
242
+ ...(validated.resources.length === 0
243
+ ? {}
244
+ : { resources: validated.resources }),
218
245
  };
219
246
  await store.saveAuthorizationCode(record);
220
247
  return code;
package/src/clients.ts CHANGED
@@ -157,6 +157,7 @@ function stringOrUndefined(value: unknown): string | undefined {
157
157
  export async function authenticateClient(
158
158
  credentials: ClientCredentials,
159
159
  load: (id: string) => Promise<Client | null>,
160
+ now: Date = new Date(),
160
161
  ): Promise<Client> {
161
162
  if (credentials.clientId === undefined) {
162
163
  throw new OAuthError("invalid_client", "Client authentication failed.");
@@ -192,6 +193,15 @@ export async function authenticateClient(
192
193
  ) {
193
194
  throw new OAuthError("invalid_client", "Client authentication failed.");
194
195
  }
196
+
197
+ // Registration may have answered `client_secret_expires_at`. This is where
198
+ // that answer becomes true: checked after the secret matched, so a wrong
199
+ // secret and an expired one are still the same sentence to the caller.
200
+ // Every endpoint that authenticates a client — token, introspection,
201
+ // revocation — arrives here, so one check covers all three.
202
+ if (client.secretExpiresAt !== undefined && client.secretExpiresAt <= now) {
203
+ throw new OAuthError("invalid_client", "Client authentication failed.");
204
+ }
195
205
  return client;
196
206
  }
197
207
 
package/src/config.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * provider reads out of the config store.
4
4
  */
5
5
 
6
+ import type { RegistrationOptions } from "./register.js";
6
7
  import type { VisaStore } from "./store.js";
7
8
 
8
9
  export interface VisaConfigInput {
@@ -27,6 +28,40 @@ export interface VisaConfigInput {
27
28
  allowPlainChallenge?: boolean;
28
29
  /** Where the endpoints are mounted. Default `/oauth`. */
29
30
  prefix?: string;
31
+ /**
32
+ * The resources this server issues tokens for (RFC 8707).
33
+ *
34
+ * Left out, any well-formed `resource` is accepted and carried. Named, an
35
+ * unknown one is refused with `invalid_target` — which is what stops a
36
+ * client asking for a token aimed at a server you do not run.
37
+ */
38
+ resourcesSupported?: string[];
39
+ /**
40
+ * Let clients register themselves (RFC 7591).
41
+ *
42
+ * OFF unless you say otherwise. An MCP client such as claude.ai has nobody
43
+ * to fill in a form for it, so it needs this; a server on the public
44
+ * internet with it open lets anyone create a client, so think about
45
+ * `initialAccessToken` before shipping one.
46
+ */
47
+ registration?: RegistrationOptions;
48
+ /**
49
+ * The protected resource this server issues tokens for (RFC 9728).
50
+ *
51
+ * Declare it and two things follow: the metadata document is served at
52
+ * `/.well-known/oauth-protected-resource`, and a refusal can name it. An
53
+ * MCP client walks from the 401 to that document to this server without
54
+ * anybody pasting a URL into a config file, which is the whole reason the
55
+ * RFC exists.
56
+ */
57
+ protectedResource?: {
58
+ /** The resource identifier — an absolute URI, no fragment. */
59
+ resource: string;
60
+ /** What a client may ask for. RECOMMENDED by §3.2. */
61
+ scopesSupported?: string[];
62
+ resourceName?: string;
63
+ resourceDocumentation?: string;
64
+ };
30
65
  }
31
66
 
32
67
  export function defineConfig(config: VisaConfigInput): VisaConfigInput {
package/src/errors.ts CHANGED
@@ -19,6 +19,8 @@ export type AuthorizationErrorCode =
19
19
  | "access_denied"
20
20
  | "unsupported_response_type"
21
21
  | "invalid_scope"
22
+ /** RFC 8707 §2 — a resource this server will not issue for, or cannot parse. */
23
+ | "invalid_target"
22
24
  | "server_error"
23
25
  | "temporarily_unavailable";
24
26
 
@@ -29,9 +31,21 @@ export type TokenErrorCode =
29
31
  | "invalid_grant"
30
32
  | "unauthorized_client"
31
33
  | "unsupported_grant_type"
32
- | "invalid_scope";
34
+ | "invalid_scope"
35
+ /** RFC 8707 §2. */
36
+ | "invalid_target";
37
+
38
+ /** Error codes the registration endpoint may return (RFC 7591 §3.2.2). */
39
+ export type RegistrationErrorCode =
40
+ | "invalid_redirect_uri"
41
+ | "invalid_client_metadata"
42
+ | "invalid_software_statement"
43
+ | "unapproved_software_statement";
33
44
 
34
- export type ProtocolErrorCode = AuthorizationErrorCode | TokenErrorCode;
45
+ export type ProtocolErrorCode =
46
+ | AuthorizationErrorCode
47
+ | TokenErrorCode
48
+ | RegistrationErrorCode;
35
49
 
36
50
  /**
37
51
  * A failure the client is told about, in the words the spec chose.
package/src/guard.ts CHANGED
@@ -21,6 +21,7 @@
21
21
 
22
22
  import { hashSecret, randomToken } from "./crypto.js";
23
23
  import { verifyAccessToken } from "./introspect.js";
24
+ import { wwwAuthenticate } from "./protectedResource.js";
24
25
  import type { VisaStore } from "./store.js";
25
26
 
26
27
  /**
@@ -214,6 +215,14 @@ export interface VisaGuardConfig {
214
215
  tokenClientId?: string;
215
216
  /** How long a minted token lasts — upstream's `expiresIn`, in seconds. */
216
217
  expiresInSeconds?: number;
218
+ /**
219
+ * The protected resource this guard stands in front of (RFC 9728).
220
+ *
221
+ * Given, {@link VisaGuard.challenge} returns a `WWW-Authenticate` naming
222
+ * the metadata document, which is how an MCP client finds the
223
+ * authorization server without being told where it is.
224
+ */
225
+ resource?: string;
217
226
  }
218
227
 
219
228
  /**
@@ -331,10 +340,41 @@ export class VisaGuard {
331
340
  return { headers: { authorization: `Bearer ${token.value}` } };
332
341
  }
333
342
 
343
+ /**
344
+ * The `WWW-Authenticate` value a 401 from this guard should carry.
345
+ *
346
+ * Returned rather than set: a warden strategy answers whether a credential
347
+ * is good, and the response belongs to whoever refuses the request. An
348
+ * exception handler sets it — `response.header('www-authenticate',
349
+ * guard.challenge())` — and with it a client that has no token learns
350
+ * where to get one instead of hitting a dead end.
351
+ */
352
+ challenge(
353
+ options: { error?: string; scope?: readonly string[] } = {},
354
+ ): string {
355
+ return wwwAuthenticate({
356
+ ...(this.#config.resource === undefined
357
+ ? {}
358
+ : { resource: this.#config.resource }),
359
+ ...(options.error === undefined ? {} : { error: options.error }),
360
+ ...(options.scope === undefined ? {} : { scope: options.scope }),
361
+ });
362
+ }
363
+
334
364
  async verify(presented: string): Promise<GuardResult> {
335
365
  const store = this.#store();
336
366
  const now = new Date();
337
- const verified = await verifyAccessToken(presented, store, now);
367
+ // The resource this guard declares is the resource it enforces. Passing
368
+ // it is the whole of RFC 8707 on this side: without it a token minted
369
+ // for another resource server — one the same authorization server also
370
+ // serves — is honoured here, and the `resource` advertised in the
371
+ // challenge is a claim nothing backs.
372
+ const verified = await verifyAccessToken(
373
+ presented,
374
+ store,
375
+ now,
376
+ this.#config.resource,
377
+ );
338
378
  if (verified === null) {
339
379
  return { authenticated: false, error: "Invalid or expired access token" };
340
380
  }
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
  type AuthorizationErrorCode,
37
37
  OAuthError,
38
38
  type ProtocolErrorCode,
39
+ type RegistrationErrorCode,
39
40
  type TokenErrorCode,
40
41
  VisaError,
41
42
  } from "./errors.js";
@@ -50,6 +51,29 @@ export {
50
51
  visaGuard,
51
52
  } from "./guard.js";
52
53
  export { introspect, revoke, verifyAccessToken } from "./introspect.js";
54
+ export {
55
+ assertResourceIdentifier,
56
+ type ChallengeOptions,
57
+ PROTECTED_RESOURCE_PATH,
58
+ type ProtectedResourceMetadata,
59
+ type ProtectedResourceOptions,
60
+ protectedResourceMetadata,
61
+ protectedResourceMetadataUrl,
62
+ wwwAuthenticate,
63
+ } from "./protectedResource.js";
64
+ export {
65
+ type ClientRegistrationRequest,
66
+ type ClientRegistrationResponse,
67
+ type RegistrationOptions,
68
+ registrationResponse,
69
+ validateRegistration,
70
+ } from "./register.js";
71
+ export {
72
+ audienceAllows,
73
+ narrowResources,
74
+ readResourceParameter,
75
+ validateResources,
76
+ } from "./resource.js";
53
77
  export {
54
78
  clearVisa,
55
79
  default as visa,
@@ -68,6 +92,7 @@ export { type TokenOptions, type TokenRequest, token } from "./token.js";
68
92
  export type {
69
93
  AccessToken,
70
94
  AuthorizationCode,
95
+ AuthorizedApplication,
71
96
  Client,
72
97
  ClientAuthMethod,
73
98
  Consent,
package/src/introspect.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import { authenticateClient, type ClientCredentials } from "./clients.js";
11
11
  import { hashSecret } from "./crypto.js";
12
12
  import { OAuthError } from "./errors.js";
13
+ import { audienceAllows } from "./resource.js";
13
14
  import type { VisaStore } from "./store.js";
14
15
  import type { IntrospectionResponse } from "./types.js";
15
16
 
@@ -55,6 +56,9 @@ export async function introspect(
55
56
  token_type: "Bearer",
56
57
  exp: Math.floor(access.expiresAt.getTime() / 1000),
57
58
  ...(access.userId === undefined ? {} : { sub: access.userId }),
59
+ // RFC 8707 §3 names introspection as where a resource server learns
60
+ // what a token was minted for.
61
+ ...(access.audience === undefined ? {} : { aud: access.audience }),
58
62
  };
59
63
  }
60
64
 
@@ -125,16 +129,28 @@ export async function verifyAccessToken(
125
129
  // not have to implement eleven methods it never calls.
126
130
  store: Pick<VisaStore, "findAccessToken">,
127
131
  now: Date = new Date(),
132
+ /**
133
+ * This resource's identifier (RFC 8707).
134
+ *
135
+ * Given, a token minted for a different resource is refused here rather
136
+ * than honoured — which is the entire point of binding one.
137
+ */
138
+ resource?: string,
128
139
  ): Promise<{
129
140
  clientId: string;
130
141
  userId?: string;
131
142
  scopes: string[];
132
143
  expiresAt: Date;
133
144
  lastUsedAt?: Date;
145
+ audience?: string[];
134
146
  } | null> {
135
147
  const token = await store.findAccessToken(hashSecret(presented));
136
148
  if (token === null) return null;
137
149
  if (token.revokedAt !== undefined || token.expiresAt <= now) return null;
150
+ // RFC 8707: a token bound to a resource is refused anywhere else. Unbound
151
+ // tokens are accepted — that is every token issued before a client started
152
+ // asking, and refusing them would break each one.
153
+ if (!audienceAllows(token.audience, resource)) return null;
138
154
  return {
139
155
  clientId: token.clientId,
140
156
  ...(token.userId === undefined ? {} : { userId: token.userId }),
@@ -145,6 +161,7 @@ export async function verifyAccessToken(
145
161
  // The PREVIOUS use, before this one is recorded — which is what the
146
162
  // upstream guard shows on `currentAccessToken.lastUsedAt`.
147
163
  ...(token.lastUsedAt === undefined ? {} : { lastUsedAt: token.lastUsedAt }),
164
+ ...(token.audience === undefined ? {} : { audience: token.audience }),
148
165
  };
149
166
  }
150
167