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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ lfc --profile local builds list # one-off profile override

Config lives at `~/.config/lifecycle-cli/config.json`. Environment overrides: `LFC_PROFILE`, `LIFECYCLE_API_URL`, `LFC_JSON=1`, `LFC_CONFIG_DIR`.

For CI and other non-interactive use, create an API key in the Lifecycle UI (Settings → API keys) and export it as `LFC_API_KEY` (an `lfc_svc_…` or `lfc_pat_…` key). It takes precedence over any cached SSO login and needs no `lfc login` or config file when `LIFECYCLE_API_URL` is also set. The key's scopes apply: `lfc builds env set` needs `env:write`. `lfc whoami` reports the key's principal and scopes.

The CLI reports anonymous usage telemetry to your own deployment's API (command and flag names, duration, and outcome — never arguments, values, error messages, or identity). Set `LFC_TELEMETRY_DISABLED=1` to turn it off.

## Builds (preview environments)
Expand Down
22 changes: 21 additions & 1 deletion src/commands/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as clack from '@clack/prompts';
import { Command } from 'commander';
import pc from 'picocolors';

import { AuthError, decodeJwt, endSession, loginDevice, loginPkce } from '../lib/auth.js';
import { apiKeyFromEnv, AuthError, decodeJwt, endSession, loginDevice, loginPkce } from '../lib/auth.js';
import { clearTokens, loadTokens, saveTokens } from '../lib/config.js';
import { runAction, type Ctx } from '../lib/context.js';
import { link, printJson } from '../lib/output.js';
Expand Down Expand Up @@ -66,6 +66,11 @@ export function registerAuthCommands(program: Command): void {
const tokens = loadTokens(ctx.profileName);
if (tokens && ctx.profile.keycloak) await endSession(ctx.profile.keycloak, tokens);
const removed = clearTokens(ctx.profileName);
if (process.env.LFC_API_KEY?.trim()) {
process.stderr.write(
`${pc.yellow('!')} LFC_API_KEY is still set; commands will keep authenticating with it\n`,
);
}
if (ctx.json) printJson({ loggedOut: removed, profile: ctx.profileName });
else
process.stderr.write(
Expand All @@ -79,6 +84,21 @@ export function registerAuthCommands(program: Command): void {
.description('Show the logged-in user and token details')
.action(
runAction(async ctx => {
if (apiKeyFromEnv()) {
const info = { profile: ctx.profileName, apiUrl: ctx.api.baseUrl, ...(await ctx.api.getAuthContext()) };
if (ctx.json) printJson(info);
else
process.stdout.write(
[
` principal ${info.principal.kind} ${info.principal.id}`,
` auth ${info.authMethod} (LFC_API_KEY)`,
` scopes ${info.scopes?.join(', ') ?? 'unscoped'}`,
` repos ${info.repositories.mode === 'all' ? 'all' : info.repositories.repositoryNames.join(', ')}`,
` api ${info.apiUrl}`,
].join('\n') + '\n',
);
return;
}
if (!ctx.profile.authEnabled) {
if (ctx.json) printJson({ authEnabled: false, profile: ctx.profileName, apiUrl: ctx.api.baseUrl });
else process.stdout.write(`Auth is disabled for profile "${ctx.profileName}" (API: ${ctx.api.baseUrl})\n`);
Expand Down
8 changes: 6 additions & 2 deletions src/commands/llms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ and gets public URLs. The lfc CLI lets you:
1 = failure (API error, failed build, invalid schema)
2 = watch timeout (--watch did not converge in time)
3 = not found (unknown uuid, no config file)
4 = authentication error (not logged in, expired session)
4 = authentication error (not logged in, expired session, rejected LFC_API_KEY)
4. On exit code 4, run "lfc login" (or "lfc login --device" when there is no
browser), then retry once. If it still fails, surface the error to the user.
When LFC_API_KEY is set, exit 4 means the key is missing, malformed, or
rejected: logging in will not help, so report it to the user instead.
5. Destructive commands (builds destroy, sites delete) require --yes when stdin is
not a TTY. Confirm with the user before tearing anything down.
6. "lfc <command> --help" works on every command and subcommand and lists all flags.
Expand Down Expand Up @@ -79,7 +81,8 @@ profiles (one per deployment):
lfc --profile <name> builds list # one-off profile override

Environment overrides: LFC_PROFILE (profile name), LIFECYCLE_API_URL (API base
URL), LFC_JSON=1 (global JSON output), LFC_CONFIG_DIR (config location).
URL), LFC_JSON=1 (global JSON output), LFC_CONFIG_DIR (config location),
LFC_API_KEY (lfc_pat_/lfc_svc_ API key for CI; overrides any cached SSO login).

## Authentication

Expand All @@ -88,6 +91,7 @@ Deployments with SSO use Keycloak (Authorization Code + PKCE, or device code):
lfc login # opens a browser for SSO
lfc login --device # headless: prints a verification URL and a short code
lfc whoami # logged-in user, roles, token expiry — best first diagnostic
# (with LFC_API_KEY set: the key's principal and scopes)
lfc logout

For --device (and "lfc init --device"), relay the printed URL and code to the user
Expand Down
17 changes: 15 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getAccessToken } from './auth.js';
import { apiKeyFromEnv, getAccessToken } from './auth.js';
import type { Profile } from './config.js';
import type { ListSitesParams, RenameSiteBody } from './generated/index.js';
import { matchBuilds, type Selector } from './resolve.js';
Expand Down Expand Up @@ -29,6 +29,14 @@ export class ApiError extends Error {
}
}

export interface AuthContext {
principal: { kind: 'user' | 'personal_key' | 'service_key'; id: string };
authMethod: 'session' | 'api_key';
scopes: string[] | null;
repositories: { mode: 'all' | 'selected'; repositoryIds: number[]; repositoryNames: string[] };
tokenId: number | null;
}

export interface ListResult<T> {
items: T[];
pagination?: PaginationMetadata;
Expand Down Expand Up @@ -77,7 +85,7 @@ export class ApiClient {
const res = await fetch(url, { method, headers, body });

// a 401 with a token that looked valid usually means clock skew or revocation — force one refresh
if (res.status === 401 && token && !retried) {
if (res.status === 401 && token && !retried && !apiKeyFromEnv()) {
return this.request<T>(method, path, opts, true);
}

Expand Down Expand Up @@ -152,6 +160,11 @@ export class ApiClient {
return { matches, truncated: false };
}

async getAuthContext(): Promise<AuthContext> {
const env = await this.request<AuthContext>('GET', '/api/v2/auth/context');
return env.data as AuthContext;
}

async getBuild(uuid: string): Promise<Build> {
const env = await this.request<Build>('GET', `/api/v2/builds/${encodeURIComponent(uuid)}`);
return env.data as Build;
Expand Down
16 changes: 16 additions & 0 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,20 @@ import { loadTokens, saveTokens, type KeycloakSettings, type Profile, type Token

export class AuthError extends Error {}

const API_KEY_PATTERN = /^lfc_(pat_|svc_)?[a-f0-9]{40}$/;

/** CI credential: LFC_API_KEY takes precedence over any cached SSO login and needs no profile or `lfc login`. */
export function apiKeyFromEnv(): string | null {
const raw = process.env.LFC_API_KEY;
if (raw === undefined) return null;
const key = raw.trim();
if (!key) throw new AuthError('LFC_API_KEY is set but empty (is the CI secret missing?)');
if (!API_KEY_PATTERN.test(key)) {
throw new AuthError('LFC_API_KEY is not a valid Lifecycle API key (expected lfc_pat_… or lfc_svc_…)');
}
return key;
}

interface OidcDiscovery {
authorization_endpoint: string;
token_endpoint: string;
Expand Down Expand Up @@ -253,6 +267,8 @@ export async function getAccessToken(
profile: Profile,
{ forceRefresh = false }: { forceRefresh?: boolean } = {},
): Promise<string | null> {
const apiKey = apiKeyFromEnv();
if (apiKey) return apiKey;
if (!profile.authEnabled) return null;
if (!profile.keycloak) throw new AuthError('Profile has authEnabled but no keycloak settings');
const tokens = loadTokens(profileName);
Expand Down
5 changes: 4 additions & 1 deletion src/lib/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export function runAction<A extends unknown[]>(
} else if (err instanceof ApiError) {
const reqId = err.requestId ? pc.dim(` (request_id: ${err.requestId})`) : '';
process.stderr.write(`${pc.red(`api error (${err.status}):`)} ${err.message}${reqId}\n`);
const exitCode = err.status === 404 ? 3 : 1;
const keyRejected = err.status === 401 && Boolean(process.env.LFC_API_KEY?.trim());
if (keyRejected)
process.stderr.write(`${pc.dim('LFC_API_KEY was rejected: it may be invalid, expired, or revoked.')}\n`);
const exitCode = keyRejected ? 4 : err.status === 404 ? 3 : 1;
process.exitCode = exitCode;
outcome = {
status: 'error',
Expand Down
17 changes: 16 additions & 1 deletion src/lib/doctor.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';

import { decodeJwt } from './auth.js';
import { apiKeyFromEnv, decodeJwt } from './auth.js';
import {
configDir,
configPath,
Expand Down Expand Up @@ -185,6 +185,21 @@ function configChecks(): DoctorCheck[] {
});
}

if (process.env.LFC_API_KEY?.trim()) {
try {
apiKeyFromEnv();
checks.push({
id: 'token_presence',
label: 'token',
status: 'ok',
message: 'LFC_API_KEY is set; cached SSO tokens are ignored',
});
} catch (err) {
checks.push({ id: 'token_presence', label: 'token', status: 'error', message: (err as Error).message });
}
return checks;
}

if (active.authEnabled) {
const tokens = loadTokens(config.currentProfile);
if (!tokens) {
Expand Down
8 changes: 7 additions & 1 deletion src/lib/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Command } from 'commander';

import pkg from '../../package.json' with { type: 'json' };

import { apiKeyFromEnv } from './auth.js';
import { loadConfig, loadTokens, saveConfig } from './config.js';
import type { Ctx } from './context.js';
import type { CreateTelemetryEventBody } from './generated/index.js';
Expand Down Expand Up @@ -84,7 +85,12 @@ export async function reportInvocation(
// On auth-required deployments, only report when a valid token is already cached.
// Refreshing here could hit Keycloak with no timeout, escaping the fetch bound below.
let token: string | null = null;
if (ctx.profile.authEnabled) {
try {
token = apiKeyFromEnv();
} catch {
return;
}
if (!token && ctx.profile.authEnabled) {
token = cachedAccessToken(ctx);
if (!token) return;
}
Expand Down
21 changes: 21 additions & 0 deletions tests/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,24 @@ describe('ApiClient webhooks', () => {
expect(result).toEqual({ status: 'success' });
});
});

describe('ApiClient with LFC_API_KEY', () => {
const key = `lfc_svc_${'a'.repeat(40)}`;

afterEach(() => {
delete process.env.LFC_API_KEY;
vi.unstubAllGlobals();
});

it('sends the key as a Bearer token and does not retry a 401', async () => {
process.env.LFC_API_KEY = key;
const fetchMock = vi.fn().mockResolvedValue(new Response('{"error":{"message":"nope"}}', { status: 401 }));
vi.stubGlobal('fetch', fetchMock);

await expect(new ApiClient('default', PROFILE).getBuild('abc')).rejects.toMatchObject({ status: 401 });

expect(fetchMock).toHaveBeenCalledTimes(1);
const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
expect((init.headers as Record<string, string>).Authorization).toBe(`Bearer ${key}`);
});
});
57 changes: 55 additions & 2 deletions tests/auth.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { createHash } from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import { describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import { decodeJwt, pkcePair } from '../src/lib/auth.js';
import { apiKeyFromEnv, AuthError, decodeJwt, getAccessToken, pkcePair } from '../src/lib/auth.js';
import { saveTokens } from '../src/lib/config.js';

describe('pkcePair', () => {
it('generates an S256 challenge for the verifier', () => {
Expand All @@ -28,3 +32,52 @@ describe('decodeJwt', () => {
expect(() => decodeJwt('garbage')).toThrow();
});
});

describe('LFC_API_KEY', () => {
const key = `lfc_svc_${'a'.repeat(40)}`;
const profile = { apiUrl: 'https://lfc.example', authEnabled: false } as const;

beforeEach(() => {
delete process.env.LFC_API_KEY;
});

afterEach(() => {
delete process.env.LFC_API_KEY;
});

it('takes precedence over cached SSO tokens', async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-auth-'));
process.env.LFC_CONFIG_DIR = dir;
try {
saveTokens('default', { accessToken: 'sso-token', expiresAt: Date.now() + 3_600_000 });
const sso = { ...profile, authEnabled: true, keycloak: { issuer: 'https://kc.example', clientId: 'cli' } };
await expect(getAccessToken('default', sso)).resolves.toBe('sso-token');

process.env.LFC_API_KEY = key;
await expect(getAccessToken('default', sso)).resolves.toBe(key);
} finally {
delete process.env.LFC_CONFIG_DIR;
fs.rmSync(dir, { recursive: true, force: true });
}
});

it('rejects a set-but-empty key instead of silently going unauthenticated', () => {
process.env.LFC_API_KEY = ' ';
expect(() => apiKeyFromEnv()).toThrow(AuthError);
});

it('is returned as the access token without a profile login', async () => {
process.env.LFC_API_KEY = ` ${key}\n`;
expect(apiKeyFromEnv()).toBe(key);
await expect(getAccessToken('nope', { ...profile })).resolves.toBe(key);
});

it('is absent when unset', () => {
expect(apiKeyFromEnv()).toBeNull();
});

it('rejects a malformed key', () => {
process.env.LFC_API_KEY = 'not-a-key';
expect(() => apiKeyFromEnv()).toThrow(AuthError);
});
});
16 changes: 16 additions & 0 deletions tests/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,27 @@ beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lfc-doctor-'));
process.env.LFC_CONFIG_DIR = dir;
delete process.env.LFC_PROFILE;
delete process.env.LFC_API_KEY;
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
delete process.env.LFC_CONFIG_DIR;
delete process.env.LFC_API_KEY;
});

describe('runDoctor with LFC_API_KEY', () => {
it('accepts a well-formed key and flags a malformed one', () => {
saveConfig({
currentProfile: 'default',
profiles: { default: { apiUrl: 'https://lifecycle.example.com', authEnabled: false } },
});
process.env.LFC_API_KEY = `lfc_svc_${'a'.repeat(40)}`;
expect(runDoctor().checks.find(c => c.id === 'token_presence')?.status).toBe('ok');

process.env.LFC_API_KEY = 'Bearer lfc_oops';
expect(runDoctor().checks.find(c => c.id === 'token_presence')?.status).toBe('error');
});
});

describe('runDoctor', () => {
Expand Down
Loading
Loading