@udibo/oauth2 0.32.1 → 0.34.0
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/adapters/hono/bff/_cookie-keys.d.ts +17 -0
- package/dist/adapters/hono/bff/_cookie-keys.js +25 -0
- package/dist/adapters/hono/bff/auth-request-store.d.ts +18 -7
- package/dist/adapters/hono/bff/auth-request-store.js +25 -12
- package/dist/adapters/hono/bff/session-store.d.ts +16 -7
- package/dist/adapters/hono/bff/session-store.js +13 -11
- package/dist/adapters/hono/bff/testing.d.ts +22 -7
- package/dist/adapters/hono/bff/testing.js +16 -10
- package/dist/testing/contract/token-rotation.d.ts +7 -0
- package/dist/testing/contract/token-rotation.js +3 -2
- package/dist/testing/contract/token.d.ts +19 -0
- package/dist/testing/contract/token.js +72 -41
- package/dist/utils/crypto.d.ts +8 -26
- package/dist/utils/crypto.js +8 -33
- package/package.json +1 -1
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
/** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
|
|
7
|
+
export declare const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
|
|
8
|
+
/** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
|
|
9
|
+
export declare const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
|
|
10
|
+
/**
|
|
11
|
+
* Derives a cookie store's key from its configured secret with HKDF-SHA-256
|
|
12
|
+
* under the store's own `info` label, so one secret given to two stores yields
|
|
13
|
+
* independent keys.
|
|
14
|
+
*
|
|
15
|
+
* @throws {Error} When the secret is shorter than 16 bytes.
|
|
16
|
+
*/
|
|
17
|
+
export declare function deriveCookieKey(secret: string | Uint8Array, info: string): Promise<CryptoKey>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
import { deriveSealKey } from "../../../utils/crypto.js";
|
|
7
|
+
/** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
|
|
8
|
+
export const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
|
|
9
|
+
/** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
|
|
10
|
+
export const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
|
|
11
|
+
const MIN_SECRET_BYTES = 16;
|
|
12
|
+
/**
|
|
13
|
+
* Derives a cookie store's key from its configured secret with HKDF-SHA-256
|
|
14
|
+
* under the store's own `info` label, so one secret given to two stores yields
|
|
15
|
+
* independent keys.
|
|
16
|
+
*
|
|
17
|
+
* @throws {Error} When the secret is shorter than 16 bytes.
|
|
18
|
+
*/
|
|
19
|
+
export async function deriveCookieKey(secret, info) {
|
|
20
|
+
const bytes = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
|
|
21
|
+
if (bytes.byteLength < MIN_SECRET_BYTES) {
|
|
22
|
+
throw new Error("secret must be at least 16 bytes; 32+ bytes of entropy is recommended.");
|
|
23
|
+
}
|
|
24
|
+
return await deriveSealKey(bytes, info);
|
|
25
|
+
}
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* caller chooses.
|
|
12
12
|
*
|
|
13
13
|
* {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
|
|
14
|
-
* cookie instead (AES-GCM under a server-held secret,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* cookie instead (AES-GCM under a key derived from a server-held secret), so no
|
|
15
|
+
* server-side storage is involved and any instance can complete the flow. Apps
|
|
16
|
+
* that already have a database can implement {@link AuthRequestStorage} against
|
|
17
|
+
* it instead.
|
|
18
18
|
*
|
|
19
19
|
* **Which storage you pick is a reliability decision, not a security one.**
|
|
20
20
|
* Server-side storage on its own says a `state` is pending somewhere — never
|
|
@@ -84,6 +84,13 @@ export interface EncryptedCookieAuthRequestStorageOptions {
|
|
|
84
84
|
/**
|
|
85
85
|
* Secret the AES-256 key is derived from; at least 32 bytes. Reuse the
|
|
86
86
|
* secret across instances so any of them can unseal a cookie another sealed.
|
|
87
|
+
*
|
|
88
|
+
* Give each store instance and each app a secret of its own. Nothing in a
|
|
89
|
+
* sealed cookie binds it to the app or store instance that sealed it, so two
|
|
90
|
+
* apps that share a secret can open each other's pending sign-ins. Sharing
|
|
91
|
+
* one with {@link EncryptedCookieSessionStore} does not let either store open
|
|
92
|
+
* the other's cookies: each derives its key with HKDF-SHA-256 under its own
|
|
93
|
+
* label.
|
|
87
94
|
*/
|
|
88
95
|
secret: string;
|
|
89
96
|
/** Cookie attribute overrides. */
|
|
@@ -138,8 +145,12 @@ export interface AuthRequestStorageFactory {
|
|
|
138
145
|
* ```ts
|
|
139
146
|
* const bff = new HonoBff({
|
|
140
147
|
* client,
|
|
141
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
142
|
-
*
|
|
148
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
149
|
+
* secret: process.env.SESSION_SECRET!,
|
|
150
|
+
* }),
|
|
151
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
152
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
153
|
+
* }),
|
|
143
154
|
* });
|
|
144
155
|
* ```
|
|
145
156
|
*/
|
|
@@ -171,7 +182,7 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
|
|
|
171
182
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
172
183
|
*
|
|
173
184
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
174
|
-
* secret: process.env.
|
|
185
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
175
186
|
* });
|
|
176
187
|
* ```
|
|
177
188
|
*/
|
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* caller chooses.
|
|
12
12
|
*
|
|
13
13
|
* {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
|
|
14
|
-
* cookie instead (AES-GCM under a server-held secret,
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* cookie instead (AES-GCM under a key derived from a server-held secret), so no
|
|
15
|
+
* server-side storage is involved and any instance can complete the flow. Apps
|
|
16
|
+
* that already have a database can implement {@link AuthRequestStorage} against
|
|
17
|
+
* it instead.
|
|
18
18
|
*
|
|
19
19
|
* **Which storage you pick is a reliability decision, not a security one.**
|
|
20
20
|
* Server-side storage on its own says a `state` is pending somewhere — never
|
|
@@ -29,13 +29,17 @@
|
|
|
29
29
|
*/
|
|
30
30
|
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
|
|
31
31
|
import { MemoryAuthRequestStorage, } from "../../../client/mod.js";
|
|
32
|
-
import {
|
|
32
|
+
import { sealJson, unsealJson } from "../../../utils/crypto.js";
|
|
33
|
+
import { AUTH_REQUEST_COOKIE_KEY_INFO, deriveCookieKey, } from "./_cookie-keys.js";
|
|
33
34
|
import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
|
|
34
35
|
const BASE_NAME = "oauth2_auth_request";
|
|
35
36
|
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
36
37
|
const DEFAULT_MAX_PENDING = 3;
|
|
37
38
|
// RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
|
|
38
39
|
const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
|
|
40
|
+
function isObject(value) {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
39
43
|
function cookieAttributes(options, maxAgeSeconds) {
|
|
40
44
|
const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
|
|
41
45
|
return {
|
|
@@ -65,8 +69,12 @@ function cookieAttributes(options, maxAgeSeconds) {
|
|
|
65
69
|
* ```ts
|
|
66
70
|
* const bff = new HonoBff({
|
|
67
71
|
* client,
|
|
68
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
69
|
-
*
|
|
72
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
73
|
+
* secret: process.env.SESSION_SECRET!,
|
|
74
|
+
* }),
|
|
75
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
76
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
77
|
+
* }),
|
|
70
78
|
* });
|
|
71
79
|
* ```
|
|
72
80
|
*/
|
|
@@ -107,17 +115,19 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
107
115
|
}
|
|
108
116
|
}
|
|
109
117
|
#key() {
|
|
110
|
-
return (this.#keyPromise ??=
|
|
118
|
+
return (this.#keyPromise ??= deriveCookieKey(this.#secret, AUTH_REQUEST_COOKIE_KEY_INFO));
|
|
111
119
|
}
|
|
112
120
|
async #read(c) {
|
|
113
121
|
const cookieValue = getCookie(c, this.#name);
|
|
114
122
|
if (!cookieValue)
|
|
115
123
|
return {};
|
|
116
124
|
const pending = await unsealJson(await this.#key(), cookieValue);
|
|
117
|
-
if (!pending)
|
|
125
|
+
if (!isObject(pending))
|
|
118
126
|
return {};
|
|
119
127
|
const cutoff = Date.now() - this.#ttlMs;
|
|
120
|
-
return Object.fromEntries(Object.entries(pending).filter((
|
|
128
|
+
return Object.fromEntries(Object.entries(pending).filter((entry) => isObject(entry[1]) &&
|
|
129
|
+
typeof entry[1].createdAt === "number" &&
|
|
130
|
+
entry[1].createdAt > cutoff));
|
|
121
131
|
}
|
|
122
132
|
async #write(c, pending, keep) {
|
|
123
133
|
const entries = Object.entries(pending)
|
|
@@ -143,7 +153,10 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
143
153
|
/** Returns the storage bound to `c`, for the duration of that request. */
|
|
144
154
|
forRequest(c) {
|
|
145
155
|
return {
|
|
146
|
-
get: async (state) =>
|
|
156
|
+
get: async (state) => {
|
|
157
|
+
const pending = await this.#read(c);
|
|
158
|
+
return Object.hasOwn(pending, state) ? pending[state] : null;
|
|
159
|
+
},
|
|
147
160
|
set: async (state, value) => {
|
|
148
161
|
const pending = await this.#read(c);
|
|
149
162
|
pending[state] = value;
|
|
@@ -168,7 +181,7 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
168
181
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
169
182
|
*
|
|
170
183
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
171
|
-
* secret: process.env.
|
|
184
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
172
185
|
* });
|
|
173
186
|
* ```
|
|
174
187
|
*/
|
|
@@ -109,7 +109,8 @@ export interface RefreshLockStore {
|
|
|
109
109
|
* longer overwrite or end the session that instance refreshed. A store
|
|
110
110
|
* that calls `refresh()` with no lease still works, but a lock that
|
|
111
111
|
* expires mid-refresh then lets the former holder's write or sign-out
|
|
112
|
-
* land on top of the new holder's.
|
|
112
|
+
* land on top of the new holder's. `runSessionStoreContractTests` fails
|
|
113
|
+
* such a store unless its options declare that the lock never expires.
|
|
113
114
|
* - Reject when the lock cannot be acquired within a bound you choose.
|
|
114
115
|
* `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
|
|
115
116
|
* session.
|
|
@@ -282,6 +283,13 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
282
283
|
* Server-held secret(s) used to derive the AES-256 key(s). Each must have at
|
|
283
284
|
* least 32 bytes of entropy; pass a base64 / hex string or raw bytes.
|
|
284
285
|
*
|
|
286
|
+
* Give each store instance and each app a secret of its own. Nothing in a
|
|
287
|
+
* sealed cookie binds it to the app or store instance that sealed it, so two
|
|
288
|
+
* apps that share a secret can open each other's session cookies. Sharing one
|
|
289
|
+
* with `EncryptedCookieAuthRequestStorage` does not let either store open the
|
|
290
|
+
* other's cookies: each derives its key with HKDF-SHA-256 under its own
|
|
291
|
+
* label.
|
|
292
|
+
*
|
|
285
293
|
* Pass an **ordered list** to rotate the secret without signing everyone out:
|
|
286
294
|
* the **first** entry is current — every cookie is sealed under it — and
|
|
287
295
|
* `read` tries each entry in turn, so a cookie sealed under a still-listed
|
|
@@ -307,10 +315,6 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
307
315
|
* `update` re-seals with a fresh stamp, so this is an **inactivity** window
|
|
308
316
|
* for a session the BFF keeps refreshing. Must be a positive number of
|
|
309
317
|
* milliseconds.
|
|
310
|
-
*
|
|
311
|
-
* The store stamps each cookie with a seal time at `create`/`update`; a
|
|
312
|
-
* cookie without that stamp is aged from its `updatedAt` instead, so it is
|
|
313
|
-
* bounded too.
|
|
314
318
|
*/
|
|
315
319
|
maxAgeMs?: number;
|
|
316
320
|
}
|
|
@@ -321,6 +325,11 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
321
325
|
* Security notes:
|
|
322
326
|
* - Each secret must have at least 256 bits of entropy. A weak secret defeats
|
|
323
327
|
* the confidentiality of the cookie.
|
|
328
|
+
* - The key is derived from the secret with HKDF-SHA-256 under a label of this
|
|
329
|
+
* store's own, so a cookie sealed by another store under the same secret
|
|
330
|
+
* does not open here.
|
|
331
|
+
* - `read` accepts only what `create` sealed: a cookie that is malformed,
|
|
332
|
+
* foreign, or the wrong shape reads as `null`.
|
|
324
333
|
* - AES-GCM provides authenticated encryption; tampered cookies fail `read`
|
|
325
334
|
* (returns `null`).
|
|
326
335
|
* - `destroy` is a no-op on the server — the cookie must be cleared in the
|
|
@@ -365,8 +374,8 @@ export declare class EncryptedCookieSessionStore implements SessionStore, Bounde
|
|
|
365
374
|
create(data: SessionData): Promise<string>;
|
|
366
375
|
/**
|
|
367
376
|
* Decrypts the cookie value, trying each configured secret in turn. Returns
|
|
368
|
-
* `null` if it is tampered, malformed, sealed with an unknown secret
|
|
369
|
-
* sealed longer ago than
|
|
377
|
+
* `null` if it is tampered, malformed, sealed with an unknown secret or by
|
|
378
|
+
* another store, not in the shape `create` seals, or sealed longer ago than
|
|
370
379
|
* {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
|
|
371
380
|
*/
|
|
372
381
|
read(cookieValue: string): Promise<SessionData | null>;
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* @module
|
|
25
25
|
*/
|
|
26
26
|
import { InvalidGrantError } from "../../../errors.js";
|
|
27
|
-
import {
|
|
27
|
+
import { sealJson, unsealJson } from "../../../utils/crypto.js";
|
|
28
|
+
import { deriveCookieKey, SESSION_COOKIE_KEY_INFO } from "./_cookie-keys.js";
|
|
28
29
|
/** Whether a store implements the {@link BackchannelLogoutStore} capability. */
|
|
29
30
|
export function supportsBackchannelLogout(store) {
|
|
30
31
|
return (typeof store.destroyByLogout ===
|
|
@@ -233,6 +234,11 @@ function isSealedSession(value) {
|
|
|
233
234
|
* Security notes:
|
|
234
235
|
* - Each secret must have at least 256 bits of entropy. A weak secret defeats
|
|
235
236
|
* the confidentiality of the cookie.
|
|
237
|
+
* - The key is derived from the secret with HKDF-SHA-256 under a label of this
|
|
238
|
+
* store's own, so a cookie sealed by another store under the same secret
|
|
239
|
+
* does not open here.
|
|
240
|
+
* - `read` accepts only what `create` sealed: a cookie that is malformed,
|
|
241
|
+
* foreign, or the wrong shape reads as `null`.
|
|
236
242
|
* - AES-GCM provides authenticated encryption; tampered cookies fail `read`
|
|
237
243
|
* (returns `null`).
|
|
238
244
|
* - `destroy` is a no-op on the server — the cookie must be cleared in the
|
|
@@ -294,7 +300,7 @@ export class EncryptedCookieSessionStore {
|
|
|
294
300
|
return this.#maxAgeMs;
|
|
295
301
|
}
|
|
296
302
|
#key(index) {
|
|
297
|
-
return (this.#keyPromises[index] ??=
|
|
303
|
+
return (this.#keyPromises[index] ??= deriveCookieKey(this.#secrets[index], SESSION_COOKIE_KEY_INFO));
|
|
298
304
|
}
|
|
299
305
|
/** Encrypts the session into the cookie value under the current secret; there is no server-side record. */
|
|
300
306
|
async create(data) {
|
|
@@ -303,8 +309,8 @@ export class EncryptedCookieSessionStore {
|
|
|
303
309
|
}
|
|
304
310
|
/**
|
|
305
311
|
* Decrypts the cookie value, trying each configured secret in turn. Returns
|
|
306
|
-
* `null` if it is tampered, malformed, sealed with an unknown secret
|
|
307
|
-
* sealed longer ago than
|
|
312
|
+
* `null` if it is tampered, malformed, sealed with an unknown secret or by
|
|
313
|
+
* another store, not in the shape `create` seals, or sealed longer ago than
|
|
308
314
|
* {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
|
|
309
315
|
*/
|
|
310
316
|
async read(cookieValue) {
|
|
@@ -312,13 +318,9 @@ export class EncryptedCookieSessionStore {
|
|
|
312
318
|
const unsealed = await unsealJson(await this.#key(index), cookieValue);
|
|
313
319
|
if (unsealed === null)
|
|
314
320
|
continue;
|
|
315
|
-
if (isSealedSession(unsealed))
|
|
316
|
-
return
|
|
317
|
-
|
|
318
|
-
const legacy = unsealed;
|
|
319
|
-
return this.#isExpired(legacy.updatedAt ?? legacy.createdAt)
|
|
320
|
-
? null
|
|
321
|
-
: legacy;
|
|
321
|
+
if (!isSealedSession(unsealed))
|
|
322
|
+
return null;
|
|
323
|
+
return this.#isExpired(unsealed.issuedAt) ? null : unsealed.data;
|
|
322
324
|
}
|
|
323
325
|
return null;
|
|
324
326
|
}
|
|
@@ -165,6 +165,20 @@ export interface SessionStoreContractOptions {
|
|
|
165
165
|
* supplying this factory.
|
|
166
166
|
*/
|
|
167
167
|
makeBoundedStore?(maxAgeMs: number): Promise<SessionStore> | SessionStore;
|
|
168
|
+
/**
|
|
169
|
+
* Set to `true` when the store's refresh lock never expires, so a holder
|
|
170
|
+
* keeps it until its `refresh` settles, as `MemorySessionStore` does by
|
|
171
|
+
* default. The suite then skips the lease checks for a store whose
|
|
172
|
+
* `withRefreshLock` passes no {@link RefreshLease}, instead of failing it.
|
|
173
|
+
*
|
|
174
|
+
* Without a lease, nothing stops a holder that lost an expiring lock from
|
|
175
|
+
* writing or destroying the session, and the suite cannot tell a lock that
|
|
176
|
+
* never expires from one that does. Declare it only when no holder can lose
|
|
177
|
+
* the lock while its refresh runs, as when the lock lives in the one process
|
|
178
|
+
* that runs every refresh. Then a holder that hangs holds the session's
|
|
179
|
+
* refresh until it settles. Defaults to `false`.
|
|
180
|
+
*/
|
|
181
|
+
refreshLockNeverExpires?: boolean;
|
|
168
182
|
}
|
|
169
183
|
/**
|
|
170
184
|
* Contract test suite for {@link SessionStore} implementations. Registers
|
|
@@ -175,12 +189,13 @@ export interface SessionStoreContractOptions {
|
|
|
175
189
|
*
|
|
176
190
|
* A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
|
|
177
191
|
* runs the lock checks; they are skipped for a store without it. The lease
|
|
178
|
-
* checks ({@link RefreshLease})
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
192
|
+
* checks ({@link RefreshLease}) fail a store whose `withRefreshLock` passes no
|
|
193
|
+
* lease, unless the options declare that its lock never expires
|
|
194
|
+
* ({@linkcode SessionStoreContractOptions.refreshLockNeverExpires}). They
|
|
195
|
+
* prove that a lease stops acting once its call releases the lock or another
|
|
196
|
+
* call holds it, not that it stops when the lock expires: expire a lease in a
|
|
197
|
+
* test of your own for that. They use one store object, so they prove the
|
|
198
|
+
* lock's behavior, not that separate instances share it: point two store
|
|
199
|
+
* objects at one backend in a test of your own for that.
|
|
185
200
|
*/
|
|
186
201
|
export declare function runSessionStoreContractTests(options: SessionStoreContractOptions): void;
|
|
@@ -204,17 +204,19 @@ async function rejection(promise) {
|
|
|
204
204
|
*
|
|
205
205
|
* A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
|
|
206
206
|
* runs the lock checks; they are skipped for a store without it. The lease
|
|
207
|
-
* checks ({@link RefreshLease})
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
207
|
+
* checks ({@link RefreshLease}) fail a store whose `withRefreshLock` passes no
|
|
208
|
+
* lease, unless the options declare that its lock never expires
|
|
209
|
+
* ({@linkcode SessionStoreContractOptions.refreshLockNeverExpires}). They
|
|
210
|
+
* prove that a lease stops acting once its call releases the lock or another
|
|
211
|
+
* call holds it, not that it stops when the lock expires: expire a lease in a
|
|
212
|
+
* test of your own for that. They use one store object, so they prove the
|
|
213
|
+
* lock's behavior, not that separate instances share it: point two store
|
|
214
|
+
* objects at one backend in a test of your own for that.
|
|
214
215
|
*/
|
|
215
216
|
export function runSessionStoreContractTests(options) {
|
|
216
217
|
const destroyClearsRead = options.destroyClearsRead ?? true;
|
|
217
218
|
const makeBoundedStore = options.makeBoundedStore;
|
|
219
|
+
const refreshLockNeverExpires = options.refreshLockNeverExpires ?? false;
|
|
218
220
|
describe(options.describeName ?? "SessionStore contract", () => {
|
|
219
221
|
let store;
|
|
220
222
|
beforeEach(async () => {
|
|
@@ -375,9 +377,13 @@ export function runSessionStoreContractTests(options) {
|
|
|
375
377
|
});
|
|
376
378
|
async function leaseOf(locking, value, skip) {
|
|
377
379
|
const lease = await locking.withRefreshLock(value, (given) => Promise.resolve(given));
|
|
378
|
-
skip(lease === undefined, "withRefreshLock passes the refresh no lease,
|
|
379
|
-
"
|
|
380
|
-
|
|
380
|
+
skip(lease === undefined && refreshLockNeverExpires, "withRefreshLock passes the refresh no lease, and the store declares " +
|
|
381
|
+
"that its lock never expires");
|
|
382
|
+
assert(lease !== undefined, "withRefreshLock passed the refresh no lease, so a holder that " +
|
|
383
|
+
"stalls past an expiring lock can still write and destroy the " +
|
|
384
|
+
"session another holder refreshed. Pass a RefreshLease, or set " +
|
|
385
|
+
"refreshLockNeverExpires: true if the lock never expires.");
|
|
386
|
+
return lease;
|
|
381
387
|
}
|
|
382
388
|
function refreshedSession() {
|
|
383
389
|
return {
|
|
@@ -82,6 +82,13 @@ export interface TokenRotationContractOptions<C extends ClientInterface, U exten
|
|
|
82
82
|
* longer to reach the lock they wait on.
|
|
83
83
|
*/
|
|
84
84
|
blockedAfterMs?: number;
|
|
85
|
+
/**
|
|
86
|
+
* Returns a new rotation-family id the store accepts as a
|
|
87
|
+
* {@linkcode RefreshToken.familyId}, distinct on every call. Each case calls
|
|
88
|
+
* it for the two families it uses. Set it when the store keeps family ids in
|
|
89
|
+
* a format other than a UUID. Defaults to `crypto.randomUUID()`.
|
|
90
|
+
*/
|
|
91
|
+
makeFamilyId?(): string;
|
|
85
92
|
/** Overrides the name passed to the outer `describe` block. */
|
|
86
93
|
describeName?: string;
|
|
87
94
|
}
|
|
@@ -55,6 +55,7 @@ export function runTokenRotationContractTests(options) {
|
|
|
55
55
|
const makeClient = options.makeClient ?? defaultMakeClient;
|
|
56
56
|
const pausable = options.rotateAwaitsBarrier ?? true;
|
|
57
57
|
const blockedAfterMs = options.blockedAfterMs ?? 100;
|
|
58
|
+
const makeFamilyId = options.makeFamilyId ?? (() => crypto.randomUUID());
|
|
58
59
|
describe(options.describeName ?? "TokenServiceInterface.rotate contract", () => {
|
|
59
60
|
let clientService;
|
|
60
61
|
let tokenService;
|
|
@@ -75,8 +76,8 @@ export function runTokenRotationContractTests(options) {
|
|
|
75
76
|
};
|
|
76
77
|
beforeEach(async () => {
|
|
77
78
|
armed = undefined;
|
|
78
|
-
family =
|
|
79
|
-
otherFamily =
|
|
79
|
+
family = makeFamilyId();
|
|
80
|
+
otherFamily = makeFamilyId();
|
|
80
81
|
const services = await options.makeServices(barriers);
|
|
81
82
|
clientService = services.clientService;
|
|
82
83
|
tokenService = services.tokenService;
|
|
@@ -46,6 +46,25 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
46
46
|
makeClient?(seq: number): C;
|
|
47
47
|
/** Builds a scope from its string value. Defaults to a {@linkcode BasicScope}. */
|
|
48
48
|
makeScope?(value: string): S;
|
|
49
|
+
/**
|
|
50
|
+
* A scope value the service accepts for the fixture client. The
|
|
51
|
+
* `acceptedScope` case with a user requests it and expects it back
|
|
52
|
+
* unchanged; the case with no user requests it and expects it not to be
|
|
53
|
+
* refused. Set it when `acceptedScope` refuses scopes outside a fixed
|
|
54
|
+
* vocabulary, for example `"openid"`. Defaults to `"read"`.
|
|
55
|
+
*/
|
|
56
|
+
supportedScope?: string;
|
|
57
|
+
/**
|
|
58
|
+
* Returns a new rotation-family id the store accepts as a
|
|
59
|
+
* {@linkcode RefreshToken.familyId}, distinct on every call. The family cases
|
|
60
|
+
* call it for each family they create and for a family that was never
|
|
61
|
+
* issued, so each case sees only its own families. Users, clients and the
|
|
62
|
+
* authorization-code and unknown-token probes keep fixed values, so a store
|
|
63
|
+
* that keeps state across cases still needs `makeServices` to isolate those.
|
|
64
|
+
* Set it when the store keeps family ids in a format other than a UUID.
|
|
65
|
+
* Defaults to `crypto.randomUUID()`.
|
|
66
|
+
*/
|
|
67
|
+
makeFamilyId?(): string;
|
|
49
68
|
/**
|
|
50
69
|
* Set to `false` when the service deliberately omits the optional
|
|
51
70
|
* reuse-detection pair, {@linkcode TokenServiceInterface.getRevokedRefreshToken}
|
|
@@ -26,11 +26,14 @@ const defaultMakeClient = (seq) => ({
|
|
|
26
26
|
grants: ["authorization_code", "refresh_token"],
|
|
27
27
|
});
|
|
28
28
|
const defaultMakeScope = (value) => new BasicScope(value);
|
|
29
|
+
const defaultMakeFamilyId = () => crypto.randomUUID();
|
|
29
30
|
/** Runs the {@link TokenServiceInterface} contract suite. */
|
|
30
31
|
export function runTokenServiceContractTests(options) {
|
|
31
32
|
const makeUser = options.makeUser ?? defaultMakeUser;
|
|
32
33
|
const makeClient = options.makeClient ?? defaultMakeClient;
|
|
33
34
|
const makeScope = options.makeScope ?? defaultMakeScope;
|
|
35
|
+
const supportedScope = options.supportedScope ?? "read";
|
|
36
|
+
const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
|
|
34
37
|
const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
|
|
35
38
|
describe(options.describeName ?? "TokenServiceInterface contract", () => {
|
|
36
39
|
let userService;
|
|
@@ -66,9 +69,9 @@ export function runTokenServiceContractTests(options) {
|
|
|
66
69
|
});
|
|
67
70
|
describe("acceptedScope", () => {
|
|
68
71
|
it("returns the requested scope unchanged for the simple case", async () => {
|
|
69
|
-
const requested = makeScope(
|
|
72
|
+
const requested = makeScope(supportedScope);
|
|
70
73
|
const result = await tokenService.acceptedScope(client, user, requested);
|
|
71
|
-
expect(result?.toString()).toStrictEqual(
|
|
74
|
+
expect(result?.toString()).toStrictEqual(supportedScope);
|
|
72
75
|
});
|
|
73
76
|
});
|
|
74
77
|
describe("save + getToken", () => {
|
|
@@ -97,7 +100,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
97
100
|
expect(typeof accessToken).toStrictEqual("string");
|
|
98
101
|
});
|
|
99
102
|
it("accepts scope for a client with no user", async () => {
|
|
100
|
-
const result = await tokenService.acceptedScope(client, undefined, makeScope(
|
|
103
|
+
const result = await tokenService.acceptedScope(client, undefined, makeScope(supportedScope));
|
|
101
104
|
assert(result !== false, "scope must not be rejected for lack of a user");
|
|
102
105
|
});
|
|
103
106
|
it("dates the expiry of an access token with no user", async () => {
|
|
@@ -214,8 +217,12 @@ export function runTokenServiceContractTests(options) {
|
|
|
214
217
|
});
|
|
215
218
|
if (expectReuseDetection) {
|
|
216
219
|
describe("refresh-token rotation family", () => {
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
let family;
|
|
221
|
+
let otherFamily;
|
|
222
|
+
beforeEach(() => {
|
|
223
|
+
family = makeFamilyId();
|
|
224
|
+
otherFamily = makeFamilyId();
|
|
225
|
+
});
|
|
219
226
|
async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
|
|
220
227
|
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
221
228
|
const refreshToken = (await tokenService.generateRefreshToken(client, user));
|
|
@@ -244,16 +251,16 @@ export function runTokenServiceContractTests(options) {
|
|
|
244
251
|
"`refreshTokenReuseDetection: false` to record the gap.");
|
|
245
252
|
});
|
|
246
253
|
it("keeps a rotated-out refresh token findable, with its family", async () => {
|
|
247
|
-
const token = await saveFamilyMember(
|
|
254
|
+
const token = await saveFamilyMember(family);
|
|
248
255
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
249
256
|
expect(await tokenService.getRefreshToken(token.refreshToken), "a rotated-out token must no longer refresh").toBe(undefined);
|
|
250
257
|
const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
|
|
251
258
|
assert(revoked !== undefined, "a revoked refresh token must stay findable — deleting the row " +
|
|
252
259
|
"is what turns a replay into an ordinary unknown token");
|
|
253
|
-
expect(revoked.familyId).toBe(
|
|
260
|
+
expect(revoked.familyId).toBe(family);
|
|
254
261
|
});
|
|
255
262
|
it("does not report a live refresh token as revoked", async () => {
|
|
256
|
-
const token = await saveFamilyMember(
|
|
263
|
+
const token = await saveFamilyMember(family);
|
|
257
264
|
expect(await tokenService.getRevokedRefreshToken(token.refreshToken), "reporting a live token as revoked turns every legitimate " +
|
|
258
265
|
"refresh into a detected replay").toBe(undefined);
|
|
259
266
|
});
|
|
@@ -261,28 +268,52 @@ export function runTokenServiceContractTests(options) {
|
|
|
261
268
|
expect(await tokenService.getRevokedRefreshToken("never-issued")).toBe(undefined);
|
|
262
269
|
});
|
|
263
270
|
it("revokes every live member of a replayed token's family", async () => {
|
|
264
|
-
const first = await saveFamilyMember(
|
|
265
|
-
const second = await saveFamilyMember(
|
|
266
|
-
expect(await tokenService.revokeFamily(
|
|
271
|
+
const first = await saveFamilyMember(family);
|
|
272
|
+
const second = await saveFamilyMember(family);
|
|
273
|
+
expect(await tokenService.revokeFamily(family)).toBe(true);
|
|
267
274
|
expect(await tokenService.getRefreshToken(first.refreshToken)).toBe(undefined);
|
|
268
275
|
expect(await tokenService.getRefreshToken(second.refreshToken)).toBe(undefined);
|
|
269
276
|
expect(await tokenService.getToken(second.accessToken), "the family's access tokens die with it — leaving them live " +
|
|
270
277
|
"keeps the thief's session alive for their remaining lifetime").toBe(undefined);
|
|
271
278
|
});
|
|
272
279
|
it("leaves other families untouched", async () => {
|
|
273
|
-
const mine = await saveFamilyMember(
|
|
274
|
-
const theirs = await saveFamilyMember(
|
|
275
|
-
await tokenService.revokeFamily(
|
|
280
|
+
const mine = await saveFamilyMember(family);
|
|
281
|
+
const theirs = await saveFamilyMember(otherFamily);
|
|
282
|
+
await tokenService.revokeFamily(family);
|
|
276
283
|
expect(await tokenService.getRefreshToken(mine.refreshToken)).toBe(undefined);
|
|
277
284
|
assert((await tokenService.getRefreshToken(theirs.refreshToken)) !==
|
|
278
285
|
undefined, "one compromised family must not sign every other session out");
|
|
279
286
|
});
|
|
280
287
|
it("returns false when the family has no live tokens", async () => {
|
|
281
|
-
expect(await tokenService.revokeFamily(
|
|
288
|
+
expect(await tokenService.revokeFamily(makeFamilyId())).toBe(false);
|
|
289
|
+
});
|
|
290
|
+
it("keeps a revoked family revoked: a successor saved after revokeFamily is not live", async ({ skip, }) => {
|
|
291
|
+
skip(typeof tokenService.rotate === "function", "the service implements rotate, so the refresh grant never saves " +
|
|
292
|
+
"a successor separately; runTokenRotationContractTests checks " +
|
|
293
|
+
"revokeFamily against rotate");
|
|
294
|
+
const parent = await saveFamilyMember(family);
|
|
295
|
+
const successor = await familyMember(family);
|
|
296
|
+
const claimed = tokenService.revokeRotated
|
|
297
|
+
? await tokenService.revokeRotated(parent)
|
|
298
|
+
: await tokenService.revoke(parent);
|
|
299
|
+
expect(claimed).toBe(true);
|
|
300
|
+
expect(await tokenService.getRefreshToken(parent.refreshToken), "the claim spends the presented refresh token").toBe(undefined);
|
|
301
|
+
await tokenService.revokeFamily(family);
|
|
302
|
+
await tokenService.save(successor);
|
|
303
|
+
expect(await tokenService.getRefreshToken(successor.refreshToken), "a successor saved after its family was revoked must be stored " +
|
|
304
|
+
"already revoked: the refresh grant claims a token and saves " +
|
|
305
|
+
"its successor in two calls, and a revokeFamily that lands " +
|
|
306
|
+
"between them otherwise leaves the successor live").toBe(undefined);
|
|
307
|
+
expect(await tokenService.getToken(successor.accessToken), "a successor saved after its family was revoked must be stored " +
|
|
308
|
+
"already revoked, its access token included").toBe(undefined);
|
|
309
|
+
if (tokenService.hasLiveRefreshToken) {
|
|
310
|
+
expect(await tokenService.hasLiveRefreshToken(family), "a successor saved after its family was revoked must be stored " +
|
|
311
|
+
"already revoked, so the family has no live member").toBe(false);
|
|
312
|
+
}
|
|
282
313
|
});
|
|
283
314
|
it("stamps a token revokeRotated revoked with its rotation time", async ({ skip, }) => {
|
|
284
315
|
skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
|
|
285
|
-
const token = await saveFamilyMember(
|
|
316
|
+
const token = await saveFamilyMember(family);
|
|
286
317
|
const before = Date.now();
|
|
287
318
|
expect(await tokenService.revokeRotated(token)).toBe(true);
|
|
288
319
|
const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
|
|
@@ -294,17 +325,17 @@ export function runTokenServiceContractTests(options) {
|
|
|
294
325
|
});
|
|
295
326
|
it("rotates in one call: the parent is spent and stamped, the successor live", async ({ skip, }) => {
|
|
296
327
|
skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
|
|
297
|
-
const parent = await saveFamilyMember(
|
|
298
|
-
const next = await familyMember(
|
|
328
|
+
const parent = await saveFamilyMember(family);
|
|
329
|
+
const next = await familyMember(family);
|
|
299
330
|
const before = Date.now();
|
|
300
331
|
const rotated = await tokenService.rotate(parent, next);
|
|
301
332
|
expect(rotated?.refreshToken).toBe(next.refreshToken);
|
|
302
|
-
expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(
|
|
333
|
+
expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(family);
|
|
303
334
|
expect(await tokenService.getToken(next.accessToken)).not.toBe(undefined);
|
|
304
335
|
expect(await tokenService.getRefreshToken(parent.refreshToken)).toBe(undefined);
|
|
305
336
|
expect(await tokenService.getToken(parent.accessToken), "rotation ends the parent's access token, as revokeRotated does").toBe(undefined);
|
|
306
337
|
const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
|
|
307
|
-
expect(tombstone?.familyId).toBe(
|
|
338
|
+
expect(tombstone?.familyId).toBe(family);
|
|
308
339
|
assert(tombstone?.rotatedAt instanceof Date &&
|
|
309
340
|
tombstone.rotatedAt.getTime() >= before - 1000 &&
|
|
310
341
|
tombstone.rotatedAt.getTime() <= Date.now() + 1000, "rotate must stamp rotatedAt as revokeRotated does, or the " +
|
|
@@ -313,11 +344,11 @@ export function runTokenServiceContractTests(options) {
|
|
|
313
344
|
});
|
|
314
345
|
it("refuses to rotate a refresh token that is no longer live, persisting nothing", async ({ skip, }) => {
|
|
315
346
|
skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
|
|
316
|
-
const parent = await saveFamilyMember(
|
|
317
|
-
const first = await familyMember(
|
|
318
|
-
const second = await familyMember(
|
|
319
|
-
const revokedParent = await saveFamilyMember(
|
|
320
|
-
const third = await familyMember(
|
|
347
|
+
const parent = await saveFamilyMember(family);
|
|
348
|
+
const first = await familyMember(family);
|
|
349
|
+
const second = await familyMember(family);
|
|
350
|
+
const revokedParent = await saveFamilyMember(family);
|
|
351
|
+
const third = await familyMember(family);
|
|
321
352
|
await tokenService.revoke(revokedParent.refreshToken, "refresh_token");
|
|
322
353
|
expect(await tokenService.rotate(parent, first)).not.toBe(undefined);
|
|
323
354
|
expect(await tokenService.rotate(parent, second), "a refresh token is claimed once").toBe(undefined);
|
|
@@ -330,12 +361,12 @@ export function runTokenServiceContractTests(options) {
|
|
|
330
361
|
});
|
|
331
362
|
it("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
|
|
332
363
|
skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
|
|
333
|
-
const rotatedOut = await saveFamilyMember(
|
|
364
|
+
const rotatedOut = await saveFamilyMember(family);
|
|
334
365
|
expect(await tokenService.revokeRotated(rotatedOut)).toBe(true);
|
|
335
|
-
const successor = await saveFamilyMember(
|
|
336
|
-
const unrelated = await saveFamilyMember(
|
|
366
|
+
const successor = await saveFamilyMember(family);
|
|
367
|
+
const unrelated = await saveFamilyMember(otherFamily);
|
|
337
368
|
const tombstone = await tokenService.getRevokedRefreshToken(rotatedOut.refreshToken);
|
|
338
|
-
assert(tombstone?.familyId ===
|
|
369
|
+
assert(tombstone?.familyId === family && tombstone.rotatedAt, "the revocation endpoint finds the family to end from the " +
|
|
339
370
|
"rotated-out token's record; without its familyId and " +
|
|
340
371
|
"rotatedAt, a sign-out racing a refresh leaves the successor live");
|
|
341
372
|
expect(tombstone.client.id.toString(), "the endpoint revokes the family only for the client the token " +
|
|
@@ -347,7 +378,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
347
378
|
undefined);
|
|
348
379
|
});
|
|
349
380
|
it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
|
|
350
|
-
const token = await saveFamilyMember(
|
|
381
|
+
const token = await saveFamilyMember(family);
|
|
351
382
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
352
383
|
const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
|
|
353
384
|
expect(revoked?.rotatedAt, "a revoked token that looks rotated gets the replay grace a " +
|
|
@@ -355,26 +386,26 @@ export function runTokenServiceContractTests(options) {
|
|
|
355
386
|
});
|
|
356
387
|
it("reports whether a family still has a live refresh token", async ({ skip, }) => {
|
|
357
388
|
skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
|
|
358
|
-
const token = await saveFamilyMember(
|
|
359
|
-
await saveFamilyMember(
|
|
360
|
-
expect(await tokenService.hasLiveRefreshToken(
|
|
389
|
+
const token = await saveFamilyMember(family);
|
|
390
|
+
await saveFamilyMember(otherFamily);
|
|
391
|
+
expect(await tokenService.hasLiveRefreshToken(family)).toBe(true);
|
|
361
392
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
362
|
-
expect(await tokenService.hasLiveRefreshToken(
|
|
363
|
-
expect(await tokenService.hasLiveRefreshToken(
|
|
364
|
-
expect(await tokenService.hasLiveRefreshToken(
|
|
393
|
+
expect(await tokenService.hasLiveRefreshToken(family), "a family whose last token was revoked has no successor to protect").toBe(false);
|
|
394
|
+
expect(await tokenService.hasLiveRefreshToken(otherFamily)).toBe(true);
|
|
395
|
+
expect(await tokenService.hasLiveRefreshToken(makeFamilyId())).toBe(false);
|
|
365
396
|
});
|
|
366
397
|
it("does not count an expired refresh token as live", async ({ skip, }) => {
|
|
367
398
|
skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
|
|
368
|
-
await saveFamilyMember(
|
|
369
|
-
expect(await tokenService.hasLiveRefreshToken(
|
|
399
|
+
await saveFamilyMember(family, new Date(Date.now() - 1000));
|
|
400
|
+
expect(await tokenService.hasLiveRefreshToken(family), "an expired successor cannot be used, so a replay of its " +
|
|
370
401
|
"predecessor must not be excused as a straggler").toBe(false);
|
|
371
402
|
});
|
|
372
403
|
async function rotateOut(skip) {
|
|
373
404
|
skip(typeof tokenService.isWithinReplayGrace !== "function", "the service does not implement isWithinReplayGrace");
|
|
374
405
|
skip(typeof tokenService.rotate !== "function" &&
|
|
375
406
|
typeof tokenService.revokeRotated !== "function", "the service implements neither rotate nor revokeRotated");
|
|
376
|
-
const parent = await saveFamilyMember(
|
|
377
|
-
const successor = await familyMember(
|
|
407
|
+
const parent = await saveFamilyMember(family);
|
|
408
|
+
const successor = await familyMember(family);
|
|
378
409
|
if (tokenService.rotate) {
|
|
379
410
|
assert(await tokenService.rotate(parent, successor));
|
|
380
411
|
}
|
|
@@ -383,7 +414,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
383
414
|
await tokenService.save(successor);
|
|
384
415
|
}
|
|
385
416
|
const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
|
|
386
|
-
assert(tombstone?.familyId ===
|
|
417
|
+
assert(tombstone?.familyId === family && tombstone.rotatedAt, "a store that judges the replay grace must still return every " +
|
|
387
418
|
"rotated-out tombstone, with its family and rotatedAt; hiding " +
|
|
388
419
|
"one inside the window hides the family from a sign-out " +
|
|
389
420
|
"racing the refresh, which then leaves the successor live");
|
package/dist/utils/crypto.d.ts
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
* Authenticated-encryption (AES-256-GCM) and key-derivation helpers for sealing
|
|
3
3
|
* session and token data at rest or in a cookie.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* a high-entropy *per-session* secret yields an encryption key that is
|
|
14
|
-
* independent of any other value derived from the same secret. This is what
|
|
15
|
-
* makes "the cookie value is both the lookup credential **and** the decryption
|
|
16
|
-
* key" safe: a database can store `sha256(secret)` as a lookup hash while the
|
|
17
|
-
* sealing key is `HKDF(secret, info)`, and knowing the stored hash never
|
|
18
|
-
* reveals the key. Always pass a distinct `info` per purpose.
|
|
5
|
+
* {@link deriveSealKey} uses **HKDF** with a domain-separating `info` label so a
|
|
6
|
+
* high-entropy secret yields an encryption key that is independent of any other
|
|
7
|
+
* value derived from the same secret. This is what makes "the cookie value is
|
|
8
|
+
* both the lookup credential **and** the decryption key" safe: a database can
|
|
9
|
+
* store `sha256(secret)` as a lookup hash while the sealing key is
|
|
10
|
+
* `HKDF(secret, info)`, and knowing the stored hash never reveals the key. It is
|
|
11
|
+
* also what lets one secret seal two kinds of value without either opening as
|
|
12
|
+
* the other. Always pass a distinct `info` per purpose.
|
|
19
13
|
*
|
|
20
14
|
* {@link seal}/{@link unseal} use AES-256-GCM (an AEAD), so tampered ciphertext
|
|
21
15
|
* fails to unseal (returns `null`) rather than decrypting to garbage.
|
|
@@ -61,18 +55,6 @@ export declare function base64urlDecode(value: string): Uint8Array;
|
|
|
61
55
|
* ```
|
|
62
56
|
*/
|
|
63
57
|
export declare function randomToken(bytes?: number): string;
|
|
64
|
-
/**
|
|
65
|
-
* Derive an AES-256-GCM key from a single server-held secret via SHA-256.
|
|
66
|
-
*
|
|
67
|
-
* Suitable when the secret is a long-lived configuration value that is never
|
|
68
|
-
* stored alongside the ciphertext. Do **not** use this when a hash of the same
|
|
69
|
-
* secret is persisted (e.g. as a lookup key) — use {@link deriveSealKey} so the
|
|
70
|
-
* sealing key is independent of that stored hash.
|
|
71
|
-
*
|
|
72
|
-
* @throws {Error} if the secret is shorter than 16 bytes. Length is all that is
|
|
73
|
-
* checked; supply 32+ bytes of real entropy.
|
|
74
|
-
*/
|
|
75
|
-
export declare function deriveAesKey(secret: string | Uint8Array): Promise<CryptoKey>;
|
|
76
58
|
/**
|
|
77
59
|
* Derive an AES-256-GCM sealing key from a high-entropy secret via HKDF-SHA-256
|
|
78
60
|
* with a domain-separating `info` label.
|
package/dist/utils/crypto.js
CHANGED
|
@@ -2,20 +2,14 @@
|
|
|
2
2
|
* Authenticated-encryption (AES-256-GCM) and key-derivation helpers for sealing
|
|
3
3
|
* session and token data at rest or in a cookie.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* a high-entropy *per-session* secret yields an encryption key that is
|
|
14
|
-
* independent of any other value derived from the same secret. This is what
|
|
15
|
-
* makes "the cookie value is both the lookup credential **and** the decryption
|
|
16
|
-
* key" safe: a database can store `sha256(secret)` as a lookup hash while the
|
|
17
|
-
* sealing key is `HKDF(secret, info)`, and knowing the stored hash never
|
|
18
|
-
* reveals the key. Always pass a distinct `info` per purpose.
|
|
5
|
+
* {@link deriveSealKey} uses **HKDF** with a domain-separating `info` label so a
|
|
6
|
+
* high-entropy secret yields an encryption key that is independent of any other
|
|
7
|
+
* value derived from the same secret. This is what makes "the cookie value is
|
|
8
|
+
* both the lookup credential **and** the decryption key" safe: a database can
|
|
9
|
+
* store `sha256(secret)` as a lookup hash while the sealing key is
|
|
10
|
+
* `HKDF(secret, info)`, and knowing the stored hash never reveals the key. It is
|
|
11
|
+
* also what lets one secret seal two kinds of value without either opening as
|
|
12
|
+
* the other. Always pass a distinct `info` per purpose.
|
|
19
13
|
*
|
|
20
14
|
* {@link seal}/{@link unseal} use AES-256-GCM (an AEAD), so tampered ciphertext
|
|
21
15
|
* fails to unseal (returns `null`) rather than decrypting to garbage.
|
|
@@ -79,25 +73,6 @@ export function base64urlDecode(value) {
|
|
|
79
73
|
export function randomToken(bytes = 32) {
|
|
80
74
|
return base64urlEncode(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
81
75
|
}
|
|
82
|
-
/**
|
|
83
|
-
* Derive an AES-256-GCM key from a single server-held secret via SHA-256.
|
|
84
|
-
*
|
|
85
|
-
* Suitable when the secret is a long-lived configuration value that is never
|
|
86
|
-
* stored alongside the ciphertext. Do **not** use this when a hash of the same
|
|
87
|
-
* secret is persisted (e.g. as a lookup key) — use {@link deriveSealKey} so the
|
|
88
|
-
* sealing key is independent of that stored hash.
|
|
89
|
-
*
|
|
90
|
-
* @throws {Error} if the secret is shorter than 16 bytes. Length is all that is
|
|
91
|
-
* checked; supply 32+ bytes of real entropy.
|
|
92
|
-
*/
|
|
93
|
-
export async function deriveAesKey(secret) {
|
|
94
|
-
const raw = toBytes(secret);
|
|
95
|
-
if (raw.byteLength < 16) {
|
|
96
|
-
throw new Error("secret must be at least 16 bytes; 32+ bytes of entropy is recommended.");
|
|
97
|
-
}
|
|
98
|
-
const digest = await crypto.subtle.digest("SHA-256", toArrayBuffer(raw));
|
|
99
|
-
return await crypto.subtle.importKey("raw", digest, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
|
100
|
-
}
|
|
101
76
|
/**
|
|
102
77
|
* Derive an AES-256-GCM sealing key from a high-entropy secret via HKDF-SHA-256
|
|
103
78
|
* with a domain-separating `info` label.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"authorization-server",
|