@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.
- package/dist/VisaManager.d.ts +40 -7
- package/dist/VisaManager.d.ts.map +1 -1
- package/dist/VisaManager.js +79 -3
- package/dist/VisaManager.js.map +1 -1
- package/dist/VisaProvider.d.ts.map +1 -1
- package/dist/VisaProvider.js +56 -0
- package/dist/VisaProvider.js.map +1 -1
- package/dist/authorize.d.ts +18 -0
- package/dist/authorize.d.ts.map +1 -1
- package/dist/authorize.js +6 -0
- package/dist/authorize.js.map +1 -1
- package/dist/config.d.ts +35 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/configure.d.ts.map +1 -1
- package/dist/configure.js +19 -0
- package/dist/configure.js.map +1 -1
- package/dist/errors.d.ts +9 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/guard.d.ts +243 -0
- package/dist/guard.d.ts.map +1 -0
- package/dist/guard.js +293 -0
- package/dist/guard.js.map +1 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/introspect.d.ts +11 -1
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.js +27 -1
- package/dist/introspect.js.map +1 -1
- package/dist/protectedResource.d.ts +81 -0
- package/dist/protectedResource.d.ts.map +1 -0
- package/dist/protectedResource.js +124 -0
- package/dist/protectedResource.js.map +1 -0
- package/dist/register.d.ts +96 -0
- package/dist/register.d.ts.map +1 -0
- package/dist/register.js +173 -0
- package/dist/register.js.map +1 -0
- package/dist/resource.d.ts +50 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/resource.js +101 -0
- package/dist/resource.js.map +1 -0
- package/dist/store.d.ts +33 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/stores/atlas.d.ts +106 -0
- package/dist/stores/atlas.d.ts.map +1 -0
- package/dist/stores/atlas.js +408 -0
- package/dist/stores/atlas.js.map +1 -0
- package/dist/stores/memory.d.ts +5 -0
- package/dist/stores/memory.d.ts.map +1 -1
- package/dist/stores/memory.js +30 -0
- package/dist/stores/memory.js.map +1 -1
- package/dist/token.d.ts +2 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +21 -1
- package/dist/token.js.map +1 -1
- package/dist/types.d.ts +49 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/VisaManager.ts +109 -3
- package/src/VisaProvider.ts +64 -0
- package/src/authorize.ts +27 -0
- package/src/config.ts +35 -0
- package/src/configure.ts +21 -0
- package/src/errors.ts +16 -2
- package/src/guard.ts +443 -0
- package/src/index.ts +41 -0
- package/src/introspect.ts +33 -2
- package/src/protectedResource.ts +171 -0
- package/src/register.ts +296 -0
- package/src/resource.ts +123 -0
- package/src/store.ts +38 -0
- package/src/stores/atlas.ts +514 -0
- package/src/stores/memory.ts +38 -0
- package/src/token.ts +45 -1
- package/src/types.ts +50 -0
- package/stubs/database/migrations/9000_create_visa_tables.stub +112 -0
|
@@ -0,0 +1,514 @@
|
|
|
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
|
+
/** Run the select and return every row — atlas' `exec()`. */
|
|
49
|
+
exec(): Promise<Row[]>;
|
|
50
|
+
insert(data: Row): PromiseLike<unknown>;
|
|
51
|
+
/** Resolves to the number of rows affected, which is what the atomic paths read. */
|
|
52
|
+
update(data: Row): PromiseLike<number | Row[]>;
|
|
53
|
+
delete(): PromiseLike<number | Row[]>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The slice of atlas' `db` service this store uses. */
|
|
57
|
+
export interface AtlasDb {
|
|
58
|
+
from(table: string): AtlasQuery;
|
|
59
|
+
table(table: string): AtlasQuery;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Where each kind of row lives. Override one to fit a schema you do not own. */
|
|
63
|
+
export interface AtlasStoreTables {
|
|
64
|
+
clients?: string;
|
|
65
|
+
authorizationCodes?: string;
|
|
66
|
+
accessTokens?: string;
|
|
67
|
+
refreshTokens?: string;
|
|
68
|
+
consents?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_TABLES = {
|
|
72
|
+
clients: "visa_clients",
|
|
73
|
+
authorizationCodes: "visa_authorization_codes",
|
|
74
|
+
accessTokens: "visa_access_tokens",
|
|
75
|
+
refreshTokens: "visa_refresh_tokens",
|
|
76
|
+
consents: "visa_consents",
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A list of strings, stored as JSON text.
|
|
81
|
+
*
|
|
82
|
+
* Text and not a native array type: three dialects are supported and only one
|
|
83
|
+
* of them has arrays. The round trip is explicit so a malformed value fails on
|
|
84
|
+
* read rather than becoming `[]` — a scope list that quietly empties is an
|
|
85
|
+
* authorisation bug, not a display one.
|
|
86
|
+
*/
|
|
87
|
+
function encodeList(values: readonly string[]): string {
|
|
88
|
+
return JSON.stringify(values);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function decodeList(value: unknown, column: string): string[] {
|
|
92
|
+
if (value === null || value === undefined) return [];
|
|
93
|
+
if (Array.isArray(value)) {
|
|
94
|
+
// A `json` column comes back parsed on some dialects and as text on others.
|
|
95
|
+
return value.filter((entry): entry is string => typeof entry === "string");
|
|
96
|
+
}
|
|
97
|
+
if (typeof value !== "string") {
|
|
98
|
+
throw new Error(`[visa] ${column} holds ${typeof value}, expected a list`);
|
|
99
|
+
}
|
|
100
|
+
const parsed: unknown = JSON.parse(value);
|
|
101
|
+
if (!Array.isArray(parsed)) {
|
|
102
|
+
throw new Error(`[visa] ${column} is not a JSON array: ${value}`);
|
|
103
|
+
}
|
|
104
|
+
return parsed.filter((entry): entry is string => typeof entry === "string");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A column that may be null, as a Date. */
|
|
108
|
+
function decodeDate(value: unknown): Date | undefined {
|
|
109
|
+
if (value === null || value === undefined) return undefined;
|
|
110
|
+
if (value instanceof Date) return value;
|
|
111
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
112
|
+
const date = new Date(value);
|
|
113
|
+
if (!Number.isNaN(date.getTime())) return date;
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** A column that must hold a date. */
|
|
119
|
+
function requireDate(value: unknown, column: string): Date {
|
|
120
|
+
const date = decodeDate(value);
|
|
121
|
+
if (date === undefined) {
|
|
122
|
+
throw new Error(`[visa] ${column} is not a date: ${String(value)}`);
|
|
123
|
+
}
|
|
124
|
+
return date;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function requireString(value: unknown, column: string): string {
|
|
128
|
+
if (typeof value !== "string") {
|
|
129
|
+
throw new Error(`[visa] ${column} is not a string: ${String(value)}`);
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function optionalString(value: unknown): string | undefined {
|
|
135
|
+
return typeof value === "string" && value !== "" ? value : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How many rows a write touched.
|
|
140
|
+
*
|
|
141
|
+
* `update()` answers a count, or the rows themselves when the caller asked for
|
|
142
|
+
* them. Only the count is ever asked for here, and the other shape is read as
|
|
143
|
+
* its length rather than assumed away.
|
|
144
|
+
*/
|
|
145
|
+
function affected(result: number | Row[]): number {
|
|
146
|
+
return typeof result === "number" ? result : result.length;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export class AtlasStore implements VisaStore {
|
|
150
|
+
readonly #db: AtlasDb;
|
|
151
|
+
readonly #tables: Required<AtlasStoreTables>;
|
|
152
|
+
|
|
153
|
+
constructor(db: AtlasDb, tables: AtlasStoreTables = {}) {
|
|
154
|
+
this.#db = db;
|
|
155
|
+
this.#tables = {
|
|
156
|
+
clients: tables.clients ?? DEFAULT_TABLES.clients,
|
|
157
|
+
authorizationCodes:
|
|
158
|
+
tables.authorizationCodes ?? DEFAULT_TABLES.authorizationCodes,
|
|
159
|
+
accessTokens: tables.accessTokens ?? DEFAULT_TABLES.accessTokens,
|
|
160
|
+
refreshTokens: tables.refreshTokens ?? DEFAULT_TABLES.refreshTokens,
|
|
161
|
+
consents: tables.consents ?? DEFAULT_TABLES.consents,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async findClient(id: string): Promise<Client | null> {
|
|
166
|
+
const row = await this.#db.from(this.#tables.clients).where({ id }).first();
|
|
167
|
+
if (row === null) return null;
|
|
168
|
+
const secretHash = optionalString(row.secret_hash);
|
|
169
|
+
return {
|
|
170
|
+
id: requireString(row.id, "id"),
|
|
171
|
+
name: requireString(row.name, "name"),
|
|
172
|
+
...(secretHash === undefined ? {} : { secretHash }),
|
|
173
|
+
redirectUris: decodeList(row.redirect_uris, "redirect_uris"),
|
|
174
|
+
grantTypes: decodeList(row.grant_types, "grant_types").filter(
|
|
175
|
+
isGrantType,
|
|
176
|
+
),
|
|
177
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
178
|
+
tokenEndpointAuthMethod: readAuthMethod(row.token_endpoint_auth_method),
|
|
179
|
+
...(row.trusted === true || row.trusted === 1 ? { trusted: true } : {}),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Persist a registered client. Not on the contract — what `registerClient` writes through. */
|
|
184
|
+
async saveClient(client: Client): Promise<void> {
|
|
185
|
+
await this.#db.table(this.#tables.clients).insert({
|
|
186
|
+
id: client.id,
|
|
187
|
+
name: client.name,
|
|
188
|
+
secret_hash: client.secretHash ?? null,
|
|
189
|
+
redirect_uris: encodeList(client.redirectUris),
|
|
190
|
+
grant_types: encodeList(client.grantTypes),
|
|
191
|
+
scopes: encodeList(client.scopes),
|
|
192
|
+
token_endpoint_auth_method: client.tokenEndpointAuthMethod,
|
|
193
|
+
trusted: client.trusted === true,
|
|
194
|
+
created_at: new Date(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async saveAuthorizationCode(code: AuthorizationCode): Promise<void> {
|
|
199
|
+
await this.#db.table(this.#tables.authorizationCodes).insert({
|
|
200
|
+
code_hash: code.codeHash,
|
|
201
|
+
client_id: code.clientId,
|
|
202
|
+
user_id: code.userId,
|
|
203
|
+
redirect_uri: code.redirectUri,
|
|
204
|
+
scopes: encodeList(code.scopes),
|
|
205
|
+
code_challenge: code.codeChallenge,
|
|
206
|
+
code_challenge_method: code.codeChallengeMethod,
|
|
207
|
+
expires_at: code.expiresAt,
|
|
208
|
+
consumed_at: code.consumedAt ?? null,
|
|
209
|
+
nonce: code.nonce ?? null,
|
|
210
|
+
resources: encodeList(code.resources ?? []),
|
|
211
|
+
created_at: new Date(),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async findAuthorizationCode(
|
|
216
|
+
codeHash: string,
|
|
217
|
+
): Promise<AuthorizationCode | null> {
|
|
218
|
+
const row = await this.#db
|
|
219
|
+
.from(this.#tables.authorizationCodes)
|
|
220
|
+
.where({ code_hash: codeHash })
|
|
221
|
+
.first();
|
|
222
|
+
if (row === null) return null;
|
|
223
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
224
|
+
const nonce = optionalString(row.nonce);
|
|
225
|
+
return {
|
|
226
|
+
codeHash: requireString(row.code_hash, "code_hash"),
|
|
227
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
228
|
+
userId: requireString(row.user_id, "user_id"),
|
|
229
|
+
redirectUri: requireString(row.redirect_uri, "redirect_uri"),
|
|
230
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
231
|
+
codeChallenge: requireString(row.code_challenge, "code_challenge"),
|
|
232
|
+
codeChallengeMethod:
|
|
233
|
+
row.code_challenge_method === "plain" ? "plain" : "S256",
|
|
234
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
235
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
236
|
+
...(nonce === undefined ? {} : { nonce }),
|
|
237
|
+
...listOrNothing("resources", row.resources),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* One UPDATE, guarded by `consumed_at IS NULL`.
|
|
243
|
+
*
|
|
244
|
+
* The single-use guarantee lives in that one statement: two requests racing
|
|
245
|
+
* with the same code both reach the database, and only the one that finds
|
|
246
|
+
* the row still unconsumed is allowed to write it. Reading and then writing
|
|
247
|
+
* would let both through.
|
|
248
|
+
*/
|
|
249
|
+
async consumeAuthorizationCode(codeHash: string, at: Date): Promise<boolean> {
|
|
250
|
+
const result = await this.#db
|
|
251
|
+
.from(this.#tables.authorizationCodes)
|
|
252
|
+
.where({ code_hash: codeHash })
|
|
253
|
+
.whereNull("consumed_at")
|
|
254
|
+
.update({ consumed_at: at });
|
|
255
|
+
return affected(result) === 1;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async saveAccessToken(token: AccessToken): Promise<void> {
|
|
259
|
+
await this.#db.table(this.#tables.accessTokens).insert({
|
|
260
|
+
token_hash: token.tokenHash,
|
|
261
|
+
client_id: token.clientId,
|
|
262
|
+
user_id: token.userId ?? null,
|
|
263
|
+
scopes: encodeList(token.scopes),
|
|
264
|
+
expires_at: token.expiresAt,
|
|
265
|
+
revoked_at: token.revokedAt ?? null,
|
|
266
|
+
last_used_at: token.lastUsedAt ?? null,
|
|
267
|
+
family_id: token.familyId ?? null,
|
|
268
|
+
audience: encodeList(token.audience ?? []),
|
|
269
|
+
created_at: new Date(),
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async findAccessToken(tokenHash: string): Promise<AccessToken | null> {
|
|
274
|
+
const row = await this.#db
|
|
275
|
+
.from(this.#tables.accessTokens)
|
|
276
|
+
.where({ token_hash: tokenHash })
|
|
277
|
+
.first();
|
|
278
|
+
return row === null ? null : readAccessToken(row);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async revokeAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
282
|
+
await this.#db
|
|
283
|
+
.from(this.#tables.accessTokens)
|
|
284
|
+
.where({ token_hash: tokenHash })
|
|
285
|
+
.whereNull("revoked_at")
|
|
286
|
+
.update({ revoked_at: at });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
290
|
+
await this.#db
|
|
291
|
+
.from(this.#tables.accessTokens)
|
|
292
|
+
.where({ token_hash: tokenHash })
|
|
293
|
+
.update({ last_used_at: at });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async saveRefreshToken(token: RefreshToken): Promise<void> {
|
|
297
|
+
await this.#db.table(this.#tables.refreshTokens).insert({
|
|
298
|
+
token_hash: token.tokenHash,
|
|
299
|
+
family_id: token.familyId,
|
|
300
|
+
client_id: token.clientId,
|
|
301
|
+
user_id: token.userId ?? null,
|
|
302
|
+
scopes: encodeList(token.scopes),
|
|
303
|
+
expires_at: token.expiresAt,
|
|
304
|
+
consumed_at: token.consumedAt ?? null,
|
|
305
|
+
revoked_at: token.revokedAt ?? null,
|
|
306
|
+
resources: encodeList(token.resources ?? []),
|
|
307
|
+
created_at: new Date(),
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async findRefreshToken(tokenHash: string): Promise<RefreshToken | null> {
|
|
312
|
+
const row = await this.#db
|
|
313
|
+
.from(this.#tables.refreshTokens)
|
|
314
|
+
.where({ token_hash: tokenHash })
|
|
315
|
+
.first();
|
|
316
|
+
if (row === null) return null;
|
|
317
|
+
const userId = optionalString(row.user_id);
|
|
318
|
+
const consumedAt = decodeDate(row.consumed_at);
|
|
319
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
320
|
+
return {
|
|
321
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
322
|
+
familyId: requireString(row.family_id, "family_id"),
|
|
323
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
324
|
+
...(userId === undefined ? {} : { userId }),
|
|
325
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
326
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
327
|
+
...(consumedAt === undefined ? {} : { consumedAt }),
|
|
328
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
329
|
+
...listOrNothing("resources", row.resources),
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Same one-statement guarantee as a code: rotation depends on it. */
|
|
334
|
+
async consumeRefreshToken(tokenHash: string, at: Date): Promise<boolean> {
|
|
335
|
+
const result = await this.#db
|
|
336
|
+
.from(this.#tables.refreshTokens)
|
|
337
|
+
.where({ token_hash: tokenHash })
|
|
338
|
+
.whereNull("consumed_at")
|
|
339
|
+
.update({ consumed_at: at });
|
|
340
|
+
return affected(result) === 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* End a family: the refresh tokens AND the access tokens they bought.
|
|
345
|
+
*
|
|
346
|
+
* Both, because revoking only the refresh half would leave an access token
|
|
347
|
+
* already minted alive for its full lifetime — which is most of what this
|
|
348
|
+
* revocation exists to prevent.
|
|
349
|
+
*/
|
|
350
|
+
async revokeFamily(familyId: string, at: Date): Promise<void> {
|
|
351
|
+
await this.#db
|
|
352
|
+
.from(this.#tables.refreshTokens)
|
|
353
|
+
.where({ family_id: familyId })
|
|
354
|
+
.whereNull("revoked_at")
|
|
355
|
+
.update({ revoked_at: at });
|
|
356
|
+
await this.#db
|
|
357
|
+
.from(this.#tables.accessTokens)
|
|
358
|
+
.where({ family_id: familyId })
|
|
359
|
+
.whereNull("revoked_at")
|
|
360
|
+
.update({ revoked_at: at });
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async findConsent(userId: string, clientId: string): Promise<Consent | null> {
|
|
364
|
+
const row = await this.#db
|
|
365
|
+
.from(this.#tables.consents)
|
|
366
|
+
.where({ user_id: userId, client_id: clientId })
|
|
367
|
+
.first();
|
|
368
|
+
if (row === null) return null;
|
|
369
|
+
return {
|
|
370
|
+
userId: requireString(row.user_id, "user_id"),
|
|
371
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
372
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
373
|
+
grantedAt: requireDate(row.granted_at, "granted_at"),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Record a consent, replacing what was there.
|
|
379
|
+
*
|
|
380
|
+
* Delete then insert rather than an upsert: the dialects spell an upsert
|
|
381
|
+
* three different ways, and re-granting is not a hot path.
|
|
382
|
+
*/
|
|
383
|
+
async saveConsent(consent: Consent): Promise<void> {
|
|
384
|
+
await this.#db
|
|
385
|
+
.from(this.#tables.consents)
|
|
386
|
+
.where({ user_id: consent.userId, client_id: consent.clientId })
|
|
387
|
+
.delete();
|
|
388
|
+
await this.#db.table(this.#tables.consents).insert({
|
|
389
|
+
user_id: consent.userId,
|
|
390
|
+
client_id: consent.clientId,
|
|
391
|
+
scopes: encodeList(consent.scopes),
|
|
392
|
+
granted_at: consent.grantedAt,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async listConsents(userId: string): Promise<Consent[]> {
|
|
397
|
+
const rows = await this.#db
|
|
398
|
+
.from(this.#tables.consents)
|
|
399
|
+
.where({ user_id: userId })
|
|
400
|
+
.orderBy("granted_at", "desc")
|
|
401
|
+
.exec();
|
|
402
|
+
return rows.map((row) => ({
|
|
403
|
+
userId: requireString(row.user_id, "user_id"),
|
|
404
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
405
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
406
|
+
grantedAt: requireDate(row.granted_at, "granted_at"),
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Expired ones included — see the contract for why. */
|
|
411
|
+
async listAccessTokens(userId: string): Promise<AccessToken[]> {
|
|
412
|
+
const rows = await this.#db
|
|
413
|
+
.from(this.#tables.accessTokens)
|
|
414
|
+
.where({ user_id: userId })
|
|
415
|
+
.orderBy("expires_at", "desc")
|
|
416
|
+
.exec();
|
|
417
|
+
return rows.map(readAccessToken);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async revokeAccessFor(
|
|
421
|
+
userId: string,
|
|
422
|
+
clientId: string,
|
|
423
|
+
at: Date,
|
|
424
|
+
): Promise<void> {
|
|
425
|
+
for (const table of [
|
|
426
|
+
this.#tables.accessTokens,
|
|
427
|
+
this.#tables.refreshTokens,
|
|
428
|
+
]) {
|
|
429
|
+
await this.#db
|
|
430
|
+
.from(table)
|
|
431
|
+
.where({ user_id: userId, client_id: clientId })
|
|
432
|
+
.whereNull("revoked_at")
|
|
433
|
+
.update({ revoked_at: at });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async deleteConsent(userId: string, clientId: string): Promise<void> {
|
|
438
|
+
await this.#db
|
|
439
|
+
.from(this.#tables.consents)
|
|
440
|
+
.where({ user_id: userId, client_id: clientId })
|
|
441
|
+
.delete();
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Drop what has expired and can no longer be presented. */
|
|
445
|
+
async prune(now: Date): Promise<void> {
|
|
446
|
+
for (const table of [
|
|
447
|
+
this.#tables.authorizationCodes,
|
|
448
|
+
this.#tables.accessTokens,
|
|
449
|
+
this.#tables.refreshTokens,
|
|
450
|
+
]) {
|
|
451
|
+
await this.#db.from(table).where("expires_at", "<", now).delete();
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** One access-token row, as the contract's shape. */
|
|
457
|
+
function readAccessToken(row: Row): AccessToken {
|
|
458
|
+
const userId = optionalString(row.user_id);
|
|
459
|
+
const revokedAt = decodeDate(row.revoked_at);
|
|
460
|
+
const lastUsedAt = decodeDate(row.last_used_at);
|
|
461
|
+
const familyId = optionalString(row.family_id);
|
|
462
|
+
return {
|
|
463
|
+
tokenHash: requireString(row.token_hash, "token_hash"),
|
|
464
|
+
clientId: requireString(row.client_id, "client_id"),
|
|
465
|
+
...(userId === undefined ? {} : { userId }),
|
|
466
|
+
scopes: decodeList(row.scopes, "scopes"),
|
|
467
|
+
expiresAt: requireDate(row.expires_at, "expires_at"),
|
|
468
|
+
...(revokedAt === undefined ? {} : { revokedAt }),
|
|
469
|
+
...(lastUsedAt === undefined ? {} : { lastUsedAt }),
|
|
470
|
+
...(familyId === undefined ? {} : { familyId }),
|
|
471
|
+
...listOrNothing("audience", row.audience),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A list column, present in the object only when it holds something.
|
|
477
|
+
*
|
|
478
|
+
* Empty and absent mean the same thing for an audience — unbound — and the
|
|
479
|
+
* contract spells that as absent, so an empty column must not become `[]` or
|
|
480
|
+
* a round trip would stop matching what went in.
|
|
481
|
+
*/
|
|
482
|
+
function listOrNothing(
|
|
483
|
+
key: "audience",
|
|
484
|
+
value: unknown,
|
|
485
|
+
): { audience?: string[] };
|
|
486
|
+
function listOrNothing(
|
|
487
|
+
key: "resources",
|
|
488
|
+
value: unknown,
|
|
489
|
+
): { resources?: string[] };
|
|
490
|
+
function listOrNothing(
|
|
491
|
+
key: "audience" | "resources",
|
|
492
|
+
value: unknown,
|
|
493
|
+
): { audience?: string[]; resources?: string[] } {
|
|
494
|
+
const list = decodeList(value, key);
|
|
495
|
+
if (list.length === 0) return {};
|
|
496
|
+
return key === "audience" ? { audience: list } : { resources: list };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isGrantType(value: string): value is GrantType {
|
|
500
|
+
return (
|
|
501
|
+
value === "authorization_code" ||
|
|
502
|
+
value === "refresh_token" ||
|
|
503
|
+
value === "client_credentials"
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function readAuthMethod(value: unknown): ClientAuthMethod {
|
|
508
|
+
if (value === "client_secret_basic" || value === "client_secret_post") {
|
|
509
|
+
return value;
|
|
510
|
+
}
|
|
511
|
+
// A row holding nothing usable is read as a public client, which is the
|
|
512
|
+
// method that grants the least.
|
|
513
|
+
return "none";
|
|
514
|
+
}
|
package/src/stores/memory.ts
CHANGED
|
@@ -70,6 +70,44 @@ export class MemoryStore implements VisaStore {
|
|
|
70
70
|
if (token !== undefined) token.revokedAt = at;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
async listConsents(userId: string): Promise<Consent[]> {
|
|
74
|
+
return [...this.#consents.values()].filter(
|
|
75
|
+
(consent) => consent.userId === userId,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async listAccessTokens(userId: string): Promise<AccessToken[]> {
|
|
80
|
+
return [...this.#accessTokens.values()].filter(
|
|
81
|
+
(token) => token.userId === userId,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async revokeAccessFor(
|
|
86
|
+
userId: string,
|
|
87
|
+
clientId: string,
|
|
88
|
+
at: Date,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
for (const token of this.#accessTokens.values()) {
|
|
91
|
+
if (token.userId !== userId || token.clientId !== clientId) continue;
|
|
92
|
+
if (token.revokedAt === undefined) token.revokedAt = at;
|
|
93
|
+
}
|
|
94
|
+
for (const token of this.#refreshTokens.values()) {
|
|
95
|
+
if (token.userId !== userId || token.clientId !== clientId) continue;
|
|
96
|
+
if (token.revokedAt === undefined) token.revokedAt = at;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async deleteConsent(userId: string, clientId: string): Promise<void> {
|
|
101
|
+
// The same NUL-separated key `saveConsent` writes: a colon would make
|
|
102
|
+
// `a:b` + `c` collide with `a` + `b:c`.
|
|
103
|
+
this.#consents.delete(`${userId}\u0000${clientId}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async touchAccessToken(tokenHash: string, at: Date): Promise<void> {
|
|
107
|
+
const token = this.#accessTokens.get(tokenHash);
|
|
108
|
+
if (token !== undefined) token.lastUsedAt = at;
|
|
109
|
+
}
|
|
110
|
+
|
|
73
111
|
async saveRefreshToken(token: RefreshToken): Promise<void> {
|
|
74
112
|
this.#refreshTokens.set(token.tokenHash, token);
|
|
75
113
|
}
|
package/src/token.ts
CHANGED
|
@@ -14,11 +14,18 @@ import {
|
|
|
14
14
|
} from "./clients.js";
|
|
15
15
|
import { hashMatches, hashSecret, randomToken, sha256 } from "./crypto.js";
|
|
16
16
|
import { OAuthError } from "./errors.js";
|
|
17
|
+
import {
|
|
18
|
+
narrowResources,
|
|
19
|
+
readResourceParameter,
|
|
20
|
+
validateResources,
|
|
21
|
+
} from "./resource.js";
|
|
17
22
|
import type { VisaStore } from "./store.js";
|
|
18
23
|
import type { Client, GrantType, TokenResponse } from "./types.js";
|
|
19
24
|
|
|
20
25
|
export interface TokenOptions {
|
|
21
26
|
accessTokenTtlSeconds?: number;
|
|
27
|
+
/** The resources this server issues tokens for — see `AuthorizeOptions`. */
|
|
28
|
+
resourcesSupported?: readonly string[];
|
|
22
29
|
refreshTokenTtlSeconds?: number;
|
|
23
30
|
/** Hand out refresh tokens at all. */
|
|
24
31
|
issueRefreshTokens?: boolean;
|
|
@@ -135,12 +142,23 @@ async function exchangeCode(
|
|
|
135
142
|
throw invalid;
|
|
136
143
|
}
|
|
137
144
|
|
|
145
|
+
// RFC 8707: a request may narrow the resources the code was bound to,
|
|
146
|
+
// never widen them — the user saw the list at authorization time.
|
|
147
|
+
const resources = narrowResources(
|
|
148
|
+
validateResources(
|
|
149
|
+
readResourceParameter(request.resource),
|
|
150
|
+
options.resourcesSupported,
|
|
151
|
+
),
|
|
152
|
+
record.resources ?? [],
|
|
153
|
+
);
|
|
154
|
+
|
|
138
155
|
return issue(
|
|
139
156
|
{
|
|
140
157
|
client,
|
|
141
158
|
userId: record.userId,
|
|
142
159
|
scopes: record.scopes,
|
|
143
160
|
familyId: codeFamily(record.codeHash),
|
|
161
|
+
resources,
|
|
144
162
|
},
|
|
145
163
|
store,
|
|
146
164
|
options,
|
|
@@ -215,6 +233,13 @@ async function exchangeRefresh(
|
|
|
215
233
|
...(record.userId === undefined ? {} : { userId: record.userId }),
|
|
216
234
|
scopes,
|
|
217
235
|
familyId: record.familyId,
|
|
236
|
+
resources: narrowResources(
|
|
237
|
+
validateResources(
|
|
238
|
+
readResourceParameter(request.resource),
|
|
239
|
+
options.resourcesSupported,
|
|
240
|
+
),
|
|
241
|
+
record.resources ?? [],
|
|
242
|
+
),
|
|
218
243
|
},
|
|
219
244
|
store,
|
|
220
245
|
options,
|
|
@@ -258,7 +283,16 @@ async function clientCredentials(
|
|
|
258
283
|
}
|
|
259
284
|
const scopes = resolveScopes(asString(request.scope), client);
|
|
260
285
|
return issue(
|
|
261
|
-
{
|
|
286
|
+
{
|
|
287
|
+
client,
|
|
288
|
+
scopes,
|
|
289
|
+
// No user consented to a list here, so the request IS the list —
|
|
290
|
+
// checked against what this server issues for, and nothing wider.
|
|
291
|
+
resources: validateResources(
|
|
292
|
+
readResourceParameter(request.resource),
|
|
293
|
+
options.resourcesSupported,
|
|
294
|
+
),
|
|
295
|
+
},
|
|
262
296
|
store,
|
|
263
297
|
{ ...options, issueRefreshTokens: false },
|
|
264
298
|
now,
|
|
@@ -270,6 +304,8 @@ interface Issued {
|
|
|
270
304
|
userId?: string;
|
|
271
305
|
scopes: string[];
|
|
272
306
|
familyId?: string;
|
|
307
|
+
/** RFC 8707 — what the token is for. Empty means unbound. */
|
|
308
|
+
resources?: string[];
|
|
273
309
|
}
|
|
274
310
|
|
|
275
311
|
/** Mint the pair and store both, hashed. */
|
|
@@ -290,6 +326,9 @@ async function issue(
|
|
|
290
326
|
scopes: grant.scopes,
|
|
291
327
|
expiresAt: new Date(now.getTime() + accessTtl * 1000),
|
|
292
328
|
...(grant.familyId === undefined ? {} : { familyId: grant.familyId }),
|
|
329
|
+
...(grant.resources === undefined || grant.resources.length === 0
|
|
330
|
+
? {}
|
|
331
|
+
: { audience: grant.resources }),
|
|
293
332
|
});
|
|
294
333
|
|
|
295
334
|
const response: TokenResponse = {
|
|
@@ -315,6 +354,11 @@ async function issue(
|
|
|
315
354
|
...(grant.userId === undefined ? {} : { userId: grant.userId }),
|
|
316
355
|
scopes: grant.scopes,
|
|
317
356
|
expiresAt: new Date(now.getTime() + refreshTtl * 1000),
|
|
357
|
+
// Carried so a rotation can narrow from the same list the code bound —
|
|
358
|
+
// without it, the second token of a session would be unbound.
|
|
359
|
+
...(grant.resources === undefined || grant.resources.length === 0
|
|
360
|
+
? {}
|
|
361
|
+
: { resources: grant.resources }),
|
|
318
362
|
});
|
|
319
363
|
response.refresh_token = refreshToken;
|
|
320
364
|
return response;
|