tfrere HF Staff Cursor commited on
Commit
5908601
·
1 Parent(s): 7a42df5

fix(auth): cookie lifetime now matches the JWT exp, not a stale 8h fallback

Browse files

Users 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>

Files changed (2) hide show
  1. README.md +1 -1
  2. backend/src/auth.ts +69 -1
README.md CHANGED
@@ -1,5 +1,5 @@
1
  ---
2
- title: Carbon tokenizer
3
  emoji: ✏️
4
  colorFrom: purple
5
  colorTo: blue
 
1
  ---
2
+ title: Research Article Template Editor
3
  emoji: ✏️
4
  colorFrom: purple
5
  colorTo: blue
backend/src/auth.ts CHANGED
@@ -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
- const maxAge = (tokenData.expires_in || 28800) * 1000;
 
 
 
 
 
 
 
 
 
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(...)`