@c9up/visa 0.1.0 → 0.1.1

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