feat(auth): added auth-broker for remote credential vault
- Added `AuthBrokerClient`, `RemoteAuthCredentialStore`, `AuthBrokerRefresher`, and `startAuthBroker` server in `packages/ai/src/auth-broker`. - Renamed `AuthCredentialStore` class to `SqliteAuthCredentialStore`; extracted `AuthCredentialStore` as a persistence interface. - Added `exportSnapshot`, `forceRefreshCredentialById`, `disableCredentialById`, and `upsertCredential` to `AuthStorage` for broker wire protocol. - Added `omp auth-broker` CLI subcommand (serve, token, login, logout, import, status) and `discoverAuthStorage` broker-mode path keyed on `OMP_AUTH_BROKER_URL`.
This commit is contained in:
@@ -11,7 +11,7 @@ const COPILOT_PREMIUM_MULTIPLIERS: Record<string, number> = {
|
||||
|
||||
import * as path from "node:path";
|
||||
import { $env } from "@oh-my-pi/pi-utils";
|
||||
import { AuthCredentialStore } from "../src/auth-storage";
|
||||
import { SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
import { createModelManager } from "../src/model-manager";
|
||||
import {
|
||||
applyGeneratedModelPolicies,
|
||||
@@ -51,7 +51,7 @@ async function resolveProviderApiKey(providerId: string, catalog: CatalogDiscove
|
||||
}
|
||||
|
||||
try {
|
||||
const storage = await AuthCredentialStore.open();
|
||||
const storage = await SqliteAuthCredentialStore.open();
|
||||
try {
|
||||
const storedApiKey = storage.getApiKey(providerId);
|
||||
if (storedApiKey) {
|
||||
@@ -214,7 +214,7 @@ const ANTIGRAVITY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com"
|
||||
|
||||
async function getOAuthCredentialsFromStorage(provider: OAuthProvider): Promise<OAuthCredentials | null> {
|
||||
try {
|
||||
const storage = await AuthCredentialStore.open();
|
||||
const storage = await SqliteAuthCredentialStore.open();
|
||||
try {
|
||||
const creds = storage.getOAuth(provider);
|
||||
if (!creds) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* HTTP client for the omp auth-broker server.
|
||||
*
|
||||
* Used by {@link RemoteAuthCredentialStore} (snapshot pulls) and by
|
||||
* `omp auth-broker status` (liveness checks). All endpoints except
|
||||
* `/v1/healthz` require a bearer token.
|
||||
*/
|
||||
import type { AuthCredential } from "../auth-storage";
|
||||
import type {
|
||||
CredentialDisableRequest,
|
||||
CredentialDisableResponse,
|
||||
CredentialRefreshResponse,
|
||||
CredentialUploadRequest,
|
||||
CredentialUploadResponse,
|
||||
HealthzResponse,
|
||||
SnapshotResponse,
|
||||
} from "./types";
|
||||
|
||||
export interface AuthBrokerClientOptions {
|
||||
/** Base URL (e.g. `https://broker.tailnet:8765`). Trailing slashes are trimmed. */
|
||||
url: string;
|
||||
/** Bearer token used for everything except `healthz`. */
|
||||
token: string;
|
||||
/** Per-request timeout in milliseconds. Default 10s. */
|
||||
timeoutMs?: number;
|
||||
/** Retry connection errors this many times. Default 1. */
|
||||
maxRetries?: number;
|
||||
/** Override fetch (used in tests). Default global `fetch`. */
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export class AuthBrokerError extends Error {
|
||||
readonly status: number | undefined;
|
||||
readonly body: string | undefined;
|
||||
constructor(message: string, opts: { status?: number; body?: string; cause?: unknown } = {}) {
|
||||
super(message, { cause: opts.cause });
|
||||
this.name = "AuthBrokerError";
|
||||
this.status = opts.status;
|
||||
this.body = opts.body;
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_MAX_RETRIES = 1;
|
||||
|
||||
export class AuthBrokerClient {
|
||||
readonly #baseUrl: string;
|
||||
readonly #token: string;
|
||||
readonly #timeoutMs: number;
|
||||
readonly #maxRetries: number;
|
||||
readonly #fetch: typeof fetch;
|
||||
|
||||
constructor(opts: AuthBrokerClientOptions) {
|
||||
this.#baseUrl = opts.url.replace(/\/+$/, "");
|
||||
this.#token = opts.token;
|
||||
this.#timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
this.#maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
|
||||
this.#fetch = opts.fetchImpl ?? fetch;
|
||||
}
|
||||
|
||||
healthz(): Promise<HealthzResponse> {
|
||||
return this.#request<HealthzResponse>("GET", "/v1/healthz", { auth: false });
|
||||
}
|
||||
|
||||
fetchSnapshot(): Promise<SnapshotResponse> {
|
||||
return this.#request<SnapshotResponse>("GET", "/v1/snapshot");
|
||||
}
|
||||
|
||||
async refreshCredential(id: number): Promise<CredentialRefreshResponse> {
|
||||
return this.#request<CredentialRefreshResponse>("POST", `/v1/credential/${id}/refresh`);
|
||||
}
|
||||
|
||||
async disableCredential(id: number, cause: string): Promise<CredentialDisableResponse> {
|
||||
const body: CredentialDisableRequest = { cause };
|
||||
return this.#request<CredentialDisableResponse>("POST", `/v1/credential/${id}/disable`, {
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadCredential(provider: string, credential: AuthCredential): Promise<CredentialUploadResponse> {
|
||||
const body: CredentialUploadRequest = { provider, credential };
|
||||
return this.#request<CredentialUploadResponse>("POST", "/v1/credential", { body });
|
||||
}
|
||||
|
||||
async #request<T>(method: "GET" | "POST", path: string, opts: { auth?: boolean; body?: unknown } = {}): Promise<T> {
|
||||
const auth = opts.auth ?? true;
|
||||
const url = `${this.#baseUrl}${path}`;
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (auth) headers.Authorization = `Bearer ${this.#token}`;
|
||||
let payload: string | undefined;
|
||||
if (opts.body !== undefined) {
|
||||
payload = JSON.stringify(opts.body);
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt <= this.#maxRetries; attempt += 1) {
|
||||
try {
|
||||
const response = await this.#fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: payload,
|
||||
signal: AbortSignal.timeout(this.#timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new AuthBrokerError(`Auth broker request failed: ${response.status} ${response.statusText}`, {
|
||||
status: response.status,
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
if (!text) return undefined as T;
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch (parseError) {
|
||||
throw new AuthBrokerError("Auth broker returned malformed JSON", {
|
||||
status: response.status,
|
||||
body: text,
|
||||
cause: parseError,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (error instanceof AuthBrokerError && error.status !== undefined) {
|
||||
// HTTP errors (4xx/5xx) don't retry — caller knows what to do.
|
||||
throw error;
|
||||
}
|
||||
if (attempt >= this.#maxRetries) break;
|
||||
}
|
||||
}
|
||||
throw new AuthBrokerError(`Auth broker request failed after ${this.#maxRetries + 1} attempt(s)`, {
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "./client";
|
||||
export * from "./refresher";
|
||||
export * from "./remote-store";
|
||||
export * from "./server";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Background OAuth refresh loop for the auth-broker server.
|
||||
*
|
||||
* Iterates active OAuth credentials at `refreshIntervalMs` cadence, refreshing
|
||||
* any whose `expires - Date.now() < refreshSkewMs`. Single-flighted per
|
||||
* credential id so a long refresh can't be retriggered until it settles.
|
||||
*
|
||||
* Definitively-failed credentials (invalid_grant / 401 not from network blip)
|
||||
* are disabled via {@link AuthStorage.disableCredentialById} so the next
|
||||
* snapshot pull surfaces a clean delete on the client.
|
||||
*/
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type { AuthStorage } from "../auth-storage";
|
||||
import { DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
|
||||
|
||||
export interface AuthBrokerRefresherOptions {
|
||||
storage: AuthStorage;
|
||||
/** Refresh credentials expiring within this window. Default 5 min. */
|
||||
refreshSkewMs?: number;
|
||||
/** Loop cadence. Default 60s. */
|
||||
refreshIntervalMs?: number;
|
||||
/** Override clock (tests). */
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
const INVALID_GRANT_REGEX = /invalid_grant|invalid_token|revoked|unauthorized|expired.*refresh|refresh.*expired/i;
|
||||
const TRANSIENT_REGEX = /timeout|network|fetch failed|ECONNREFUSED/i;
|
||||
const HTTP_401_403_REGEX = /\b(401|403)\b/;
|
||||
|
||||
function isDefinitiveFailure(errorMsg: string): boolean {
|
||||
if (INVALID_GRANT_REGEX.test(errorMsg)) return true;
|
||||
if (HTTP_401_403_REGEX.test(errorMsg) && !TRANSIENT_REGEX.test(errorMsg)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export class AuthBrokerRefresher {
|
||||
readonly #storage: AuthStorage;
|
||||
readonly #refreshSkewMs: number;
|
||||
readonly #refreshIntervalMs: number;
|
||||
readonly #now: () => number;
|
||||
readonly #inFlight: Map<number, Promise<void>> = new Map();
|
||||
#timer: NodeJS.Timeout | undefined;
|
||||
#running = false;
|
||||
|
||||
constructor(opts: AuthBrokerRefresherOptions) {
|
||||
this.#storage = opts.storage;
|
||||
this.#refreshSkewMs = opts.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS;
|
||||
this.#refreshIntervalMs = opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS;
|
||||
this.#now = opts.now ?? Date.now;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.#timer !== undefined) return;
|
||||
// Refresh sweep is best-effort; kick once immediately so freshly-booted
|
||||
// brokers don't hand out near-expired tokens for the first interval.
|
||||
void this.tick();
|
||||
this.#timer = setInterval(() => {
|
||||
void this.tick();
|
||||
}, this.#refreshIntervalMs);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.#timer !== undefined) {
|
||||
clearInterval(this.#timer);
|
||||
this.#timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one sweep. Exposed for tests. */
|
||||
async tick(): Promise<void> {
|
||||
if (this.#running) return;
|
||||
this.#running = true;
|
||||
try {
|
||||
await this.#storage.reload();
|
||||
const snapshot = this.#storage.exportSnapshot();
|
||||
const now = this.#now();
|
||||
const deadline = now + this.#refreshSkewMs;
|
||||
const targets: number[] = [];
|
||||
for (const entry of snapshot.credentials) {
|
||||
if (entry.credential.type !== "oauth") continue;
|
||||
const expires = entry.credential.expires;
|
||||
if (typeof expires !== "number" || !Number.isFinite(expires)) continue;
|
||||
if (expires > deadline) continue;
|
||||
targets.push(entry.id);
|
||||
}
|
||||
await Promise.all(targets.map(id => this.#refreshOne(id)));
|
||||
} finally {
|
||||
this.#running = false;
|
||||
}
|
||||
}
|
||||
|
||||
#refreshOne(id: number): Promise<void> {
|
||||
const existing = this.#inFlight.get(id);
|
||||
if (existing) return existing;
|
||||
const promise = (async () => {
|
||||
try {
|
||||
await this.#storage.forceRefreshCredentialById(id);
|
||||
} catch (error) {
|
||||
const errorMsg = String(error);
|
||||
if (isDefinitiveFailure(errorMsg)) {
|
||||
logger.warn("auth-broker refresh failed definitively; disabling credential", {
|
||||
id,
|
||||
error: errorMsg,
|
||||
});
|
||||
this.#storage.disableCredentialById(id, `auth-broker refresh failed: ${errorMsg}`);
|
||||
} else {
|
||||
logger.debug("auth-broker refresh failed (transient)", { id, error: errorMsg });
|
||||
}
|
||||
} finally {
|
||||
this.#inFlight.delete(id);
|
||||
}
|
||||
})();
|
||||
this.#inFlight.set(id, promise);
|
||||
return promise;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Client-side {@link AuthCredentialStore} that mirrors a remote broker's
|
||||
* snapshot. Refresh tokens never leave the broker; mutating methods (`replace*`,
|
||||
* `upsert*`, `delete*ForProvider`) throw because login flows are server-side.
|
||||
*
|
||||
* Cache (`getCache`/`setCache`/`cleanExpiredCache`) is in-memory and ephemeral —
|
||||
* usage reports cache TTL is ~30s, so durability across runs isn't required.
|
||||
*/
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type {
|
||||
AuthCredential,
|
||||
AuthCredentialSnapshot,
|
||||
AuthCredentialStore,
|
||||
StoredAuthCredential,
|
||||
} from "../auth-storage";
|
||||
import type { AuthBrokerClient } from "./client";
|
||||
|
||||
interface CacheEntry {
|
||||
value: string;
|
||||
expiresAtSec: number;
|
||||
}
|
||||
|
||||
export interface RemoteAuthCredentialStoreOptions {
|
||||
client: AuthBrokerClient;
|
||||
/**
|
||||
* Initial snapshot. When omitted, callers must call
|
||||
* {@link RemoteAuthCredentialStore.refreshSnapshot} before the first read.
|
||||
*/
|
||||
initialSnapshot?: AuthCredentialSnapshot;
|
||||
}
|
||||
|
||||
export class RemoteAuthCredentialStore implements AuthCredentialStore {
|
||||
readonly #client: AuthBrokerClient;
|
||||
#snapshot: AuthCredentialSnapshot;
|
||||
#cache: Map<string, CacheEntry> = new Map();
|
||||
#closed = false;
|
||||
|
||||
constructor(opts: RemoteAuthCredentialStoreOptions) {
|
||||
this.#client = opts.client;
|
||||
this.#snapshot = opts.initialSnapshot ?? { generatedAt: 0, credentials: [] };
|
||||
}
|
||||
|
||||
get client(): AuthBrokerClient {
|
||||
return this.#client;
|
||||
}
|
||||
|
||||
get snapshot(): AuthCredentialSnapshot {
|
||||
return this.#snapshot;
|
||||
}
|
||||
|
||||
/** Re-hydrate the in-memory snapshot from the broker. */
|
||||
async refreshSnapshot(): Promise<AuthCredentialSnapshot> {
|
||||
this.#snapshot = await this.#client.fetchSnapshot();
|
||||
return this.#snapshot;
|
||||
}
|
||||
|
||||
listAuthCredentials(provider?: string): StoredAuthCredential[] {
|
||||
const out: StoredAuthCredential[] = [];
|
||||
for (const entry of this.#snapshot.credentials) {
|
||||
if (provider !== undefined && entry.provider !== provider) continue;
|
||||
out.push({
|
||||
id: entry.id,
|
||||
provider: entry.provider,
|
||||
credential: entry.credential as AuthCredential,
|
||||
disabledCause: null,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory update from a successful refresh through the broker. AuthStorage
|
||||
* calls this after `#replaceCredentialAt`; the broker already persisted the
|
||||
* authoritative row, so we just mirror it.
|
||||
*/
|
||||
updateAuthCredential(id: number, credential: AuthCredential): void {
|
||||
for (const entry of this.#snapshot.credentials) {
|
||||
if (entry.id !== id) continue;
|
||||
entry.credential = credential as typeof entry.credential;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
deleteAuthCredential(id: number, disabledCause: string): void {
|
||||
const next = this.#snapshot.credentials.filter(entry => entry.id !== id);
|
||||
this.#snapshot = { ...this.#snapshot, credentials: next };
|
||||
// Fire-and-forget: tell the broker to persist the disable.
|
||||
this.#client.disableCredential(id, disabledCause).catch(error => {
|
||||
logger.warn("auth-broker disable propagation failed", { id, error: String(error) });
|
||||
});
|
||||
}
|
||||
|
||||
tryDisableAuthCredentialIfMatches(id: number, _expectedData: string, disabledCause: string): boolean {
|
||||
const found = this.#snapshot.credentials.find(entry => entry.id === id);
|
||||
if (!found) return false;
|
||||
this.deleteAuthCredential(id, disabledCause);
|
||||
return true;
|
||||
}
|
||||
|
||||
replaceAuthCredentialsForProvider(_provider: string, _credentials: AuthCredential[]): StoredAuthCredential[] {
|
||||
throw new Error(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
upsertAuthCredentialForProvider(_provider: string, _credential: AuthCredential): StoredAuthCredential[] {
|
||||
throw new Error(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker login <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
deleteAuthCredentialsForProvider(_provider: string, _disabledCause: string): void {
|
||||
throw new Error(
|
||||
"RemoteAuthCredentialStore is read-only on the client. Use `omp auth-broker logout <provider>` to mutate credentials.",
|
||||
);
|
||||
}
|
||||
|
||||
getCache(key: string): string | null {
|
||||
const entry = this.#cache.get(key);
|
||||
if (!entry) return null;
|
||||
if (entry.expiresAtSec * 1000 <= Date.now()) {
|
||||
this.#cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
setCache(key: string, value: string, expiresAtSec: number): void {
|
||||
this.#cache.set(key, { value, expiresAtSec });
|
||||
}
|
||||
|
||||
cleanExpiredCache(): void {
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
for (const [key, entry] of this.#cache) {
|
||||
if (entry.expiresAtSec <= nowSec) this.#cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
if (this.#closed) return;
|
||||
this.#closed = true;
|
||||
this.#cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Auth broker HTTP server.
|
||||
*
|
||||
* Wraps an {@link AuthStorage} (backed by a SQLite store on the broker host)
|
||||
* and exposes a minimal REST API for snapshot pulls and explicit refresh /
|
||||
* disable operations. Background refresh of expiring credentials lives in
|
||||
* {@link AuthBrokerRefresher}.
|
||||
*
|
||||
* Transport security is delegated to the operator (Tailscale / Wireguard);
|
||||
* the server only checks a bearer token against an allow-list per request.
|
||||
*/
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type { AuthStorage } from "../auth-storage";
|
||||
import { AuthBrokerRefresher } from "./refresher";
|
||||
import type {
|
||||
CredentialDisableRequest,
|
||||
CredentialDisableResponse,
|
||||
CredentialRefreshResponse,
|
||||
CredentialUploadRequest,
|
||||
CredentialUploadResponse,
|
||||
HealthzResponse,
|
||||
SnapshotResponse,
|
||||
} from "./types";
|
||||
import { DEFAULT_AUTH_BROKER_BIND, DEFAULT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_SKEW_MS } from "./types";
|
||||
|
||||
export interface AuthBrokerServerOptions {
|
||||
/** Underlying credential storage (wraps the local SQLite store on the broker). */
|
||||
storage: AuthStorage;
|
||||
/** Listen address; accepts `host:port` or just `port`. */
|
||||
bind?: string;
|
||||
/** Accept any of these bearer tokens. Empty disables auth (loopback only). */
|
||||
bearerTokens: string[];
|
||||
/** Broker version string surfaced on `/v1/healthz`. */
|
||||
version?: string;
|
||||
/** Refresh credentials expiring within this window. Default 5 min. */
|
||||
refreshSkewMs?: number;
|
||||
/** Background refresh cadence. Default 60s. */
|
||||
refreshIntervalMs?: number;
|
||||
/** Disable the background refresher (e.g. for tests). */
|
||||
disableRefresher?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthBrokerServerHandle {
|
||||
/** Bound URL (`http://host:port`). */
|
||||
url: string;
|
||||
port: number;
|
||||
hostname: string;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface ParsedBind {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
function parseBind(raw: string): ParsedBind {
|
||||
const trimmed = raw.trim();
|
||||
if (/^\d+$/.test(trimmed)) {
|
||||
return { hostname: "127.0.0.1", port: Number.parseInt(trimmed, 10) };
|
||||
}
|
||||
const lastColon = trimmed.lastIndexOf(":");
|
||||
if (lastColon < 0) {
|
||||
throw new Error(`Invalid bind '${raw}'; expected 'host:port' or 'port'.`);
|
||||
}
|
||||
const hostPart = trimmed.slice(0, lastColon);
|
||||
const portPart = trimmed.slice(lastColon + 1);
|
||||
const port = Number.parseInt(portPart, 10);
|
||||
if (!Number.isFinite(port) || port < 0 || port > 65535) {
|
||||
throw new Error(`Invalid bind '${raw}'; port out of range.`);
|
||||
}
|
||||
return { hostname: hostPart, port };
|
||||
}
|
||||
|
||||
function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function isAuthorized(req: Request, tokens: ReadonlySet<string>): boolean {
|
||||
if (tokens.size === 0) return true;
|
||||
const header = req.headers.get("authorization");
|
||||
if (!header) return false;
|
||||
const match = header.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match) return false;
|
||||
return tokens.has(match[1].trim());
|
||||
}
|
||||
|
||||
const REFRESH_ROUTE = /^\/v1\/credential\/(\d+)\/refresh$/;
|
||||
const DISABLE_ROUTE = /^\/v1\/credential\/(\d+)\/disable$/;
|
||||
|
||||
/** Boot the broker. Caller owns lifecycle; `handle.close()` to stop. */
|
||||
export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServerHandle {
|
||||
const bind = parseBind(opts.bind ?? DEFAULT_AUTH_BROKER_BIND);
|
||||
const tokens = new Set<string>(opts.bearerTokens);
|
||||
const version = opts.version;
|
||||
|
||||
const refresher = opts.disableRefresher
|
||||
? undefined
|
||||
: new AuthBrokerRefresher({
|
||||
storage: opts.storage,
|
||||
refreshSkewMs: opts.refreshSkewMs ?? DEFAULT_REFRESH_SKEW_MS,
|
||||
refreshIntervalMs: opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS,
|
||||
});
|
||||
refresher?.start();
|
||||
|
||||
const server = Bun.serve({
|
||||
hostname: bind.hostname,
|
||||
port: bind.port,
|
||||
fetch: async (req): Promise<Response> => {
|
||||
const url = new URL(req.url);
|
||||
const pathname = url.pathname;
|
||||
const peer =
|
||||
req.headers.get("x-forwarded-for")?.split(",")[0].trim() || req.headers.get("x-real-ip") || "unknown";
|
||||
try {
|
||||
if (req.method === "GET" && pathname === "/v1/healthz") {
|
||||
const body: HealthzResponse = { ok: true, version };
|
||||
return json(200, body);
|
||||
}
|
||||
if (!isAuthorized(req, tokens)) {
|
||||
logger.info("auth-broker request unauthorized", { method: req.method, path: pathname, peer });
|
||||
return json(401, { error: "unauthorized" });
|
||||
}
|
||||
if (req.method === "GET" && pathname === "/v1/snapshot") {
|
||||
await opts.storage.reload();
|
||||
const body: SnapshotResponse = opts.storage.exportSnapshot();
|
||||
logger.info("auth-broker snapshot served", { peer, credentials: body.credentials.length });
|
||||
return json(200, body);
|
||||
}
|
||||
const refreshMatch = req.method === "POST" ? pathname.match(REFRESH_ROUTE) : null;
|
||||
if (refreshMatch) {
|
||||
const id = Number.parseInt(refreshMatch[1], 10);
|
||||
try {
|
||||
const entry = await opts.storage.forceRefreshCredentialById(id);
|
||||
const body: CredentialRefreshResponse = { entry };
|
||||
logger.info("auth-broker credential refreshed", {
|
||||
id,
|
||||
provider: entry.provider,
|
||||
peer,
|
||||
expires: entry.credential.type === "oauth" ? entry.credential.expires : undefined,
|
||||
});
|
||||
return json(200, body);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("auth-broker refresh failed", { id, peer, error: message });
|
||||
const status = message.includes("No credential with id") ? 404 : 500;
|
||||
return json(status, { error: message });
|
||||
}
|
||||
}
|
||||
const disableMatch = req.method === "POST" ? pathname.match(DISABLE_ROUTE) : null;
|
||||
if (disableMatch) {
|
||||
const id = Number.parseInt(disableMatch[1], 10);
|
||||
let cause = "disabled via auth-broker";
|
||||
try {
|
||||
const body = (await req.json()) as Partial<CredentialDisableRequest>;
|
||||
if (typeof body?.cause === "string" && body.cause.length > 0) cause = body.cause;
|
||||
} catch {
|
||||
// Empty / malformed body — default cause already set.
|
||||
}
|
||||
const ok = opts.storage.disableCredentialById(id, cause);
|
||||
if (!ok) {
|
||||
logger.info("auth-broker disable miss", { id, peer, cause });
|
||||
return json(404, { error: `No credential with id=${id}` });
|
||||
}
|
||||
logger.info("auth-broker credential disabled", { id, peer, cause });
|
||||
const response: CredentialDisableResponse = { ok: true };
|
||||
return json(200, response);
|
||||
}
|
||||
if (req.method === "POST" && pathname === "/v1/credential") {
|
||||
let body: Partial<CredentialUploadRequest>;
|
||||
try {
|
||||
body = (await req.json()) as Partial<CredentialUploadRequest>;
|
||||
} catch (error) {
|
||||
return json(400, { error: `Invalid JSON body: ${String(error)}` });
|
||||
}
|
||||
if (!body || typeof body.provider !== "string" || body.provider.length === 0) {
|
||||
return json(400, { error: "Missing `provider` field" });
|
||||
}
|
||||
if (!body.credential || typeof body.credential !== "object") {
|
||||
return json(400, { error: "Missing `credential` field" });
|
||||
}
|
||||
const credential = body.credential;
|
||||
if (credential.type !== "oauth" && credential.type !== "api_key") {
|
||||
return json(400, {
|
||||
error: `Invalid credential.type: ${String((credential as { type?: unknown }).type)}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
const entries = opts.storage.upsertCredential(body.provider, credential);
|
||||
const identity =
|
||||
credential.type === "oauth"
|
||||
? (credential.email ?? credential.accountId ?? credential.projectId ?? "(no identity)")
|
||||
: "(api key)";
|
||||
logger.info("auth-broker credential upserted", {
|
||||
provider: body.provider,
|
||||
type: credential.type,
|
||||
identity,
|
||||
peer,
|
||||
providerTotal: entries.length,
|
||||
});
|
||||
const response: CredentialUploadResponse = { entries };
|
||||
return json(200, response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger.warn("auth-broker upload failed", { provider: body.provider, peer, error: message });
|
||||
return json(500, { error: message });
|
||||
}
|
||||
}
|
||||
return json(404, { error: `No route: ${req.method} ${pathname}` });
|
||||
} catch (error) {
|
||||
logger.error("auth-broker handler crashed", {
|
||||
method: req.method,
|
||||
path: pathname,
|
||||
error: String(error),
|
||||
});
|
||||
return json(500, { error: "internal error" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const boundHost = server.hostname ?? bind.hostname;
|
||||
const boundPort = server.port ?? bind.port;
|
||||
return {
|
||||
url: `http://${boundHost}:${boundPort}`,
|
||||
port: boundPort,
|
||||
hostname: boundHost,
|
||||
close: async () => {
|
||||
refresher?.stop();
|
||||
server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Wire types shared between the auth-broker server and clients.
|
||||
*
|
||||
* The broker holds OAuth refresh tokens and exposes a redacted snapshot;
|
||||
* clients use `access` tokens directly and call back to the broker when a
|
||||
* credential expires or a 401 surfaces on a supposedly-fresh credential.
|
||||
*/
|
||||
|
||||
import type { AuthCredential, AuthCredentialSnapshot, AuthCredentialSnapshotEntry } from "../auth-storage";
|
||||
|
||||
/** GET /v1/healthz response body. */
|
||||
export interface HealthzResponse {
|
||||
ok: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/** GET /v1/snapshot response body. */
|
||||
export type SnapshotResponse = AuthCredentialSnapshot;
|
||||
|
||||
/** POST /v1/credential/:id/refresh response body. */
|
||||
export interface CredentialRefreshResponse {
|
||||
entry: AuthCredentialSnapshotEntry;
|
||||
}
|
||||
|
||||
/** POST /v1/credential/:id/disable request body. */
|
||||
export interface CredentialDisableRequest {
|
||||
cause: string;
|
||||
}
|
||||
|
||||
/** POST /v1/credential/:id/disable response body. */
|
||||
export interface CredentialDisableResponse {
|
||||
ok: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /v1/credential request body. The OAuth `refresh` must be the *real*
|
||||
* refresh token (not the sentinel) — the broker is the canonical writer.
|
||||
*/
|
||||
export interface CredentialUploadRequest {
|
||||
provider: string;
|
||||
credential: AuthCredential;
|
||||
}
|
||||
|
||||
/** POST /v1/credential response body — redacted snapshot of the provider's rows after upsert. */
|
||||
export interface CredentialUploadResponse {
|
||||
entries: AuthCredentialSnapshotEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Default bearer-protected route prefix. The broker exposes `/v1/healthz`
|
||||
* unauthenticated for liveness probes; everything else requires a bearer.
|
||||
*/
|
||||
export const AUTH_BROKER_API_PREFIX = "/v1";
|
||||
|
||||
/** Default port when none is configured. Loopback-only, no external exposure. */
|
||||
export const DEFAULT_AUTH_BROKER_BIND = "127.0.0.1:8765";
|
||||
|
||||
/** Default broker→provider refresh skew. Refresh credentials this close to expiry. */
|
||||
export const DEFAULT_REFRESH_SKEW_MS = 5 * 60_000;
|
||||
|
||||
/** Default broker refresh-loop cadence. */
|
||||
export const DEFAULT_REFRESH_INTERVAL_MS = 60_000;
|
||||
+318
-21
@@ -3,9 +3,9 @@
|
||||
* Handles loading, saving, refreshing credentials, and usage tracking.
|
||||
*
|
||||
* This module defines:
|
||||
* - `AuthCredentialStore` interface: abstracting persistence (SQLite, memory, etc.)
|
||||
* - `AuthCredentialStore` interface: persistence abstraction (SQLite, remote vault, …)
|
||||
* - `AuthStorage` class: credential management with round-robin, usage limits, OAuth refresh
|
||||
* - `AuthCredentialStore`: concrete SQLite-backed implementation
|
||||
* - `SqliteAuthCredentialStore`: concrete SQLite-backed implementation
|
||||
*/
|
||||
import { Database, type Statement } from "bun:sqlite";
|
||||
import * as fs from "node:fs/promises";
|
||||
@@ -78,6 +78,69 @@ export interface StoredAuthCredential {
|
||||
disabledCause: string | null;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Auth Broker Snapshot Types
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sentinel value placed in OAuth `refresh` fields when a credential is shared
|
||||
* via {@link AuthStorage.exportSnapshot}. Refresh tokens never leave the broker;
|
||||
* clients must call back to refresh.
|
||||
*/
|
||||
export const REMOTE_REFRESH_SENTINEL = "__remote__" as const;
|
||||
export type RemoteRefreshSentinel = typeof REMOTE_REFRESH_SENTINEL;
|
||||
|
||||
/** OAuth credential with refresh token replaced by the broker sentinel. */
|
||||
export type RemoteOAuthCredential = Omit<OAuthCredential, "refresh"> & {
|
||||
refresh: RemoteRefreshSentinel;
|
||||
};
|
||||
|
||||
/** Discriminated credential payload as published by the broker. */
|
||||
export type SnapshotCredential = ApiKeyCredential | RemoteOAuthCredential;
|
||||
|
||||
export interface AuthCredentialSnapshotEntry {
|
||||
id: number;
|
||||
provider: string;
|
||||
credential: SnapshotCredential;
|
||||
identityKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire-shaped snapshot exported by {@link AuthStorage.exportSnapshot} and
|
||||
* served by the auth-broker server on `GET /v1/snapshot`.
|
||||
*/
|
||||
export interface AuthCredentialSnapshot {
|
||||
generatedAt: number;
|
||||
credentials: AuthCredentialSnapshotEntry[];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AuthCredentialStore interface
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Persistence abstraction consumed by {@link AuthStorage}.
|
||||
*
|
||||
* Concrete implementations:
|
||||
* - {@link SqliteAuthCredentialStore} — local SQLite-backed store (default).
|
||||
* - `RemoteAuthCredentialStore` from `./auth-broker` — client-side snapshot of
|
||||
* a remote broker; mutating methods (`replace*`, `upsert*`, `delete*ForProvider`)
|
||||
* throw because login flows route through the broker, not the client.
|
||||
*/
|
||||
export interface AuthCredentialStore {
|
||||
close(): void;
|
||||
listAuthCredentials(provider?: string): StoredAuthCredential[];
|
||||
updateAuthCredential(id: number, credential: AuthCredential): void;
|
||||
deleteAuthCredential(id: number, disabledCause: string): void;
|
||||
tryDisableAuthCredentialIfMatches(id: number, expectedData: string, disabledCause: string): boolean;
|
||||
replaceAuthCredentialsForProvider(provider: string, credentials: AuthCredential[]): StoredAuthCredential[];
|
||||
upsertAuthCredentialForProvider(provider: string, credential: AuthCredential): StoredAuthCredential[];
|
||||
deleteAuthCredentialsForProvider(provider: string, disabledCause: string): void;
|
||||
getCache(key: string): string | null;
|
||||
setCache(key: string, value: string, expiresAtSec: number): void;
|
||||
cleanExpiredCache(): void;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AuthStorage Options
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -117,6 +180,30 @@ export type AuthStorageOptions = {
|
||||
* duplicate credentials (uninteresting hygiene).
|
||||
*/
|
||||
onCredentialDisabled?: (event: CredentialDisabledEvent) => void | Promise<void>;
|
||||
/**
|
||||
* Override OAuth refresh. When set, `AuthStorage` calls this instead of the
|
||||
* per-provider local refresh function. Receives the credential id so the
|
||||
* implementation can address remote credentials.
|
||||
*
|
||||
* Must return updated {@link OAuthCredentials} with at least `access` and
|
||||
* `expires`. `refresh` may be an opaque sentinel (e.g. `"__remote__"`) when
|
||||
* the actual refresh token never leaves the broker.
|
||||
*/
|
||||
refreshOAuthCredential?: (
|
||||
provider: Provider,
|
||||
credentialId: number,
|
||||
credential: OAuthCredential,
|
||||
) => Promise<OAuthCredentials>;
|
||||
/**
|
||||
* Human-readable description of the credential store backing this
|
||||
* AuthStorage instance. Surfaced through {@link AuthStorage.describeCredentialSource}
|
||||
* so the TUI can show where a token came from (broker URL or local SQLite path).
|
||||
*
|
||||
* Examples:
|
||||
* - `"local ~/.omp/agent/agent.db"`
|
||||
* - `"broker http://can.internal:8765"`
|
||||
*/
|
||||
sourceLabel?: string;
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -289,6 +376,8 @@ export class AuthStorage {
|
||||
#fallbackResolver?: (provider: string) => string | undefined;
|
||||
#store: AuthCredentialStore;
|
||||
#configValueResolver: (config: string) => Promise<string | undefined>;
|
||||
#refreshOAuthCredentialOverride?: AuthStorageOptions["refreshOAuthCredential"];
|
||||
#sourceLabel?: string;
|
||||
#credentialDisabledListeners: Set<(event: CredentialDisabledEvent) => void | Promise<void>> = new Set();
|
||||
/**
|
||||
* Buffer for credential_disabled events fired while no listener is subscribed.
|
||||
@@ -309,6 +398,8 @@ export class AuthStorage {
|
||||
this.#usageCache = new AuthStorageUsageCache(this.#store);
|
||||
this.#usageFetch = options.usageFetch ?? fetch;
|
||||
this.#usageRequestTimeoutMs = options.usageRequestTimeoutMs ?? DEFAULT_USAGE_REQUEST_TIMEOUT_MS;
|
||||
this.#refreshOAuthCredentialOverride = options.refreshOAuthCredential;
|
||||
this.#sourceLabel = options.sourceLabel;
|
||||
if (options.onCredentialDisabled) {
|
||||
// Constructor-registered subscribers are permanent for this AuthStorage's lifetime;
|
||||
// the unsubscribe handle is intentionally discarded.
|
||||
@@ -328,7 +419,7 @@ export class AuthStorage {
|
||||
* @param dbPath - Path to SQLite database
|
||||
*/
|
||||
static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
|
||||
const store = await AuthCredentialStore.open(dbPath);
|
||||
const store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
return new AuthStorage(store, options);
|
||||
}
|
||||
|
||||
@@ -1264,6 +1355,26 @@ export class AuthStorage {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the stored credential id matching a {@link UsageCredential} so the
|
||||
* refresh override can address the row. Mirrors the matching logic in
|
||||
* {@link AuthStorage.#persistRefreshedUsageCredential}.
|
||||
*/
|
||||
#findStoredCredentialIdForUsageCredential(provider: Provider, previous: UsageCredential): number | undefined {
|
||||
const entries = this.#getStoredCredentials(provider);
|
||||
const match = entries.find(entry => {
|
||||
if (entry.credential.type !== "oauth") return false;
|
||||
if (previous.refreshToken && entry.credential.refresh === previous.refreshToken) return true;
|
||||
if (previous.accessToken && entry.credential.access === previous.accessToken) return true;
|
||||
return (
|
||||
entry.credential.accountId === previous.accountId &&
|
||||
entry.credential.email === previous.email &&
|
||||
entry.credential.projectId === previous.projectId
|
||||
);
|
||||
});
|
||||
return match?.id;
|
||||
}
|
||||
|
||||
#persistRefreshedUsageCredential(provider: Provider, previous: UsageCredential, next: UsageCredential): void {
|
||||
const entries = this.#getStoredCredentials(provider);
|
||||
const index = entries.findIndex(entry => {
|
||||
@@ -1312,7 +1423,15 @@ export class AuthStorage {
|
||||
const refreshableCredential = this.#buildRefreshableOauthCredential(request.credential);
|
||||
if (refreshableCredential) {
|
||||
try {
|
||||
const refreshed = await this.#refreshOAuthCredential(request.provider, refreshableCredential);
|
||||
const refreshableCredentialId = this.#findStoredCredentialIdForUsageCredential(
|
||||
request.provider,
|
||||
request.credential,
|
||||
);
|
||||
const refreshed = await this.#refreshOAuthCredential(
|
||||
request.provider,
|
||||
refreshableCredential,
|
||||
refreshableCredentialId,
|
||||
);
|
||||
const refreshedCredential = this.#mergeRefreshedUsageCredential(request.credential, refreshed);
|
||||
this.#persistRefreshedUsageCredential(request.provider, request.credential, refreshedCredential);
|
||||
params = {
|
||||
@@ -1883,9 +2002,11 @@ export class AuthStorage {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const credentialId = this.#getStoredCredentials(provider)[candidate.selection.index]?.id;
|
||||
const refreshedCredentials = await this.#refreshOAuthCredential(
|
||||
provider,
|
||||
candidate.selection.credential,
|
||||
credentialId,
|
||||
);
|
||||
candidate.selection.credential = {
|
||||
...candidate.selection.credential,
|
||||
@@ -1927,17 +2048,25 @@ export class AuthStorage {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async #refreshOAuthCredential(provider: Provider, credential: OAuthCredential): Promise<OAuthCredentials> {
|
||||
async #refreshOAuthCredential(
|
||||
provider: Provider,
|
||||
credential: OAuthCredential,
|
||||
credentialId: number | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
if (Date.now() < credential.expires) return credential;
|
||||
const customProvider = getOAuthProvider(provider);
|
||||
let refreshPromise: Promise<OAuthCredentials>;
|
||||
if (customProvider) {
|
||||
if (!customProvider.refreshToken) {
|
||||
throw new Error(`OAuth provider "${provider}" does not support token refresh`);
|
||||
}
|
||||
refreshPromise = customProvider.refreshToken(credential);
|
||||
if (this.#refreshOAuthCredentialOverride && credentialId !== undefined) {
|
||||
refreshPromise = this.#refreshOAuthCredentialOverride(provider, credentialId, credential);
|
||||
} else {
|
||||
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
|
||||
const customProvider = getOAuthProvider(provider);
|
||||
if (customProvider) {
|
||||
if (!customProvider.refreshToken) {
|
||||
throw new Error(`OAuth provider "${provider}" does not support token refresh`);
|
||||
}
|
||||
refreshPromise = customProvider.refreshToken(credential);
|
||||
} else {
|
||||
refreshPromise = refreshOAuthToken(provider as OAuthProvider, credential);
|
||||
}
|
||||
}
|
||||
// Bound the refresh so a slow/hanging token endpoint cannot stall credential selection.
|
||||
let timeout: NodeJS.Timeout | undefined;
|
||||
@@ -2014,7 +2143,11 @@ export class AuthStorage {
|
||||
let result: { newCredentials: OAuthCredentials; apiKey: string } | null;
|
||||
const customProvider = getOAuthProvider(provider);
|
||||
if (customProvider) {
|
||||
const refreshedCredentials = await this.#refreshOAuthCredential(provider, selection.credential);
|
||||
const refreshedCredentials = await this.#refreshOAuthCredential(
|
||||
provider,
|
||||
selection.credential,
|
||||
this.#getStoredCredentials(provider)[selection.index]?.id,
|
||||
);
|
||||
const apiKey = customProvider.getApiKey
|
||||
? customProvider.getApiKey(refreshedCredentials)
|
||||
: refreshedCredentials.access;
|
||||
@@ -2204,10 +2337,171 @@ export class AuthStorage {
|
||||
// Fall back to custom resolver (e.g., models.json custom providers)
|
||||
return this.#fallbackResolver?.(provider) ?? undefined;
|
||||
}
|
||||
|
||||
// ─── Auth Broker integration ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a redacted snapshot of all loaded credentials for the auth-broker
|
||||
* wire. OAuth refresh tokens are replaced with {@link REMOTE_REFRESH_SENTINEL}
|
||||
* so clients never see the actual refresh token.
|
||||
*
|
||||
* Callers must {@link AuthStorage.reload} first when serving a stale snapshot
|
||||
* (the broker server's HTTP handler does this).
|
||||
*/
|
||||
exportSnapshot(): AuthCredentialSnapshot {
|
||||
const entries: AuthCredentialSnapshotEntry[] = [];
|
||||
for (const [provider, stored] of this.#data) {
|
||||
for (const entry of stored) {
|
||||
const credential = entry.credential;
|
||||
const redacted: SnapshotCredential =
|
||||
credential.type === "api_key" ? credential : { ...credential, refresh: REMOTE_REFRESH_SENTINEL };
|
||||
entries.push({
|
||||
id: entry.id,
|
||||
provider,
|
||||
credential: redacted,
|
||||
identityKey: resolveCredentialIdentityKey(provider, credential),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { generatedAt: Date.now(), credentials: entries };
|
||||
}
|
||||
|
||||
/**
|
||||
* Force-refresh the OAuth credential with the given id, bypassing the
|
||||
* not-yet-expired guard. Used by the auth-broker server to honour
|
||||
* `POST /v1/credential/:id/refresh`.
|
||||
*
|
||||
* Returns the redacted snapshot entry for the refreshed row.
|
||||
* Throws when no OAuth credential with that id is loaded.
|
||||
*/
|
||||
async forceRefreshCredentialById(id: number): Promise<AuthCredentialSnapshotEntry> {
|
||||
for (const [provider, entries] of this.#data) {
|
||||
const index = entries.findIndex(entry => entry.id === id);
|
||||
if (index === -1) continue;
|
||||
const target = entries[index];
|
||||
if (target.credential.type !== "oauth") {
|
||||
throw new Error(`Credential ${id} is not OAuth (provider=${provider}, type=${target.credential.type})`);
|
||||
}
|
||||
// Pass a clone with expires=0 so the cached not-yet-expired short-circuit
|
||||
// in #refreshOAuthCredential doesn't suppress the requested refresh.
|
||||
const stale: OAuthCredential = { ...target.credential, expires: 0 };
|
||||
const refreshed = await this.#refreshOAuthCredential(provider as Provider, stale, id);
|
||||
const updated: OAuthCredential = {
|
||||
type: "oauth",
|
||||
access: refreshed.access,
|
||||
refresh: refreshed.refresh,
|
||||
expires: refreshed.expires,
|
||||
accountId: refreshed.accountId ?? target.credential.accountId,
|
||||
email: refreshed.email ?? target.credential.email,
|
||||
projectId: refreshed.projectId ?? target.credential.projectId,
|
||||
enterpriseUrl: refreshed.enterpriseUrl ?? target.credential.enterpriseUrl,
|
||||
};
|
||||
this.#replaceCredentialAt(provider, index, updated);
|
||||
return {
|
||||
id,
|
||||
provider,
|
||||
credential: { ...updated, refresh: REMOTE_REFRESH_SENTINEL },
|
||||
identityKey: resolveCredentialIdentityKey(provider, updated),
|
||||
};
|
||||
}
|
||||
throw new Error(`No credential with id=${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable the credential with the given id and emit a
|
||||
* {@link CredentialDisabledEvent}. Used by the auth-broker server to honour
|
||||
* `POST /v1/credential/:id/disable`. Returns `false` when no such row exists.
|
||||
*/
|
||||
disableCredentialById(id: number, disabledCause: string): boolean {
|
||||
for (const [provider, entries] of this.#data) {
|
||||
const index = entries.findIndex(entry => entry.id === id);
|
||||
if (index === -1) continue;
|
||||
this.#store.deleteAuthCredential(id, disabledCause);
|
||||
const next = entries.filter((_value, idx) => idx !== index);
|
||||
this.#setStoredCredentials(provider, next);
|
||||
this.#resetProviderAssignments(provider);
|
||||
this.#emitCredentialDisabled({ provider, disabledCause });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert a credential into the underlying store, refresh the in-memory
|
||||
* snapshot, and return the redacted snapshot entries for the provider.
|
||||
*
|
||||
* Used by the auth-broker server to honour `POST /v1/credential`. The
|
||||
* persistence layer (`SqliteAuthCredentialStore.upsertAuthCredentialForProvider`)
|
||||
* does identity-key matching, so re-uploading the same email/account replaces
|
||||
* the existing row instead of inserting a duplicate.
|
||||
*/
|
||||
upsertCredential(provider: string, credential: AuthCredential): AuthCredentialSnapshotEntry[] {
|
||||
const stored = this.#store.upsertAuthCredentialForProvider(provider, credential);
|
||||
this.#setStoredCredentials(
|
||||
provider,
|
||||
stored.map(entry => ({ id: entry.id, credential: entry.credential })),
|
||||
);
|
||||
this.#resetProviderAssignments(provider);
|
||||
return stored.map(entry => {
|
||||
const persisted = entry.credential;
|
||||
const redacted: SnapshotCredential =
|
||||
persisted.type === "api_key" ? persisted : { ...persisted, refresh: REMOTE_REFRESH_SENTINEL };
|
||||
return {
|
||||
id: entry.id,
|
||||
provider: entry.provider,
|
||||
credential: redacted,
|
||||
identityKey: resolveCredentialIdentityKey(provider, persisted),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe where the active credential for a provider came from.
|
||||
*
|
||||
* Surfaces three layers, highest precedence first:
|
||||
* 1. Runtime override (`--api-key`).
|
||||
* 2. Stored credential (the one this session is currently sticky to, or the
|
||||
* one round-robin would pick next when no session id is supplied).
|
||||
* 3. Env var / fallback resolver — when no stored credential exists.
|
||||
*
|
||||
* The string is purely informational; consumers must not parse it.
|
||||
*/
|
||||
describeCredentialSource(provider: string, sessionId?: string): string | undefined {
|
||||
if (this.#runtimeOverrides.has(provider)) {
|
||||
return "runtime override (--api-key)";
|
||||
}
|
||||
|
||||
const baseLabel = this.#sourceLabel ?? "local store";
|
||||
const stored = this.#getStoredCredentials(provider);
|
||||
if (stored.length === 0) {
|
||||
if (getEnvApiKey(provider)) return `env ${baseLabel ? `(fallback over ${baseLabel})` : ""}`.trim();
|
||||
if (this.#fallbackResolver?.(provider) !== undefined) return `fallback resolver`;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const session = sessionId ? this.#sessionLastCredential.get(provider)?.get(sessionId) : undefined;
|
||||
// Same selection logic as #selectCredentialByType for "no session" lookups: prefer
|
||||
// the type with stored credentials, lean OAuth before api_key. We don't run the
|
||||
// full round-robin here because describing the source shouldn't advance the index.
|
||||
const preferredType: AuthCredential["type"] =
|
||||
session?.type ?? (stored.some(entry => entry.credential.type === "oauth") ? "oauth" : "api_key");
|
||||
const typed = stored
|
||||
.map((entry, index) => ({ entry, index }))
|
||||
.filter(({ entry }) => entry.credential.type === preferredType);
|
||||
if (typed.length === 0) return baseLabel;
|
||||
const index = session?.index ?? typed[0].index;
|
||||
const chosen = stored[index] ?? typed[0].entry;
|
||||
const credential = chosen.credential;
|
||||
const identity =
|
||||
credential.type === "oauth"
|
||||
? (credential.email ?? credential.accountId ?? credential.projectId ?? `cred ${chosen.id}`)
|
||||
: `cred ${chosen.id}`;
|
||||
return `${baseLabel} · ${preferredType} #${chosen.id} (${identity})`;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// AuthCredentialStore
|
||||
// SqliteAuthCredentialStore
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Row shape for auth_credentials table queries */
|
||||
@@ -2389,11 +2683,14 @@ function extractOAuthTokenIdentifiers(token: string | undefined): string[] | und
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Standalone SQLite-backed implementation of AuthCredentialStore interface.
|
||||
* Used by the pi-ai CLI and as the default store for AuthStorage.create().
|
||||
* Also has convenience methods for simple CRUD (saveOAuth, getOAuth, etc.).
|
||||
* Default SQLite-backed implementation of {@link AuthCredentialStore}.
|
||||
*
|
||||
* Used by the pi-ai CLI and as the default store for `AuthStorage.create()`.
|
||||
* Also exposes convenience methods (`saveOAuth`, `getOAuth`, `saveApiKey`,
|
||||
* `getApiKey`, `listProviders`, `deleteProvider`) that callers can use directly
|
||||
* without going through `AuthStorage`.
|
||||
*/
|
||||
export class AuthCredentialStore {
|
||||
export class SqliteAuthCredentialStore implements AuthCredentialStore {
|
||||
#db: Database;
|
||||
#listActiveStmt: Statement;
|
||||
#listActiveByProviderStmt: Statement;
|
||||
@@ -2447,7 +2744,7 @@ export class AuthCredentialStore {
|
||||
this.#deleteExpiredCacheStmt = this.#db.prepare(`DELETE FROM cache WHERE expires_at <= ${SQLITE_NOW_EPOCH}`);
|
||||
}
|
||||
|
||||
static async open(dbPath: string = getAgentDbPath()): Promise<AuthCredentialStore> {
|
||||
static async open(dbPath: string = getAgentDbPath()): Promise<SqliteAuthCredentialStore> {
|
||||
const dir = path.dirname(dbPath);
|
||||
const dirExists = await fs
|
||||
.stat(dir)
|
||||
@@ -2464,7 +2761,7 @@ export class AuthCredentialStore {
|
||||
// Ignore chmod failures (e.g., Windows)
|
||||
}
|
||||
|
||||
return new AuthCredentialStore(db);
|
||||
return new SqliteAuthCredentialStore(db);
|
||||
}
|
||||
|
||||
#initializeSchema(): void {
|
||||
@@ -2493,7 +2790,7 @@ export class AuthCredentialStore {
|
||||
const schemaVersion = this.#readAuthSchemaVersion() ?? this.#inferAuthSchemaVersion();
|
||||
const shouldWriteSchemaVersion = schemaVersion <= AUTH_SCHEMA_VERSION;
|
||||
if (schemaVersion > AUTH_SCHEMA_VERSION) {
|
||||
logger.warn("AuthCredentialStore schema version mismatch", {
|
||||
logger.warn("SqliteAuthCredentialStore schema version mismatch", {
|
||||
current: schemaVersion,
|
||||
expected: AUTH_SCHEMA_VERSION,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env bun
|
||||
import * as readline from "node:readline";
|
||||
import { AuthCredentialStore } from "./auth-storage";
|
||||
import { SqliteAuthCredentialStore } from "./auth-storage";
|
||||
import { getOAuthProviders } from "./utils/oauth";
|
||||
import type { OAuthCredentials, OAuthProvider } from "./utils/oauth/types";
|
||||
|
||||
@@ -60,7 +60,7 @@ async function login(provider: OAuthProvider): Promise<void> {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
|
||||
const promptFn = (msg: string) => prompt(rl, `${msg} `);
|
||||
const storage = await AuthCredentialStore.open();
|
||||
const storage = await SqliteAuthCredentialStore.open();
|
||||
|
||||
try {
|
||||
let credentials: OAuthCredentials;
|
||||
@@ -387,7 +387,7 @@ Examples:
|
||||
}
|
||||
|
||||
if (command === "status") {
|
||||
const storage = await AuthCredentialStore.open();
|
||||
const storage = await SqliteAuthCredentialStore.open();
|
||||
try {
|
||||
const providers = storage.listProviders();
|
||||
if (providers.length === 0) {
|
||||
@@ -426,7 +426,7 @@ Examples:
|
||||
|
||||
if (command === "logout") {
|
||||
let provider = args[1] as OAuthProvider | undefined;
|
||||
const storage = await AuthCredentialStore.open();
|
||||
const storage = await SqliteAuthCredentialStore.open();
|
||||
|
||||
try {
|
||||
if (!provider) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { type ZodType, z } from "zod/v4";
|
||||
export * from "./api-registry";
|
||||
export * from "./auth-broker";
|
||||
export * from "./auth-storage";
|
||||
export * from "./model-cache";
|
||||
export * from "./model-manager";
|
||||
@@ -38,6 +39,13 @@ export * from "./utils/anthropic-auth";
|
||||
export * from "./utils/discovery";
|
||||
export * from "./utils/event-stream";
|
||||
export * from "./utils/h2-fetch";
|
||||
export * from "./utils/oauth";
|
||||
export type {
|
||||
OAuthCredentials,
|
||||
OAuthProvider,
|
||||
OAuthProviderId,
|
||||
OAuthProviderInfo,
|
||||
} from "./utils/oauth/types";
|
||||
export * from "./utils/overflow";
|
||||
export * from "./utils/retry";
|
||||
export * from "./utils/schema";
|
||||
|
||||
@@ -16,6 +16,12 @@ export interface ProviderDetailsContext {
|
||||
model: Model<Api>;
|
||||
sessionId?: string;
|
||||
authMode?: string;
|
||||
/**
|
||||
* Human-readable description of the active credential, e.g.
|
||||
* `"broker http://can.internal:8765 · oauth #5 (foo@bar.com)"`.
|
||||
* Rendered as a `Source` field; omitted when undefined.
|
||||
*/
|
||||
credentialSource?: string;
|
||||
preferWebsockets?: boolean;
|
||||
providerSessionState?: Map<string, ProviderSessionState>;
|
||||
}
|
||||
@@ -28,6 +34,9 @@ export function getProviderDetails(context: ProviderDetailsContext): ProviderDet
|
||||
{ label: "Auth", value: context.authMode ?? "auto" },
|
||||
{ label: "Endpoint", value: endpoint },
|
||||
];
|
||||
if (context.credentialSource) {
|
||||
fields.push({ label: "Source", value: context.credentialSource });
|
||||
}
|
||||
|
||||
if (context.model.api === "openai-codex-responses") {
|
||||
const codexDetails = getOpenAICodexTransportDetails(context.model as Model<"openai-codex-responses">, {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 5. Generic Anthropic fallback (ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL)
|
||||
*/
|
||||
import { $env, getAgentDbPath } from "@oh-my-pi/pi-utils";
|
||||
import { type AuthCredential, AuthCredentialStore } from "../auth-storage";
|
||||
import { type AuthCredential, type AuthCredentialStore, SqliteAuthCredentialStore } from "../auth-storage";
|
||||
import {
|
||||
buildAnthropicHeaders as buildProviderAnthropicHeaders,
|
||||
normalizeAnthropicBaseUrl,
|
||||
@@ -80,7 +80,7 @@ function toAnthropicOAuthCredential(credential: AuthCredential): AnthropicOAuthC
|
||||
*/
|
||||
async function readAnthropicOAuthCredentials(store?: AuthCredentialStore): Promise<AnthropicOAuthCredential[]> {
|
||||
const ownsStore = !store;
|
||||
const effectiveStore = store ?? (await AuthCredentialStore.open(getAgentDbPath()));
|
||||
const effectiveStore = store ?? (await SqliteAuthCredentialStore.open(getAgentDbPath()));
|
||||
try {
|
||||
const records = effectiveStore.listAuthCredentials("anthropic");
|
||||
const credentials: AnthropicOAuthCredential[] = [];
|
||||
@@ -133,7 +133,7 @@ export async function findAnthropicAuth(store?: AuthCredentialStore): Promise<An
|
||||
|
||||
// Tiers 3-4 use the credential store; manage lifecycle once
|
||||
const ownsStore = !store;
|
||||
const effectiveStore = store ?? (await AuthCredentialStore.open(getAgentDbPath()));
|
||||
const effectiveStore = store ?? (await SqliteAuthCredentialStore.open(getAgentDbPath()));
|
||||
try {
|
||||
// 3. OAuth credentials in agent.db (with 5-minute expiry buffer)
|
||||
const expiryBuffer = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -151,12 +151,14 @@ export async function findAnthropicAuth(store?: AuthCredentialStore): Promise<An
|
||||
}
|
||||
|
||||
// 4. API key credentials in agent.db
|
||||
const storedApiKey = effectiveStore.getApiKey("anthropic");
|
||||
if (storedApiKey) {
|
||||
const apiKeyRecord = effectiveStore
|
||||
.listAuthCredentials("anthropic")
|
||||
.find(record => record.credential.type === "api_key");
|
||||
if (apiKeyRecord && apiKeyRecord.credential.type === "api_key") {
|
||||
return {
|
||||
apiKey: storedApiKey,
|
||||
apiKey: apiKeyRecord.credential.key,
|
||||
baseUrl: resolveAnthropicBaseUrlFromEnv() ?? DEFAULT_BASE_URL,
|
||||
isOAuth: isOAuthToken(storedApiKey),
|
||||
isOAuth: isOAuthToken(apiKeyRecord.credential.key),
|
||||
};
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "bun:test";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthCredentialStore } from "../src/auth-storage";
|
||||
import { SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
import { buildAnthropicUrl, findAnthropicAuth } from "../src/utils/anthropic-auth";
|
||||
import { AnthropicOAuthFlow, refreshAnthropicToken } from "../src/utils/oauth/anthropic";
|
||||
import { withEnv } from "./helpers";
|
||||
@@ -199,7 +199,7 @@ describe("anthropic auth resolution", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `pi-ai-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const dbPath = path.join(tmpDir, "agent.db");
|
||||
const store = await AuthCredentialStore.open(dbPath);
|
||||
const store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
try {
|
||||
store.replaceAuthCredentialsForProvider("anthropic", [
|
||||
{ type: "oauth", access: "sk-ant-oat-db", refresh: "refresh", expires: Date.now() + 20 * 60 * 1000 },
|
||||
@@ -231,7 +231,7 @@ describe("anthropic auth resolution", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `pi-ai-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const dbPath = path.join(tmpDir, "agent.db");
|
||||
const store = await AuthCredentialStore.open(dbPath);
|
||||
const store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
try {
|
||||
store.replaceAuthCredentialsForProvider("anthropic", [
|
||||
{ type: "oauth", access: "sk-ant-oat-db", refresh: "refresh", expires: Date.now() + 20 * 60 * 1000 },
|
||||
@@ -261,7 +261,7 @@ describe("anthropic auth resolution", () => {
|
||||
const tmpDir = path.join(os.tmpdir(), `pi-ai-auth-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const dbPath = path.join(tmpDir, "agent.db");
|
||||
const store = await AuthCredentialStore.open(dbPath);
|
||||
const store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
try {
|
||||
store.replaceAuthCredentialsForProvider("anthropic", [{ type: "api_key", key: "sk-ant-api-db" }]);
|
||||
await withEnv(
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthBrokerRefresher, AuthStorage, SqliteAuthCredentialStore } from "../src";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
|
||||
const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const;
|
||||
const savedEnv: Partial<Record<(typeof ANTHROPIC_ENV)[number], string | undefined>> = {};
|
||||
|
||||
describe("AuthBrokerRefresher", () => {
|
||||
let tempDir = "";
|
||||
let store: SqliteAuthCredentialStore | undefined;
|
||||
let storage: AuthStorage | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-refresher-"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
storage?.close();
|
||||
store?.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
test("refreshes credentials inside the skew window", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const skew = 5 * 60_000;
|
||||
// Credential expires in 1 minute — well within the 5-min skew → must refresh.
|
||||
store!.saveOAuth("anthropic", {
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: now + 60_000,
|
||||
accountId: "a",
|
||||
});
|
||||
const refreshSpy = vi.spyOn(oauthUtils, "refreshOAuthToken").mockResolvedValue({
|
||||
access: "fresh",
|
||||
refresh: "fresh-refresh",
|
||||
expires: now + 2 * 60 * 60_000,
|
||||
accountId: "a",
|
||||
});
|
||||
|
||||
storage = new AuthStorage(store!);
|
||||
await storage.reload();
|
||||
const refresher = new AuthBrokerRefresher({
|
||||
storage,
|
||||
refreshSkewMs: skew,
|
||||
now: () => now,
|
||||
});
|
||||
await refresher.tick();
|
||||
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
const persisted = store!.getOAuth("anthropic");
|
||||
expect(persisted?.access).toBe("fresh");
|
||||
expect(persisted?.refresh).toBe("fresh-refresh");
|
||||
});
|
||||
|
||||
test("does not refresh credentials safely outside the skew window", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const skew = 5 * 60_000;
|
||||
store!.saveOAuth("anthropic", {
|
||||
access: "ok",
|
||||
refresh: "ok-refresh",
|
||||
expires: now + 60 * 60_000, // 1 hour out
|
||||
accountId: "a",
|
||||
});
|
||||
const refreshSpy = vi.spyOn(oauthUtils, "refreshOAuthToken").mockResolvedValue({
|
||||
access: "should-not-run",
|
||||
refresh: "x",
|
||||
expires: now,
|
||||
});
|
||||
|
||||
storage = new AuthStorage(store!);
|
||||
await storage.reload();
|
||||
const refresher = new AuthBrokerRefresher({
|
||||
storage,
|
||||
refreshSkewMs: skew,
|
||||
now: () => now,
|
||||
});
|
||||
await refresher.tick();
|
||||
|
||||
expect(refreshSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("disables credentials on definitive failure (invalid_grant)", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
store!.saveOAuth("anthropic", {
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: now + 60_000,
|
||||
accountId: "a",
|
||||
});
|
||||
vi.spyOn(oauthUtils, "refreshOAuthToken").mockRejectedValue(new Error("invalid_grant"));
|
||||
|
||||
storage = new AuthStorage(store!);
|
||||
const disableEvents: string[] = [];
|
||||
storage.onCredentialDisabled(event => {
|
||||
disableEvents.push(event.disabledCause);
|
||||
});
|
||||
await storage.reload();
|
||||
const refresher = new AuthBrokerRefresher({
|
||||
storage,
|
||||
refreshSkewMs: 5 * 60_000,
|
||||
now: () => now,
|
||||
});
|
||||
await refresher.tick();
|
||||
|
||||
expect(disableEvents).toHaveLength(1);
|
||||
expect(disableEvents[0]).toMatch(/invalid_grant/);
|
||||
// The active row is now disabled; storage.exportSnapshot reflects it.
|
||||
expect(storage.exportSnapshot().credentials).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("keeps credentials on transient failures (timeout/network)", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
store!.saveOAuth("anthropic", {
|
||||
access: "old",
|
||||
refresh: "old-refresh",
|
||||
expires: now + 60_000,
|
||||
accountId: "a",
|
||||
});
|
||||
vi.spyOn(oauthUtils, "refreshOAuthToken").mockRejectedValue(new Error("fetch failed: ECONNREFUSED"));
|
||||
|
||||
storage = new AuthStorage(store!);
|
||||
const disableEvents: string[] = [];
|
||||
storage.onCredentialDisabled(event => {
|
||||
disableEvents.push(event.disabledCause);
|
||||
});
|
||||
await storage.reload();
|
||||
const refresher = new AuthBrokerRefresher({
|
||||
storage,
|
||||
refreshSkewMs: 5 * 60_000,
|
||||
now: () => now,
|
||||
});
|
||||
await refresher.tick();
|
||||
|
||||
expect(disableEvents).toHaveLength(0);
|
||||
expect(storage.exportSnapshot().credentials).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
type AuthBrokerServerHandle,
|
||||
AuthStorage,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
SqliteAuthCredentialStore,
|
||||
startAuthBroker,
|
||||
} from "../src";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
|
||||
const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const;
|
||||
const savedEnv: Partial<Record<(typeof ANTHROPIC_ENV)[number], string | undefined>> = {};
|
||||
|
||||
function mintOAuthCredential(suffix: string, expires: number) {
|
||||
return {
|
||||
type: "oauth" as const,
|
||||
access: `access-${suffix}`,
|
||||
refresh: `refresh-${suffix}`,
|
||||
expires,
|
||||
accountId: `account-${suffix}`,
|
||||
email: `${suffix}@example.com`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("auth-broker wire surface", () => {
|
||||
let tempDir = "";
|
||||
let store: SqliteAuthCredentialStore | undefined;
|
||||
let storage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
let token = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-wire-"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
store.saveOAuth("anthropic", mintOAuthCredential("a", Date.now() + 60_000));
|
||||
storage = new AuthStorage(store);
|
||||
await storage.reload();
|
||||
token = "test-bearer";
|
||||
handle = startAuthBroker({
|
||||
storage,
|
||||
bind: "127.0.0.1:0",
|
||||
bearerTokens: [token],
|
||||
disableRefresher: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await handle?.close();
|
||||
storage?.close();
|
||||
store?.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
test("GET /v1/healthz returns ok without auth", async () => {
|
||||
const res = await fetch(`${handle!.url}/v1/healthz`);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean };
|
||||
expect(body.ok).toBe(true);
|
||||
});
|
||||
|
||||
test("GET /v1/snapshot requires bearer and redacts refresh tokens", async () => {
|
||||
const unauthorized = await fetch(`${handle!.url}/v1/snapshot`);
|
||||
expect(unauthorized.status).toBe(401);
|
||||
|
||||
const client = new AuthBrokerClient({ url: handle!.url, token });
|
||||
const snapshot = await client.fetchSnapshot();
|
||||
expect(snapshot.credentials).toHaveLength(1);
|
||||
const entry = snapshot.credentials[0];
|
||||
expect(entry.provider).toBe("anthropic");
|
||||
expect(entry.credential.type).toBe("oauth");
|
||||
if (entry.credential.type === "oauth") {
|
||||
expect(entry.credential.access).toBe("access-a");
|
||||
// Refresh token is replaced with the wire sentinel — clients never see it.
|
||||
expect(entry.credential.refresh).toBe(REMOTE_REFRESH_SENTINEL);
|
||||
}
|
||||
});
|
||||
|
||||
test("POST /v1/credential/:id/refresh forces a refresh and persists the new credential", async () => {
|
||||
const refreshed = {
|
||||
access: "access-rotated",
|
||||
refresh: "refresh-rotated",
|
||||
expires: Date.now() + 120_000,
|
||||
accountId: "account-a",
|
||||
email: "a@example.com",
|
||||
};
|
||||
vi.spyOn(oauthUtils, "refreshOAuthToken").mockResolvedValue(refreshed);
|
||||
|
||||
const initialSnapshot = await new AuthBrokerClient({ url: handle!.url, token }).fetchSnapshot();
|
||||
const id = initialSnapshot.credentials[0].id;
|
||||
|
||||
const client = new AuthBrokerClient({ url: handle!.url, token });
|
||||
const result = await client.refreshCredential(id);
|
||||
expect(result.entry.id).toBe(id);
|
||||
if (result.entry.credential.type === "oauth") {
|
||||
expect(result.entry.credential.access).toBe("access-rotated");
|
||||
expect(result.entry.credential.refresh).toBe(REMOTE_REFRESH_SENTINEL);
|
||||
}
|
||||
|
||||
// Underlying SQLite row was updated with the *real* refresh token (no sentinel).
|
||||
const persisted = store!.getOAuth("anthropic");
|
||||
expect(persisted?.access).toBe("access-rotated");
|
||||
expect(persisted?.refresh).toBe("refresh-rotated");
|
||||
});
|
||||
|
||||
test("POST /v1/credential/:id/disable soft-deletes the credential and surfaces 404 thereafter", async () => {
|
||||
const client = new AuthBrokerClient({ url: handle!.url, token });
|
||||
const initialSnapshot = await client.fetchSnapshot();
|
||||
const id = initialSnapshot.credentials[0].id;
|
||||
|
||||
const result = await client.disableCredential(id, "revoked by user");
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
const after = await client.fetchSnapshot();
|
||||
expect(after.credentials).toHaveLength(0);
|
||||
|
||||
await expect(client.refreshCredential(id)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("Unknown route returns 404", async () => {
|
||||
const res = await fetch(`${handle!.url}/v1/nope`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
|
||||
import { AuthCredentialStore, AuthStorage } from "../src/auth-storage";
|
||||
import { AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
import * as kagiModule from "../src/utils/oauth/kagi";
|
||||
import * as ollamaCloudModule from "../src/utils/oauth/ollama-cloud";
|
||||
|
||||
@@ -23,7 +23,7 @@ function countCredentialRows(dbPath: string, provider: string): number {
|
||||
describe("AuthStorage api-key login replacement", () => {
|
||||
let tempDir = "";
|
||||
let dbPath = "";
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let store: SqliteAuthCredentialStore | null = null;
|
||||
let authStorage: AuthStorage | null = null;
|
||||
let loginKagiSpy: Mock<typeof kagiModule.loginKagi>;
|
||||
let loginOllamaCloudSpy: Mock<typeof ollamaCloudModule.loginOllamaCloud>;
|
||||
@@ -31,7 +31,7 @@ describe("AuthStorage api-key login replacement", () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-api-key-login-"));
|
||||
dbPath = path.join(tempDir, "agent.db");
|
||||
store = await AuthCredentialStore.open(dbPath);
|
||||
store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
authStorage = new AuthStorage(store);
|
||||
loginKagiSpy = vi.spyOn(kagiModule, "loginKagi");
|
||||
loginOllamaCloudSpy = vi.spyOn(ollamaCloudModule, "loginOllamaCloud");
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthCredentialStore, AuthStorage } from "../src/auth-storage";
|
||||
import { type AuthCredentialStore, AuthStorage, SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
import type { UsageLimit, UsageProvider, UsageReport } from "../src/usage";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
import type { OAuthCredentials } from "../src/utils/oauth/types";
|
||||
@@ -120,7 +120,7 @@ describe("AuthStorage codex oauth ranking", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-codex-selection-"));
|
||||
store = await AuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
authStorage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "openai-codex" ? usageProvider : undefined),
|
||||
});
|
||||
@@ -590,7 +590,7 @@ describe("AuthStorage claude oauth ranking", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-claude-selection-"));
|
||||
store = await AuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
authStorage = new AuthStorage(store, {
|
||||
usageProviderResolver: provider => (provider === "anthropic" ? usageProvider : undefined),
|
||||
});
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthCredentialStore, AuthStorage, type CredentialDisabledEvent } from "../src/auth-storage";
|
||||
import {
|
||||
type AuthCredentialStore,
|
||||
AuthStorage,
|
||||
type CredentialDisabledEvent,
|
||||
SqliteAuthCredentialStore,
|
||||
} from "../src/auth-storage";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
|
||||
// Env vars short-circuit AuthStorage.getApiKey before the OAuth refresh path runs; suppress
|
||||
@@ -29,7 +34,7 @@ describe("AuthStorage credential_disabled subscriptions", () => {
|
||||
const stores: AuthCredentialStore[] = [];
|
||||
|
||||
const openStorage = async (options?: ConstructorParameters<typeof AuthStorage>[1]): Promise<AuthStorage> => {
|
||||
const store = await AuthCredentialStore.open(path.join(tempDir, `agent-${stores.length}.db`));
|
||||
const store = await SqliteAuthCredentialStore.open(path.join(tempDir, `agent-${stores.length}.db`));
|
||||
stores.push(store);
|
||||
return new AuthStorage(store, options);
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthCredentialStore, AuthStorage, type OAuthCredential } from "../src/auth-storage";
|
||||
import { AuthStorage, type OAuthCredential, SqliteAuthCredentialStore } from "../src/auth-storage";
|
||||
|
||||
const LEGACY_TIMESTAMP = 1_700_000_000;
|
||||
|
||||
@@ -105,13 +105,13 @@ function readTableSql(dbPath: string, tableName: string): string | null {
|
||||
describe("AuthStorage openai-codex email dedupe", () => {
|
||||
let tempDir = "";
|
||||
let dbPath = "";
|
||||
let store: AuthCredentialStore | null = null;
|
||||
let store: SqliteAuthCredentialStore | null = null;
|
||||
let authStorage: AuthStorage | null = null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-email-dedupe-"));
|
||||
dbPath = path.join(tempDir, "agent.db");
|
||||
store = await AuthCredentialStore.open(dbPath);
|
||||
store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
authStorage = new AuthStorage(store);
|
||||
});
|
||||
|
||||
@@ -255,8 +255,8 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
it("saveOAuth does not delete accounts missing from stale AuthStorage cache", async () => {
|
||||
if (!store || !dbPath) throw new Error("test setup failed");
|
||||
|
||||
const staleStore = await AuthCredentialStore.open(dbPath);
|
||||
const freshStore = await AuthCredentialStore.open(dbPath);
|
||||
const staleStore = await SqliteAuthCredentialStore.open(dbPath);
|
||||
const freshStore = await SqliteAuthCredentialStore.open(dbPath);
|
||||
const staleAuthStorage = new AuthStorage(staleStore);
|
||||
try {
|
||||
staleStore.saveOAuth(
|
||||
@@ -396,7 +396,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
const migratedStore = await AuthCredentialStore.open(legacyDbPath);
|
||||
const migratedStore = await SqliteAuthCredentialStore.open(legacyDbPath);
|
||||
try {
|
||||
expect(readStoredIdentityRows(legacyDbPath, "anthropic")).toEqual([
|
||||
{ identity_key: "email:legacy-anthropic@example.com", disabled_cause: null },
|
||||
@@ -427,7 +427,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
if (!tempDir) throw new Error("test setup failed");
|
||||
|
||||
const freshDbPath = path.join(tempDir, "fresh-schema-agent.db");
|
||||
const freshStore = await AuthCredentialStore.open(freshDbPath);
|
||||
const freshStore = await SqliteAuthCredentialStore.open(freshDbPath);
|
||||
try {
|
||||
expect(readAuthSchemaVersion(freshDbPath)).toBe(4);
|
||||
expect(readTableSql(freshDbPath, "auth_credentials")).not.toContain("unixepoch(");
|
||||
@@ -461,7 +461,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
`);
|
||||
futureDb.close();
|
||||
|
||||
const reopenedStore = await AuthCredentialStore.open(futureDbPath);
|
||||
const reopenedStore = await SqliteAuthCredentialStore.open(futureDbPath);
|
||||
try {
|
||||
expect(readAuthSchemaVersion(futureDbPath)).toBe(5);
|
||||
} finally {
|
||||
@@ -512,7 +512,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
const migratedStore = await AuthCredentialStore.open(legacyDbPath);
|
||||
const migratedStore = await SqliteAuthCredentialStore.open(legacyDbPath);
|
||||
try {
|
||||
expect(readAuthSchemaVersion(legacyDbPath)).toBe(4);
|
||||
expect(readTableSql(legacyDbPath, "auth_credentials")).not.toContain("unixepoch(");
|
||||
@@ -566,7 +566,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
const migratedStore = await AuthCredentialStore.open(legacyDbPath);
|
||||
const migratedStore = await SqliteAuthCredentialStore.open(legacyDbPath);
|
||||
try {
|
||||
expect(readStoredIdentityRows(legacyDbPath, "openai-codex")).toEqual([
|
||||
{ identity_key: "email:legacy-v1@example.com", disabled_cause: null },
|
||||
@@ -608,7 +608,7 @@ describe("AuthStorage openai-codex email dedupe", () => {
|
||||
);
|
||||
legacyDb.close();
|
||||
|
||||
const migratedStore = await AuthCredentialStore.open(legacyDbPath);
|
||||
const migratedStore = await SqliteAuthCredentialStore.open(legacyDbPath);
|
||||
try {
|
||||
expect(migratedStore.listAuthCredentials("openai-codex")).toHaveLength(0);
|
||||
expect(readStoredIdentityRows(legacyDbPath, "openai-codex")).toEqual([
|
||||
|
||||
@@ -2,7 +2,12 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AuthCredentialStore, AuthStorage, type CredentialDisabledEvent } from "../src/auth-storage";
|
||||
import {
|
||||
type AuthCredentialStore,
|
||||
AuthStorage,
|
||||
type CredentialDisabledEvent,
|
||||
SqliteAuthCredentialStore,
|
||||
} from "../src/auth-storage";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
import { withEnv } from "./helpers";
|
||||
|
||||
@@ -19,7 +24,7 @@ describe("AuthStorage OAuth refresh race", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-ai-auth-oauth-race-"));
|
||||
store = await AuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
store = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
events = [];
|
||||
authStorage = new AuthStorage(store, {
|
||||
onCredentialDisabled: event => {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
type AuthBrokerServerHandle,
|
||||
AuthStorage,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
RemoteAuthCredentialStore,
|
||||
SqliteAuthCredentialStore,
|
||||
startAuthBroker,
|
||||
} from "../src";
|
||||
import * as oauthUtils from "../src/utils/oauth";
|
||||
|
||||
const ANTHROPIC_ENV = ["ANTHROPIC_API_KEY", "ANTHROPIC_OAUTH_TOKEN"] as const;
|
||||
const savedEnv: Partial<Record<(typeof ANTHROPIC_ENV)[number], string | undefined>> = {};
|
||||
|
||||
describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
|
||||
let tempDir = "";
|
||||
let serverStore: SqliteAuthCredentialStore | undefined;
|
||||
let serverStorage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
const token = "remote-bearer";
|
||||
|
||||
beforeEach(async () => {
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-broker-remote-"));
|
||||
serverStore = await SqliteAuthCredentialStore.open(path.join(tempDir, "agent.db"));
|
||||
serverStore.saveOAuth("anthropic", {
|
||||
access: "server-access-1",
|
||||
refresh: "server-refresh-1",
|
||||
expires: Date.now() - 60_000, // expired so refresh is forced
|
||||
accountId: "account-1",
|
||||
email: "a@example.com",
|
||||
});
|
||||
serverStorage = new AuthStorage(serverStore);
|
||||
await serverStorage.reload();
|
||||
handle = startAuthBroker({
|
||||
storage: serverStorage,
|
||||
bind: "127.0.0.1:0",
|
||||
bearerTokens: [token],
|
||||
disableRefresher: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await handle?.close();
|
||||
serverStorage?.close();
|
||||
serverStore?.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
for (const key of ANTHROPIC_ENV) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
test("client-side AuthStorage refreshes via broker override, never via local OAuth path", async () => {
|
||||
// Real refresh executed by the broker server; mock surfaces the rotated tokens.
|
||||
const rotated = {
|
||||
access: "server-access-rotated",
|
||||
refresh: "server-refresh-rotated",
|
||||
expires: Date.now() + 120_000,
|
||||
accountId: "account-1",
|
||||
email: "a@example.com",
|
||||
};
|
||||
const refreshSpy = vi.spyOn(oauthUtils, "refreshOAuthToken").mockResolvedValue(rotated);
|
||||
|
||||
const brokerClient = new AuthBrokerClient({ url: handle!.url, token });
|
||||
const initialSnapshot = await brokerClient.fetchSnapshot();
|
||||
expect(initialSnapshot.credentials).toHaveLength(1);
|
||||
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: brokerClient,
|
||||
initialSnapshot,
|
||||
});
|
||||
|
||||
let overrideCalls = 0;
|
||||
const clientStorage = new AuthStorage(remoteStore, {
|
||||
refreshOAuthCredential: async (_provider, credentialId, _credential) => {
|
||||
overrideCalls += 1;
|
||||
const { entry } = await brokerClient.refreshCredential(credentialId);
|
||||
if (entry.credential.type !== "oauth") throw new Error("unexpected");
|
||||
return {
|
||||
access: entry.credential.access,
|
||||
refresh: REMOTE_REFRESH_SENTINEL,
|
||||
expires: entry.credential.expires,
|
||||
accountId: entry.credential.accountId,
|
||||
email: entry.credential.email,
|
||||
};
|
||||
},
|
||||
});
|
||||
await clientStorage.reload();
|
||||
|
||||
const apiKey = await clientStorage.getApiKey("anthropic");
|
||||
expect(apiKey).toBe("server-access-rotated");
|
||||
expect(overrideCalls).toBe(1);
|
||||
// The local oauth refresh helper was used exactly once — by the broker server.
|
||||
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
||||
clientStorage.close();
|
||||
});
|
||||
|
||||
test("RemoteAuthCredentialStore rejects writes from the client", () => {
|
||||
const remoteStore = new RemoteAuthCredentialStore({
|
||||
client: new AuthBrokerClient({ url: handle!.url, token }),
|
||||
});
|
||||
expect(() => remoteStore.replaceAuthCredentialsForProvider("anthropic", [])).toThrow(/read-only/);
|
||||
expect(() => remoteStore.upsertAuthCredentialForProvider("anthropic", { type: "api_key", key: "x" })).toThrow(
|
||||
/read-only/,
|
||||
);
|
||||
expect(() => remoteStore.deleteAuthCredentialsForProvider("anthropic", "x")).toThrow(/read-only/);
|
||||
remoteStore.close();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,18 @@
|
||||
- Renamed the embedded-documentation internal URL scheme from `pi://` to `omp://`. `OmpProtocolHandler` replaces `PiProtocolHandler`; update any external references accordingly.
|
||||
- Removed the `StringEnum` re-export from `@oh-my-pi/pi-coding-agent`. Custom tools and extensions should use `z.enum([...])` directly via the injected `pi.zod`.
|
||||
- Replaced the `eval` tool's LARK-grammar `input` string with a structured `cells` array. Each cell is `{ language: "py" | "js", code, title?, timeout?, reset? }`. Removed the implicit/sniffed language path, the `*** Cell` / `*** End` / `*** Abort` markers, and the per-cell `t:<duration>` unit suffixes — `timeout` is now seconds (1-600).
|
||||
|
||||
### Added
|
||||
|
||||
- Added `omp auth-broker` subcommand for running and consuming a hosted credential vault.
|
||||
- `serve [--bind=host:port]` — boots a local broker against the SQLite store at `$AGENT_DB_PATH`.
|
||||
- `token [--regenerate]` — prints (and rotates) the bearer token stored at `~/.omp/auth-broker.token`.
|
||||
- `login <provider> [--via=user@host] [--dry-run]` — drives the OAuth flow locally or via SSH `-L` tunnel into a remote broker (callback ports pinned per provider).
|
||||
- `logout <provider>` — disables every credential for the given provider in the local SQLite store.
|
||||
- `import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run]` — imports CLIProxyAPI-style JSON credential dumps (`~/.cliproxy/auth/*.json`). When `OMP_AUTH_BROKER_URL` is configured, credentials are uploaded to the remote broker via `POST /v1/credential`; otherwise they go into the local SQLite store. JSON `type` is mapped to omp providers (`claude` → `anthropic`, `codex` → `openai-codex`, `gemini[-cli]` → `google-gemini-cli`, `antigravity` → `google-antigravity`); `--provider` overrides the mapping for unrecognized types.
|
||||
- `status` — pings the configured remote broker (`OMP_AUTH_BROKER_URL`).
|
||||
- Added remote credential vault support to `discoverAuthStorage`. Configure via env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) or by setting `auth.broker.url` and `auth.broker.token` in `~/.omp/agent/config.yml` (hidden from the settings UI; supports `!command` resolution). Falls back to `~/.omp/auth-broker.token` when no token is provided inline. Otherwise behavior is unchanged.
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed TTSR `interruptMode` semantics so a non-interrupting decision on a tool-source match now folds the rule reminder into that specific tool's `toolResult` content instead of queuing a loop-wide deferred follow-up turn. Text/thinking matches keep the previous deferred-injection behavior.
|
||||
|
||||
@@ -32,6 +32,7 @@ process.title = APP_NAME;
|
||||
const commands: CommandEntry[] = [
|
||||
{ name: "launch", load: () => import("./commands/launch").then(m => m.default) },
|
||||
{ name: "acp", load: () => import("./commands/acp").then(m => m.default) },
|
||||
{ name: "auth-broker", load: () => import("./commands/auth-broker").then(m => m.default) },
|
||||
{ name: "agents", load: () => import("./commands/agents").then(m => m.default) },
|
||||
{ name: "commit", load: () => import("./commands/commit").then(m => m.default) },
|
||||
{ name: "config", load: () => import("./commands/config").then(m => m.default) },
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
/**
|
||||
* `omp auth-broker` command handlers.
|
||||
*
|
||||
* Sub-verbs:
|
||||
* - `serve [--bind=…]` — boots the broker against the local SQLite store.
|
||||
* - `token` / `token --regenerate` — manages the bearer token file.
|
||||
* - `login <provider> [--via=user@host]` — logs into a provider locally, or
|
||||
* via SSH tunnel into a remote broker host.
|
||||
* - `import <file|dir>` — imports CLIProxyAPI-style JSON credentials into
|
||||
* the local SQLite store (typical use: `import ~/.cliproxy/auth`).
|
||||
* - `status` — health-pings the configured remote broker.
|
||||
*/
|
||||
import * as crypto from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
AuthStorage,
|
||||
type CredentialDisabledEvent,
|
||||
DEFAULT_AUTH_BROKER_BIND,
|
||||
getOAuthProviders,
|
||||
type OAuthCredential,
|
||||
type OAuthProvider,
|
||||
SqliteAuthCredentialStore,
|
||||
startAuthBroker,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
import { $which, APP_NAME, getAgentDbPath, getConfigRootDir, isEnoent, logger, VERSION } from "@oh-my-pi/pi-utils";
|
||||
import { $ } from "bun";
|
||||
import chalk from "chalk";
|
||||
import { resolveAuthBrokerConfig } from "../session/auth-broker-config";
|
||||
|
||||
export type AuthBrokerAction = "serve" | "token" | "login" | "logout" | "status" | "import";
|
||||
|
||||
export interface AuthBrokerCommandArgs {
|
||||
action: AuthBrokerAction;
|
||||
flags: {
|
||||
json?: boolean;
|
||||
bind?: string;
|
||||
regenerate?: boolean;
|
||||
via?: string;
|
||||
provider?: string;
|
||||
dryRun?: boolean;
|
||||
/** `login`/`logout`: provider id. `import`: filesystem path. */
|
||||
source?: string;
|
||||
/** `import`: keep credentials whose JSON had `disabled: true`. */
|
||||
includeDisabled?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIONS: readonly AuthBrokerAction[] = ["serve", "token", "login", "logout", "import", "status"];
|
||||
|
||||
/** Callback ports baked from the per-provider OAuth flow modules. */
|
||||
const CALLBACK_PORTS: Record<string, number> = {
|
||||
anthropic: 54545,
|
||||
"openai-codex": 1455,
|
||||
"google-gemini-cli": 8085,
|
||||
"google-antigravity": 51121,
|
||||
"gitlab-duo": 8080,
|
||||
};
|
||||
|
||||
function getTokenFilePath(): string {
|
||||
return path.join(getConfigRootDir(), "auth-broker.token");
|
||||
}
|
||||
|
||||
async function readToken(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await Bun.file(getTokenFilePath()).text();
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeToken(token: string): Promise<void> {
|
||||
const file = getTokenFilePath();
|
||||
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
|
||||
await Bun.write(file, token);
|
||||
try {
|
||||
await fs.chmod(file, 0o600);
|
||||
} catch {
|
||||
// Best-effort (e.g. Windows).
|
||||
}
|
||||
}
|
||||
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
const existing = await readToken();
|
||||
if (existing) return existing;
|
||||
const token = generateToken();
|
||||
await writeToken(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
async function runServe(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
const bind = flags.bind ?? DEFAULT_AUTH_BROKER_BIND;
|
||||
const token = await ensureToken();
|
||||
const dbPath = getAgentDbPath();
|
||||
const store = await SqliteAuthCredentialStore.open(dbPath);
|
||||
const storage = new AuthStorage(store);
|
||||
await storage.reload();
|
||||
const handle = startAuthBroker({
|
||||
storage,
|
||||
bind,
|
||||
bearerTokens: [token],
|
||||
version: VERSION,
|
||||
});
|
||||
process.stdout.write(`auth-broker listening on ${handle.url}\n`);
|
||||
process.stdout.write(`bearer token: ${getTokenFilePath()} (chmod 0600)\n`);
|
||||
|
||||
const credentialDisabledUnsub = storage.onCredentialDisabled((event: CredentialDisabledEvent) => {
|
||||
logger.warn("auth-broker credential disabled", { ...event });
|
||||
});
|
||||
|
||||
const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
|
||||
process.stdout.write(`\nReceived ${signal}, shutting down...\n`);
|
||||
credentialDisabledUnsub();
|
||||
await handle.close();
|
||||
storage.close();
|
||||
process.exit(0);
|
||||
};
|
||||
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
|
||||
// Block forever; lifecycle is signal-driven.
|
||||
await new Promise<never>(() => {});
|
||||
}
|
||||
|
||||
async function runToken(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
if (flags.regenerate) {
|
||||
const next = generateToken();
|
||||
await writeToken(next);
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({ token: next, path: getTokenFilePath() })}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${next}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const token = await ensureToken();
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({ token, path: getTokenFilePath() })}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${token}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runLogin(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
const providerArg = flags.provider;
|
||||
if (!providerArg) {
|
||||
throw new Error("Usage: omp auth-broker login <provider> [--via=user@host]");
|
||||
}
|
||||
const oauthProviders = new Set<string>(getOAuthProviders().map(p => p.id));
|
||||
if (!oauthProviders.has(providerArg)) {
|
||||
throw new Error(`Unknown OAuth provider '${providerArg}'. Known: ${[...oauthProviders].sort().join(", ")}`);
|
||||
}
|
||||
if (flags.via) {
|
||||
await runRemoteLogin(providerArg, flags.via, flags.dryRun ?? false);
|
||||
return;
|
||||
}
|
||||
await runLocalLogin(providerArg as OAuthProvider);
|
||||
}
|
||||
|
||||
async function runLocalLogin(provider: OAuthProvider): Promise<void> {
|
||||
// Spawn the pi-ai CLI in-process — it handles the per-provider OAuth dance
|
||||
// and persists into the same SQLite store the broker uses.
|
||||
const piAiCli = Bun.fileURLToPath(import.meta.resolve("@oh-my-pi/pi-ai/cli"));
|
||||
const proc = Bun.spawn({
|
||||
cmd: [process.execPath, piAiCli, "login", provider],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`pi-ai login exited with code ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRemoteLogin(provider: string, via: string, dryRun: boolean): Promise<void> {
|
||||
const port = CALLBACK_PORTS[provider];
|
||||
if (port === undefined) {
|
||||
throw new Error(
|
||||
`No known OAuth callback port for '${provider}'. Use device-code flow on the broker host directly.`,
|
||||
);
|
||||
}
|
||||
const sshArgs = [
|
||||
"-L",
|
||||
`${port}:127.0.0.1:${port}`,
|
||||
"-o",
|
||||
"ExitOnForwardFailure=yes",
|
||||
via,
|
||||
`${APP_NAME} auth-broker login ${provider}`,
|
||||
];
|
||||
if (dryRun) {
|
||||
process.stdout.write(`ssh ${sshArgs.map(a => (a.includes(" ") ? `'${a}'` : a)).join(" ")}\n`);
|
||||
return;
|
||||
}
|
||||
const sshBin = $which("ssh");
|
||||
if (!sshBin) {
|
||||
throw new Error("ssh binary not found in PATH");
|
||||
}
|
||||
const proc = Bun.spawn({
|
||||
cmd: [sshBin, ...sshArgs],
|
||||
stdin: "inherit",
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`ssh exited with code ${exitCode}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runLogout(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
const providerArg = flags.provider;
|
||||
if (!providerArg) {
|
||||
throw new Error("Usage: omp auth-broker logout <provider>");
|
||||
}
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
store.deleteAuthCredentialsForProvider(providerArg, "logged out by user");
|
||||
process.stdout.write(`Logged out of ${providerArg}\n`);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CLIProxyAPI import ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps the `type` field of a CLIProxyAPI credential JSON to the omp provider id.
|
||||
* The filename also encodes the type (e.g. `claude-foo@bar.json`), but the
|
||||
* in-file `type` is authoritative — we only fall back to filename if absent.
|
||||
*/
|
||||
const CLIPROXY_TYPE_TO_PROVIDER: Record<string, string> = {
|
||||
claude: "anthropic",
|
||||
codex: "openai-codex",
|
||||
gemini: "google-gemini-cli",
|
||||
antigravity: "google-antigravity",
|
||||
"gemini-cli": "google-gemini-cli",
|
||||
};
|
||||
|
||||
interface CliProxyCredentialJson {
|
||||
type?: string;
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expired?: string;
|
||||
last_refresh?: string;
|
||||
email?: string;
|
||||
account_id?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ImportPlanEntry {
|
||||
sourceFile: string;
|
||||
provider: string;
|
||||
email: string | null;
|
||||
accountId: string | null;
|
||||
expiresAt: number;
|
||||
disabled: boolean;
|
||||
credential: OAuthCredential;
|
||||
}
|
||||
|
||||
function resolveCliProxyProvider(json: CliProxyCredentialJson, filename: string, overrideId?: string): string | null {
|
||||
if (overrideId && overrideId.length > 0) return overrideId;
|
||||
const typeField = json.type?.trim().toLowerCase();
|
||||
if (typeField && CLIPROXY_TYPE_TO_PROVIDER[typeField]) return CLIPROXY_TYPE_TO_PROVIDER[typeField];
|
||||
// Fall back to filename prefix: `<type>-<email>.json`
|
||||
const base = path.basename(filename, ".json").toLowerCase();
|
||||
for (const prefix in CLIPROXY_TYPE_TO_PROVIDER) {
|
||||
const providerId = CLIPROXY_TYPE_TO_PROVIDER[prefix];
|
||||
if (base.startsWith(`${prefix}-`) || base === prefix) return providerId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCliProxyExpiry(raw: string | undefined): number | null {
|
||||
if (!raw) return null;
|
||||
// CLIProxyAPI writes RFC3339-ish dates. `Date.parse` handles both `Z` and offsets.
|
||||
const ms = Date.parse(raw);
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
async function collectImportSources(target: string): Promise<string[]> {
|
||||
const stat = await fs.stat(target);
|
||||
if (stat.isFile()) return [target];
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`Import source is neither file nor directory: ${target}`);
|
||||
}
|
||||
const entries = await fs.readdir(target, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.endsWith(".json")) continue;
|
||||
files.push(path.join(target, entry.name));
|
||||
}
|
||||
files.sort();
|
||||
return files;
|
||||
}
|
||||
|
||||
async function loadImportPlan(
|
||||
target: string,
|
||||
overrideProvider: string | undefined,
|
||||
includeDisabled: boolean,
|
||||
): Promise<{ entries: ImportPlanEntry[]; skipped: Array<{ file: string; reason: string }> }> {
|
||||
const files = await collectImportSources(target);
|
||||
const entries: ImportPlanEntry[] = [];
|
||||
const skipped: Array<{ file: string; reason: string }> = [];
|
||||
for (const file of files) {
|
||||
let json: CliProxyCredentialJson;
|
||||
try {
|
||||
json = (await Bun.file(file).json()) as CliProxyCredentialJson;
|
||||
} catch (err) {
|
||||
skipped.push({ file, reason: `unreadable JSON: ${String(err)}` });
|
||||
continue;
|
||||
}
|
||||
if (json.disabled === true && !includeDisabled) {
|
||||
skipped.push({ file, reason: "credential marked disabled (use --include-disabled to import anyway)" });
|
||||
continue;
|
||||
}
|
||||
const provider = resolveCliProxyProvider(json, file, overrideProvider);
|
||||
if (!provider) {
|
||||
skipped.push({
|
||||
file,
|
||||
reason: `cannot determine omp provider from type=${json.type ?? "?"} (pass --provider to override)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!json.access_token || !json.refresh_token) {
|
||||
skipped.push({ file, reason: "missing access_token or refresh_token" });
|
||||
continue;
|
||||
}
|
||||
const expiresAt = parseCliProxyExpiry(json.expired);
|
||||
if (expiresAt === null) {
|
||||
skipped.push({ file, reason: `cannot parse expired=${json.expired ?? "?"}` });
|
||||
continue;
|
||||
}
|
||||
const email = typeof json.email === "string" && json.email.length > 0 ? json.email : null;
|
||||
const accountId = typeof json.account_id === "string" && json.account_id.length > 0 ? json.account_id : null;
|
||||
const credential: OAuthCredential = {
|
||||
type: "oauth",
|
||||
access: json.access_token,
|
||||
refresh: json.refresh_token,
|
||||
expires: expiresAt,
|
||||
...(email !== null ? { email } : {}),
|
||||
...(accountId !== null ? { accountId } : {}),
|
||||
};
|
||||
entries.push({
|
||||
sourceFile: file,
|
||||
provider,
|
||||
email,
|
||||
accountId,
|
||||
expiresAt,
|
||||
disabled: json.disabled === true,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
return { entries, skipped };
|
||||
}
|
||||
|
||||
function describeImportEntry(entry: ImportPlanEntry): string {
|
||||
const ident = entry.email ?? entry.accountId ?? "(no identity)";
|
||||
const stale = entry.expiresAt < Date.now() ? " [expired]" : "";
|
||||
const disabled = entry.disabled ? " [disabled]" : "";
|
||||
return `${entry.provider}: ${ident}${stale}${disabled} from ${entry.sourceFile}`;
|
||||
}
|
||||
|
||||
async function runImport(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
const target = flags.source;
|
||||
if (!target) {
|
||||
throw new Error("Usage: omp auth-broker import <file|dir> [--provider=<id>] [--include-disabled] [--dry-run]");
|
||||
}
|
||||
const resolvedTarget = path.resolve(target.startsWith("~") ? target.replace(/^~/, os.homedir()) : target);
|
||||
const { entries, skipped } = await loadImportPlan(resolvedTarget, flags.provider, flags.includeDisabled === true);
|
||||
|
||||
if (flags.json) {
|
||||
process.stdout.write(
|
||||
`${JSON.stringify({
|
||||
dryRun: flags.dryRun === true,
|
||||
imported: flags.dryRun
|
||||
? []
|
||||
: entries.map(e => ({ provider: e.provider, email: e.email, file: e.sourceFile })),
|
||||
plan: entries.map(e => ({
|
||||
provider: e.provider,
|
||||
email: e.email,
|
||||
accountId: e.accountId,
|
||||
expiresAt: e.expiresAt,
|
||||
disabled: e.disabled,
|
||||
file: e.sourceFile,
|
||||
})),
|
||||
skipped,
|
||||
})}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!flags.json) {
|
||||
for (const skip of skipped) {
|
||||
process.stdout.write(`${chalk.yellow("skip")} ${skip.file}: ${skip.reason}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
if (!flags.json) process.stdout.write(`No importable credentials in ${resolvedTarget}.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flags.dryRun === true) {
|
||||
if (!flags.json) {
|
||||
process.stdout.write(`Dry run — would import ${entries.length} credential(s):\n`);
|
||||
for (const entry of entries) process.stdout.write(` ${describeImportEntry(entry)}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const brokerConfig = await resolveAuthBrokerConfig();
|
||||
if (brokerConfig) {
|
||||
const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await client.uploadCredential(entry.provider, entry.credential);
|
||||
if (!flags.json) {
|
||||
process.stdout.write(`${chalk.green("uploaded")} ${describeImportEntry(entry)} → ${brokerConfig.url}\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({ error: message, file: entry.sourceFile })}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${chalk.red("failed")} ${describeImportEntry(entry)}: ${message}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
for (const entry of entries) {
|
||||
store.upsertAuthCredentialForProvider(entry.provider, entry.credential);
|
||||
if (!flags.json) process.stdout.write(`${chalk.green("imported")} ${describeImportEntry(entry)}\n`);
|
||||
}
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runStatus(flags: AuthBrokerCommandArgs["flags"]): Promise<void> {
|
||||
const cfg = await resolveAuthBrokerConfig();
|
||||
if (!cfg) {
|
||||
const message = "No auth-broker configured (set OMP_AUTH_BROKER_URL to enable).";
|
||||
if (flags.json) process.stdout.write(`${JSON.stringify({ ok: false, reason: "not_configured" })}\n`);
|
||||
else process.stdout.write(`${chalk.yellow(message)}\n`);
|
||||
return;
|
||||
}
|
||||
const client = new AuthBrokerClient({ url: cfg.url, token: cfg.token });
|
||||
try {
|
||||
const health = await client.healthz();
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({ url: cfg.url, ...health })}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${chalk.green("OK")} ${cfg.url} (version=${health.version ?? "unknown"})\n`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({ ok: false, url: cfg.url, error: message })}\n`);
|
||||
} else {
|
||||
process.stdout.write(`${chalk.red("FAILED")} ${cfg.url}: ${message}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runAuthBrokerCommand(cmd: AuthBrokerCommandArgs): Promise<void> {
|
||||
switch (cmd.action) {
|
||||
case "serve":
|
||||
await runServe(cmd.flags);
|
||||
return;
|
||||
case "token":
|
||||
await runToken(cmd.flags);
|
||||
return;
|
||||
case "login":
|
||||
await runLogin(cmd.flags);
|
||||
return;
|
||||
case "logout":
|
||||
await runLogout(cmd.flags);
|
||||
return;
|
||||
case "import":
|
||||
await runImport(cmd.flags);
|
||||
return;
|
||||
case "status":
|
||||
await runStatus(cmd.flags);
|
||||
return;
|
||||
default: {
|
||||
// Exhaustive check.
|
||||
const _exhaustive: never = cmd.action;
|
||||
throw new Error(`Unknown auth-broker action: ${String(_exhaustive)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ACTIONS as AUTH_BROKER_ACTIONS };
|
||||
|
||||
// Touch `$` so Bun's tree-shaker keeps the shell helper imported (used by future verbs).
|
||||
void $;
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* `omp auth-broker` — manage the omp credential vault.
|
||||
*/
|
||||
import { Args, Command, Flags, renderCommandHelp } from "@oh-my-pi/pi-utils/cli";
|
||||
import {
|
||||
AUTH_BROKER_ACTIONS,
|
||||
type AuthBrokerAction,
|
||||
type AuthBrokerCommandArgs,
|
||||
runAuthBrokerCommand,
|
||||
} from "../cli/auth-broker-cli";
|
||||
import { initTheme } from "../modes/theme/theme";
|
||||
|
||||
export default class AuthBroker extends Command {
|
||||
static description = "Manage the omp auth-broker (credential vault)";
|
||||
|
||||
static args = {
|
||||
action: Args.string({
|
||||
description: "Sub-command",
|
||||
required: false,
|
||||
options: [...AUTH_BROKER_ACTIONS],
|
||||
}),
|
||||
// Second positional: provider id (login/logout) or filesystem path (import).
|
||||
source: Args.string({
|
||||
description: "OAuth provider id (login/logout) or path (import)",
|
||||
required: false,
|
||||
}),
|
||||
};
|
||||
|
||||
static flags = {
|
||||
json: Flags.boolean({ description: "Output JSON" }),
|
||||
bind: Flags.string({ description: "Bind address for `serve` (host:port)", char: "b" }),
|
||||
regenerate: Flags.boolean({ description: "Regenerate the bearer token" }),
|
||||
via: Flags.string({
|
||||
description: "SSH user@host for remote login (login --via=user@host)",
|
||||
}),
|
||||
provider: Flags.string({
|
||||
description: "Override provider id for `import` (e.g. when JSON `type` is unrecognized)",
|
||||
}),
|
||||
"include-disabled": Flags.boolean({
|
||||
description: "Import credentials whose JSON has `disabled: true` (import)",
|
||||
}),
|
||||
"dry-run": Flags.boolean({ description: "Print actions without executing (import / login --via)" }),
|
||||
};
|
||||
|
||||
static examples = [
|
||||
"# Boot the broker against the local SQLite store\n omp auth-broker serve",
|
||||
"# Boot on a non-default port\n omp auth-broker serve --bind=127.0.0.1:9000",
|
||||
"# Print the bearer token\n omp auth-broker token",
|
||||
"# Rotate the bearer token\n omp auth-broker token --regenerate",
|
||||
"# Local login (run on the broker host)\n omp auth-broker login anthropic",
|
||||
"# Remote login over SSH tunnel\n omp auth-broker login anthropic --via=user@broker",
|
||||
"# Import a CLIProxyAPI auth dump\n omp auth-broker import ~/.cliproxy/auth",
|
||||
"# Import a single CLIProxyAPI JSON, overriding the provider mapping\n omp auth-broker import ~/.cliproxy/auth/claude-foo.json --provider anthropic",
|
||||
"# Health-check the configured remote broker\n omp auth-broker status",
|
||||
];
|
||||
|
||||
async run(): Promise<void> {
|
||||
const { args, flags } = await this.parse(AuthBroker);
|
||||
if (!args.action) {
|
||||
renderCommandHelp("omp", "auth-broker", AuthBroker);
|
||||
return;
|
||||
}
|
||||
const action = args.action as AuthBrokerAction;
|
||||
const cmd: AuthBrokerCommandArgs = {
|
||||
action,
|
||||
flags: {
|
||||
json: flags.json,
|
||||
bind: flags.bind,
|
||||
regenerate: flags.regenerate,
|
||||
via: flags.via,
|
||||
// `login`/`logout` reuse the legacy `provider` slot; `import` keeps `source` separate
|
||||
// so `provider` flag (used as an override) is unambiguous.
|
||||
provider: action === "import" ? flags.provider : (args.source ?? flags.provider),
|
||||
source: args.source,
|
||||
includeDisabled: flags["include-disabled"],
|
||||
dryRun: flags["dry-run"],
|
||||
},
|
||||
};
|
||||
await initTheme();
|
||||
await runAuthBrokerCommand(cmd);
|
||||
}
|
||||
}
|
||||
@@ -234,6 +234,13 @@ export const SETTINGS_SCHEMA = {
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
lastChangelogVersion: { type: "string", default: undefined },
|
||||
|
||||
// Auth broker — credentials proxied through a remote `omp auth-broker serve`
|
||||
// host. Hidden from the UI; populate via env vars or hand-edited config.yml.
|
||||
// Env (`OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN`) takes precedence so
|
||||
// per-machine overrides remain trivial.
|
||||
"auth.broker.url": { type: "string", default: undefined },
|
||||
"auth.broker.token": { type: "string", default: undefined },
|
||||
|
||||
autoResume: {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
|
||||
@@ -374,10 +374,15 @@ export class CommandController {
|
||||
const openaiWebsocketSetting = this.ctx.settings.get("providers.openaiWebsockets") ?? "auto";
|
||||
const preferOpenAICodexWebsockets =
|
||||
openaiWebsocketSetting === "on" ? true : openaiWebsocketSetting === "off" ? false : undefined;
|
||||
const credentialSource = this.ctx.session.modelRegistry.authStorage.describeCredentialSource(
|
||||
model.provider,
|
||||
stats.sessionId,
|
||||
);
|
||||
const providerDetails = getProviderDetails({
|
||||
model,
|
||||
sessionId: stats.sessionId,
|
||||
authMode,
|
||||
credentialSource,
|
||||
preferWebsockets: preferOpenAICodexWebsockets,
|
||||
providerSessionState: this.ctx.session.providerSessionState,
|
||||
});
|
||||
|
||||
@@ -93,7 +93,13 @@ import {
|
||||
SecretObfuscator,
|
||||
} from "./secrets";
|
||||
import { AgentSession } from "./session/agent-session";
|
||||
import { AuthStorage } from "./session/auth-storage";
|
||||
import { resolveAuthBrokerConfig } from "./session/auth-broker-config";
|
||||
import {
|
||||
AuthBrokerClient,
|
||||
AuthStorage,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
RemoteAuthCredentialStore,
|
||||
} from "./session/auth-storage";
|
||||
import { convertToLlm } from "./session/messages";
|
||||
import { SessionManager } from "./session/session-manager";
|
||||
import { closeAllConnections } from "./ssh/connection-manager";
|
||||
@@ -317,13 +323,52 @@ function getDefaultAgentDir(): string {
|
||||
// Discovery Functions
|
||||
|
||||
/**
|
||||
* Create an AuthStorage instance with fallback support.
|
||||
* Reads from primary path first, then falls back to legacy paths (.pi, .claude).
|
||||
* Create an AuthStorage instance.
|
||||
*
|
||||
* Default: local SQLite store at `<agentDir>/agent.db`.
|
||||
*
|
||||
* Broker mode: when `OMP_AUTH_BROKER_URL` is set, credentials are pulled from
|
||||
* a remote auth-broker over the wire. Refresh tokens never leave the broker;
|
||||
* the client receives access tokens with `refresh = "__remote__"` and calls
|
||||
* back into the broker through the {@link AuthStorageOptions.refreshOAuthCredential}
|
||||
* override to re-mint access tokens when needed.
|
||||
*/
|
||||
export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
|
||||
const brokerConfig = await resolveAuthBrokerConfig();
|
||||
if (brokerConfig) {
|
||||
const client = new AuthBrokerClient({ url: brokerConfig.url, token: brokerConfig.token });
|
||||
const initialSnapshot = await client.fetchSnapshot();
|
||||
const store = new RemoteAuthCredentialStore({ client, initialSnapshot });
|
||||
const storage = new AuthStorage(store, {
|
||||
configValueResolver: resolveConfigValue,
|
||||
sourceLabel: `broker ${brokerConfig.url}`,
|
||||
refreshOAuthCredential: async (_provider, credentialId, _credential) => {
|
||||
const { entry } = await client.refreshCredential(credentialId);
|
||||
if (entry.credential.type !== "oauth") {
|
||||
throw new Error(`Broker returned non-OAuth credential for id=${credentialId}`);
|
||||
}
|
||||
const refreshed = entry.credential;
|
||||
return {
|
||||
access: refreshed.access,
|
||||
// Sentinel — AuthStorage stores it back into the in-memory snapshot,
|
||||
// but a refresh through the broker is the only legal way to mint tokens.
|
||||
refresh: REMOTE_REFRESH_SENTINEL,
|
||||
expires: refreshed.expires,
|
||||
accountId: refreshed.accountId,
|
||||
email: refreshed.email,
|
||||
projectId: refreshed.projectId,
|
||||
enterpriseUrl: refreshed.enterpriseUrl,
|
||||
};
|
||||
},
|
||||
});
|
||||
await storage.reload();
|
||||
return storage;
|
||||
}
|
||||
const dbPath = getAgentDbPath(agentDir);
|
||||
|
||||
const storage = await AuthStorage.create(dbPath, { configValueResolver: resolveConfigValue });
|
||||
const storage = await AuthStorage.create(dbPath, {
|
||||
configValueResolver: resolveConfigValue,
|
||||
sourceLabel: `local ${dbPath}`,
|
||||
});
|
||||
await storage.reload();
|
||||
return storage;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Resolve auth-broker connection configuration for the local omp client.
|
||||
*
|
||||
* Precedence (highest first):
|
||||
* 1. `OMP_AUTH_BROKER_URL` / `OMP_AUTH_BROKER_TOKEN` env vars.
|
||||
* 2. `auth.broker.url` / `auth.broker.token` in `~/.omp/agent/config.yml`
|
||||
* (hidden from the settings UI; `!command` resolution supported).
|
||||
* 3. Token file `~/.omp/auth-broker.token` (paired with URL from env or config).
|
||||
*
|
||||
* Returns null when no broker URL is configured — caller falls back to the
|
||||
* local SQLite store.
|
||||
*
|
||||
* Reads config.yml directly (instead of going through `Settings.init`) because
|
||||
* `discoverAuthStorage` runs before the settings singleton is initialized in
|
||||
* `runRootCommand`, and we want hand-edited config entries to be honoured at
|
||||
* boot without forcing a startup reorder.
|
||||
*/
|
||||
import * as path from "node:path";
|
||||
import { getAgentDir, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
import { YAML } from "bun";
|
||||
import { resolveConfigValue } from "../config/resolve-config-value";
|
||||
|
||||
export interface AuthBrokerClientConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
/** Path to the local bearer token file. Created on the broker host by `omp auth-broker token`. */
|
||||
export function getAuthBrokerTokenFilePath(): string {
|
||||
return path.join(getConfigRootDir(), "auth-broker.token");
|
||||
}
|
||||
|
||||
async function readTokenFile(): Promise<string | null> {
|
||||
try {
|
||||
const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
|
||||
const trimmed = raw.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return null;
|
||||
logger.warn("auth-broker token file unreadable", { error: String(err) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConfigSnapshot {
|
||||
url?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
async function readConfigYaml(): Promise<ConfigSnapshot> {
|
||||
const configPath = path.join(getAgentDir(), "config.yml");
|
||||
try {
|
||||
const raw = await Bun.file(configPath).text();
|
||||
const parsed = YAML.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const url = typeof record["auth.broker.url"] === "string" ? (record["auth.broker.url"] as string) : undefined;
|
||||
const token =
|
||||
typeof record["auth.broker.token"] === "string" ? (record["auth.broker.token"] as string) : undefined;
|
||||
return { url, token };
|
||||
} catch (err) {
|
||||
if (isEnoent(err)) return {};
|
||||
logger.warn("auth-broker config.yml unreadable", { error: String(err) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read broker configuration. Returns null when the URL is missing
|
||||
* (broker disabled — local store is used). Throws when URL is set but no
|
||||
* token is available — the caller cannot fall back silently because the
|
||||
* user explicitly asked to use the broker.
|
||||
*/
|
||||
export async function resolveAuthBrokerConfig(): Promise<AuthBrokerClientConfig | null> {
|
||||
const envUrl = process.env.OMP_AUTH_BROKER_URL;
|
||||
const envToken = process.env.OMP_AUTH_BROKER_TOKEN;
|
||||
|
||||
let url = envUrl && envUrl.length > 0 ? envUrl : undefined;
|
||||
let configToken: string | undefined;
|
||||
if (!url || !envToken) {
|
||||
const fromConfig = await readConfigYaml();
|
||||
if (!url && fromConfig.url) {
|
||||
const resolved = await resolveConfigValue(fromConfig.url);
|
||||
if (resolved && resolved.length > 0) url = resolved;
|
||||
}
|
||||
if (fromConfig.token) {
|
||||
const resolved = await resolveConfigValue(fromConfig.token);
|
||||
if (resolved && resolved.length > 0) configToken = resolved;
|
||||
}
|
||||
}
|
||||
if (!url) return null;
|
||||
|
||||
const token =
|
||||
(envToken && envToken.length > 0 ? envToken : undefined) ?? configToken ?? (await readTokenFile()) ?? undefined;
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`OMP_AUTH_BROKER_URL is set (${url}) but no bearer token is available. ` +
|
||||
`Set OMP_AUTH_BROKER_TOKEN, the \`auth.broker.token\` config entry, or place one at ${getAuthBrokerTokenFilePath()}.`,
|
||||
);
|
||||
}
|
||||
return { url, token };
|
||||
}
|
||||
@@ -14,4 +14,10 @@ export type {
|
||||
SerializedAuthStorage,
|
||||
StoredAuthCredential,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
export { AuthStorage, SqliteAuthCredentialStore } from "@oh-my-pi/pi-ai";
|
||||
export {
|
||||
AuthBrokerClient,
|
||||
AuthStorage,
|
||||
REMOTE_REFRESH_SENTINEL,
|
||||
RemoteAuthCredentialStore,
|
||||
SqliteAuthCredentialStore,
|
||||
} from "@oh-my-pi/pi-ai";
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { type AuthBrokerServerHandle, AuthStorage, SqliteAuthCredentialStore, startAuthBroker } from "@oh-my-pi/pi-ai";
|
||||
import { getAgentDbPath, setAgentDir } from "@oh-my-pi/pi-utils";
|
||||
import { runAuthBrokerCommand } from "../src/cli/auth-broker-cli";
|
||||
|
||||
const ORIGINAL_STDOUT_WRITE = process.stdout.write.bind(process.stdout);
|
||||
|
||||
function silenceStdout(): () => string {
|
||||
let captured = "";
|
||||
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
|
||||
captured += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
return () => captured;
|
||||
}
|
||||
|
||||
describe("auth-broker import (CLIProxyAPI)", () => {
|
||||
let agentDir = "";
|
||||
let cliproxyDir = "";
|
||||
let originalAgentDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalAgentDir = process.env.OMP_AGENT_DIR;
|
||||
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-import-agent-"));
|
||||
cliproxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-import-cliproxy-"));
|
||||
setAgentDir(agentDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
process.stdout.write = ORIGINAL_STDOUT_WRITE;
|
||||
if (originalAgentDir === undefined) delete process.env.OMP_AGENT_DIR;
|
||||
else process.env.OMP_AGENT_DIR = originalAgentDir;
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(cliproxyDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function writeCliProxyJson(name: string, body: Record<string, unknown>): Promise<string> {
|
||||
const file = path.join(cliproxyDir, name);
|
||||
await Bun.write(file, JSON.stringify(body));
|
||||
return file;
|
||||
}
|
||||
|
||||
test("imports a directory of CLIProxyAPI JSONs and maps types to omp providers", async () => {
|
||||
await writeCliProxyJson("claude-sample.json", {
|
||||
type: "claude",
|
||||
access_token: "claude-access-1",
|
||||
refresh_token: "claude-refresh-1",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "claude-user@example.com",
|
||||
id_token: "ignored",
|
||||
last_refresh: "2025-01-01T00:00:00Z",
|
||||
});
|
||||
await writeCliProxyJson("codex-sample.json", {
|
||||
type: "codex",
|
||||
access_token: "codex-access-1",
|
||||
refresh_token: "codex-refresh-1",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "codex-user@example.com",
|
||||
account_id: "acct-codex-1",
|
||||
websockets: true,
|
||||
});
|
||||
await writeCliProxyJson("disabled.json", {
|
||||
type: "claude",
|
||||
access_token: "x",
|
||||
refresh_token: "y",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "disabled@example.com",
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const restore = silenceStdout();
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir, json: false },
|
||||
});
|
||||
restore();
|
||||
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
const claude = store.listAuthCredentials("anthropic");
|
||||
expect(claude).toHaveLength(1);
|
||||
expect(claude[0].credential.type).toBe("oauth");
|
||||
if (claude[0].credential.type === "oauth") {
|
||||
expect(claude[0].credential.access).toBe("claude-access-1");
|
||||
expect(claude[0].credential.refresh).toBe("claude-refresh-1");
|
||||
expect(claude[0].credential.email).toBe("claude-user@example.com");
|
||||
expect(claude[0].credential.expires).toBe(Date.parse("2099-12-31T23:59:59Z"));
|
||||
}
|
||||
|
||||
const codex = store.listAuthCredentials("openai-codex");
|
||||
expect(codex).toHaveLength(1);
|
||||
if (codex[0].credential.type === "oauth") {
|
||||
expect(codex[0].credential.access).toBe("codex-access-1");
|
||||
expect(codex[0].credential.accountId).toBe("acct-codex-1");
|
||||
}
|
||||
|
||||
// disabled.json was skipped by default
|
||||
const disabled = store
|
||||
.listAuthCredentials("anthropic")
|
||||
.find(r => r.credential.type === "oauth" && r.credential.email === "disabled@example.com");
|
||||
expect(disabled).toBeUndefined();
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("dry-run does not write any credentials", async () => {
|
||||
await writeCliProxyJson("claude.json", {
|
||||
type: "claude",
|
||||
access_token: "a",
|
||||
refresh_token: "b",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "dryrun@example.com",
|
||||
});
|
||||
|
||||
const restore = silenceStdout();
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir, dryRun: true, json: true },
|
||||
});
|
||||
const output = restore();
|
||||
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
expect(store.listAuthCredentials()).toHaveLength(0);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
const parsed = JSON.parse(output.trim().split("\n").pop() ?? "{}");
|
||||
expect(parsed.dryRun).toBe(true);
|
||||
expect(parsed.plan).toHaveLength(1);
|
||||
expect(parsed.plan[0].provider).toBe("anthropic");
|
||||
});
|
||||
|
||||
test("--provider override forces a provider id when the JSON type is unrecognized", async () => {
|
||||
await writeCliProxyJson("weird.json", {
|
||||
type: "some-future-type",
|
||||
access_token: "z",
|
||||
refresh_token: "w",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "future@example.com",
|
||||
});
|
||||
|
||||
const restore = silenceStdout();
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir, provider: "anthropic" },
|
||||
});
|
||||
restore();
|
||||
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
const rows = store.listAuthCredentials("anthropic");
|
||||
expect(rows).toHaveLength(1);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("--include-disabled imports rows marked disabled", async () => {
|
||||
await writeCliProxyJson("disabled.json", {
|
||||
type: "claude",
|
||||
access_token: "d",
|
||||
refresh_token: "e",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "disabled-import@example.com",
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
const restore = silenceStdout();
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir, includeDisabled: true },
|
||||
});
|
||||
restore();
|
||||
|
||||
const store = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
expect(store.listAuthCredentials("anthropic")).toHaveLength(1);
|
||||
} finally {
|
||||
store.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("auth-broker import (broker-routed)", () => {
|
||||
let agentDir = "";
|
||||
let brokerAgentDir = "";
|
||||
let cliproxyDir = "";
|
||||
let brokerStore: SqliteAuthCredentialStore | undefined;
|
||||
let brokerStorage: AuthStorage | undefined;
|
||||
let handle: AuthBrokerServerHandle | undefined;
|
||||
const token = "broker-import-bearer";
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(async () => {
|
||||
savedEnv.OMP_AUTH_BROKER_URL = process.env.OMP_AUTH_BROKER_URL;
|
||||
savedEnv.OMP_AUTH_BROKER_TOKEN = process.env.OMP_AUTH_BROKER_TOKEN;
|
||||
agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-import-client-"));
|
||||
brokerAgentDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-import-broker-"));
|
||||
cliproxyDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-import-cliproxy-broker-"));
|
||||
setAgentDir(agentDir);
|
||||
|
||||
brokerStore = await SqliteAuthCredentialStore.open(path.join(brokerAgentDir, "agent.db"));
|
||||
brokerStorage = new AuthStorage(brokerStore);
|
||||
await brokerStorage.reload();
|
||||
handle = startAuthBroker({
|
||||
storage: brokerStorage,
|
||||
bind: "127.0.0.1:0",
|
||||
bearerTokens: [token],
|
||||
disableRefresher: true,
|
||||
});
|
||||
process.env.OMP_AUTH_BROKER_URL = handle.url;
|
||||
process.env.OMP_AUTH_BROKER_TOKEN = token;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await handle?.close();
|
||||
brokerStorage?.close();
|
||||
brokerStore?.close();
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(brokerAgentDir, { recursive: true, force: true });
|
||||
await fs.rm(cliproxyDir, { recursive: true, force: true });
|
||||
for (const key of ["OMP_AUTH_BROKER_URL", "OMP_AUTH_BROKER_TOKEN"] as const) {
|
||||
if (savedEnv[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedEnv[key];
|
||||
}
|
||||
});
|
||||
|
||||
test("uploads CLIProxyAPI JSONs to the broker when configured, not the local store", async () => {
|
||||
await Bun.write(
|
||||
path.join(cliproxyDir, "claude-foo@bar.json"),
|
||||
JSON.stringify({
|
||||
type: "claude",
|
||||
access_token: "broker-access",
|
||||
refresh_token: "broker-refresh-real",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "foo@bar.com",
|
||||
}),
|
||||
);
|
||||
|
||||
const ORIGINAL_STDOUT = process.stdout.write.bind(process.stdout);
|
||||
let captured = "";
|
||||
process.stdout.write = ((chunk: string | Uint8Array): boolean => {
|
||||
captured += typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir },
|
||||
});
|
||||
} finally {
|
||||
process.stdout.write = ORIGINAL_STDOUT;
|
||||
}
|
||||
|
||||
// The broker received it (and persisted the real refresh token).
|
||||
const persisted = brokerStore!.getOAuth("anthropic");
|
||||
expect(persisted?.access).toBe("broker-access");
|
||||
expect(persisted?.refresh).toBe("broker-refresh-real");
|
||||
expect(persisted?.email).toBe("foo@bar.com");
|
||||
|
||||
// The local client SQLite store was NOT touched.
|
||||
const localStore = await SqliteAuthCredentialStore.open(getAgentDbPath());
|
||||
try {
|
||||
expect(localStore.listAuthCredentials()).toHaveLength(0);
|
||||
} finally {
|
||||
localStore.close();
|
||||
}
|
||||
|
||||
expect(captured).toContain("uploaded");
|
||||
expect(captured).toContain(handle!.url);
|
||||
});
|
||||
|
||||
test("dry-run does not upload even when broker is configured", async () => {
|
||||
await Bun.write(
|
||||
path.join(cliproxyDir, "claude-dry.json"),
|
||||
JSON.stringify({
|
||||
type: "claude",
|
||||
access_token: "a",
|
||||
refresh_token: "b",
|
||||
expired: "2099-12-31T23:59:59Z",
|
||||
email: "dry@example.com",
|
||||
}),
|
||||
);
|
||||
|
||||
const ORIGINAL_STDOUT = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = (() => true) as typeof process.stdout.write;
|
||||
try {
|
||||
await runAuthBrokerCommand({
|
||||
action: "import",
|
||||
flags: { source: cliproxyDir, dryRun: true },
|
||||
});
|
||||
} finally {
|
||||
process.stdout.write = ORIGINAL_STDOUT;
|
||||
}
|
||||
|
||||
expect(brokerStore!.listAuthCredentials()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,19 @@ export function warn(message: string, context?: Record<string, unknown>): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an informational message.
|
||||
* @param message - The message to log.
|
||||
* @param context - The context to log.
|
||||
*/
|
||||
export function info(message: string, context?: Record<string, unknown>): void {
|
||||
try {
|
||||
winstonLogger.info(message, context);
|
||||
} catch {
|
||||
// Silently ignore logging failures
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message.
|
||||
* @param message - The message to log.
|
||||
|
||||
Reference in New Issue
Block a user