@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/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/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/configure.ts CHANGED
@@ -11,8 +11,20 @@
11
11
  * session on deploy, and a consent screen nobody wrote grants silently.
12
12
  */
13
13
 
14
+ import { access, constants } from "node:fs/promises";
15
+ import { resolve } from "node:path";
14
16
  import { stubsRoot } from "./stubs.js";
15
17
 
18
+ /**
19
+ * The migration this package ships.
20
+ *
21
+ * A high, stable prefix rather than a timestamp: these five tables reference
22
+ * nothing else, so running them last is fine, and a number nobody's own
23
+ * migrations are likely to have taken means the file lands instead of silently
24
+ * losing to a name that already exists.
25
+ */
26
+ const MIGRATION_STUB = "database/migrations/9000_create_visa_tables.stub";
27
+
16
28
  interface Codemods {
17
29
  addProvider(importPath: string): Promise<void>;
18
30
  addEnvVars(vars: Record<string, string>): Promise<void>;
@@ -30,6 +42,11 @@ interface Codemods {
30
42
  }
31
43
 
32
44
  export async function configure(codemods: Codemods): Promise<void> {
45
+ // Fail fast BEFORE touching the project: a pruned tarball or an unreadable
46
+ // stub must not leave an application with a provider registered, a config
47
+ // written and no tables to put anything in.
48
+ await access(resolve(stubsRoot, MIGRATION_STUB), constants.R_OK);
49
+
33
50
  // The config below reads this, so it is declared here. Writing the file
34
51
  // without it leaves an application whose config asks the environment for
35
52
  // something nothing ever put there.
@@ -39,4 +56,8 @@ export async function configure(codemods: Codemods): Promise<void> {
39
56
 
40
57
  await codemods.addProvider("@c9up/visa/provider");
41
58
  await codemods.makeUsingStub(stubsRoot, "config/visa.stub");
59
+ // The tables the atlas store reads and writes. An application on another
60
+ // data layer deletes the file and keeps its own store — which is why this
61
+ // is a migration and not a schema the package owns.
62
+ await codemods.makeUsingStub(stubsRoot, MIGRATION_STUB);
42
63
  }
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 ADDED
@@ -0,0 +1,443 @@
1
+ /**
2
+ * `visaGuard()` — authenticating a request with an OAuth access token.
3
+ *
4
+ * Declared in `config/auth.ts` beside `jwtGuard` and `sessionGuard`, and it
5
+ * behaves like the access-tokens guard upstream: the authenticated user carries
6
+ * a `currentAccessToken` whose `abilities` are the token's scopes, with
7
+ * `allows`, `denies`, `authorize`, `isExpired()` and `lastUsedAt` answering
8
+ * exactly as they do there. `allows` treats `*` as every ability, which is the
9
+ * upstream rule read off `@adonisjs/auth`'s build rather than recalled.
10
+ *
11
+ * Two things this fixes for every application at once. A route gate no longer
12
+ * has to introspect the token itself — the scopes are on the guard's token. And
13
+ * the user is fetched through the application's own provider on every request,
14
+ * so an account disabled after the token was issued is refused here instead of
15
+ * in each application that remembers to check.
16
+ *
17
+ * NO import of warden. A guard is structurally `{ name, authenticate, verify }`
18
+ * and that is all warden asks for, so visa stays a package you can run without
19
+ * it — the same rule every other package in this universe follows.
20
+ */
21
+
22
+ import { hashSecret, randomToken } from "./crypto.js";
23
+ import { verifyAccessToken } from "./introspect.js";
24
+ import { wwwAuthenticate } from "./protectedResource.js";
25
+ import type { VisaStore } from "./store.js";
26
+
27
+ /**
28
+ * The user shape a guard hands back.
29
+ *
30
+ * `id` and the two optional arrays are what warden reads; everything else the
31
+ * application's provider returns travels with it untouched.
32
+ */
33
+ export interface GuardUser {
34
+ id: string;
35
+ roles?: string[];
36
+ permissions?: string[];
37
+ /** The token this request was authenticated with. Set by the guard. */
38
+ currentAccessToken?: VisaAccessToken;
39
+ [key: string]: unknown;
40
+ }
41
+
42
+ /**
43
+ * What the guard needs from the store, and nothing more.
44
+ *
45
+ * A narrow slice rather than the whole contract: authenticating reads one row
46
+ * and records one use, so that is what it asks for. A full {@link VisaStore}
47
+ * satisfies it, and a resource server that keeps only tokens can implement two
48
+ * methods instead of twelve.
49
+ */
50
+ export type GuardStore = Pick<
51
+ VisaStore,
52
+ | "findAccessToken"
53
+ | "touchAccessToken"
54
+ | "saveAccessToken"
55
+ | "revokeAccessToken"
56
+ >;
57
+
58
+ /** What a guard answers. Warden's `AuthResult`, declared here so visa owes it nothing. */
59
+ export interface GuardResult {
60
+ authenticated: boolean;
61
+ user?: GuardUser;
62
+ error?: string;
63
+ }
64
+
65
+ /** What a test client sends to be this user. Warden's `AuthClientResponse`. */
66
+ export interface GuardClientResponse {
67
+ headers?: Record<string, string>;
68
+ session?: Record<string, unknown>;
69
+ }
70
+
71
+ /**
72
+ * The token behind the current request.
73
+ *
74
+ * Upstream's `AccessToken`, minus what an opaque OAuth token has no equivalent
75
+ * for: there is no `identifier`/`secret` pair to decode here, because visa
76
+ * stores a hash and the plaintext never comes back.
77
+ */
78
+ export class VisaAccessToken {
79
+ /** The client the token was issued to — an OAuth token always has one. */
80
+ readonly clientId: string;
81
+ /** Absent for `client_credentials`: there is no user behind it. */
82
+ readonly userId?: string;
83
+ /** The token's scopes. Named `abilities` for the upstream surface. */
84
+ readonly abilities: string[];
85
+ /**
86
+ * A recognisable name, as upstream carries one.
87
+ *
88
+ * An OAuth token is not named by the user who holds it — it is named by the
89
+ * client it was issued to — so this is the client's name when the store
90
+ * knows it, and null otherwise.
91
+ */
92
+ readonly name: string | null;
93
+ readonly expiresAt: Date | null;
94
+ readonly lastUsedAt: Date | null;
95
+ /**
96
+ * The token itself, and only just after it was created.
97
+ *
98
+ * Upstream's `value`: the one moment the plaintext exists is when it is
99
+ * minted, because what is stored is a hash. A token read back from the
100
+ * store never has it, and that is not a gap — it is why a database copy is
101
+ * not a set of working tokens.
102
+ */
103
+ readonly value?: string;
104
+
105
+ constructor(attributes: {
106
+ clientId: string;
107
+ userId?: string;
108
+ abilities: string[];
109
+ name?: string | null;
110
+ expiresAt?: Date | null;
111
+ lastUsedAt?: Date | null;
112
+ value?: string;
113
+ }) {
114
+ this.clientId = attributes.clientId;
115
+ if (attributes.userId !== undefined) this.userId = attributes.userId;
116
+ this.abilities = [...attributes.abilities];
117
+ this.name = attributes.name ?? null;
118
+ this.expiresAt = attributes.expiresAt ?? null;
119
+ this.lastUsedAt = attributes.lastUsedAt ?? null;
120
+ if (attributes.value !== undefined) this.value = attributes.value;
121
+ }
122
+
123
+ /** `*` means every ability — upstream's rule, read off its build. */
124
+ allows(ability: string): boolean {
125
+ return this.abilities.includes(ability) || this.abilities.includes("*");
126
+ }
127
+
128
+ denies(ability: string): boolean {
129
+ return !this.allows(ability);
130
+ }
131
+
132
+ /**
133
+ * Throw unless the token carries `ability`.
134
+ *
135
+ * The upstream error id verbatim, so a handler that already maps
136
+ * `E_UNAUTHORIZED_ACCESS` keeps working.
137
+ */
138
+ authorize(ability: string): void {
139
+ if (this.allows(ability)) return;
140
+ const error = new Error(
141
+ `Access denied: the token does not carry "${ability}"`,
142
+ ) as Error & { code: string; status: number };
143
+ error.code = "E_UNAUTHORIZED_ACCESS";
144
+ error.status = 403;
145
+ throw error;
146
+ }
147
+
148
+ /** A token with no expiry never expires. */
149
+ isExpired(): boolean {
150
+ if (this.expiresAt === null) return false;
151
+ return this.expiresAt.getTime() <= Date.now();
152
+ }
153
+
154
+ /** Upstream's keys, plus the client an OAuth token always belongs to. */
155
+ toJSON(): {
156
+ type: "bearer";
157
+ name: string | null;
158
+ token: string | undefined;
159
+ clientId: string;
160
+ abilities: string[];
161
+ lastUsedAt: Date | null;
162
+ expiresAt: Date | null;
163
+ } {
164
+ return {
165
+ type: "bearer",
166
+ name: this.name,
167
+ // Present exactly once, on the token `createToken` just returned —
168
+ // which is the only time a caller can hand it to anybody.
169
+ token: this.value,
170
+ clientId: this.clientId,
171
+ abilities: this.abilities,
172
+ lastUsedAt: this.lastUsedAt,
173
+ expiresAt: this.expiresAt,
174
+ };
175
+ }
176
+ }
177
+
178
+ /** An hour, as the token endpoint uses by default. */
179
+ const DEFAULT_TTL = 3600;
180
+
181
+ export interface VisaGuardConfig {
182
+ /**
183
+ * Where the tokens live. A function, so the guard can be built in
184
+ * `config/auth.ts` before the provider has seated the manager — config is
185
+ * read at boot, and the store exists a moment later.
186
+ */
187
+ store: GuardStore | (() => GuardStore);
188
+ /**
189
+ * The application's user provider — upstream's `tokensUserProvider({ model })`.
190
+ *
191
+ * Called on every authenticated request, deliberately: a token issued to an
192
+ * account that has since been disabled must stop working, and the only way
193
+ * to know is to ask. Answer `null` and the request is refused.
194
+ */
195
+ findUser: (userId: string) => Promise<GuardUser | null>;
196
+ /**
197
+ * What a `client_credentials` token authenticates as, when anything.
198
+ *
199
+ * There is no user behind that grant, so by default such a token does not
200
+ * authenticate a user at all and the guard refuses. An application that
201
+ * serves machine callers supplies this and gets a subject of its choosing.
202
+ */
203
+ findClientSubject?: (clientId: string) => Promise<GuardUser | null>;
204
+ /** Guard name, for `auth.use(name)`. Defaults to `visa`. */
205
+ name?: string;
206
+ /**
207
+ * The client a token minted by {@link VisaGuard.createToken} belongs to.
208
+ *
209
+ * Upstream's guard needs no such thing because it has no clients. Here
210
+ * every token is issued TO somebody, and one with no client could not be
211
+ * introspected, revoked by its owner, or listed on a "connected
212
+ * applications" screen. Name the first-party application and a minted
213
+ * token is an ordinary token in every other respect.
214
+ */
215
+ tokenClientId?: string;
216
+ /** How long a minted token lasts — upstream's `expiresIn`, in seconds. */
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;
226
+ }
227
+
228
+ /**
229
+ * A guard, in the shape warden's config expects.
230
+ *
231
+ * Shared per application, so it holds no per-request state — the same rule
232
+ * warden states for its own strategies.
233
+ */
234
+ export class VisaGuard {
235
+ readonly name: string;
236
+ readonly #config: VisaGuardConfig;
237
+
238
+ constructor(config: VisaGuardConfig) {
239
+ this.#config = config;
240
+ this.name = config.name ?? "visa";
241
+ }
242
+
243
+ #store(): GuardStore {
244
+ const store = this.#config.store;
245
+ return typeof store === "function" ? store() : store;
246
+ }
247
+
248
+ /**
249
+ * There is no password flow here, and OAuth 2.1 removed the one there used
250
+ * to be. A token comes from the token endpoint.
251
+ *
252
+ * THROWS rather than answering "not authenticated", which is what warden's
253
+ * own access-tokens guard does: a bearer guard handed an email and a
254
+ * password is a misconfigured application, not a bad credential, and the two
255
+ * must not produce the same 401.
256
+ */
257
+ async authenticate(): Promise<GuardResult> {
258
+ throw new Error(
259
+ "visa authenticates a bearer token, not credentials. Use verify() with the access token.",
260
+ );
261
+ }
262
+
263
+ /**
264
+ * Mint a token for `user`, as upstream's guard does.
265
+ *
266
+ * The token is a real one: stored hashed, listed, introspectable,
267
+ * revocable. What it skips is the authorization code flow, so it is for a
268
+ * FIRST-PARTY client you own — `tokenClientId` — and nothing here should
269
+ * ever be handed a third-party client id.
270
+ */
271
+ async createToken(
272
+ user: { id: string } | string,
273
+ abilities: string[] = ["*"],
274
+ options: { name?: string; expiresInSeconds?: number } = {},
275
+ ): Promise<VisaAccessToken> {
276
+ const clientId = this.#config.tokenClientId;
277
+ if (clientId === undefined) {
278
+ throw new Error(
279
+ "[visa] createToken needs `tokenClientId` — a token is always issued to a client. Name the first-party application in visaGuard({ tokenClientId }).",
280
+ );
281
+ }
282
+ const userId = typeof user === "string" ? user : user.id;
283
+ const ttl =
284
+ options.expiresInSeconds ?? this.#config.expiresInSeconds ?? DEFAULT_TTL;
285
+ const secret = randomToken();
286
+ const expiresAt = new Date(Date.now() + ttl * 1000);
287
+
288
+ await this.#store().saveAccessToken({
289
+ tokenHash: hashSecret(secret),
290
+ clientId,
291
+ userId,
292
+ scopes: abilities,
293
+ expiresAt,
294
+ });
295
+
296
+ return new VisaAccessToken({
297
+ clientId,
298
+ userId,
299
+ abilities,
300
+ name: options.name ?? null,
301
+ expiresAt,
302
+ // The one moment the plaintext exists.
303
+ value: secret,
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Revoke a token — upstream's `invalidateToken`.
309
+ *
310
+ * NAMED DEVIATION — upstream's guard is built per request and invalidates
311
+ * the token it just authenticated with, so it takes no argument. A warden
312
+ * strategy is shared by the whole application and holds no per-request
313
+ * state, by warden's own rule, so the token to revoke is passed in. A
314
+ * sign-out handler has it: it is the bearer credential of the request.
315
+ *
316
+ * Answers whether the token was there to revoke.
317
+ */
318
+ async invalidateToken(presented: string): Promise<boolean> {
319
+ const store = this.#store();
320
+ const hash = hashSecret(presented);
321
+ const existing = await store.findAccessToken(hash);
322
+ if (existing === null || existing.revokedAt !== undefined) return false;
323
+ await store.revokeAccessToken(hash, new Date());
324
+ return true;
325
+ }
326
+
327
+ /**
328
+ * What a test client sends to be `user` — upstream's signature.
329
+ *
330
+ * It mints a token, as upstream does, so a test authenticates the way the
331
+ * application will rather than against a credential the store has never
332
+ * seen.
333
+ */
334
+ async authenticateAsClient(
335
+ user: { id: string } | string,
336
+ abilities: string[] = ["*"],
337
+ options: { name?: string; expiresInSeconds?: number } = {},
338
+ ): Promise<GuardClientResponse> {
339
+ const token = await this.createToken(user, abilities, options);
340
+ return { headers: { authorization: `Bearer ${token.value}` } };
341
+ }
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
+
364
+ async verify(presented: string): Promise<GuardResult> {
365
+ const store = this.#store();
366
+ const now = new Date();
367
+ const verified = await verifyAccessToken(presented, store, now);
368
+ if (verified === null) {
369
+ return { authenticated: false, error: "Invalid or expired access token" };
370
+ }
371
+
372
+ const subject =
373
+ verified.userId === undefined
374
+ ? await this.#config.findClientSubject?.(verified.clientId)
375
+ : await this.#config.findUser(verified.userId);
376
+
377
+ if (subject === null || subject === undefined) {
378
+ // The account is gone or disabled since the token was minted. Refusing
379
+ // here is the point of asking on every request.
380
+ return {
381
+ authenticated: false,
382
+ error:
383
+ verified.userId === undefined
384
+ ? "This token authenticates no user"
385
+ : "The account behind this token is no longer available",
386
+ };
387
+ }
388
+
389
+ // The PREVIOUS use is what the token reports, and this one is recorded
390
+ // after — the same order upstream reads its row in.
391
+ await touch(store, presented, now);
392
+
393
+ const token = new VisaAccessToken({
394
+ clientId: verified.clientId,
395
+ ...(verified.userId === undefined ? {} : { userId: verified.userId }),
396
+ abilities: verified.scopes,
397
+ expiresAt: verified.expiresAt ?? null,
398
+ lastUsedAt: verified.lastUsedAt ?? null,
399
+ });
400
+
401
+ // A copy: the provider's object is the application's, and a guard that
402
+ // mutated it would leak one request's token into the next.
403
+ const user: GuardUser = { ...subject, currentAccessToken: token };
404
+ if (verified.scopes.length > 0) {
405
+ // Scopes surface on `permissions` as well, the way warden's access-token
406
+ // guard does it, so `ctx.auth.user.permissions` reads the same whichever
407
+ // guard authenticated.
408
+ //
409
+ // They remain a SEPARATE axis from the route-level rights gate, exactly
410
+ // as upstream: a token ability is checked with
411
+ // `currentAccessToken.allows(...)`, and Bouncer does not consult it.
412
+ user.permissions = [
413
+ ...new Set([...(subject.permissions ?? []), ...verified.scopes]),
414
+ ];
415
+ }
416
+ return { authenticated: true, user };
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Record that the token was just used.
422
+ *
423
+ * Never fatal: a store with no `touchAccessToken` — the contract shipped
424
+ * without one — still authenticates, it simply reports no last use.
425
+ */
426
+ async function touch(
427
+ store: GuardStore,
428
+ presented: string,
429
+ now: Date,
430
+ ): Promise<void> {
431
+ if (store.touchAccessToken === undefined) return;
432
+ try {
433
+ await store.touchAccessToken(hashSecret(presented), now);
434
+ } catch {
435
+ // Recording the use is bookkeeping; failing it must not refuse a request
436
+ // whose token is valid.
437
+ }
438
+ }
439
+
440
+ /** Build a visa guard from its config — `visaGuard({...})` in `config/auth.ts`. */
441
+ export function visaGuard(config: VisaGuardConfig): VisaGuard {
442
+ return new VisaGuard(config);
443
+ }
package/src/index.ts CHANGED
@@ -36,10 +36,44 @@ 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";
43
+ export {
44
+ type GuardClientResponse,
45
+ type GuardResult,
46
+ type GuardStore,
47
+ type GuardUser,
48
+ VisaAccessToken,
49
+ VisaGuard,
50
+ type VisaGuardConfig,
51
+ visaGuard,
52
+ } from "./guard.js";
42
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";
43
77
  export {
44
78
  clearVisa,
45
79
  default as visa,
@@ -47,11 +81,18 @@ export {
47
81
  setVisa,
48
82
  } from "./services/main.js";
49
83
  export type { VisaStore } from "./store.js";
84
+ export {
85
+ type AtlasDb,
86
+ type AtlasQuery,
87
+ AtlasStore,
88
+ type AtlasStoreTables,
89
+ } from "./stores/atlas.js";
50
90
  export { MemoryStore } from "./stores/memory.js";
51
91
  export { type TokenOptions, type TokenRequest, token } from "./token.js";
52
92
  export type {
53
93
  AccessToken,
54
94
  AuthorizationCode,
95
+ AuthorizedApplication,
55
96
  Client,
56
97
  ClientAuthMethod,
57
98
  Consent,