feat(HAI-095): add authentication settings UI with backend API
- Add backend auth API endpoints (GET/PUT) with validation in routes.ts - Create frontend API client functions for fetching and updating auth config - Add Authentication Settings section to SettingsModal with token input form - Add comprehensive tests for routes, API client, and SettingsModal components - Wire up auth settings routes in dashboard server
This commit is contained in:
@@ -4,6 +4,7 @@ import http from "node:http";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import type { TaskStore, TaskAttachment } from "@hai/core";
|
||||
import type { TaskDetail } from "@hai/core";
|
||||
import type { AuthStorageLike } from "./routes.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -374,3 +375,178 @@ describe("Attachment routes", () => {
|
||||
expect(res.body.error).toBe("disk error");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Auth route tests ---
|
||||
|
||||
function createMockAuthStorage(overrides: Partial<AuthStorageLike> = {}): AuthStorageLike {
|
||||
return {
|
||||
reload: vi.fn(),
|
||||
getOAuthProviders: vi.fn().mockReturnValue([
|
||||
{ id: "anthropic", name: "Anthropic" },
|
||||
]),
|
||||
hasAuth: vi.fn().mockReturnValue(false),
|
||||
login: vi.fn().mockImplementation((_provider: string, callbacks: any) => {
|
||||
// Simulate onAuth callback with a URL, then resolve
|
||||
callbacks.onAuth({ url: "https://auth.example.com/login", instructions: "Open in browser" });
|
||||
return Promise.resolve();
|
||||
}),
|
||||
logout: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as AuthStorageLike;
|
||||
}
|
||||
|
||||
describe("GET /auth/status", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns provider list with auth status", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(true);
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers).toEqual([
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||
]);
|
||||
expect(authStorage.reload).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns unauthenticated status", async () => {
|
||||
(authStorage.hasAuth as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.providers[0].authenticated).toBe(false);
|
||||
});
|
||||
|
||||
it("returns 500 on error", async () => {
|
||||
(authStorage.getOAuthProviders as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("storage error");
|
||||
});
|
||||
|
||||
const res = await GET(buildApp(), "/api/auth/status");
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("storage error");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/login", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("returns auth URL for valid provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.url).toBe("https://auth.example.com/login");
|
||||
expect(res.body.instructions).toBe("Open in browser");
|
||||
});
|
||||
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("provider is required");
|
||||
});
|
||||
|
||||
it("returns 400 for unknown provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "unknown" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("Unknown provider");
|
||||
});
|
||||
|
||||
it("returns 500 when login fails", async () => {
|
||||
(authStorage.login as ReturnType<typeof vi.fn>).mockImplementation((_provider: string, callbacks: any) => {
|
||||
return Promise.reject(new Error("OAuth failed"));
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/login", JSON.stringify({ provider: "anthropic" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("OAuth failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /auth/logout", () => {
|
||||
let store: TaskStore;
|
||||
let authStorage: AuthStorageLike;
|
||||
|
||||
beforeEach(() => {
|
||||
store = createMockStore();
|
||||
authStorage = createMockAuthStorage();
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { authStorage }));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("removes credentials for a provider", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({ provider: "anthropic" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(authStorage.logout).toHaveBeenCalledWith("anthropic");
|
||||
});
|
||||
|
||||
it("returns 400 when provider is missing", async () => {
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toBe("provider is required");
|
||||
});
|
||||
|
||||
it("returns 500 on error", async () => {
|
||||
(authStorage.logout as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
throw new Error("logout failed");
|
||||
});
|
||||
|
||||
const res = await REQUEST(buildApp(), "POST", "/api/auth/logout", JSON.stringify({ provider: "anthropic" }), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toBe("logout failed");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,26 @@ import type { TaskStore, Column, MergeResult } from "@hai/core";
|
||||
import { COLUMNS } from "@hai/core";
|
||||
import type { ServerOptions } from "./server.js";
|
||||
|
||||
/**
|
||||
* Minimal interface matching pi-coding-agent's AuthStorage API surface
|
||||
* used by the auth routes. Avoids a direct dependency on the pi-coding-agent package.
|
||||
*/
|
||||
export interface AuthStorageLike {
|
||||
reload(): void;
|
||||
getOAuthProviders(): Array<{ id: string; name: string }>;
|
||||
hasAuth(provider: string): boolean;
|
||||
login(
|
||||
providerId: string,
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise<string>;
|
||||
onProgress?: (message: string) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
): Promise<void>;
|
||||
logout(provider: string): void;
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
@@ -231,5 +251,160 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- Auth routes ----------
|
||||
registerAuthRoutes(router, options?.authStorage);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register authentication status, login, and logout routes.
|
||||
* Uses pi-coding-agent's AuthStorage for credential management.
|
||||
* If no AuthStorage is provided, creates one internally (reads from ~/.pi/agent/auth.json).
|
||||
*/
|
||||
function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void {
|
||||
// Use injected AuthStorage or fail gracefully if not provided.
|
||||
// When running via the CLI/engine, AuthStorage is passed in via ServerOptions.
|
||||
function getAuthStorage(): AuthStorageLike {
|
||||
if (!authStorage) {
|
||||
throw new Error("Authentication is not configured");
|
||||
}
|
||||
return authStorage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track in-progress login flows to prevent concurrent logins for the same provider.
|
||||
* Maps provider ID → AbortController for the active login.
|
||||
*/
|
||||
const loginInProgress = new Map<string, AbortController>();
|
||||
|
||||
/**
|
||||
* GET /api/auth/status
|
||||
* Returns list of OAuth providers with their authentication status.
|
||||
* Response: { providers: [{ id: string, name: string, authenticated: boolean }] }
|
||||
*/
|
||||
router.get("/auth/status", (_req, res) => {
|
||||
try {
|
||||
const storage = getAuthStorage();
|
||||
storage.reload();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
const providers = oauthProviders.map((p) => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
authenticated: storage.hasAuth(p.id),
|
||||
}));
|
||||
res.json({ providers });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Initiates OAuth login for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { url: string, instructions?: string }
|
||||
*
|
||||
* The endpoint starts the OAuth flow and returns the auth URL from the
|
||||
* onAuth callback. The client should open this URL in a new tab and
|
||||
* poll GET /api/auth/status to detect completion.
|
||||
*/
|
||||
router.post("/auth/login", async (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
res.status(400).json({ error: "provider is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent concurrent logins for the same provider
|
||||
if (loginInProgress.has(provider)) {
|
||||
res.status(409).json({ error: `Login already in progress for ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
const oauthProviders = storage.getOAuthProviders();
|
||||
const found = oauthProviders.find((p) => p.id === provider);
|
||||
if (!found) {
|
||||
res.status(400).json({ error: `Unknown provider: ${provider}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
loginInProgress.set(provider, abortController);
|
||||
|
||||
// We need to get the URL from the onAuth callback before responding.
|
||||
// The login() call continues in the background until the user completes OAuth.
|
||||
let authResolve: (info: { url: string; instructions?: string }) => void;
|
||||
let authReject: (err: Error) => void;
|
||||
const authUrlPromise = new Promise<{ url: string; instructions?: string }>((resolve, reject) => {
|
||||
authResolve = resolve;
|
||||
authReject = reject;
|
||||
});
|
||||
|
||||
// Start login flow in background — don't await the full login
|
||||
const loginPromise = storage.login(provider, {
|
||||
onAuth: (info) => {
|
||||
authResolve({ url: info.url, instructions: info.instructions });
|
||||
},
|
||||
onPrompt: async (prompt) => {
|
||||
// Web UI cannot interactively prompt — return empty string if allowed
|
||||
if (prompt.allowEmpty) return "";
|
||||
return prompt.placeholder || "";
|
||||
},
|
||||
onProgress: () => {}, // no-op for web UI
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
// Race: either we get the auth URL or the login completes/fails first
|
||||
const timeout = setTimeout(() => {
|
||||
authReject(new Error("Login initiation timed out"));
|
||||
}, 30_000);
|
||||
|
||||
loginPromise
|
||||
.then(() => {
|
||||
// Login completed (user finished OAuth in browser)
|
||||
})
|
||||
.catch((err) => {
|
||||
// Login failed — also reject auth URL if not yet received
|
||||
authReject(err);
|
||||
})
|
||||
.finally(() => {
|
||||
clearTimeout(timeout);
|
||||
loginInProgress.delete(provider);
|
||||
});
|
||||
|
||||
const authInfo = await authUrlPromise;
|
||||
clearTimeout(timeout);
|
||||
res.json({ url: authInfo.url, instructions: authInfo.instructions });
|
||||
} catch (err: any) {
|
||||
// Clean up on error
|
||||
const provider = req.body?.provider;
|
||||
if (provider) loginInProgress.delete(provider);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Removes credentials for a provider.
|
||||
* Body: { provider: string }
|
||||
* Response: { success: true }
|
||||
*/
|
||||
router.post("/auth/logout", (req, res) => {
|
||||
try {
|
||||
const { provider } = req.body;
|
||||
if (!provider || typeof provider !== "string") {
|
||||
res.status(400).json({ error: "provider is required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getAuthStorage();
|
||||
storage.logout(provider);
|
||||
res.json({ success: true });
|
||||
} catch (err: any) {
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { join, dirname } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { TaskStore, MergeResult } from "@hai/core";
|
||||
import type { AuthStorageLike } from "./routes.js";
|
||||
import { createApiRoutes } from "./routes.js";
|
||||
import { createSSE } from "./sse.js";
|
||||
import { rateLimit, RATE_LIMITS } from "./rate-limit.js";
|
||||
@@ -14,6 +15,8 @@ export interface ServerOptions {
|
||||
onMerge?: (taskId: string) => Promise<MergeResult>;
|
||||
/** Maximum concurrent worktrees / execution slots (default 2) */
|
||||
maxConcurrent?: number;
|
||||
/** Optional AuthStorage instance for auth routes — if not provided, one is created internally */
|
||||
authStorage?: AuthStorageLike;
|
||||
}
|
||||
|
||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||
|
||||
Reference in New Issue
Block a user