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
191 changes: 41 additions & 150 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,66 +11,7 @@ import type { CliConfig } from '../lib/config-store.js';
import { formatWorkOSCommand } from '../utils/command-invocation.js';
import { autoInstallSkills } from './install-skill.js';
import { isJsonMode } from '../utils/output.js';

/**
* Parse JWT payload
*/
function parseJwt(token: string): Record<string, unknown> | null {
try {
const parts = token.split('.');
if (parts.length !== 3) return null;
return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
} catch {
return null;
}
}

/**
* Extract expiry time from JWT token
*/
function getJwtExpiry(token: string): number | null {
const payload = parseJwt(token);
if (!payload || typeof payload.exp !== 'number') return null;
return payload.exp * 1000;
}

const POLL_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes

/**
* Get Connect OAuth endpoints from AuthKit domain
*/
function getConnectEndpoints() {
const domain = getAuthkitDomain();
return {
deviceAuthorization: `${domain}/oauth2/device_authorization`,
token: `${domain}/oauth2/token`,
};
}

interface DeviceAuthResponse {
device_code: string;
user_code: string;
verification_uri: string;
verification_uri_complete: string;
expires_in: number;
interval: number;
}

interface ConnectTokenResponse {
access_token: string;
id_token: string;
token_type: string;
expires_in: number;
refresh_token?: string;
}

