Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 61 additions & 3 deletions cli/src/commands/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1122,6 +1122,26 @@ async fn run_oauth_add(

// ---- Device code add flow (I22) ----

fn parse_device_code_deadline(initiate: &Value) -> std::time::Instant {
use chrono::DateTime;

if let Some(s) = initiate["expires_at"].as_str()
&& let Ok(at) = DateTime::parse_from_rfc3339(s)
{
let secs = (at.timestamp() - chrono::Utc::now().timestamp()).max(0) as u64;
return std::time::Instant::now() + std::time::Duration::from_secs(secs);
}

if let Some(secs) = initiate["expires_in"]
.as_u64()
.or_else(|| initiate["expires_in"].as_str().and_then(|s| s.parse().ok()))
{
return std::time::Instant::now() + std::time::Duration::from_secs(secs);
}

std::time::Instant::now() + std::time::Duration::from_secs(15 * 60)
}

async fn run_device_code_add(
api: &mut ApiClient,
slug: Option<String>,
Expand Down Expand Up @@ -1208,6 +1228,7 @@ async fn run_device_code_add(
.as_u64()
.or_else(|| initiate["interval"].as_str().and_then(|s| s.parse().ok()))
.unwrap_or(5);
let deadline = parse_device_code_deadline(&initiate);

eprintln!("Device Code Authorization");
eprintln!();
Expand All @@ -1220,13 +1241,15 @@ async fn run_device_code_add(

// Poll for completion
let poll_body = serde_json::json!({ "state": state });
let poll_path = format!("/providers/{provider_id}/connect/device-code/poll");
let mut consecutive_poll_errors = 0_u8;

loop {
while std::time::Instant::now() < deadline {
tokio::time::sleep(std::time::Duration::from_secs(interval)).await;

let poll_path = format!("/providers/{provider_id}/connect/device-code/poll");
match api.post::<Value, _>(&poll_path, &poll_body).await {
Ok(result) => {
consecutive_poll_errors = 0;
let status = result["status"].as_str().unwrap_or("");
if status == "complete"
|| status == "authorized"
Expand Down Expand Up @@ -1263,12 +1286,22 @@ async fn run_device_code_add(
std::io::stderr().flush()?;
}
Err(_) => {
// Treat errors during polling as "still pending"
consecutive_poll_errors += 1;
eprint!(".");
std::io::stderr().flush()?;
if consecutive_poll_errors >= 30 {
eprintln!();
bail!("device code polling failed repeatedly — check your network and re-run");
}
}
}
}

eprintln!();
bail!(
"Device code authorization timed out (the code may have expired or the request was denied).\n\
Re-run the command to start a new authorization."
);
}

async fn wait_for_authorized_key(api: &mut ApiClient, key_id: &str) -> Result<Value> {
Expand Down Expand Up @@ -1617,6 +1650,31 @@ mod tests {
assert!(!requires_credential_prompt("none", true));
}

#[test]
fn parse_device_code_deadline_uses_expires_at() {
let future = chrono::Utc::now() + chrono::Duration::seconds(600);
let v = serde_json::json!({ "expires_at": future.to_rfc3339() });
let deadline = parse_device_code_deadline(&v);
let secs = deadline.duration_since(std::time::Instant::now()).as_secs();
assert!((550..=650).contains(&secs), "got {secs}s");
}

#[test]
fn parse_device_code_deadline_uses_expires_in() {
let v = serde_json::json!({ "expires_in": 600 });
let deadline = parse_device_code_deadline(&v);
let secs = deadline.duration_since(std::time::Instant::now()).as_secs();
assert!((550..=650).contains(&secs), "got {secs}s");
}

#[test]
fn parse_device_code_deadline_falls_back_to_default() {
let v = serde_json::json!({});
let deadline = parse_device_code_deadline(&v);
let secs = deadline.duration_since(std::time::Instant::now()).as_secs();
assert!((850..=900).contains(&secs), "got {secs}s");
}

// ── Issue #414: explicit_scripted dispatch nuance ─────────────────

/// Test helper: build a clean "no flags" baseline so each test
Expand Down
2 changes: 1 addition & 1 deletion cli/src/wizard/assets/index.html

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cli/src/wizard/bundle-meta/index.hash
Original file line number Diff line number Diff line change
@@ -1 +1 @@
56f4f63078e728e5700f0c57bd4418ba2f02100ebb08a84591686aca9c402cd5
c34e46e38c8a81329ebeaa57905b492e6b0224884f80dd0acfce56493f599651
1 change: 1 addition & 0 deletions cli/src/wizard/bundle-meta/index.manifest
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
frontend/src/app.css
frontend/src/components/cli-wizard/access-scope-card.tsx
frontend/src/components/cli-wizard/ai-key-confirm-panel.tsx
frontend/src/components/cli-wizard/auth-flow-polling.ts
frontend/src/components/cli-wizard/auth-flows.tsx
frontend/src/components/cli-wizard/catalog-grid.tsx
frontend/src/components/cli-wizard/client.ts
Expand Down
67 changes: 67 additions & 0 deletions frontend/src/components/cli-wizard/auth-flow-polling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
export function isTerminalAuthFailureStatus(
status: string | undefined,
): boolean {
return status === "revoked" || status === "failed" || status === "expired";
}

interface PollOAuthKeyUntilActiveOptions {
readonly keyId: string;
readonly getKey: (keyId: string) => Promise<{ readonly status: string }>;
readonly completeWithKey: (keyId: string) => Promise<void>;
readonly isCancelled: () => boolean;
readonly onTerminalFailure: (status: string) => void;
readonly onTimeout: () => void;
readonly sleepMs?: (ms: number) => Promise<void>;
readonly nowMs?: () => number;
readonly timeoutMs?: number;
readonly intervalMs?: number;
}

export async function pollOAuthKeyUntilActive({
keyId,
getKey,
completeWithKey,
isCancelled,
onTerminalFailure,
onTimeout,
sleepMs = sleep,
nowMs = Date.now,
timeoutMs = 5 * 60 * 1000,
intervalMs = 2000,
}: PollOAuthKeyUntilActiveOptions): Promise<void> {
const deadline = nowMs() + timeoutMs;
while (nowMs() < deadline) {
if (isCancelled()) return;
await sleepMs(intervalMs);
if (isCancelled()) return;
try {
const key = await getKey(keyId);
if (key.status === "active") {
await completeWithKey(keyId);
return;
}
// Terminal failure statuses: when the backend eventually marks
// placeholders as `revoked` / `failed` on OAuth callback errors
// (e.g. user denial), this exits the poll immediately instead of
// waiting for the deadline. Today the backend leaves placeholders
// in `pending_auth` on deny so this branch is forward-compat.
if (isTerminalAuthFailureStatus(key.status)) {
if (!isCancelled()) {
onTerminalFailure(key.status);
}
return;
}
} catch {
// Transient; keep polling.
}
}
if (!isCancelled()) {
onTimeout();
}
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}
67 changes: 67 additions & 0 deletions frontend/src/components/cli-wizard/auth-flows.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import {
isTerminalAuthFailureStatus,
pollOAuthKeyUntilActive,
} from "./auth-flow-polling";

describe("cli wizard auth flows", () => {
it.each(["revoked", "failed", "expired"] as const)(
"treats %s as a terminal auth failure status",
(status) => {
expect(isTerminalAuthFailureStatus(status)).toBe(true);
},
);

it("does not treat active or pending_auth as terminal auth failures", () => {
expect(isTerminalAuthFailureStatus("active")).toBe(false);
expect(isTerminalAuthFailureStatus("pending_auth")).toBe(false);
expect(isTerminalAuthFailureStatus(undefined)).toBe(false);
});

it("stops OAuth polling when the placeholder reaches a terminal failure", async () => {
const getKey = vi
.fn()
.mockResolvedValueOnce({ status: "pending_auth" })
.mockResolvedValueOnce({ status: "revoked" });
const completeWithKey = vi.fn();
const onTerminalFailure = vi.fn();
const onTimeout = vi.fn();
const sleepMs = vi.fn().mockResolvedValue(undefined);

await pollOAuthKeyUntilActive({
keyId: "key-1",
getKey,
completeWithKey,
isCancelled: () => false,
onTerminalFailure,
onTimeout,
sleepMs,
});

expect(getKey).toHaveBeenCalledTimes(2);
expect(completeWithKey).not.toHaveBeenCalled();
expect(onTerminalFailure).toHaveBeenCalledWith("revoked");
expect(onTimeout).not.toHaveBeenCalled();
});

it("completes OAuth polling when the placeholder becomes active", async () => {
const getKey = vi.fn().mockResolvedValue({ status: "active" });
const completeWithKey = vi.fn().mockResolvedValue(undefined);
const onTerminalFailure = vi.fn();
const onTimeout = vi.fn();

await pollOAuthKeyUntilActive({
keyId: "key-1",
getKey,
completeWithKey,
isCancelled: () => false,
onTerminalFailure,
onTimeout,
sleepMs: vi.fn().mockResolvedValue(undefined),
});

expect(completeWithKey).toHaveBeenCalledWith("key-1");
expect(onTerminalFailure).not.toHaveBeenCalled();
expect(onTimeout).not.toHaveBeenCalled();
});
});
39 changes: 19 additions & 20 deletions frontend/src/components/cli-wizard/auth-flows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
reservePairingAction,
rewindPairingAction,
} from "@/pages/cli-pair/reserve-action";
import { pollOAuthKeyUntilActive } from "./auth-flow-polling";

interface FlowProps {
readonly providerId: string;
Expand Down Expand Up @@ -893,27 +894,25 @@ export function OAuthFlow({
}, [phase]);

async function pollUntilActive(keyId: string) {
const deadline = Date.now() + 5 * 60 * 1000;
while (Date.now() < deadline) {
if (cancelledRef.current) return;
await sleep(2000);
if (cancelledRef.current) return;
try {
const key = await api.get<ActiveKeyResponse>(
`/keys/${encodeURIComponent(keyId)}`,
await pollOAuthKeyUntilActive({
keyId,
getKey: (id) =>
api.get<ActiveKeyResponse>(`/keys/${encodeURIComponent(id)}`),
completeWithKey,
isCancelled: () => cancelledRef.current,
onTerminalFailure: () => {
setPhase("error");
setError(
"Authorization didn't complete (it may have been canceled or denied on the provider page). Cancel and re-run to try again.",
);
if (key.status === "active") {
await completeWithKey(keyId);
return;
}
} catch {
// Transient; keep polling.
}
}
if (!cancelledRef.current) {
setPhase("error");
setError("OAuth didn't complete within 5 minutes. Try again.");
}
},
onTimeout: () => {
setPhase("error");
setError(
"We didn't see authorization complete within 5 minutes. If you canceled on the provider page or it's taking longer than expected, cancel and re-run.",
);
},
});
}

async function completeWithKey(keyId: string) {
Expand Down
Loading