@c9up/visa 0.1.0 → 0.1.2

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 (79) hide show
  1. package/dist/VisaManager.d.ts +40 -7
  2. package/dist/VisaManager.d.ts.map +1 -1
  3. package/dist/VisaManager.js +79 -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/config.d.ts +35 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/config.js.map +1 -1
  15. package/dist/configure.d.ts.map +1 -1
  16. package/dist/configure.js +19 -0
  17. package/dist/configure.js.map +1 -1
  18. package/dist/errors.d.ts +9 -3
  19. package/dist/errors.d.ts.map +1 -1
  20. package/dist/errors.js.map +1 -1
  21. package/dist/guard.d.ts +243 -0
  22. package/dist/guard.d.ts.map +1 -0
  23. package/dist/guard.js +293 -0
  24. package/dist/guard.js.map +1 -0
  25. package/dist/index.d.ts +7 -2
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/introspect.d.ts +11 -1
  30. package/dist/introspect.d.ts.map +1 -1
  31. package/dist/introspect.js +27 -1
  32. package/dist/introspect.js.map +1 -1
  33. package/dist/protectedResource.d.ts +81 -0
  34. package/dist/protectedResource.d.ts.map +1 -0
  35. package/dist/protectedResource.js +124 -0
  36. package/dist/protectedResource.js.map +1 -0
  37. package/dist/register.d.ts +96 -0
  38. package/dist/register.d.ts.map +1 -0
  39. package/dist/register.js +173 -0
  40. package/dist/register.js.map +1 -0
  41. package/dist/resource.d.ts +50 -0
  42. package/dist/resource.d.ts.map +1 -0
  43. package/dist/resource.js +101 -0
  44. package/dist/resource.js.map +1 -0
  45. package/dist/store.d.ts +33 -0
  46. package/dist/store.d.ts.map +1 -1
  47. package/dist/stores/atlas.d.ts +106 -0
  48. package/dist/stores/atlas.d.ts.map +1 -0
  49. package/dist/stores/atlas.js +408 -0
  50. package/dist/stores/atlas.js.map +1 -0
  51. package/dist/stores/memory.d.ts +5 -0
  52. package/dist/stores/memory.d.ts.map +1 -1
  53. package/dist/stores/memory.js +30 -0
  54. package/dist/stores/memory.js.map +1 -1
  55. package/dist/token.d.ts +2 -0
  56. package/dist/token.d.ts.map +1 -1
  57. package/dist/token.js +21 -1
  58. package/dist/token.js.map +1 -1
  59. package/dist/types.d.ts +49 -0
  60. package/dist/types.d.ts.map +1 -1
  61. package/package.json +5 -1
  62. package/src/VisaManager.ts +109 -3
  63. package/src/VisaProvider.ts +64 -0
  64. package/src/authorize.ts +27 -0
  65. package/src/config.ts +35 -0
  66. package/src/configure.ts +21 -0
  67. package/src/errors.ts +16 -2
  68. package/src/guard.ts +443 -0
  69. package/src/index.ts +41 -0
  70. package/src/introspect.ts +33 -2
  71. package/src/protectedResource.ts +171 -0
  72. package/src/register.ts +296 -0
  73. package/src/resource.ts +123 -0
  74. package/src/store.ts +38 -0
  75. package/src/stores/atlas.ts +514 -0
  76. package/src/stores/memory.ts +38 -0
  77. package/src/token.ts +45 -1
  78. package/src/types.ts +50 -0
  79. package/stubs/database/migrations/9000_create_visa_tables.stub +112 -0
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
 
@@ -121,16 +125,43 @@ export async function revoke(
121
125
  */
122
126
  export async function verifyAccessToken(
123
127
  presented: string,
124
- store: VisaStore,
128
+ // Only the one read: a resource server that keeps nothing but tokens should
129
+ // not have to implement eleven methods it never calls.
130
+ store: Pick<VisaStore, "findAccessToken">,
125
131
  now: Date = new Date(),
126
- ): Promise<{ clientId: string; userId?: string; scopes: string[] } | null> {
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,
139
+ ): Promise<{
140
+ clientId: string;
141
+ userId?: string;
142
+ scopes: string[];
143
+ expiresAt: Date;
144
+ lastUsedAt?: Date;
145
+ audience?: string[];
146
+ } | null> {
127
147
  const token = await store.findAccessToken(hashSecret(presented));
128
148
  if (token === null) return null;
129
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;
130
154
  return {
131
155
  clientId: token.clientId,
132
156
  ...(token.userId === undefined ? {} : { userId: token.userId }),
133
157
  scopes: token.scopes,
158
+ // Carried out so a caller can report the expiry it just checked, rather
159
+ // than reading the store a second time to say the same thing.
160
+ expiresAt: token.expiresAt,
161
+ // The PREVIOUS use, before this one is recorded — which is what the
162
+ // upstream guard shows on `currentAccessToken.lastUsedAt`.
163
+ ...(token.lastUsedAt === undefined ? {} : { lastUsedAt: token.lastUsedAt }),
164
+ ...(token.audience === undefined ? {} : { audience: token.audience }),
134
165
  };
135
166
  }
136
167
 
@@ -0,0 +1,171 @@
1
+ /**
2
+ * What a protected resource says when it refuses, so a client can find its way
3
+ * in on its own (RFC 9728).
4
+ *
5
+ * Without this, a 401 is a dead end: the caller knows it needs a token and has
6
+ * no way to learn where tokens come from. With it, the refusal names a
7
+ * metadata document, the document names the authorization server, and an MCP
8
+ * client walks from one to the other unattended. That is the whole point — it
9
+ * is the difference between an agent that can connect and one that needs
10
+ * somebody to paste a URL into a config file.
11
+ *
12
+ * Nothing here is visa-specific: a resource server is free to be a different
13
+ * process from the authorization server, which is why the metadata takes the
14
+ * issuer as a value rather than reading one.
15
+ */
16
+
17
+ /** The metadata document, §3.2. Only `resource` is required. */
18
+ export interface ProtectedResourceMetadata {
19
+ resource: string;
20
+ authorization_servers?: string[];
21
+ scopes_supported?: string[];
22
+ bearer_methods_supported?: string[];
23
+ resource_name?: string;
24
+ resource_documentation?: string;
25
+ jwks_uri?: string;
26
+ }
27
+
28
+ export interface ProtectedResourceOptions {
29
+ /** This resource's identifier — an absolute URI, no fragment. */
30
+ resource: string;
31
+ /** The authorization servers that issue tokens for it. */
32
+ authorizationServers?: readonly string[];
33
+ /** What a client may ask for here. RECOMMENDED by §3.2. */
34
+ scopesSupported?: readonly string[];
35
+ /** Where the token may travel. Defaults to the header, which is all visa reads. */
36
+ bearerMethodsSupported?: readonly string[];
37
+ resourceName?: string;
38
+ resourceDocumentation?: string;
39
+ }
40
+
41
+ /** The path the document is served at, §3. */
42
+ export const PROTECTED_RESOURCE_PATH = "/.well-known/oauth-protected-resource";
43
+
44
+ /**
45
+ * The well-known URL for a resource identifier.
46
+ *
47
+ * §3 inserts the well-known segment BETWEEN the host and the path, so a
48
+ * resource at `https://api.example/mcp` publishes at
49
+ * `https://api.example/.well-known/oauth-protected-resource/mcp` — not at the
50
+ * resource's own path with the segment appended, which is the easy mistake and
51
+ * the one that leaves a client fetching 404s.
52
+ */
53
+ export function protectedResourceMetadataUrl(resource: string): string {
54
+ const url = new URL(resource);
55
+ const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
56
+ url.pathname = `${PROTECTED_RESOURCE_PATH}${path}`;
57
+ url.search = "";
58
+ url.hash = "";
59
+ return url.href;
60
+ }
61
+
62
+ /** Build the document a client fetches after a 401. */
63
+ export function protectedResourceMetadata(
64
+ options: ProtectedResourceOptions,
65
+ ): ProtectedResourceMetadata {
66
+ assertResourceIdentifier(options.resource);
67
+ return {
68
+ resource: options.resource,
69
+ ...(options.authorizationServers === undefined
70
+ ? {}
71
+ : { authorization_servers: [...options.authorizationServers] }),
72
+ ...(options.scopesSupported === undefined
73
+ ? {}
74
+ : { scopes_supported: [...options.scopesSupported] }),
75
+ bearer_methods_supported: [
76
+ ...(options.bearerMethodsSupported ?? ["header"]),
77
+ ],
78
+ ...(options.resourceName === undefined
79
+ ? {}
80
+ : { resource_name: options.resourceName }),
81
+ ...(options.resourceDocumentation === undefined
82
+ ? {}
83
+ : { resource_documentation: options.resourceDocumentation }),
84
+ };
85
+ }
86
+
87
+ export interface ChallengeOptions {
88
+ /** Where the metadata lives. Derived from the resource when omitted. */
89
+ resourceMetadata?: string;
90
+ /** This resource's identifier, used to derive the metadata URL. */
91
+ resource?: string;
92
+ /** `invalid_token`, `insufficient_scope` — RFC 6750 §3.1. */
93
+ error?: string;
94
+ errorDescription?: string;
95
+ /** What the caller would have needed, with `insufficient_scope`. */
96
+ scope?: readonly string[];
97
+ }
98
+
99
+ /**
100
+ * The `WWW-Authenticate` value for a 401, §5.1.
101
+ *
102
+ * Quoted values are checked rather than escaped: the header is a
103
+ * comma-separated list of quoted strings, and a value carrying a quote or a
104
+ * control character cannot be represented in it at all. A caller that built
105
+ * one from user input should hear about it here, not ship a header a client
106
+ * parses into something else.
107
+ */
108
+ export function wwwAuthenticate(options: ChallengeOptions = {}): string {
109
+ const metadataUrl =
110
+ options.resourceMetadata ??
111
+ (options.resource === undefined
112
+ ? undefined
113
+ : protectedResourceMetadataUrl(options.resource));
114
+
115
+ const parameters: Array<[string, string]> = [];
116
+ if (options.error !== undefined) parameters.push(["error", options.error]);
117
+ if (options.errorDescription !== undefined) {
118
+ parameters.push(["error_description", options.errorDescription]);
119
+ }
120
+ if (options.scope !== undefined && options.scope.length > 0) {
121
+ parameters.push(["scope", options.scope.join(" ")]);
122
+ }
123
+ if (metadataUrl !== undefined) {
124
+ parameters.push(["resource_metadata", metadataUrl]);
125
+ }
126
+
127
+ if (parameters.length === 0) return "Bearer";
128
+ const rendered = parameters
129
+ .map(([key, value]) => `${key}="${quotable(value, key)}"`)
130
+ .join(", ");
131
+ return `Bearer ${rendered}`;
132
+ }
133
+
134
+ function quotable(value: string, parameter: string): string {
135
+ // A quoted-string holds neither a bare quote nor a control character, and
136
+ // neither can be escaped into one usefully — a client would read the header
137
+ // as a different set of parameters.
138
+ for (const character of value) {
139
+ const code = character.codePointAt(0) ?? 0;
140
+ if (
141
+ character === '"' ||
142
+ character === "\\" ||
143
+ code < 0x20 ||
144
+ code === 0x7f
145
+ ) {
146
+ throw new Error(
147
+ `[visa] ${parameter} cannot go in a WWW-Authenticate header: it holds a quote, a backslash or a control character`,
148
+ );
149
+ }
150
+ }
151
+ return value;
152
+ }
153
+
154
+ /**
155
+ * A resource identifier is an absolute URI with no fragment (RFC 8707 §2).
156
+ *
157
+ * Checked where the metadata is built, because a resource that publishes an
158
+ * identifier its own tokens will not match is broken in a way only visible at
159
+ * the moment a client is refused.
160
+ */
161
+ export function assertResourceIdentifier(resource: string): void {
162
+ let url: URL;
163
+ try {
164
+ url = new URL(resource);
165
+ } catch {
166
+ throw new Error(`[visa] resource must be an absolute URI: ${resource}`);
167
+ }
168
+ if (url.hash !== "") {
169
+ throw new Error(`[visa] resource must not carry a fragment: ${resource}`);
170
+ }
171
+ }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Clients that register themselves (RFC 7591).
3
+ *
4
+ * An MCP client such as claude.ai or ChatGPT has nobody to fill in a form for
5
+ * it: it discovers this server, registers, and comes back with an id. Without
6
+ * this the only way in is a human pasting credentials somewhere.
7
+ *
8
+ * OFF unless the application turns it on, and that is the important part. Open
9
+ * registration means anyone who can reach the endpoint can create a client —
10
+ * the RFC says a server SHOULD allow it for interoperability, and also says
11
+ * the endpoint MAY be protected (§1.2, §3). The safe default here is the one
12
+ * the RFC permits rather than the one it encourages, because an authorization
13
+ * server that silently accepts registrations is a thing nobody asked for. Turn
14
+ * it on deliberately, and with an initial access token if the deployment is
15
+ * not meant to be open.
16
+ *
17
+ * A registered client gets exactly what this server allows: the grants,
18
+ * scopes and auth methods in the configuration, never what the request asked
19
+ * for. That is the difference between letting a client name itself and letting
20
+ * it decide what it may do.
21
+ */
22
+
23
+ import { OAuthError } from "./errors.js";
24
+ import type { Client, ClientAuthMethod, GrantType } from "./types.js";
25
+
26
+ /** The metadata a request may send (§2). Unknown members are ignored. */
27
+ export interface ClientRegistrationRequest {
28
+ redirect_uris?: unknown;
29
+ token_endpoint_auth_method?: unknown;
30
+ grant_types?: unknown;
31
+ response_types?: unknown;
32
+ client_name?: unknown;
33
+ client_uri?: unknown;
34
+ logo_uri?: unknown;
35
+ scope?: unknown;
36
+ contacts?: unknown;
37
+ tos_uri?: unknown;
38
+ policy_uri?: unknown;
39
+ software_id?: unknown;
40
+ software_version?: unknown;
41
+ software_statement?: unknown;
42
+ /**
43
+ * Whatever else the client sent.
44
+ *
45
+ * §2 says a server ignores metadata it does not understand, and this is a
46
+ * parsed HTTP body — a client that sends `client_id` hoping to pick its own
47
+ * is a real thing, and it lands here rather than being a type error nobody
48
+ * sees at run time.
49
+ */
50
+ [member: string]: unknown;
51
+ }
52
+
53
+ /** The 201 body (§3.2.1). */
54
+ export interface ClientRegistrationResponse {
55
+ client_id: string;
56
+ client_secret?: string;
57
+ /** Seconds since the epoch. */
58
+ client_id_issued_at: number;
59
+ /** REQUIRED when a secret was issued. `0` means it does not expire. */
60
+ client_secret_expires_at?: number;
61
+ redirect_uris: string[];
62
+ grant_types: GrantType[];
63
+ token_endpoint_auth_method: ClientAuthMethod;
64
+ client_name: string;
65
+ scope?: string;
66
+ }
67
+
68
+ export interface RegistrationOptions {
69
+ /** Nothing happens unless this is true. */
70
+ enabled?: boolean;
71
+ /**
72
+ * Require this bearer token on the registration request.
73
+ *
74
+ * Left out, the endpoint is open — which is what an MCP client that has
75
+ * never met you needs, and what a deployment on the public internet should
76
+ * think about before shipping.
77
+ */
78
+ initialAccessToken?: string;
79
+ /** What a self-registered client may ever ask for. Defaults to none. */
80
+ scopes?: readonly string[];
81
+ /** Which grants it may use. Defaults to the authorization code flow and refresh. */
82
+ grantTypes?: readonly GrantType[];
83
+ /** Seconds until an issued secret expires. Omitted means it does not (§3.2.1). */
84
+ secretTtlSeconds?: number;
85
+ }
86
+
87
+ const DEFAULT_GRANTS: readonly GrantType[] = [
88
+ "authorization_code",
89
+ "refresh_token",
90
+ ];
91
+
92
+ /**
93
+ * Check a registration request and say what the client may be.
94
+ *
95
+ * Returns the client to store and the plaintext secret, if one was issued —
96
+ * the caller persists the first and hands back the second, once.
97
+ */
98
+ export function validateRegistration(
99
+ request: ClientRegistrationRequest,
100
+ options: RegistrationOptions,
101
+ presentedToken?: string,
102
+ ): {
103
+ redirectUris: string[];
104
+ grantTypes: GrantType[];
105
+ scopes: string[];
106
+ tokenEndpointAuthMethod: ClientAuthMethod;
107
+ name: string;
108
+ } {
109
+ if (options.enabled !== true) {
110
+ throw new OAuthError(
111
+ "invalid_client_metadata",
112
+ "This server does not accept client registrations.",
113
+ );
114
+ }
115
+ if (options.initialAccessToken !== undefined) {
116
+ // Constant-time is not the point here: the token is compared once per
117
+ // registration, and a registration endpoint is not a guessing oracle
118
+ // worth the machinery. Absent or wrong reads the same to the caller.
119
+ if (presentedToken !== options.initialAccessToken) {
120
+ throw new OAuthError(
121
+ "invalid_client_metadata",
122
+ "Registration on this server needs an initial access token.",
123
+ );
124
+ }
125
+ }
126
+
127
+ const redirectUris = readStringList(request.redirect_uris);
128
+ const grantTypes = resolveGrants(request.grant_types, options);
129
+
130
+ // A client using the authorization code flow has somewhere to come back to,
131
+ // and this is the last moment anyone checks.
132
+ if (grantTypes.includes("authorization_code") && redirectUris.length === 0) {
133
+ throw new OAuthError(
134
+ "invalid_redirect_uri",
135
+ "redirect_uris is required for the authorization_code grant.",
136
+ );
137
+ }
138
+ for (const uri of redirectUris) assertRedirectUri(uri);
139
+
140
+ const allowed = options.scopes ?? [];
141
+ const requested = readScope(request.scope);
142
+ const outside = requested.filter((scope) => !allowed.includes(scope));
143
+ if (outside.length > 0) {
144
+ throw new OAuthError(
145
+ "invalid_client_metadata",
146
+ `This server does not grant ${outside.join(", ")} to a self-registered client.`,
147
+ );
148
+ }
149
+
150
+ return {
151
+ redirectUris,
152
+ grantTypes,
153
+ // What was asked for, narrowed to what is allowed — never the allowed
154
+ // set, or every client would be registered with everything.
155
+ scopes: requested,
156
+ tokenEndpointAuthMethod: readAuthMethod(request.token_endpoint_auth_method),
157
+ name: readName(request.client_name),
158
+ };
159
+ }
160
+
161
+ /** The 201 body for a client that was just created. */
162
+ export function registrationResponse(
163
+ client: Client,
164
+ issuedAt: Date,
165
+ secret: string | undefined,
166
+ options: RegistrationOptions,
167
+ ): ClientRegistrationResponse {
168
+ const seconds = Math.floor(issuedAt.getTime() / 1000);
169
+ return {
170
+ client_id: client.id,
171
+ ...(secret === undefined ? {} : { client_secret: secret }),
172
+ client_id_issued_at: seconds,
173
+ // REQUIRED when a secret was issued, and `0` is the spelling for "never"
174
+ // — not an omission, which would mean the field was not answered.
175
+ ...(secret === undefined
176
+ ? {}
177
+ : {
178
+ client_secret_expires_at:
179
+ options.secretTtlSeconds === undefined
180
+ ? 0
181
+ : seconds + options.secretTtlSeconds,
182
+ }),
183
+ redirect_uris: client.redirectUris,
184
+ grant_types: client.grantTypes,
185
+ token_endpoint_auth_method: client.tokenEndpointAuthMethod,
186
+ client_name: client.name,
187
+ ...(client.scopes.length === 0 ? {} : { scope: client.scopes.join(" ") }),
188
+ };
189
+ }
190
+
191
+ function resolveGrants(
192
+ value: unknown,
193
+ options: RegistrationOptions,
194
+ ): GrantType[] {
195
+ const allowed = options.grantTypes ?? DEFAULT_GRANTS;
196
+ const requested = readStringList(value);
197
+ if (requested.length === 0) return [...allowed];
198
+ const grants: GrantType[] = [];
199
+ for (const grant of requested) {
200
+ if (!isGrantType(grant) || !allowed.includes(grant)) {
201
+ throw new OAuthError(
202
+ "invalid_client_metadata",
203
+ `This server does not allow the ${grant} grant to a self-registered client.`,
204
+ );
205
+ }
206
+ if (!grants.includes(grant)) grants.push(grant);
207
+ }
208
+ return grants;
209
+ }
210
+
211
+ /**
212
+ * A redirect URI a self-registered client may use.
213
+ *
214
+ * https, or http on a loopback address — a native app's callback. Anything
215
+ * else is refused: an authorization code sent over plain http to a host
216
+ * someone else controls is the code given away.
217
+ */
218
+ function assertRedirectUri(value: string): void {
219
+ let url: URL;
220
+ try {
221
+ url = new URL(value);
222
+ } catch {
223
+ throw new OAuthError(
224
+ "invalid_redirect_uri",
225
+ `${value} is not an absolute URI.`,
226
+ );
227
+ }
228
+ if (url.hash !== "") {
229
+ throw new OAuthError(
230
+ "invalid_redirect_uri",
231
+ `${value} must not carry a fragment.`,
232
+ );
233
+ }
234
+ if (url.protocol === "https:") return;
235
+ const loopback =
236
+ url.hostname === "127.0.0.1" ||
237
+ url.hostname === "[::1]" ||
238
+ url.hostname === "localhost";
239
+ if (url.protocol === "http:" && loopback) return;
240
+ throw new OAuthError(
241
+ "invalid_redirect_uri",
242
+ `${value} must use https, or http on a loopback address.`,
243
+ );
244
+ }
245
+
246
+ function readStringList(value: unknown): string[] {
247
+ if (value === undefined || value === null) return [];
248
+ if (!Array.isArray(value)) {
249
+ throw new OAuthError(
250
+ "invalid_client_metadata",
251
+ "redirect_uris and grant_types must be arrays of strings.",
252
+ );
253
+ }
254
+ return value.filter((entry): entry is string => typeof entry === "string");
255
+ }
256
+
257
+ function readScope(value: unknown): string[] {
258
+ if (value === undefined || value === null) return [];
259
+ if (typeof value !== "string") {
260
+ throw new OAuthError(
261
+ "invalid_client_metadata",
262
+ "scope must be a space-separated string.",
263
+ );
264
+ }
265
+ return value.split(/\s+/).filter((scope) => scope !== "");
266
+ }
267
+
268
+ function readAuthMethod(value: unknown): ClientAuthMethod {
269
+ if (value === undefined || value === null) return "client_secret_basic";
270
+ if (
271
+ value === "client_secret_basic" ||
272
+ value === "client_secret_post" ||
273
+ value === "none"
274
+ ) {
275
+ return value;
276
+ }
277
+ throw new OAuthError(
278
+ "invalid_client_metadata",
279
+ `token_endpoint_auth_method ${String(value)} is not supported.`,
280
+ );
281
+ }
282
+
283
+ function readName(value: unknown): string {
284
+ if (typeof value === "string" && value.trim() !== "") return value.trim();
285
+ // A client with no name shows as one on a consent screen, which is worse
286
+ // than a placeholder nobody mistakes for a brand.
287
+ return "Unnamed client";
288
+ }
289
+
290
+ function isGrantType(value: string): value is GrantType {
291
+ return (
292
+ value === "authorization_code" ||
293
+ value === "refresh_token" ||
294
+ value === "client_credentials"
295
+ );
296
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Which resource a token is for (RFC 8707).
3
+ *
4
+ * A token with no stated audience is a token for everything. Send it to the
5
+ * wrong server — or let a compromised one replay it — and every other resource
6
+ * that trusts the same issuer accepts it. The `resource` parameter is how a
7
+ * client says "this one is for the calendar API", and how the calendar API
8
+ * refuses one minted for somebody else.
9
+ *
10
+ * Read off the RFC rather than recalled: the value MUST be an absolute URI
11
+ * without a fragment (§2), it MAY repeat, and a resource the server does not
12
+ * accept is refused with `invalid_target` (§2) — not `invalid_request`, which
13
+ * would tell a client its request was malformed when it was merely aimed
14
+ * somewhere it may not go.
15
+ */
16
+
17
+ import { OAuthError } from "./errors.js";
18
+
19
+ /**
20
+ * Normalise however the parameter arrived into a list.
21
+ *
22
+ * A form body gives one value or several under the same name, and a query
23
+ * string the same; both are `resource=` repeated, which parsers hand over
24
+ * either way.
25
+ */
26
+ export function readResourceParameter(value: unknown): string[] {
27
+ if (value === undefined || value === null) return [];
28
+ if (Array.isArray(value)) {
29
+ return value.filter((entry): entry is string => typeof entry === "string");
30
+ }
31
+ return typeof value === "string" && value !== "" ? [value] : [];
32
+ }
33
+
34
+ /**
35
+ * Check each one, and refuse the request if any is unusable.
36
+ *
37
+ * `invalid_target` for every rejection here, including a malformed URI: §2
38
+ * names that code for "cannot parse the provided value(s)" as well as for a
39
+ * resource the server will not issue for.
40
+ */
41
+ export function validateResources(
42
+ requested: readonly string[],
43
+ accepted?: readonly string[],
44
+ ): string[] {
45
+ const resources: string[] = [];
46
+ for (const value of requested) {
47
+ let url: URL;
48
+ try {
49
+ url = new URL(value);
50
+ } catch {
51
+ throw new OAuthError(
52
+ "invalid_target",
53
+ `resource must be an absolute URI: ${value}`,
54
+ );
55
+ }
56
+ if (url.hash !== "") {
57
+ throw new OAuthError(
58
+ "invalid_target",
59
+ `resource must not carry a fragment: ${value}`,
60
+ );
61
+ }
62
+ // §2 SHOULD NOT include a query, and recognises that some cases need
63
+ // one — so it travels rather than being refused or stripped. Stripping
64
+ // would hand back a token for a resource nobody asked for.
65
+ if (accepted !== undefined && !accepted.includes(value)) {
66
+ throw new OAuthError(
67
+ "invalid_target",
68
+ `this server does not issue tokens for ${value}`,
69
+ );
70
+ }
71
+ if (!resources.includes(value)) resources.push(value);
72
+ }
73
+ return resources;
74
+ }
75
+
76
+ /**
77
+ * What a token request may narrow to.
78
+ *
79
+ * A refresh or an exchange may ask for fewer resources than were authorised,
80
+ * never for more — the same rule scopes follow, and for the same reason: the
81
+ * user agreed to a set, and a later request must not widen it.
82
+ */
83
+ export function narrowResources(
84
+ requested: readonly string[],
85
+ granted: readonly string[],
86
+ ): string[] {
87
+ if (requested.length === 0) return [...granted];
88
+ if (granted.length === 0) {
89
+ // Nothing was bound at authorization time, so there is nothing to narrow
90
+ // FROM. Letting the request name one here would mint a token for an
91
+ // audience the user never saw.
92
+ throw new OAuthError(
93
+ "invalid_target",
94
+ "this grant is not bound to any resource",
95
+ );
96
+ }
97
+ const outside = requested.filter((value) => !granted.includes(value));
98
+ if (outside.length > 0) {
99
+ throw new OAuthError(
100
+ "invalid_target",
101
+ `this grant does not cover ${outside.join(", ")}`,
102
+ );
103
+ }
104
+ return [...requested];
105
+ }
106
+
107
+ /**
108
+ * Is this token for me?
109
+ *
110
+ * What a resource server asks. A token bound to nothing is accepted — that is
111
+ * every token issued before a client started sending `resource`, and refusing
112
+ * them would break each one. A token bound to SOMETHING must name this
113
+ * resource, or it was minted for another server and arriving here means it
114
+ * travelled.
115
+ */
116
+ export function audienceAllows(
117
+ audience: readonly string[] | undefined,
118
+ resource: string | undefined,
119
+ ): boolean {
120
+ if (audience === undefined || audience.length === 0) return true;
121
+ if (resource === undefined) return true;
122
+ return audience.includes(resource);
123
+ }
package/src/store.ts CHANGED
@@ -51,6 +51,44 @@ export interface VisaStore {
51
51
  findConsent(userId: string, clientId: string): Promise<Consent | null>;
52
52
  saveConsent(consent: Consent): Promise<void>;
53
53
 
54
+ /**
55
+ * Everything this user has granted, so they can be shown it.
56
+ *
57
+ * The consent is the unit a person manages: they authorised an
58
+ * application, not a token. Tokens come and go under that decision.
59
+ */
60
+ listConsents(userId: string): Promise<Consent[]>;
61
+
62
+ /**
63
+ * Every access token of this user, EXPIRED ONES INCLUDED.
64
+ *
65
+ * Included deliberately: "last used" is most interesting precisely when
66
+ * nothing is live any more, and a screen that hid them would tell a user an
67
+ * application never touched their data when it did.
68
+ */
69
+ listAccessTokens(userId: string): Promise<AccessToken[]>;
70
+
71
+ /**
72
+ * Cut one application off for one user: every token, both kinds.
73
+ *
74
+ * Not the consent — {@link deleteConsent} is separate, because forgetting
75
+ * the grant and ending the sessions are two different things and a caller
76
+ * revoking in a panic wants the second one first.
77
+ */
78
+ revokeAccessFor(userId: string, clientId: string, at: Date): Promise<void>;
79
+
80
+ /** Forget the grant, so the user is asked again next time. */
81
+ deleteConsent(userId: string, clientId: string): Promise<void>;
82
+
83
+ /**
84
+ * Record that an access token was just used.
85
+ *
86
+ * Optional, and deliberately best-effort: it is one write per authenticated
87
+ * request, which a store on a hot path may not want. A store without it
88
+ * authenticates exactly the same, it simply reports no last use.
89
+ */
90
+ touchAccessToken?(tokenHash: string, at: Date): Promise<void>;
91
+
54
92
  /** Drop what has expired. Optional: a store with a TTL does it itself. */
55
93
  prune?(now: Date): Promise<void>;
56
94
  }