Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/commands/auth/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { type CallbackServer } from '../../lib/auth/callback-server.js';
import { AuthRequiredError, TransientAuthError } from '../../lib/auth/api.js';
import { readAuth, saveAuth, type AuthState } from '../../lib/fs/config.js';
import { runSetup, type SetupDeps } from './setup.js';

Expand All @@ -29,6 +30,7 @@ const makeDeps = () => {
openBrowser: vi.fn().mockResolvedValue(undefined),
printStatus: vi.fn().mockResolvedValue(undefined),
ensureDaemon: vi.fn().mockResolvedValue({}),
introspect: vi.fn().mockResolvedValue({ userId: 'u1' }),
};
return { deps, server, close };
};
Expand Down Expand Up @@ -104,12 +106,38 @@ describe('runSetup', () => {
expect(readAuth()?.tokens.accessToken).toBe('at-1');
});

it('short-circuits when already signed in', async () => {
it('short-circuits when already signed in and the session validates', async () => {
saveAuth(existingAuth);
const { deps } = makeDeps();
await runSetup({}, deps);
expect(deps.introspect).toHaveBeenCalledOnce();
expect(deps.register).not.toHaveBeenCalled();
expect(deps.printStatus).toHaveBeenCalled();
expect(readAuth()?.tokens.accessToken).toBe('old-at'); // creds untouched
});

it('auto-enters sign-in when the stored session is terminally expired', async () => {
saveAuth(existingAuth);
const { deps } = makeDeps();
(deps.introspect as ReturnType<typeof vi.fn>).mockRejectedValue(
new AuthRequiredError('session expired')
);
await runSetup({}, deps);
// No --reauth flag needed: a dead session falls straight through to a fresh sign-in.
expect(deps.register).toHaveBeenCalled();
expect(readAuth()?.clientId).toBe('client-1');
});

it('does not force reauth or wipe creds on a transient verify failure', async () => {
saveAuth(existingAuth);
const { deps } = makeDeps();
(deps.introspect as ReturnType<typeof vi.fn>).mockRejectedValue(
new TransientAuthError('temporarily failed')
);
await runSetup({}, deps);
expect(deps.register).not.toHaveBeenCalled();
expect(deps.printStatus).toHaveBeenCalled();
expect(readAuth()?.tokens.accessToken).toBe('old-at'); // creds intact
});

it('re-authenticates with --reauth despite existing auth', async () => {
Expand Down
34 changes: 27 additions & 7 deletions src/commands/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
readAuth,
type AuthState,
} from '../../lib/fs/config.js';
import { AuthRequiredError, introspectToken } from '../../lib/auth/api.js';
import {
buildAuthorizeUrl,
exchangeCode,
Expand Down Expand Up @@ -37,6 +38,7 @@ export interface SetupDeps {
openBrowser: (url: string) => Promise<void>;
printStatus: () => Promise<void>;
ensureDaemon: typeof ensureDaemon;
introspect: typeof introspectToken;
}

const defaultDeps: SetupDeps = {
Expand All @@ -50,6 +52,7 @@ const defaultDeps: SetupDeps = {
},
printStatus: () => runStatus({}),
ensureDaemon,
introspect: introspectToken,
};

const toAuthState = (fqdn: string, clientId: string, tokens: TokenResponse): AuthState => ({
Expand Down Expand Up @@ -86,6 +89,28 @@ const disconnect = async (deps: SetupDeps): Promise<void> => {
console.log('Disconnected - local credentials removed.');
};

// Validate the stored session instead of trusting mere token presence. Returns true to short-circuit
// (valid or a transient blip - creds intact), false to proceed into a fresh sign-in (truly expired).
const alreadySignedIn = async (auth: AuthState, deps: SetupDeps): Promise<boolean> => {
try {
await deps.introspect(auth, links(auth.siteFqdn));
console.log(
`Already signed in. Run ${chalk.white('agentage setup --reauth')} to sign in again.\n`
);
await deps.printStatus();
return true;
} catch (err) {
if (err instanceof AuthRequiredError) {
console.log('Session expired - signing you in again.\n');
return false; // fall through to the sign-in flow, no --reauth needed
}
// Transient: do not force reauth on a blip; keep credentials and show status.
console.log('You appear signed in (could not fully verify - temporary).\n');
await deps.printStatus();
return true;
}
};

export const runSetup = async (
opts: SetupOptions,
deps: SetupDeps = defaultDeps
Expand All @@ -94,13 +119,8 @@ export const runSetup = async (
await disconnect(deps);
return;
}
if (readAuth() && !opts.reauth) {
console.log(
`Already signed in. Run ${chalk.white('agentage setup --reauth')} to sign in again.\n`
);
await deps.printStatus();
return;
}
const stored = readAuth();
if (stored && !opts.reauth && (await alreadySignedIn(stored, deps))) return;
const fqdn = siteFqdn();
const target = links(fqdn);
ensureConfigDir();
Expand Down
2 changes: 2 additions & 0 deletions src/commands/status/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ const row = (label: string, value: string): void => {
// Introspection already proved the session is active server-side.
const authLine = (auth: StatusReport['auth']): string => {
if (!auth.signedIn) return `${mark(false)} ${auth.note ?? 'not signed in'}`;
// Transient: we hold a valid-looking token but could not re-verify - a non-terminal `~`, never ✗.
if (auth.transient) return `${chalk.yellow('~')} ${auth.note ?? 'signed in'}`;
return `${mark(true)} signed in (session active)`;
};

Expand Down
46 changes: 43 additions & 3 deletions src/lib/auth/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { authedGet, authedPost, AuthRequiredError, currentBearer, introspectToken } from './api.js';
import {
authedGet,
authedPost,
AuthRequiredError,
currentBearer,
introspectToken,
TransientAuthError,
} from './api.js';
import { readAuth, type AuthState } from '../fs/config.js';
import { links } from '../net/origins.js';
import { VERSION } from '../../utils/version.js';
Expand Down Expand Up @@ -288,16 +295,49 @@ describe('introspectToken', () => {
expect(readAuth()?.tokens.accessToken).toBe('new-token');
});

it('throws AuthRequiredError when a 200 + null survives a failed refresh', async () => {
it('throws AuthRequiredError when a 200 + null survives a terminal refresh (invalid_grant)', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(200, null)
String(url).includes('/token')
? jsonResponse(400, { error: 'invalid_grant' })
: jsonResponse(200, null)
)
);
vi.stubGlobal('fetch', fetchMock);
await expect(introspectToken(makeAuth(), target)).rejects.toThrow(AuthRequiredError);
});

it('throws TransientAuthError (not AuthRequired) when a 200 + null refresh blips 429', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
String(url).includes('/token') ? jsonResponse(429, {}) : jsonResponse(200, null)
)
);
vi.stubGlobal('fetch', fetchMock);
await expect(introspectToken(makeAuth(), target)).rejects.toThrow(TransientAuthError);
});

it('reports the unexpired stored token as signed-in when get-session blips 5xx', async () => {
const expiresAt = Date.now() + 3_600_000;
const fetchMock = vi.fn(async () => jsonResponse(500, {}));
vi.stubGlobal('fetch', fetchMock);
const auth = makeAuth({ expiresAt });
const session = await introspectToken(auth, target);
// Held an unexpired token: treat the 5xx as a blip, report the stored expiry, never throw.
expect(session.expiresAt).toBe(new Date(expiresAt).toISOString());
});

it('throws TransientAuthError when an expired token cannot refresh (5xx)', async () => {
const fetchMock = vi.fn((url: string) =>
Promise.resolve(
String(url).includes('/token') ? jsonResponse(503, {}) : jsonResponse(200, {})
)
);
vi.stubGlobal('fetch', fetchMock);
const auth = makeAuth({ expiresAt: Date.now() - 1000 });
await expect(introspectToken(auth, target)).rejects.toThrow(TransientAuthError);
});

it('refreshes and re-introspects when the session reports an already-past expiry', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
const future = new Date(Date.now() + 3_600_000).toISOString();
Expand Down
91 changes: 35 additions & 56 deletions src/lib/auth/api.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
import { mutateAuth, type AuthState, type StoredTokens } from '../fs/config.js';
import { refreshTokens } from './oauth.js';
import { refreshTokens, TokenRequestError } from './oauth.js';
import { type Links } from '../net/origins.js';
import { requestHeaders } from '../net/user-agent.js';
import { AuthRequiredError, isTerminalRefresh, TransientAuthError } from './auth-errors.js';

export { AuthRequiredError, TransientAuthError } from './auth-errors.js';

// Every authed call is CLI-originated; identify the caller alongside the bearer.
const cliHeaders = (): Record<string, string> => requestHeaders({ component: 'cli' });

export class AuthRequiredError extends Error {
constructor(message = 'not signed in') {
super(message);
this.name = 'AuthRequiredError';
// Refresh the stored token in place. On success mutates auth + persists and returns. On failure it
// throws: AuthRequiredError when the grant is dead (401/invalid_grant/invalid_client), else
// TransientAuthError (429/5xx/network/timeout) - a blip that must not be read as a dead session.
export const refreshOrThrow = async (auth: AuthState, links: Links): Promise<void> => {
if (!auth.tokens.refreshToken) throw new AuthRequiredError('no refresh token');
let fresh;
try {
fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken);
} catch (err) {
if (err instanceof TokenRequestError && isTerminalRefresh(err.status, err.oauthError))
throw new AuthRequiredError('session expired');
throw new TransientAuthError('refresh temporarily failed');
}
}
const tokens: StoredTokens = {
accessToken: fresh.access_token,
refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken,
expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined,
};
auth.tokens = tokens; // the caller retries its request with this in-memory copy
// Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent
// writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy.
await mutateAuth((current) => {
const base = current ?? auth;
base.tokens = tokens;
return base;
});
};

// Boolean shim for the request paths that just want "refresh once, then retry": any failure
// (terminal or transient) collapses to false so they fall through to their own status handling.
const tryRefresh = async (auth: AuthState, links: Links): Promise<boolean> => {
if (!auth.tokens.refreshToken) return false;
try {
const fresh = await refreshTokens(links.auth, auth.clientId, auth.tokens.refreshToken);
const tokens: StoredTokens = {
accessToken: fresh.access_token,
refreshToken: fresh.refresh_token ?? auth.tokens.refreshToken,
expiresAt: fresh.expires_in ? Date.now() + fresh.expires_in * 1000 : undefined,
};
auth.tokens = tokens; // the caller retries its request with this in-memory copy
// Persist under the lock, folding the new tokens onto the freshly-read state so a concurrent
// writer's other fields (clientId, siteFqdn) are not clobbered by a stale in-memory copy.
await mutateAuth((current) => {
const base = current ?? auth;
base.tokens = tokens;
return base;
});
await refreshOrThrow(auth, links);
return true;
} catch {
return false;
Expand Down Expand Up @@ -92,38 +104,5 @@ export const authedPost = async (
return res;
};

interface IntrospectionResponse {
userId?: string;
accessTokenExpiresAt?: string;
}

export interface TokenSession {
userId?: string;
expiresAt?: string;
}

const isPast = (iso?: string): boolean => {
if (!iso) return false;
const t = Date.parse(iso);
return !Number.isNaN(t) && t <= Date.now();
};

// The OAuth introspection endpoint: the only live surface that validates the
// CLI's bearer token today (backend REST accepts session cookies only).
// get-session answers 200 + null (not 401) for an expired/inactive session, so authedGet's
// on-401 refresh never fires here. Mirror currentBearer: refresh proactively when the stored
// token is past expiry, reactively once when a 200 + null slips through, and once more when the
// returned session reports an already-past expiry - always re-introspect so the returned expiry
// reflects the post-refresh token and a checkmark never sits next to a stale timestamp.
export const introspectToken = async (auth: AuthState, links: Links): Promise<TokenSession> => {
const url = `${links.auth}/api/auth/mcp/get-session`;
const get = (): Promise<IntrospectionResponse | null> =>
authedGet<IntrospectionResponse | null>(auth, links, url);
const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now();
if (expired && !(await tryRefresh(auth, links))) throw new AuthRequiredError('session expired');
let body = await get();
const stale = !body || isPast(body.accessTokenExpiresAt);
if (stale && (await tryRefresh(auth, links))) body = await get();
if (!body) throw new AuthRequiredError('no active session');
return { userId: body.userId, expiresAt: body.accessTokenExpiresAt };
};
// Re-export the introspection surface so existing callers keep importing from './api.js'.
export { introspectToken, type TokenSession } from './introspect.js';
27 changes: 27 additions & 0 deletions src/lib/auth/auth-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Auth failures split two ways: TERMINAL means the session truly needs re-auth (401,
// invalid_grant/invalid_client on the refresh grant); TRANSIENT means a blip we must not report as
// expired (429, 5xx, network, timeout, unexpected redirect). Mirrors web#219's get-session taxonomy.

// The session is genuinely gone: user must sign in again.
export class AuthRequiredError extends Error {
constructor(message = 'not signed in') {
super(message);
this.name = 'AuthRequiredError';
}
}

// A temporary failure (rate limit / server hiccup / network) - the session may still be valid.
export class TransientAuthError extends Error {
constructor(message = 'could not verify session') {
super(message);
this.name = 'TransientAuthError';
}
}

// OAuth token-endpoint error codes that mean the refresh grant is dead, not merely throttled.
const TERMINAL_GRANT_ERRORS = new Set(['invalid_grant', 'invalid_client']);

// Classify a failed refresh POST: 401 or an explicit invalid_grant/invalid_client is terminal;
// everything else (429, 5xx, network, redirect, unknown) is transient.
export const isTerminalRefresh = (status: number, oauthError?: string): boolean =>
status === 401 || (oauthError !== undefined && TERMINAL_GRANT_ERRORS.has(oauthError));
Loading