@udibo/oauth2 0.22.0 → 0.24.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/bff.d.ts +4 -2
- package/dist/adapters/hono/bff/bff.js +28 -9
- package/dist/adapters/hono/bff/mod.d.ts +1 -1
- package/dist/adapters/hono/bff/session-store.d.ts +85 -12
- package/dist/adapters/hono/bff/session-store.js +105 -11
- package/dist/adapters/hono/bff/testing.d.ts +6 -2
- package/dist/adapters/hono/bff/testing.js +96 -2
- package/dist/client/direct-client.d.ts +2 -2
- package/dist/client/discovery-cache.d.ts +3 -3
- package/dist/identity/captcha.d.ts +2 -2
- package/dist/models/authorization.d.ts +5 -5
- package/dist/models/authorization.js +2 -2
- package/dist/models/client.d.ts +1 -1
- package/dist/models/token.d.ts +4 -4
- package/dist/react/components/class-names.d.ts +2 -2
- package/dist/react/components/class-names.js +2 -2
- package/dist/server/authorization-server.d.ts +26 -11
- package/dist/server/authorization-server.js +36 -10
- package/dist/server/resource-server.d.ts +10 -10
- package/dist/server/resource-server.js +3 -4
- package/dist/server/services/token.d.ts +26 -9
- package/dist/testing/contract/token.d.ts +3 -1
- package/dist/testing/contract/token.js +19 -1
- package/dist/testing/services.d.ts +4 -2
- package/dist/testing/services.js +10 -2
- package/package.json +1 -1
|
@@ -327,7 +327,9 @@ export interface HonoBffOptions {
|
|
|
327
327
|
* - A {@link SessionStore} over a database or cache every instance shares,
|
|
328
328
|
* for server-side revocation and back-channel logout. Implement
|
|
329
329
|
* `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
|
|
330
|
-
* session one at a time,
|
|
330
|
+
* session one at a time, pass each refresh a `RefreshLease` so a holder
|
|
331
|
+
* whose lock expired cannot overwrite or end the session, and check it
|
|
332
|
+
* with `runSessionStoreContractTests`.
|
|
331
333
|
* - {@link MemorySessionStore} for a single process, tests and local
|
|
332
334
|
* development. A session it holds is unknown to every other instance, so
|
|
333
335
|
* behind a load balancer the user appears signed out.
|
|
@@ -681,7 +683,7 @@ export interface HonoBffOptions {
|
|
|
681
683
|
/**
|
|
682
684
|
* Resolves the per-request origin (scheme + host) used to build the
|
|
683
685
|
* browser-facing authorize URL and `redirect_uri`. Lets ONE build-once BFF
|
|
684
|
-
* serve every deployment host (preview URLs,
|
|
686
|
+
* serve every deployment host (preview URLs, per-application subdomains): the
|
|
685
687
|
* configured client's authorize/redirect paths are retargeted at this
|
|
686
688
|
* origin per request. Return only allowlisted/trusted origins (never a raw
|
|
687
689
|
* forged `Host`). When omitted, the client's configured origin is used.
|
|
@@ -1080,7 +1080,7 @@ export class HonoBff {
|
|
|
1080
1080
|
return await this.#exchangeRefresh(entry);
|
|
1081
1081
|
const { cookieValue } = entry;
|
|
1082
1082
|
try {
|
|
1083
|
-
return await store.withRefreshLock(cookieValue, async () => {
|
|
1083
|
+
return await store.withRefreshLock(cookieValue, async (lease) => {
|
|
1084
1084
|
const current = await store.read(cookieValue);
|
|
1085
1085
|
if (!current)
|
|
1086
1086
|
return { status: "revoked" };
|
|
@@ -1090,28 +1090,31 @@ export class HonoBff {
|
|
|
1090
1090
|
entry: { cookieValue, data: current },
|
|
1091
1091
|
};
|
|
1092
1092
|
}
|
|
1093
|
-
return await this.#exchangeRefresh({ cookieValue, data: current });
|
|
1093
|
+
return await this.#exchangeRefresh({ cookieValue, data: current }, lease);
|
|
1094
1094
|
});
|
|
1095
1095
|
}
|
|
1096
1096
|
catch {
|
|
1097
1097
|
return { status: "unavailable" };
|
|
1098
1098
|
}
|
|
1099
1099
|
}
|
|
1100
|
-
async #exchangeRefresh(entry) {
|
|
1100
|
+
async #exchangeRefresh(entry, lease) {
|
|
1101
1101
|
const { cookieValue, data } = entry;
|
|
1102
|
-
|
|
1102
|
+
const refreshToken = data.refreshToken;
|
|
1103
|
+
if (!refreshToken)
|
|
1103
1104
|
return { status: "unavailable" };
|
|
1104
1105
|
try {
|
|
1105
|
-
const result = await this.#client.exchangeRefreshToken(
|
|
1106
|
+
const result = await this.#client.exchangeRefreshToken(refreshToken);
|
|
1106
1107
|
const refreshed = {
|
|
1107
1108
|
tokens: result.tokens,
|
|
1108
|
-
refreshToken: result.refreshToken ??
|
|
1109
|
+
refreshToken: result.refreshToken ?? refreshToken,
|
|
1109
1110
|
user: data.user,
|
|
1110
1111
|
sid: data.sid,
|
|
1111
1112
|
createdAt: data.createdAt,
|
|
1112
1113
|
updatedAt: Date.now(),
|
|
1113
1114
|
};
|
|
1114
|
-
const newCookieValue =
|
|
1115
|
+
const newCookieValue = lease
|
|
1116
|
+
? await lease.update(refreshed)
|
|
1117
|
+
: await this.#store.update(cookieValue, refreshed);
|
|
1115
1118
|
return {
|
|
1116
1119
|
status: "refreshed",
|
|
1117
1120
|
entry: { cookieValue: newCookieValue, data: refreshed },
|
|
@@ -1121,12 +1124,28 @@ export class HonoBff {
|
|
|
1121
1124
|
if (!isRevokedGrant(error))
|
|
1122
1125
|
return { status: "unavailable" };
|
|
1123
1126
|
const current = await this.#store.read(cookieValue);
|
|
1124
|
-
if (current && current.refreshToken !==
|
|
1127
|
+
if (current && current.refreshToken !== refreshToken) {
|
|
1125
1128
|
return { status: "refreshed", entry: { cookieValue, data: current } };
|
|
1126
1129
|
}
|
|
1127
|
-
|
|
1130
|
+
if (!lease) {
|
|
1131
|
+
await this.#destroySession(cookieValue);
|
|
1132
|
+
return { status: "revoked" };
|
|
1133
|
+
}
|
|
1134
|
+
if (current && !(await lease.destroy(refreshToken))) {
|
|
1135
|
+
return await this.#afterRefusedDestroy(cookieValue, refreshToken);
|
|
1136
|
+
}
|
|
1137
|
+
await this.#rotatedCookies?.forget(cookieValue);
|
|
1138
|
+
return { status: "revoked" };
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
async #afterRefusedDestroy(cookieValue, refreshToken) {
|
|
1142
|
+
const current = await this.#store.read(cookieValue);
|
|
1143
|
+
if (!current)
|
|
1128
1144
|
return { status: "revoked" };
|
|
1145
|
+
if (current.refreshToken !== refreshToken) {
|
|
1146
|
+
return { status: "refreshed", entry: { cookieValue, data: current } };
|
|
1129
1147
|
}
|
|
1148
|
+
return { status: "unavailable" };
|
|
1130
1149
|
}
|
|
1131
1150
|
/**
|
|
1132
1151
|
* Middleware that reads the session cookie, refreshes the access token
|
|
@@ -21,4 +21,4 @@
|
|
|
21
21
|
export { type BackchannelLogoutSubject, type BffCallbackError, HonoBff, type HonoBffBackchannelOptions, type HonoBffCookieOptions, type HonoBffCsrfOptions, type HonoBffOptions, type HonoBffPaths, type HonoBffResourceServer, } from "./bff.ts";
|
|
22
22
|
export { type AuthRequestCookieOptions, type AuthRequestStorageFactory, EncryptedCookieAuthRequestStorage, encryptedCookieAuthRequestStorage, type EncryptedCookieAuthRequestStorageOptions, MemoryAuthRequestStorageFactory, } from "./auth-request-store.ts";
|
|
23
23
|
export { DEFAULT_PROXY_FORWARD_HEADERS, type HonoBffProxyOptions, } from "./proxy.ts";
|
|
24
|
-
export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type RefreshLockStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
|
|
24
|
+
export { type BackchannelLogoutStore, type BoundedSessionStore, DEFAULT_SESSION_MAX_AGE_MS, EncryptedCookieSessionStore, type EncryptedCookieSessionStoreOptions, MemorySessionStore, type MemorySessionStoreOptions, type RefreshLease, type RefreshLockStore, type SessionData, type SessionStore, sessionStoreMaxAgeMs, supportsBackchannelLogout, } from "./session-store.ts";
|
|
@@ -101,19 +101,59 @@ export interface RefreshLockStore {
|
|
|
101
101
|
* seconds when its discovery cache is cold, since discovery tries two
|
|
102
102
|
* well-known paths and every request has a 10-second deadline. If the
|
|
103
103
|
* lock expires when its holder dies, expire it only after that plus the
|
|
104
|
-
* store's own I/O, or renew
|
|
105
|
-
*
|
|
104
|
+
* store's own I/O, or renew it while `refresh` runs.
|
|
105
|
+
* - Pass `refresh` a {@link RefreshLease} for this call. The parameter is
|
|
106
|
+
* optional only so stores written before it keep compiling. `HonoBff` writes
|
|
107
|
+
* and destroys the session through it, so a holder that lost the lock
|
|
108
|
+
* (it stalled past the expiry and another instance took it) can no
|
|
109
|
+
* longer overwrite or end the session that instance refreshed. A store
|
|
110
|
+
* that calls `refresh()` with no lease still works, but a lock that
|
|
111
|
+
* expires mid-refresh then lets the former holder's write or sign-out
|
|
112
|
+
* land on top of the new holder's.
|
|
106
113
|
* - Reject when the lock cannot be acquired within a bound you choose.
|
|
107
114
|
* `HonoBff` then answers `502` `temporarily_unavailable` and keeps the
|
|
108
115
|
* session.
|
|
109
116
|
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
117
|
+
* Keep the lock beside the session record, so the lease's conditional
|
|
118
|
+
* write is one atomic compare-and-set on that record: for example a lease
|
|
119
|
+
* token and a lease expiry on the session row, taken by an update that
|
|
120
|
+
* matches only when no unexpired lease is set, and checked by the lease's
|
|
121
|
+
* `update` and `destroy`. Never hold a database transaction open across
|
|
122
|
+
* `refresh`: it spans a network call to the authorization server. Key the
|
|
123
|
+
* lock by the same key the store keys its records by — the digest, never
|
|
124
|
+
* the raw cookie value, plus any prefix or scope the store adds to it.
|
|
115
125
|
*/
|
|
116
|
-
withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
|
|
126
|
+
withRefreshLock<T>(cookieValue: string, refresh: (lease?: RefreshLease) => Promise<T>): Promise<T>;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The fence a {@link RefreshLockStore} passes to the refresh it runs: an
|
|
130
|
+
* `update` and a `destroy` of the locked session that take effect only while
|
|
131
|
+
* this call still holds the lock. The lease is current from when the call
|
|
132
|
+
* acquires the lock until the call releases the lock or another call takes
|
|
133
|
+
* it; a store may also end it when the lock expires. Once another call has
|
|
134
|
+
* taken the lock, the lease is never current again.
|
|
135
|
+
*/
|
|
136
|
+
export interface RefreshLease {
|
|
137
|
+
/**
|
|
138
|
+
* Writes the locked session like {@link SessionStore.update}, as one atomic
|
|
139
|
+
* step that also checks this lease is current. Resolves with the cookie
|
|
140
|
+
* value to set.
|
|
141
|
+
*
|
|
142
|
+
* @throws {InvalidGrantError} When the lease is no longer current, or the
|
|
143
|
+
* session is missing, expired or revoked. It never creates or restores a
|
|
144
|
+
* session.
|
|
145
|
+
*/
|
|
146
|
+
update(data: SessionData): Promise<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Destroys the locked session, as one atomic step, only while this lease
|
|
149
|
+
* is current and the session still stores `refreshToken`. `HonoBff` calls
|
|
150
|
+
* it after the authorization server refuses that refresh token.
|
|
151
|
+
*
|
|
152
|
+
* @returns `true` when it destroyed the session; `false` when it destroyed
|
|
153
|
+
* nothing, because the lease is no longer current, the session stores a
|
|
154
|
+
* different refresh token, or the session no longer exists.
|
|
155
|
+
*/
|
|
156
|
+
destroy(refreshToken: string): Promise<boolean>;
|
|
117
157
|
}
|
|
118
158
|
/** Whether a store implements the {@link RefreshLockStore} capability. */
|
|
119
159
|
export declare function supportsRefreshLock(store: SessionStore): store is SessionStore & RefreshLockStore;
|
|
@@ -172,6 +212,30 @@ export interface SessionStore {
|
|
|
172
212
|
/** Destroy the session identified by `cookieValue`. */
|
|
173
213
|
destroy(cookieValue: string): Promise<void>;
|
|
174
214
|
}
|
|
215
|
+
/** Options for {@link MemorySessionStore}. */
|
|
216
|
+
export interface MemorySessionStoreOptions {
|
|
217
|
+
/**
|
|
218
|
+
* How long a `withRefreshLock` call holds the lock without renewing it, in
|
|
219
|
+
* milliseconds. The store renews it every third of this while the refresh
|
|
220
|
+
* runs. A lease that still lapses, because its holder's timers were
|
|
221
|
+
* starved, is lost at once: another call may take the lock, and the lost
|
|
222
|
+
* lease's `update` and `destroy` are refused.
|
|
223
|
+
*
|
|
224
|
+
* Defaults to no expiry, which is right for one process: a holder cannot
|
|
225
|
+
* die without the store dying with it. Set it in tests that model an
|
|
226
|
+
* instance losing its lock mid-refresh. Must be a positive number no
|
|
227
|
+
* larger than 2147483647 (2^31 − 1, the longest timer delay).
|
|
228
|
+
*/
|
|
229
|
+
refreshLeaseMs?: number;
|
|
230
|
+
/**
|
|
231
|
+
* How long `withRefreshLock` waits for a lock another call holds, in
|
|
232
|
+
* milliseconds, before it rejects. `HonoBff` then answers `502` and keeps
|
|
233
|
+
* the session. Defaults to waiting until the lock is released or expires.
|
|
234
|
+
* Must be a positive number no larger than 2147483647 (2^31 − 1, the
|
|
235
|
+
* longest timer delay).
|
|
236
|
+
*/
|
|
237
|
+
refreshLockWaitMs?: number;
|
|
238
|
+
}
|
|
175
239
|
/**
|
|
176
240
|
* In-memory stateful session store. Data is lost when the process
|
|
177
241
|
* restarts. Suitable for single-process development or tests; use a
|
|
@@ -179,6 +243,11 @@ export interface SessionStore {
|
|
|
179
243
|
*/
|
|
180
244
|
export declare class MemorySessionStore implements SessionStore, BackchannelLogoutStore, RefreshLockStore {
|
|
181
245
|
#private;
|
|
246
|
+
/**
|
|
247
|
+
* @throws {RangeError} When `refreshLeaseMs` or `refreshLockWaitMs` is not
|
|
248
|
+
* a positive number of at most 2^31 − 1 milliseconds.
|
|
249
|
+
*/
|
|
250
|
+
constructor(options?: MemorySessionStoreOptions);
|
|
182
251
|
/** Stores the session under a fresh random id and returns it as the cookie value. */
|
|
183
252
|
create(data: SessionData): Promise<string>;
|
|
184
253
|
/** Looks up the session by its id, or `null` if no live session matches. */
|
|
@@ -188,11 +257,15 @@ export declare class MemorySessionStore implements SessionStore, BackchannelLogo
|
|
|
188
257
|
/** Removes the session record for the given id. */
|
|
189
258
|
destroy(cookieValue: string): Promise<void>;
|
|
190
259
|
/**
|
|
191
|
-
* Runs `refresh` once
|
|
192
|
-
*
|
|
193
|
-
*
|
|
260
|
+
* Runs `refresh` once no other call holds the session's lock, so every
|
|
261
|
+
* `HonoBff` sharing this store object refreshes a session once. Calls for
|
|
262
|
+
* other sessions run alongside. `refresh` gets a {@link RefreshLease} that
|
|
263
|
+
* writes and destroys the session only while this call holds the lock.
|
|
264
|
+
*
|
|
265
|
+
* @throws {Error} When the lock stays held past
|
|
266
|
+
* {@linkcode MemorySessionStoreOptions.refreshLockWaitMs}.
|
|
194
267
|
*/
|
|
195
|
-
withRefreshLock<T>(cookieValue: string, refresh: () => Promise<T>): Promise<T>;
|
|
268
|
+
withRefreshLock<T>(cookieValue: string, refresh: (lease: RefreshLease) => Promise<T>): Promise<T>;
|
|
196
269
|
/**
|
|
197
270
|
* Destroys the sessions a validated logout_token targets: the specific `sid`
|
|
198
271
|
* when given, otherwise every session for `sub`. Returns the number
|
|
@@ -55,6 +55,16 @@ export function sessionStoreMaxAgeMs(store) {
|
|
|
55
55
|
? maxAgeMs
|
|
56
56
|
: undefined;
|
|
57
57
|
}
|
|
58
|
+
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
59
|
+
function timerMs(name, value) {
|
|
60
|
+
if (value === undefined)
|
|
61
|
+
return Infinity;
|
|
62
|
+
if (!(value > 0 && value <= MAX_TIMER_MS)) {
|
|
63
|
+
throw new RangeError(`${name} must be a positive number of milliseconds no larger than ` +
|
|
64
|
+
`${MAX_TIMER_MS}, got ${value}. Leave it unset for no limit.`);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
58
68
|
/**
|
|
59
69
|
* In-memory stateful session store. Data is lost when the process
|
|
60
70
|
* restarts. Suitable for single-process development or tests; use a
|
|
@@ -62,7 +72,18 @@ export function sessionStoreMaxAgeMs(store) {
|
|
|
62
72
|
*/
|
|
63
73
|
export class MemorySessionStore {
|
|
64
74
|
#sessions = new Map();
|
|
65
|
-
#
|
|
75
|
+
#leases = new Map();
|
|
76
|
+
#lockWaiters = new Map();
|
|
77
|
+
#leaseMs;
|
|
78
|
+
#lockWaitMs;
|
|
79
|
+
/**
|
|
80
|
+
* @throws {RangeError} When `refreshLeaseMs` or `refreshLockWaitMs` is not
|
|
81
|
+
* a positive number of at most 2^31 − 1 milliseconds.
|
|
82
|
+
*/
|
|
83
|
+
constructor(options = {}) {
|
|
84
|
+
this.#leaseMs = timerMs("refreshLeaseMs", options.refreshLeaseMs);
|
|
85
|
+
this.#lockWaitMs = timerMs("refreshLockWaitMs", options.refreshLockWaitMs);
|
|
86
|
+
}
|
|
66
87
|
/** Stores the session under a fresh random id and returns it as the cookie value. */
|
|
67
88
|
create(data) {
|
|
68
89
|
const id = crypto.randomUUID();
|
|
@@ -87,24 +108,97 @@ export class MemorySessionStore {
|
|
|
87
108
|
return Promise.resolve();
|
|
88
109
|
}
|
|
89
110
|
/**
|
|
90
|
-
* Runs `refresh` once
|
|
91
|
-
*
|
|
92
|
-
*
|
|
111
|
+
* Runs `refresh` once no other call holds the session's lock, so every
|
|
112
|
+
* `HonoBff` sharing this store object refreshes a session once. Calls for
|
|
113
|
+
* other sessions run alongside. `refresh` gets a {@link RefreshLease} that
|
|
114
|
+
* writes and destroys the session only while this call holds the lock.
|
|
115
|
+
*
|
|
116
|
+
* @throws {Error} When the lock stays held past
|
|
117
|
+
* {@linkcode MemorySessionStoreOptions.refreshLockWaitMs}.
|
|
93
118
|
*/
|
|
94
119
|
async withRefreshLock(cookieValue, refresh) {
|
|
95
|
-
const
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
120
|
+
const lease = await this.#acquire(cookieValue);
|
|
121
|
+
const renewal = Number.isFinite(this.#leaseMs)
|
|
122
|
+
? setInterval(() => {
|
|
123
|
+
if (this.#holds(cookieValue, lease)) {
|
|
124
|
+
lease.expiresAt = Date.now() + this.#leaseMs;
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
clearInterval(renewal);
|
|
128
|
+
}
|
|
129
|
+
}, this.#leaseMs / 3)
|
|
130
|
+
: undefined;
|
|
99
131
|
try {
|
|
100
|
-
return await
|
|
132
|
+
return await refresh(this.#fence(cookieValue, lease));
|
|
101
133
|
}
|
|
102
134
|
finally {
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
clearInterval(renewal);
|
|
136
|
+
if (this.#leases.get(cookieValue) === lease) {
|
|
137
|
+
this.#leases.delete(cookieValue);
|
|
138
|
+
}
|
|
139
|
+
this.#wakeLockWaiters(cookieValue);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async #acquire(cookieValue) {
|
|
143
|
+
const deadline = Date.now() + this.#lockWaitMs;
|
|
144
|
+
for (;;) {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
const held = this.#leases.get(cookieValue);
|
|
147
|
+
if (!held || held.expiresAt <= now) {
|
|
148
|
+
const lease = { expiresAt: now + this.#leaseMs };
|
|
149
|
+
this.#leases.set(cookieValue, lease);
|
|
150
|
+
return lease;
|
|
151
|
+
}
|
|
152
|
+
if (now >= deadline) {
|
|
153
|
+
throw new Error("timed out waiting for the session's refresh lock");
|
|
154
|
+
}
|
|
155
|
+
await this.#lockChange(cookieValue, Math.min(held.expiresAt, deadline) - now);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
#lockChange(cookieValue, timeoutMs) {
|
|
159
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
160
|
+
const waiters = this.#lockWaiters.get(cookieValue) ?? new Set();
|
|
161
|
+
this.#lockWaiters.set(cookieValue, waiters);
|
|
162
|
+
let timer;
|
|
163
|
+
const wake = () => {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
waiters.delete(wake);
|
|
166
|
+
if (waiters.size === 0 &&
|
|
167
|
+
this.#lockWaiters.get(cookieValue) === waiters) {
|
|
168
|
+
this.#lockWaiters.delete(cookieValue);
|
|
105
169
|
}
|
|
170
|
+
resolve();
|
|
171
|
+
};
|
|
172
|
+
waiters.add(wake);
|
|
173
|
+
if (Number.isFinite(timeoutMs))
|
|
174
|
+
timer = setTimeout(wake, timeoutMs);
|
|
175
|
+
return promise;
|
|
176
|
+
}
|
|
177
|
+
#wakeLockWaiters(cookieValue) {
|
|
178
|
+
for (const wake of [...(this.#lockWaiters.get(cookieValue) ?? [])]) {
|
|
179
|
+
wake();
|
|
106
180
|
}
|
|
107
181
|
}
|
|
182
|
+
#holds(cookieValue, lease) {
|
|
183
|
+
return (this.#leases.get(cookieValue) === lease && lease.expiresAt > Date.now());
|
|
184
|
+
}
|
|
185
|
+
#fence(cookieValue, lease) {
|
|
186
|
+
return {
|
|
187
|
+
update: (data) => this.#holds(cookieValue, lease)
|
|
188
|
+
? this.update(cookieValue, data)
|
|
189
|
+
: Promise.reject(new InvalidGrantError("the session's refresh lock was lost")),
|
|
190
|
+
destroy: (refreshToken) => {
|
|
191
|
+
const session = this.#sessions.get(cookieValue);
|
|
192
|
+
if (!session ||
|
|
193
|
+
session.refreshToken !== refreshToken ||
|
|
194
|
+
!this.#holds(cookieValue, lease)) {
|
|
195
|
+
return Promise.resolve(false);
|
|
196
|
+
}
|
|
197
|
+
this.#sessions.delete(cookieValue);
|
|
198
|
+
return Promise.resolve(true);
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
108
202
|
/**
|
|
109
203
|
* Destroys the sessions a validated logout_token targets: the specific `sid`
|
|
110
204
|
* when given, otherwise every session for `sub`. Returns the number
|
|
@@ -174,8 +174,12 @@ export interface SessionStoreContractOptions {
|
|
|
174
174
|
* requires — test that yourself.
|
|
175
175
|
*
|
|
176
176
|
* A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
|
|
177
|
-
* runs the lock checks; they are skipped for a store without it.
|
|
178
|
-
*
|
|
177
|
+
* runs the lock checks; they are skipped for a store without it. The lease
|
|
178
|
+
* checks ({@link RefreshLease}) are skipped for a store whose
|
|
179
|
+
* `withRefreshLock` passes no lease. They prove that a lease stops acting once
|
|
180
|
+
* its call releases the lock or another call holds it, not that it stops
|
|
181
|
+
* when the lock expires: expire a lease in a test of your own for that. They
|
|
182
|
+
* use one store object, so they prove the lock's behavior, not that separate
|
|
179
183
|
* instances share it: point two store objects at one backend in a test of
|
|
180
184
|
* your own for that.
|
|
181
185
|
*/
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
19
19
|
import { delay } from "../../../utils/_delay.js";
|
|
20
|
+
import { InvalidGrantError } from "../../../errors.js";
|
|
20
21
|
import { sessionStoreMaxAgeMs, supportsRefreshLock, } from "./session-store.js";
|
|
21
22
|
/**
|
|
22
23
|
* Seeds a session in `bff.sessionStore` and returns a `Cookie` header
|
|
@@ -184,6 +185,16 @@ async function settlesWithin(promise, message) {
|
|
|
184
185
|
timer.abort();
|
|
185
186
|
}
|
|
186
187
|
}
|
|
188
|
+
function required(lease) {
|
|
189
|
+
assert(lease !== undefined, "withRefreshLock passed a lease to one refresh and none to another");
|
|
190
|
+
return lease;
|
|
191
|
+
}
|
|
192
|
+
async function rejection(promise) {
|
|
193
|
+
const fulfilled = Symbol("fulfilled");
|
|
194
|
+
const outcome = await promise.then(() => fulfilled, (error) => error);
|
|
195
|
+
assert(outcome !== fulfilled, "expected the lease to refuse");
|
|
196
|
+
return outcome;
|
|
197
|
+
}
|
|
187
198
|
/**
|
|
188
199
|
* Contract test suite for {@link SessionStore} implementations. Registers
|
|
189
200
|
* `describe`/`it` blocks from Vitest, so call it at a test
|
|
@@ -192,8 +203,12 @@ async function settlesWithin(promise, message) {
|
|
|
192
203
|
* requires — test that yourself.
|
|
193
204
|
*
|
|
194
205
|
* A store that implements `withRefreshLock` ({@link RefreshLockStore}) also
|
|
195
|
-
* runs the lock checks; they are skipped for a store without it.
|
|
196
|
-
*
|
|
206
|
+
* runs the lock checks; they are skipped for a store without it. The lease
|
|
207
|
+
* checks ({@link RefreshLease}) are skipped for a store whose
|
|
208
|
+
* `withRefreshLock` passes no lease. They prove that a lease stops acting once
|
|
209
|
+
* its call releases the lock or another call holds it, not that it stops
|
|
210
|
+
* when the lock expires: expire a lease in a test of your own for that. They
|
|
211
|
+
* use one store object, so they prove the lock's behavior, not that separate
|
|
197
212
|
* instances share it: point two store objects at one backend in a test of
|
|
198
213
|
* your own for that.
|
|
199
214
|
*/
|
|
@@ -358,6 +373,85 @@ export function runSessionStoreContractTests(options) {
|
|
|
358
373
|
expect(await settlesWithin(locking.withRefreshLock(value, () => Promise.resolve("next")), "a refresh that threw kept the lock, so the session can never " +
|
|
359
374
|
"refresh again")).toBe("next");
|
|
360
375
|
});
|
|
376
|
+
async function leaseOf(locking, value, skip) {
|
|
377
|
+
const lease = await locking.withRefreshLock(value, (given) => Promise.resolve(given));
|
|
378
|
+
skip(lease === undefined, "withRefreshLock passes the refresh no lease, so a holder that lost " +
|
|
379
|
+
"the lock can still write and destroy the session");
|
|
380
|
+
return required(lease);
|
|
381
|
+
}
|
|
382
|
+
function refreshedSession() {
|
|
383
|
+
return {
|
|
384
|
+
...sampleSession(),
|
|
385
|
+
tokens: { ...sampleSession().tokens, accessToken: "access-2" },
|
|
386
|
+
refreshToken: "refresh-2",
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
it("withRefreshLock passes a lease that writes the session while the lock is held", async ({ skip, }) => {
|
|
390
|
+
const locking = lockStore(skip);
|
|
391
|
+
const value = await locking.create(sampleSession());
|
|
392
|
+
await leaseOf(locking, value, skip);
|
|
393
|
+
const next = await settlesWithin(locking.withRefreshLock(value, (lease) => required(lease).update(refreshedSession())), "a lease's update blocked on the lock its own call holds");
|
|
394
|
+
const fetched = await locking.read(next);
|
|
395
|
+
expect(fetched?.refreshToken).toBe("refresh-2");
|
|
396
|
+
});
|
|
397
|
+
it("withRefreshLock's lease refuses to write or destroy once its call has settled", async ({ skip, }) => {
|
|
398
|
+
const locking = lockStore(skip);
|
|
399
|
+
const value = await locking.create(sampleSession());
|
|
400
|
+
const former = await leaseOf(locking, value, skip);
|
|
401
|
+
const error = await rejection(former.update(refreshedSession()));
|
|
402
|
+
expect(error, "a holder whose lock was released wrote the session, so a holder " +
|
|
403
|
+
"that stalled past its lock would overwrite the next holder's refresh").toBeInstanceOf(InvalidGrantError);
|
|
404
|
+
expect(await former.destroy("refresh-1"), "a holder whose lock was released destroyed the session").toBe(false);
|
|
405
|
+
expect((await locking.read(value))?.refreshToken).toBe("refresh-1");
|
|
406
|
+
});
|
|
407
|
+
it("withRefreshLock's lease cannot act while a later call holds the lock", async ({ skip, }) => {
|
|
408
|
+
const locking = lockStore(skip);
|
|
409
|
+
const value = await locking.create(sampleSession());
|
|
410
|
+
const former = await leaseOf(locking, value, skip);
|
|
411
|
+
const holding = Promise.withResolvers();
|
|
412
|
+
const releaseHolder = Promise.withResolvers();
|
|
413
|
+
const holder = locking.withRefreshLock(value, async (given) => {
|
|
414
|
+
const lease = required(given);
|
|
415
|
+
holding.resolve(lease);
|
|
416
|
+
await releaseHolder.promise;
|
|
417
|
+
return await lease.update(refreshedSession());
|
|
418
|
+
});
|
|
419
|
+
await settlesWithin(holding.promise, "the second call never took the lock");
|
|
420
|
+
const refusedWrite = await rejection(former.update(sampleSession()));
|
|
421
|
+
const refusedDestroy = await former.destroy("refresh-1");
|
|
422
|
+
releaseHolder.resolve();
|
|
423
|
+
await holder;
|
|
424
|
+
expect(refusedWrite).toBeInstanceOf(InvalidGrantError);
|
|
425
|
+
expect(refusedDestroy).toBe(false);
|
|
426
|
+
expect((await locking.read(value))?.refreshToken, "the current holder's write must stand").toBe("refresh-2");
|
|
427
|
+
});
|
|
428
|
+
it("withRefreshLock's lease destroys the session only while it stores the given refresh token", async ({ skip, }) => {
|
|
429
|
+
const locking = lockStore(skip);
|
|
430
|
+
const value = await locking.create(sampleSession());
|
|
431
|
+
await leaseOf(locking, value, skip);
|
|
432
|
+
const results = await locking.withRefreshLock(value, async (given) => {
|
|
433
|
+
const lease = required(given);
|
|
434
|
+
return [
|
|
435
|
+
await lease.destroy("refresh-0"),
|
|
436
|
+
(await locking.read(value)) !== null,
|
|
437
|
+
await lease.destroy("refresh-1"),
|
|
438
|
+
];
|
|
439
|
+
});
|
|
440
|
+
expect(results, "a destroy for a refresh token the session no longer stores would end " +
|
|
441
|
+
"a session a peer has just refreshed").toStrictEqual([false, true, true]);
|
|
442
|
+
expect(await locking.read(value)).toBe(null);
|
|
443
|
+
});
|
|
444
|
+
it("withRefreshLock's lease never restores a destroyed session", async ({ skip, }) => {
|
|
445
|
+
const locking = lockStore(skip);
|
|
446
|
+
const value = await locking.create(sampleSession());
|
|
447
|
+
await leaseOf(locking, value, skip);
|
|
448
|
+
const error = await locking.withRefreshLock(value, async (lease) => {
|
|
449
|
+
await locking.destroy(value);
|
|
450
|
+
return await rejection(required(lease).update(refreshedSession()));
|
|
451
|
+
});
|
|
452
|
+
expect(error).toBeInstanceOf(InvalidGrantError);
|
|
453
|
+
expect(await locking.read(value)).toBe(null);
|
|
454
|
+
});
|
|
361
455
|
if (makeBoundedStore) {
|
|
362
456
|
it("reports the max age it was built with, so a BFF can outlive it", async () => {
|
|
363
457
|
const bounded = await makeBoundedStore(BOUND_MS);
|
|
@@ -129,7 +129,7 @@ export interface LoginOptions extends BaseLoginOptions {
|
|
|
129
129
|
* Override the origin (scheme + host) of the authorize endpoint and the
|
|
130
130
|
* `redirect_uri` for THIS call, keeping their configured paths. Lets one
|
|
131
131
|
* build-once client serve multiple deployment origins (preview URLs,
|
|
132
|
-
*
|
|
132
|
+
* per-application subdomains) — e.g. the Hono BFF passes the trusted request
|
|
133
133
|
* origin so sign-in works on whichever host the request arrived at. The
|
|
134
134
|
* callback returns to the same origin, so the matching `exchange` call is
|
|
135
135
|
* given the same origin.
|
|
@@ -139,7 +139,7 @@ export interface LoginOptions extends BaseLoginOptions {
|
|
|
139
139
|
* Store the pending authorization request here instead of in the client's
|
|
140
140
|
* configured `authRequestStorage`, for THIS call. Lets a server bind the
|
|
141
141
|
* record to the request it belongs to — a per-request cookie store, or a
|
|
142
|
-
* per-
|
|
142
|
+
* per-application table — rather than sharing one process-wide store. The
|
|
143
143
|
* matching `exchangeAuthorizationCode` call MUST be given a storage that
|
|
144
144
|
* can read the record back.
|
|
145
145
|
*/
|
|
@@ -44,7 +44,7 @@ export interface DiscoveryCacheEntry {
|
|
|
44
44
|
* collapse concurrent misses into one `load` call — a get/set pair cannot.
|
|
45
45
|
*
|
|
46
46
|
* Metadata is issuer-scoped and public: it carries no client id, secret, or
|
|
47
|
-
*
|
|
47
|
+
* user data, so one cache may serve many clients pointed at the same issuer.
|
|
48
48
|
* Do share a cache across clients that reach an issuer the same way; do not
|
|
49
49
|
* share one between a stubbed transport and live traffic.
|
|
50
50
|
*
|
|
@@ -90,8 +90,8 @@ export interface MemoryDiscoveryCacheOptions {
|
|
|
90
90
|
/**
|
|
91
91
|
* Maximum number of issuers held. Defaults to
|
|
92
92
|
* {@link DEFAULT_DISCOVERY_CACHE_MAX_ENTRIES}. Expired entries are dropped
|
|
93
|
-
* first, then the least recently used, so a
|
|
94
|
-
* unbounded
|
|
93
|
+
* first, then the least recently used, so a server that reaches an
|
|
94
|
+
* unbounded list of issuers cannot grow without limit.
|
|
95
95
|
*/
|
|
96
96
|
maxEntries?: number;
|
|
97
97
|
}
|
|
@@ -118,8 +118,8 @@ export interface VerifyCaptchaOptions {
|
|
|
118
118
|
* What to do when the provider throws (an outage). `true` (the default)
|
|
119
119
|
* fails open — the request passes with `degraded: true` — so a provider
|
|
120
120
|
* outage falls back to your rate-limit/lockout floor instead of blocking
|
|
121
|
-
* every user. `false` fails closed for a
|
|
122
|
-
* degrade.
|
|
121
|
+
* every user. `false` fails closed for a deployment that would rather refuse
|
|
122
|
+
* than degrade.
|
|
123
123
|
*/
|
|
124
124
|
failOpen?: boolean;
|
|
125
125
|
}
|
|
@@ -47,7 +47,7 @@ export interface RequireConditions {
|
|
|
47
47
|
scope?: string | string[];
|
|
48
48
|
/** Permission(s) the subject must hold ({@link Authorization.can}). */
|
|
49
49
|
permission?: string | string[];
|
|
50
|
-
/**
|
|
50
|
+
/** Issuer-wide role(s) the subject must hold ({@link Authorization.hasRole}). */
|
|
51
51
|
role?: string | string[];
|
|
52
52
|
/**
|
|
53
53
|
* Role(s) the subject must hold in the active organization
|
|
@@ -78,9 +78,9 @@ export interface AuthorizationFailure {
|
|
|
78
78
|
export interface AuthorizationInit {
|
|
79
79
|
/** The scope granted to the client, when known. */
|
|
80
80
|
scope?: AbstractScope | null;
|
|
81
|
-
/**
|
|
81
|
+
/** Issuer-wide role slugs the subject holds, outside any organization. */
|
|
82
82
|
roles?: Iterable<string>;
|
|
83
|
-
/** Permissions the subject holds (
|
|
83
|
+
/** Permissions the subject holds (issuer-wide plus the active organization's). */
|
|
84
84
|
permissions?: Iterable<string>;
|
|
85
85
|
/** The active organization context, when the credential carries one. */
|
|
86
86
|
organization?: OrganizationContext | null;
|
|
@@ -111,7 +111,7 @@ export interface AuthorizationInit {
|
|
|
111
111
|
export declare class Authorization {
|
|
112
112
|
/** The scope granted to the client, or `null` when unknown. */
|
|
113
113
|
readonly scope: AbstractScope | null;
|
|
114
|
-
/**
|
|
114
|
+
/** Issuer-wide role slugs the subject holds, outside any organization. */
|
|
115
115
|
readonly roles: ReadonlySet<string>;
|
|
116
116
|
/** Permissions the subject holds in the credential's scope of issue. */
|
|
117
117
|
readonly permissions: ReadonlySet<string>;
|
|
@@ -130,7 +130,7 @@ export declare class Authorization {
|
|
|
130
130
|
* permission is the contract, role names are presentation.
|
|
131
131
|
*/
|
|
132
132
|
can(permission: string): boolean;
|
|
133
|
-
/** Whether the subject holds the
|
|
133
|
+
/** Whether the subject holds the issuer-wide role `role`. */
|
|
134
134
|
hasRole(role: string): boolean;
|
|
135
135
|
/** Whether the subject holds `role` in the active organization. */
|
|
136
136
|
hasOrgRole(role: string): boolean;
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
export class Authorization {
|
|
43
43
|
/** The scope granted to the client, or `null` when unknown. */
|
|
44
44
|
scope;
|
|
45
|
-
/**
|
|
45
|
+
/** Issuer-wide role slugs the subject holds, outside any organization. */
|
|
46
46
|
roles;
|
|
47
47
|
/** Permissions the subject holds in the credential's scope of issue. */
|
|
48
48
|
permissions;
|
|
@@ -70,7 +70,7 @@ export class Authorization {
|
|
|
70
70
|
can(permission) {
|
|
71
71
|
return this.permissions.has(permission);
|
|
72
72
|
}
|
|
73
|
-
/** Whether the subject holds the
|
|
73
|
+
/** Whether the subject holds the issuer-wide role `role`. */
|
|
74
74
|
hasRole(role) {
|
|
75
75
|
return this.roles.has(role);
|
|
76
76
|
}
|
package/dist/models/client.d.ts
CHANGED
package/dist/models/token.d.ts
CHANGED
|
@@ -67,10 +67,10 @@ export interface RefreshToken<Client extends ClientInterface, User, Scope extend
|
|
|
67
67
|
*/
|
|
68
68
|
familyCreatedAt?: Date;
|
|
69
69
|
/**
|
|
70
|
-
* When the refresh grant rotated this token out. A token service
|
|
71
|
-
*
|
|
72
|
-
* `
|
|
73
|
-
* revoked any other way has none.
|
|
70
|
+
* When the refresh grant rotated this token out. A token service sets it in
|
|
71
|
+
* `revokeRotated` and returns it from `getRevokedRefreshToken`; the refresh
|
|
72
|
+
* grant's `replayGraceSeconds` and the revocation endpoint's family
|
|
73
|
+
* revocation both need it. A token revoked any other way has none.
|
|
74
74
|
*/
|
|
75
75
|
rotatedAt?: Date;
|
|
76
76
|
}
|
|
@@ -32,12 +32,12 @@ import type { AuthFormClassNames } from "./types.ts";
|
|
|
32
32
|
*
|
|
33
33
|
* @example Theme a custom field to match the form around it
|
|
34
34
|
* ```tsx
|
|
35
|
-
* function
|
|
35
|
+
* function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
|
|
36
36
|
* const classNames = useAuthFormClassNames();
|
|
37
37
|
* return (
|
|
38
38
|
* <div className={classNames.field}>
|
|
39
39
|
* <label className={classNames.label} htmlFor={props.fieldProps.id}>
|
|
40
|
-
*
|
|
40
|
+
* Invite code
|
|
41
41
|
* </label>
|
|
42
42
|
* <input {...props.fieldProps} className={classNames.input} />
|
|
43
43
|
* </div>
|
|
@@ -49,12 +49,12 @@ function slotKey(classNames) {
|
|
|
49
49
|
*
|
|
50
50
|
* @example Theme a custom field to match the form around it
|
|
51
51
|
* ```tsx
|
|
52
|
-
* function
|
|
52
|
+
* function InviteCodeField(props: { fieldProps: AuthFieldProps }) {
|
|
53
53
|
* const classNames = useAuthFormClassNames();
|
|
54
54
|
* return (
|
|
55
55
|
* <div className={classNames.field}>
|
|
56
56
|
* <label className={classNames.label} htmlFor={props.fieldProps.id}>
|
|
57
|
-
*
|
|
57
|
+
* Invite code
|
|
58
58
|
* </label>
|
|
59
59
|
* <input {...props.fieldProps} className={classNames.input} />
|
|
60
60
|
* </div>
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* - Server metadata (RFC 8414)
|
|
10
10
|
* - Device authorization (RFC 8628)
|
|
11
11
|
*
|
|
12
|
-
* **Configuration.**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* **Configuration.** The only configuration seam is `resolve(request)`, which
|
|
13
|
+
* returns the per-request configuration: the services the server's own
|
|
14
|
+
* endpoints need (`{ clientService, tokenService }`) plus the issuer and
|
|
15
|
+
* endpoint URLs. Because it runs per request, one server can serve the origins
|
|
16
|
+
* or applications a business operates. Each grant resolves its own services
|
|
17
|
+
* independently. For a single deployment, `resolve` is a constant function
|
|
18
|
+
* (create the services once, outside it, and return them every time).
|
|
18
19
|
*
|
|
19
20
|
* @see https://datatracker.ietf.org/doc/html/rfc6749
|
|
20
21
|
* @module
|
|
@@ -161,9 +162,11 @@ export interface AuthorizationServerContext<Client extends ClientInterface, User
|
|
|
161
162
|
*/
|
|
162
163
|
export interface AuthorizationServerOptions<Client extends ClientInterface, User, S extends AbstractScope> extends Omit<ResourceServerOptions<Client, User, S>, "resolve"> {
|
|
163
164
|
/**
|
|
164
|
-
* Resolves the per-request
|
|
165
|
-
* issuer
|
|
166
|
-
*
|
|
165
|
+
* Resolves the per-request configuration — the server's own services plus
|
|
166
|
+
* the issuer and endpoint URLs — so one server can serve the origins or
|
|
167
|
+
* applications a business operates. This is the only services seam: for a
|
|
168
|
+
* single deployment it is a constant function returning the same context
|
|
169
|
+
* every time.
|
|
167
170
|
*/
|
|
168
171
|
resolve: (request: Request) => AuthorizationServerContext<Client, User, S> | Promise<AuthorizationServerContext<Client, User, S>>;
|
|
169
172
|
/** Grants the server supports, keyed by `grant_type` (each key must equal `grant.grantType`). */
|
|
@@ -406,8 +409,8 @@ export declare const ENDPOINT_METHODS: Readonly<Record<EndpointKey, readonly str
|
|
|
406
409
|
* Framework-agnostic: each `handle*Request` method takes a web `Request` and
|
|
407
410
|
* returns a `Response`, so an adapter routes its endpoints to them. Extends
|
|
408
411
|
* {@linkcode ResourceServer}, so the same instance also validates bearer
|
|
409
|
-
* tokens.
|
|
410
|
-
* callback in {@linkcode AuthorizationServerOptions}.
|
|
412
|
+
* tokens. Its issuer, endpoint URLs and services come per request from the
|
|
413
|
+
* `resolve` callback in {@linkcode AuthorizationServerOptions}.
|
|
411
414
|
*
|
|
412
415
|
* The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
|
|
413
416
|
* {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
|
|
@@ -641,6 +644,18 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
|
|
|
641
644
|
* end another application's live sessions at will. A token belonging to a
|
|
642
645
|
* different client is left alone.
|
|
643
646
|
*
|
|
647
|
+
* **A refresh token that rotation already replaced revokes its family.** When
|
|
648
|
+
* the token is no longer live but the token service's
|
|
649
|
+
* `getRevokedRefreshToken` returns it with {@linkcode RefreshToken.rotatedAt}
|
|
650
|
+
* and a `familyId`, and it was issued to the authenticated client, the
|
|
651
|
+
* server calls `revokeFamily`, so the successor rotation issued dies with
|
|
652
|
+
* it (RFC 7009 §2.1 allows revoking related tokens). A sign-out that races
|
|
653
|
+
* the client's own refresh therefore still ends the session. A token
|
|
654
|
+
* revoked other than by rotation, a token with no family, and a service
|
|
655
|
+
* without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
|
|
656
|
+
* The refresh grant's `replayGraceSeconds` does not apply here, and
|
|
657
|
+
* `onTokenReuse` does not fire.
|
|
658
|
+
*
|
|
644
659
|
* Always returns 200 OK, even if the token was invalid, already revoked, or
|
|
645
660
|
* issued to another client: RFC 7009 Section 2.2 requires that answer so the
|
|
646
661
|
* endpoint cannot be used as an oracle for whether a token value is live or
|
|
@@ -9,12 +9,13 @@
|
|
|
9
9
|
* - Server metadata (RFC 8414)
|
|
10
10
|
* - Device authorization (RFC 8628)
|
|
11
11
|
*
|
|
12
|
-
* **Configuration.**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* **Configuration.** The only configuration seam is `resolve(request)`, which
|
|
13
|
+
* returns the per-request configuration: the services the server's own
|
|
14
|
+
* endpoints need (`{ clientService, tokenService }`) plus the issuer and
|
|
15
|
+
* endpoint URLs. Because it runs per request, one server can serve the origins
|
|
16
|
+
* or applications a business operates. Each grant resolves its own services
|
|
17
|
+
* independently. For a single deployment, `resolve` is a constant function
|
|
18
|
+
* (create the services once, outside it, and return them every time).
|
|
18
19
|
*
|
|
19
20
|
* @see https://datatracker.ietf.org/doc/html/rfc6749
|
|
20
21
|
* @module
|
|
@@ -154,8 +155,8 @@ function isDeviceAuthorizationGrant(grant) {
|
|
|
154
155
|
* Framework-agnostic: each `handle*Request` method takes a web `Request` and
|
|
155
156
|
* returns a `Response`, so an adapter routes its endpoints to them. Extends
|
|
156
157
|
* {@linkcode ResourceServer}, so the same instance also validates bearer
|
|
157
|
-
* tokens.
|
|
158
|
-
* callback in {@linkcode AuthorizationServerOptions}.
|
|
158
|
+
* tokens. Its issuer, endpoint URLs and services come per request from the
|
|
159
|
+
* `resolve` callback in {@linkcode AuthorizationServerOptions}.
|
|
159
160
|
*
|
|
160
161
|
* The handlers throw {@linkcode HttpError} subclasses on failure (e.g.
|
|
161
162
|
* {@linkcode InvalidRequestError}, {@linkcode InvalidClientError},
|
|
@@ -935,6 +936,18 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
935
936
|
* end another application's live sessions at will. A token belonging to a
|
|
936
937
|
* different client is left alone.
|
|
937
938
|
*
|
|
939
|
+
* **A refresh token that rotation already replaced revokes its family.** When
|
|
940
|
+
* the token is no longer live but the token service's
|
|
941
|
+
* `getRevokedRefreshToken` returns it with {@linkcode RefreshToken.rotatedAt}
|
|
942
|
+
* and a `familyId`, and it was issued to the authenticated client, the
|
|
943
|
+
* server calls `revokeFamily`, so the successor rotation issued dies with
|
|
944
|
+
* it (RFC 7009 §2.1 allows revoking related tokens). A sign-out that races
|
|
945
|
+
* the client's own refresh therefore still ends the session. A token
|
|
946
|
+
* revoked other than by rotation, a token with no family, and a service
|
|
947
|
+
* without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
|
|
948
|
+
* The refresh grant's `replayGraceSeconds` does not apply here, and
|
|
949
|
+
* `onTokenReuse` does not fire.
|
|
950
|
+
*
|
|
938
951
|
* Always returns 200 OK, even if the token was invalid, already revoked, or
|
|
939
952
|
* issued to another client: RFC 7009 Section 2.2 requires that answer so the
|
|
940
953
|
* endpoint cannot be used as an oracle for whether a token value is live or
|
|
@@ -962,8 +975,10 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
962
975
|
const hint = typeof tokenTypeHint === "string" ? tokenTypeHint : null;
|
|
963
976
|
const { tokenService } = context.services;
|
|
964
977
|
const resolved = await this.#resolveToken(tokenService, token, hint);
|
|
965
|
-
if (resolved
|
|
966
|
-
|
|
978
|
+
if (!resolved) {
|
|
979
|
+
await this.#revokeRotatedOutFamily(tokenService, token, client);
|
|
980
|
+
}
|
|
981
|
+
else if (resolved.token.client.id.toString() === client.id.toString()) {
|
|
967
982
|
await tokenService.revoke(token, resolved.tokenType);
|
|
968
983
|
}
|
|
969
984
|
return new Response(null, { status: 200 });
|
|
@@ -972,6 +987,17 @@ export class AuthorizationServer extends ResourceServer {
|
|
|
972
987
|
return this.#handleEndpointError(error, this.prepareClientAuthError(request));
|
|
973
988
|
}
|
|
974
989
|
}
|
|
990
|
+
async #revokeRotatedOutFamily(tokenService, value, client) {
|
|
991
|
+
if (!tokenService.getRevokedRefreshToken || !tokenService.revokeFamily) {
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
const rotatedOut = await tokenService.getRevokedRefreshToken(value);
|
|
995
|
+
if (rotatedOut?.rotatedAt &&
|
|
996
|
+
rotatedOut.familyId &&
|
|
997
|
+
rotatedOut.client.id.toString() === client.id.toString()) {
|
|
998
|
+
await tokenService.revokeFamily(rotatedOut.familyId);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
975
1001
|
async #resolveToken(tokenService, value, hint) {
|
|
976
1002
|
const refreshFirst = hint === "refresh_token";
|
|
977
1003
|
if (refreshFirst) {
|
|
@@ -22,17 +22,18 @@ export interface ResourceServerServices<Client extends ClientInterface, User, S
|
|
|
22
22
|
}
|
|
23
23
|
/** Per-request context resolved by a {@link ResourceServer}. */
|
|
24
24
|
export interface ResourceServerContext<Client extends ClientInterface, User, S extends AbstractScope> {
|
|
25
|
-
/**
|
|
25
|
+
/** The services to use for this request. */
|
|
26
26
|
services: ResourceServerServices<Client, User, S>;
|
|
27
27
|
}
|
|
28
28
|
/** Options for configuring the resource server. */
|
|
29
29
|
export interface ResourceServerOptions<Client extends ClientInterface, User, S extends AbstractScope> {
|
|
30
30
|
/**
|
|
31
|
-
* Resolves the per-request
|
|
32
|
-
* ResourceServerServices} for THIS request — so
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* (create the services once, outside `resolve`,
|
|
31
|
+
* Resolves the per-request configuration — the {@link
|
|
32
|
+
* ResourceServerServices} for THIS request — so one server can serve the
|
|
33
|
+
* origins or applications a business operates. This is the only services
|
|
34
|
+
* seam: for a single deployment it is a constant function returning the
|
|
35
|
+
* same services every time (create the services once, outside `resolve`,
|
|
36
|
+
* and return them here).
|
|
36
37
|
*/
|
|
37
38
|
resolve: (request: Request) => ResourceServerContext<Client, User, S> | Promise<ResourceServerContext<Client, User, S>>;
|
|
38
39
|
/** Scope constructor used to parse and compare scopes. Defaults to {@linkcode BasicScope}. */
|
|
@@ -133,10 +134,9 @@ export interface AuthenticatedContext<Client extends ClientInterface, User, S ex
|
|
|
133
134
|
* authentication. Framework-agnostic: it works on web `Request`/`Response`, so
|
|
134
135
|
* an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
|
|
135
136
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* services every time.
|
|
137
|
+
* Per-request services come from the `resolve` callback in
|
|
138
|
+
* {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
|
|
139
|
+
* constant function returning the same services every time.
|
|
140
140
|
*
|
|
141
141
|
* {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
|
|
142
142
|
* ({@linkcode AccessDeniedError} when no token is sent,
|
|
@@ -31,10 +31,9 @@ const CHALLENGE_ERROR_CODES = new Set([
|
|
|
31
31
|
* authentication. Framework-agnostic: it works on web `Request`/`Response`, so
|
|
32
32
|
* an adapter (Hono, etc.) wires {@linkcode authenticate} into its middleware.
|
|
33
33
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* services every time.
|
|
34
|
+
* Per-request services come from the `resolve` callback in
|
|
35
|
+
* {@linkcode ResourceServerOptions}. For a single deployment, `resolve` is a
|
|
36
|
+
* constant function returning the same services every time.
|
|
38
37
|
*
|
|
39
38
|
* {@linkcode authenticate} throws {@linkcode HttpError} subclasses on failure
|
|
40
39
|
* ({@linkcode AccessDeniedError} when no token is sent,
|
|
@@ -122,9 +122,11 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
122
122
|
* cannot terminate the very session it renews. When absent, the refresh
|
|
123
123
|
* grant falls back to {@linkcode revoke}.
|
|
124
124
|
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
125
|
+
* Stamp the revoked record with {@linkcode RefreshToken.rotatedAt} here and
|
|
126
|
+
* return it, with its `familyId`, from {@linkcode getRevokedRefreshToken}.
|
|
127
|
+
* The refresh grant's `replayGraceSeconds` reads it, and the revocation
|
|
128
|
+
* endpoint revokes the family of a token presented after rotation replaced
|
|
129
|
+
* it, so a sign-out racing a refresh still ends the successor.
|
|
128
130
|
*/
|
|
129
131
|
revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
|
|
130
132
|
/**
|
|
@@ -139,17 +141,32 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
139
141
|
*/
|
|
140
142
|
revokeCode(code: string): Promise<boolean>;
|
|
141
143
|
/**
|
|
142
|
-
* Looks up a refresh token that has already been revoked/rotated out
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
144
|
+
* Looks up a refresh token that has already been revoked/rotated out. The
|
|
145
|
+
* refresh grant's reuse detection reads it, and so does the revocation
|
|
146
|
+
* endpoint, to revoke the family of a token rotation replaced. Implement
|
|
147
|
+
* this (with {@linkcode revokeFamily}) by keeping revoked refresh-token
|
|
148
|
+
* records findable instead of deleting them; when absent, reuse detection
|
|
149
|
+
* is silently disabled and `/revoke` cannot reach a successor.
|
|
150
|
+
*
|
|
151
|
+
* Return the tombstone unconditionally. Apply any replay grace through
|
|
152
|
+
* `RefreshTokenGrantOptions.replayGraceSeconds`, never by hiding recent
|
|
153
|
+
* tombstones here: a hidden tombstone also hides the family from a sign-out
|
|
154
|
+
* racing the refresh, which then leaves the successor live.
|
|
147
155
|
*/
|
|
148
156
|
getRevokedRefreshToken?(refreshToken: string): Promise<RefreshToken<Client, User, Scope> | undefined>;
|
|
149
157
|
/**
|
|
150
158
|
* Revokes every token belonging to a rotation family — called by the
|
|
151
159
|
* refresh grant when a rotated-out member is replayed, so a stolen token
|
|
152
|
-
* can't keep a session alive
|
|
160
|
+
* can't keep a session alive, and by the revocation endpoint when it is
|
|
161
|
+
* given a token whose tombstone carries {@linkcode RefreshToken.rotatedAt}.
|
|
162
|
+
* Returns true if anything was revoked.
|
|
163
|
+
*
|
|
164
|
+
* `/revoke` ends the family without firing `onTokenReuse`, so any audit
|
|
165
|
+
* trail for that case has to come from this method.
|
|
166
|
+
*
|
|
167
|
+
* Keep the family revoked: a successor that a concurrent rotation saves
|
|
168
|
+
* after this call must be stored already revoked, as
|
|
169
|
+
* `MemoryTokenService` does.
|
|
153
170
|
*/
|
|
154
171
|
revokeFamily?(familyId: string): Promise<boolean>;
|
|
155
172
|
/**
|
|
@@ -49,7 +49,9 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
49
49
|
* and {@linkcode TokenServiceInterface.revokeFamily}. The suite then
|
|
50
50
|
* registers an ignored case naming what is not covered — a replayed
|
|
51
51
|
* rotated-out refresh token cannot be detected, so a stolen token keeps
|
|
52
|
-
* working alongside the victim's
|
|
52
|
+
* working alongside the victim's, and revoking a rotated-out token at the
|
|
53
|
+
* revocation endpoint leaves its successor live — instead of passing in
|
|
54
|
+
* silence. Defaults to
|
|
53
55
|
* `true`, so a service that meant to implement the pair and does not fails
|
|
54
56
|
* loudly rather than losing reuse detection quietly.
|
|
55
57
|
*/
|
|
@@ -285,6 +285,24 @@ export function runTokenServiceContractTests(options) {
|
|
|
285
285
|
revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
|
|
286
286
|
"applies, so every straggler revokes its family");
|
|
287
287
|
});
|
|
288
|
+
it("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
|
|
289
|
+
skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
|
|
290
|
+
const rotatedOut = await saveFamilyMember(FAMILY);
|
|
291
|
+
expect(await tokenService.revokeRotated(rotatedOut)).toBe(true);
|
|
292
|
+
const successor = await saveFamilyMember(FAMILY);
|
|
293
|
+
const unrelated = await saveFamilyMember(OTHER_FAMILY);
|
|
294
|
+
const tombstone = await tokenService.getRevokedRefreshToken(rotatedOut.refreshToken);
|
|
295
|
+
assert(tombstone?.familyId === FAMILY && tombstone.rotatedAt, "the revocation endpoint finds the family to end from the " +
|
|
296
|
+
"rotated-out token's record; without its familyId and " +
|
|
297
|
+
"rotatedAt, a sign-out racing a refresh leaves the successor live");
|
|
298
|
+
expect(tombstone.client.id.toString(), "the endpoint revokes the family only for the client the token " +
|
|
299
|
+
"was issued to").toBe(client.id.toString());
|
|
300
|
+
expect(await tokenService.revokeFamily(tombstone.familyId)).toBe(true);
|
|
301
|
+
expect(await tokenService.getRefreshToken(successor.refreshToken)).toBe(undefined);
|
|
302
|
+
expect(await tokenService.getToken(successor.accessToken)).toBe(undefined);
|
|
303
|
+
assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
|
|
304
|
+
undefined);
|
|
305
|
+
});
|
|
288
306
|
it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
|
|
289
307
|
const token = await saveFamilyMember(FAMILY);
|
|
290
308
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
@@ -311,7 +329,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
311
329
|
});
|
|
312
330
|
}
|
|
313
331
|
else {
|
|
314
|
-
it.skip("refresh-token reuse detection is not implemented — a replayed rotated-out token cannot be detected (refreshTokenReuseDetection: false)", () => { });
|
|
332
|
+
it.skip("refresh-token reuse detection is not implemented — a replayed rotated-out token cannot be detected, and revoking one does not end its successor (refreshTokenReuseDetection: false)", () => { });
|
|
315
333
|
}
|
|
316
334
|
});
|
|
317
335
|
}
|
|
@@ -256,8 +256,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
256
256
|
*/
|
|
257
257
|
getRevokedRefreshToken(refreshToken: string): Promise<RefreshToken<C, U, S> | undefined>;
|
|
258
258
|
/**
|
|
259
|
-
* Revokes every live token in a rotation family (reuse detection response)
|
|
260
|
-
*
|
|
259
|
+
* Revokes every live token in a rotation family (reuse detection response)
|
|
260
|
+
* and keeps the family revoked, so a successor a concurrent rotation saves
|
|
261
|
+
* afterwards is stored already revoked. Resolves `true` when at least one
|
|
262
|
+
* live token was removed.
|
|
261
263
|
*/
|
|
262
264
|
revokeFamily(familyId: string): Promise<boolean>;
|
|
263
265
|
/**
|
package/dist/testing/services.js
CHANGED
|
@@ -207,6 +207,7 @@ export class MemoryTokenService {
|
|
|
207
207
|
#tokensByRefreshHash = new Map();
|
|
208
208
|
#tokensByCodeHash = new Map();
|
|
209
209
|
#revokedByRefreshHash = new Map();
|
|
210
|
+
#revokedFamilies = new Set();
|
|
210
211
|
/**
|
|
211
212
|
* Creates an empty token store.
|
|
212
213
|
*
|
|
@@ -340,6 +341,10 @@ export class MemoryTokenService {
|
|
|
340
341
|
stored.refreshTokenExpiresAt = rt.refreshTokenExpiresAt;
|
|
341
342
|
stored.familyId = rt.familyId;
|
|
342
343
|
stored.familyCreatedAt = rt.familyCreatedAt;
|
|
344
|
+
if (rt.familyId && this.#revokedFamilies.has(rt.familyId)) {
|
|
345
|
+
this.#tombstone(stored.refreshTokenHash, stored);
|
|
346
|
+
return token;
|
|
347
|
+
}
|
|
343
348
|
this.#tokensByRefreshHash.set(stored.refreshTokenHash, stored);
|
|
344
349
|
}
|
|
345
350
|
this.#tokensByAccessHash.set(accessTokenHash, stored);
|
|
@@ -408,10 +413,13 @@ export class MemoryTokenService {
|
|
|
408
413
|
return this.#hydrate(stored, "", refreshToken);
|
|
409
414
|
}
|
|
410
415
|
/**
|
|
411
|
-
* Revokes every live token in a rotation family (reuse detection response)
|
|
412
|
-
*
|
|
416
|
+
* Revokes every live token in a rotation family (reuse detection response)
|
|
417
|
+
* and keeps the family revoked, so a successor a concurrent rotation saves
|
|
418
|
+
* afterwards is stored already revoked. Resolves `true` when at least one
|
|
419
|
+
* live token was removed.
|
|
413
420
|
*/
|
|
414
421
|
revokeFamily(familyId) {
|
|
422
|
+
this.#revokedFamilies.add(familyId);
|
|
415
423
|
let revoked = false;
|
|
416
424
|
for (const [hash, stored] of this.#tokensByRefreshHash) {
|
|
417
425
|
if (stored.familyId === familyId) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.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",
|