Skip to content

Commit b86c39b

Browse files
authored
feat!: move login/logout to auth subcommand, add auth status (#84)
* feat!: move login/logout to auth subcommand, add auth status BREAKING CHANGE: `workos login` and `workos logout` are now `workos auth login` and `workos auth logout`. New `workos auth status` command shows current auth state: - Logged in email/userId - Token expiry (time remaining or how long ago it expired) - Refresh token presence - Active environment name and type - Supports --json for machine-readable output All error messages referencing `workos login` updated to `workos auth login`. * chore: formatting and linting fixes * feat: add credential audit log for debugging auth loss Writes timestamped entries to ~/.workos/audit.log for every credential mutation: SAVE_CREDENTIALS, CLEAR_CREDENTIALS, UPDATE_TOKENS, and GET_CREDENTIALS (when returning null). Logs only non-sensitive metadata: email, userId, token expiry, refresh token presence, storage backend, and caller stack frame. Never logs tokens or API keys. Disabled automatically in test environments (VITEST). * fix: guard keyring operations behind insecure storage flag When forceInsecureStorage is true, all keyring read/write/delete operations are now no-ops. Previously, deleteFromKeyring() would still hit the real system keychain even in insecure-storage mode, meaning tests using setInsecureStorage(true) could wipe real credentials from the keychain. This was the root cause of credentials mysteriously vanishing after running tests. * Revert "fix: guard keyring operations behind insecure storage flag" This reverts commit e8b74d1. * Revert "feat: add credential audit log for debugging auth loss" This reverts commit 2d78cf5. * docs: update README auth commands to use auth subcommand * fix: use plain colored output for auth status instead of clack * fix: add blank line after update notice to separate from command output * fix: use plain colored output for login already-authenticated messages * fix: suppress update notice in JSON mode to keep stdout clean * fix: review fixes — isJsonMode guard, missed string ref, double newline - bin.ts: use isJsonMode() instead of hasJsonFlag to suppress update notice in all JSON contexts (including non-TTY auto-detection) - ensure-auth.ts: fix missed workos login → workos auth login at line 94 - version-check.ts: fix double blank line (console.log already adds \n) - Update CLAUDE.md, DEVELOPMENT.md, SKILL.md references
1 parent a1c1518 commit b86c39b

20 files changed

Lines changed: 147 additions & 46 deletions

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ WorkOS CLI for installing AuthKit integrations and managing WorkOS resources (or
1212
## Non-TTY Behavior
1313

1414
- **Output**: Auto-switches to JSON when piped or `--json` flag. `WORKOS_FORCE_TTY=1` overrides.
15-
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos login` or `WORKOS_API_KEY` env var.
15+
- **Auth**: Exits code 4 instead of opening browser. Requires prior `workos auth login` or `WORKOS_API_KEY` env var.
1616
- **Errors**: Structured JSON to stderr: `{ "error": { "code": "...", "message": "..." } }`
1717
- **Exit codes**: 0=success, 1=error, 2=cancelled, 4=auth required (follows `gh` CLI convention)
1818
- **Headless flags**: `--no-branch`, `--no-commit`, `--create-pr`, `--no-git-check`

DEVELOPMENT.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,9 @@ src/
2626
│ ├── user.ts # workos user (get/list/update/delete)
2727
│ ├── install.ts # workos install
2828
│ ├── install-skill.ts # workos install-skill
29-
│ ├── login.ts # workos login
30-
│ └── logout.ts # workos logout
29+
│ ├── auth-status.ts # workos auth status
30+
│ ├── login.ts # workos auth login
31+
│ └── logout.ts # workos auth logout
3132
├── dashboard/ # Ink/React TUI components
3233
├── nextjs/ # Next.js installer agent
3334
├── react/ # React SPA installer agent

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -470,10 +470,13 @@ The CLI uses WorkOS Connect OAuth device flow for authentication:
470470

471471
```bash
472472
# Login (opens browser for authentication)
473-
workos login
473+
workos auth login
474+
475+
# Check current auth status
476+
workos auth status
474477
475478
# Logout (clears stored credentials)
476-
workos logout
479+
workos auth logout
477480
```
478481

479482
OAuth credentials are stored in the system keychain (with `~/.workos/credentials.json` fallback). Access tokens are not persisted long-term for security - users re-authenticate when tokens expire.

skills/workos-management/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ description: Manage WorkOS resources (orgs, users, roles, SSO, directories, webh
55

66
# WorkOS Management Commands
77

8-
Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos login` or `WORKOS_API_KEY` env var.
8+
Use these commands to manage WorkOS resources directly from the terminal. The CLI must be authenticated via `workos auth login` or `WORKOS_API_KEY` env var.
99

1010
All commands support `--json` for structured output. Use `--json` when you need to parse output (e.g., extract an ID).
1111

src/bin.ts

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ if (!satisfies(process.version, NODE_VERSION_RANGE)) {
2828
}
2929

3030
import { isNonInteractiveEnvironment } from './utils/environment.js';
31-
import { resolveOutputMode, setOutputMode, outputJson, exitWithError } from './utils/output.js';
31+
import { resolveOutputMode, setOutputMode, isJsonMode, outputJson, exitWithError } from './utils/output.js';
3232
import clack from './utils/clack.js';
3333
import { registerSubcommand } from './utils/register-subcommand.js';
3434

@@ -177,8 +177,8 @@ const installerOptions = {
177177
},
178178
};
179179

180-
// Check for updates (blocks up to 500ms)
181-
await checkForUpdates();
180+
// Check for updates (blocks up to 500ms, skip in JSON mode to keep stdout clean)
181+
if (!isJsonMode()) await checkForUpdates();
182182

183183
yargs(rawArgs)
184184
.env('WORKOS_INSTALLER')
@@ -188,16 +188,43 @@ yargs(rawArgs)
188188
describe: 'Output results as JSON (auto-enabled in non-TTY)',
189189
global: true,
190190
})
191-
.command('login', 'Authenticate with WorkOS via browser-based OAuth', insecureStorageOption, async (argv) => {
192-
await applyInsecureStorage(argv.insecureStorage);
193-
const { runLogin } = await import('./commands/login.js');
194-
await runLogin();
195-
process.exit(0);
196-
})
197-
.command('logout', 'Remove stored WorkOS credentials and tokens', insecureStorageOption, async (argv) => {
198-
await applyInsecureStorage(argv.insecureStorage);
199-
const { runLogout } = await import('./commands/logout.js');
200-
await runLogout();
191+
.command('auth', 'Manage authentication (login, logout, status)', (yargs) => {
192+
yargs.options(insecureStorageOption);
193+
registerSubcommand(
194+
yargs,
195+
'login',
196+
'Authenticate with WorkOS via browser-based OAuth',
197+
(y) => y,
198+
async (argv) => {
199+
await applyInsecureStorage(argv.insecureStorage);
200+
const { runLogin } = await import('./commands/login.js');
201+
await runLogin();
202+
process.exit(0);
203+
},
204+
);
205+
registerSubcommand(
206+
yargs,
207+
'logout',
208+
'Remove stored WorkOS credentials and tokens',
209+
(y) => y,
210+
async (argv) => {
211+
await applyInsecureStorage(argv.insecureStorage);
212+
const { runLogout } = await import('./commands/logout.js');
213+
await runLogout();
214+
},
215+
);
216+
registerSubcommand(
217+
yargs,
218+
'status',
219+
'Show current authentication status',
220+
(y) => y,
221+
async (argv) => {
222+
await applyInsecureStorage(argv.insecureStorage);
223+
const { runAuthStatus } = await import('./commands/auth-status.js');
224+
await runAuthStatus();
225+
},
226+
);
227+
return yargs.demandCommand(1, 'Please specify an auth subcommand').strict();
201228
})
202229
.command(
203230
'install-skill',

src/commands/auth-status.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import chalk from 'chalk';
2+
import { getCredentials, isTokenExpired } from '../lib/credentials.js';
3+
import { getActiveEnvironment } from '../lib/config-store.js';
4+
import { isJsonMode, outputJson } from '../utils/output.js';
5+
6+
function formatTimeRemaining(ms: number): string {
7+
if (ms <= 0) return 'expired';
8+
const seconds = Math.floor(ms / 1000);
9+
const minutes = Math.floor(seconds / 60);
10+
const hours = Math.floor(minutes / 60);
11+
if (hours > 0) return `${hours}h ${minutes % 60}m`;
12+
if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
13+
return `${seconds}s`;
14+
}
15+
16+
export async function runAuthStatus(): Promise<void> {
17+
const creds = getCredentials();
18+
const activeEnv = getActiveEnvironment();
19+
20+
if (!creds) {
21+
if (isJsonMode()) {
22+
outputJson({ authenticated: false });
23+
return;
24+
}
25+
console.log(chalk.yellow('Not logged in'));
26+
console.log(chalk.dim('Run `workos auth login` to authenticate'));
27+
return;
28+
}
29+
30+
const expired = isTokenExpired(creds);
31+
const timeRemaining = creds.expiresAt - Date.now();
32+
33+
if (isJsonMode()) {
34+
outputJson({
35+
authenticated: true,
36+
email: creds.email ?? null,
37+
userId: creds.userId,
38+
tokenExpired: expired,
39+
tokenExpiresAt: new Date(creds.expiresAt).toISOString(),
40+
tokenExpiresIn: expired ? null : formatTimeRemaining(timeRemaining),
41+
hasRefreshToken: !!creds.refreshToken,
42+
activeEnvironment: activeEnv ? { name: activeEnv.name, type: activeEnv.type } : null,
43+
});
44+
return;
45+
}
46+
47+
console.log(chalk.green(`Logged in as ${creds.email ?? creds.userId}`));
48+
49+
if (expired) {
50+
console.log(chalk.yellow(`Token expired ${formatTimeRemaining(-timeRemaining)} ago`));
51+
} else {
52+
console.log(chalk.dim(`Token expires in ${formatTimeRemaining(timeRemaining)}`));
53+
}
54+
55+
console.log(chalk.dim(`Refresh token: ${creds.refreshToken ? 'present' : 'absent'}`));
56+
57+
if (activeEnv) {
58+
console.log(chalk.dim(`Environment: ${activeEnv.name} (${activeEnv.type})`));
59+
}
60+
}

src/commands/login.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import open from 'opn';
2+
import chalk from 'chalk';
23
import clack from '../utils/clack.js';
34
import { saveCredentials, getCredentials, getAccessToken, isTokenExpired, updateTokens } from '../lib/credentials.js';
45
import { getCliAuthClientId, getAuthkitDomain } from '../lib/settings.js';
@@ -76,8 +77,8 @@ export async function runLogin(): Promise<void> {
7677
// Check if already logged in with valid token
7778
if (getAccessToken()) {
7879
const creds = getCredentials();
79-
clack.log.info(`Already logged in as ${creds?.email ?? 'unknown'}`);
80-
clack.log.info('Run `workos logout` to log out');
80+
console.log(chalk.green(`Already logged in as ${creds?.email ?? 'unknown'}`));
81+
console.log(chalk.dim('Run `workos auth logout` to log out'));
8182
return;
8283
}
8384

@@ -90,8 +91,8 @@ export async function runLogin(): Promise<void> {
9091
if (result.accessToken && result.expiresAt) {
9192
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
9293
logInfo('[login] Session refreshed via refresh token');
93-
clack.log.info(`Already logged in as ${existingCreds.email ?? 'unknown'}`);
94-
clack.log.info('Run `workos logout` to log out');
94+
console.log(chalk.green(`Already logged in as ${existingCreds.email ?? 'unknown'}`));
95+
console.log(chalk.dim('Run `workos auth logout` to log out'));
9596
return;
9697
}
9798
} catch {

src/doctor/checks/ai-analysis.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,10 @@ async function callModel(prompt: string, model: string): Promise<string> {
6161
if (!creds) throw new Error('Not authenticated');
6262

6363
if (isTokenExpired(creds)) {
64-
if (!creds.refreshToken) throw new Error('Session expired — run `workos login` to re-authenticate');
64+
if (!creds.refreshToken) throw new Error('Session expired — run `workos auth login` to re-authenticate');
6565
const result = await refreshAccessToken(getAuthkitDomain(), getCliAuthClientId());
6666
if (!result.success || !result.accessToken || !result.expiresAt) {
67-
throw new Error('Session expired — run `workos login` to re-authenticate');
67+
throw new Error('Session expired — run `workos auth login` to re-authenticate');
6868
}
6969
updateTokens(result.accessToken, result.expiresAt, result.refreshToken);
7070
creds = getCredentials()!;
@@ -111,7 +111,7 @@ export async function checkAiAnalysis(context: AnalysisContext, options: { skipA
111111
process.stderr.write(` ${line}\n`);
112112
}
113113
process.stderr.write('\n');
114-
return skippedResult('Not authenticated — run `workos login` for AI-powered analysis');
114+
return skippedResult('Not authenticated — run `workos auth login` for AI-powered analysis');
115115
}
116116

117117
const startTime = Date.now();

src/lib/adapters/cli-adapter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ export class CLIAdapter implements InstallerAdapter {
406406

407407
// Add actionable hints for common errors
408408
if (message.includes('authentication') || message.includes('auth')) {
409-
clack.log.info('Try running: workos logout && workos install');
409+
clack.log.info('Try running: workos auth logout && workos install');
410410
}
411411
if (message.includes('ENOENT') || message.includes('not found')) {
412412
clack.log.info('Ensure you are in a project directory');

src/lib/agent-interface.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,12 +360,12 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
360360
// Check/refresh authentication for production (unless skipping auth)
361361
if (!options.skipAuth && !options.local) {
362362
if (!hasCredentials()) {
363-
throw new Error('Not authenticated. Run `workos login` to authenticate.');
363+
throw new Error('Not authenticated. Run `workos auth login` to authenticate.');
364364
}
365365

366366
const creds = getCredentials();
367367
if (!creds) {
368-
throw new Error('Not authenticated. Run `workos login` to authenticate.');
368+
throw new Error('Not authenticated. Run `workos auth login` to authenticate.');
369369
}
370370

371371
// Check if we have refresh token capability and proxy is not disabled
@@ -386,7 +386,7 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
386386
onRefreshExpired: () => {
387387
logError('[agent-interface] Session expired, refresh token invalid');
388388
options.emitter?.emit('error', {
389-
message: 'Session expired. Run `workos login` to re-authenticate.',
389+
message: 'Session expired. Run `workos auth login` to re-authenticate.',
390390
});
391391
},
392392
},
@@ -403,9 +403,9 @@ export async function initializeAgent(config: AgentConfig, options: InstallerOpt
403403
// No refresh token OR proxy disabled - fall back to old behavior (5 min limit)
404404
if (!creds.refreshToken) {
405405
logWarn('[agent-interface] No refresh token available, session limited to 5 minutes');
406-
logWarn('[agent-interface] Run `workos login` to enable extended sessions');
406+
logWarn('[agent-interface] Run `workos auth login` to enable extended sessions');
407407
options.emitter?.emit('status', {
408-
message: 'Note: Run `workos login` to enable extended sessions',
408+
message: 'Note: Run `workos auth login` to enable extended sessions',
409409
});
410410
} else {
411411
logWarn('[agent-interface] Proxy disabled via INSTALLER_DISABLE_PROXY');

0 commit comments

Comments
 (0)