Archived
fix(auth): preserve OAuth interaction semantics
This commit is contained in:
@@ -1,8 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
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", () => {
|
||||
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", () => {
|
||||
expect(parsePiWebConfigResponse({
|
||||
path: "/tmp/config.json",
|
||||
|
||||
@@ -392,6 +392,7 @@ export function parseOAuthFlowState(value: unknown): OAuthFlowState {
|
||||
...optionalField("auth", optionalOAuthAuth(record["auth"])),
|
||||
...optionalField("prompt", optionalOAuthPrompt(record["prompt"])),
|
||||
...optionalField("select", optionalOAuthSelect(record["select"])),
|
||||
...optionalField("info", optionalOAuthInfo(record["info"])),
|
||||
};
|
||||
return flow;
|
||||
}
|
||||
@@ -404,7 +405,21 @@ function parseOAuthFlowStatus(value: unknown): OAuthFlowState["status"] {
|
||||
function optionalOAuthAuth(value: unknown): OAuthFlowState["auth"] | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
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 {
|
||||
@@ -412,7 +427,20 @@ function optionalOAuthPrompt(value: unknown): OAuthFlowState["prompt"] | undefin
|
||||
const record = requireRecord(value);
|
||||
const kind = requireString(record, "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 {
|
||||
@@ -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"]) };
|
||||
}
|
||||
|
||||
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 {
|
||||
if (value === undefined) return {};
|
||||
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 { customElement, property, query } from "lit/decorators.js";
|
||||
import type { AuthDialogState } from "../appState";
|
||||
import type { AuthProviderOption } from "../api";
|
||||
import type { AuthProviderOption, OAuthFlowState } from "../api";
|
||||
import { commandPickerStyles } from "./shared";
|
||||
|
||||
@customElement("auth-dialog")
|
||||
@@ -86,22 +86,35 @@ export class AuthDialog extends LitElement {
|
||||
const flow = state.flow;
|
||||
const prompt = flow.prompt;
|
||||
const select = flow.select;
|
||||
const promptInputType = oauthPromptInputType(prompt?.promptType);
|
||||
return html`
|
||||
<div class="form">
|
||||
${flow.auth !== undefined ? html`
|
||||
<p>Open this authorization link:</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>`}
|
||||
${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`
|
||||
<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>
|
||||
` : null}
|
||||
${select !== undefined ? html`
|
||||
<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}
|
||||
${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}
|
||||
@@ -157,11 +170,18 @@ export class AuthDialog extends LitElement {
|
||||
.warning { color: var(--pi-warning); }
|
||||
.error-text { color: var(--pi-danger); }
|
||||
.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 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; }
|
||||
`];
|
||||
}
|
||||
|
||||
export function oauthPromptInputType(promptType: NonNullable<OAuthFlowState["prompt"]>["promptType"]): "text" | "password" {
|
||||
return promptType === "secret" ? "password" : "text";
|
||||
}
|
||||
|
||||
function authTypeLabel(authType: "oauth" | "api_key"): string {
|
||||
return authType === "oauth" ? "subscription" : "API key";
|
||||
}
|
||||
|
||||
@@ -43,6 +43,26 @@ describe("AuthController", () => {
|
||||
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 () => {
|
||||
const flow = oauthFlow({ prompt: { requestId: "request-1", message: "Paste callback", kind: "manual" } });
|
||||
const { controller, getState } = createController(
|
||||
|
||||
Reference in New Issue
Block a user