fix(auth): preserve OAuth interaction semantics

This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 23:34:58 +02:00
parent a39cf49f3a
commit 3a208e648e
8 changed files with 387 additions and 66 deletions
+43 -1
View File
@@ -1,8 +1,50 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities"; import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers"; import { parseCommandResult, parseFileContentResponse, parseFileSuggestion, parseGitStatusResponse, parseMachineRuntime, parseMessagePage, parseOAuthFlowState, parsePiPackageMutationResponse, parsePiPackagesResponse, parsePiWebConfigResponse, parsePiWebPluginsResponse, parsePiWebRuntimeResponse, parsePiWebStatusResponse, parseSessionBulkArchiveResponse, parseSessionBulkDeleteArchivedResponse, parseSessionCleanupExecuteResponse, parseSessionCleanupPreviewResponse, parseSessionInfo, parseSessionStatus, parseSessionStreamSnapshot, parseSlashCommand, parseTerminalCommandRun, parseTerminalInfo, parseWorkspace, parseWorkspaceActivityResponse } from "./parsers";
describe("API parsers", () => { describe("API parsers", () => {
it("preserves additive OAuth interaction semantics", () => {
expect(parseOAuthFlowState({
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
auth: {
url: "https://example.test/device",
instructions: "Enter code",
deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 },
},
prompt: { requestId: "prompt-1", message: "Secret", kind: "prompt", promptType: "secret", allowEmpty: false, placeholder: "token" },
select: { requestId: "select-1", message: "Choose", options: [{ value: "work", label: "Work", description: "Company account" }] },
progress: ["Read the guide"],
info: [{ message: "Read the guide", links: [{ url: "https://example.test/docs", label: "Guide" }] }],
})).toMatchObject({
auth: { deviceCode: { userCode: "ABCD", intervalSeconds: 5, expiresInSeconds: 900 } },
prompt: { kind: "prompt", promptType: "secret", allowEmpty: false },
select: { options: [{ value: "work", description: "Company account" }] },
info: [{ links: [{ url: "https://example.test/docs", label: "Guide" }] }],
});
});
it("defaults semantic prompt types from legacy OAuth wire kinds", () => {
const flow = {
flowId: "flow-1",
providerId: "provider",
providerName: "Provider",
status: "running",
progress: [],
};
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "text", message: "Value", kind: "prompt" } }).prompt).toMatchObject({
kind: "prompt",
promptType: "text",
});
expect(parseOAuthFlowState({ ...flow, prompt: { requestId: "manual", message: "Code", kind: "manual" } }).prompt).toMatchObject({
kind: "manual",
promptType: "manual_code",
});
});
it("parses PI WEB config responses", () => { it("parses PI WEB config responses", () => {
expect(parsePiWebConfigResponse({ expect(parsePiWebConfigResponse({
path: "/tmp/config.json", path: "/tmp/config.json",
+46 -2
View File
@@ -392,6 +392,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState {
...optionalField("auth", optionalOAuthAuth(record["auth"])), ...optionalField("auth", optionalOAuthAuth(record["auth"])),
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])), ...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
...optionalField("select", optionalOAuthSelect(record["select"])), ...optionalField("select", optionalOAuthSelect(record["select"])),
...optionalField("info", optionalOAuthInfo(record["info"])),
}; };
return flow; return flow;
} }
@@ -404,7 +405,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined { function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
if (value === undefined) return undefined; if (value === undefined) return undefined;
const record = requireRecord(value); const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("instructions", optionalString(record, "instructions")) }; return {
url: requireString(record, "url"),
...optionalField("instructions", optionalString(record, "instructions")),
...optionalField("deviceCode", optionalOAuthDeviceCode(record["deviceCode"])),
};
}
function optionalOAuthDeviceCode(value: unknown): NonNullable<OAuthFlowState["auth"]>["deviceCode"] | undefined {
if (value === undefined) return undefined;
const record = requireRecord(value);
return {
userCode: requireString(record, "userCode"),
...optionalField("intervalSeconds", optionalNumber(record, "intervalSeconds")),
...optionalField("expiresInSeconds", optionalNumber(record, "expiresInSeconds")),
};
} }
function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined { function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefined {
@@ -412,7 +427,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin
const record = requireRecord(value); const record = requireRecord(value);
const kind = requireString(record, "kind"); const kind = requireString(record, "kind");
if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind"); if (kind !== "prompt" && kind !== "manual") throw new Error("Invalid OAuth prompt kind");
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), kind, ...optionalField("placeholder", optionalString(record, "placeholder")), ...(record["allowEmpty"] === true ? { allowEmpty: true } : {}) }; const promptType = record["promptType"] === undefined ? (kind === "manual" ? "manual_code" : "text") : parseOAuthPromptType(record["promptType"]);
return {
requestId: requireString(record, "requestId"),
message: requireString(record, "message"),
kind,
promptType,
...optionalField("placeholder", optionalString(record, "placeholder")),
...optionalField("allowEmpty", optionalBoolean(record, "allowEmpty")),
};
}
function parseOAuthPromptType(value: unknown): "text" | "secret" | "manual_code" {
if (value !== "text" && value !== "secret" && value !== "manual_code") throw new Error("Invalid OAuth prompt type");
return value;
} }
function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined { function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefined {
@@ -421,6 +449,22 @@ function optionalOAuthSelect(value: unknown): OAuthFlowState["select"] | undefin
return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) }; return { requestId: requireString(record, "requestId"), message: requireString(record, "message"), options: arrayOf(parseCommandOption)(record["options"]) };
} }
function optionalOAuthInfo(value: unknown): OAuthFlowState["info"] | undefined {
if (value === undefined) return undefined;
return arrayOf((item) => {
const record = requireRecord(item);
return {
message: requireString(record, "message"),
...optionalField("links", record["links"] === undefined ? undefined : arrayOf(parseOAuthInfoLink)(record["links"])),
};
})(value);
}
function parseOAuthInfoLink(value: unknown): NonNullable<NonNullable<OAuthFlowState["info"]>[number]["links"]>[number] {
const record = requireRecord(value);
return { url: requireString(record, "url"), ...optionalField("label", optionalString(record, "label")) };
}
function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object { function optionalContextUsage(value: unknown): Pick<SessionStatus, "contextUsage"> | object {
if (value === undefined) return {}; if (value === undefined) return {};
const record = requireRecord(value); const record = requireRecord(value);
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { oauthPromptInputType } from "./AuthDialog";
describe("oauthPromptInputType", () => {
it("renders additive secret prompts as password inputs and defaults legacy prompts to text", () => {
expect(oauthPromptInputType("secret")).toBe("password");
expect(oauthPromptInputType("text")).toBe("text");
expect(oauthPromptInputType("manual_code")).toBe("text");
expect(oauthPromptInputType(undefined)).toBe("text");
});
});
+24 -4
View File
@@ -1,7 +1,7 @@
import { LitElement, css, html } from "lit"; import { LitElement, css, html } from "lit";
import { customElement, property, query } from "lit/decorators.js"; import { customElement, property, query } from "lit/decorators.js";
import type { AuthDialogState } from "../appState"; import type { AuthDialogState } from "../appState";
import type { AuthProviderOption } from "../api"; import type { AuthProviderOption, OAuthFlowState } from "../api";
import { commandPickerStyles } from "./shared"; import { commandPickerStyles } from "./shared";
@customElement("auth-dialog") @customElement("auth-dialog")
@@ -86,22 +86,35 @@ export class AuthDialog extends LitElement {
const flow = state.flow; const flow = state.flow;
const prompt = flow.prompt; const prompt = flow.prompt;
const select = flow.select; const select = flow.select;
const promptInputType = oauthPromptInputType(prompt?.promptType);
return html` return html`
<div class="form"> <div class="form">
${flow.auth !== undefined ? html` ${flow.auth !== undefined ? html`
<p>Open this authorization link:</p> <p>Open this authorization link:</p>
<p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p> <p><a href=${flow.auth.url} target="_blank" rel="noreferrer">${flow.auth.url}</a></p>
${flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null} ${flow.auth.deviceCode !== undefined ? html`
<p class="warning">Enter code: <code>${flow.auth.deviceCode.userCode}</code></p>
` : flow.auth.instructions !== undefined ? html`<p class="warning">${flow.auth.instructions}</p>` : null}
` : html`<p>Starting login flow…</p>`} ` : html`<p>Starting login flow…</p>`}
${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null} ${flow.progress.length > 0 ? html`<ul class="progress">${flow.progress.map((line) => html`<li>${line}</li>`)}</ul>` : null}
${flow.info?.map((item) => item.links === undefined || item.links.length === 0 ? null : html`
<div class="info-links" aria-label="Related information">
${item.links.map((link) => html`<a href=${link.url} target="_blank" rel="noreferrer" title=${item.message}>${link.label ?? link.url}</a>`)}
</div>
`) ?? null}
${prompt !== undefined ? html` ${prompt !== undefined ? html`
<label>${prompt.message}</label> <label>${prompt.message}</label>
<input .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}> <input type=${promptInputType} autocomplete=${promptInputType === "password" ? "off" : "on"} .value=${state.inputValue ?? ""} placeholder=${prompt.placeholder ?? ""} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onOAuthInput?.(event.target.value); }}>
<div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div> <div class="actions"><button @click=${() => { this.onOAuthCancel?.(); }}>Cancel</button><button class="primary" ?disabled=${state.responding === true} @click=${() => { this.onOAuthRespond?.(); }}>Submit</button></div>
` : null} ` : null}
${select !== undefined ? html` ${select !== undefined ? html`
<p>${select.message}</p> <p>${select.message}</p>
<div class="inline-options">${select.options.map((option) => html`<button @click=${() => { this.onOAuthRespond?.(option.value); }}>${option.label}</button>`)}</div> <div class="inline-options">${select.options.map((option) => html`
<button @click=${() => { this.onOAuthRespond?.(option.value); }}>
<span>${option.label}</span>
${option.description === undefined ? null : html`<small>${option.description}</small>`}
</button>
`)}</div>
` : null} ` : null}
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null} ${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null} ${flow.status === "error" || flow.status === "cancelled" ? html`<div class="error-text">${flow.error ?? flow.status}</div><div class="actions"><button @click=${() => { this.cancel(); }}>Close</button></div>` : null}
@@ -157,11 +170,18 @@ export class AuthDialog extends LitElement {
.warning { color: var(--pi-warning); } .warning { color: var(--pi-warning); }
.error-text { color: var(--pi-danger); } .error-text { color: var(--pi-danger); }
.progress { margin: 0; padding-left: 18px; color: var(--pi-muted); } .progress { margin: 0; padding-left: 18px; color: var(--pi-muted); }
.info-links { display: flex; flex-wrap: wrap; gap: 8px 12px; }
.inline-options { display: grid; gap: 8px; } .inline-options { display: grid; gap: 8px; }
.inline-options button { display: grid; gap: 2px; text-align: left; }
.inline-options small { color: var(--pi-muted); }
em { color: var(--pi-success); font-style: normal; font-size: 12px; } em { color: var(--pi-success); font-style: normal; font-size: 12px; }
`]; `];
} }
export function oauthPromptInputType(promptType: NonNullable<OAuthFlowState["prompt"]>["promptType"]): "text" | "password" {
return promptType === "secret" ? "password" : "text";
}
function authTypeLabel(authType: "oauth" | "api_key"): string { function authTypeLabel(authType: "oauth" | "api_key"): string {
return authType === "oauth" ? "subscription" : "API key"; return authType === "oauth" ? "subscription" : "API key";
} }
@@ -43,6 +43,26 @@ describe("AuthController", () => {
expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true }); expect(getState().authDialog).toMatchObject({ step: "oauth", inputValue: "https://callback", responding: true });
}); });
it("submits an allowed blank OAuth text response without client-side rejection", async () => {
const flow = oauthFlow({
prompt: { requestId: "request-1", message: "GitHub Enterprise URL/domain (blank for github.com)", kind: "prompt", promptType: "text", allowEmpty: true },
});
const respondCalls: string[] = [];
const { controller } = createController(
{ authDialog: { step: "oauth", flow, inputValue: "" } },
{
respondOAuthFlow: (_flowId, _requestId, value) => {
respondCalls.push(value);
return Promise.resolve(oauthFlow({ status: "complete" }));
},
},
);
await controller.respondOAuth();
expect(respondCalls).toEqual([""]);
});
it("resets OAuth prompt input and submit state when the request id changes", async () => { it("resets OAuth prompt input and submit state when the request id changes", async () => {
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } }); const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
const { controller, getState } = createController( const { controller, getState } = createController(
@@ -29,7 +29,7 @@ describe("OAuthLoginFlowService", () => {
const prompt = state.prompt; const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected prompt"); if (prompt === undefined) throw new Error("Expected prompt");
expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] }); expect(state).toMatchObject({ auth: { url: "https://example.test/auth", instructions: "Open it" }, progress: ["Waiting for code"] });
expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt" }); expect(prompt).toMatchObject({ message: "Paste code", placeholder: "code", kind: "prompt", promptType: "text", allowEmpty: true });
const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123"); const afterRespond = service.respond(state.flowId, prompt.requestId, "abc123");
expect(afterRespond.prompt).toBeUndefined(); expect(afterRespond.prompt).toBeUndefined();
@@ -41,18 +41,103 @@ describe("OAuthLoginFlowService", () => {
service.dispose(); service.dispose();
}); });
it("allows blank text responses for providers that use blank as a default", async () => {
let domain: string | undefined;
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "github-copilot",
providerName: "GitHub Copilot",
runtime: fakeRuntime(async (_providerId, interaction) => {
domain = await interaction.prompt({
type: "text",
message: "GitHub Enterprise URL/domain (blank for github.com)",
});
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected text prompt");
expect(prompt).toMatchObject({ kind: "prompt", promptType: "text", allowEmpty: true });
service.respond(state.flowId, prompt.requestId, "");
await flushAsyncLogin();
expect(domain).toBe("");
expect(service.get(state.flowId).status).toBe("complete");
service.dispose();
});
it("preserves secret prompt semantics behind the legacy prompt kind", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
await interaction.prompt({ type: "secret", message: "Enter secret", placeholder: "token" });
}),
});
const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected secret prompt");
expect(prompt).toMatchObject({
kind: "prompt",
promptType: "secret",
message: "Enter secret",
placeholder: "token",
});
expect(prompt).not.toHaveProperty("allowEmpty");
expect(() => { service.respond(state.flowId, prompt.requestId, ""); }).toThrow("A value is required");
service.dispose();
});
it("preserves info-event links without replacing the authorization URL", () => {
const service = new OAuthLoginFlowService();
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
interaction.notify({ type: "auth_url", url: "https://example.test/login" });
interaction.notify({
type: "info",
message: "Review the provider setup guide",
links: [{ url: "https://example.test/docs", label: "Setup guide" }],
});
await interaction.prompt({ type: "text", message: "Continue" });
}),
});
expect(state).toMatchObject({
auth: { url: "https://example.test/login" },
progress: ["Review the provider setup guide"],
info: [{ message: "Review the provider setup guide", links: [{ url: "https://example.test/docs", label: "Setup guide" }] }],
});
service.dispose();
});
it("surfaces device-code events through the auth field", () => { it("surfaces device-code events through the auth field", () => {
const service = new OAuthLoginFlowService(); const service = new OAuthLoginFlowService();
const state = service.start({ const state = service.start({
providerId: "test-provider", providerId: "test-provider",
providerName: "Test Provider", providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => { runtime: fakeRuntime(async (_providerId, interaction) => {
interaction.notify({ type: "device_code", userCode: "WXYZ-1234", verificationUri: "https://example.test/device" }); interaction.notify({
type: "device_code",
userCode: "WXYZ-1234",
verificationUri: "https://example.test/device",
intervalSeconds: 5,
expiresInSeconds: 900,
});
await interaction.prompt({ type: "text", message: "Waiting" }); await interaction.prompt({ type: "text", message: "Waiting" });
}), }),
}); });
expect(service.get(state.flowId)).toMatchObject({ auth: { url: "https://example.test/device", instructions: "Enter code: WXYZ-1234" } }); expect(service.get(state.flowId)).toMatchObject({
auth: {
url: "https://example.test/device",
instructions: "Enter code: WXYZ-1234",
deviceCode: { userCode: "WXYZ-1234", intervalSeconds: 5, expiresInSeconds: 900 },
},
});
service.dispose(); service.dispose();
}); });
@@ -66,14 +151,14 @@ describe("OAuthLoginFlowService", () => {
selectedValue = await interaction.prompt({ selectedValue = await interaction.prompt({
type: "select", type: "select",
message: "Choose account", message: "Choose account",
options: [{ id: "work", label: "Work" }, { id: "personal", label: "Personal" }], options: [{ id: "work", label: "Work", description: "Company account" }, { id: "personal", label: "Personal" }],
}); });
}), }),
}); });
const select = state.select; const select = state.select;
if (select === undefined) throw new Error("Expected select prompt"); if (select === undefined) throw new Error("Expected select prompt");
expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work" }, { value: "personal", label: "Personal" }] }); expect(select).toMatchObject({ message: "Choose account", options: [{ value: "work", label: "Work", description: "Company account" }, { value: "personal", label: "Personal" }] });
service.respond(state.flowId, select.requestId, "personal"); service.respond(state.flowId, select.requestId, "personal");
await flushAsyncLogin(); await flushAsyncLogin();
@@ -83,25 +168,53 @@ describe("OAuthLoginFlowService", () => {
service.dispose(); service.dispose();
}); });
it("uses a manual-code prompt for callback-server flows", async () => { it("rejects responses outside the pending select options", () => {
let manualValue: string | undefined;
const service = new OAuthLoginFlowService(); const service = new OAuthLoginFlowService();
const state = service.start({ const state = service.start({
providerId: "test-provider", providerId: "test-provider",
providerName: "Test Provider", providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => { runtime: fakeRuntime(async (_providerId, interaction) => {
manualValue = await interaction.prompt({ type: "manual_code", message: "Paste the callback URL or authorization code" }); await interaction.prompt({
type: "select",
message: "Choose account",
options: [{ id: "work", label: "Work" }],
});
}),
});
const select = state.select;
if (select === undefined) throw new Error("Expected select prompt");
expect(() => { service.respond(state.flowId, select.requestId, "personal"); }).toThrow("Invalid OAuth selection");
expect(service.get(state.flowId).select).toEqual(select);
service.dispose();
});
it("uses a manual-code prompt for callback-server flows and cleans up its abort listener", async () => {
let manualValue: string | undefined;
const service = new OAuthLoginFlowService();
const controller = new AbortController();
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
const state = service.start({
providerId: "test-provider",
providerName: "Test Provider",
runtime: fakeRuntime(async (_providerId, interaction) => {
manualValue = await interaction.prompt({
type: "manual_code",
message: "Paste the callback URL or authorization code",
signal: controller.signal,
});
}), }),
}); });
const prompt = state.prompt; const prompt = state.prompt;
if (prompt === undefined) throw new Error("Expected manual prompt"); if (prompt === undefined) throw new Error("Expected manual prompt");
expect(prompt).toMatchObject({ kind: "manual", message: "Paste the callback URL or authorization code" }); expect(prompt).toMatchObject({ kind: "manual", promptType: "manual_code", message: "Paste the callback URL or authorization code" });
service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc"); service.respond(state.flowId, prompt.requestId, "https://localhost/callback?code=abc");
await flushAsyncLogin(); await flushAsyncLogin();
expect(manualValue).toBe("https://localhost/callback?code=abc"); expect(manualValue).toBe("https://localhost/callback?code=abc");
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
expect(service.get(state.flowId).status).toBe("complete"); expect(service.get(state.flowId).status).toBe("complete");
service.dispose(); service.dispose();
}); });
@@ -110,6 +223,7 @@ describe("OAuthLoginFlowService", () => {
const promptRejected = deferred<Error>(); const promptRejected = deferred<Error>();
const service = new OAuthLoginFlowService(); const service = new OAuthLoginFlowService();
const controller = new AbortController(); const controller = new AbortController();
const removeAbortListener = vi.spyOn(controller.signal, "removeEventListener");
const state = service.start({ const state = service.start({
providerId: "test-provider", providerId: "test-provider",
providerName: "Test Provider", providerName: "Test Provider",
@@ -128,6 +242,7 @@ describe("OAuthLoginFlowService", () => {
expect(state.prompt).toMatchObject({ kind: "manual" }); expect(state.prompt).toMatchObject({ kind: "manual" });
controller.abort(); controller.abort();
await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" }); await expect(promptRejected.promise).resolves.toMatchObject({ message: "Prompt cancelled" });
expect(removeAbortListener).toHaveBeenCalledWith("abort", expect.any(Function));
const afterAbort = service.get(state.flowId); const afterAbort = service.get(state.flowId);
expect(afterAbort.status).toBe("running"); expect(afterAbort.status).toBe("running");
+104 -48
View File
@@ -6,12 +6,16 @@ import type { CommandOption, OAuthFlowState } from "../../shared/apiTypes.js";
/** The single runtime capability this service drives — narrowed for testable DI. */ /** The single runtime capability this service drives — narrowed for testable DI. */
type OAuthLoginRuntime = Pick<ModelRuntime, "login">; type OAuthLoginRuntime = Pick<ModelRuntime, "login">;
type TimerHandle = ReturnType<typeof setTimeout>; type TimerHandle = ReturnType<typeof setTimeout>;
type SelectPrompt = Extract<AuthPrompt, { type: "select" }>;
type ValuePrompt = Exclude<AuthPrompt, { type: "select" }>;
interface PendingOAuthRequest { interface PendingOAuthRequest {
requestId: string; requestId: string;
allowEmpty: boolean; allowEmpty: boolean;
resolve: (value: string) => void; resolve: (value: string) => void;
reject: (error: Error) => void; reject: (error: Error) => void;
allowedValues?: ReadonlySet<string>;
cleanup?: () => void;
} }
interface OAuthFlowRecord { interface OAuthFlowRecord {
@@ -79,13 +83,13 @@ export class OAuthLoginFlowService {
void options.runtime.login(options.providerId, "oauth", interaction) void options.runtime.login(options.providerId, "oauth", interaction)
.then(() => { .then(() => {
if (!this.isCurrentRunning(record)) return; if (!this.isCurrentRunning(record)) return;
record.pending = undefined; this.clearPending(record);
this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] }); this.markTerminal(record, { ...withoutInteraction(record.state), status: "complete", progress: [...record.state.progress, "Login complete"] });
options.onComplete?.(); options.onComplete?.();
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (this.flows.get(record.flowId) !== record) return; if (this.flows.get(record.flowId) !== record) return;
record.pending = undefined; this.clearPending(record);
if (record.state.status !== "running") return; if (record.state.status !== "running") return;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) }); this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: error instanceof Error ? error.message : String(error) });
}); });
@@ -106,7 +110,8 @@ export class OAuthLoginFlowService {
const pending = record.pending; const pending = record.pending;
if (pending?.requestId !== requestId) throw new Error("OAuth login request expired"); if (pending?.requestId !== requestId) throw new Error("OAuth login request expired");
if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required"); if (!pending.allowEmpty && value.trim() === "") throw new Error("A value is required");
record.pending = undefined; if (pending.allowedValues !== undefined && !pending.allowedValues.has(value)) throw new Error("Invalid OAuth selection");
this.clearPending(record);
this.updateState(record, withoutInteraction(record.state)); this.updateState(record, withoutInteraction(record.state));
pending.resolve(value); pending.resolve(value);
return cloneState(record.state); return cloneState(record.state);
@@ -117,8 +122,7 @@ export class OAuthLoginFlowService {
if (record === undefined) throw new Error("OAuth login flow not found"); if (record === undefined) throw new Error("OAuth login flow not found");
if (record.state.status === "running") { if (record.state.status === "running") {
record.abort.abort(); record.abort.abort();
const pending = record.pending; const pending = this.clearPending(record);
record.pending = undefined;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" }); this.markTerminal(record, { ...withoutInteraction(record.state), status: "cancelled", error: "Login cancelled" });
pending?.reject(new Error("Login cancelled")); pending?.reject(new Error("Login cancelled"));
} }
@@ -129,25 +133,15 @@ export class OAuthLoginFlowService {
for (const record of this.flows.values()) { for (const record of this.flows.values()) {
this.clearTimer(record); this.clearTimer(record);
record.abort.abort(); record.abort.abort();
const pending = record.pending; const pending = this.clearPending(record);
record.pending = undefined;
pending?.reject(new Error("Login cancelled")); pending?.reject(new Error("Login cancelled"));
} }
this.flows.clear(); this.flows.clear();
} }
private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise<string> { private handlePrompt(record: OAuthFlowRecord, prompt: AuthPrompt): Promise<string> {
if (prompt.type === "select") { if (prompt.type === "select") return this.waitForSelect(record, prompt);
return this.waitForSelect(record, prompt.message, prompt.options, prompt.signal); return this.waitForPrompt(record, prompt);
}
// `manual_code` is the paste-back path for callback-server flows; text/secret
// are ordinary interactive entry. Both map to the single web-UI prompt shape.
const kind = prompt.type === "manual_code" ? "manual" : "prompt";
return this.waitForPrompt(record, {
message: prompt.message,
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
...(prompt.signal === undefined ? {} : { signal: prompt.signal }),
}, kind);
} }
private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void { private handleEvent(record: OAuthFlowRecord, event: AuthEvent): void {
@@ -156,75 +150,127 @@ export class OAuthLoginFlowService {
case "auth_url": case "auth_url":
this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } }); this.updateState(record, { ...record.state, auth: { url: event.url, ...(event.instructions === undefined ? {} : { instructions: event.instructions }) } });
return; return;
// Device-code flows have no redirect URL; reuse the auth field so the web UI // Keep the legacy auth URL/instructions while adding structured metadata
// shows the verification link and user code without a dedicated API shape. // that newer browsers can use during rolling sessiond upgrades.
case "device_code": case "device_code":
this.updateState(record, { ...record.state, auth: { url: event.verificationUri, instructions: `Enter code: ${event.userCode}` } }); this.updateState(record, {
...record.state,
auth: {
url: event.verificationUri,
instructions: `Enter code: ${event.userCode}`,
deviceCode: {
userCode: event.userCode,
...(event.intervalSeconds === undefined ? {} : { intervalSeconds: event.intervalSeconds }),
...(event.expiresInSeconds === undefined ? {} : { expiresInSeconds: event.expiresInSeconds }),
},
},
});
return; return;
case "info": case "info":
this.updateState(record, {
...record.state,
progress: [...record.state.progress, event.message],
info: [
...(record.state.info ?? []),
{
message: event.message,
...(event.links === undefined ? {} : {
links: event.links.map((link) => ({
url: link.url,
...(link.label === undefined ? {} : { label: link.label }),
})),
}),
},
],
});
return;
case "progress": case "progress":
this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] }); this.updateState(record, { ...record.state, progress: [...record.state.progress, event.message] });
return; return;
} }
} }
private waitForPrompt(record: OAuthFlowRecord, prompt: { message: string; placeholder?: string; signal?: AbortSignal }, kind: "prompt" | "manual"): Promise<string> { private waitForPrompt(record: OAuthFlowRecord, prompt: ValuePrompt): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) { if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled")); reject(new Error("Login cancelled"));
return; return;
} }
if (prompt.signal?.aborted === true) {
reject(new Error("Prompt cancelled"));
return;
}
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
record.pending = { requestId, allowEmpty: false, resolve, reject }; const pending: PendingOAuthRequest = {
this.bindPromptSignal(record, requestId, prompt.signal); requestId,
allowEmpty: prompt.type === "text",
resolve,
reject,
};
record.pending = pending;
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state); const base = withoutInteraction(record.state);
this.updateState(record, { this.updateState(record, {
...base, ...base,
prompt: { prompt: {
requestId, requestId,
message: prompt.message, message: prompt.message,
kind, kind: prompt.type === "manual_code" ? "manual" : "prompt",
promptType: prompt.type,
...(prompt.type === "text" ? { allowEmpty: true } : {}),
...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }), ...(prompt.placeholder === undefined ? {} : { placeholder: prompt.placeholder }),
}, },
}); });
}); });
} }
private waitForSelect(record: OAuthFlowRecord, message: string, promptOptions: readonly { id: string; label: string; description?: string }[], signal?: AbortSignal): Promise<string> { private waitForSelect(record: OAuthFlowRecord, prompt: SelectPrompt): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!this.isCurrentRunning(record)) { if (!this.isCurrentRunning(record)) {
reject(new Error("Login cancelled")); reject(new Error("Login cancelled"));
return; return;
} }
if (signal?.aborted === true) {
reject(new Error("Prompt cancelled"));
return;
}
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const options: CommandOption[] = promptOptions.map((option) => ({ value: option.id, label: option.label })); const options: CommandOption[] = prompt.options.map((option) => ({
record.pending = { requestId, allowEmpty: true, resolve, reject }; value: option.id,
this.bindPromptSignal(record, requestId, signal); label: option.label,
...(option.description === undefined ? {} : { description: option.description }),
}));
const pending: PendingOAuthRequest = {
requestId,
allowEmpty: false,
resolve,
reject,
allowedValues: new Set(options.map((option) => option.value)),
};
record.pending = pending;
if (!this.bindPromptSignal(record, pending, prompt.signal)) return;
const base = withoutInteraction(record.state); const base = withoutInteraction(record.state);
this.updateState(record, { ...base, select: { requestId, message, options } }); this.updateState(record, { ...base, select: { requestId, message: prompt.message, options } });
}); });
} }
// A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced // A prompt may carry its own AbortSignal (e.g. a manual_code prompt raced
// against a callback server). When it fires, drop just that pending request // against a callback server). When it fires, drop just that pending request
// and clear the interaction from state — the overall login keeps running. // and clear the interaction from state — the overall login keeps running.
private bindPromptSignal(record: OAuthFlowRecord, requestId: string, signal?: AbortSignal): void { private bindPromptSignal(record: OAuthFlowRecord, pending: PendingOAuthRequest, signal?: AbortSignal): boolean {
if (signal === undefined) return; if (signal === undefined) return true;
signal.addEventListener("abort", () => { const onAbort = () => {
const pending = record.pending; if (record.pending !== pending) return;
if (pending?.requestId !== requestId) return; this.clearPending(record);
record.pending = undefined;
if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state)); if (this.isCurrentRunning(record)) this.updateState(record, withoutInteraction(record.state));
pending.reject(new Error("Prompt cancelled")); pending.reject(new Error("Prompt cancelled"));
}, { once: true }); };
pending.cleanup = () => { signal.removeEventListener("abort", onAbort); };
signal.addEventListener("abort", onAbort, { once: true });
if (signal.aborted) {
onAbort();
return false;
}
return true;
}
private clearPending(record: OAuthFlowRecord): PendingOAuthRequest | undefined {
const pending = record.pending;
record.pending = undefined;
pending?.cleanup?.();
return pending;
} }
private isCurrentRunning(record: OAuthFlowRecord): boolean { private isCurrentRunning(record: OAuthFlowRecord): boolean {
@@ -270,8 +316,7 @@ export class OAuthLoginFlowService {
private expireRunningFlow(record: OAuthFlowRecord): void { private expireRunningFlow(record: OAuthFlowRecord): void {
if (!this.isCurrentRunning(record)) return; if (!this.isCurrentRunning(record)) return;
record.abort.abort(); record.abort.abort();
const pending = record.pending; const pending = this.clearPending(record);
record.pending = undefined;
this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" }); this.markTerminal(record, { ...withoutInteraction(record.state), status: "error", error: "OAuth login flow expired" });
pending?.reject(new Error("OAuth login flow expired")); pending?.reject(new Error("OAuth login flow expired"));
} }
@@ -300,9 +345,20 @@ function cloneState(state: OAuthFlowState): OAuthFlowState {
return { return {
...state, ...state,
progress: [...state.progress], progress: [...state.progress],
...(state.auth === undefined ? {} : { auth: { ...state.auth } }), ...(state.auth === undefined ? {} : {
auth: {
...state.auth,
...(state.auth.deviceCode === undefined ? {} : { deviceCode: { ...state.auth.deviceCode } }),
},
}),
...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }), ...(state.prompt === undefined ? {} : { prompt: { ...state.prompt } }),
...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }), ...(state.select === undefined ? {} : { select: { ...state.select, options: state.select.options.map((option) => ({ ...option })) } }),
...(state.info === undefined ? {} : {
info: state.info.map((item) => ({
...item,
...(item.links === undefined ? {} : { links: item.links.map((link) => ({ ...link })) }),
})),
}),
}; };
} }
+15 -2
View File
@@ -384,10 +384,23 @@ export interface OAuthFlowState {
providerId: string; providerId: string;
providerName: string; providerName: string;
status: "running" | "complete" | "error" | "cancelled"; status: "running" | "complete" | "error" | "cancelled";
auth?: { url: string; instructions?: string }; auth?: {
prompt?: { requestId: string; message: string; placeholder?: string; allowEmpty?: boolean; kind: "prompt" | "manual" }; url: string;
instructions?: string;
deviceCode?: { userCode: string; intervalSeconds?: number; expiresInSeconds?: number };
};
prompt?: {
requestId: string;
message: string;
placeholder?: string;
allowEmpty?: boolean;
/** Additive semantic detail; legacy peers continue to use `kind`. */
promptType?: "text" | "secret" | "manual_code";
kind: "prompt" | "manual";
};
select?: { requestId: string; message: string; options: CommandOption[] }; select?: { requestId: string; message: string; options: CommandOption[] };
progress: string[]; progress: string[];
info?: { message: string; links?: { url: string; label?: string }[] }[];
error?: string; error?: string;
} }