fix(oauth): preserve consent failures - #25
Conversation
Bugbot couldn't run - usage limit reachedBugbot is counted against Cursor usage for this user or team, and this run hit a usage or spend limit. A user or team admin can review and increase usage limits in the Cursor dashboard. (requestId: serverGenReqId_e8bbc87e-6617-4f96-9dad-fadf367d67cc) |
There was a problem hiding this comment.
Code Review
This pull request introduces a helper function shouldPreserveOAuthConsentUnauthorized to prevent unauthorized (401) responses on the OAuth consent page (/oauth/authorize) from triggering a global logout or redirect, and adds corresponding unit tests. The review feedback suggests improving the URL parsing to robustly handle absolute URLs using the URL API, reusing an existing local logout variable in layout.context.tsx for consistency, and adding a test case to verify absolute URL handling.
| export function shouldPreserveOAuthConsentUnauthorized( | ||
| url: string, | ||
| status: number, | ||
| hasLogoutHeader: boolean | ||
| ) { | ||
| return ( | ||
| !hasLogoutHeader && | ||
| status === 401 && | ||
| url.split('?', 1)[0] === '/oauth/authorize' | ||
| ); | ||
| } |
There was a problem hiding this comment.
The current implementation of shouldPreserveOAuthConsentUnauthorized uses url.split('?', 1)[0] === '/oauth/authorize' to check the path. However, if the fetch wrapper resolves or prepends a base URL (e.g., https://api.gitroom.com/oauth/authorize), url will be an absolute URL, and this check will fail, causing the application to redirect to / instead of preserving the consent failure.
Using new URL(url, 'http://localhost') provides a robust way to extract the pathname for both relative and absolute URLs.
| export function shouldPreserveOAuthConsentUnauthorized( | |
| url: string, | |
| status: number, | |
| hasLogoutHeader: boolean | |
| ) { | |
| return ( | |
| !hasLogoutHeader && | |
| status === 401 && | |
| url.split('?', 1)[0] === '/oauth/authorize' | |
| ); | |
| } | |
| export function shouldPreserveOAuthConsentUnauthorized( | |
| url: string, | |
| status: number, | |
| hasLogoutHeader: boolean | |
| ) { | |
| if (hasLogoutHeader || status !== 401) { | |
| return false; | |
| } | |
| try { | |
| const parsedUrl = new URL(url, 'http://localhost'); | |
| return parsedUrl.pathname === '/oauth/authorize'; | |
| } catch { | |
| return url.split('?', 1)[0] === '/oauth/authorize'; | |
| } | |
| } |
| if ( | ||
| (response.status === 401 || response?.headers?.get('logout')) && | ||
| !shouldPreserveOAuthConsentUnauthorized( | ||
| url, | ||
| response.status, | ||
| Boolean(response?.headers?.get('logout')) | ||
| ) | ||
| ) { |
There was a problem hiding this comment.
The local variable logout is already defined at lines 44-45, which also handles the case-insensitive fallback (Logout). We should reuse it here instead of calling response?.headers?.get('logout') multiple times.
| if ( | |
| (response.status === 401 || response?.headers?.get('logout')) && | |
| !shouldPreserveOAuthConsentUnauthorized( | |
| url, | |
| response.status, | |
| Boolean(response?.headers?.get('logout')) | |
| ) | |
| ) { | |
| if ( | |
| (response.status === 401 || logout) && | |
| !shouldPreserveOAuthConsentUnauthorized( | |
| url, | |
| response.status, | |
| Boolean(logout) | |
| ) | |
| ) { |
| it('keeps a failed consent POST on the OAuth page instead of sending it to launches', () => { | ||
| expect( | ||
| shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, false) | ||
| ).toBe(true); | ||
| expect( | ||
| shouldPreserveOAuthConsentUnauthorized( | ||
| '/oauth/authorize?state=test', | ||
| 401, | ||
| false | ||
| ) | ||
| ).toBe(true); | ||
| expect( | ||
| shouldPreserveOAuthConsentUnauthorized('/user/profile', 401, false) | ||
| ).toBe(false); | ||
| expect( | ||
| shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, true) | ||
| ).toBe(false); | ||
| }); |
There was a problem hiding this comment.
Adding a test case for absolute URLs ensures that shouldPreserveOAuthConsentUnauthorized works correctly when the fetch wrapper prepends the base URL (e.g., in production or beta environments).
it('keeps a failed consent POST on the OAuth page instead of sending it to launches', () => {
expect(
shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, false)
).toBe(true);
expect(
shouldPreserveOAuthConsentUnauthorized(
'/oauth/authorize?state=test',
401,
false
)
).toBe(true);
expect(
shouldPreserveOAuthConsentUnauthorized(
'https://api.gitroom.com/oauth/authorize?state=test',
401,
false
)
).toBe(true);
expect(
shouldPreserveOAuthConsentUnauthorized('/user/profile', 401, false)
).toBe(false);
expect(
shouldPreserveOAuthConsentUnauthorized('/oauth/authorize', 401, true)
).toBe(false);
});
Problem
A failed first-party OAuth consent POST returns
401, but the global frontend handler navigates to/. On Beta, the proxy redirects/to/launches, masking the safe consent error and leaving the DOS-Me OAuth flow unused.Change
Preserve
401responses from/oauth/authorizeon the OAuth page unless the backend explicitly requests logout. The existing authorization action then renders its static consent-session error instead of navigating away.Validation
pnpm exec jest --config tests/bootstrap.jest.cjs --runInBand tests/bootstrap-oauth-consent-error.spec.tspnpm --filter ./apps/frontend exec tsc --noEmitgit diff --checkNote
Low Risk
Narrows an existing global 401 handler for one route; logout-header behavior on
/oauth/authorizeis unchanged.Overview
Failed first-party OAuth consent POSTs no longer trigger the global
401redirect to/(and thus away from the consent UI on environments that bounce/to/launches).The layout fetch
afterRequesthandler now skips cookie clearing andwindow.location.href = '/'whenshouldPreserveOAuthConsentUnauthorizedmatches:401on/oauth/authorize(query string ignored), with nologoutresponse header. A small helper inoauth-consent-unauthorized.tsencodes that rule so the authorize page can show the existing safe consent-session error instead of navigating away.Tests in
bootstrap-oauth-consent-error.spec.tscover the helper鈥檚 true/false cases (other paths, logout header).Reviewed by Cursor Bugbot for commit 775cc83. Configure here.