Skip to content

Commit 2fb3371

Browse files
feat: add auth error handling (#461)
* fix: update biome.json to match installed CLI version * feat: add auth error handling
1 parent 4afb3b0 commit 2fb3371

File tree

7 files changed

+191
-10
lines changed

7 files changed

+191
-10
lines changed

.envrc.example

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
#!/usr/bin/env bash
22

3+
# API Configuration
34
export GRAPHQL_HOST='https://api.nes.herodevs.com';
5+
export GRAPHQL_PATH='/graphql';
46
export EOL_REPORT_URL='https://eol-report-card.apps.herodevs.com/reports';
5-
export ANALYTICS_URL='https://eol-api.herodevs.com/track';
7+
export ANALYTICS_URL='https://eol-api.herodevs.com/track';
8+
9+
# Authentication (set to 'true' to enable auth requirement for scans)
10+
export ENABLE_AUTH='false';
11+
export OAUTH_CONNECT_URL='';
12+
export OAUTH_CLIENT_ID='';
13+
14+
# Performance tuning (optional)
15+
# export CONCURRENT_PAGE_REQUESTS='3';
16+
# export PAGE_SIZE='500';
17+
18+
# Keyring configuration (optional, for debugging)
19+
# export HD_AUTH_SERVICE_NAME='@herodevs/cli';
20+
# export HD_AUTH_ACCESS_KEY='access-token';
21+
# export HD_AUTH_REFRESH_KEY='refresh-token';

biome.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://biomejs.dev/schemas/2.3.4/schema.json",
2+
"$schema": "https://biomejs.dev/schemas/2.3.8/schema.json",
33
"assist": { "actions": { "source": { "organizeImports": "on" } } },
44
"linter": {
55
"enabled": true,

src/api/errors.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
const API_ERROR_CODES = ['SESSION_EXPIRED', 'INVALID_TOKEN', 'UNAUTHENTICATED', 'FORBIDDEN'] as const;
2+
export type ApiErrorCode = (typeof API_ERROR_CODES)[number];
3+
4+
const VALID_API_ERROR_CODES = new Set<ApiErrorCode>(API_ERROR_CODES);
5+
6+
export class ApiError extends Error {
7+
readonly code: ApiErrorCode;
8+
9+
constructor(message: string, code: ApiErrorCode) {
10+
super(message);
11+
this.name = 'ApiError';
12+
this.code = code;
13+
}
14+
}
15+
16+
export function isApiErrorCode(code: string): code is ApiErrorCode {
17+
return VALID_API_ERROR_CODES.has(code as ApiErrorCode);
18+
}

src/api/nes.client.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,17 @@ import type {
88
} from '@herodevs/eol-shared';
99
import type { GraphQLFormattedError } from 'graphql';
1010
import { config } from '../config/constants.ts';
11-
import { requireAccessToken } from '../service/auth.svc.ts';
11+
import { requireAccessTokenForScan } from '../service/auth.svc.ts';
1212
import { debugLogger } from '../service/log.svc.ts';
1313
import { stripTypename } from '../utils/strip-typename.ts';
14+
import { ApiError, type ApiErrorCode, isApiErrorCode } from './errors.ts';
1415
import { createReportMutation, getEolReportQuery } from './gql-operations.ts';
1516

1617
const createAuthorizedFetch = (): typeof fetch => async (input, init) => {
1718
const headers = new Headers(init?.headers);
1819

1920
if (config.enableAuth) {
20-
// Temporary gate while legacy commands migrate to authenticated flow
21-
const token = await requireAccessToken();
21+
const token = await requireAccessTokenForScan();
2222
headers.set('Authorization', `Bearer ${token}`);
2323
}
2424

@@ -29,6 +29,12 @@ type GraphQLExecutionResult = {
2929
errors?: ReadonlyArray<GraphQLFormattedError>;
3030
};
3131

32+
function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
33+
const code = (errors[0]?.extensions as { code?: string })?.code;
34+
if (!code || !isApiErrorCode(code)) return;
35+
return code;
36+
}
37+
3238
export const createApollo = (uri: string) =>
3339
new ApolloClient({
3440
cache: new InMemoryCache(),
@@ -54,10 +60,14 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
5460
});
5561

5662
if (res?.error || (res as GraphQLExecutionResult)?.errors) {
57-
debugLogger(
58-
'Error returned from createReport mutation: %o',
59-
res.error || (res as GraphQLExecutionResult | undefined)?.errors,
60-
);
63+
const errors = (res as GraphQLExecutionResult | undefined)?.errors;
64+
debugLogger('Error returned from createReport mutation: %o', res.error || errors);
65+
if (errors?.length) {
66+
const code = extractErrorCode(errors);
67+
if (code) {
68+
throw new ApiError(errors[0].message, code);
69+
}
70+
}
6171
throw new Error('Failed to create EOL report');
6272
}
6373

@@ -97,6 +107,12 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
97107
const queryErrors = (response as GraphQLExecutionResult | undefined)?.errors;
98108
if (response?.error || queryErrors?.length || !response.data?.eol) {
99109
debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response);
110+
if (queryErrors?.length) {
111+
const code = extractErrorCode(queryErrors);
112+
if (code) {
113+
throw new ApiError(queryErrors[0].message, code);
114+
}
115+
}
100116
throw new Error('Failed to fetch EOL report');
101117
}
102118

src/commands/scan/eol.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared';
22
import { trimCdxBom } from '@herodevs/eol-shared';
33
import { Command, Flags } from '@oclif/core';
44
import ora from 'ora';
5+
import { ApiError } from '../../api/errors.ts';
56
import { submitScan } from '../../api/nes.client.ts';
67
import { config, filenamePrefix } from '../../config/constants.ts';
78
import { track } from '../../service/analytics.svc.ts';
9+
import { requireAccessTokenForScan } from '../../service/auth.svc.ts';
810
import { createSbom } from '../../service/cdx.svc.ts';
911
import {
1012
countComponentsByStatus,
@@ -82,6 +84,10 @@ export default class ScanEol extends Command {
8284
public async run(): Promise<EolReport | undefined> {
8385
const { flags } = await this.parse(ScanEol);
8486

87+
if (config.enableAuth) {
88+
await requireAccessTokenForScan();
89+
}
90+
8591
track('CLI EOL Scan Started', (context) => ({
8692
command: context.command,
8793
command_flags: context.command_flags,
@@ -209,6 +215,23 @@ export default class ScanEol extends Command {
209215
return scan;
210216
} catch (error) {
211217
spinner.fail('Scanning failed');
218+
219+
if (error instanceof ApiError) {
220+
track('CLI EOL Scan Failed', (context) => ({
221+
command: context.command,
222+
command_flags: context.command_flags,
223+
scan_failure_reason: error.code,
224+
}));
225+
226+
const errorMessages: Record<string, string> = {
227+
SESSION_EXPIRED: 'Your session is no longer valid. To re-authenticate, run "hd auth login".',
228+
INVALID_TOKEN: 'Your session is no longer valid. To re-authenticate, run "hd auth login".',
229+
UNAUTHENTICATED: 'Please log in to perform a scan. To authenticate, run "hd auth login".',
230+
FORBIDDEN: 'You do not have permission to perform this action.',
231+
};
232+
this.error(errorMessages[error.code]);
233+
}
234+
212235
const errorMessage = getErrorMessage(error);
213236
track('CLI EOL Scan Failed', (context) => ({
214237
command: context.command,

src/service/auth.svc.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
11
import type { TokenResponse } from '../types/auth.ts';
22
import { refreshTokens } from './auth-refresh.svc.ts';
33
import { clearStoredTokens, getStoredTokens, isAccessTokenExpired, saveTokens } from './auth-token.svc.ts';
4+
import { debugLogger } from './log.svc.ts';
5+
6+
export type AuthErrorCode = 'NOT_LOGGED_IN' | 'SESSION_EXPIRED';
7+
8+
export class AuthError extends Error {
9+
readonly code: AuthErrorCode;
10+
11+
constructor(message: string, code: AuthErrorCode) {
12+
super(message);
13+
this.name = 'AuthError';
14+
this.code = code;
15+
}
16+
}
417

518
export async function persistTokenResponse(token: TokenResponse) {
619
await saveTokens({
@@ -40,3 +53,28 @@ export async function requireAccessToken(): Promise<string> {
4053
export async function logoutLocally() {
4154
await clearStoredTokens();
4255
}
56+
57+
export async function requireAccessTokenForScan(): Promise<string> {
58+
const tokens = await getStoredTokens();
59+
60+
if (!tokens?.accessToken) {
61+
throw new AuthError('Please log in to perform a scan. To authenticate, run "hd auth login".', 'NOT_LOGGED_IN');
62+
}
63+
64+
if (!isAccessTokenExpired(tokens.accessToken)) {
65+
return tokens.accessToken;
66+
}
67+
68+
if (tokens.refreshToken) {
69+
try {
70+
const newTokens = await refreshTokens(tokens.refreshToken);
71+
await persistTokenResponse(newTokens);
72+
return newTokens.access_token;
73+
} catch (error) {
74+
// Refresh failed - fall through to session expired error
75+
debugLogger('Token refresh failed: %O', error);
76+
}
77+
}
78+
79+
throw new AuthError('Your session is no longer valid. To re-authenticate, run "hd auth login".', 'SESSION_EXPIRED');
80+
}

test/service/auth.svc.test.ts

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,14 @@ vi.mock('../../src/service/auth-refresh.svc.ts', () => ({
1313
refreshTokens: vi.fn(),
1414
}));
1515

16-
import { getAccessToken, logoutLocally, persistTokenResponse, requireAccessToken } from '../../src/service/auth.svc.ts';
16+
import {
17+
AuthError,
18+
getAccessToken,
19+
logoutLocally,
20+
persistTokenResponse,
21+
requireAccessToken,
22+
requireAccessTokenForScan,
23+
} from '../../src/service/auth.svc.ts';
1724
import { refreshTokens } from '../../src/service/auth-refresh.svc.ts';
1825
import {
1926
clearStoredTokens,
@@ -77,4 +84,67 @@ describe('auth.svc', () => {
7784
await logoutLocally();
7885
expect(clearStoredTokens).toHaveBeenCalled();
7986
});
87+
88+
describe('requireAccessTokenForScan', () => {
89+
it('returns token when access token is valid', async () => {
90+
(getStoredTokens as Mock).mockResolvedValue({ accessToken: 'valid-token' });
91+
(isAccessTokenExpired as Mock).mockReturnValue(false);
92+
93+
const token = await requireAccessTokenForScan();
94+
expect(token).toBe('valid-token');
95+
expect(refreshTokens).not.toHaveBeenCalled();
96+
});
97+
98+
it('auto-refreshes when access token expired with valid refresh token', async () => {
99+
(getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired', refreshToken: 'refresh-1' });
100+
(isAccessTokenExpired as Mock).mockReturnValue(true);
101+
(refreshTokens as Mock).mockResolvedValue({ access_token: 'new-token', refresh_token: 'refresh-2' });
102+
103+
const token = await requireAccessTokenForScan();
104+
expect(token).toBe('new-token');
105+
expect(refreshTokens).toHaveBeenCalledWith('refresh-1');
106+
expect(saveTokens).toHaveBeenCalledWith({ accessToken: 'new-token', refreshToken: 'refresh-2' });
107+
});
108+
109+
it('throws AuthError with NOT_LOGGED_IN when no tokens exist', async () => {
110+
(getStoredTokens as Mock).mockResolvedValue(undefined);
111+
112+
await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError);
113+
await expect(requireAccessTokenForScan()).rejects.toMatchObject({
114+
code: 'NOT_LOGGED_IN',
115+
message: 'Please log in to perform a scan. To authenticate, run "hd auth login".',
116+
});
117+
});
118+
119+
it('throws AuthError with NOT_LOGGED_IN when access token is missing', async () => {
120+
(getStoredTokens as Mock).mockResolvedValue({ refreshToken: 'refresh-only' });
121+
122+
await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError);
123+
await expect(requireAccessTokenForScan()).rejects.toMatchObject({
124+
code: 'NOT_LOGGED_IN',
125+
});
126+
});
127+
128+
it('throws AuthError with SESSION_EXPIRED when refresh fails', async () => {
129+
(getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired', refreshToken: 'invalid-refresh' });
130+
(isAccessTokenExpired as Mock).mockReturnValue(true);
131+
(refreshTokens as Mock).mockRejectedValue(new Error('refresh failed'));
132+
133+
await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError);
134+
await expect(requireAccessTokenForScan()).rejects.toMatchObject({
135+
code: 'SESSION_EXPIRED',
136+
message: 'Your session is no longer valid. To re-authenticate, run "hd auth login".',
137+
});
138+
});
139+
140+
it('throws AuthError with SESSION_EXPIRED when access token expired and no refresh token', async () => {
141+
(getStoredTokens as Mock).mockResolvedValue({ accessToken: 'expired' });
142+
(isAccessTokenExpired as Mock).mockReturnValue(true);
143+
144+
await expect(requireAccessTokenForScan()).rejects.toThrow(AuthError);
145+
await expect(requireAccessTokenForScan()).rejects.toMatchObject({
146+
code: 'SESSION_EXPIRED',
147+
});
148+
});
149+
});
80150
});

0 commit comments

Comments
 (0)