Archived
fix(auth): preserve OAuth interaction semantics
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user