Skip to content

Commit efe381f

Browse files
authored
refactor(oauth): retire the producerless paste-code authorization presentation (#3399)
* refactor(oauth): retire the paste-code authorization presentation Every OAuth provider that survives on main enrols through a device flow: the Host opens the provider's page in a browser and the provider hands the credential back. `request_authorization_code` — the presentation where the user copies a code out of the browser and pastes it into Maka — had no producer left. Nothing selected it, so its decoders, its pending-code state machine, and the PKCE/loopback authorization builders behind it were reached only by their own tests. Removed, from the wire inward: - `request_authorization_code` leaves `OAuthPresentationMethod`, and the request/result unions collapse to the single shape the method carries. The decoder still validates the arriving method, so a peer offering anything else is refused rather than silently presented. - The coordinator's `#present` overloads, its authorization-timeout bounds, and `#exchangeCode` go with it. `#exchangeCodexCode` stays: the Codex device flow exchanges a code the provider returns, not one the user pastes. - Desktop drops the pending-code state machine and the paste branch of the IPC surface. `complete-authorization` stays — device flows drive `getAuthUrl -> openAuthUrl -> completeAuthorization`. - `oauth-login.ts` loses `buildOAuthLoginAuthorization`, `exchangeOAuthAuthorizationCode`, and the redirect/state/PKCE helpers only they called. The token-endpoint transport stays; device flows use it. - `@maka/core` loses `parsePastedAuthorization`, `constantTimeStringEqual`, `PENDING_AUTHORIZATION_TTL_MS`, the PKCE challenge helpers, and the `invalid_paste_code` / `authorization_expired` failure reasons. The Codex device flow receives its verifier from the provider rather than deriving one, so nothing computes a PKCE challenge any more. The seven transport tests that reached the streaming and bounding behaviour through `exchangeOAuthAuthorizationCode` now drive `requestOAuthTokenEndpointJson` directly. That behaviour is still live for device flows, so the coverage moves rather than disappears. `RUNTIME_HOST_COMPATIBILITY_EPOCH` 31 -> 32: an older Client still offers the removed presentation and an older Host still asks for it, and neither side can carry the authorization code the other expects. Closes #3219 Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J * docs(desktop): drop the loopback PKCE mentions from the login-flow hook The hook's header and its wait comment still described a loopback PKCE alternative that no provider takes now that the paste-code presentation is gone. Both paths through it are device-code polling. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J * refactor(oauth): remove what the paste-code teardown orphaned Self-review of the previous commit found code the removal left behind but nothing reports: biome does not flag unused private functions, and the compiler is happy to keep a field no one reads. - `oauth-provider-contracts.ts` — the xAI contract still declared `authorizationEndpoint`, `redirectUri`, `authorizationExtras` and `presentation: 'loopback'`. The live enrolment reads `clientId`, `deviceEndpoint`, `deviceGrant`, `scope`, `tokenEndpoint` and `defaultTokenLifetimeSeconds` and nothing else. `presentation: 'loopback'` was the worst of them: it asserted a flow xAI does not take, in the table a reader consults to learn what it does take. - `oauth-coordinator.ts` — `randomOpaqueValue` generated the verifier and state for the removed authorization request; it and its `randomBytes` import had no caller left. - `oauth-login.ts` — `assertOpaqueValue` was called only from the deleted exchange, and `OAuthLoginProvider` was only a parameter of the deleted authorization input. No module imports either. - `runtime-host-oauth-presentation.ts` — `OAuthExternalPresentation.method` lost its only reader when the Desktop attempt record stopped storing the presentation method. It was written as a constant and read by no one. - `protocol/oauth.ts` — `safeInteger` decoded the quota window fields that left with the account-usage operation in #3183. A drive-by on my own prior PR, in the file this change already rewrites. Test-side: the off-contract method in the protocol test is cast through `unknown` and named, so it reads as a value arriving from an older peer rather than as a claim that the string is `open_external`; two imports that were already dead on main in `oauth-coordinator.test.ts` go with it. Claude-Session: https://claude.ai/code/session_014ajaRxC4jydavY9nYUFj5J
1 parent e955575 commit efe381f

18 files changed

Lines changed: 96 additions & 663 deletions

apps/desktop/src/main/__tests__/runtime-host-oauth-ipc-main.test.ts

Lines changed: 5 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from '../runtime-host-oauth-ipc-main.js';
1111
import { RuntimeHostOAuthPresentation } from '../runtime-host-oauth-presentation.js';
1212

13-
test('presents both Host OAuth methods without exposing the authorization URL', async () => {
13+
test('presents the Host OAuth handoff without exposing the authorization URL', async () => {
1414
const opened: string[] = [];
1515
const presentation = new RuntimeHostOAuthPresentation(async (url) => {
1616
opened.push(url);
@@ -21,27 +21,9 @@ test('presents both Host OAuth methods without exposing the authorization URL',
2121
'DEVICE-CODE',
2222
new AbortController().signal,
2323
);
24-
assert.deepEqual(await external.presented, {
25-
method: 'open_external',
26-
stateHint: 'DEVICE-CODE',
27-
});
24+
assert.deepEqual(await external.presented, { stateHint: 'DEVICE-CODE' });
2825

29-
const pasted = presentation.expect('code-attempt');
30-
const code = presentation.requestAuthorizationCode(
31-
'https://auth.example/authorize',
32-
'STATE-HINT',
33-
new AbortController().signal,
34-
);
35-
assert.deepEqual(await pasted.presented, {
36-
method: 'request_authorization_code',
37-
stateHint: 'STATE-HINT',
38-
});
39-
assert.equal(presentation.submitAuthorizationCode('code-attempt', 'code#state'), true);
40-
assert.equal(await code, 'code#state');
41-
assert.deepEqual(opened, [
42-
'https://auth.example/device',
43-
'https://auth.example/authorize',
44-
]);
26+
assert.deepEqual(opened, ['https://auth.example/device']);
4527
});
4628

4729
test('adapts every Host OAuth provider through one Desktop flow', async () => {
@@ -86,9 +68,8 @@ test('adapts every Host OAuth provider through one Desktop flow', async () => {
8668
},
8769
startOAuthLogin: async (nextAttemptId, connectionId) => {
8870
attemptId = nextAttemptId;
89-
// Codex device login presents with `open_external`; the paste-code
90-
// presentation this fixture used has no producer, so asserting it proved
91-
// the desktop bridge against a flow no provider takes.
71+
// Codex device login presents through `open_external`: the browser
72+
// carries the authorization and the Host writes the credential back.
9273
void presentation
9374
.openExternal(
9475
'https://codex.example/authorize',

apps/desktop/src/main/runtime-host-oauth-ipc-main.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ export interface RuntimeHostOAuthIpcDeps {
5858

5959
interface ActiveOAuthAttempt {
6060
readonly provider: OAuthLoginProvider;
61-
readonly presentationMethod: OAuthExternalPresentation['method'];
6261
}
6362

6463
/** Adapts the existing Desktop OAuth UI to the Host's provider-neutral OAuth operations. */
@@ -88,7 +87,6 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
8887
const presented = await waitForPresentation(deps.client, attemptId, expectation.presented);
8988
activeAttempts.set(attemptId, {
9089
provider,
91-
presentationMethod: presented.method,
9290
});
9391
return { authRequestId: attemptId, stateHint: presented.stateHint };
9492
} catch (error) {
@@ -110,22 +108,13 @@ export function registerRuntimeHostOAuthIpc(deps: RuntimeHostOAuthIpcDeps): void
110108
});
111109
deps.ipcMain.handle(
112110
channel('complete-authorization'),
113-
async (_event, attemptId: unknown, authorizationCode: unknown) => {
111+
async (_event, attemptId: unknown) => {
114112
if (typeof attemptId !== 'string') {
115113
return actionFailure('OAuth authorization is not active', 'authorization_pending');
116114
}
117-
const attempt = providerAttempt(activeAttempts, attemptId, provider);
118-
if (!attempt) {
115+
if (!providerAttempt(activeAttempts, attemptId, provider)) {
119116
return actionFailure('OAuth authorization is not active', 'authorization_pending');
120117
}
121-
if (attempt.presentationMethod === 'request_authorization_code') {
122-
if (typeof authorizationCode !== 'string' || authorizationCode.length === 0) {
123-
return actionFailure('OAuth authorization code is required', 'invalid_paste_code');
124-
}
125-
if (!deps.presentation.submitAuthorizationCode(attemptId, authorizationCode)) {
126-
return actionFailure('OAuth authorization is not active', 'authorization_pending');
127-
}
128-
}
129118
try {
130119
const terminal = await waitForTerminal(deps.client, attemptId);
131120
activeAttempts.delete(attemptId);
@@ -295,7 +284,6 @@ function providerDisabled() {
295284
function actionFailure(
296285
message: string,
297286
reason:
298-
| 'invalid_paste_code'
299287
| 'authorization_pending'
300288
| 'authorization_cancelled'
301289
| 'authorization_denied'

apps/desktop/src/main/runtime-host-oauth-presentation.ts

Lines changed: 2 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import type { OAuthPresentationBackend } from '@maka/runtime-host/client';
33
const PRESENTATION_TIMEOUT_MS = 30_000;
44

55
export interface OAuthExternalPresentation {
6-
readonly method: 'open_external' | 'request_authorization_code';
76
readonly stateHint: string;
87
}
98

@@ -23,8 +22,6 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend {
2322
let resolvePresented!: (presentation: OAuthExternalPresentation) => void;
2423
let rejectPresented!: (reason?: unknown) => void;
2524
let presentedSettled = false;
26-
let resolveAuthorizationCode: ((value: string) => void) | undefined;
27-
let rejectAuthorizationCode: ((reason?: unknown) => void) | undefined;
2825
const presented = new Promise<OAuthExternalPresentation>((accept, decline) => {
2926
resolvePresented = accept;
3027
rejectPresented = decline;
@@ -43,30 +40,13 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend {
4340
resolve: (presentation) => {
4441
clearTimeout(timer);
4542
presentedSettled = true;
46-
if (presentation.method === 'open_external' && this.#pending === pending) {
47-
this.#pending = undefined;
48-
}
43+
if (this.#pending === pending) this.#pending = undefined;
4944
resolvePresented(presentation);
5045
},
5146
reject: (reason) => {
5247
clearTimeout(timer);
5348
if (this.#pending === pending) this.#pending = undefined;
5449
if (!presentedSettled) rejectPresented(reason);
55-
rejectAuthorizationCode?.(reason);
56-
},
57-
authorizationCode: (signal) =>
58-
new Promise<string>((resolveCode, rejectCode) => {
59-
resolveAuthorizationCode = resolveCode;
60-
rejectAuthorizationCode = rejectCode;
61-
signal.addEventListener(
62-
'abort',
63-
() => pending.reject(signal.reason),
64-
{ once: true },
65-
);
66-
}),
67-
submitAuthorizationCode: (value) => {
68-
if (this.#pending === pending) this.#pending = undefined;
69-
resolveAuthorizationCode?.(value);
7050
},
7151
};
7252
this.#pending = pending;
@@ -91,34 +71,13 @@ export class RuntimeHostOAuthPresentation implements OAuthPresentationBackend {
9171
try {
9272
await this.openSystemBrowser(url);
9373
signal.throwIfAborted();
94-
pending.resolve({ method: 'open_external', stateHint });
74+
pending.resolve({ stateHint });
9575
} catch (error) {
9676
pending.reject(error);
9777
throw error;
9878
}
9979
}
10080

101-
async requestAuthorizationCode(
102-
url: string,
103-
stateHint: string,
104-
signal: AbortSignal,
105-
): Promise<string> {
106-
signal.throwIfAborted();
107-
const pending = this.#pending;
108-
if (!pending) throw new Error('Desktop has no matching OAuth presentation request');
109-
await this.openSystemBrowser(url);
110-
signal.throwIfAborted();
111-
pending.resolve({ method: 'request_authorization_code', stateHint });
112-
return pending.authorizationCode(signal);
113-
}
114-
115-
submitAuthorizationCode(attemptId: string, authorizationCode: string): boolean {
116-
const pending = this.#pending;
117-
if (!pending || pending.attemptId !== attemptId) return false;
118-
pending.submitAuthorizationCode(authorizationCode);
119-
return true;
120-
}
121-
12281
cancel(attemptId: string, reason: unknown = new Error('OAuth presentation cancelled')): void {
12382
if (this.#pending?.attemptId === attemptId) this.#pending.reject(reason);
12483
}
@@ -128,6 +87,4 @@ interface PendingPresentation {
12887
readonly attemptId: string;
12988
resolve(presentation: OAuthExternalPresentation): void;
13089
reject(reason?: unknown): void;
131-
authorizationCode(signal: AbortSignal): Promise<string>;
132-
submitAuthorizationCode(value: string): void;
13390
}

apps/desktop/src/renderer/locales/settings-provider-copy.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ const zhCopy = {
170170
copilotDescription: '导入兼容 GitHub 凭据连接 Copilot 订阅。', serviceUnavailable: '登录服务暂时不可用,请检查网络后重试。',
171171
aria: 'OAuth 登录',
172172
staleState: 'OAuth 登录状态暂时没刷新成功,已保留上一次状态。',
173-
codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:', stateHint: '提示:state 以', startsWith: '开头。',
173+
codexDetail: '点击下方按钮打开设备授权页,并在页面中输入这里显示的登录码。', xaiDetail: '点击下方按钮打开浏览器登录,授权完成后会自动回写。', deviceCode: '登录码:',
174174
openingBrowser: '打开浏览器…', logout: '退出登录', loggingOut: '退出中…',
175175
copilotSubtitle: '导入兼容的 GitHub 登录;token 不会暴露给渲染进程。', copilotImported: '已导入 GitHub Copilot 订阅账号。',
176176
copilotSetup: '请配置具有 Copilot Requests 权限的 fine-grained PAT;普通 gh auth login 可能不包含该权限。', importing: '导入中…',
@@ -304,7 +304,7 @@ const enCopy: ProviderSettingsCopy = {
304304
copilotDescription: 'Import compatible GitHub credentials to connect a Copilot subscription.', serviceUnavailable: 'The sign-in service is temporarily unavailable. Check the network and try again.',
305305
aria: 'OAuth sign-in',
306306
staleState: 'OAuth sign-in status could not be refreshed. The last known state is preserved. ',
307-
codexDetail: 'Open the device page below and enter the sign-in code shown here.', xaiDetail: 'Open the browser below to sign in. Authorization is written back automatically.', deviceCode: 'Sign-in code:', stateHint: 'Tip: state begins with', startsWith: '.',
307+
codexDetail: 'Open the device page below and enter the sign-in code shown here.', xaiDetail: 'Open the browser below to sign in. Authorization is written back automatically.', deviceCode: 'Sign-in code:',
308308
openingBrowser: 'Opening browser…', logout: 'Sign out', loggingOut: 'Signing out…',
309309
copilotSubtitle: 'Import a compatible GitHub sign-in. The token is never exposed to the renderer.', copilotImported: 'GitHub Copilot subscription account imported.',
310310
copilotSetup: 'Configure a fine-grained PAT with Copilot Requests permission. A normal gh auth login may not include it.', importing: 'Importing…',

apps/desktop/src/renderer/settings/use-connection-detail.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ import { runtimeHostOAuthLoginBridge } from './runtime-host-settings-bridge.js';
3838
// Maps an OAuth model-connection provider type to the browser-assisted login
3939
// service that can re-run its authorization from inside the connection dialog. Only
4040
// the browser-assisted services (Codex and xAI) are one-button-drivable
41-
// here; Claude's paste-code flow and plain API-key providers return null so the
42-
// notice falls back to prose instead of rendering a dead button.
41+
// here; plain API-key providers return null so the notice falls back to
42+
// prose instead of rendering a dead button.
4343
export interface OAuthLoginService {
4444
bridge: OAuthLoginFlowBridge;
4545
display: { name: string; shortName: string };

apps/desktop/src/renderer/settings/use-oauth-login-flow.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,15 @@ import { createOneShotActionGuard, teardownPendingAuthorization } from './oauth-
66
import { getProviderSettingsCopy } from '../locales/settings-provider-copy';
77

88

9-
// Shared browser-assisted OAuth login-flow controller (device-code
10-
// polling / loopback PKCE depending on the provider).
9+
// Shared browser-assisted OAuth login-flow controller (device-code polling).
1110
//
1211
// Extracted from the SubscriptionLoginModal `startLogin` flow so BOTH the
1312
// OAuth catalog login modals (Codex / xAI) AND the model
1413
// connection detail sheet's 重新登录 affordance drive the same
1514
// getAuthUrl -> openAuthUrl -> refresh -> completeAuthorization sequence with
1615
// one authRequestId lifecycle, one synchronous pending-action guard, and
17-
// cancellation-on-unmount. Claude's paste-code flow is deliberately NOT
18-
// routed through this hook -- it needs a manual authorization-code step and
19-
// its own experimental gate, so it keeps its bespoke card.
16+
// cancellation-on-unmount. Every OAuth provider hands authorization to the
17+
// browser, so this is the only login shape the renderer drives.
2018
//
2119
// GitHub Copilot rides the same controller through the `direct` account
2220
// flow (#1042): importing an existing GitHub login is one bridge call, so
@@ -206,7 +204,7 @@ export function useOAuthLoginFlow(params: {
206204
}
207205
const refreshed = await refresh();
208206
if (!oauthLoginFlowMountedRef.current || !refreshed) return;
209-
// Loopback / polling -- wait for the backend to complete.
207+
// Wait for the backend to finish polling the provider.
210208
const result = await bridge.completeAuthorization(payload.authRequestId);
211209
if (!oauthLoginFlowMountedRef.current) return;
212210
authRequestIdRef.current = null;
Lines changed: 1 addition & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,11 @@
11
import { strict as assert } from 'node:assert';
2-
import { createHash } from 'node:crypto';
32
import { describe, it } from 'node:test';
43

5-
import {
6-
PKCE_VERIFIER_LENGTH_BYTES,
7-
base64urlEncode,
8-
constantTimeStringEqual,
9-
parsePastedAuthorization,
10-
pkceCodeChallenge,
11-
type Sha256Digest,
12-
} from '../oauth-subscription.js';
13-
14-
const nodeSha256: Sha256Digest = {
15-
digest(input: string): Uint8Array {
16-
return new Uint8Array(createHash('sha256').update(input, 'utf8').digest());
17-
},
18-
};
4+
import { base64urlEncode } from '../oauth-subscription.js';
195

206
describe('OAuth subscription helpers', () => {
217
it('matches base64url encoding for empty and reserved bytes', () => {
228
assert.equal(base64urlEncode(new Uint8Array()), '');
239
assert.equal(base64urlEncode(new Uint8Array([0xfb, 0xff, 0xbf])), '-_-_');
2410
});
25-
26-
it('produces the RFC PKCE challenge with a safe verifier length', () => {
27-
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
28-
assert.equal(
29-
pkceCodeChallenge(verifier, nodeSha256),
30-
'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
31-
);
32-
assert.equal(PKCE_VERIFIER_LENGTH_BYTES, 32);
33-
});
34-
35-
it('parses only the strict code-state pasted shape', () => {
36-
assert.deepEqual(parsePastedAuthorization('abc_123-XYZ#state_value-42'), {
37-
code: 'abc_123-XYZ',
38-
state: 'state_value-42',
39-
});
40-
assert.deepEqual(parsePastedAuthorization(' \n abc#xyz \n'), {
41-
code: 'abc',
42-
state: 'xyz',
43-
});
44-
45-
const invalid: unknown[] = [
46-
null,
47-
' ',
48-
'abc',
49-
'#xyz',
50-
'abc#',
51-
'abc!#xyz',
52-
'abc#xy z',
53-
'abc#xy#z',
54-
];
55-
for (const value of invalid) assert.equal(parsePastedAuthorization(value), null);
56-
});
57-
58-
it('compares equal and unequal strings without widening the contract', () => {
59-
const cases = [
60-
['abc', 'abc', true],
61-
['', '', true],
62-
['abc', 'abcd', false],
63-
['abc', 'abd', false],
64-
] as const;
65-
for (const [left, right, expected] of cases) {
66-
assert.equal(constantTimeStringEqual(left, right), expected);
67-
}
68-
});
6911
});

0 commit comments

Comments
 (0)