fix(oauth): scope failPending to matching connectorId so cancelling one OAuth does not reject others
This commit is contained in:
1 parent
d8904c88e5
commit
cfe5f9ff73
1 file changed
+157
@@ -0,0 +1,157 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { shell } from "electron";
|
||||
import { getOAuthProvider, type OAuthLoginCallbacks } from "@mariozechner/pi-ai/oauth";
|
||||
import type { DesktopEvent } from "@shared/types";
|
||||
import { getConnectorDefinition } from "@main/connectors";
|
||||
import { CredentialStore } from "./credential-store";
|
||||
import type { OAuthCredentialPayload } from "./types";
|
||||
|
||||
interface PendingPrompt {
|
||||
connectorId: string;
|
||||
resolve: (value: string) => void;
|
||||
reject: (reason: Error) => void;
|
||||
}
|
||||
|
||||
export interface OAuthLoginServiceDeps {
|
||||
store: CredentialStore;
|
||||
emitEvent: (event: DesktopEvent) => void;
|
||||
/** Refresh the connector's health row after a successful login so its
|
||||
* `status` flips from `auth_required` to `ready` without waiting for the
|
||||
* next bootstrap pass. */
|
||||
refreshConnectorHealth: (connectorId: string) => Promise<void>;
|
||||
logger: { info: (...args: unknown[]) => void; warn: (...args: unknown[]) => void; error: (...args: unknown[]) => void };
|
||||
}
|
||||
|
||||
export class OAuthLoginService {
|
||||
private readonly active = new Map<string, AbortController>();
|
||||
private readonly prompts = new Map<string, PendingPrompt>();
|
||||
private readonly manualCodes = new Map<string, PendingPrompt>();
|
||||
|
||||
constructor(private readonly deps: OAuthLoginServiceDeps) {}
|
||||
|
||||
async start(connectorId: string): Promise<void> {
|
||||
const definition = getConnectorDefinition(connectorId);
|
||||
if (definition.auth.type !== "oauth") {
|
||||
throw new Error(`Connector ${connectorId} does not use OAuth.`);
|
||||
}
|
||||
const provider = getOAuthProvider(definition.auth.oauthId);
|
||||
if (!provider) {
|
||||
throw new Error(`Unknown OAuth provider: ${definition.auth.oauthId}`);
|
||||
}
|
||||
|
||||
if (this.active.has(connectorId)) {
|
||||
throw new Error(`OAuth login already in progress for ${connectorId}.`);
|
||||
}
|
||||
const controller = new AbortController();
|
||||
this.active.set(connectorId, controller);
|
||||
this.deps.emitEvent({ type: "oauth:status", connectorId, status: "in_progress" });
|
||||
|
||||
let lastAuthUrl: string | undefined;
|
||||
let lastAuthInstructions: string | undefined;
|
||||
|
||||
const callbacks: OAuthLoginCallbacks = {
|
||||
onAuth: ({ url, instructions }: { url: string; instructions?: string }) => {
|
||||
lastAuthUrl = url;
|
||||
lastAuthInstructions = instructions;
|
||||
void shell.openExternal(url);
|
||||
this.deps.emitEvent({
|
||||
type: "oauth:auth-url",
|
||||
connectorId,
|
||||
url,
|
||||
instructions,
|
||||
});
|
||||
},
|
||||
onProgress: (message: string) => {
|
||||
this.deps.emitEvent({ type: "oauth:progress", connectorId, message });
|
||||
},
|
||||
onPrompt: ({ message, placeholder, allowEmpty }: { message: string; placeholder?: string; allowEmpty?: boolean }) => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const requestId = randomUUID();
|
||||
this.prompts.set(requestId, { connectorId, resolve, reject });
|
||||
this.deps.emitEvent({
|
||||
type: "oauth:prompt-request",
|
||||
connectorId,
|
||||
requestId,
|
||||
message,
|
||||
placeholder,
|
||||
allowEmpty,
|
||||
});
|
||||
});
|
||||
},
|
||||
onManualCodeInput: () => {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const requestId = randomUUID();
|
||||
this.manualCodes.set(requestId, { connectorId, resolve, reject });
|
||||
this.deps.emitEvent({
|
||||
type: "oauth:manual-code-request",
|
||||
connectorId,
|
||||
requestId,
|
||||
authUrl: lastAuthUrl,
|
||||
instructions: lastAuthInstructions,
|
||||
});
|
||||
});
|
||||
},
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
try {
|
||||
const credentials = await provider.login(callbacks);
|
||||
const expiresAt = typeof credentials.expires === "number" && Number.isFinite(credentials.expires)
|
||||
? new Date(credentials.expires).toISOString()
|
||||
: null;
|
||||
this.deps.store.save(
|
||||
connectorId,
|
||||
{ kind: "oauth", value: credentials as OAuthCredentialPayload },
|
||||
expiresAt,
|
||||
);
|
||||
await this.deps.refreshConnectorHealth(connectorId);
|
||||
this.deps.emitEvent({ type: "oauth:status", connectorId, status: "active" });
|
||||
this.deps.emitEvent({ type: "credentials-changed", connectorId });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const status = controller.signal.aborted ? "cancelled" : "error";
|
||||
this.deps.emitEvent({ type: "oauth:status", connectorId, status, errorMessage: status === "error" ? message : undefined });
|
||||
throw error;
|
||||
} finally {
|
||||
this.active.delete(connectorId);
|
||||
this.failPending(connectorId, "OAuth login session ended.");
|
||||
}
|
||||
}
|
||||
|
||||
cancel(connectorId: string): void {
|
||||
const controller = this.active.get(connectorId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
}
|
||||
this.failPending(connectorId, "Cancelled by user.");
|
||||
}
|
||||
|
||||
submitPromptResponse(requestId: string, value: string): boolean {
|
||||
const pending = this.prompts.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.prompts.delete(requestId);
|
||||
pending.resolve(value);
|
||||
return true;
|
||||
}
|
||||
|
||||
submitManualCode(requestId: string, code: string): boolean {
|
||||
const pending = this.manualCodes.get(requestId);
|
||||
if (!pending) return false;
|
||||
this.manualCodes.delete(requestId);
|
||||
pending.resolve(code);
|
||||
return true;
|
||||
}
|
||||
|
||||
private failPending(connectorId: string, reason: string) {
|
||||
for (const [id, pending] of this.prompts) {
|
||||
if (pending.connectorId !== connectorId) continue;
|
||||
pending.reject(new Error(reason));
|
||||
this.prompts.delete(id);
|
||||
}
|
||||
for (const [id, pending] of this.manualCodes) {
|
||||
if (pending.connectorId !== connectorId) continue;
|
||||
pending.reject(new Error(reason));
|
||||
this.manualCodes.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user