fix(auth): cookie lifetime now matches the JWT exp, not a stale 8h fallback
Browse filesUsers who came back to the editor 9+ hours after logging in were
getting a confusing "OAuth token has expired" error from HF when
the server tried to push to the dataset on their behalf. The
underlying HF JWT is valid for 30 days but our cookie was set
to 8 hours, so the cookie died first, the browser stopped
sending it, /api/auth/status flipped to unauthenticated, and
the background push retried with the old _cachedToken until it
finally got 401'd by HF.
Fix the cookie lifetime to match the real JWT exp claim:
- Decode the JWT (no signature verification - we just want exp,
HF re-checks the signature on every API call anyway) and use
`exp - now` directly. That's the most authoritative "when does
this stop working" signal available.
- Fall back to `expires_in` from the OAuth /token response if
decoding fails (malformed JWT, future format change, ...).
- Floor at 30 days. The previous fallback was 8 hours which
guaranteed friction even when HF returned a healthy long-lived
token: any code path that read `tokenData.expires_in` as 0 or
undefined hit the floor and the user got booted at lunchtime.
- Cap at 400 days. Browsers clamp cookie max-age past 400d
(RFC 6265bis 5.5) so going higher gets silently truncated.
Users still on an old short-lived cookie need to sign out and
back in once to pick up the new 30-day window. After that, they
keep the same session for the full life of the underlying JWT.
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +1 -1
- backend/src/auth.ts +69 -1
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: ✏️
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
|
|
| 1 |
---
|
| 2 |
+
title: Research Article Template Editor
|
| 3 |
emoji: ✏️
|
| 4 |
colorFrom: purple
|
| 5 |
colorTo: blue
|
|
@@ -142,7 +142,16 @@ export async function handleOAuthCallback(req: Request, res: Response) {
|
|
| 142 |
}
|
| 143 |
|
| 144 |
const tokenData = (await tokenRes.json()) as { access_token: string; expires_in?: number };
|
| 145 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
|
| 147 |
res.cookie(COOKIE_NAME, tokenData.access_token, {
|
| 148 |
httpOnly: true,
|
|
@@ -159,6 +168,65 @@ export async function handleOAuthCallback(req: Request, res: Response) {
|
|
| 159 |
}
|
| 160 |
}
|
| 161 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
/**
|
| 163 |
* Clear the HF access token cookie, signing the visitor out of the
|
| 164 |
* editor. We mirror every attribute the original `res.cookie(...)`
|
|
|
|
| 142 |
}
|
| 143 |
|
| 144 |
const tokenData = (await tokenRes.json()) as { access_token: string; expires_in?: number };
|
| 145 |
+
|
| 146 |
+
// Cookie lifetime strategy: match the underlying JWT's real
|
| 147 |
+
// expiration so the cookie dies exactly when the token does.
|
| 148 |
+
// HF currently emits 30-day tokens, but rather than hard-code
|
| 149 |
+
// that we decode the JWT's `exp` claim (it's a public field,
|
| 150 |
+
// no verification needed - we just want the timestamp) and use
|
| 151 |
+
// it directly. Falls back to `expires_in` from the OAuth
|
| 152 |
+
// response, then to a 30-day floor as a last resort so a
|
| 153 |
+
// stale 8-hour fallback can never sneak back in.
|
| 154 |
+
const maxAge = computeCookieMaxAge(tokenData);
|
| 155 |
|
| 156 |
res.cookie(COOKIE_NAME, tokenData.access_token, {
|
| 157 |
httpOnly: true,
|
|
|
|
| 168 |
}
|
| 169 |
}
|
| 170 |
|
| 171 |
+
/**
|
| 172 |
+
* Compute the cookie max-age in milliseconds for an HF access token.
|
| 173 |
+
*
|
| 174 |
+
* Order of preference:
|
| 175 |
+
* 1. Decode the JWT's `exp` claim and use `exp - now` directly.
|
| 176 |
+
* HF tokens are JWTs and their `exp` is the most authoritative
|
| 177 |
+
* "when does this stop working" signal we have.
|
| 178 |
+
* 2. Fall back to `expires_in` from the OAuth /token response if
|
| 179 |
+
* decoding failed (third-party JWT lib unavailable, malformed
|
| 180 |
+
* token, etc.).
|
| 181 |
+
* 3. Floor at 30 days. Anything shorter than that is almost
|
| 182 |
+
* certainly a misread - HF currently issues 30-day tokens,
|
| 183 |
+
* so an 8-hour fallback would just teach users to mistrust
|
| 184 |
+
* the editor when it boots them out mid-day.
|
| 185 |
+
* 4. Cap at 400 days. That's the maximum Chrome / Chromium-
|
| 186 |
+
* derived browsers accept since cookies-with-max-age-greater-
|
| 187 |
+
* than-400-days were silently clamped (RFC 6265bis section
|
| 188 |
+
* 5.5). Setting a larger value would either be silently
|
| 189 |
+
* truncated or trigger console warnings.
|
| 190 |
+
*/
|
| 191 |
+
function computeCookieMaxAge(tokenData: {
|
| 192 |
+
access_token: string;
|
| 193 |
+
expires_in?: number;
|
| 194 |
+
}): number {
|
| 195 |
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
| 196 |
+
const FOUR_HUNDRED_DAYS_MS = 400 * 24 * 60 * 60 * 1000;
|
| 197 |
+
|
| 198 |
+
let lifeMs = 0;
|
| 199 |
+
|
| 200 |
+
// 1. JWT exp claim. We don't verify the signature - that's HF's
|
| 201 |
+
// job at every API call - we just read the exp timestamp.
|
| 202 |
+
try {
|
| 203 |
+
const parts = tokenData.access_token.split(".");
|
| 204 |
+
if (parts.length === 3) {
|
| 205 |
+
// base64url -> base64 -> JSON
|
| 206 |
+
const padded = parts[1]
|
| 207 |
+
.replace(/-/g, "+")
|
| 208 |
+
.replace(/_/g, "/")
|
| 209 |
+
.padEnd(Math.ceil(parts[1].length / 4) * 4, "=");
|
| 210 |
+
const payload = JSON.parse(
|
| 211 |
+
Buffer.from(padded, "base64").toString("utf-8"),
|
| 212 |
+
);
|
| 213 |
+
if (typeof payload?.exp === "number") {
|
| 214 |
+
lifeMs = Math.max(lifeMs, payload.exp * 1000 - Date.now());
|
| 215 |
+
}
|
| 216 |
+
}
|
| 217 |
+
} catch {
|
| 218 |
+
// Malformed JWT, swallow and fall through to expires_in.
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// 2. expires_in fallback.
|
| 222 |
+
if (typeof tokenData.expires_in === "number") {
|
| 223 |
+
lifeMs = Math.max(lifeMs, tokenData.expires_in * 1000);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
// 3. & 4. Floor + cap.
|
| 227 |
+
return Math.min(Math.max(lifeMs, THIRTY_DAYS_MS), FOUR_HUNDRED_DAYS_MS);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
/**
|
| 231 |
* Clear the HF access token cookie, signing the visitor out of the
|
| 232 |
* editor. We mirror every attribute the original `res.cookie(...)`
|