@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/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +19 -0
- package/dist/configure.js.map +1 -1
- package/dist/guard.d.ts +222 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +274 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts +3 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +10 -1
- package/dist/introspect.js.map +1 -1
- package/dist/store.d.ts +8 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/atlas.d.ts +99 -0
- package/dist/stores/atlas.d.ts.map +1 -0
- package/dist/stores/atlas.js +354 -0
- package/dist/stores/atlas.js.map +1 -0
- package/dist/stores/memory.d.ts +1 -0
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +5 -0
- package/dist/stores/memory.js.map +1 -1
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/configure.ts +21 -0
- package/src/guard.ts +413 -0
- package/src/index.ts +16 -0
- package/src/introspect.ts +16 -2
- package/src/store.ts +9 -0
- package/src/stores/atlas.ts +430 -0
- package/src/stores/memory.ts +5 -0
- package/src/types.ts +7 -0
- package/stubs/database/migrations/9000_create_visa_tables.stub +103 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The store an application running atlas gets for free.
|
|
3
|
+
*
|
|
4
|
+
* Twelve methods, two of them atomic, is not something every application
|
|
5
|
+
* should write again — and the two that matter are exactly the ones a
|
|
6
|
+
* hand-written store gets subtly wrong. This is that code, once.
|
|
7
|
+
*
|
|
8
|
+
* It takes atlas' `db` service rather than importing atlas: the structural
|
|
9
|
+
* slice below is what the store calls and nothing more, so visa still installs
|
|
10
|
+
* without a data layer and the package stays as standalone as every other one
|
|
11
|
+
* here. Built on atlas' query builder rather than on SQL text, deliberately —
|
|
12
|
+
* dialects are atlas' problem, and it already solves them.
|
|
13
|
+
*
|
|
14
|
+
* Every credential arrives HASHED, as the contract states, so what this writes
|
|
15
|
+
* is what the rest of the package writes: a database copy is not a set of
|
|
16
|
+
* working tokens.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { VisaStore } from "../store.js";
|
|
20
|
+
import type {
|
|
21
|
+
AccessToken,
|
|
22
|
+
AuthorizationCode,
|
|
23
|
+
Client,
|
|
24
|
+
ClientAuthMethod,
|
|
25
|
+
Consent,
|
|
26
|
+
GrantType,
|
|
27
|
+
RefreshToken,
|
|
28
|
+
} from "../types.js";
|
|
29
|
+
|
|
30
|
+
/** A row as atlas hands it back. */
|
|
31
|
+
type Row = Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
/** The comparisons atlas' builder accepts. */
|
|
34
|
+
type Operator = "=" | "!=" | ">" | ">=" | "<" | "<=";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The slice of atlas' `DatabaseQueryBuilder` this store uses.
|
|
38
|
+
*
|
|
39
|
+
* Every read here fetches one row, so the slice asks for `first()` and not for
|
|
40
|
+
* the builder's thenable form. Nothing is declared that nothing calls.
|
|
41
|
+
*/
|
|
42
|
+
export interface AtlasQuery {
|
|
43
|
+
where(conditions: Row): AtlasQuery;
|
|
44
|
+
where(column: string, operator: Operator, value: unknown): AtlasQuery;
|
|
45
|
+
whereNull(column: string): AtlasQuery;
|
|
46
|
+
orderBy(column: string, direction?: "asc" | "desc"): AtlasQuery;
|
|
47
|
+
first(): Promise<Row | null>;
|
|
48
|
+
insert(data: Row): PromiseLike<unknown>;
|
|
49
|
+
/** Resolves to the number of rows affected, which is what the atomic paths read. */
|
|
50
|
+
update(data: Row): PromiseLike<number | Row[]>;
|
|
51
|
+
delete(): PromiseLike<number | Row[]>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The slice of atlas' `db` service this store uses. */
|
|
55
|
+
export interface AtlasDb {
|
|
56
|
+
from(table: string): AtlasQuery;
|
|
57
|
+
table(table: string): AtlasQuery;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Where each kind of row lives. Override one to fit a schema you do not own. */
|
|
61
|
+
export interface AtlasStoreTables {
|
|
62
|
+
clients?: string;
|
|
63
|
+
authorizationCodes?: string;
|
|
64
|
+
accessTokens?: string;
|
|
65
|
+
refreshTokens?: string;
|
|
66
|
+
consents?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_TABLES = {
|
|
70
|
+
clients: "visa_clients",
|
|
71
|
+
authorizationCodes: "visa_authorization_codes",
|
|
72
|
+
accessTokens: "visa_access_tokens",
|
|
73
|
+
refreshTokens: "visa_refresh_tokens",
|
|
74
|
+
consents: "visa_consents",
|
|
75
|
+
} as const;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A list of strings, stored as JSON text.
|
|
79
|
+
*
|
|
80
|
+
* Text and not a native array type: three dialects are supported and only one
|
|
81
|
+
* of them has arrays. The round trip is explicit so a malformed value fails on
|
|
82
|
+
* read rather than becoming `[]` — a scope list that quietly empties is an
|
|
83
|
+
* authorisation bug, not a display one.
|
|
84
|
+
*/
|
|
85
|
+
function encodeList(values: readonly string[]): string {
|
|
86
|
+
return JSON.stringify(values);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function decodeList(value: unknown, column: string): string[] {
|
|
90
|
+
if (value === null || value === undefined) return [];
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
// A `json` column comes back parsed on some dialects and as text on others.
|
|
93
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
94
|
+
}
|
|
95
|
+
if (typeof value !== "string") {
|
|
96
|
+
throw new Error(`[visa] ${column} holds ${typeof value}, expected a list`);
|
|
97
|
+
}
|
|
98
|
+
const parsed: unknown = JSON.parse(value);
|
|
99
|
+
if (!Array.isArray(parsed)) {
|
|
100
|
+
throw new Error(`[visa] ${column} is not a JSON array: ${value}`);
|
|
101
|
+
}
|
|
102
|
+
return parsed.filter((entry): entry is string => typeof entry === "string");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A column that may be null, as a Date. */
|
|
106
|
+
function decodeDate(value: unknown): Date | undefined {
|
|
107
|
+
if (value === null || value === undefined) return undefined;
|
|
108
|
+
if (value instanceof Date) return value;
|
|
109
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
110
|
+
const date = new Date(value);
|
|
111
|
+
if (!Number.isNaN(date.getTime())) return date;
|
|
112
|
+
}
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A column that must hold a date. */
|
|
117
|
+
function requireDate(value: unknown, column: string): Date {
|
|
118
|
+
const date = decodeDate(value);
|
|
119
|
+
if (date === undefined) {
|
|
120
|
+
throw new Error(`[visa] ${column} is not a date: ${String(value)}`);
|
|
121
|
+
}
|
|
122
|
+
return date;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function requireString(value: unknown, column: string): string {
|
|
126
|
+
if (typeof value !== "string") {
|
|
127
|
+
throw new Error(`[visa] ${column} is not a string: ${String(value)}`);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function optionalString(value: unknown): string | undefined {
|
|
133
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* How many rows a write touched.
|
|
138
|
+
*
|
|
139
|
+
* `update()` answers a count, or the rows themselves when the caller asked for
|
|
140
|
+
* them. Only the count is ever asked for here, and the other shape is read as
|
|
141
|
+
* its length rather than assumed away.
|
|
142
|
+
*/
|
|
143
|
+
function affected(result: number | Row[]): number {
|
|
144
|
+
return typeof result === "number" ? result : result.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class AtlasStore implements VisaStore {
|
|
148
|
+
readonly #db: AtlasDb;
|
|
149
|
+
readonly #tables: Required<AtlasStoreTables>;
|
|
150
|
+
|
|
151
|
+
constructor(db: AtlasDb, tables: AtlasStoreTables = {}) {
|
|
152
|
+
this.#db = db;
|
|
153
|
+
this.#tables = {
|
|
154
|
+
clients: tables.clients ?? DEFAULT_TABLES.clients,
|
|
155
|
+
authorizationCodes:
|
|
156
|
+
tables.authorizationCodes ?? DEFAULT_TABLES.authorizationCodes,
|
|
157
|
+
accessTokens: tables.accessTokens ?? DEFAULT_TABLES.accessTokens,
|
|
158
|
+
refreshTokens: tables.refreshTokens ?? DEFAULT_TABLES.refreshTokens,
|
|
159
|
+
consents: tables.consents ?? DEFAULT_TABLES.consents,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async findClient(id: string): Promise<Client | null> {
|
|
164
|
+
const row = await this.#db.from(this.#tables.clients).where({ id }).first();
|
|
165
|
+
if (row === null) return null;
|
|
166
|
+
const secretHash = optionalString(row.secret_hash);
|
|
167
|
+
return {
|
|
168
|
+
id: requireString(row.id, "id"),
|
|
169
|
+
name: requireString(row.name, "name"),
|
|
170
|
+
...(secretHash === undefined ? {} : { secretHash }),
|
|
171
|
+
redirectUris: decodeList(row.redirect_uris, "redirect_uris"),
|
|
172
|
+
grantTypes: decodeList(row.grant_types, "grant_types").filter(
|
|
173
|
+
isGrantType,
|
|
174
|
+
),
|
|
175
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
176
|
+
tokenEndpointAuthMethod: readAuthMethod(row.token_endpoint_auth_method),
|
|
177
|
+
...(row.trusted === true || row.trusted === 1 ? { trusted: true } : {}),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Persist a registered client. Not on the contract — what `registerClient` writes through. */
|
|
182
|
+
async saveClient(client: Client): Promise<void> {
|
|
183
|
+
await this.#db.table(this.#tables.clients).insert({
|
|
184
|
+
id: client.id,
|
|
185
|
+
name: client.name,
|
|
186
|
+
secret_hash: client.secretHash ?? null,
|
|
187
|
+
redirect_uris: encodeList(client.redirectUris),
|
|
188
|
+
grant_types: encodeList(client.grantTypes),
|
|
189
|
+
scopes: encodeList(client.scopes),
|
|
190
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
191
|
+
trusted: client.trusted === true,
|
|
192
|
+
created_at: new Date(),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async saveAuthorizationCode(code: AuthorizationCode): Promise<void> {
|
|
197
|
+
await this.#db.table(this.#tables.authorizationCodes).insert({
|
|
198
|
+
code_hash: code.codeHash,
|
|
199
|
+
client_id: code.clientId,
|
|
200
|
+
user_id: code.userId,
|
|
201
|
+
redirect_uri: code.redirectUri,
|
|
202
|
+
scopes: encodeList(code.scopes),
|
|
203
|
+
code_challenge: code.codeChallenge,
|
|
204
|
+
code_challenge_method: code.codeChallengeMethod,
|
|
205
|
+
expires_at: code.expiresAt,
|
|
206
|
+
consumed_at: code.consumedAt ?? null,
|
|
207
|
+
nonce: code.nonce ?? null,
|
|
208
|
+
created_at: new Date(),
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async findAuthorizationCode(
|
|
213
|
+
codeHash: string,
|
|
214
|
+
): Promise<AuthorizationCode | null> {
|
|
215
|
+
const row = await this.#db
|
|
216
|
+
.from(this.#tables.authorizationCodes)
|
|
217
|
+
.where({ code_hash: codeHash })
|
|
218
|
+
.first();
|
|
219
|
+
if (row === null) return null;
|
|
220
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
221
|
+
const nonce = optionalString(row.nonce);
|
|
222
|
+
return {
|
|
223
|
+
codeHash: requireString(row.code_hash, "code_hash"),
|
|
224
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
225
|
+
userId: requireString(row.user_id, "user_id"),
|
|
226
|
+
redirectUri: requireString(row.redirect_uri, "redirect_uri"),
|
|
227
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
228
|
+
codeChallenge: requireString(row.code_challenge, "code_challenge"),
|
|
229
|
+
codeChallengeMethod:
|
|
230
|
+
row.code_challenge_method === "plain" ? "plain" : "S256",
|
|
231
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
232
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
233
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* One UPDATE, guarded by `consumed_at IS NULL`.
|
|
239
|
+
*
|
|
240
|
+
* The single-use guarantee lives in that one statement: two requests racing
|
|
241
|
+
* with the same code both reach the database, and only the one that finds
|
|
242
|
+
* the row still unconsumed is allowed to write it. Reading and then writing
|
|
243
|
+
* would let both through.
|
|
244
|
+
*/
|
|
245
|
+
async consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean> {
|
|
246
|
+
const result = await this.#db
|
|
247
|
+
.from(this.#tables.authorizationCodes)
|
|
248
|
+
.where({ code_hash: codeHash })
|
|
249
|
+
.whereNull("consumed_at")
|
|
250
|
+
.update({ consumed_at: at });
|
|
251
|
+
return affected(result) === 1;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async saveAccessToken(token: AccessToken): Promise<void> {
|
|
255
|
+
await this.#db.table(this.#tables.accessTokens).insert({
|
|
256
|
+
token_hash: token.tokenHash,
|
|
257
|
+
client_id: token.clientId,
|
|
258
|
+
user_id: token.userId ?? null,
|
|
259
|
+
scopes: encodeList(token.scopes),
|
|
260
|
+
expires_at: token.expiresAt,
|
|
261
|
+
revoked_at: token.revokedAt ?? null,
|
|
262
|
+
last_used_at: token.lastUsedAt ?? null,
|
|
263
|
+
family_id: token.familyId ?? null,
|
|
264
|
+
created_at: new Date(),
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async findAccessToken(tokenHash: string): Promise<AccessToken | null> {
|
|
269
|
+
const row = await this.#db
|
|
270
|
+
.from(this.#tables.accessTokens)
|
|
271
|
+
.where({ token_hash: tokenHash })
|
|
272
|
+
.first();
|
|
273
|
+
if (row === null) return null;
|
|
274
|
+
const userId = optionalString(row.user_id);
|
|
275
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
276
|
+
const lastUsedAt = decodeDate(row.last_used_at);
|
|
277
|
+
const familyId = optionalString(row.family_id);
|
|
278
|
+
return {
|
|
279
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
280
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
281
|
+
...(userId === undefined ? {} : { userId }),
|
|
282
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
283
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
284
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
285
|
+
...(lastUsedAt === undefined ? {} : { lastUsedAt }),
|
|
286
|
+
...(familyId === undefined ? {} : { familyId }),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async revokeAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
291
|
+
await this.#db
|
|
292
|
+
.from(this.#tables.accessTokens)
|
|
293
|
+
.where({ token_hash: tokenHash })
|
|
294
|
+
.whereNull("revoked_at")
|
|
295
|
+
.update({ revoked_at: at });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
299
|
+
await this.#db
|
|
300
|
+
.from(this.#tables.accessTokens)
|
|
301
|
+
.where({ token_hash: tokenHash })
|
|
302
|
+
.update({ last_used_at: at });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async saveRefreshToken(token: RefreshToken): Promise<void> {
|
|
306
|
+
await this.#db.table(this.#tables.refreshTokens).insert({
|
|
307
|
+
token_hash: token.tokenHash,
|
|
308
|
+
family_id: token.familyId,
|
|
309
|
+
client_id: token.clientId,
|
|
310
|
+
user_id: token.userId ?? null,
|
|
311
|
+
scopes: encodeList(token.scopes),
|
|
312
|
+
expires_at: token.expiresAt,
|
|
313
|
+
consumed_at: token.consumedAt ?? null,
|
|
314
|
+
revoked_at: token.revokedAt ?? null,
|
|
315
|
+
created_at: new Date(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async findRefreshToken(tokenHash: string): Promise<RefreshToken | null> {
|
|
320
|
+
const row = await this.#db
|
|
321
|
+
.from(this.#tables.refreshTokens)
|
|
322
|
+
.where({ token_hash: tokenHash })
|
|
323
|
+
.first();
|
|
324
|
+
if (row === null) return null;
|
|
325
|
+
const userId = optionalString(row.user_id);
|
|
326
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
327
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
328
|
+
return {
|
|
329
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
330
|
+
familyId: requireString(row.family_id, "family_id"),
|
|
331
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
332
|
+
...(userId === undefined ? {} : { userId }),
|
|
333
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
334
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
335
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
336
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Same one-statement guarantee as a code: rotation depends on it. */
|
|
341
|
+
async consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean> {
|
|
342
|
+
const result = await this.#db
|
|
343
|
+
.from(this.#tables.refreshTokens)
|
|
344
|
+
.where({ token_hash: tokenHash })
|
|
345
|
+
.whereNull("consumed_at")
|
|
346
|
+
.update({ consumed_at: at });
|
|
347
|
+
return affected(result) === 1;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* End a family: the refresh tokens AND the access tokens they bought.
|
|
352
|
+
*
|
|
353
|
+
* Both, because revoking only the refresh half would leave an access token
|
|
354
|
+
* already minted alive for its full lifetime — which is most of what this
|
|
355
|
+
* revocation exists to prevent.
|
|
356
|
+
*/
|
|
357
|
+
async revokeFamily(familyId: string, at: Date): Promise<void> {
|
|
358
|
+
await this.#db
|
|
359
|
+
.from(this.#tables.refreshTokens)
|
|
360
|
+
.where({ family_id: familyId })
|
|
361
|
+
.whereNull("revoked_at")
|
|
362
|
+
.update({ revoked_at: at });
|
|
363
|
+
await this.#db
|
|
364
|
+
.from(this.#tables.accessTokens)
|
|
365
|
+
.where({ family_id: familyId })
|
|
366
|
+
.whereNull("revoked_at")
|
|
367
|
+
.update({ revoked_at: at });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async findConsent(userId: string, clientId: string): Promise<Consent | null> {
|
|
371
|
+
const row = await this.#db
|
|
372
|
+
.from(this.#tables.consents)
|
|
373
|
+
.where({ user_id: userId, client_id: clientId })
|
|
374
|
+
.first();
|
|
375
|
+
if (row === null) return null;
|
|
376
|
+
return {
|
|
377
|
+
userId: requireString(row.user_id, "user_id"),
|
|
378
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
379
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
380
|
+
grantedAt: requireDate(row.granted_at, "granted_at"),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Record a consent, replacing what was there.
|
|
386
|
+
*
|
|
387
|
+
* Delete then insert rather than an upsert: the dialects spell an upsert
|
|
388
|
+
* three different ways, and re-granting is not a hot path.
|
|
389
|
+
*/
|
|
390
|
+
async saveConsent(consent: Consent): Promise<void> {
|
|
391
|
+
await this.#db
|
|
392
|
+
.from(this.#tables.consents)
|
|
393
|
+
.where({ user_id: consent.userId, client_id: consent.clientId })
|
|
394
|
+
.delete();
|
|
395
|
+
await this.#db.table(this.#tables.consents).insert({
|
|
396
|
+
user_id: consent.userId,
|
|
397
|
+
client_id: consent.clientId,
|
|
398
|
+
scopes: encodeList(consent.scopes),
|
|
399
|
+
granted_at: consent.grantedAt,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Drop what has expired and can no longer be presented. */
|
|
404
|
+
async prune(now: Date): Promise<void> {
|
|
405
|
+
for (const table of [
|
|
406
|
+
this.#tables.authorizationCodes,
|
|
407
|
+
this.#tables.accessTokens,
|
|
408
|
+
this.#tables.refreshTokens,
|
|
409
|
+
]) {
|
|
410
|
+
await this.#db.from(table).where("expires_at", "<", now).delete();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isGrantType(value: string): value is GrantType {
|
|
416
|
+
return (
|
|
417
|
+
value === "authorization_code" ||
|
|
418
|
+
value === "refresh_token" ||
|
|
419
|
+
value === "client_credentials"
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function readAuthMethod(value: unknown): ClientAuthMethod {
|
|
424
|
+
if (value === "client_secret_basic" || value === "client_secret_post") {
|
|
425
|
+
return value;
|
|
426
|
+
}
|
|
427
|
+
// A row holding nothing usable is read as a public client, which is the
|
|
428
|
+
// method that grants the least.
|
|
429
|
+
return "none";
|
|
430
|
+
}
|
package/src/stores/memory.ts
CHANGED
|
@@ -70,6 +70,11 @@ export class MemoryStore implements VisaStore {
|
|
|
70
70
|
if (token !== undefined) token.revokedAt = at;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
74
|
+
const token = this.#accessTokens.get(tokenHash);
|
|
75
|
+
if (token !== undefined) token.lastUsedAt = at;
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
async saveRefreshToken(token: RefreshToken): Promise<void> {
|
|
74
79
|
this.#refreshTokens.set(token.tokenHash, token);
|
|
75
80
|
}
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,13 @@ export interface AccessToken {
|
|
|
69
69
|
scopes: string[];
|
|
70
70
|
expiresAt: Date;
|
|
71
71
|
revokedAt?: Date;
|
|
72
|
+
/**
|
|
73
|
+
* When this token was last presented, if the store records it.
|
|
74
|
+
*
|
|
75
|
+
* What an "Applications connectées" screen shows next to each authorisation,
|
|
76
|
+
* and what tells a dormant token from a live one.
|
|
77
|
+
*/
|
|
78
|
+
lastUsedAt?: Date;
|
|
72
79
|
/**
|
|
73
80
|
* The refresh family this token was minted in, when there is one.
|
|
74
81
|
*
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
---
|
|
2
|
+
to: database/migrations/9000_create_visa_tables.ts
|
|
3
|
+
---
|
|
4
|
+
import { Migration } from "@c9up/atlas";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* What the authorization server keeps.
|
|
8
|
+
*
|
|
9
|
+
* Every credential column holds a HASH, never the value that was handed out —
|
|
10
|
+
* a dump of these tables is not a set of working tokens.
|
|
11
|
+
*
|
|
12
|
+
* Two portability rules are followed deliberately and should survive an edit:
|
|
13
|
+
*
|
|
14
|
+
* No `t.timestamps()`. It emits `DEFAULT (NOW())`, which is not a sqlite
|
|
15
|
+
* function and fails at `migration:run` on a fresh sqlite app. Every timestamp
|
|
16
|
+
* here is written explicitly by the store instead.
|
|
17
|
+
*
|
|
18
|
+
* Every string that is a primary key or an index stays well inside MySQL's
|
|
19
|
+
* InnoDB utf8mb4 limit of 3072 bytes (768 characters). A hash is 64 characters
|
|
20
|
+
* of hex; an identifier is capped at 255.
|
|
21
|
+
*/
|
|
22
|
+
export default class CreateVisaTables extends Migration {
|
|
23
|
+
async up() {
|
|
24
|
+
this.schema.createTable("visa_clients", (t) => {
|
|
25
|
+
t.string("id", 255).primary();
|
|
26
|
+
t.string("name", 255).notNullable();
|
|
27
|
+
// Null for a public client — a SPA or a native app has no secret to keep.
|
|
28
|
+
t.string("secret_hash", 255).nullable();
|
|
29
|
+
t.json("redirect_uris").notNullable();
|
|
30
|
+
t.json("grant_types").notNullable();
|
|
31
|
+
t.json("scopes").notNullable();
|
|
32
|
+
t.string("token_endpoint_auth_method", 32).notNullable();
|
|
33
|
+
t.boolean("trusted").notNullable();
|
|
34
|
+
t.timestamp("created_at").notNullable();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
this.schema.createTable("visa_authorization_codes", (t) => {
|
|
38
|
+
t.string("code_hash", 64).primary();
|
|
39
|
+
t.string("client_id", 255).notNullable();
|
|
40
|
+
t.string("user_id", 255).notNullable();
|
|
41
|
+
t.text("redirect_uri").notNullable();
|
|
42
|
+
t.json("scopes").notNullable();
|
|
43
|
+
t.string("code_challenge", 128).notNullable();
|
|
44
|
+
t.string("code_challenge_method", 10).notNullable();
|
|
45
|
+
t.timestamp("expires_at").notNullable();
|
|
46
|
+
// Set the moment it is exchanged: the single-use guarantee is an
|
|
47
|
+
// UPDATE guarded on this column being null.
|
|
48
|
+
t.timestamp("consumed_at").nullable();
|
|
49
|
+
t.string("nonce", 255).nullable();
|
|
50
|
+
t.timestamp("created_at").notNullable();
|
|
51
|
+
t.index("user_id");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
this.schema.createTable("visa_access_tokens", (t) => {
|
|
55
|
+
t.string("token_hash", 64).primary();
|
|
56
|
+
t.string("client_id", 255).notNullable();
|
|
57
|
+
// Null for `client_credentials`: there is no user behind that grant.
|
|
58
|
+
t.string("user_id", 255).nullable();
|
|
59
|
+
t.json("scopes").notNullable();
|
|
60
|
+
t.timestamp("expires_at").notNullable();
|
|
61
|
+
t.timestamp("revoked_at").nullable();
|
|
62
|
+
t.timestamp("last_used_at").nullable();
|
|
63
|
+
// The refresh family this token was minted in. Indexed because
|
|
64
|
+
// revoking a leaked family reads every token by it.
|
|
65
|
+
t.string("family_id", 64).nullable();
|
|
66
|
+
t.timestamp("created_at").notNullable();
|
|
67
|
+
t.index("family_id");
|
|
68
|
+
t.index("user_id");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
this.schema.createTable("visa_refresh_tokens", (t) => {
|
|
72
|
+
t.string("token_hash", 64).primary();
|
|
73
|
+
t.string("family_id", 64).notNullable();
|
|
74
|
+
t.string("client_id", 255).notNullable();
|
|
75
|
+
t.string("user_id", 255).nullable();
|
|
76
|
+
t.json("scopes").notNullable();
|
|
77
|
+
t.timestamp("expires_at").notNullable();
|
|
78
|
+
t.timestamp("consumed_at").nullable();
|
|
79
|
+
t.timestamp("revoked_at").nullable();
|
|
80
|
+
t.timestamp("created_at").notNullable();
|
|
81
|
+
t.index("family_id");
|
|
82
|
+
t.index("user_id");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
this.schema.createTable("visa_consents", (t) => {
|
|
86
|
+
t.string("user_id", 255).notNullable();
|
|
87
|
+
t.string("client_id", 255).notNullable();
|
|
88
|
+
t.json("scopes").notNullable();
|
|
89
|
+
t.timestamp("granted_at").notNullable();
|
|
90
|
+
// One consent per pair: asking twice for the same thing is the bug
|
|
91
|
+
// this table exists to prevent.
|
|
92
|
+
t.primary(["user_id", "client_id"]);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async down() {
|
|
97
|
+
this.schema.dropTable("visa_consents");
|
|
98
|
+
this.schema.dropTable("visa_refresh_tokens");
|
|
99
|
+
this.schema.dropTable("visa_access_tokens");
|
|
100
|
+
this.schema.dropTable("visa_authorization_codes");
|
|
101
|
+
this.schema.dropTable("visa_clients");
|
|
102
|
+
}
|
|
103
|
+
}
|