Skip to content

Commit d3be3d3

Browse files
committed
feat: implement device flow for CLI browser-based login and registration
1 parent c118311 commit d3be3d3

6 files changed

Lines changed: 538 additions & 83 deletions

File tree

packages/cli/src/commands/auth/login.ts

Lines changed: 187 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Command, Flags } from '@oclif/core';
44
import { printHeader, printSuccess, printError, printKV } from '../../utils/format.js';
5-
import { writeAuthConfig } from '../../utils/auth-config.js';
5+
import { writeAuthConfig, readAuthConfig } from '../../utils/auth-config.js';
66
import { ObjectStackClient } from '@objectstack/client';
77
import * as readline from 'node:readline/promises';
88
import { stdin as input, stdout as output } from 'node:process';
@@ -35,7 +35,7 @@ async function promptPassword(promptText: string): Promise<string> {
3535

3636
const handler = (char: string) => {
3737
switch (char) {
38-
case '\u0003': // Ctrl+C
38+
case '': // Ctrl+C
3939
cleanup();
4040
process.kill(process.pid, 'SIGINT');
4141
break;
@@ -44,7 +44,7 @@ async function promptPassword(promptText: string): Promise<string> {
4444
cleanup();
4545
resolve(chars.join(''));
4646
break;
47-
case '\u007f': // Backspace
47+
case '': // Backspace
4848
if (chars.length > 0) {
4949
chars.pop();
5050
process.stdout.clearLine(0);
@@ -63,13 +63,24 @@ async function promptPassword(promptText: string): Promise<string> {
6363
});
6464
}
6565

66+
/**
67+
* Open a URL in the system default browser (cross-platform).
68+
*/
69+
async function openBrowser(url: string): Promise<void> {
70+
const { exec } = await import('node:child_process');
71+
const platform = process.platform;
72+
const cmd = platform === 'darwin' ? `open "${url}"` : platform === 'win32' ? `start "" "${url}"` : `xdg-open "${url}"`;
73+
exec(cmd, () => { /* best-effort */ });
74+
}
75+
6676
export default class AuthLogin extends Command {
6777
static override description = 'Authenticate and store session credentials';
6878

6979
static override examples = [
7080
'$ os auth login',
7181
'$ os auth login --url https://api.example.com',
7282
'$ os auth login --email user@example.com --password mypassword',
83+
'$ os auth login --no-browser',
7384
];
7485

7586
static override flags = {
@@ -81,11 +92,20 @@ export default class AuthLogin extends Command {
8192
}),
8293
email: Flags.string({
8394
char: 'e',
84-
description: 'Email address',
95+
description: 'Email address (skips browser flow)',
8596
}),
8697
password: Flags.string({
8798
char: 'p',
88-
description: 'Password',
99+
description: 'Password (skips browser flow)',
100+
}),
101+
'no-browser': Flags.boolean({
102+
description: 'Print the login URL without opening a browser',
103+
default: false,
104+
}),
105+
force: Flags.boolean({
106+
char: 'f',
107+
description: 'Force login even if already authenticated',
108+
default: false,
89109
}),
90110
json: Flags.boolean({
91111
description: 'Output as JSON',
@@ -96,93 +116,192 @@ export default class AuthLogin extends Command {
96116
const { flags } = await this.parse(AuthLogin);
97117

98118
try {
119+
// --- Already logged in guard ---
120+
if (!flags.force) {
121+
try {
122+
const existing = await readAuthConfig();
123+
if (existing?.token) {
124+
if (flags.json) {
125+
console.log(JSON.stringify({ success: false, error: 'Already logged in', email: existing.email }));
126+
} else {
127+
printSuccess(`Already logged in as ${existing.email || existing.userId}`);
128+
console.log('');
129+
console.log(' Run `os auth logout` first to switch accounts, or use --force to re-authenticate.');
130+
console.log('');
131+
}
132+
return;
133+
}
134+
} catch {
135+
// No credentials stored — proceed with login
136+
}
137+
}
138+
99139
if (!flags.json) {
100140
printHeader('ObjectStack Login');
101141
printKV('Server', flags.url);
102142
console.log('');
103143
}
104144

105-
// Prompt for credentials if not provided
145+
const client = new ObjectStackClient({ baseUrl: flags.url });
146+
147+
// --- CI / non-interactive path: email + password flags provided ---
148+
if (flags.email && flags.password) {
149+
await this.loginWithPassword(client, flags.url, flags.email, flags.password, flags.json);
150+
return;
151+
}
152+
153+
// --- Interactive TTY: prompt style ---
154+
if (process.stdin.isTTY && !flags.email && !flags.password) {
155+
// Default: browser-based device flow
156+
await this.loginWithBrowser(client, flags.url, flags['no-browser'], flags.json);
157+
return;
158+
}
159+
160+
// --- Non-TTY fallback: prompt for email/password ---
161+
const rl = readline.createInterface({ input, output });
106162
let email = flags.email;
107163
let password = flags.password;
108164

109-
if (!email || !password) {
110-
const rl = readline.createInterface({ input, output });
111-
112-
if (!email) {
113-
email = await rl.question('Email: ');
114-
}
165+
if (!email) email = await rl.question('Email: ');
166+
rl.close();
167+
if (!password) password = await promptPassword('Password: ');
115168

116-
rl.close();
169+
if (!email || !password) throw new Error('Email and password are required');
117170

118-
if (!password) {
119-
password = await promptPassword('Password: ');
120-
}
171+
await this.loginWithPassword(client, flags.url, email, password, flags.json);
172+
} catch (error: any) {
173+
if (flags.json) {
174+
console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
175+
this.exit(1);
121176
}
177+
printError(error.message || String(error));
178+
this.exit(1);
179+
}
180+
}
122181

123-
if (!email || !password) {
124-
throw new Error('Email and password are required');
125-
}
182+
private async loginWithPassword(
183+
client: ObjectStackClient,
184+
url: string,
185+
email: string,
186+
password: string,
187+
jsonOutput?: boolean,
188+
): Promise<void> {
189+
const response = await client.auth.login({ type: 'email', email, password });
126190

127-
// Create client and authenticate
128-
const client = new ObjectStackClient({
129-
baseUrl: flags.url,
130-
});
191+
if (!response.data?.token && !response.data?.user) {
192+
throw new Error('Login failed: Invalid response from server');
193+
}
131194

132-
const response = await client.auth.login({
133-
type: 'email',
134-
email,
135-
password,
136-
});
195+
const token = response.data?.token || (response as any).token;
196+
const user = response.data?.user;
137197

138-
// Check if login was successful
139-
if (!response.data?.token && !response.data?.user) {
140-
throw new Error('Login failed: Invalid response from server');
141-
}
198+
if (!token) throw new Error('Login failed: No token received from server');
142199

143-
// Extract token - it might be in different locations depending on the auth system
144-
const token = response.data?.token || (response as any).token;
145-
const user = response.data?.user;
200+
await writeAuthConfig({
201+
url,
202+
token,
203+
email: user?.email || email,
204+
userId: user?.id,
205+
createdAt: new Date().toISOString(),
206+
});
146207

147-
if (!token) {
148-
throw new Error('Login failed: No token received from server');
149-
}
208+
if (jsonOutput) {
209+
console.log(JSON.stringify({ success: true, email: user?.email || email, userId: user?.id }, null, 2));
210+
} else {
211+
printSuccess('Authentication successful');
212+
printKV('Email', user?.email || email);
213+
if (user?.id) printKV('User ID', user.id);
214+
console.log('');
215+
console.log(' Credentials stored in ~/.objectstack/credentials.json');
216+
console.log('');
217+
}
218+
}
150219

151-
// Store credentials
152-
await writeAuthConfig({
153-
url: flags.url,
154-
token,
155-
email: user?.email || email,
156-
userId: user?.id,
157-
createdAt: new Date().toISOString(),
158-
});
220+
private async loginWithBrowser(
221+
_client: ObjectStackClient,
222+
url: string,
223+
noBrowser: boolean,
224+
jsonOutput?: boolean,
225+
): Promise<void> {
226+
// Request a device code from the server
227+
const res = await globalThis.fetch(`${url}/api/v1/auth/device/request`, {
228+
method: 'POST',
229+
headers: { 'Content-Type': 'application/json' },
230+
body: '{}',
231+
});
232+
if (!res.ok) throw new Error(`Device request failed: ${res.status}`);
233+
const resJson = await res.json() as any;
234+
const deviceData = resJson?.data ?? resJson;
235+
const { code, verificationUrl, expiresAt, interval = 2 } = deviceData ?? {};
159236

160-
if (flags.json) {
161-
console.log(JSON.stringify({
162-
success: true,
163-
email: user?.email || email,
237+
if (!code || !verificationUrl) {
238+
throw new Error('Server did not return a device code. Try `os auth login --email <email> --password <password>` instead.');
239+
}
240+
241+
if (jsonOutput) {
242+
console.log(JSON.stringify({ code, verificationUrl, expiresAt }));
243+
} else {
244+
console.log(' Open the following URL to log in:');
245+
console.log('');
246+
console.log(` ${verificationUrl}`);
247+
console.log('');
248+
}
249+
250+
if (!noBrowser && !jsonOutput) {
251+
await openBrowser(verificationUrl);
252+
console.log(' (Browser opened automatically. Press Ctrl+C to cancel.)');
253+
console.log('');
254+
}
255+
256+
// Poll for approval
257+
const pollMs = interval * 1000;
258+
const expiryTime = new Date(expiresAt).getTime();
259+
let spinner = 0;
260+
const spinChars = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
261+
262+
while (Date.now() < expiryTime) {
263+
await new Promise(r => setTimeout(r, pollMs));
264+
265+
const pollRes = await globalThis.fetch(`${url}/api/v1/auth/device/token?code=${encodeURIComponent(code)}`);
266+
const pollJson = await pollRes.json() as any;
267+
const pollData = pollJson?.data ?? pollJson;
268+
269+
if (pollData?.status === 'approved') {
270+
const { token, user } = pollData;
271+
272+
if (!jsonOutput) process.stdout.write('\r\x1b[K'); // clear spinner line
273+
274+
await writeAuthConfig({
275+
url,
276+
token,
277+
email: user?.email,
164278
userId: user?.id,
165-
}, null, 2));
166-
} else {
167-
printSuccess('Authentication successful');
168-
printKV('Email', user?.email || email);
169-
if (user?.id) {
170-
printKV('User ID', user.id);
279+
createdAt: new Date().toISOString(),
280+
});
281+
282+
if (jsonOutput) {
283+
console.log(JSON.stringify({ success: true, email: user?.email, userId: user?.id }, null, 2));
284+
} else {
285+
printSuccess('Authentication successful');
286+
if (user?.email) printKV('Email', user.email);
287+
if (user?.id) printKV('User ID', user.id);
288+
console.log('');
289+
console.log(' Credentials stored in ~/.objectstack/credentials.json');
290+
console.log('');
171291
}
172-
console.log('');
173-
console.log(' Credentials stored in ~/.objectstack/credentials.json');
174-
console.log('');
292+
return;
175293
}
176-
} catch (error: any) {
177-
if (flags.json) {
178-
console.log(JSON.stringify({
179-
success: false,
180-
error: error.message,
181-
}, null, 2));
182-
this.exit(1);
294+
295+
if (pollData?.status === 'expired') {
296+
throw new Error('Login timed out. Please run `os auth login` again.');
297+
}
298+
299+
if (!jsonOutput) {
300+
process.stdout.write(`\r ${spinChars[spinner % spinChars.length]} Waiting for browser approval...`);
301+
spinner++;
183302
}
184-
printError(error.message || String(error));
185-
this.exit(1);
186303
}
304+
305+
throw new Error('Login timed out. Please run `os auth login` again.');
187306
}
188307
}

packages/cli/src/commands/auth/logout.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
import { Command, Flags } from '@oclif/core';
44
import { printHeader, printSuccess, printError } from '../../utils/format.js';
5-
import { deleteAuthConfig } from '../../utils/auth-config.js';
5+
import { deleteAuthConfig, readAuthConfig } from '../../utils/auth-config.js';
6+
import { ObjectStackClient } from '@objectstack/client';
67

78
export default class AuthLogout extends Command {
89
static override description = 'Clear stored authentication credentials';
@@ -25,6 +26,17 @@ export default class AuthLogout extends Command {
2526
printHeader('ObjectStack Logout');
2627
}
2728

29+
// Revoke server-side session before deleting local credentials
30+
try {
31+
const config = await readAuthConfig();
32+
if (config?.token && config?.url) {
33+
const client = new ObjectStackClient({ baseUrl: config.url, token: config.token });
34+
await client.auth.logout();
35+
}
36+
} catch {
37+
// Best-effort: continue even if server revocation fails
38+
}
39+
2840
await deleteAuthConfig();
2941

3042
if (flags.json) {

0 commit comments

Comments
 (0)