Skip to content

Commit 002bd12

Browse files
authored
feat(auth): add login/logout/whoami and require auth for push/pull (#28)
* feat(auth): add login/logout/whoami commands and require auth for push/pull Implement CLI authentication with LEAPERone's auth system: - Add credentials module (~/.envx/credentials.json) for token storage - Add `envx login` with browser and device flow support - Add `envx logout` to clear credentials - Add `envx whoami` to show current user - Require auth token for push/pull, with clear error on 401 Closes #3, closes #4 * fix(login): unref timeout timer so process exits after login
1 parent 548bef0 commit 002bd12

7 files changed

Lines changed: 416 additions & 10 deletions

File tree

src/commands/login.ts

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
import { Command } from 'commander';
2+
import http from 'node:http';
3+
import { exec } from 'node:child_process';
4+
import chalk from 'chalk';
5+
import ora from 'ora';
6+
import {
7+
saveCredentials,
8+
loadCredentials,
9+
getAuthBaseUrl,
10+
CREDENTIALS_FILE,
11+
} from '@/utils/credentials';
12+
13+
function openBrowser(url: string): void {
14+
const cmd =
15+
process.platform === 'darwin'
16+
? 'open'
17+
: process.platform === 'win32'
18+
? 'start'
19+
: 'xdg-open';
20+
exec(`${cmd} "${url}"`);
21+
}
22+
23+
function browserLogin(baseUrl: string): Promise<string> {
24+
return new Promise((resolve, reject) => {
25+
const server = http.createServer((req, res) => {
26+
const url = new URL(req.url!, `http://localhost`);
27+
28+
if (url.pathname === '/callback') {
29+
const code = url.searchParams.get('code');
30+
if (!code) {
31+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
32+
res.end('<html><body><h2>Missing authorization code.</h2></body></html>');
33+
server.close();
34+
reject(new Error('No authorization code received'));
35+
return;
36+
}
37+
38+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
39+
res.end(`<html><body style="display:flex;justify-content:center;align-items:center;height:100vh;font-family:system-ui">
40+
<div style="text-align:center">
41+
<h2 style="color:#22c55e">Authorization successful!</h2>
42+
<p>You can close this tab and return to your terminal.</p>
43+
</div>
44+
</body></html>`);
45+
46+
fetch(new URL('/api/v1/cli/auth/exchange', baseUrl).toString(), {
47+
method: 'PUT',
48+
headers: { 'Content-Type': 'application/json' },
49+
body: JSON.stringify({ code }),
50+
})
51+
.then(async (r) => {
52+
const text = await r.text();
53+
try {
54+
return JSON.parse(text);
55+
} catch {
56+
throw new Error(
57+
`Exchange API returned non-JSON (HTTP ${r.status}): ${text.slice(0, 200)}`
58+
);
59+
}
60+
})
61+
.then((data) => {
62+
server.close();
63+
if (data.success && data.data?.token) {
64+
resolve(data.data.token);
65+
} else {
66+
reject(new Error(data.error || 'Failed to exchange code'));
67+
}
68+
})
69+
.catch((err) => {
70+
server.close();
71+
reject(err);
72+
});
73+
} else {
74+
res.writeHead(404);
75+
res.end();
76+
}
77+
});
78+
79+
server.listen(0, '127.0.0.1', () => {
80+
const addr = server.address();
81+
if (!addr || typeof addr === 'string') {
82+
reject(new Error('Failed to start local server'));
83+
return;
84+
}
85+
const port = addr.port;
86+
const authUrl = `${baseUrl}/auth/cli?port=${port}`;
87+
88+
console.log(`Opening browser to authorize...`);
89+
console.log(` ${chalk.underline(authUrl)}`);
90+
console.log();
91+
92+
openBrowser(authUrl);
93+
});
94+
95+
const timer = setTimeout(() => {
96+
server.close();
97+
reject(new Error('Authorization timed out (3 minutes)'));
98+
}, 3 * 60 * 1000);
99+
timer.unref();
100+
});
101+
}
102+
103+
async function deviceLogin(baseUrl: string): Promise<string> {
104+
const codeRes = await fetch(new URL('/api/auth/device/code', baseUrl).toString(), {
105+
method: 'POST',
106+
headers: { 'Content-Type': 'application/json' },
107+
body: JSON.stringify({ client_id: 'envx-cli' }),
108+
});
109+
110+
if (!codeRes.ok) {
111+
const body = (await codeRes.json().catch(() => ({}))) as { message?: string };
112+
throw new Error(body.message || `Failed to request device code (HTTP ${codeRes.status})`);
113+
}
114+
115+
const codeData = (await codeRes.json()) as {
116+
user_code: string;
117+
device_code: string;
118+
verification_uri: string;
119+
verification_uri_complete?: string;
120+
interval: number;
121+
expires_in: number;
122+
};
123+
124+
console.log();
125+
console.log(` Your device code: ${chalk.bold(codeData.user_code)}`);
126+
console.log();
127+
const verifyUrl =
128+
codeData.verification_uri_complete ||
129+
`${baseUrl}${codeData.verification_uri}?user_code=${encodeURIComponent(codeData.user_code)}`;
130+
console.log(` Open this URL to authorize:`);
131+
console.log(` ${chalk.underline(verifyUrl)}`);
132+
console.log();
133+
134+
openBrowser(verifyUrl);
135+
136+
const spinner = ora('Waiting for authorization...').start();
137+
const interval = (codeData.interval || 5) * 1000;
138+
const deadline = Date.now() + codeData.expires_in * 1000;
139+
140+
while (Date.now() < deadline) {
141+
await new Promise((r) => setTimeout(r, interval));
142+
143+
const tokenRes = await fetch(new URL('/api/auth/device/token', baseUrl).toString(), {
144+
method: 'POST',
145+
headers: { 'Content-Type': 'application/json' },
146+
body: JSON.stringify({
147+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
148+
device_code: codeData.device_code,
149+
client_id: 'envx-cli',
150+
}),
151+
});
152+
153+
const tokenData = (await tokenRes.json()) as {
154+
access_token?: string;
155+
error?: string;
156+
};
157+
158+
if (tokenData.access_token) {
159+
spinner.stop();
160+
return tokenData.access_token;
161+
}
162+
163+
if (tokenData.error === 'authorization_pending' || tokenData.error === 'slow_down') {
164+
continue;
165+
}
166+
167+
spinner.stop();
168+
169+
if (tokenData.error === 'expired_token') {
170+
throw new Error('Device code expired. Please try again.');
171+
}
172+
if (tokenData.error === 'access_denied') {
173+
throw new Error('Authorization was denied.');
174+
}
175+
if (tokenData.error) {
176+
throw new Error(`Device flow error: ${tokenData.error}`);
177+
}
178+
}
179+
180+
spinner.stop();
181+
throw new Error('Device code expired. Please try again.');
182+
}
183+
184+
export function loginCommand(program: Command): void {
185+
program
186+
.command('login')
187+
.description('Authenticate with LEAPERone to enable push/pull')
188+
.option('--device', 'Use device flow (no localhost server needed)')
189+
.option('--base-url <url>', 'Override base URL for authentication')
190+
.action(async (opts: { device?: boolean; baseUrl?: string }) => {
191+
try {
192+
const baseUrl = opts.baseUrl || getAuthBaseUrl();
193+
194+
let token: string;
195+
if (opts.device) {
196+
token = await deviceLogin(baseUrl);
197+
} else {
198+
token = await browserLogin(baseUrl);
199+
}
200+
201+
// Verify the token
202+
const spinner = ora('Verifying...').start();
203+
204+
const res = await fetch(new URL('/api/v1/cli/me', baseUrl).toString(), {
205+
headers: {
206+
Authorization: `Bearer ${token}`,
207+
'User-Agent': '@leaperone/envx',
208+
},
209+
});
210+
211+
if (!res.ok) {
212+
spinner.stop();
213+
const body = (await res.json().catch(() => ({}))) as { error?: string };
214+
throw new Error(body.error || `Verification failed (HTTP ${res.status})`);
215+
}
216+
217+
const data = (await res.json()) as {
218+
success: boolean;
219+
data: { id: string; name?: string; email?: string };
220+
};
221+
222+
if (!data.success) {
223+
spinner.stop();
224+
throw new Error('Verification failed');
225+
}
226+
227+
// Save token
228+
const credentials = loadCredentials();
229+
credentials.token = token;
230+
credentials.baseUrl = baseUrl;
231+
saveCredentials(credentials);
232+
233+
spinner.stop();
234+
235+
console.log(
236+
chalk.green(
237+
`\u2705 Authenticated as ${data.data.name || data.data.email || data.data.id}`
238+
)
239+
);
240+
console.log(` Credentials saved to ${chalk.dim(CREDENTIALS_FILE)}`);
241+
} catch (err) {
242+
console.error(chalk.red(`\u274c Login failed: ${(err as Error).message}`));
243+
process.exit(1);
244+
}
245+
});
246+
}

src/commands/logout.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Command } from 'commander';
2+
import chalk from 'chalk';
3+
import { clearCredentials } from '@/utils/credentials';
4+
5+
export function logoutCommand(program: Command): void {
6+
program
7+
.command('logout')
8+
.description('Remove stored credentials')
9+
.action(() => {
10+
clearCredentials();
11+
console.log(chalk.green('\u2705 Logged out. Credentials removed.'));
12+
});
13+
}

