From 0d06be299598e74eab2c2ea66a97376e1e641143 Mon Sep 17 00:00:00 2001 From: Facundo Rodriguez Date: Thu, 8 Jan 2026 10:12:13 -0300 Subject: [PATCH 1/2] fix: update biome.json to match installed CLI version --- biome.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/biome.json b/biome.json index c58a45ce..65b5e406 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.4/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.8/schema.json", "assist": { "actions": { "source": { "organizeImports": "on" } } }, "linter": { "enabled": true, From a4c8b5432ffbf2dc531a681052141c3d29f20aaf Mon Sep 17 00:00:00 2001 From: Facundo Rodriguez Date: Fri, 9 Jan 2026 16:19:13 -0300 Subject: [PATCH 2/2] feat: add auth error handling --- .envrc.example | 18 ++++++++- src/api/errors.ts | 18 +++++++++ src/api/nes.client.ts | 30 +++++++++++---- src/commands/scan/eol.ts | 23 +++++++++++ src/service/auth.svc.ts | 38 ++++++++++++++++++ test/service/auth.svc.test.ts | 72 ++++++++++++++++++++++++++++++++++- 6 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 src/api/errors.ts diff --git a/.envrc.example b/.envrc.example index f4497423..65624cc0 100644 --- a/.envrc.example +++ b/.envrc.example @@ -1,5 +1,21 @@ #!/usr/bin/env bash +# API Configuration export GRAPHQL_HOST='https://api.nes.herodevs.com'; +export GRAPHQL_PATH='/graphql'; export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports'; -export ANALYTICS_URL='https://eol-api.herodevs.com/track'; \ No newline at end of file +export ANALYTICS_URL='https://eol-api.herodevs.com/track'; + +# Authentication (set to 'true' to enable auth requirement for scans) +export ENABLE_AUTH='false'; +export OAUTH_CONNECT_URL=''; +export OAUTH_CLIENT_ID=''; + +# Performance tuning (optional) +# export CONCURRENT_PAGE_REQUESTS='3'; +# export PAGE_SIZE='500'; + +# Keyring configuration (optional, for debugging) +# export HD_AUTH_SERVICE_NAME='@herodevs/cli'; +# export HD_AUTH_ACCESS_KEY='access-token'; +# export HD_AUTH_REFRESH_KEY='refresh-token'; diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 00000000..528efb4e --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,18 @@ +const API_ERROR_CODES = ['SESSION_EXPIRED', 'INVALID_TOKEN', 'UNAUTHENTICATED', 'FORBIDDEN'] as const; +export type ApiErrorCode = (typeof API_ERROR_CODES)[number]; + +const VALID_API_ERROR_CODES = new Set(API_ERROR_CODES); + +export class ApiError extends Error { + readonly code: ApiErrorCode; + + constructor(message: string, code: ApiErrorCode) { + super(message); + this.name = 'ApiError'; + this.code = code; + } +} + +export function isApiErrorCode(code: string): code is ApiErrorCode { + return VALID_API_ERROR_CODES.has(code as ApiErrorCode); +} diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 6334329b..64191fce 100644 --- a/src/api/nes.client.ts +++ b/src/api/nes.client.ts @@ -8,17 +8,17 @@ import type { } from '@herodevs/eol-shared'; import type { GraphQLFormattedError } from 'graphql'; import { config } from '../config/constants.ts'; -import { requireAccessToken } from '../service/auth.svc.ts'; +import { requireAccessTokenForScan } from '../service/auth.svc.ts'; import { debugLogger } from '../service/log.svc.ts'; import { stripTypename } from '../utils/strip-typename.ts'; +import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts'; import { createReportMutation, getEolReportQuery } from './gql-operations.ts'; const createAuthorizedFetch = (): typeof fetch => async (input, init) => { const headers = new Headers(init?.headers); if (config.enableAuth) { - // Temporary gate while legacy commands migrate to authenticated flow - const token = await requireAccessToken(); + const token = await requireAccessTokenForScan(); headers.set('Authorization', `Bearer ${token}`); } @@ -29,6 +29,12 @@ type GraphQLExecutionResult = { errors?: ReadonlyArray; }; +function extractErrorCode(errors: ReadonlyArray): ApiErrorCode | undefined { + const code = (errors[0]?.extensions as { code?: string })?.code; + if (!code || !isApiErrorCode(code)) return; + return code; +} + export const createApollo = (uri: string) => new ApolloClient({ cache: new InMemoryCache(), @@ -54,10 +60,14 @@ export const SbomScanner = (client: ReturnType) => { }); if (res?.error || (res as GraphQLExecutionResult)?.errors) { - debugLogger( - 'Error returned from createReport mutation: %o', - res.error || (res as GraphQLExecutionResult | undefined)?.errors, - ); + const errors = (res as GraphQLExecutionResult | undefined)?.errors; + debugLogger('Error returned from createReport mutation: %o', res.error || errors); + if (errors?.length) { + const code = extractErrorCode(errors); + if (code) { + throw new ApiError(errors[0].message, code); + } + } throw new Error('Failed to create EOL report'); } @@ -97,6 +107,12 @@ export const SbomScanner = (client: ReturnType) => { const queryErrors = (response as GraphQLExecutionResult | undefined)?.errors; if (response?.error || queryErrors?.length || !response.data?.eol) { debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response); + if (queryErrors?.length) { + const code = extractErrorCode(queryErrors); + if (code) { + throw new ApiError(queryErrors[0].message, code); + } + } throw new Error('Failed to fetch EOL report'); } diff --git a/src/commands/scan/eol.ts b/src/commands/scan/eol.ts index 4d71f1c9..78f42b59 100644 --- a/src/commands/scan/eol.ts +++ b/src/commands/scan/eol.ts @@ -2,9 +2,11 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared'; import { trimCdxBom } from '@herodevs/eol-shared'; import { Command, Flags } from '@oclif/core'; import ora from 'ora'; +import { ApiError } from '../../api/errors.ts'; import { submitScan } from '../../api/nes.client.ts'; import { config, filenamePrefix } from '../../config/constants.ts'; import { track } from '../../service/analytics.svc.ts'; +import { requireAccessTokenForScan } from '../../service/auth.svc.ts'; import { createSbom } from '../../service/cdx.svc.ts'; import { countComponentsByStatus, @@ -82,6 +84,10 @@ export default class ScanEol extends Command { public async run(): Promise { const { flags } = await this.parse(ScanEol); + if (config.enableAuth) { + await requireAccessTokenForScan(); + } + track('CLI EOL Scan Started', (context) => ({ command: context.command, command_flags: context.command_flags, @@ -209,6 +215,23 @@ export default class ScanEol extends Command { return scan; } catch (error) { spinner.fail('Scanning failed'); + + if (error instanceof ApiError) { + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_failure_reason: error.code, + })); + + const errorMessages: Record = { + SESSION_EXPIRED: 'Your session is no longer valid. To re-authenticate, run "hd auth login".', + INVALID_TOKEN: 'Your session is no longer valid. To re-authenticate, run "hd auth login".', + UNAUTHENTICATED: 'Please log in to perform a scan. To authenticate, run "hd auth login".', + FORBIDDEN: 'You do not have permission to perform this action.', + }; + this.error(errorMessages[error.code]); + } + const errorMessage = getErrorMessage(error); track('CLI EOL Scan Failed', (context) => ({ command: context.command, diff --git a/src/service/auth.svc.ts b/src/service/auth.svc.ts index 92b9ad34..65c75cb9 100644 --- a/src/service/auth.svc.ts +++ b/src/service/auth.svc.ts @@ -1,6 +1,19 @@ import type { TokenResponse } from '../types/auth.ts'; import { refreshTokens } from './auth-refresh.svc.ts'; import { clearStoredTokens, getStoredTokens, isAccessTokenExpired, saveTokens } from './auth-token.svc.ts'; +import { debugLogger } from './log.svc.ts'; + +export type AuthErrorCode = 'NOT_LOGGED_IN' | 'SESSION_EXPIRED'; + +export class AuthError extends Error { + readonly code: AuthErrorCode; + + constructor(message: string, code: AuthErrorCode) { + super(message); + this.name = 'AuthError'; + this.code = code; + } +} export async function persistTokenResponse(token: TokenResponse) { await saveTokens({ @@ -40,3 +53,28 @@ export async function requireAccessToken(): Promise { export async function logoutLocally() { await clearStoredTokens(); } + +export async function requireAccessTokenForScan(): Promise { + const tokens = await getStoredTokens(); + + if (!tokens?.accessToken) { + throw new AuthError('Please log in to perform a scan. To authenticate, run "hd auth login".', 'NOT_LOGGED_IN'); + } + + if (!isAccessTokenExpired(tokens.accessToken)) { + return tokens.accessToken; + } + + if (tokens.refreshToken) { + try { + const newTokens = await refreshTokens(tokens.refreshToken); + await persistTokenResponse(newTokens); + return newTokens.access_token; + } catch (error) { + // Refresh failed - fall through to session expired error + debugLogger('Token refresh failed: %O', error); + } + } + + throw new AuthError('Your session is no longer valid. To re-authenticate, run "hd auth login".', 'SESSION_EXPIRED'); +} diff --git a/test/service/auth.svc.test.ts b/test/service/auth.svc.test.ts index 55ba805d..83f49153 100644 --- a/test/service/auth.svc.test.ts +++ b/test/service/auth.svc.test.ts @@ -13,7 +13,14 @@ vi.mock('../../src/service/auth-refresh.svc.ts', () => ({ refreshTokens: vi.fn(), })); -import { getAccessToken, logoutLocally, persistTokenResponse, requireAccessToken } from '../../src/service/auth.svc.ts'; +import { + AuthError, + getAccessToken, + logoutLocally, + persistTokenResponse, + requireAccessToken, + requireAccessTokenForScan, +} from '../../src/service/auth.svc.ts'; import { refreshTokens } from '../../src/service/auth-refresh.svc.ts'; import { clearStoredTokens, @@ -77,4 +84,67 @@ describe('auth.svc', () => { await logoutLocally(); expect(clearStoredTokens).toHaveBeenCalled(); }); + + describe('requireAccessTokenForScan', () => { + it('returns token when access token is valid', async () => { + (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'valid-token' }); + (isAccessTokenExpired as Mock).mockReturnValue(false); + + const token = await requireAccessTokenForScan(); + expect(token).toBe('valid-token'); + expect(refreshTokens).not.toHaveBeenCalled(); + }); + + it('auto-refreshes when access token expired with valid refresh token', async () => { + (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired', refreshToken: 'refresh-1' }); + (isAccessTokenExpired as Mock).mockReturnValue(true); + (refreshTokens as Mock).mockResolvedValue({ access_token: 'new-token', refresh_token: 'refresh-2' }); + + const token = await requireAccessTokenForScan(); + expect(token).toBe('new-token'); + expect(refreshTokens).toHaveBeenCalledWith('refresh-1'); + expect(saveTokens).toHaveBeenCalledWith({ accessToken: 'new-token', refreshToken: 'refresh-2' }); + }); + + it('throws AuthError with NOT_LOGGED_IN when no tokens exist', async () => { + (getStoredTokens as Mock).mockResolvedValue(undefined); + + await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError); + await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + code: 'NOT_LOGGED_IN', + message: 'Please log in to perform a scan. To authenticate, run "hd auth login".', + }); + }); + + it('throws AuthError with NOT_LOGGED_IN when access token is missing', async () => { + (getStoredTokens as Mock).mockResolvedValue({ refreshToken: 'refresh-only' }); + + await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError); + await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + code: 'NOT_LOGGED_IN', + }); + }); + + it('throws AuthError with SESSION_EXPIRED when refresh fails', async () => { + (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired', refreshToken: 'invalid-refresh' }); + (isAccessTokenExpired as Mock).mockReturnValue(true); + (refreshTokens as Mock).mockRejectedValue(new Error('refresh failed')); + + await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError); + await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + code: 'SESSION_EXPIRED', + message: 'Your session is no longer valid. To re-authenticate, run "hd auth login".', + }); + }); + + it('throws AuthError with SESSION_EXPIRED when access token expired and no refresh token', async () => { + (getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired' }); + (isAccessTokenExpired as Mock).mockReturnValue(true); + + await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError); + await expect(requireAccessTokenForScan()).rejects.toMatchObject({ + code: 'SESSION_EXPIRED', + }); + }); + }); });