interface AuthErrorResponse {
error: string;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
import { requestDeviceCode, pollForToken, DeviceAuthTimeoutError } from '../lib/device-auth.js';

/**
* Best-effort skill install after a successful auth-login.
Expand Down Expand Up @@ -169,29 +110,19 @@ export async function runLogin(): Promise<void> {
}
}

clack.log.step('Starting authentication...');

const endpoints = getConnectEndpoints();
const authkitDomain = getAuthkitDomain();

const authResponse = await fetch(endpoints.deviceAuthorization, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
client_id: clientId,
scope: 'openid email staging-environment:credentials:read offline_access',
}),
});
clack.log.step('Starting authentication...');

if (!authResponse.ok) {
clack.log.error(`Failed to start authentication: ${authResponse.status}`);
let deviceAuth;
try {
deviceAuth = await requestDeviceCode({ clientId, authkitDomain });
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
clack.log.error(`Failed to start authentication: ${msg}`);
process.exit(1);
Comment on lines +115 to 123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add JSON-mode handling for the new login status/error output paths.

Line 115, Line 122, and Lines 157-177 introduce/modify human-only clack output without a JSON branch. In --json mode, this can emit non-machine-readable text on the command path you changed.

Suggested direction
-  clack.log.step('Starting authentication...');
+  if (isJsonMode()) {
+    console.log(JSON.stringify({ event: 'auth_start' }));
+  } else {
+    clack.log.step('Starting authentication...');
+  }

...

-    spinner.stop('Authentication successful!');
-    clack.log.success(`Logged in as ${result.email || result.userId}`);
-    clack.log.info(`Token expires in ${expiresInSec} seconds`);
+    if (isJsonMode()) {
+      console.log(
+        JSON.stringify({
+          event: 'auth_success',
+          userId: result.userId,
+          email: result.email,
+          expiresInSec,
+        }),
+      );
+    } else {
+      spinner.stop('Authentication successful!');
+      clack.log.success(`Logged in as ${result.email || result.userId}`);
+      clack.log.info(`Token expires in ${expiresInSec} seconds`);
+    }

...

-    if (error instanceof DeviceAuthTimeoutError) {
-      spinner.stop('Authentication timed out');
-      clack.log.error('Authentication timed out. Please try again.');
-    } else {
-      spinner.stop('Authentication failed');
-      const msg = error instanceof Error ? error.message : String(error);
-      clack.log.error(`Authentication error: ${msg}`);
-    }
+    if (isJsonMode()) {
+      const msg = error instanceof Error ? error.message : String(error);
+      console.log(
+        JSON.stringify({
+          event: 'auth_error',
+          timeout: error instanceof DeviceAuthTimeoutError,
+          message: msg,
+        }),
+      );
+    } else if (error instanceof DeviceAuthTimeoutError) {
+      spinner.stop('Authentication timed out');
+      clack.log.error('Authentication timed out. Please try again.');
+    } else {
+      spinner.stop('Authentication failed');
+      const msg = error instanceof Error ? error.message : String(error);
+      clack.log.error(`Authentication error: ${msg}`);
+    }

As per coding guidelines: src/commands/**/*.ts: Implement both human and JSON output modes in commands; check OutputMode usage in src/bin.ts.

Also applies to: 157-177

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/login.ts` around lines 115 - 123, The new human-only clack
messages around the device auth flow need JSON branches so --json emits
machine-readable output: update the requestDeviceCode error handling and the
status/result blocks (around requestDeviceCode/deviceAuth and the later 157-177
status/error outputs) to check the current OutputMode (or the outputMode
variable) and, when in JSON mode, write structured JSON (e.g., { status:
"error", error: msg } or { status: "device_code", data: deviceAuth }) to stdout
instead of clack.log.*; keep the existing clack.log.* for human mode and ensure
the same semantic info is present in both branches using the same identifiers
(requestDeviceCode, deviceAuth, and the login command's status/error variables).

}

const deviceAuth = (await authResponse.json()) as DeviceAuthResponse;
const pollIntervalMs = (deviceAuth.interval || 5) * 1000;

clack.log.info(`\nOpen this URL in your browser:\n`);
console.log(` ${deviceAuth.verification_uri}`);
console.log(`\nEnter code: ${deviceAuth.user_code}\n`);
Expand All @@ -206,84 +137,44 @@ export async function runLogin(): Promise<void> {
const spinner = clack.spinner();
spinner.start('Waiting for authentication...');

const startTime = Date.now();
let currentInterval = pollIntervalMs;

while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleep(currentInterval);

try {
const tokenResponse = await fetch(endpoints.token, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
device_code: deviceAuth.device_code,
client_id: clientId,
}),
});

const data = await tokenResponse.json();

if (tokenResponse.ok) {
const result = data as ConnectTokenResponse;

// Parse user info from id_token JWT
const idTokenPayload = parseJwt(result.id_token);
const userId = (idTokenPayload?.sub as string) || 'unknown';
const email = (idTokenPayload?.email as string) || undefined;

// Extract actual expiry from access token JWT, fallback to response or 15 min
const jwtExpiry = getJwtExpiry(result.access_token);
const expiresAt =
jwtExpiry ?? (result.expires_in ? Date.now() + result.expires_in * 1000 : Date.now() + 15 * 60 * 1000);

const expiresInSec = Math.round((expiresAt - Date.now()) / 1000);

saveCredentials({
accessToken: result.access_token,
expiresAt,
userId,
email,
refreshToken: result.refresh_token,
});
try {
const result = await pollForToken(deviceAuth.device_code, {
clientId,
authkitDomain,
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

interval: deviceAuth.interval,
});

spinner.stop('Authentication successful!');
clack.log.success(`Logged in as ${email || userId}`);
clack.log.info(`Token expires in ${expiresInSec} seconds`);
const expiresInSec = Math.round((result.expiresAt - Date.now()) / 1000);

// Auto-provision staging environment
const provisioned = await provisionStagingEnvironment(result.access_token);
if (provisioned) {
clack.log.success('Staging environment configured automatically');
} else {
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
}
saveCredentials({
accessToken: result.accessToken,
expiresAt: result.expiresAt,
userId: result.userId,
email: result.email,
refreshToken: result.refreshToken,
});

// Best-effort skill install. Wrapped helper guarantees login never
// fails on skill errors.
await installSkillsAfterLogin();
return;
}
spinner.stop('Authentication successful!');
clack.log.success(`Logged in as ${result.email || result.userId}`);
clack.log.info(`Token expires in ${expiresInSec} seconds`);

const errorData = data as AuthErrorResponse;
if (errorData.error === 'authorization_pending') continue;
if (errorData.error === 'slow_down') {
currentInterval += 5000;
continue;
}
const provisioned = await provisionStagingEnvironment(result.accessToken);
if (provisioned) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test

clack.log.success('Staging environment configured automatically');
} else {
clack.log.info(chalk.dim('Run `workos env add` to configure an environment manually'));
}

await installSkillsAfterLogin();
} catch (error) {
if (error instanceof DeviceAuthTimeoutError) {
spinner.stop('Authentication timed out');
clack.log.error('Authentication timed out. Please try again.');
} else {
spinner.stop('Authentication failed');
clack.log.error(`Authentication error: ${errorData.error}`);
process.exit(1);
} catch {
continue;
const msg = error instanceof Error ? error.message : String(error);
clack.log.error(`Authentication error: ${msg}`);
}
process.exit(1);
}

spinner.stop('Authentication timed out');
clack.log.error('Authentication timed out. Please try again.');
process.exit(1);
}
41 changes: 32 additions & 9 deletions src/lib/device-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ export class DeviceAuthError extends Error {
}
}

export class DeviceAuthTimeoutError extends DeviceAuthError {
constructor(message: string) {
super(message);
this.name = 'DeviceAuthTimeoutError';
}
}

const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const DEFAULT_POLL_INTERVAL_SECONDS = 5;
const POLL_REQUEST_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -94,14 +101,30 @@ export async function requestDeviceCode(options: DeviceAuthOptions): Promise<Dev
const url = `${options.authkitDomain}/oauth2/device_authorization`;

logInfo('[device-auth] Requesting device code from:', url);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: options.clientId,
scope: scopes.join(' '),
}),
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), POLL_REQUEST_TIMEOUT_MS);
let res: Response;
try {
res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: options.clientId,
scope: scopes.join(' '),
}),
signal: controller.signal,
});
} catch (error) {
clearTimeout(timeout);
if (error instanceof DOMException && error.name === 'AbortError') {
throw new DeviceAuthTimeoutError('Device authorization request timed out');
}
throw new DeviceAuthError(
`Device authorization request failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
clearTimeout(timeout);
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

logInfo('[device-auth] Device code response status:', res.status);
if (!res.ok) {
Expand Down Expand Up @@ -196,7 +219,7 @@ export async function pollForToken(
}

logError('[device-auth] Authentication timed out, last poll:', lastPollSummary);
throw new DeviceAuthError(
throw new DeviceAuthTimeoutError(
`Authentication timed out after ${Math.round(timeoutMs / 1000)} seconds (last token response: ${lastPollSummary})`,
);
}
Expand Down
Loading