mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
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
This commit is contained in:
parent
815eac43db
commit
0ca3606be5
2 changed files with 51 additions and 30 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
|
||||
/** 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<PollResult> {
|
|||
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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue