The owner keeps a personal account and a company one and moves between them during a day — by what the work
is, and sometimes because one has hit a limit and the work has to carry on. /login handles ARRIVING at an
account. It could not go back to one, because the credential it overwrote was gone.
ai_provider_usage already answered "which of my accounts has room". This answers what immediately follows:
"then use that one". The chips under the numbers stop selecting whose stats you read and start selecting who
you ARE — which turned out to be the useful half of the same gesture, since reading the other account's
percentages was only ever a prelude to using it.
anthropic_accounts holds each login's OAuth object encrypted, keyed (user_id, account_uuid).
One key per ACCOUNT, not one for the table: encryptSecret('anthropic-account:<uuid>', …), so the secret
store mints a separate key each and one leak is one account. Same reasoning that split VAULT_STORE_KEY into
purposes. The uuid comes from Anthropic and survives a re-login; the email is stable too but is editable at
the provider.
What is stored includes a REFRESH token, deliberately — the access token lives hours and the refresh token
is what makes a switch work a week later. So a row plus its key is thirty days of acting as that account.
That was accepted knowingly; the per-account key narrows it, and refreshExpiresAt is a column so a dead
credential gets purged on read rather than sitting here for ever. An expired one cannot be switched to at
all: reveal returns null, because writing an expired credential would sign the owner out with no message.
Capture happens on login AND on every read of the account list. Without that second one this would know
only about logins that went through this dialog, and an account signed into from a terminal would be
invisible to the switcher — half your accounts missing, silently. The switch also captures the OUTGOING
credential first, or switching away from a terminal login loses it.
.credentials.json is replaced wholesale; .claude.json is PATCHED. The second holds the CLI's entire config
and overwriting it would take out every project entry and preference in it.
Owner only, and the isolation is unchanged: getOwnerHomeDir ignores the email it is passed, so every path
here is the owner's. A member's claude reads their own home and none of this touches it. The earlier claim
in this work that the credential was "machine-wide" was wrong — the owner corrected it. What a switch
actually changes is every claude running AS THE OWNER: terminals, task runners, pipelines, the commander.
The spinner is not decoration. A switch rewrites a file and changes nothing visible, so without it the
honest reading of a click is "nothing happened" and the second click is somebody checking. A running turn
keeps the credential it was spawned with, and the response says so rather than leaving the UI to imply
otherwise.
Verified before shipping: capture records the live account with org, plan and expiry; reveal decrypts
byte-identical to what is on disk; /api/claude-auth/accounts is 401 unauthenticated and 403 for a non-owner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
17 KiB
TypeScript
385 lines
17 KiB
TypeScript
import type { ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
import { spawn } from 'node:child_process';
|
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import {
|
|
listAnthropicAccounts,
|
|
markAnthropicAccountUsed,
|
|
purgeExpiredAnthropicAccounts,
|
|
revealAnthropicAccount,
|
|
saveAnthropicAccount,
|
|
} from 'officerdb';
|
|
import { createRouter } from '../../create-router';
|
|
import { getHomeDir, getOwnerHomeDir } from '../../data-path';
|
|
import { claudeBinIn } from '../../os-user-claude';
|
|
import { runAsArgv } from '../../os-user';
|
|
|
|
/*
|
|
`/login` in the chat composer, doing what it does in a terminal.
|
|
|
|
Officer's members and its owner both authenticate `claude` with a Claude SUBSCRIPTION over OAuth, not with
|
|
an Anthropic API key — see `sidecar/claude/state.ts`. That flow is a browser round trip: the CLI prints a
|
|
URL, you authorise in claude.com, and you paste a code back. It is the one part of running an agent that
|
|
a browser-only user could not do from the browser: they had to open a terminal panel and drive the CLI by
|
|
hand, which works and is exactly the thing this removes.
|
|
|
|
It needs no pty. `claude auth login` behaves identically through plain pipes — verified 2026-08-21 by
|
|
capturing its raw bytes — so this is an ordinary subprocess with two reads and one write:
|
|
|
|
spawn ─► read stdout until the URL ─► [person authorises] ─► write the code to stdin ─► exit
|
|
|
|
The one non-obvious piece is the parsing. The CLI wraps the URL in an OSC-8 hyperlink escape AND prints it
|
|
again as visible text, so a naive `https://\S+` match returns two URLs concatenated with no separator.
|
|
Stripping the escape wrappers first leaves exactly one.
|
|
*/
|
|
|
|
/** OSC-8 hyperlink: `ESC ] 8 ; ; <uri> BEL` … `ESC ] 8 ; ; BEL`. Both halves go. */
|
|
const OSC8 = /\x1b\]8;;[^\x07\x1b]*(?:\x07|\x1b\\)/g;
|
|
const SGR = /\x1b\[[0-9;]*m/g;
|
|
|
|
const visibleText = (raw: string): string => raw.replace(OSC8, '').replace(SGR, '');
|
|
|
|
/** The URL the person has to open, or null while the CLI has not printed it yet. */
|
|
export function extractLoginUrl(raw: string): string | null {
|
|
const m = visibleText(raw).match(/https:\/\/\S+/);
|
|
return m ? m[0] : null;
|
|
}
|
|
|
|
/** The CLI is waiting on stdin. Distinct from "printed a URL" — it prints the URL first. */
|
|
export const awaitingCode = (raw: string): boolean => /Paste code here/i.test(visibleText(raw));
|
|
|
|
type Pending = {
|
|
userId: number;
|
|
child: ChildProcessWithoutNullStreams;
|
|
url: string;
|
|
out: string;
|
|
reaper: ReturnType<typeof setTimeout>;
|
|
};
|
|
|
|
/*
|
|
In memory, and deliberately not in Postgres.
|
|
|
|
A pending login is worth nothing after the process holding its PKCE verifier dies, so persisting it would
|
|
store a row that can only ever be stale. A restart losing them is correct: the code in the person's
|
|
clipboard is already useless, and the honest recovery is to type `/login` again.
|
|
*/
|
|
const pending = new Map<string, Pending>();
|
|
|
|
/*
|
|
The authorisation code expires and so does the person's patience. Ten minutes is long enough to find a
|
|
password manager and short enough that a forgotten tab does not leave a `claude` process parked on a
|
|
read of stdin for the rest of the week.
|
|
*/
|
|
const TTL_MS = 10 * 60 * 1000;
|
|
|
|
function abandon(id: string): void {
|
|
const p = pending.get(id);
|
|
if (!p) return;
|
|
clearTimeout(p.reaper);
|
|
pending.delete(id);
|
|
try {
|
|
p.child.kill('SIGTERM');
|
|
} catch {
|
|
// Already gone. Nothing to do and nothing worth logging.
|
|
}
|
|
}
|
|
|
|
type Who = { id: number; isOwner: boolean; osUser: string | null; email: string };
|
|
|
|
/**
|
|
* The login process for one account.
|
|
*
|
|
* The owner runs it as themselves, in their real home, so it refreshes the very credentials
|
|
* `officer-anthropic-proxy` reads. A member runs through `runAsArgv` — `setpriv --reset-env` — as their own
|
|
* Linux user, writing into their own home, which is the whole point: their turns bill their subscription
|
|
* rather than the owner's, and that only holds if the credential is theirs.
|
|
*
|
|
* Nothing Anthropic-shaped is inherited in either case. For the owner those variables point at the local
|
|
* proxy, and the proxy exists to swap a shared secret for the owner's bearer token — handing them to a
|
|
* login is how you get a "success" that authenticates nothing.
|
|
*/
|
|
function spawnLogin(who: Who): ChildProcessWithoutNullStreams {
|
|
const stripped = { ...process.env };
|
|
for (const name of [
|
|
'ANTHROPIC_API_KEY',
|
|
'ANTHROPIC_AUTH_TOKEN',
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_FOUNDRY_API_KEY',
|
|
'_CLAUDE_CODE_ASSUME_FIRST_PARTY_BASE_URL',
|
|
'CLAUDE_CODE_OAUTH_TOKEN',
|
|
])
|
|
delete stripped[name];
|
|
|
|
if (who.isOwner) {
|
|
return spawn('claude', ['auth', 'login', '--claudeai'], {
|
|
env: stripped,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
}) as ChildProcessWithoutNullStreams;
|
|
}
|
|
|
|
if (!who.osUser) throw new Error('no Linux account: an agent for this member cannot hold its own credentials');
|
|
|
|
const home = getHomeDir(who.email);
|
|
// `env` inside the argv, because `--reset-env` discards anything handed to `setpriv` itself. Only PATH,
|
|
// and only so `claude` can find `node`; nothing here carries a credential.
|
|
const [launcher, ...args] = runAsArgv(who.osUser, [claudeBinIn(home), 'auth', 'login', '--claudeai']);
|
|
if (!launcher) throw new Error('runAsArgv returned an empty argv');
|
|
return spawn(launcher, args, { cwd: home, stdio: ['pipe', 'pipe', 'pipe'] }) as ChildProcessWithoutNullStreams;
|
|
}
|
|
|
|
/** Reads until the URL appears, or the process dies, or we run out of patience waiting for it. */
|
|
function urlFrom(child: ChildProcessWithoutNullStreams): Promise<{ url: string; out: string }> {
|
|
return new Promise((resolve, reject) => {
|
|
let out = '';
|
|
// Separate from TTL_MS: this is the CLI failing to start, which shows up as silence rather than an error.
|
|
const giveUp = setTimeout(
|
|
() => reject(new Error(`no login URL after 25s. Output so far: ${visibleText(out)}`)),
|
|
25_000,
|
|
);
|
|
const settle = (fn: () => void) => {
|
|
clearTimeout(giveUp);
|
|
fn();
|
|
};
|
|
const onChunk = (b: Buffer) => {
|
|
out += b.toString();
|
|
const url = extractLoginUrl(out);
|
|
if (url) settle(() => resolve({ url, out }));
|
|
};
|
|
child.stdout.on('data', onChunk);
|
|
// The CLI prints the URL to stdout, but its warnings go to stderr and one of them explains a failure.
|
|
child.stderr.on('data', (b: Buffer) => {
|
|
out += b.toString();
|
|
});
|
|
child.on('error', (err) => settle(() => reject(err)));
|
|
child.on('exit', (code) =>
|
|
settle(() => reject(new Error(`claude auth login exited ${code} before printing a URL: ${visibleText(out)}`))),
|
|
);
|
|
});
|
|
}
|
|
|
|
export const claudeLoginRouter = createRouter();
|
|
|
|
// POST /auth/claude-login — start one, and hand back the URL to open.
|
|
claudeLoginRouter.post('/', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const who: Who = { id: user.id, isOwner: user.id === 1, osUser: user.osUser ?? null, email: user.email };
|
|
|
|
// One at a time per account. A second `/login` while one is open is a person retrying, not a person
|
|
// wanting two, and two processes racing to write one credentials file is a corrupt file.
|
|
for (const [id, p] of pending) if (p.userId === who.id) abandon(id);
|
|
|
|
let child: ChildProcessWithoutNullStreams;
|
|
try {
|
|
child = spawnLogin(who);
|
|
} catch (err) {
|
|
return ctx.json({ error: err instanceof Error ? err.message : 'could not start the login' }, 400);
|
|
}
|
|
|
|
try {
|
|
const { url, out } = await urlFrom(child);
|
|
const id = crypto.randomUUID();
|
|
pending.set(id, { userId: who.id, child, url, out, reaper: setTimeout(() => abandon(id), TTL_MS) });
|
|
// Keep reading, or the pipe fills and the child blocks — and we want its later output for the result.
|
|
child.stdout.on('data', (b: Buffer) => {
|
|
const p = pending.get(id);
|
|
if (p) p.out += b.toString();
|
|
});
|
|
return ctx.json({ id, url, expiresInMs: TTL_MS });
|
|
} catch (err) {
|
|
try {
|
|
child.kill('SIGTERM');
|
|
} catch {
|
|
// Nothing to clean up.
|
|
}
|
|
return ctx.json({ error: err instanceof Error ? err.message : 'the login did not start' }, 502);
|
|
}
|
|
});
|
|
|
|
// POST /auth/claude-login/:id/code — hand over what claude.com gave them.
|
|
claudeLoginRouter.post('/:id/code', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
const id = ctx.req.param('id');
|
|
const p = pending.get(id);
|
|
// Same answer for "expired", "never existed" and "somebody else's": all three are equally not-yours.
|
|
if (!p || p.userId !== user.id) return ctx.json({ error: 'that login is no longer open — run /login again' }, 404);
|
|
|
|
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
|
const code = typeof body.code === 'string' ? body.code.trim() : '';
|
|
if (!code) return ctx.json({ error: 'no code' }, 400);
|
|
|
|
const finished = new Promise<number | null>((resolve) => p.child.once('exit', (c) => resolve(c)));
|
|
p.child.stdin.write(`${code}\n`);
|
|
|
|
// The exchange is a network round trip on the CLI's side.
|
|
const exit = await Promise.race([finished, new Promise<'timeout'>((r) => setTimeout(() => r('timeout'), 45_000))]);
|
|
const tail = visibleText(p.out).slice(-600);
|
|
abandon(id);
|
|
|
|
if (exit === 'timeout') return ctx.json({ ok: false, error: 'the CLI did not finish in 45s', detail: tail }, 504);
|
|
if (exit !== 0) return ctx.json({ ok: false, error: `claude auth login exited ${exit}`, detail: tail }, 400);
|
|
|
|
// Record what was just signed into, so it can be switched BACK to later without another round trip. Only
|
|
// for the owner: a member's credential is in their own home and this module only ever reads the owner's.
|
|
if (user.id === 1) await captureCurrentAnthropicAccount(user.id);
|
|
const now = user.id === 1 ? await readCurrentAccount() : null;
|
|
|
|
// The account is the answer the person opened this for. Two accounts on one machine means "signed in" on
|
|
// its own is not enough to act on.
|
|
return ctx.json({ ok: true, detail: tail, email: now?.emailAccount ?? null, orgName: now?.orgName ?? null });
|
|
});
|
|
|
|
// DELETE /auth/claude-login/:id — they closed the dialog.
|
|
claudeLoginRouter.delete('/:id', (ctx) => {
|
|
const user = ctx.get('user');
|
|
const id = ctx.req.param('id');
|
|
const p = pending.get(id);
|
|
if (p && p.userId === user.id) abandon(id);
|
|
return ctx.json({ ok: true });
|
|
});
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────────
|
|
Switching between accounts that have already been signed into.
|
|
|
|
The owner keeps a personal account and a company one and moves between them during a day. `/login` above
|
|
handles arriving at an account; this handles going back to one, without a browser round trip, using the
|
|
credential kept from last time.
|
|
|
|
Owner only, and structurally: `getOwnerHomeDir` ignores the email it is passed, so every path below is
|
|
the owner's whatever the caller claims. A member's `claude` reads their own home and is untouched by any
|
|
of this — the isolation is the same as it was, and this feature does not widen it.
|
|
──────────────────────────────────────────────────────────────────────────── */
|
|
|
|
/** `.credentials.json` holds one key. `.claude.json` holds a whole config and must be PATCHED, not written. */
|
|
const credentialsPath = () => join(getOwnerHomeDir(''), '.claude', '.credentials.json');
|
|
const configPath = () => join(getOwnerHomeDir(''), '.claude.json');
|
|
|
|
async function readJson(path: string): Promise<Record<string, unknown> | null> {
|
|
try {
|
|
return JSON.parse(await readFile(path, 'utf-8')) as Record<string, unknown>;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* What the CLI is signed in as right now, read from its own two files.
|
|
*
|
|
* Both are needed and neither is sufficient: the credential lives in `.claude/.credentials.json` and the
|
|
* identity — email, org, account uuid — lives in `.claude.json` beside it.
|
|
*/
|
|
async function readCurrentAccount(): Promise<{
|
|
accountUuid: string;
|
|
emailAccount: string;
|
|
orgName: string | null;
|
|
plan: string | null;
|
|
credentialsJson: string;
|
|
oauthAccountJson: string;
|
|
refreshExpiresAt: Date | null;
|
|
} | null> {
|
|
const creds = await readJson(credentialsPath());
|
|
const config = await readJson(configPath());
|
|
const oauth = creds?.claudeAiOauth as Record<string, unknown> | undefined;
|
|
const account = config?.oauthAccount as Record<string, unknown> | undefined;
|
|
if (!oauth || !account) return null;
|
|
|
|
const accountUuid = typeof account.accountUuid === 'string' ? account.accountUuid : '';
|
|
const emailAccount = typeof account.emailAddress === 'string' ? account.emailAddress : '';
|
|
if (!accountUuid || !emailAccount) return null;
|
|
|
|
const sub = typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : '';
|
|
const refreshMs = typeof oauth.refreshTokenExpiresAt === 'number' ? oauth.refreshTokenExpiresAt : null;
|
|
return {
|
|
accountUuid,
|
|
emailAccount,
|
|
orgName: typeof account.organizationName === 'string' ? account.organizationName : null,
|
|
plan: sub ? sub[0]!.toUpperCase() + sub.slice(1) : null,
|
|
credentialsJson: JSON.stringify(oauth),
|
|
oauthAccountJson: JSON.stringify(account),
|
|
refreshExpiresAt: refreshMs ? new Date(refreshMs) : null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Keep whatever the CLI is currently signed in as.
|
|
*
|
|
* Called after a `/login` here AND whenever the account list is read, which is what makes an account signed
|
|
* into from a TERMINAL switchable too. Without that second call this would only know about logins that
|
|
* happened to go through this dialog — a switcher that silently omits half your accounts.
|
|
*
|
|
* Never throws into a caller: failing to record a credential must not fail the request that noticed it.
|
|
*/
|
|
export async function captureCurrentAnthropicAccount(userId: number): Promise<void> {
|
|
try {
|
|
const now = await readCurrentAccount();
|
|
if (!now) return;
|
|
await saveAnthropicAccount({ userId, ...now });
|
|
} catch (err) {
|
|
console.warn('[claude-auth] could not record the current account:', err instanceof Error ? err.message : err);
|
|
}
|
|
}
|
|
|
|
// GET /claude-auth/accounts — who can be switched to, and who is current. No secrets.
|
|
claudeLoginRouter.get('/accounts', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
|
|
|
|
// Record before listing, so an account signed into elsewhere appears the first time the panel is opened
|
|
// rather than only after the next login through this dialog.
|
|
await captureCurrentAnthropicAccount(user.id);
|
|
await purgeExpiredAnthropicAccounts(user.id).catch(() => 0);
|
|
|
|
const current = await readCurrentAccount();
|
|
const accounts = (await listAnthropicAccounts(user.id)).map((a) => ({
|
|
...a,
|
|
current: a.accountUuid === current?.accountUuid,
|
|
}));
|
|
return ctx.json({ accounts });
|
|
});
|
|
|
|
// POST /claude-auth/switch — become one of them.
|
|
claudeLoginRouter.post('/switch', async (ctx) => {
|
|
const user = ctx.get('user');
|
|
if (user.id !== 1) return ctx.json({ error: 'Anthropic accounts belong to the owner of this machine' }, 403);
|
|
|
|
const body = await ctx.req.json().catch(() => ({}) as Record<string, unknown>);
|
|
const accountUuid = typeof body.accountUuid === 'string' ? body.accountUuid : '';
|
|
if (!accountUuid) return ctx.json({ error: 'no account' }, 400);
|
|
|
|
// Keep the outgoing credential before overwriting it, or switching away from an account signed into
|
|
// outside this UI loses it — the one-way trip this feature exists to remove.
|
|
await captureCurrentAnthropicAccount(user.id);
|
|
|
|
const target = await revealAnthropicAccount(user.id, accountUuid);
|
|
if (!target) return ctx.json({ error: 'that sign-in has expired — run /login for it again' }, 409);
|
|
|
|
try {
|
|
// `.credentials.json` is replaced wholesale: it holds this one key and nothing else of ours.
|
|
// `.claude.json` is PATCHED — it is the CLI's whole config, and overwriting it would take out every
|
|
// project entry, tip counter and preference in it.
|
|
const existingCreds = (await readJson(credentialsPath())) ?? {};
|
|
await writeFile(
|
|
credentialsPath(),
|
|
JSON.stringify({ ...existingCreds, claudeAiOauth: JSON.parse(target.credentialsJson) }),
|
|
{
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
|
|
const config = (await readJson(configPath())) ?? {};
|
|
await writeFile(
|
|
configPath(),
|
|
JSON.stringify({ ...config, oauthAccount: JSON.parse(target.oauthAccountJson) }, null, 2),
|
|
{
|
|
mode: 0o600,
|
|
},
|
|
);
|
|
} catch (err) {
|
|
return ctx.json({ error: `could not write the credential: ${err instanceof Error ? err.message : err}` }, 500);
|
|
}
|
|
|
|
await markAnthropicAccountUsed(user.id, accountUuid);
|
|
// A turn already running keeps the credential it was spawned with. Said here rather than guessed at in
|
|
// the UI, because "it did not take effect" and "it takes effect next turn" look identical from outside.
|
|
return ctx.json({ ok: true, email: target.emailAccount, appliesTo: 'the next turn, and every new claude' });
|
|
});
|