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/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, diff --git a/src/api/errors.ts b/src/api/errors.ts new file mode 100644 index 00000000..c158aa06 --- /dev/null +++ b/src/api/errors.ts @@ -0,0 +1,17 @@ +export type ApiErrorCode = 'SESSION_EXPIRED' | 'INVALID_TOKEN' | 'UNAUTHENTICATED' | 'FORBIDDEN'; + +export class ApiError extends Error { + readonly code: ApiErrorCode; + + constructor(message: string, code: ApiErrorCode) { + super(message); + this.name = 'ApiError'; + this.code = code; + } +} + +const VALID_API_ERROR_CODES: ApiErrorCode[] = ['SESSION_EXPIRED', 'INVALID_TOKEN', 'UNAUTHENTICATED', 'FORBIDDEN']; + +export function isApiErrorCode(code: string): code is ApiErrorCode { + return VALID_API_ERROR_CODES.includes(code as ApiErrorCode); +} diff --git a/src/api/nes.client.ts b/src/api/nes.client.ts index 6334329b..1f2f31dd 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,15 @@ type GraphQLExecutionResult = { errors?: ReadonlyArray; }; +function extractErrorCode(errors: ReadonlyArray): ApiErrorCode | undefined { + const firstError = errors[0]; + const code = (firstError?.extensions as { code?: string })?.code; + if (code && isApiErrorCode(code)) { + return code; + } + return undefined; +} + export const createApollo = (uri: string) => new ApolloClient({ cache: new InMemoryCache(), @@ -54,10 +63,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 +110,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..cd9aa35c 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,34 @@ export default class ScanEol extends Command { return scan; } catch (error) { spinner.fail('Scanning failed'); + + if (error instanceof ApiError) { + if (error.code === 'SESSION_EXPIRED' || error.code === 'INVALID_TOKEN') { + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_failure_reason: error.code, + })); + this.error('Your session is no longer valid. To re-authenticate, run "hd auth login".'); + } + if (error.code === 'UNAUTHENTICATED') { + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_failure_reason: error.code, + })); + this.error('Please log in to perform a scan. To authenticate, run "hd auth login".'); + } + if (error.code === 'FORBIDDEN') { + track('CLI EOL Scan Failed', (context) => ({ + command: context.command, + command_flags: context.command_flags, + scan_failure_reason: error.code, + })); + this.error('You do not have permission to perform this action.'); + } + } + 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..d519d0ce 100644 --- a/src/service/auth.svc.ts +++ b/src/service/auth.svc.ts @@ -2,6 +2,18 @@ import type { TokenResponse } from '../types/auth.ts'; import { refreshTokens } from './auth-refresh.svc.ts'; import { clearStoredTokens, getStoredTokens, isAccessTokenExpired, saveTokens } from './auth-token.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({ accessToken: token.access_token, @@ -40,3 +52,27 @@ 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 { + // Refresh failed - fall through to session expired 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', + }); + }); + }); });