Skip to content

Commit f387ccd

Browse files
committed
fix(oauth): say that a stale sign-in link was used instead of "try again"
The runtime's callback listener rejects a callback whose state is not the current attempt's - the browser finished a link from an earlier attempt, since every retry issues a new one. That failure was folded into the generic oauth_login_failed; it is now oauth_state_mismatch and the dialog explains to start again and open the link shown now. The raw runtime message still never crosses the protocol.
1 parent 2849696 commit f387ccd

4 files changed

Lines changed: 43 additions & 2 deletions

File tree

server/gjc-bun-oauth-controller.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -271,11 +271,18 @@ export class GjcBunOAuthController {
271271
},
272272
signal: attempt.abortController.signal,
273273
});
274-
} catch {
274+
} catch (error) {
275275
if (!this.#isActive(attempt)) return;
276276
clearTimeout(attempt.timeout);
277277
this.#active = undefined;
278-
this.#transition(attempt, 'failed', { errorCode: 'oauth_login_failed' });
278+
// The runtime's callback listener rejects a callback whose `state` is
279+
// not this attempt's: the browser finished a link from an earlier
280+
// attempt (a retry issues a new one). Named so the dialog can say
281+
// "use the newest link" instead of a generic "try again"; the raw
282+
// message never crosses the protocol.
283+
const message = error instanceof Error ? error.message : String(error);
284+
const errorCode = /state mismatch/i.test(message) ? 'oauth_state_mismatch' : 'oauth_login_failed';
285+
this.#transition(attempt, 'failed', { errorCode });
279286
return;
280287
}
281288

server/gjc-sdk-contract.bun.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,31 @@ test('OAuth refresh failure preserves persisted auth state and reports a distinc
825825
}
826826
});
827827

828+
test('a callback from an earlier attempt fails as a state mismatch, named but never quoted', async () => {
829+
const f = await fixture(
830+
'contract-model',
831+
undefined,
832+
{ id: 'contract-model', provider: 'contract-provider' },
833+
undefined,
834+
async () => { throw new Error('State mismatch - possible CSRF attack (canary)'); },
835+
);
836+
try {
837+
await f.host.handle(request('oauth.start', 'oauth-state-mismatch', { providerId: 'openai-codex' }));
838+
const start = ((response(f.frames, 'oauth-state-mismatch').payload as Record<string, unknown>).result ?? {}) as Record<string, unknown>;
839+
const attemptId = start.attemptId as string;
840+
const failed = await waitFor(() => f.frames
841+
.filter((frame) => frame.method === 'oauth.phase')
842+
.map((frame) => frame.payload as Record<string, unknown>)
843+
.find((phase) => phase.attemptId === attemptId && phase.phase === 'failed'));
844+
845+
assert.equal(failed.errorCode, 'oauth_state_mismatch');
846+
assert.equal(JSON.stringify(f.frames).includes('canary'), false);
847+
assert.equal(JSON.stringify(f.frames).includes('CSRF'), false);
848+
} finally {
849+
await f.close();
850+
}
851+
});
852+
828853
test('OAuth automatic callback flow completes without a manual submit', async () => {
829854
const f = await fixture(
830855
'contract-model',

src/components/chat/hooks/useOAuthLogin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ const safeFailure = (code: string): OAuthLoginFailure => {
194194
return { code, message: 'Connection lost. Reconnect before continuing sign-in.' };
195195
case 'oauth_model_refresh_failed':
196196
return { code, message: 'Sign-in was saved, but available models could not be refreshed.' };
197+
case 'oauth_state_mismatch':
198+
return { code, message: 'The link you opened belongs to an earlier sign-in attempt. Each attempt issues a new link: start again and open the link shown now.' };
197199
default:
198200
return { code: 'oauth_failed', message: 'Sign-in could not be completed. Try again.' };
199201
}

src/components/chat/tests/OAuthLogin.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,13 @@ test('OAuth failure messages distinguish disconnect and persisted-login refresh
7979
assert.equal(oauthFailureForCode('raw-provider-error').code, 'oauth_failed');
8080
});
8181

82+
test('a state mismatch tells the person to use the newest link', () => {
83+
const failure = oauthFailureForCode('oauth_state_mismatch');
84+
assert.equal(failure.code, 'oauth_state_mismatch');
85+
assert.match(failure.message, /earlier sign-in attempt/);
86+
assert.match(failure.message, /link shown now/);
87+
});
88+
8289
test('OAuth terminal failures expose safe retry messages without raw provider errors', () => {
8390
for (const code of ['oauth_login_failed', 'oauth_timed_out', 'raw-provider-error']) {
8491
const failure = oauthFailureForCode(code);

0 commit comments

Comments
 (0)