@growth-labs/auth 0.14.1 → 0.16.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/README.md +2 -1
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +101 -24
- package/dist/middleware/auth.js.map +1 -1
- package/dist/types.d.ts +18 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/refresh-rejection.d.ts +23 -0
- package/dist/utils/refresh-rejection.d.ts.map +1 -0
- package/dist/utils/refresh-rejection.js +89 -0
- package/dist/utils/refresh-rejection.js.map +1 -0
- package/package.json +1 -1
- package/src/middleware/auth.ts +108 -31
- package/src/types.ts +18 -4
- package/src/utils/refresh-rejection.ts +115 -0
package/README.md
CHANGED
|
@@ -51,7 +51,8 @@ auth({
|
|
|
51
51
|
3. Requires the access token `aud` to match `resource`; wrong or missing audiences remain unauthenticated
|
|
52
52
|
4. If expired but refresh token valid → silent refresh, verify the new access token audience, then set new cookies
|
|
53
53
|
— one exchange per token per isolate, and never on a beacon or a prefetch (those are answered as a guest / `425`, and the next real request refreshes)
|
|
54
|
-
|
|
54
|
+
— a refresh token the issuer refuses is remembered in `<prefix>_rtx`; the issuer is not asked again while it matches, and the token cookies are cleared only when a document navigation ≥60s later is refused a second time
|
|
55
|
+
5. Populates `context.locals.user` as `User | null`, `context.locals.authSession` with how the session resolved, and — whenever the refresh path was entered — `context.locals.authRefreshFingerprint` with a SHA-256 prefix of the presented refresh token (never the token) for joining a site's session event to the issuer's `tokenFingerprint`
|
|
55
56
|
6. If path matches `gatedPaths` and `user` is null → redirect to `loginPath`
|
|
56
57
|
|
|
57
58
|
**Routes:**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AACA,OAAO,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AACA,OAAO,sBAAsB,CAAA;AA4B7B,wBAAsB,SAAS,CAC9B,OAAO,EAAE;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,GAAG,EAAE,GAAG,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,EACxE,IAAI,EAAE,MAAM,OAAO,CAAC,QAAQ,CAAC,GAC3B,OAAO,CAAC,QAAQ,CAAC,CA2OnB"}
|
package/dist/middleware/auth.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { getConfig } from '../config-store.js';
|
|
2
2
|
import '../runtime-bridge.js';
|
|
3
3
|
import { resolveClientSecret } from '../utils/client-secret.js';
|
|
4
|
-
import { getCookie, setTokenCookies } from '../utils/cookies.js';
|
|
4
|
+
import { clearTokenCookies, getCookie, setTokenCookies } from '../utils/cookies.js';
|
|
5
5
|
import { secretResolutionFailureResponse } from '../utils/fail-closed.js';
|
|
6
6
|
import { resolveInternalIssuer } from '../utils/issuer.js';
|
|
7
7
|
import { classifyRefreshCarrier, prefetchDeferredResponse, } from '../utils/refresh-carrier.js';
|
|
8
|
+
import { clearRejectionMarker, fingerprintRefreshToken, mayConfirmRejection, parseRejectionMarker, rejectionMarkerCookieName, setRejectionMarker, } from '../utils/refresh-rejection.js';
|
|
8
9
|
import { refreshTokens, verifyAccessToken } from '../utils/tokens.js';
|
|
9
10
|
const PRIVATE_NO_STORE_HEADERS = [
|
|
10
11
|
'Cache-Control',
|
|
@@ -66,18 +67,64 @@ export async function onRequest(context, next) {
|
|
|
66
67
|
if (carrier !== 'consumable')
|
|
67
68
|
deferredCarrier = carrier;
|
|
68
69
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
70
|
+
// Confirm before clear (refresh-rejection.ts). A refresh token the issuer
|
|
71
|
+
// has refused once is remembered in a short marker cookie; while it matches,
|
|
72
|
+
// the issuer is not asked again, and only a document navigation past the
|
|
73
|
+
// confirm window may ask a second time and clear on a second refusal.
|
|
74
|
+
const documentNavigation = isDocumentNavigation(request);
|
|
75
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
76
|
+
const cookieWrites = [];
|
|
77
|
+
let suppressed = false;
|
|
78
|
+
let confirmedDead = false;
|
|
79
|
+
// Name the presented refresh token, never carry it (0.16.0). Computed once
|
|
80
|
+
// the refresh path is entered — deferred, suppressed, refreshed, expired or
|
|
81
|
+
// failed — and exposed as `context.locals.authRefreshFingerprint` so a site
|
|
82
|
+
// can put the same value the issuer logs as `tokenFingerprint` on its own
|
|
83
|
+
// session event and join the two. After a successful refresh it still names
|
|
84
|
+
// the token that was PRESENTED, which is the one a later lost-rotation
|
|
85
|
+
// rejection at the issuer will name too.
|
|
86
|
+
let fingerprint;
|
|
87
|
+
if (!user && refreshToken) {
|
|
88
|
+
fingerprint = await fingerprintRefreshToken(refreshToken);
|
|
89
|
+
context.locals.authRefreshFingerprint = fingerprint;
|
|
90
|
+
}
|
|
91
|
+
if (!user && refreshToken && fingerprint && deferredCarrier === null) {
|
|
92
|
+
const marker = parseRejectionMarker(getCookie(request, rejectionMarkerCookieName(options)));
|
|
93
|
+
const marked = marker !== null && marker.fingerprint === fingerprint;
|
|
94
|
+
const confirming = marked && mayConfirmRejection(marker, nowSeconds, documentNavigation);
|
|
95
|
+
if (marked && !confirming) {
|
|
96
|
+
suppressed = true;
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
const clientId = options.clientId || context.url.hostname;
|
|
100
|
+
refreshed = await refreshTokens(issuer, refreshToken, clientId, resolvedClientSecret, options.resource, (failure) => {
|
|
101
|
+
refreshFailure = failure;
|
|
102
|
+
});
|
|
103
|
+
if (refreshed) {
|
|
104
|
+
user = {
|
|
105
|
+
id: refreshed.claims.sub,
|
|
106
|
+
email: refreshed.claims.email,
|
|
107
|
+
name: refreshed.claims.name ?? null,
|
|
108
|
+
avatarUrl: refreshed.claims.image ?? null,
|
|
109
|
+
};
|
|
110
|
+
if (marker)
|
|
111
|
+
cookieWrites.push((response) => clearRejectionMarker(response, options));
|
|
112
|
+
}
|
|
113
|
+
else if (refreshFailure?.reason === 'invalid_grant') {
|
|
114
|
+
if (confirming) {
|
|
115
|
+
// Second refusal, a minute or more after the first, on a request
|
|
116
|
+
// the member is waiting for: the token is dead. Clear it.
|
|
117
|
+
confirmedDead = true;
|
|
118
|
+
cookieWrites.push((response) => {
|
|
119
|
+
clearTokenCookies(response, options);
|
|
120
|
+
clearRejectionMarker(response, options);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const firstSeen = { fingerprint, rejectedAt: nowSeconds };
|
|
125
|
+
cookieWrites.push((response) => setRejectionMarker(response, options, firstSeen));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
81
128
|
}
|
|
82
129
|
}
|
|
83
130
|
// 3. Populate context.locals.user and say HOW the session resolved. A site
|
|
@@ -94,15 +141,16 @@ export async function onRequest(context, next) {
|
|
|
94
141
|
hadRefreshToken: refreshToken !== null,
|
|
95
142
|
refreshFailure,
|
|
96
143
|
deferred: deferredCarrier !== null,
|
|
144
|
+
suppressed,
|
|
97
145
|
});
|
|
98
146
|
context.locals.authSession = session;
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// the
|
|
104
|
-
//
|
|
105
|
-
|
|
147
|
+
// A first refusal clears nothing: the issuer keeps refresh tokens in an
|
|
148
|
+
// eventually consistent store, so a token only seconds old can read as
|
|
149
|
+
// missing from another location and be answered invalid_grant, and a
|
|
150
|
+
// straggling subrequest may still carry the token a fresh sign-in replaced.
|
|
151
|
+
// Only the confirmed second refusal above clears, and the response that
|
|
152
|
+
// clears still reports `expired`, so a site's sign-in-again notice fires
|
|
153
|
+
// once; every request after it is anonymous.
|
|
106
154
|
if (deferredCarrier !== null) {
|
|
107
155
|
try {
|
|
108
156
|
console.info('[auth] session refresh deferred', {
|
|
@@ -118,6 +166,18 @@ export async function onRequest(context, next) {
|
|
|
118
166
|
if (deferredCarrier === 'prefetch')
|
|
119
167
|
return prefetchDeferredResponse();
|
|
120
168
|
}
|
|
169
|
+
else if (suppressed) {
|
|
170
|
+
try {
|
|
171
|
+
console.info('[auth] session refresh suppressed', {
|
|
172
|
+
pathname: context.url.pathname,
|
|
173
|
+
documentNavigation,
|
|
174
|
+
session,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* a patched console must not turn a guest page into a 500 */
|
|
179
|
+
}
|
|
180
|
+
}
|
|
121
181
|
else if (!user && refreshToken) {
|
|
122
182
|
const failure = refreshFailure ?? { reason: 'unreported' };
|
|
123
183
|
try {
|
|
@@ -126,6 +186,7 @@ export async function onRequest(context, next) {
|
|
|
126
186
|
pathname: context.url.pathname,
|
|
127
187
|
documentNavigation,
|
|
128
188
|
session,
|
|
189
|
+
...(confirmedDead ? { cleared: true } : {}),
|
|
129
190
|
});
|
|
130
191
|
}
|
|
131
192
|
catch {
|
|
@@ -138,14 +199,25 @@ export async function onRequest(context, next) {
|
|
|
138
199
|
// one the login renderer already knows how to explain. A rejected access
|
|
139
200
|
// token with no refresh token beside it (wrong audience, say) is not a
|
|
140
201
|
// session that ended, and its redirect stays exactly as it always was.
|
|
141
|
-
const sessionEnded = refreshFailure?.reason === 'invalid_grant';
|
|
202
|
+
const sessionEnded = refreshFailure?.reason === 'invalid_grant' || suppressed;
|
|
142
203
|
const loginUrl = new URL(`${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}${sessionEnded ? '&error=session_expired' : ''}`, context.url.origin);
|
|
143
|
-
|
|
204
|
+
if (cookieWrites.length === 0)
|
|
205
|
+
return Response.redirect(loginUrl.toString(), 302);
|
|
206
|
+
// Response.redirect's headers are immutable; the marker or the clear has
|
|
207
|
+
// to ride on this redirect, so build the same 302 by hand.
|
|
208
|
+
const redirect = new Response(null, {
|
|
209
|
+
status: 302,
|
|
210
|
+
headers: { Location: loginUrl.toString() },
|
|
211
|
+
});
|
|
212
|
+
for (const write of cookieWrites)
|
|
213
|
+
write(redirect);
|
|
214
|
+
return redirect;
|
|
144
215
|
}
|
|
145
216
|
// 5. Continue to the page
|
|
146
217
|
const response = await next();
|
|
147
|
-
// 6. If tokens were refreshed or the response must
|
|
148
|
-
|
|
218
|
+
// 6. If tokens were refreshed, a rejection marker moved, or the response must
|
|
219
|
+
// carry no-store, clone and apply.
|
|
220
|
+
if (refreshed || cookieWrites.length > 0 || context.locals.synthetic === true) {
|
|
149
221
|
const modifiedResponse = new Response(response.body, {
|
|
150
222
|
status: response.status,
|
|
151
223
|
statusText: response.statusText,
|
|
@@ -154,6 +226,8 @@ export async function onRequest(context, next) {
|
|
|
154
226
|
if (refreshed) {
|
|
155
227
|
setTokenCookies(modifiedResponse, options, refreshed.accessToken, refreshed.refreshToken);
|
|
156
228
|
}
|
|
229
|
+
for (const write of cookieWrites)
|
|
230
|
+
write(modifiedResponse);
|
|
157
231
|
if (context.locals.synthetic === true) {
|
|
158
232
|
for (const header of PRIVATE_NO_STORE_HEADERS) {
|
|
159
233
|
modifiedResponse.headers.set(header, 'private, no-store');
|
|
@@ -173,6 +247,9 @@ function resolveSessionState(input) {
|
|
|
173
247
|
// A rejected access token with no refresh token beside it cannot be revived.
|
|
174
248
|
if (!input.hadRefreshToken)
|
|
175
249
|
return 'expired';
|
|
250
|
+
// A refusal remembered from a moment ago is still a refusal.
|
|
251
|
+
if (input.suppressed)
|
|
252
|
+
return 'expired';
|
|
176
253
|
return input.refreshFailure?.reason === 'invalid_grant' ? 'expired' : 'refresh_failed';
|
|
177
254
|
}
|
|
178
255
|
/** Sec-Fetch-* is authoritative where the browser sends it; Accept is the fallback. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AAC9C,OAAO,sBAAsB,CAAA;AAE7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAA;AAC/D,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"auth.js","sourceRoot":"","sources":["../../src/middleware/auth.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -140,10 +140,12 @@ export interface TokenExchangeResult {
|
|
|
140
140
|
* may pass; `deferred` holds a refresh token this request deliberately did not
|
|
141
141
|
* spend, because it is a beacon or a prefetch whose response the browser would
|
|
142
142
|
* not apply (0.14.0) — the next consumable request refreshes, so treat it as a
|
|
143
|
-
* guest request and nothing more. NONE is proof the session is dead
|
|
144
|
-
* issuer's token store is eventually consistent), so
|
|
145
|
-
* them
|
|
146
|
-
*
|
|
143
|
+
* guest request and nothing more. NONE is proof the session is dead on its own
|
|
144
|
+
* (the issuer's token store is eventually consistent), so a site never clears
|
|
145
|
+
* cookies on them. The middleware clears the token cookies only after a second
|
|
146
|
+
* `invalid_grant` on a document navigation a minute or more after the first
|
|
147
|
+
* (0.15.0, `<prefix>_rtx` marker); that response still reports `expired`, and
|
|
148
|
+
* every request after it is `anonymous`.
|
|
147
149
|
*/
|
|
148
150
|
export type AuthSessionState = 'anonymous' | 'verified' | 'refreshed' | 'expired' | 'refresh_failed' | 'deferred';
|
|
149
151
|
declare global {
|
|
@@ -151,6 +153,18 @@ declare global {
|
|
|
151
153
|
interface Locals {
|
|
152
154
|
user: User | null;
|
|
153
155
|
authSession?: AuthSessionState;
|
|
156
|
+
/**
|
|
157
|
+
* 16-hex SHA-256 prefix of the refresh token this request presented
|
|
158
|
+
* (0.16.0). Set only when the refresh path was entered — `authSession`
|
|
159
|
+
* is `refreshed`, `expired`, `refresh_failed` or `deferred` — and never
|
|
160
|
+
* when the access token verified or no refresh token was present. It
|
|
161
|
+
* is the value the issuer logs as `tokenFingerprint` on
|
|
162
|
+
* `oauth.token_refresh.rejected` and the value carried in the
|
|
163
|
+
* `{prefix}_rtx` marker: credential-linked, never person-linked, and
|
|
164
|
+
* the same token after a successful rotation. A site may write it on
|
|
165
|
+
* a session-outcome analytics event; it must never write the token.
|
|
166
|
+
*/
|
|
167
|
+
authRefreshFingerprint?: string;
|
|
154
168
|
synthetic?: boolean;
|
|
155
169
|
}
|
|
156
170
|
}
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ResolvedAuthOptions } from '../options.js';
|
|
2
|
+
/** Seconds between the first rejection and the one that may clear. Past KV's convergence. */
|
|
3
|
+
export declare const REFRESH_REJECTION_CONFIRM_AFTER_SECONDS = 60;
|
|
4
|
+
/** How long a rejection is remembered. Long enough for a second navigation, short enough to forget. */
|
|
5
|
+
export declare const REFRESH_REJECTION_MARKER_MAX_AGE_SECONDS = 900;
|
|
6
|
+
export interface RefreshRejectionMarker {
|
|
7
|
+
fingerprint: string;
|
|
8
|
+
/** Unix seconds when the first rejection was seen. */
|
|
9
|
+
rejectedAt: number;
|
|
10
|
+
}
|
|
11
|
+
export declare function rejectionMarkerCookieName(options: Pick<ResolvedAuthOptions, 'cookiePrefix'>): string;
|
|
12
|
+
/** SHA-256 hex prefix of the refresh token: names it without carrying it. */
|
|
13
|
+
export declare function fingerprintRefreshToken(refreshToken: string): Promise<string>;
|
|
14
|
+
export declare function parseRejectionMarker(value: string | null): RefreshRejectionMarker | null;
|
|
15
|
+
export declare function formatRejectionMarker(marker: RefreshRejectionMarker): string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether this request may ask the issuer a second time and clear on refusal:
|
|
18
|
+
* a document navigation, carrying the marked token, past the confirm window.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mayConfirmRejection(marker: RefreshRejectionMarker, nowSeconds: number, documentNavigation: boolean): boolean;
|
|
21
|
+
export declare function setRejectionMarker(response: Response, options: ResolvedAuthOptions, marker: RefreshRejectionMarker): void;
|
|
22
|
+
export declare function clearRejectionMarker(response: Response, options: ResolvedAuthOptions): void;
|
|
23
|
+
//# sourceMappingURL=refresh-rejection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh-rejection.d.ts","sourceRoot":"","sources":["../../src/utils/refresh-rejection.ts"],"names":[],"mappings":"AA6BA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;AAGxD,6FAA6F;AAC7F,eAAO,MAAM,uCAAuC,KAAK,CAAA;AACzD,uGAAuG;AACvG,eAAO,MAAM,wCAAwC,MAAM,CAAA;AAK3D,MAAM,WAAW,sBAAsB;IACtC,WAAW,EAAE,MAAM,CAAA;IACnB,sDAAsD;IACtD,UAAU,EAAE,MAAM,CAAA;CAClB;AAED,wBAAgB,yBAAyB,CACxC,OAAO,EAAE,IAAI,CAAC,mBAAmB,EAAE,cAAc,CAAC,GAChD,MAAM,CAER;AAED,6EAA6E;AAC7E,wBAAsB,uBAAuB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAMnF;AAED,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,sBAAsB,GAAG,IAAI,CAOxF;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,sBAAsB,GAAG,MAAM,CAE5E;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAClC,MAAM,EAAE,sBAAsB,EAC9B,UAAU,EAAE,MAAM,EAClB,kBAAkB,EAAE,OAAO,GACzB,OAAO,CAIT;AAYD,wBAAgB,kBAAkB,CACjC,QAAQ,EAAE,QAAQ,EAClB,OAAO,EAAE,mBAAmB,EAC5B,MAAM,EAAE,sBAAsB,GAC5B,IAAI,CAKN;AAED,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,QAAQ,EAAE,OAAO,EAAE,mBAAmB,GAAG,IAAI,CAK3F"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Confirm before clear.
|
|
2
|
+
//
|
|
3
|
+
// 0.13.0 chose never to clear a cookie on invalid_grant, for two recorded
|
|
4
|
+
// reasons (packages #626): the issuer keeps refresh tokens in Workers KV, so a
|
|
5
|
+
// token seconds old can read as missing from another colo and be refused once;
|
|
6
|
+
// and a subrequest still carrying the OLD refresh token can arrive after the
|
|
7
|
+
// member has just signed in again, and a clear on its response would wipe the
|
|
8
|
+
// new session. Both are real. Neither needs a dead token re-presented on every
|
|
9
|
+
// request for hours, which is what 407 issuer rejections from 29 tokens in a
|
|
10
|
+
// day were (2026-09-20).
|
|
11
|
+
//
|
|
12
|
+
// So the middleware keeps a short marker of the rejection instead:
|
|
13
|
+
//
|
|
14
|
+
// 1. The first invalid_grant for a refresh token sets `<prefix>_rtx` holding a
|
|
15
|
+
// SHA-256 prefix of that token and the time. Nothing else changes: the
|
|
16
|
+
// session is `expired`, the notice a site shows still fires, no cookie is
|
|
17
|
+
// cleared.
|
|
18
|
+
// 2. While the marker matches the presented token, requests do not ask the
|
|
19
|
+
// issuer again; they report `expired` at once. That ends the amplification.
|
|
20
|
+
// 3. A document navigation at least CONFIRM_AFTER seconds later asks once
|
|
21
|
+
// more. A second invalid_grant clears the token cookies and the marker; a
|
|
22
|
+
// success clears the marker and carries on.
|
|
23
|
+
//
|
|
24
|
+
// A straggler can never clear: it is a subrequest, and its marker names the old
|
|
25
|
+
// token, not the new one. A consistency miss can never clear: the second ask
|
|
26
|
+
// happens after KV has converged, succeeds, and drops the marker. The marker
|
|
27
|
+
// carries a digest, never a token, and its only power is to hurry the clearing
|
|
28
|
+
// of the cookies it sits beside.
|
|
29
|
+
import { setCookie } from './cookies.js';
|
|
30
|
+
/** Seconds between the first rejection and the one that may clear. Past KV's convergence. */
|
|
31
|
+
export const REFRESH_REJECTION_CONFIRM_AFTER_SECONDS = 60;
|
|
32
|
+
/** How long a rejection is remembered. Long enough for a second navigation, short enough to forget. */
|
|
33
|
+
export const REFRESH_REJECTION_MARKER_MAX_AGE_SECONDS = 900;
|
|
34
|
+
const FINGERPRINT_HEX_LENGTH = 16;
|
|
35
|
+
const MARKER_PATTERN = /^([0-9a-f]{16})\.(\d{1,12})$/;
|
|
36
|
+
export function rejectionMarkerCookieName(options) {
|
|
37
|
+
return `${options.cookiePrefix}_rtx`;
|
|
38
|
+
}
|
|
39
|
+
/** SHA-256 hex prefix of the refresh token: names it without carrying it. */
|
|
40
|
+
export async function fingerprintRefreshToken(refreshToken) {
|
|
41
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(refreshToken));
|
|
42
|
+
return [...new Uint8Array(digest)]
|
|
43
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
44
|
+
.join('')
|
|
45
|
+
.slice(0, FINGERPRINT_HEX_LENGTH);
|
|
46
|
+
}
|
|
47
|
+
export function parseRejectionMarker(value) {
|
|
48
|
+
if (!value)
|
|
49
|
+
return null;
|
|
50
|
+
const match = MARKER_PATTERN.exec(value);
|
|
51
|
+
if (!match)
|
|
52
|
+
return null;
|
|
53
|
+
const rejectedAt = Number(match[2]);
|
|
54
|
+
if (!Number.isSafeInteger(rejectedAt))
|
|
55
|
+
return null;
|
|
56
|
+
return { fingerprint: match[1], rejectedAt };
|
|
57
|
+
}
|
|
58
|
+
export function formatRejectionMarker(marker) {
|
|
59
|
+
return `${marker.fingerprint}.${marker.rejectedAt}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether this request may ask the issuer a second time and clear on refusal:
|
|
63
|
+
* a document navigation, carrying the marked token, past the confirm window.
|
|
64
|
+
*/
|
|
65
|
+
export function mayConfirmRejection(marker, nowSeconds, documentNavigation) {
|
|
66
|
+
return (documentNavigation && nowSeconds - marker.rejectedAt >= REFRESH_REJECTION_CONFIRM_AFTER_SECONDS);
|
|
67
|
+
}
|
|
68
|
+
function markerAttributes(options) {
|
|
69
|
+
return {
|
|
70
|
+
path: '/',
|
|
71
|
+
httpOnly: true,
|
|
72
|
+
secure: true,
|
|
73
|
+
sameSite: 'lax',
|
|
74
|
+
...(options.sessionCookieDomain && { domain: options.sessionCookieDomain }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
export function setRejectionMarker(response, options, marker) {
|
|
78
|
+
setCookie(response, rejectionMarkerCookieName(options), formatRejectionMarker(marker), {
|
|
79
|
+
...markerAttributes(options),
|
|
80
|
+
maxAge: REFRESH_REJECTION_MARKER_MAX_AGE_SECONDS,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
export function clearRejectionMarker(response, options) {
|
|
84
|
+
setCookie(response, rejectionMarkerCookieName(options), '', {
|
|
85
|
+
...markerAttributes(options),
|
|
86
|
+
maxAge: 0,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=refresh-rejection.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh-rejection.js","sourceRoot":"","sources":["../../src/utils/refresh-rejection.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/middleware/auth.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { getConfig } from '../config-store.js'
|
|
|
2
2
|
import '../runtime-bridge.js'
|
|
3
3
|
import type { AuthSessionState, User } from '../types.js'
|
|
4
4
|
import { resolveClientSecret } from '../utils/client-secret.js'
|
|
5
|
-
import { getCookie, setTokenCookies } from '../utils/cookies.js'
|
|
5
|
+
import { clearTokenCookies, getCookie, setTokenCookies } from '../utils/cookies.js'
|
|
6
6
|
import { secretResolutionFailureResponse } from '../utils/fail-closed.js'
|
|
7
7
|
import { resolveInternalIssuer } from '../utils/issuer.js'
|
|
8
8
|
import {
|
|
@@ -10,6 +10,15 @@ import {
|
|
|
10
10
|
prefetchDeferredResponse,
|
|
11
11
|
type RefreshCarrier,
|
|
12
12
|
} from '../utils/refresh-carrier.js'
|
|
13
|
+
import {
|
|
14
|
+
clearRejectionMarker,
|
|
15
|
+
fingerprintRefreshToken,
|
|
16
|
+
mayConfirmRejection,
|
|
17
|
+
parseRejectionMarker,
|
|
18
|
+
type RefreshRejectionMarker,
|
|
19
|
+
rejectionMarkerCookieName,
|
|
20
|
+
setRejectionMarker,
|
|
21
|
+
} from '../utils/refresh-rejection.js'
|
|
13
22
|
import { type RefreshFailure, refreshTokens, verifyAccessToken } from '../utils/tokens.js'
|
|
14
23
|
|
|
15
24
|
const PRIVATE_NO_STORE_HEADERS = [
|
|
@@ -80,24 +89,68 @@ export async function onRequest(
|
|
|
80
89
|
const carrier = classifyRefreshCarrier(request)
|
|
81
90
|
if (carrier !== 'consumable') deferredCarrier = carrier
|
|
82
91
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
92
|
+
|
|
93
|
+
// Confirm before clear (refresh-rejection.ts). A refresh token the issuer
|
|
94
|
+
// has refused once is remembered in a short marker cookie; while it matches,
|
|
95
|
+
// the issuer is not asked again, and only a document navigation past the
|
|
96
|
+
// confirm window may ask a second time and clear on a second refusal.
|
|
97
|
+
const documentNavigation = isDocumentNavigation(request)
|
|
98
|
+
const nowSeconds = Math.floor(Date.now() / 1000)
|
|
99
|
+
const cookieWrites: Array<(response: Response) => void> = []
|
|
100
|
+
let suppressed = false
|
|
101
|
+
let confirmedDead = false
|
|
102
|
+
// Name the presented refresh token, never carry it (0.16.0). Computed once
|
|
103
|
+
// the refresh path is entered — deferred, suppressed, refreshed, expired or
|
|
104
|
+
// failed — and exposed as `context.locals.authRefreshFingerprint` so a site
|
|
105
|
+
// can put the same value the issuer logs as `tokenFingerprint` on its own
|
|
106
|
+
// session event and join the two. After a successful refresh it still names
|
|
107
|
+
// the token that was PRESENTED, which is the one a later lost-rotation
|
|
108
|
+
// rejection at the issuer will name too.
|
|
109
|
+
let fingerprint: string | undefined
|
|
110
|
+
if (!user && refreshToken) {
|
|
111
|
+
fingerprint = await fingerprintRefreshToken(refreshToken)
|
|
112
|
+
context.locals.authRefreshFingerprint = fingerprint
|
|
113
|
+
}
|
|
114
|
+
if (!user && refreshToken && fingerprint && deferredCarrier === null) {
|
|
115
|
+
const marker = parseRejectionMarker(getCookie(request, rejectionMarkerCookieName(options)))
|
|
116
|
+
const marked = marker !== null && marker.fingerprint === fingerprint
|
|
117
|
+
const confirming = marked && mayConfirmRejection(marker, nowSeconds, documentNavigation)
|
|
118
|
+
|
|
119
|
+
if (marked && !confirming) {
|
|
120
|
+
suppressed = true
|
|
121
|
+
} else {
|
|
122
|
+
const clientId = options.clientId || context.url.hostname
|
|
123
|
+
refreshed = await refreshTokens(
|
|
124
|
+
issuer,
|
|
125
|
+
refreshToken,
|
|
126
|
+
clientId,
|
|
127
|
+
resolvedClientSecret,
|
|
128
|
+
options.resource,
|
|
129
|
+
(failure) => {
|
|
130
|
+
refreshFailure = failure
|
|
131
|
+
},
|
|
132
|
+
)
|
|
133
|
+
if (refreshed) {
|
|
134
|
+
user = {
|
|
135
|
+
id: refreshed.claims.sub,
|
|
136
|
+
email: refreshed.claims.email,
|
|
137
|
+
name: refreshed.claims.name ?? null,
|
|
138
|
+
avatarUrl: refreshed.claims.image ?? null,
|
|
139
|
+
}
|
|
140
|
+
if (marker) cookieWrites.push((response) => clearRejectionMarker(response, options))
|
|
141
|
+
} else if (refreshFailure?.reason === 'invalid_grant') {
|
|
142
|
+
if (confirming) {
|
|
143
|
+
// Second refusal, a minute or more after the first, on a request
|
|
144
|
+
// the member is waiting for: the token is dead. Clear it.
|
|
145
|
+
confirmedDead = true
|
|
146
|
+
cookieWrites.push((response) => {
|
|
147
|
+
clearTokenCookies(response, options)
|
|
148
|
+
clearRejectionMarker(response, options)
|
|
149
|
+
})
|
|
150
|
+
} else {
|
|
151
|
+
const firstSeen: RefreshRejectionMarker = { fingerprint, rejectedAt: nowSeconds }
|
|
152
|
+
cookieWrites.push((response) => setRejectionMarker(response, options, firstSeen))
|
|
153
|
+
}
|
|
101
154
|
}
|
|
102
155
|
}
|
|
103
156
|
}
|
|
@@ -116,17 +169,17 @@ export async function onRequest(
|
|
|
116
169
|
hadRefreshToken: refreshToken !== null,
|
|
117
170
|
refreshFailure,
|
|
118
171
|
deferred: deferredCarrier !== null,
|
|
172
|
+
suppressed,
|
|
119
173
|
})
|
|
120
174
|
context.locals.authSession = session
|
|
121
175
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
// the
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
176
|
+
// A first refusal clears nothing: the issuer keeps refresh tokens in an
|
|
177
|
+
// eventually consistent store, so a token only seconds old can read as
|
|
178
|
+
// missing from another location and be answered invalid_grant, and a
|
|
179
|
+
// straggling subrequest may still carry the token a fresh sign-in replaced.
|
|
180
|
+
// Only the confirmed second refusal above clears, and the response that
|
|
181
|
+
// clears still reports `expired`, so a site's sign-in-again notice fires
|
|
182
|
+
// once; every request after it is anonymous.
|
|
130
183
|
if (deferredCarrier !== null) {
|
|
131
184
|
try {
|
|
132
185
|
console.info('[auth] session refresh deferred', {
|
|
@@ -139,6 +192,16 @@ export async function onRequest(
|
|
|
139
192
|
// A prefetched document rendered as a guest would be shown to the member
|
|
140
193
|
// on navigation as if they had signed out; a non-2xx is dropped instead.
|
|
141
194
|
if (deferredCarrier === 'prefetch') return prefetchDeferredResponse()
|
|
195
|
+
} else if (suppressed) {
|
|
196
|
+
try {
|
|
197
|
+
console.info('[auth] session refresh suppressed', {
|
|
198
|
+
pathname: context.url.pathname,
|
|
199
|
+
documentNavigation,
|
|
200
|
+
session,
|
|
201
|
+
})
|
|
202
|
+
} catch {
|
|
203
|
+
/* a patched console must not turn a guest page into a 500 */
|
|
204
|
+
}
|
|
142
205
|
} else if (!user && refreshToken) {
|
|
143
206
|
const failure: RefreshFailure = refreshFailure ?? { reason: 'unreported' }
|
|
144
207
|
try {
|
|
@@ -147,6 +210,7 @@ export async function onRequest(
|
|
|
147
210
|
pathname: context.url.pathname,
|
|
148
211
|
documentNavigation,
|
|
149
212
|
session,
|
|
213
|
+
...(confirmedDead ? { cleared: true } : {}),
|
|
150
214
|
})
|
|
151
215
|
} catch {
|
|
152
216
|
/* a patched console must not turn a guest page into a 500 */
|
|
@@ -159,21 +223,30 @@ export async function onRequest(
|
|
|
159
223
|
// one the login renderer already knows how to explain. A rejected access
|
|
160
224
|
// token with no refresh token beside it (wrong audience, say) is not a
|
|
161
225
|
// session that ended, and its redirect stays exactly as it always was.
|
|
162
|
-
const sessionEnded = refreshFailure?.reason === 'invalid_grant'
|
|
226
|
+
const sessionEnded = refreshFailure?.reason === 'invalid_grant' || suppressed
|
|
163
227
|
const loginUrl = new URL(
|
|
164
228
|
`${options.loginPath}?redirect=${encodeURIComponent(context.url.pathname)}${
|
|
165
229
|
sessionEnded ? '&error=session_expired' : ''
|
|
166
230
|
}`,
|
|
167
231
|
context.url.origin,
|
|
168
232
|
)
|
|
169
|
-
return Response.redirect(loginUrl.toString(), 302)
|
|
233
|
+
if (cookieWrites.length === 0) return Response.redirect(loginUrl.toString(), 302)
|
|
234
|
+
// Response.redirect's headers are immutable; the marker or the clear has
|
|
235
|
+
// to ride on this redirect, so build the same 302 by hand.
|
|
236
|
+
const redirect = new Response(null, {
|
|
237
|
+
status: 302,
|
|
238
|
+
headers: { Location: loginUrl.toString() },
|
|
239
|
+
})
|
|
240
|
+
for (const write of cookieWrites) write(redirect)
|
|
241
|
+
return redirect
|
|
170
242
|
}
|
|
171
243
|
|
|
172
244
|
// 5. Continue to the page
|
|
173
245
|
const response = await next()
|
|
174
246
|
|
|
175
|
-
// 6. If tokens were refreshed or the response must
|
|
176
|
-
|
|
247
|
+
// 6. If tokens were refreshed, a rejection marker moved, or the response must
|
|
248
|
+
// carry no-store, clone and apply.
|
|
249
|
+
if (refreshed || cookieWrites.length > 0 || context.locals.synthetic === true) {
|
|
177
250
|
const modifiedResponse = new Response(response.body, {
|
|
178
251
|
status: response.status,
|
|
179
252
|
statusText: response.statusText,
|
|
@@ -182,6 +255,7 @@ export async function onRequest(
|
|
|
182
255
|
if (refreshed) {
|
|
183
256
|
setTokenCookies(modifiedResponse, options, refreshed.accessToken, refreshed.refreshToken)
|
|
184
257
|
}
|
|
258
|
+
for (const write of cookieWrites) write(modifiedResponse)
|
|
185
259
|
if (context.locals.synthetic === true) {
|
|
186
260
|
for (const header of PRIVATE_NO_STORE_HEADERS) {
|
|
187
261
|
modifiedResponse.headers.set(header, 'private, no-store')
|
|
@@ -200,12 +274,15 @@ function resolveSessionState(input: {
|
|
|
200
274
|
hadRefreshToken: boolean
|
|
201
275
|
refreshFailure: RefreshFailure | null
|
|
202
276
|
deferred: boolean
|
|
277
|
+
suppressed: boolean
|
|
203
278
|
}): AuthSessionState {
|
|
204
279
|
if (input.user) return input.refreshed ? 'refreshed' : 'verified'
|
|
205
280
|
if (!input.hadAccessToken && !input.hadRefreshToken) return 'anonymous'
|
|
206
281
|
if (input.deferred) return 'deferred'
|
|
207
282
|
// A rejected access token with no refresh token beside it cannot be revived.
|
|
208
283
|
if (!input.hadRefreshToken) return 'expired'
|
|
284
|
+
// A refusal remembered from a moment ago is still a refusal.
|
|
285
|
+
if (input.suppressed) return 'expired'
|
|
209
286
|
return input.refreshFailure?.reason === 'invalid_grant' ? 'expired' : 'refresh_failed'
|
|
210
287
|
}
|
|
211
288
|
|
package/src/types.ts
CHANGED
|
@@ -158,10 +158,12 @@ export interface TokenExchangeResult {
|
|
|
158
158
|
* may pass; `deferred` holds a refresh token this request deliberately did not
|
|
159
159
|
* spend, because it is a beacon or a prefetch whose response the browser would
|
|
160
160
|
* not apply (0.14.0) — the next consumable request refreshes, so treat it as a
|
|
161
|
-
* guest request and nothing more. NONE is proof the session is dead
|
|
162
|
-
* issuer's token store is eventually consistent), so
|
|
163
|
-
* them
|
|
164
|
-
*
|
|
161
|
+
* guest request and nothing more. NONE is proof the session is dead on its own
|
|
162
|
+
* (the issuer's token store is eventually consistent), so a site never clears
|
|
163
|
+
* cookies on them. The middleware clears the token cookies only after a second
|
|
164
|
+
* `invalid_grant` on a document navigation a minute or more after the first
|
|
165
|
+
* (0.15.0, `<prefix>_rtx` marker); that response still reports `expired`, and
|
|
166
|
+
* every request after it is `anonymous`.
|
|
165
167
|
*/
|
|
166
168
|
export type AuthSessionState =
|
|
167
169
|
| 'anonymous'
|
|
@@ -177,6 +179,18 @@ declare global {
|
|
|
177
179
|
interface Locals {
|
|
178
180
|
user: User | null
|
|
179
181
|
authSession?: AuthSessionState
|
|
182
|
+
/**
|
|
183
|
+
* 16-hex SHA-256 prefix of the refresh token this request presented
|
|
184
|
+
* (0.16.0). Set only when the refresh path was entered — `authSession`
|
|
185
|
+
* is `refreshed`, `expired`, `refresh_failed` or `deferred` — and never
|
|
186
|
+
* when the access token verified or no refresh token was present. It
|
|
187
|
+
* is the value the issuer logs as `tokenFingerprint` on
|
|
188
|
+
* `oauth.token_refresh.rejected` and the value carried in the
|
|
189
|
+
* `{prefix}_rtx` marker: credential-linked, never person-linked, and
|
|
190
|
+
* the same token after a successful rotation. A site may write it on
|
|
191
|
+
* a session-outcome analytics event; it must never write the token.
|
|
192
|
+
*/
|
|
193
|
+
authRefreshFingerprint?: string
|
|
180
194
|
synthetic?: boolean
|
|
181
195
|
}
|
|
182
196
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Confirm before clear.
|
|
2
|
+
//
|
|
3
|
+
// 0.13.0 chose never to clear a cookie on invalid_grant, for two recorded
|
|
4
|
+
// reasons (packages #626): the issuer keeps refresh tokens in Workers KV, so a
|
|
5
|
+
// token seconds old can read as missing from another colo and be refused once;
|
|
6
|
+
// and a subrequest still carrying the OLD refresh token can arrive after the
|
|
7
|
+
// member has just signed in again, and a clear on its response would wipe the
|
|
8
|
+
// new session. Both are real. Neither needs a dead token re-presented on every
|
|
9
|
+
// request for hours, which is what 407 issuer rejections from 29 tokens in a
|
|
10
|
+
// day were (2026-09-20).
|
|
11
|
+
//
|
|
12
|
+
// So the middleware keeps a short marker of the rejection instead:
|
|
13
|
+
//
|
|
14
|
+
// 1. The first invalid_grant for a refresh token sets `<prefix>_rtx` holding a
|
|
15
|
+
// SHA-256 prefix of that token and the time. Nothing else changes: the
|
|
16
|
+
// session is `expired`, the notice a site shows still fires, no cookie is
|
|
17
|
+
// cleared.
|
|
18
|
+
// 2. While the marker matches the presented token, requests do not ask the
|
|
19
|
+
// issuer again; they report `expired` at once. That ends the amplification.
|
|
20
|
+
// 3. A document navigation at least CONFIRM_AFTER seconds later asks once
|
|
21
|
+
// more. A second invalid_grant clears the token cookies and the marker; a
|
|
22
|
+
// success clears the marker and carries on.
|
|
23
|
+
//
|
|
24
|
+
// A straggler can never clear: it is a subrequest, and its marker names the old
|
|
25
|
+
// token, not the new one. A consistency miss can never clear: the second ask
|
|
26
|
+
// happens after KV has converged, succeeds, and drops the marker. The marker
|
|
27
|
+
// carries a digest, never a token, and its only power is to hurry the clearing
|
|
28
|
+
// of the cookies it sits beside.
|
|
29
|
+
|
|
30
|
+
import type { ResolvedAuthOptions } from '../options.js'
|
|
31
|
+
import { type CookieAttributes, setCookie } from './cookies.js'
|
|
32
|
+
|
|
33
|
+
/** Seconds between the first rejection and the one that may clear. Past KV's convergence. */
|
|
34
|
+
export const REFRESH_REJECTION_CONFIRM_AFTER_SECONDS = 60
|
|
35
|
+
/** How long a rejection is remembered. Long enough for a second navigation, short enough to forget. */
|
|
36
|
+
export const REFRESH_REJECTION_MARKER_MAX_AGE_SECONDS = 900
|
|
37
|
+
|
|
38
|
+
const FINGERPRINT_HEX_LENGTH = 16
|
|
39
|
+
const MARKER_PATTERN = /^([0-9a-f]{16})\.(\d{1,12})$/
|
|
40
|
+
|
|
41
|
+
export interface RefreshRejectionMarker {
|
|
42
|
+
fingerprint: string
|
|
43
|
+
/** Unix seconds when the first rejection was seen. */
|
|
44
|
+
rejectedAt: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rejectionMarkerCookieName(
|
|
48
|
+
options: Pick<ResolvedAuthOptions, 'cookiePrefix'>,
|
|
49
|
+
): string {
|
|
50
|
+
return `${options.cookiePrefix}_rtx`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** SHA-256 hex prefix of the refresh token: names it without carrying it. */
|
|
54
|
+
export async function fingerprintRefreshToken(refreshToken: string): Promise<string> {
|
|
55
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(refreshToken))
|
|
56
|
+
return [...new Uint8Array(digest)]
|
|
57
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
58
|
+
.join('')
|
|
59
|
+
.slice(0, FINGERPRINT_HEX_LENGTH)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function parseRejectionMarker(value: string | null): RefreshRejectionMarker | null {
|
|
63
|
+
if (!value) return null
|
|
64
|
+
const match = MARKER_PATTERN.exec(value)
|
|
65
|
+
if (!match) return null
|
|
66
|
+
const rejectedAt = Number(match[2])
|
|
67
|
+
if (!Number.isSafeInteger(rejectedAt)) return null
|
|
68
|
+
return { fingerprint: match[1], rejectedAt }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function formatRejectionMarker(marker: RefreshRejectionMarker): string {
|
|
72
|
+
return `${marker.fingerprint}.${marker.rejectedAt}`
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Whether this request may ask the issuer a second time and clear on refusal:
|
|
77
|
+
* a document navigation, carrying the marked token, past the confirm window.
|
|
78
|
+
*/
|
|
79
|
+
export function mayConfirmRejection(
|
|
80
|
+
marker: RefreshRejectionMarker,
|
|
81
|
+
nowSeconds: number,
|
|
82
|
+
documentNavigation: boolean,
|
|
83
|
+
): boolean {
|
|
84
|
+
return (
|
|
85
|
+
documentNavigation && nowSeconds - marker.rejectedAt >= REFRESH_REJECTION_CONFIRM_AFTER_SECONDS
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function markerAttributes(options: ResolvedAuthOptions): CookieAttributes {
|
|
90
|
+
return {
|
|
91
|
+
path: '/',
|
|
92
|
+
httpOnly: true,
|
|
93
|
+
secure: true,
|
|
94
|
+
sameSite: 'lax',
|
|
95
|
+
...(options.sessionCookieDomain && { domain: options.sessionCookieDomain }),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function setRejectionMarker(
|
|
100
|
+
response: Response,
|
|
101
|
+
options: ResolvedAuthOptions,
|
|
102
|
+
marker: RefreshRejectionMarker,
|
|
103
|
+
): void {
|
|
104
|
+
setCookie(response, rejectionMarkerCookieName(options), formatRejectionMarker(marker), {
|
|
105
|
+
...markerAttributes(options),
|
|
106
|
+
maxAge: REFRESH_REJECTION_MARKER_MAX_AGE_SECONDS,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function clearRejectionMarker(response: Response, options: ResolvedAuthOptions): void {
|
|
111
|
+
setCookie(response, rejectionMarkerCookieName(options), '', {
|
|
112
|
+
...markerAttributes(options),
|
|
113
|
+
maxAge: 0,
|
|
114
|
+
})
|
|
115
|
+
}
|