From 0ca3606be5b100492944597a42e82afecd6c32e9 Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 6 Jul 2026 15:54:06 +0530 Subject: [PATCH] fix(apps): device-flow poll stuck on 'Waiting for authorization' GitHub's OAuth endpoints take form-encoded params; the JSON token request produced an unrecognized error that fell through to 'pending' forever, so the dialog never advanced after the user authorized. - form-encode device/code and access_token requests - unknown token-endpoint errors now fail loudly instead of spinning; incorrect_device_code maps to expired (restart offered) - issued-token is remembered so a transient identity-fetch failure retries on the next poll instead of burning the consumed device code - dialog catches hard poll failures and surfaces them --- .../src/components/apps/publish-dialog.tsx | 7 +- apps/x/packages/core/src/apps/github-auth.ts | 74 +++++++++++-------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx index fe249481..6eba1137 100644 --- a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -56,7 +56,12 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.') setPhase('auth') } - })() + })().catch((e: unknown) => { + // A hard failure (bad client config, network) must surface, not spin. + if (pollTimer.current) window.clearInterval(pollTimer.current) + setError(e instanceof Error ? e.message : String(e)) + setPhase('auth') + }) }, 5000) } catch (e) { setError(e instanceof Error ? e.message : String(e)) diff --git a/apps/x/packages/core/src/apps/github-auth.ts b/apps/x/packages/core/src/apps/github-auth.ts index d67ad394..c13f8f17 100644 --- a/apps/x/packages/core/src/apps/github-auth.ts +++ b/apps/x/packages/core/src/apps/github-auth.ts @@ -35,6 +35,8 @@ type PendingFlow = { deviceCode: string; intervalMs: number; expiresAt: number; + /** Token already issued but the identity fetch failed — retry that step. */ + issuedToken?: string; }; let pending: PendingFlow | null = null; @@ -53,10 +55,11 @@ async function writeAuth(auth: StoredAuth): Promise { /** Start the device flow. Returns the code the user enters on github.com. */ export async function startDeviceFlow(): Promise<{ userCode: string; verificationUri: string; expiresIn: number }> { + // GitHub's OAuth endpoints take form-encoded params (JSON is not reliable). const res = await fetch('https://github.com/login/device/code', { method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }), + headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }).toString(), }); if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`); const body = await res.json() as { @@ -85,44 +88,57 @@ export async function pollDeviceFlow(): Promise { return { status: 'expired' }; } - const res = await fetch('https://github.com/login/oauth/access_token', { - method: 'POST', - headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, - body: JSON.stringify({ - client_id: GITHUB_OAUTH_CLIENT_ID, - device_code: pending.deviceCode, - grant_type: 'urn:ietf:params:oauth:grant-type:device_code', - }), - }); - const body = await res.json() as { access_token?: string; error?: string }; + let accessToken = pending.issuedToken; + if (!accessToken) { + const res = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: GITHUB_OAUTH_CLIENT_ID, + device_code: pending.deviceCode, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }).toString(), + }); + const body = await res.json() as { access_token?: string; error?: string; error_description?: string }; - if (body.error === 'authorization_pending') return { status: 'pending' }; - if (body.error === 'slow_down') { - pending.intervalMs += 5000; - return { status: 'pending' }; + if (body.error === 'authorization_pending') return { status: 'pending' }; + if (body.error === 'slow_down') { + pending.intervalMs += 5000; + return { status: 'pending' }; + } + if (body.error === 'expired_token' || body.error === 'incorrect_device_code') { + pending = null; + return { status: 'expired' }; + } + if (body.error === 'access_denied') { + pending = null; + return { status: 'denied' }; + } + if (body.error) { + // Unknown/config errors (unsupported_grant_type, device_flow_disabled…) + // must FAIL loudly, not spin as pending forever. + pending = null; + throw new Error(`device_flow_error: ${body.error}${body.error_description ? ` — ${body.error_description}` : ''}`); + } + if (!body.access_token) return { status: 'pending' }; + accessToken = body.access_token; + // The device code is consumed once the token is issued — remember the + // token so a transient identity failure below can retry next poll. + pending.issuedToken = accessToken; } - if (body.error === 'expired_token') { - pending = null; - return { status: 'expired' }; - } - if (body.error === 'access_denied') { - pending = null; - return { status: 'denied' }; - } - if (!body.access_token) return { status: 'pending' }; // Identity: cache login alongside the token. const userRes = await fetch('https://api.github.com/user', { - headers: { 'Authorization': `Bearer ${body.access_token}`, 'Accept': 'application/vnd.github+json' }, + headers: { 'Authorization': `Bearer ${accessToken}`, 'Accept': 'application/vnd.github+json' }, }); - if (!userRes.ok) throw new Error(`identity_failed: HTTP ${userRes.status}`); + if (!userRes.ok) return { status: 'pending' }; // retry identity next poll const user = await userRes.json() as { login: string }; const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() }; if (cipher?.isAvailable()) { - auth.tokenEncrypted = cipher.encrypt(body.access_token); + auth.tokenEncrypted = cipher.encrypt(accessToken); } else { - auth.token = body.access_token; + auth.token = accessToken; auth.plaintext = true; } await writeAuth(auth);