Skip to content

Add refresh-token auth flow and harden HTML injection paths#425

Open
ghengeveld wants to merge 2 commits intonextfrom
refresh-token
Open

Add refresh-token auth flow and harden HTML injection paths#425
ghengeveld wants to merge 2 commits intonextfrom
refresh-token

Conversation

@ghengeveld
Copy link
Copy Markdown
Member

Summary

  • add refresh-token based auth renewal in the manager client using a validated auth blob (accessToken, refreshToken, tokenEndpoint, sessionId) stored under the existing storage key
  • centralize reactive refresh in authExchange with in-tab single-flight behavior, 10s timeout, one retry for transient/5xx failures, and session clearing on terminal auth failure
  • harden the UI against HTML/code injection by banning dangerouslySetInnerHTML/innerHTML-style sinks in ESLint and replacing innerHTML usage in PulsatingEffect with textContent

Test plan

  • yarn lint
  • yarn typecheck
  • yarn vitest run src/utils/graphQLClient.test.ts

Made with Cursor

ghengeveld and others added 2 commits May 5, 2026 10:41
Store auth as a validated token blob with persisted session metadata, send session headers on token exchanges, and refresh access tokens reactively with timeout/retry handling to avoid forced re-login during active use.

Co-authored-by: Cursor <cursoragent@cursor.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 5, 2026

Codecov Report

❌ Patch coverage is 66.51982% with 76 lines in your changes missing coverage. Please review.
✅ Project coverage is 8.05%. Comparing base (d7bf277) to head (9413949).

Files with missing lines Patch % Lines
src/utils/graphQLClient.tsx 72.65% 35 Missing ⚠️
src/utils/requestAccessToken.ts 64.44% 32 Missing ⚠️
src/screens/Authentication/Verify.tsx 0.00% 7 Missing ⚠️
src/screens/GuidedTour/PulsatingEffect.tsx 0.00% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##            next    #425      +/-   ##
========================================
+ Coverage   6.80%   8.05%   +1.24%     
========================================
  Files        166     166              
  Lines      14216   14384     +168     
  Branches     311     345      +34     
========================================
+ Hits         968    1158     +190     
+ Misses     13168   13150      -18     
+ Partials      80      76       -4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link
Copy Markdown
Contributor

@valentinpalkovic valentinpalkovic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CCG review (Codex + Gemini + Claude synthesis)

Solid security upgrade overall. PKCE + auth-code is a clear win and the single-flight refresh pattern is the right shape. Findings cluster around (a) refreshed token never reaching React state, (b) refresh-error propagation, (c) isRetryableRefreshError defaulting to retry on terminal OAuth errors. No Critical. 3 High that should land before merge.

Reviewer convergence

Finding Codex Gemini Claude
H1 Stale React token after refresh
H2 Premature setAuthenticatedSession
H3 Swallowed refresh error partial
M1 isRetryableRefreshError default
M2 refresh_token required by schema
M3 Logout/refresh race
M5 No legacy storage migration partial
M6 Cross-tab refresh not single-flight
M7 Silent failure, no UX feedback partial
L2 Lint gaps (outerHTML, document.write, srcdoc)
L3 globalThis vs window inconsistency
L4 <React.Fragment /> regression

No conflicts between advisors.

Action checklist (priority order)

  1. H3 — rethrow refresh errors so authExchange.refreshAuth sees failure.
  2. H1 — propagate refreshed access token to React state / useAccessToken subscribers.
  3. H2 — defer setAuthenticatedSession until project-creation flow completes.
  4. M1 — fix isRetryableRefreshError fallthrough.
  5. M3 — generation counter to discard refresh result after logout.
  6. M2 — make refresh_token optional in TokenResponseSchema (RFC 6749 §6).
  7. M7 — surface session-expired notification.
  8. M5 — document forced re-login on upgrade or migrate legacy values.
  9. M6 — decide cross-tab strategy (BroadcastChannel / storage event) or document.
  10. L2 / L3 / L4 — quick cleanups.
  11. Tests for H1 / M3 / state-mismatch on error callback / single-flight concurrent 401 / refresh-without-rotation.

Further related findings outside this PR's diff (already in next): OAuth state not validated on the error sub-branch in Verify.tsx's grant handler, and state forwarded with optional validation in manager.tsx. Per RFC 6749 §10.12 state must be validated on all authorization responses. Worth following up.

Detailed inline comments below.

sessionId: currentAuth.sessionId,
signal: abortController.signal,
});
setCurrentAuth(nextAuth);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Refreshed access token never reaches React state — builds run with stale token.

attemptTokenRefresh updates the module-level currentAuth / currentToken and localStorage, but useAccessToken only updates via setTokenState inside updateToken (lines 94-100). The refresh path bypasses that callback entirely.

useBuildEvents reads accessToken from React state and emits it on START_BUILD (src/utils/useBuildEvents.ts:11,36). After a silent refresh, the React tree (and therefore startBuildpreset.ts START_BUILD handler) keeps the expired token. So a 401-triggered refresh succeeds for GraphQL but the next build still fires with the stale token.

Fix options:

  • expose a small subscribe/notify on the module and have useAccessToken subscribe;
  • read currentToken lazily inside startBuild instead of capturing via closure / props;
  • push token change through useAddonState so useAccessToken re-renders.

Whichever you pick, add a regression test: simulate refresh → assert React-side accessToken updated.

(Codex + Claude)

return true;
}
}
return true;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] isRetryableRefreshError defaults to true for non-status-coded errors → retries terminal OAuth errors.

if (error instanceof Error) {
  if (statusMatch) return statusCode >= 500;   // 4xx → false ✓
  if (error.name === 'AbortError') return true;
  // falls through ↓
}
return true;  // ← non-status Errors and non-Errors retry

Errors thrown by decodeTokenResponse (e.g. invalid_grant, invalid_request) carry no (NNN) token in the message. They fall through and retry once — wasted call against an already-revoked refresh token, plus extra latency before logout.

const isRetryableRefreshError = (error: unknown) => {
  if (error instanceof Error) {
    const statusMatch = error.message.match(/\((\d{3})\)/);
    if (statusMatch) {
      const statusCode = Number(statusMatch[1]);
      return statusCode >= 500;
    }
    if (error.name === 'AbortError') {
      return true;
    }
    return false;
  }
  return false;
};

(Gemini + Claude)

throw error;
}
})()
.catch(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Refresh failures are swallowed — authExchange.refreshAuth (line 204) sees success even when the session was cleared.

The outer .catch(() => { console.warn(...); clearCurrentAuth(); }) resolves the refreshPromise cleanly. urql's authExchange.refreshAuth awaits this and treats it as a successful refresh, then retries the original op (now without auth) instead of surfacing the auth error.

Fix: rethrow inside the catch (or remove the catch entirely and let it propagate, with clearCurrentAuth() in a finally). Pair with M7 (UX toast) so the user understands why they were just signed out.

.catch((error) => {
  clearCurrentAuth();
  throw error;
})

(Codex + Claude)

}
})()
.catch(() => {
console.warn('Session expired. Please sign in again.');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] No UI feedback on silent refresh failure.

A console.warn is the only signal when a session expires server-side or the refresh token has been revoked. The user sees stale UI / failing requests until something else triggers an auth-gated render.

Suggest routing through useErrorNotification or emitting a session-expired toast on the addon channel when clearCurrentAuth is called from the refresh path (vs an explicit logout). That's also the natural pairing for the rethrow in H3.

(Gemini)

};

