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
5 changes: 4 additions & 1 deletion src/auth/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ export function createAuthClient(options = {}) {

if (!response.ok) {
let body = await parseResponseBody(response);
throw parseAuthError(response.status, body, endpoint);
let error = parseAuthError(response.status, body, endpoint);
let retryAfter = Number(response.headers?.get('retry-after'));
if (retryAfter > 0) error.context.retryAfter = retryAfter;
throw error;
}

return response.json();
Expand Down
93 changes: 90 additions & 3 deletions src/auth/core.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,33 @@ export function parseAuthError(status, body, _endpoint) {
errorText = body;
}

let retryAfter = Number(body?.retryAfter);
let context = {
status,
serverCode: body?.code,
retryAfter: retryAfter > 0 ? retryAfter : undefined,
};

if (status === 401) {
return new AuthError(
errorText ||
'Invalid credentials. Please check your email/username and password.'
'Invalid credentials. Please check your email/username and password.',
context
);
}

if (status === 429) {
return new VizzlyError(
'Too many login attempts. Please try again later.',
'RATE_LIMIT_ERROR'
'RATE_LIMIT_ERROR',
context
);
}

return new VizzlyError(
`Authentication request failed: ${status}${errorText ? ` - ${errorText}` : ''}`,
'AUTH_REQUEST_ERROR'
'AUTH_REQUEST_ERROR',
context
);
}

Expand Down Expand Up @@ -184,3 +194,80 @@ export function validateTokens(auth, requiredField = 'accessToken') {

return { valid: true, error: null };
}

export function normalizeDeviceFlowResponse(deviceFlow) {
let verificationUri =
deviceFlow.verification_uri || deviceFlow.verificationUri;
let userCode = deviceFlow.user_code || deviceFlow.userCode;
let deviceCode = deviceFlow.device_code || deviceFlow.deviceCode;

if (!verificationUri || !userCode || !deviceCode) {
throw new Error('Invalid device flow response from server');
}

return {
verificationUri,
userCode,
deviceCode,
urlWithCode:
deviceFlow.verification_uri_complete ||
deviceFlow.verificationUriComplete ||
`${verificationUri}?code=${encodeURIComponent(userCode)}`,
};
}

export function resolveAuthorizedTokenData(pollResponse) {
let accessToken =
pollResponse.tokens?.accessToken || pollResponse.tokens?.access_token;

if (accessToken) {
return pollResponse;
}

if (pollResponse.status === 'pending') {
throw new Error(
'Authorization not complete yet. Please complete the authorization in your browser and try running "vizzly login" again.'
);
}
if (pollResponse.status === 'expired') {
throw new Error('Device code expired. Please try logging in again.');
}
if (pollResponse.status === 'invalid') {
throw new Error(
'Device code is invalid or expired. Please try logging in again.'
);
}
if (pollResponse.status === 'denied') {
throw new Error('Authorization denied. Please try logging in again.');
}

throw new Error(
'Unexpected response from authorization server. Please try logging in again.'
);
}

export function buildTokens(tokenData, now = Date.now()) {
let tokensData = tokenData.tokens || tokenData;
let tokenExpiresIn = tokensData.expiresIn || tokensData.expires_in;
let tokenExpiresAt = tokenExpiresIn
? new Date(now + tokenExpiresIn * 1000).toISOString()
: tokenData.expires_at || tokenData.expiresAt;

return {
accessToken: tokensData.accessToken || tokensData.access_token,
refreshToken: tokensData.refreshToken || tokensData.refresh_token,
expiresAt: tokenExpiresAt,
user: tokenData.user,
organizations: tokenData.organizations,
};
}

/** Map protocol codes without depending on the server's human-readable text. */
export function deviceAuthorizationStatus(error) {
return {
AUTHORIZATION_PENDING: 'pending',
ACCESS_DENIED: 'denied',
EXPIRED_TOKEN: 'expired',
INVALID_DEVICE_CODE: 'invalid',
}[error.context?.serverCode];
}
44 changes: 29 additions & 15 deletions src/auth/operations.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@
* This makes them trivially testable without mocking modules.
*/

import * as output from '../utils/output.js';
import {
buildDevicePollPayload,
buildLogoutPayload,
buildRefreshPayload,
buildTokenData,
deviceAuthorizationStatus,
validateTokens,
} from './core.js';

Expand All @@ -38,12 +40,23 @@ export async function initiateDeviceFlow(httpClient) {
* @param {string} deviceCode - Device code from initiate
* @returns {Promise<Object>} Token data or pending status
*/
export async function pollDeviceAuthorization(httpClient, deviceCode) {
return httpClient.request('/api/auth/cli/device/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildDevicePollPayload(deviceCode)),
});
export async function pollDeviceAuthorization(
httpClient,
deviceCode,
options = {}
) {
try {
return await httpClient.request('/api/auth/cli/device/poll', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildDevicePollPayload(deviceCode)),
signal: options.signal,
});
} catch (error) {
let status = deviceAuthorizationStatus(error);
if (status) return { status };
throw error;
}
}

/**
Expand Down Expand Up @@ -102,17 +115,18 @@ export async function logout(httpClient, tokenStore) {

if (auth?.refreshToken) {
try {
await httpClient.request('/api/auth/cli/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildLogoutPayload(auth.refreshToken)),
});
await httpClient.authenticatedRequest(
'/api/auth/cli/logout',
auth.accessToken,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildLogoutPayload(auth.refreshToken)),
}
);
} catch (error) {
// If server request fails, still clear local tokens
console.warn(
'Warning: Failed to revoke tokens on server:',
error.message
);
output.warn(`Failed to revoke tokens on server: ${error.message}`);
}
}

Expand Down
Loading
Loading