Skip to content
Open
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
18 changes: 17 additions & 1 deletion .envrc.example
Original file line number Diff line number Diff line change
@@ -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';
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';
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
17 changes: 17 additions & 0 deletions src/api/errors.ts
Original file line number Diff line number Diff line change
@@ -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);
}
33 changes: 26 additions & 7 deletions src/api/nes.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}

Expand All @@ -29,6 +29,15 @@ type GraphQLExecutionResult = {
errors?: ReadonlyArray<GraphQLFormattedError>;
};

function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): 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(),
Expand All @@ -54,10 +63,14 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
});

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');
}

Expand Down Expand Up @@ -97,6 +110,12 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
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');
}

Expand Down
34 changes: 34 additions & 0 deletions src/commands/scan/eol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -82,6 +84,10 @@ export default class ScanEol extends Command {
public async run(): Promise<EolReport | undefined> {
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,
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 36 additions & 0 deletions src/service/auth.svc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -40,3 +52,27 @@ export async function requireAccessToken(): Promise<string> {
export async function logoutLocally() {
await clearStoredTokens();
}

export async function requireAccessTokenForScan(): Promise<string> {
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');
}
72 changes: 71 additions & 1 deletion test/service/auth.svc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
});
});
});
});