const clearCurrentAuth = () => {
refreshAbortController?.abort();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Logout ↔ in-flight refresh race can resurrect auth state.

clearCurrentAuth aborts the controller and nulls module state, but if attemptTokenRefresh's fetch already resolved (response in flight, abort no-ops), the awaited continuation still calls setCurrentAuth(nextAuth) (line 142) and re-populates auth post-logout.

Guard with a generation counter:

let authGen = 0;
const clearCurrentAuth = () => { authGen++; /* ...rest */ };
const attemptTokenRefresh = async () => {
  const myGen = authGen;
  const nextAuth = await refreshAccessToken({...});
  if (myGen !== authGen) return; // logged out mid-flight
  setCurrentAuth(nextAuth);
};

(Codex + Claude)


const TokenResponseSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] refresh_token is required, but RFC 6749 §6 allows the server to omit it on refresh responses.

const TokenResponseSchema = z.object({
  access_token: z.string().min(1),
  refresh_token: z.string().min(1),  // ← required
  ...
});

If Chromatic's token endpoint ever returns an access-only response (i.e. doesn't rotate refresh tokens on every refresh), the schema fails and the user is logged out. Make refresh_token optional and reuse the previous refreshToken when omitted:

const TokenResponseSchema = z.object({
  access_token: z.string().min(1),
  refresh_token: z.string().min(1).optional(),
  error: z.string().optional(),
  error_description: z.string().optional(),
});

Then in refreshAccessToken: refreshToken: data.refresh_token ?? refreshToken.

(Codex + Claude)

refreshToken: z.string().min(1),
tokenEndpoint: z.string().url(),
sessionId: z.string().min(1),
});
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] No backward-compat for users with the previous raw-token storage format.

The schema requires version: literal(1) and a JSON object. Existing users with a plain JWT string in ACCESS_TOKEN_KEY will hit the parse failure path → clearCurrentAuth() → forced re-login on upgrade.

If re-login is acceptable, please call it out in CHANGELOG and release notes (one-time hit but visible to users). Otherwise add a small migration — detect non-JSON value, treat as expired, drop it cleanly.

(Codex)

});
if (!token) throw new Error('Failed to fetch an access token');
accessToken.current = token;
setAuthenticatedSession(token);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[High] Refresh token persisted before the auth flow is actually complete.

setAuthenticatedSession(token) here writes the refresh token to localStorage immediately after token exchange, but the comment three lines down still says "don't store it yet." For users with no projects, control branches into the newProjectUrl dialog (line ~113) without ever calling setAccessToken. If the user closes the dialog at that point, a long-lived refresh token sits in localStorage while the Panel-level auth state is still empty.

Fix: defer setAuthenticatedSession to the same point as setAccessToken(accessToken.current) (lines 104 / 124). Or, alternatively, clear auth on Verify-screen unmount unless setAccessToken was called.

(Codex + Claude)

Comment thread eslint.config.mjs
selector: "NewExpression[callee.name='Function']",
message: 'Function constructor is banned',
},
],
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] Sink ban is incomplete as a guardrail.

Great addition overall. Common sinks not yet covered:

  • outerHTML assignment
  • document.write / document.writeln
  • iframe srcdoc JSXAttribute
  • Range.createContextualFragment
  • string-form setTimeout / setInterval

Suggested additions:

{ selector: "AssignmentExpression[left.property.name='outerHTML']", message: 'outerHTML assignment is banned' },
{ selector: "CallExpression[callee.object.name='document'][callee.property.name=/^writeln?$/]", message: 'document.write/writeln is banned' },
{ selector: "JSXAttribute[name.name='srcdoc']", message: 'iframe srcdoc is banned' },
{ selector: "CallExpression[callee.property.name='createContextualFragment']", message: 'createContextualFragment is banned' },
{ selector: "CallExpression[callee.name=/^setTimeout|setInterval$/][arguments.0.type='Literal']", message: 'String-form setTimeout/setInterval is banned' },

(Codex)

}, [targetSelector]);

return <></>;
return <React.Fragment />;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Low] <React.Fragment /> is inconsistent with the rest of the repo.

Elsewhere in the codebase (e.g. VisualTests.tsx:323) we use <></>. Suggest reverting this hunk to keep the style uniform.

(Gemini)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants