@udibo/oauth2 0.33.0 → 0.35.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 +22 -8
- package/dist/adapters/hono/bff/auth-request-store.js +29 -13
- package/dist/adapters/hono/bff/session-store.d.ts +14 -6
- package/dist/adapters/hono/bff/session-store.js +13 -11
- package/dist/cli/idp/server.js +2 -1
- package/dist/server/authorization-server.d.ts +32 -2
- package/dist/server/authorization-server.js +21 -2
- package/dist/server/grants/authorization-code.d.ts +56 -5
- package/dist/server/grants/authorization-code.js +97 -21
- package/dist/server/services/token.d.ts +51 -9
- package/dist/testing/contract/code-exchange.d.ts +100 -0
- package/dist/testing/contract/code-exchange.js +309 -0
- package/dist/testing/contract/mod.d.ts +6 -0
- package/dist/testing/contract/mod.js +6 -0
- package/dist/testing/contract/token.d.ts +8 -0
- package/dist/testing/contract/token.js +27 -0
- package/dist/testing/server.js +3 -2
- package/dist/testing/services.d.ts +23 -5
- package/dist/testing/services.js +40 -8
- 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. */
|
|
@@ -132,14 +139,21 @@ export interface AuthRequestStorageFactory {
|
|
|
132
139
|
*
|
|
133
140
|
* It has no `take`: the record travels with the browser and cannot be claimed
|
|
134
141
|
* atomically, so a replayed callback is bounded by the authorization server's
|
|
135
|
-
* single-use code and PKCE.
|
|
142
|
+
* single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
|
|
143
|
+
* the token endpoint, and an authorization server that revokes on code replay,
|
|
144
|
+
* as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
|
|
145
|
+
* user is signed out and has to sign in again.
|
|
136
146
|
*
|
|
137
147
|
* @example
|
|
138
148
|
* ```ts
|
|
139
149
|
* const bff = new HonoBff({
|
|
140
150
|
* client,
|
|
141
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
142
|
-
*
|
|
151
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
152
|
+
* secret: process.env.SESSION_SECRET!,
|
|
153
|
+
* }),
|
|
154
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
155
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
156
|
+
* }),
|
|
143
157
|
* });
|
|
144
158
|
* ```
|
|
145
159
|
*/
|
|
@@ -171,7 +185,7 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
|
|
|
171
185
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
172
186
|
*
|
|
173
187
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
174
|
-
* secret: process.env.
|
|
188
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
175
189
|
* });
|
|
176
190
|
* ```
|
|
177
191
|
*/
|
|
@@ -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 {
|
|
@@ -59,14 +63,21 @@ function cookieAttributes(options, maxAgeSeconds) {
|
|
|
59
63
|
*
|
|
60
64
|
* It has no `take`: the record travels with the browser and cannot be claimed
|
|
61
65
|
* atomically, so a replayed callback is bounded by the authorization server's
|
|
62
|
-
* single-use code and PKCE.
|
|
66
|
+
* single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
|
|
67
|
+
* the token endpoint, and an authorization server that revokes on code replay,
|
|
68
|
+
* as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
|
|
69
|
+
* user is signed out and has to sign in again.
|
|
63
70
|
*
|
|
64
71
|
* @example
|
|
65
72
|
* ```ts
|
|
66
73
|
* const bff = new HonoBff({
|
|
67
74
|
* client,
|
|
68
|
-
* sessionStore: new EncryptedCookieSessionStore({
|
|
69
|
-
*
|
|
75
|
+
* sessionStore: new EncryptedCookieSessionStore({
|
|
76
|
+
* secret: process.env.SESSION_SECRET!,
|
|
77
|
+
* }),
|
|
78
|
+
* authRequestStorage: new EncryptedCookieAuthRequestStorage({
|
|
79
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
80
|
+
* }),
|
|
70
81
|
* });
|
|
71
82
|
* ```
|
|
72
83
|
*/
|
|
@@ -107,17 +118,19 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
107
118
|
}
|
|
108
119
|
}
|
|
109
120
|
#key() {
|
|
110
|
-
return (this.#keyPromise ??=
|
|
121
|
+
return (this.#keyPromise ??= deriveCookieKey(this.#secret, AUTH_REQUEST_COOKIE_KEY_INFO));
|
|
111
122
|
}
|
|
112
123
|
async #read(c) {
|
|
113
124
|
const cookieValue = getCookie(c, this.#name);
|
|
114
125
|
if (!cookieValue)
|
|
115
126
|
return {};
|
|
116
127
|
const pending = await unsealJson(await this.#key(), cookieValue);
|
|
117
|
-
if (!pending)
|
|
128
|
+
if (!isObject(pending))
|
|
118
129
|
return {};
|
|
119
130
|
const cutoff = Date.now() - this.#ttlMs;
|
|
120
|
-
return Object.fromEntries(Object.entries(pending).filter((
|
|
131
|
+
return Object.fromEntries(Object.entries(pending).filter((entry) => isObject(entry[1]) &&
|
|
132
|
+
typeof entry[1].createdAt === "number" &&
|
|
133
|
+
entry[1].createdAt > cutoff));
|
|
121
134
|
}
|
|
122
135
|
async #write(c, pending, keep) {
|
|
123
136
|
const entries = Object.entries(pending)
|
|
@@ -143,7 +156,10 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
143
156
|
/** Returns the storage bound to `c`, for the duration of that request. */
|
|
144
157
|
forRequest(c) {
|
|
145
158
|
return {
|
|
146
|
-
get: async (state) =>
|
|
159
|
+
get: async (state) => {
|
|
160
|
+
const pending = await this.#read(c);
|
|
161
|
+
return Object.hasOwn(pending, state) ? pending[state] : null;
|
|
162
|
+
},
|
|
147
163
|
set: async (state, value) => {
|
|
148
164
|
const pending = await this.#read(c);
|
|
149
165
|
pending[state] = value;
|
|
@@ -168,7 +184,7 @@ export class EncryptedCookieAuthRequestStorage {
|
|
|
168
184
|
* import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
|
|
169
185
|
*
|
|
170
186
|
* const authRequestStorage = encryptedCookieAuthRequestStorage({
|
|
171
|
-
* secret: process.env.
|
|
187
|
+
* secret: process.env.AUTH_REQUEST_SECRET!,
|
|
172
188
|
* });
|
|
173
189
|
* ```
|
|
174
190
|
*/
|
|
@@ -283,6 +283,13 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
283
283
|
* Server-held secret(s) used to derive the AES-256 key(s). Each must have at
|
|
284
284
|
* least 32 bytes of entropy; pass a base64 / hex string or raw bytes.
|
|
285
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
|
+
*
|
|
286
293
|
* Pass an **ordered list** to rotate the secret without signing everyone out:
|
|
287
294
|
* the **first** entry is current — every cookie is sealed under it — and
|
|
288
295
|
* `read` tries each entry in turn, so a cookie sealed under a still-listed
|
|
@@ -308,10 +315,6 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
308
315
|
* `update` re-seals with a fresh stamp, so this is an **inactivity** window
|
|
309
316
|
* for a session the BFF keeps refreshing. Must be a positive number of
|
|
310
317
|
* milliseconds.
|
|
311
|
-
*
|
|
312
|
-
* The store stamps each cookie with a seal time at `create`/`update`; a
|
|
313
|
-
* cookie without that stamp is aged from its `updatedAt` instead, so it is
|
|
314
|
-
* bounded too.
|
|
315
318
|
*/
|
|
316
319
|
maxAgeMs?: number;
|
|
317
320
|
}
|
|
@@ -322,6 +325,11 @@ export interface EncryptedCookieSessionStoreOptions {
|
|
|
322
325
|
* Security notes:
|
|
323
326
|
* - Each secret must have at least 256 bits of entropy. A weak secret defeats
|
|
324
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`.
|
|
325
333
|
* - AES-GCM provides authenticated encryption; tampered cookies fail `read`
|
|
326
334
|
* (returns `null`).
|
|
327
335
|
* - `destroy` is a no-op on the server — the cookie must be cleared in the
|
|
@@ -366,8 +374,8 @@ export declare class EncryptedCookieSessionStore implements SessionStore, Bounde
|
|
|
366
374
|
create(data: SessionData): Promise<string>;
|
|
367
375
|
/**
|
|
368
376
|
* Decrypts the cookie value, trying each configured secret in turn. Returns
|
|
369
|
-
* `null` if it is tampered, malformed, sealed with an unknown secret
|
|
370
|
-
* 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
|
|
371
379
|
* {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
|
|
372
380
|
*/
|
|
373
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
|
}
|
package/dist/cli/idp/server.js
CHANGED
|
@@ -55,12 +55,13 @@ function newSession(userId, signedInFor) {
|
|
|
55
55
|
async function createState(config, signingKey, issuer) {
|
|
56
56
|
const userService = new MemoryUserService();
|
|
57
57
|
const clientService = new MemoryClientService(userService);
|
|
58
|
+
const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
|
|
58
59
|
const tokenService = new MemoryTokenService({
|
|
59
60
|
clientService,
|
|
60
61
|
userService,
|
|
61
62
|
accessTokenLifetime: config.accessTokenLifetime,
|
|
63
|
+
authorizationCodeService,
|
|
62
64
|
});
|
|
63
|
-
const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
|
|
64
65
|
const grants = {};
|
|
65
66
|
if (config.grants.authorization_code) {
|
|
66
67
|
grants.authorization_code = new AuthorizationCodeGrant({
|
|
@@ -314,6 +314,35 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
|
|
|
314
314
|
* contains a space or non-ASCII character.
|
|
315
315
|
*/
|
|
316
316
|
acrValuesSupported?: readonly string[];
|
|
317
|
+
/**
|
|
318
|
+
* Called when the revocation endpoint revokes the rotation family of a
|
|
319
|
+
* refresh token that rotation already replaced — the client presented a
|
|
320
|
+
* token that is no longer live, so record it alongside your reuse events.
|
|
321
|
+
* It fires once per such request, after `revokeFamily`, only for a token
|
|
322
|
+
* issued to the authenticated client. It never fires `onTokenReuse`: a
|
|
323
|
+
* sign-out racing the client's own refresh presents the same token, so this
|
|
324
|
+
* is not by itself evidence of theft.
|
|
325
|
+
*
|
|
326
|
+
* Trigger point `rotated-out-revocation` (see `docs/trigger-points.md`).
|
|
327
|
+
* Fire-and-forget: it is awaited, but a throw is caught and logged, never
|
|
328
|
+
* rethrown, so a failing hook cannot change the `200` the endpoint answers.
|
|
329
|
+
*/
|
|
330
|
+
onRotatedOutRevocation?: (event: RotatedOutRevocationEvent<Client, User>) => void | Promise<void>;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* What the revocation endpoint observed when it revoked the rotation family of
|
|
334
|
+
* a refresh token that rotation already replaced. Passed to
|
|
335
|
+
* {@link AuthorizationServerOptions.onRotatedOutRevocation}.
|
|
336
|
+
*/
|
|
337
|
+
export interface RotatedOutRevocationEvent<Client extends ClientInterface, User> {
|
|
338
|
+
/** The rotation family the presented token belonged to. */
|
|
339
|
+
familyId: string;
|
|
340
|
+
/** The client the presented token was issued to, which authenticated. */
|
|
341
|
+
client: Client;
|
|
342
|
+
/** The user the presented token was issued for, if any. */
|
|
343
|
+
user?: User;
|
|
344
|
+
/** Whether {@linkcode TokenServiceInterface.revokeFamily} revoked anything. */
|
|
345
|
+
familyRevoked: boolean;
|
|
317
346
|
}
|
|
318
347
|
/**
|
|
319
348
|
* Function to authenticate the user during authorization.
|
|
@@ -707,7 +736,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
707
736
|
* revoked other than by rotation, a token with no family, and a service
|
|
708
737
|
* without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
|
|
709
738
|
* The refresh grant's `replayGraceSeconds` does not apply here, and
|
|
710
|
-
* `onTokenReuse` does not fire
|
|
739
|
+
* `onTokenReuse` does not fire; the server reports the revocation to
|
|
740
|
+
* {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
|
|
711
741
|
*
|
|
712
742
|
* Always returns 200 OK, even if the token was invalid, already revoked, or
|
|
713
743
|
* issued to another client: RFC 7009 Section 2.2 requires that answer so the
|
|
@@ -860,7 +890,7 @@ export { AbstractGrant } from "./grants/grant.ts";
|
|
|
860
890
|
export type { DispatchableGrant, GrantOptions, GrantServices, } from "./grants/grant.ts";
|
|
861
891
|
export { AuthorizationCodeGrant } from "./grants/authorization-code.ts";
|
|
862
892
|
export type { PKCEClientCredentials } from "./grants/authorization-code.ts";
|
|
863
|
-
export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
|
|
893
|
+
export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, CodeReuseEvent, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
|
|
864
894
|
export { ClientCredentialsGrant } from "./grants/client-credentials.ts";
|
|
865
895
|
export type { ClientCredentialsGrantOptions, ClientCredentialsGrantServices, } from "./grants/client-credentials.ts";
|
|
866
896
|
export { createJwtAccessTokenGenerator, exportSigningKeyJwk, generateSigningKey, importSigningKeyJwk, RotatingSigningKeyProvider, type RotatingSigningKeyProviderOptions, type SigningKey, type SigningKeyProvider, signJwt, StaticSigningKeyProvider, verifyJwt, type VerifyJwtOptions, } from "./signing-keys.ts";
|
|
@@ -200,6 +200,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
200
200
|
#subjectOf;
|
|
201
201
|
#userClaims;
|
|
202
202
|
#introspectionClaims;
|
|
203
|
+
#onRotatedOutRevocation;
|
|
203
204
|
#canIntrospectToken;
|
|
204
205
|
#resolve;
|
|
205
206
|
/**
|
|
@@ -254,6 +255,7 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
254
255
|
this.#userClaims = options.userClaims;
|
|
255
256
|
this.#introspectionClaims = options.introspectionClaims;
|
|
256
257
|
this.#canIntrospectToken = options.canIntrospectToken;
|
|
258
|
+
this.#onRotatedOutRevocation = options.onRotatedOutRevocation;
|
|
257
259
|
}
|
|
258
260
|
#handleEndpointError(error, prepare) {
|
|
259
261
|
const oauth2Error = toOAuth2Error(error);
|
|
@@ -1001,7 +1003,8 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
1001
1003
|
* revoked other than by rotation, a token with no family, and a service
|
|
1002
1004
|
* without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
|
|
1003
1005
|
* The refresh grant's `replayGraceSeconds` does not apply here, and
|
|
1004
|
-
* `onTokenReuse` does not fire
|
|
1006
|
+
* `onTokenReuse` does not fire; the server reports the revocation to
|
|
1007
|
+
* {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
|
|
1005
1008
|
*
|
|
1006
1009
|
* Always returns 200 OK, even if the token was invalid, already revoked, or
|
|
1007
1010
|
* issued to another client: RFC 7009 Section 2.2 requires that answer so the
|
|
@@ -1050,7 +1053,23 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
1050
1053
|
if (rotatedOut?.rotatedAt &&
|
|
1051
1054
|
rotatedOut.familyId &&
|
|
1052
1055
|
rotatedOut.client.id.toString() === client.id.toString()) {
|
|
1053
|
-
await tokenService.revokeFamily(rotatedOut.familyId);
|
|
1056
|
+
const familyRevoked = await tokenService.revokeFamily(rotatedOut.familyId);
|
|
1057
|
+
await this.#notifyRotatedOutRevocation({
|
|
1058
|
+
familyId: rotatedOut.familyId,
|
|
1059
|
+
client: rotatedOut.client,
|
|
1060
|
+
user: rotatedOut.user,
|
|
1061
|
+
familyRevoked,
|
|
1062
|
+
});
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
async #notifyRotatedOutRevocation(event) {
|
|
1066
|
+
if (!this.#onRotatedOutRevocation)
|
|
1067
|
+
return;
|
|
1068
|
+
try {
|
|
1069
|
+
await this.#onRotatedOutRevocation(event);
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
console.error("[@udibo/oauth2] onRotatedOutRevocation hook failed:", error instanceof Error ? error.message : error);
|
|
1054
1073
|
}
|
|
1055
1074
|
}
|
|
1056
1075
|
async #resolveToken(tokenService, value, hint) {
|
|
@@ -17,6 +17,27 @@ export interface AuthorizationCodeGrantServices<Client extends ClientInterface,
|
|
|
17
17
|
/** Generates, stores, and looks up authorization codes for the exchange. */
|
|
18
18
|
authorizationCodeService: AuthorizationCodeServiceInterface<Client, User, S>;
|
|
19
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* What the authorization code grant observed when a code that already issued
|
|
22
|
+
* tokens was presented again. Passed to
|
|
23
|
+
* {@link AuthorizationCodeGrantOptions.onCodeReuse}.
|
|
24
|
+
*/
|
|
25
|
+
export interface CodeReuseEvent<Client extends ClientInterface> {
|
|
26
|
+
/**
|
|
27
|
+
* The SHA-256 digest of the replayed code, as lowercase hex — the value a
|
|
28
|
+
* store that hashes codes keys them on, so it correlates without recording
|
|
29
|
+
* the code itself.
|
|
30
|
+
*/
|
|
31
|
+
codeHash: string;
|
|
32
|
+
/**
|
|
33
|
+
* The client that presented the code. It is the code's own client only when
|
|
34
|
+
* the token service's `revokeCode` honors its `client` argument. A public
|
|
35
|
+
* client, or any client the grant looks up by id because
|
|
36
|
+
* `requireClientAuthentication` is `false`, is named by `client_id` alone,
|
|
37
|
+
* so whoever holds the spent code can present it as that client.
|
|
38
|
+
*/
|
|
39
|
+
client: Client;
|
|
40
|
+
}
|
|
20
41
|
/**
|
|
21
42
|
* Options for configuring the authorization code grant.
|
|
22
43
|
*
|
|
@@ -64,6 +85,19 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
|
|
|
64
85
|
* @see https://datatracker.ietf.org/doc/html/rfc9700#section-2.1.1
|
|
65
86
|
*/
|
|
66
87
|
requireClientAuthentication?: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* Called when a code that already issued tokens is presented again and the
|
|
90
|
+
* token service's `revokeCode` revoked those tokens — record it as a
|
|
91
|
+
* security event. A `revokeCode` that honors its `client` argument revokes
|
|
92
|
+
* nothing, and so does not fire this, for a client other than the code's
|
|
93
|
+
* own; {@linkcode CodeReuseEvent.client} says who that excludes.
|
|
94
|
+
*
|
|
95
|
+
* Trigger point `code-reuse` (see `docs/trigger-points.md`).
|
|
96
|
+
* Fire-and-forget: it is awaited, but a throw is caught and logged, never
|
|
97
|
+
* rethrown, so a failing security-logging hook cannot change the
|
|
98
|
+
* client-facing `invalid_grant` a replayed code always yields.
|
|
99
|
+
*/
|
|
100
|
+
onCodeReuse?: (event: CodeReuseEvent<Client>) => void | Promise<void>;
|
|
67
101
|
}
|
|
68
102
|
/** Client credentials extended with optional PKCE code_verifier. */
|
|
69
103
|
export interface PKCEClientCredentials extends ClientCredentials {
|
|
@@ -103,6 +137,7 @@ export interface GenerateAuthorizationCodeOptions<Client extends ClientInterface
|
|
|
103
137
|
* @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1.1
|
|
104
138
|
*/
|
|
105
139
|
export declare class AuthorizationCodeGrant<Client extends ClientInterface, User, S extends AbstractScope = BasicScope> extends AbstractGrant<Client, User, S, AuthorizationCodeGrantServices<Client, User, S>> {
|
|
140
|
+
#private;
|
|
106
141
|
/** The OAuth2 grant type: `authorization_code`. */
|
|
107
142
|
readonly grantType = "authorization_code";
|
|
108
143
|
/** The PKCE challenge methods this grant accepts. Defaults to `S256` only. */
|
|
@@ -169,11 +204,27 @@ export declare class AuthorizationCodeGrant<Client extends ClientInterface, User
|
|
|
169
204
|
* Exchanges an authorization code for an access token. If PKCE was used
|
|
170
205
|
* during authorization, the code_verifier must be provided.
|
|
171
206
|
*
|
|
172
|
-
*
|
|
173
|
-
* `redirect_uri`
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
207
|
+
* A failed exchange still consumes the code: one that fails the
|
|
208
|
+
* client-match, verifier or `redirect_uri` check, whose token cannot be
|
|
209
|
+
* generated, or that `exchangeCode` refuses with an OAuth2 error, is claimed
|
|
210
|
+
* before the error is thrown. Any other error from `exchangeCode`, such as a
|
|
211
|
+
* lost database connection, leaves the code claimable. Only the caller whose
|
|
212
|
+
* claim wins receives a token, given an atomic
|
|
213
|
+
* `authorizationCodeService.revoke`.
|
|
214
|
+
*
|
|
215
|
+
* When the token service implements
|
|
216
|
+
* {@linkcode TokenServiceInterface.exchangeCode}, the grant claims the code
|
|
217
|
+
* and saves the token in that one call. Otherwise it claims the code with
|
|
218
|
+
* `authorizationCodeService.revoke` before its checks and saves the token
|
|
219
|
+
* with `tokenService.save` after them; a replay that lands between those
|
|
220
|
+
* two calls finds no token to revoke, so the token saved afterwards stays
|
|
221
|
+
* live.
|
|
222
|
+
*
|
|
223
|
+
* A code presented after it was claimed, whether it was gone when the
|
|
224
|
+
* request read it or a concurrent exchange won the claim, even one that
|
|
225
|
+
* then fails its checks, asks
|
|
226
|
+
* `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
|
|
227
|
+
* the presenting client, fires `onCodeReuse` when it did, and fails with
|
|
177
228
|
* `invalid_grant` (RFC 6819 §4.4.1.1).
|
|
178
229
|
*
|
|
179
230
|
* @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
|