src/commands/pull.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
buildPullUrl,
1010
} from '@/utils/url';
1111
import { detectDefaultShell, exportEnv } from '@/utils/env';
12+
import { getCredential } from '@/utils/credentials';
1213
// env file updates will be handled via writeEnvs
1314

1415
interface PullOptions {
@@ -132,10 +133,12 @@ export function pullCommand(program: Command): void {
132133
const headers: Record<string, string> = {
133134
'Content-Type': 'application/json',
134135
};
135-
const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY;
136-
if (apiKey) {
137-
headers['Authorization'] = `Bearer ${apiKey}`;
136+
const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY || getCredential();
137+
if (!apiKey) {
138+
console.error(chalk.red('❌ Not authenticated. Run `envx login` first, or set ENVX_API_KEY.'));
139+
process.exit(1);
138140
}
141+
headers['Authorization'] = `Bearer ${apiKey}`;
139142

140143
const response = await fetchFn(fullUrl, {
141144
method: 'GET',
@@ -149,8 +152,12 @@ export function pullCommand(program: Command): void {
149152
};
150153

151154
if (!response.ok) {
152-
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
153-
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));
155+
if (response.status === 401) {
156+
console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.'));
157+
} else {
158+
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
159+
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));
160+
}
154161
if (options.verbose && responseData.data) {
155162
console.error(chalk.gray('Response data:'));
156163
console.error(chalk.gray(JSON.stringify(responseData.data, null, 2)));

src/commands/push.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from 'path';
55
import { ConfigManager } from '@/utils/config';
66
import { getEnvs } from '@/utils/com';
77
import { parseRef, buildPushUrl } from '@/utils/url';
8+
import { getCredential } from '@/utils/credentials';
89

910
interface PushOptions {
1011
verbose?: boolean;
@@ -111,10 +112,12 @@ export function pushCommand(program: Command): void {
111112
const headers: Record<string, string> = {
112113
'Content-Type': 'application/json',
113114
};
114-
const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY;
115-
if (apiKey) {
116-
headers['Authorization'] = `Bearer ${apiKey}`;
115+
const apiKey = devConfigResult.config.apiKey || process.env.ENVX_API_KEY || getCredential();
116+
if (!apiKey) {
117+
console.error(chalk.red('❌ Not authenticated. Run `envx login` first, or set ENVX_API_KEY.'));
118+
process.exit(1);
117119
}
120+
headers['Authorization'] = `Bearer ${apiKey}`;
118121

119122
const response = await fetchFn(remoteUrl, {
120123
method: 'POST',
@@ -129,8 +132,12 @@ export function pushCommand(program: Command): void {
129132
};
130133

131134
if (!response.ok) {
132-
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
133-
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));
135+
if (response.status === 401) {
136+
console.error(chalk.red('❌ Authentication failed. Run `envx login` to re-authenticate.'));
137+
} else {
138+
console.error(chalk.red(`❌ Error: Remote server returned ${response.status}`));
139+
console.error(chalk.red(`Message: ${responseData.msg || 'Unknown error'}`));
140+
}
134141
if (options.verbose && responseData.data) {
135142
console.error(chalk.gray('Response data:'));
136143
console.error(chalk.gray(JSON.stringify(responseData.data, null, 2)));

0 commit comments

Comments
 (0)