@udibo/oauth2 0.21.2 → 0.22.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/_common.d.ts +6 -0
- package/dist/adapters/hono/_common.js +11 -3
- package/dist/adapters/hono/bff/_rotated-cookies.d.ts +36 -0
- package/dist/adapters/hono/bff/_rotated-cookies.js +93 -0
- package/dist/adapters/hono/bff/bff.d.ts +30 -1
- package/dist/adapters/hono/bff/bff.js +66 -7
- package/dist/models/token.d.ts +7 -0
- package/dist/server/grants/refresh-token.d.ts +27 -0
- package/dist/server/grants/refresh-token.js +16 -1
- package/dist/server/services/token.d.ts +10 -0
- package/dist/testing/contract/token.js +37 -2
- package/dist/testing/services.d.ts +9 -0
- package/dist/testing/services.js +26 -0
- package/package.json +1 -1
|
@@ -26,6 +26,12 @@ export interface HonoResourceServerVariables<Client extends ClientInterface, Use
|
|
|
26
26
|
/** The authenticated OAuth2 context set by the protect middleware. */
|
|
27
27
|
[OAUTH2_CONTEXT_KEY]: AuthenticatedContext<Client, User, S>;
|
|
28
28
|
}
|
|
29
|
+
/**
|
|
30
|
+
* Answers with `response` while keeping the headers earlier middleware set on
|
|
31
|
+
* `c`, such as the `Set-Cookie` a BFF sets when a refresh rotates or ends the
|
|
32
|
+
* session. A middleware returning a bare `Response` would drop them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function respondInContext(c: Context, response: Response): Response;
|
|
29
35
|
/**
|
|
30
36
|
* Builds a Hono middleware that authenticates bearer tokens, stores the
|
|
31
37
|
* authenticated context at {@link OAUTH2_CONTEXT_KEY}, and on failure returns
|
|
@@ -13,6 +13,14 @@ import { ResourceServer } from "../../server/resource-server.js";
|
|
|
13
13
|
* context. Namespaced to avoid collisions with other middleware.
|
|
14
14
|
*/
|
|
15
15
|
export const OAUTH2_CONTEXT_KEY = "@udibo/oauth2";
|
|
16
|
+
/**
|
|
17
|
+
* Answers with `response` while keeping the headers earlier middleware set on
|
|
18
|
+
* `c`, such as the `Set-Cookie` a BFF sets when a refresh rotates or ends the
|
|
19
|
+
* session. A middleware returning a bare `Response` would drop them.
|
|
20
|
+
*/
|
|
21
|
+
export function respondInContext(c, response) {
|
|
22
|
+
return c.newResponse(response.body, response);
|
|
23
|
+
}
|
|
16
24
|
/**
|
|
17
25
|
* Builds a Hono middleware that authenticates bearer tokens, stores the
|
|
18
26
|
* authenticated context at {@link OAUTH2_CONTEXT_KEY}, and on failure returns
|
|
@@ -26,7 +34,7 @@ export function createProtectMiddleware(server, requiredScope) {
|
|
|
26
34
|
await next();
|
|
27
35
|
}
|
|
28
36
|
catch (error) {
|
|
29
|
-
return server.handleAuthError(error);
|
|
37
|
+
return respondInContext(c, server.handleAuthError(error));
|
|
30
38
|
}
|
|
31
39
|
};
|
|
32
40
|
}
|
|
@@ -45,7 +53,7 @@ export function createRequireScopeMiddleware(server, adapterName, requiredScope)
|
|
|
45
53
|
server.assertScope(scope, requiredScope);
|
|
46
54
|
}
|
|
47
55
|
catch (error) {
|
|
48
|
-
return server.handleAuthError(error);
|
|
56
|
+
return respondInContext(c, server.handleAuthError(error));
|
|
49
57
|
}
|
|
50
58
|
await next();
|
|
51
59
|
};
|
|
@@ -63,7 +71,7 @@ export function createRequireMiddleware(server, adapterName, conditions) {
|
|
|
63
71
|
server.assertAuthorized(context, conditions);
|
|
64
72
|
}
|
|
65
73
|
catch (error) {
|
|
66
|
-
return server.handleAuthError(error);
|
|
74
|
+
return respondInContext(c, server.handleAuthError(error));
|
|
67
75
|
}
|
|
68
76
|
await next();
|
|
69
77
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance memory of session cookies a refresh replaced, used by
|
|
3
|
+
* `HonoBff`'s `rotatedCookieGrace`.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
/** A refresh {@link RotatedCookies.start} began, to hand to `finish`. */
|
|
8
|
+
export interface PendingReplacement {
|
|
9
|
+
readonly key: string;
|
|
10
|
+
ended: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Maps a replaced session cookie, by its SHA-256 digest, to the cookie that
|
|
14
|
+
* replaced it, for `ttlMs` and at most `maxEntries` cookies. The oldest entry
|
|
15
|
+
* is evicted first.
|
|
16
|
+
*/
|
|
17
|
+
export declare class RotatedCookies {
|
|
18
|
+
#private;
|
|
19
|
+
constructor(ttlMs: number, maxEntries: number);
|
|
20
|
+
/** Records that `successor` replaced `previous`. */
|
|
21
|
+
remember(previous: string, successor: string): Promise<void>;
|
|
22
|
+
/**
|
|
23
|
+
* Starts a refresh of `previous`. If its chain is forgotten before
|
|
24
|
+
* {@link finish}, the replacement is not remembered.
|
|
25
|
+
*/
|
|
26
|
+
start(previous: string): Promise<PendingReplacement>;
|
|
27
|
+
/** Ends a refresh, remembering `successor` unless its chain was forgotten. */
|
|
28
|
+
finish(pending: PendingReplacement, successor: string | undefined): Promise<void>;
|
|
29
|
+
/** The cookie that replaced `cookieValue` within the window, if any. */
|
|
30
|
+
successor(cookieValue: string): Promise<string | undefined>;
|
|
31
|
+
/**
|
|
32
|
+
* Drops every entry on the chain of replacements `cookieValue` belongs to,
|
|
33
|
+
* so no cookie of a session that ended leads to another.
|
|
34
|
+
*/
|
|
35
|
+
forget(cookieValue: string): Promise<void>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance memory of session cookies a refresh replaced, used by
|
|
3
|
+
* `HonoBff`'s `rotatedCookieGrace`.
|
|
4
|
+
*
|
|
5
|
+
* @module
|
|
6
|
+
*/
|
|
7
|
+
import { sha256Hash } from "../../../server/utils/hash.js";
|
|
8
|
+
/**
|
|
9
|
+
* Maps a replaced session cookie, by its SHA-256 digest, to the cookie that
|
|
10
|
+
* replaced it, for `ttlMs` and at most `maxEntries` cookies. The oldest entry
|
|
11
|
+
* is evicted first.
|
|
12
|
+
*/
|
|
13
|
+
export class RotatedCookies {
|
|
14
|
+
#ttlMs;
|
|
15
|
+
#maxEntries;
|
|
16
|
+
#successors = new Map();
|
|
17
|
+
#pending = new Set();
|
|
18
|
+
constructor(ttlMs, maxEntries) {
|
|
19
|
+
this.#ttlMs = ttlMs;
|
|
20
|
+
this.#maxEntries = maxEntries;
|
|
21
|
+
}
|
|
22
|
+
/** Records that `successor` replaced `previous`. */
|
|
23
|
+
async remember(previous, successor) {
|
|
24
|
+
await this.finish(await this.start(previous), successor);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Starts a refresh of `previous`. If its chain is forgotten before
|
|
28
|
+
* {@link finish}, the replacement is not remembered.
|
|
29
|
+
*/
|
|
30
|
+
async start(previous) {
|
|
31
|
+
const pending = { key: await sha256Hash(previous), ended: false };
|
|
32
|
+
this.#pending.add(pending);
|
|
33
|
+
return pending;
|
|
34
|
+
}
|
|
35
|
+
/** Ends a refresh, remembering `successor` unless its chain was forgotten. */
|
|
36
|
+
async finish(pending, successor) {
|
|
37
|
+
this.#pending.delete(pending);
|
|
38
|
+
if (pending.ended || successor === undefined)
|
|
39
|
+
return;
|
|
40
|
+
const key = pending.key;
|
|
41
|
+
this.#successors.delete(key);
|
|
42
|
+
this.#successors.set(key, {
|
|
43
|
+
cookieValue: successor,
|
|
44
|
+
digest: await sha256Hash(successor),
|
|
45
|
+
expiresAt: Date.now() + this.#ttlMs,
|
|
46
|
+
});
|
|
47
|
+
this.#evict();
|
|
48
|
+
}
|
|
49
|
+
/** The cookie that replaced `cookieValue` within the window, if any. */
|
|
50
|
+
async successor(cookieValue) {
|
|
51
|
+
const key = await sha256Hash(cookieValue);
|
|
52
|
+
const entry = this.#successors.get(key);
|
|
53
|
+
if (!entry)
|
|
54
|
+
return undefined;
|
|
55
|
+
if (entry.expiresAt <= Date.now()) {
|
|
56
|
+
this.#successors.delete(key);
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return entry.cookieValue;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Drops every entry on the chain of replacements `cookieValue` belongs to,
|
|
63
|
+
* so no cookie of a session that ended leads to another.
|
|
64
|
+
*/
|
|
65
|
+
async forget(cookieValue) {
|
|
66
|
+
const ended = new Set([await sha256Hash(cookieValue)]);
|
|
67
|
+
let dropped = true;
|
|
68
|
+
while (dropped) {
|
|
69
|
+
dropped = false;
|
|
70
|
+
for (const [key, entry] of this.#successors) {
|
|
71
|
+
if (ended.has(key) || ended.has(entry.digest)) {
|
|
72
|
+
this.#successors.delete(key);
|
|
73
|
+
ended.add(key);
|
|
74
|
+
ended.add(entry.digest);
|
|
75
|
+
dropped = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
for (const pending of this.#pending) {
|
|
80
|
+
if (ended.has(pending.key))
|
|
81
|
+
pending.ended = true;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
#evict() {
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
for (const [key, entry] of this.#successors) {
|
|
87
|
+
if (this.#successors.size <= this.#maxEntries && entry.expiresAt > now) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
this.#successors.delete(key);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -322,7 +322,8 @@ export interface HonoBffOptions {
|
|
|
322
322
|
* shared {@link SessionStore} when you need revocation. Against an
|
|
323
323
|
* authorization server that rotates refresh tokens, a request still
|
|
324
324
|
* carrying the cookie from before a refresh replays the old token and
|
|
325
|
-
* trips reuse detection
|
|
325
|
+
* trips reuse detection, unless it reaches the instance that ran the
|
|
326
|
+
* refresh within {@link HonoBffOptions.rotatedCookieGrace}.
|
|
326
327
|
* - A {@link SessionStore} over a database or cache every instance shares,
|
|
327
328
|
* for server-side revocation and back-channel logout. Implement
|
|
328
329
|
* `RefreshLockStore` (`withRefreshLock`) on it so instances refresh a
|
|
@@ -637,6 +638,34 @@ export interface HonoBffOptions {
|
|
|
637
638
|
* refresh proactively. Defaults to 30s.
|
|
638
639
|
*/
|
|
639
640
|
refreshSkewSeconds?: number;
|
|
641
|
+
/**
|
|
642
|
+
* How this instance answers a request that still carries the session cookie
|
|
643
|
+
* a refresh replaced. Applies when a refresh hands the browser a new cookie
|
|
644
|
+
* value, as {@link EncryptedCookieSessionStore} does on every refresh.
|
|
645
|
+
*
|
|
646
|
+
* A browser that sent several requests before the refresh's response arrived
|
|
647
|
+
* still sends the old cookie on the others. Against an authorization server
|
|
648
|
+
* that rotates refresh tokens, a request that arrives after the refresh has
|
|
649
|
+
* finished would present the rotated-out token: reuse detection revokes the
|
|
650
|
+
* family and the user is signed out. With this grace, the instance that ran
|
|
651
|
+
* the refresh remembers which cookie replaced which for `ttlMs` and answers
|
|
652
|
+
* such a request with the new session and its cookie, spending nothing.
|
|
653
|
+
*
|
|
654
|
+
* Defaults to `{ ttlMs: 10_000, maxEntries: 1000 }`. Pass `false` to turn it
|
|
655
|
+
* off. Beyond `maxEntries` the oldest entry is dropped. A session that ends
|
|
656
|
+
* on this instance (sign-out, a sign-in replacing it, a refused refresh)
|
|
657
|
+
* forgets its whole chain, including a refresh still in flight.
|
|
658
|
+
*
|
|
659
|
+
* The cost is that a stolen pre-refresh cookie replayed to the same
|
|
660
|
+
* instance within `ttlMs` receives the new session, where without the grace
|
|
661
|
+
* it would trip reuse detection. Only the instance that ran the refresh
|
|
662
|
+
* remembers, so a request on another instance still replays the token. See
|
|
663
|
+
* the session limitations in `docs/known-limitations.md`.
|
|
664
|
+
*/
|
|
665
|
+
rotatedCookieGrace?: {
|
|
666
|
+
ttlMs?: number;
|
|
667
|
+
maxEntries?: number;
|
|
668
|
+
} | false;
|
|
640
669
|
/**
|
|
641
670
|
* Optional resource server. When provided, {@link HonoBff.protect}
|
|
642
671
|
* becomes available — a single middleware that composes
|
|
@@ -55,11 +55,28 @@ import { defaultFetch } from "../../../utils/_default-fetch.js";
|
|
|
55
55
|
import { inboundRequestWithHeaders } from "../../../utils/_inbound-request.js";
|
|
56
56
|
import { timingSafeMatchIndex } from "../../../utils/_timing-safe.js";
|
|
57
57
|
import { sha256Hash } from "../../../server/utils/hash.js";
|
|
58
|
-
import { OAUTH2_CONTEXT_KEY } from "../_common.js";
|
|
58
|
+
import { OAUTH2_CONTEXT_KEY, respondInContext } from "../_common.js";
|
|
59
59
|
import { DEFAULT_MAX_RETURN_TO_LENGTH, loginContinuation as computeLoginContinuation, safeReturnTo, } from "../../../utils/url.js";
|
|
60
60
|
import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
|
|
61
61
|
import { DEFAULT_SESSION_MAX_AGE_MS, sessionStoreMaxAgeMs, supportsBackchannelLogout, supportsRefreshLock, } from "./session-store.js";
|
|
62
|
+
import { RotatedCookies } from "./_rotated-cookies.js";
|
|
62
63
|
import { buildDownstreamHeaders, buildUpstreamHeaders, isInvalidTokenChallenge, resolveForwardHeaders, resolveProxyUrl, } from "./proxy.js";
|
|
64
|
+
const DEFAULT_ROTATED_COOKIE_GRACE_MS = 10_000;
|
|
65
|
+
const DEFAULT_ROTATED_COOKIE_MAX_ENTRIES = 1000;
|
|
66
|
+
const MAX_ROTATED_COOKIE_HOPS = 4;
|
|
67
|
+
function rotatedCookies(grace) {
|
|
68
|
+
if (grace === false)
|
|
69
|
+
return undefined;
|
|
70
|
+
const ttlMs = grace?.ttlMs ?? DEFAULT_ROTATED_COOKIE_GRACE_MS;
|
|
71
|
+
const maxEntries = grace?.maxEntries ?? DEFAULT_ROTATED_COOKIE_MAX_ENTRIES;
|
|
72
|
+
if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
|
|
73
|
+
throw new RangeError(`rotatedCookieGrace.ttlMs must be a positive number of milliseconds, got ${ttlMs}`);
|
|
74
|
+
}
|
|
75
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
76
|
+
throw new RangeError(`rotatedCookieGrace.maxEntries must be a positive integer, got ${maxEntries}`);
|
|
77
|
+
}
|
|
78
|
+
return new RotatedCookies(ttlMs, maxEntries);
|
|
79
|
+
}
|
|
63
80
|
function refreshedSince(seen, current) {
|
|
64
81
|
return (current.refreshToken !== seen.refreshToken ||
|
|
65
82
|
current.tokens.accessToken !== seen.tokens.accessToken);
|
|
@@ -319,6 +336,7 @@ export class HonoBff {
|
|
|
319
336
|
#extraAuthorizeParams;
|
|
320
337
|
#maxReturnToLength;
|
|
321
338
|
#refreshes = new Map();
|
|
339
|
+
#rotatedCookies;
|
|
322
340
|
/**
|
|
323
341
|
* Defaults are applied for session mode (`"own"`), cookie name, and CSRF.
|
|
324
342
|
* The session store and auth-request storage have no default.
|
|
@@ -410,6 +428,7 @@ export class HonoBff {
|
|
|
410
428
|
}
|
|
411
429
|
}
|
|
412
430
|
}
|
|
431
|
+
this.#rotatedCookies = rotatedCookies(options.rotatedCookieGrace);
|
|
413
432
|
if (options.backchannelLogout?.maxBodyBytes !== undefined) {
|
|
414
433
|
assertMaxBodyBytes(options.backchannelLogout.maxBodyBytes, "backchannelLogout.maxBodyBytes");
|
|
415
434
|
}
|
|
@@ -475,7 +494,7 @@ export class HonoBff {
|
|
|
475
494
|
return null;
|
|
476
495
|
if (!this.#isBeyondSessionMaxAge(data))
|
|
477
496
|
return data;
|
|
478
|
-
await this.#
|
|
497
|
+
await this.#destroySession(cookieValue);
|
|
479
498
|
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
480
499
|
return null;
|
|
481
500
|
}
|
|
@@ -740,7 +759,7 @@ export class HonoBff {
|
|
|
740
759
|
const inbound = getCookie(c, this.#cookieName);
|
|
741
760
|
const existing = this.#sessionMode === "shared" ? inbound : undefined;
|
|
742
761
|
if (inbound && !existing)
|
|
743
|
-
await this.#
|
|
762
|
+
await this.#destroySession(inbound);
|
|
744
763
|
const cookieValue = existing
|
|
745
764
|
? await this.#store.update(existing, data)
|
|
746
765
|
: await this.#store.create(data);
|
|
@@ -784,7 +803,7 @@ export class HonoBff {
|
|
|
784
803
|
// best-effort
|
|
785
804
|
}
|
|
786
805
|
}
|
|
787
|
-
await this.#
|
|
806
|
+
await this.#destroySession(cookieValue);
|
|
788
807
|
deleteCookie(c, this.#cookieName, this.#cookieOptions());
|
|
789
808
|
}
|
|
790
809
|
const returnTo = this.#safeReturnTo(c.req.query("return_to"));
|
|
@@ -1009,12 +1028,52 @@ export class HonoBff {
|
|
|
1009
1028
|
const inFlight = this.#refreshes.get(entry.cookieValue);
|
|
1010
1029
|
if (inFlight)
|
|
1011
1030
|
return inFlight;
|
|
1012
|
-
const pending = this.#
|
|
1031
|
+
const pending = this.#refreshUnlessReplaced(entry).finally(() => {
|
|
1013
1032
|
this.#refreshes.delete(entry.cookieValue);
|
|
1014
1033
|
});
|
|
1015
1034
|
this.#refreshes.set(entry.cookieValue, pending);
|
|
1016
1035
|
return pending;
|
|
1017
1036
|
}
|
|
1037
|
+
async #refreshUnlessReplaced(entry) {
|
|
1038
|
+
const memo = this.#rotatedCookies;
|
|
1039
|
+
if (!memo)
|
|
1040
|
+
return await this.#lockedRefresh(entry);
|
|
1041
|
+
let latest = entry.cookieValue;
|
|
1042
|
+
for (let hop = 0; hop < MAX_ROTATED_COOKIE_HOPS; hop++) {
|
|
1043
|
+
const next = await memo.successor(latest);
|
|
1044
|
+
if (next === undefined)
|
|
1045
|
+
break;
|
|
1046
|
+
latest = next;
|
|
1047
|
+
}
|
|
1048
|
+
if (latest !== entry.cookieValue) {
|
|
1049
|
+
try {
|
|
1050
|
+
const data = await this.#store.read(latest);
|
|
1051
|
+
return data
|
|
1052
|
+
? { status: "refreshed", entry: { cookieValue: latest, data } }
|
|
1053
|
+
: { status: "revoked" };
|
|
1054
|
+
}
|
|
1055
|
+
catch {
|
|
1056
|
+
return { status: "unavailable" };
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const pending = await memo.start(entry.cookieValue);
|
|
1060
|
+
let successor;
|
|
1061
|
+
try {
|
|
1062
|
+
const outcome = await this.#lockedRefresh(entry);
|
|
1063
|
+
if (outcome.status === "refreshed" &&
|
|
1064
|
+
outcome.entry.cookieValue !== entry.cookieValue) {
|
|
1065
|
+
successor = outcome.entry.cookieValue;
|
|
1066
|
+
}
|
|
1067
|
+
return outcome;
|
|
1068
|
+
}
|
|
1069
|
+
finally {
|
|
1070
|
+
await memo.finish(pending, successor);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
async #destroySession(cookieValue) {
|
|
1074
|
+
await this.#rotatedCookies?.forget(cookieValue);
|
|
1075
|
+
await this.#store.destroy(cookieValue);
|
|
1076
|
+
}
|
|
1018
1077
|
async #lockedRefresh(entry) {
|
|
1019
1078
|
const store = this.#store;
|
|
1020
1079
|
if (!supportsRefreshLock(store))
|
|
@@ -1065,7 +1124,7 @@ export class HonoBff {
|
|
|
1065
1124
|
if (current && current.refreshToken !== data.refreshToken) {
|
|
1066
1125
|
return { status: "refreshed", entry: { cookieValue, data: current } };
|
|
1067
1126
|
}
|
|
1068
|
-
await this.#
|
|
1127
|
+
await this.#destroySession(cookieValue);
|
|
1069
1128
|
return { status: "revoked" };
|
|
1070
1129
|
}
|
|
1071
1130
|
}
|
|
@@ -1208,7 +1267,7 @@ export class HonoBff {
|
|
|
1208
1267
|
await next();
|
|
1209
1268
|
}
|
|
1210
1269
|
catch (error) {
|
|
1211
|
-
return resourceServer.handleAuthError(error);
|
|
1270
|
+
return respondInContext(c, resourceServer.handleAuthError(error));
|
|
1212
1271
|
}
|
|
1213
1272
|
};
|
|
1214
1273
|
}
|
package/dist/models/token.d.ts
CHANGED
|
@@ -66,6 +66,13 @@ export interface RefreshToken<Client extends ClientInterface, User, Scope extend
|
|
|
66
66
|
* with a fresh anchor, so the family is capped from then on.
|
|
67
67
|
*/
|
|
68
68
|
familyCreatedAt?: Date;
|
|
69
|
+
/**
|
|
70
|
+
* When the refresh grant rotated this token out. A token service that
|
|
71
|
+
* supports `RefreshTokenGrantOptions.replayGraceSeconds` sets it in
|
|
72
|
+
* `revokeRotated` and returns it from `getRevokedRefreshToken`. A token
|
|
73
|
+
* revoked any other way has none.
|
|
74
|
+
*/
|
|
75
|
+
rotatedAt?: Date;
|
|
69
76
|
}
|
|
70
77
|
/**
|
|
71
78
|
* Standard OAuth2 token response.
|
|
@@ -40,6 +40,33 @@ export interface RefreshTokenGrantOptions<Client extends ClientInterface, User,
|
|
|
40
40
|
* client-facing `invalid_grant` a replayed token always yields.
|
|
41
41
|
*/
|
|
42
42
|
onTokenReuse?: (event: TokenReuseEvent<Client, User>) => void | Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Seconds after a refresh token is rotated during which presenting it again
|
|
45
|
+
* is answered with `invalid_grant` but not treated as reuse: the family
|
|
46
|
+
* keeps its live token and `onTokenReuse` does not fire. Defaults to `0`,
|
|
47
|
+
* which turns the grace off.
|
|
48
|
+
*
|
|
49
|
+
* It covers a client that sends a request with the old refresh token after
|
|
50
|
+
* its own concurrent request already rotated it, such as a backend-for-
|
|
51
|
+
* frontend whose instances do not coordinate refreshes, so that one
|
|
52
|
+
* straggler does not sign the user out. It applies only while the family
|
|
53
|
+
* still has a live refresh token, and only to a token the grant rotated
|
|
54
|
+
* out, not to one revoked any other way. Outside the window, or with no
|
|
55
|
+
* live successor, reuse detection runs as usual.
|
|
56
|
+
*
|
|
57
|
+
* The cost is that a stolen refresh token replayed within the window after
|
|
58
|
+
* the legitimate client rotated it goes unflagged: the family is not
|
|
59
|
+
* revoked and no reuse event is raised. It cuts the other way too: when a
|
|
60
|
+
* thief rotates a stolen live token first, the legitimate client's replay
|
|
61
|
+
* within the window is refused without revoking the family, so the thief
|
|
62
|
+
* keeps the session and the user is the one signed out. Keep the window to
|
|
63
|
+
* seconds.
|
|
64
|
+
*
|
|
65
|
+
* Needs a token service that sets {@linkcode RefreshToken.rotatedAt} in
|
|
66
|
+
* `revokeRotated` and implements `hasLiveRefreshToken`; with any other
|
|
67
|
+
* service the grace never applies.
|
|
68
|
+
*/
|
|
69
|
+
replayGraceSeconds?: number;
|
|
43
70
|
}
|
|
44
71
|
/**
|
|
45
72
|
* The refresh token grant type.
|
|
@@ -16,10 +16,16 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
16
16
|
/** The OAuth2 grant type: `refresh_token`. */
|
|
17
17
|
grantType = "refresh_token";
|
|
18
18
|
#onTokenReuse;
|
|
19
|
+
#replayGraceMs;
|
|
19
20
|
/** Creates the grant from its {@linkcode RefreshTokenGrantOptions}. */
|
|
20
21
|
constructor(options) {
|
|
21
22
|
super({ ...options, allowRefreshToken: true });
|
|
22
23
|
this.#onTokenReuse = options.onTokenReuse;
|
|
24
|
+
const graceSeconds = options.replayGraceSeconds ?? 0;
|
|
25
|
+
if (!Number.isFinite(graceSeconds) || graceSeconds < 0) {
|
|
26
|
+
throw new RangeError(`replayGraceSeconds must be a non-negative number of seconds, got ${graceSeconds}`);
|
|
27
|
+
}
|
|
28
|
+
this.#replayGraceMs = graceSeconds * 1000;
|
|
23
29
|
}
|
|
24
30
|
/**
|
|
25
31
|
* Handles a token request for the refresh token grant. Issues a new access
|
|
@@ -60,7 +66,8 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
60
66
|
if (tokenService.getRevokedRefreshToken && tokenService.revokeFamily) {
|
|
61
67
|
const replayed = await tokenService.getRevokedRefreshToken(refreshTokenStr);
|
|
62
68
|
if (replayed?.familyId &&
|
|
63
|
-
replayed.client.id.toString() === client.id.toString()
|
|
69
|
+
replayed.client.id.toString() === client.id.toString() &&
|
|
70
|
+
!(await this.#withinReplayGrace(tokenService, replayed))) {
|
|
64
71
|
const familyRevoked = await tokenService.revokeFamily(replayed.familyId);
|
|
65
72
|
await this.#notifyTokenReuse({
|
|
66
73
|
familyId: replayed.familyId,
|
|
@@ -123,6 +130,14 @@ export class RefreshTokenGrant extends AbstractGrant {
|
|
|
123
130
|
}
|
|
124
131
|
return await tokenService.save(nextRefreshToken);
|
|
125
132
|
}
|
|
133
|
+
async #withinReplayGrace(tokenService, replayed) {
|
|
134
|
+
const { rotatedAt, familyId } = replayed;
|
|
135
|
+
if (this.#replayGraceMs === 0 || !rotatedAt || !familyId)
|
|
136
|
+
return false;
|
|
137
|
+
if (Date.now() - rotatedAt.getTime() > this.#replayGraceMs)
|
|
138
|
+
return false;
|
|
139
|
+
return (await tokenService.hasLiveRefreshToken?.(familyId)) ?? false;
|
|
140
|
+
}
|
|
126
141
|
async #notifyTokenReuse(event) {
|
|
127
142
|
if (!this.#onTokenReuse)
|
|
128
143
|
return;
|
|
@@ -121,6 +121,10 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
121
121
|
* the token belongs to, cascading to sibling records — so a routine refresh
|
|
122
122
|
* cannot terminate the very session it renews. When absent, the refresh
|
|
123
123
|
* grant falls back to {@linkcode revoke}.
|
|
124
|
+
*
|
|
125
|
+
* To support the refresh grant's `replayGraceSeconds`, stamp the revoked
|
|
126
|
+
* record with {@linkcode RefreshToken.rotatedAt} here and return it from
|
|
127
|
+
* {@linkcode getRevokedRefreshToken}.
|
|
124
128
|
*/
|
|
125
129
|
revokeRotated?(token: RefreshToken<Client, User, Scope>): Promise<boolean>;
|
|
126
130
|
/**
|
|
@@ -148,6 +152,12 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
148
152
|
* can't keep a session alive. Returns true if anything was revoked.
|
|
149
153
|
*/
|
|
150
154
|
revokeFamily?(familyId: string): Promise<boolean>;
|
|
155
|
+
/**
|
|
156
|
+
* Whether the rotation family still has a live, unexpired refresh token.
|
|
157
|
+
* Read by the refresh grant's `replayGraceSeconds`: without it, the grace
|
|
158
|
+
* never applies and every replay is treated as reuse.
|
|
159
|
+
*/
|
|
160
|
+
hasLiveRefreshToken?(familyId: string): Promise<boolean>;
|
|
151
161
|
}
|
|
152
162
|
/** Options for configuring an abstract token service. */
|
|
153
163
|
export interface AbstractTokenServiceOptions<Client extends ClientInterface, User> {
|
|
@@ -212,14 +212,14 @@ export function runTokenServiceContractTests(options) {
|
|
|
212
212
|
describe("refresh-token rotation family", () => {
|
|
213
213
|
const FAMILY = "family-1";
|
|
214
214
|
const OTHER_FAMILY = "family-2";
|
|
215
|
-
async function saveFamilyMember(familyId) {
|
|
215
|
+
async function saveFamilyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
|
|
216
216
|
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
217
217
|
const refreshToken = (await tokenService.generateRefreshToken(client, user));
|
|
218
218
|
const token = {
|
|
219
219
|
accessToken,
|
|
220
220
|
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
221
221
|
refreshToken,
|
|
222
|
-
refreshTokenExpiresAt
|
|
222
|
+
refreshTokenExpiresAt,
|
|
223
223
|
familyId,
|
|
224
224
|
familyCreatedAt: new Date(Date.now() - 60_000),
|
|
225
225
|
client,
|
|
@@ -273,6 +273,41 @@ export function runTokenServiceContractTests(options) {
|
|
|
273
273
|
it("returns false when the family has no live tokens", async () => {
|
|
274
274
|
expect(await tokenService.revokeFamily("never-issued")).toBe(false);
|
|
275
275
|
});
|
|
276
|
+
it("stamps a token revokeRotated revoked with its rotation time", async ({ skip, }) => {
|
|
277
|
+
skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
|
|
278
|
+
const token = await saveFamilyMember(FAMILY);
|
|
279
|
+
const before = Date.now();
|
|
280
|
+
expect(await tokenService.revokeRotated(token)).toBe(true);
|
|
281
|
+
const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
|
|
282
|
+
assert(revoked !== undefined);
|
|
283
|
+
assert(revoked.rotatedAt instanceof Date &&
|
|
284
|
+
revoked.rotatedAt.getTime() >= before - 1000 &&
|
|
285
|
+
revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
|
|
286
|
+
"applies, so every straggler revokes its family");
|
|
287
|
+
});
|
|
288
|
+
it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
|
|
289
|
+
const token = await saveFamilyMember(FAMILY);
|
|
290
|
+
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
291
|
+
const revoked = await tokenService.getRevokedRefreshToken(token.refreshToken);
|
|
292
|
+
expect(revoked?.rotatedAt, "a revoked token that looks rotated gets the replay grace a " +
|
|
293
|
+
"signed-out session must never have").toBe(undefined);
|
|
294
|
+
});
|
|
295
|
+
it("reports whether a family still has a live refresh token", async ({ skip, }) => {
|
|
296
|
+
skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
|
|
297
|
+
const token = await saveFamilyMember(FAMILY);
|
|
298
|
+
await saveFamilyMember(OTHER_FAMILY);
|
|
299
|
+
expect(await tokenService.hasLiveRefreshToken(FAMILY)).toBe(true);
|
|
300
|
+
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
301
|
+
expect(await tokenService.hasLiveRefreshToken(FAMILY), "a family whose last token was revoked has no successor to protect").toBe(false);
|
|
302
|
+
expect(await tokenService.hasLiveRefreshToken(OTHER_FAMILY)).toBe(true);
|
|
303
|
+
expect(await tokenService.hasLiveRefreshToken("never-issued")).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
it("does not count an expired refresh token as live", async ({ skip, }) => {
|
|
306
|
+
skip(typeof tokenService.hasLiveRefreshToken !== "function", "the service does not implement hasLiveRefreshToken");
|
|
307
|
+
await saveFamilyMember(FAMILY, new Date(Date.now() - 1000));
|
|
308
|
+
expect(await tokenService.hasLiveRefreshToken(FAMILY), "an expired successor cannot be used, so a replay of its " +
|
|
309
|
+
"predecessor must not be excused as a straggler").toBe(false);
|
|
310
|
+
});
|
|
276
311
|
});
|
|
277
312
|
}
|
|
278
313
|
else {
|
|
@@ -240,6 +240,15 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
240
240
|
* token. Resolves `true` when something was removed.
|
|
241
241
|
*/
|
|
242
242
|
revoke(token: string, hint?: string | null): Promise<boolean>;
|
|
243
|
+
/**
|
|
244
|
+
* Revokes a refresh token the refresh grant just rotated out, as
|
|
245
|
+
* {@linkcode revoke} does, and stamps its tombstone with the rotation time
|
|
246
|
+
* so the grant's `replayGraceSeconds` can apply. Resolves `false` when the
|
|
247
|
+
* token was already claimed.
|
|
248
|
+
*/
|
|
249
|
+
revokeRotated(token: RefreshToken<C, U, S>): Promise<boolean>;
|
|
250
|
+
/** Whether the family has a live refresh token that has not expired. */
|
|
251
|
+
hasLiveRefreshToken(familyId: string): Promise<boolean>;
|
|
243
252
|
/**
|
|
244
253
|
* Looks up a revoked/rotated-out refresh token for the refresh grant's
|
|
245
254
|
* reuse detection: revoked refresh records are kept as tombstones instead
|
package/dist/testing/services.js
CHANGED
|
@@ -290,6 +290,7 @@ export class MemoryTokenService {
|
|
|
290
290
|
refreshTokenExpiresAt: stored.refreshTokenExpiresAt,
|
|
291
291
|
familyId: stored.familyId,
|
|
292
292
|
familyCreatedAt: stored.familyCreatedAt,
|
|
293
|
+
rotatedAt: stored.rotatedAt,
|
|
293
294
|
};
|
|
294
295
|
}
|
|
295
296
|
return token;
|
|
@@ -367,6 +368,31 @@ export class MemoryTokenService {
|
|
|
367
368
|
}
|
|
368
369
|
return true;
|
|
369
370
|
}
|
|
371
|
+
/**
|
|
372
|
+
* Revokes a refresh token the refresh grant just rotated out, as
|
|
373
|
+
* {@linkcode revoke} does, and stamps its tombstone with the rotation time
|
|
374
|
+
* so the grant's `replayGraceSeconds` can apply. Resolves `false` when the
|
|
375
|
+
* token was already claimed.
|
|
376
|
+
*/
|
|
377
|
+
async revokeRotated(token) {
|
|
378
|
+
const stored = this.#tokensByRefreshHash.get(await sha256Hash(token.refreshToken));
|
|
379
|
+
if (!stored)
|
|
380
|
+
return false;
|
|
381
|
+
stored.rotatedAt = new Date();
|
|
382
|
+
return await this.revoke(token);
|
|
383
|
+
}
|
|
384
|
+
/** Whether the family has a live refresh token that has not expired. */
|
|
385
|
+
hasLiveRefreshToken(familyId) {
|
|
386
|
+
const now = Date.now();
|
|
387
|
+
for (const stored of this.#tokensByRefreshHash.values()) {
|
|
388
|
+
if (stored.familyId === familyId &&
|
|
389
|
+
(!stored.refreshTokenExpiresAt ||
|
|
390
|
+
stored.refreshTokenExpiresAt.getTime() > now)) {
|
|
391
|
+
return Promise.resolve(true);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return Promise.resolve(false);
|
|
395
|
+
}
|
|
370
396
|
#tombstone(refreshTokenHash, stored) {
|
|
371
397
|
this.#revokedByRefreshHash.set(refreshTokenHash, stored);
|
|
372
398
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.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",
|