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.')
|
setError(p.status === 'expired' ? 'The code expired — start again.' : 'Authorization was denied.')
|
||||||
setPhase('auth')
|
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)
|
}, 5000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : String(e))
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ type PendingFlow = {
|
||||||
deviceCode: string;
|
deviceCode: string;
|
||||||
intervalMs: number;
|
intervalMs: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
/** Token already issued but the identity fetch failed — retry that step. */
|
||||||
|
issuedToken?: string;
|
||||||
};
|
};
|
||||||
let pending: PendingFlow | null = null;
|
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. */
|
/** Start the device flow. Returns the code the user enters on github.com. */
|
||||||
export async function startDeviceFlow(): Promise<{ userCode: string; verificationUri: string; expiresIn: number }> {
|
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', {
|
const res = await fetch('https://github.com/login/device/code', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: JSON.stringify({ client_id: GITHUB_OAUTH_CLIENT_ID, scope: 'public_repo' }),
|
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}`);
|
if (!res.ok) throw new Error(`device_code_failed: HTTP ${res.status}`);
|
||||||
const body = await res.json() as {
|
const body = await res.json() as {
|
||||||
|
|
@ -85,44 +88,57 @@ export async function pollDeviceFlow(): Promise<PollResult> {
|
||||||
return { status: 'expired' };
|
return { status: 'expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch('https://github.com/login/oauth/access_token', {
|
let accessToken = pending.issuedToken;
|
||||||
method: 'POST',
|
if (!accessToken) {
|
||||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' },
|
const res = await fetch('https://github.com/login/oauth/access_token', {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
client_id: GITHUB_OAUTH_CLIENT_ID,
|
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
device_code: pending.deviceCode,
|
body: new URLSearchParams({
|
||||||
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
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 };
|
}).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 === 'authorization_pending') return { status: 'pending' };
|
||||||
if (body.error === 'slow_down') {
|
if (body.error === 'slow_down') {
|
||||||
pending.intervalMs += 5000;
|
pending.intervalMs += 5000;
|
||||||
return { status: 'pending' };
|
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.
|
// Identity: cache login alongside the token.
|
||||||
const userRes = await fetch('https://api.github.com/user', {
|
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 user = await userRes.json() as { login: string };
|
||||||
|
|
||||||
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };
|
const auth: StoredAuth = { login: user.login, createdAt: new Date().toISOString() };
|
||||||
if (cipher?.isAvailable()) {
|
if (cipher?.isAvailable()) {
|
||||||
auth.tokenEncrypted = cipher.encrypt(body.access_token);
|
auth.tokenEncrypted = cipher.encrypt(accessToken);
|
||||||
} else {
|
} else {
|
||||||
auth.token = body.access_token;
|
auth.token = accessToken;
|
||||||
auth.plaintext = true;
|
auth.plaintext = true;
|
||||||
}
|
}
|
||||||
await writeAuth(auth);
|
await writeAuth(auth);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue