Skip to content

Commit 46b1417

Browse files
feat: add auth error handling
1 parent 0d06be2 commit 46b1417

File tree

6 files changed

+201
-9
lines changed

6 files changed

+201
-9
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';

src/api/errors.ts

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

src/api/nes.client.ts

Lines changed: 26 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,15 @@ type GraphQLExecutionResult = {
2929
errors?: ReadonlyArray<GraphQLFormattedError>;
3030
};
3131

32+
function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErrorCode | undefined {
33+
const firstError = errors[0];
34+
const code = (firstError?.extensions as { code?: string })?.code;
35+
if (code && isApiErrorCode(code)) {
36+
return code;
37+
}
38+
return undefined;
39+
}
40+
3241
export const createApollo = (uri: string) =>
3342
new ApolloClient({
3443
cache: new InMemoryCache(),
@@ -54,10 +63,14 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
5463
});
5564

5665
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-
);
66+
const errors = (res as GraphQLExecutionResult | undefined)?.errors;
67+
debugLogger('Error returned from createReport mutation: %o', res.error || errors);
68+
if (errors?.length) {
69+
const code = extractErrorCode(errors);
70+
if (code) {
71+
throw new ApiError(errors[0].message, code);
72+
}
73+
}
6174
throw new Error('Failed to create EOL report');
6275
}
6376

@@ -97,6 +110,12 @@ export const SbomScanner = (client: ReturnType<typeof createApollo>) => {
97110
const queryErrors = (response as GraphQLExecutionResult | undefined)?.errors;
98111
if (response?.error || queryErrors?.length || !response.data?.eol) {
99112
debugLogger('Error in getReport query response: %o', response?.error ?? queryErrors ?? response);
113+
if (queryErrors?.length) {
114+
const code = extractErrorCode(queryErrors);
115+
if (code) {
116+
throw new ApiError(queryErrors[0].message, code);
117+
}
118+
}
100119
throw new Error('Failed to fetch EOL report');
101120
}
102121

src/commands/scan/eol.ts

Lines changed: 34 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,34 @@ export default class ScanEol extends Command {
209215
return scan;
210216
} catch (error) {
211217
spinner.fail('Scanning failed');
218+
219+
if (error instanceof ApiError) {
220+
if (error.code === 'SESSION_EXPIRED' || error.code === 'INVALID_TOKEN') {
221+
track('CLI EOL Scan Failed', (context) => ({
222+
command: context.command,
223+
command_flags: context.command_flags,
224+
scan_failure_reason: error.code,
225+
}));
226+
this.error('Your session is no longer valid. To re-authenticate, run "hd auth login".');
227+
}
228+
if (error.code === 'UNAUTHENTICATED') {
229+
track('CLI EOL Scan Failed', (context) => ({
230+
command: context.command,
231+
command_flags: context.command_flags,
232+
scan_failure_reason: error.code,
233+
}));
234+
this.error('Please log in to perform a scan. To authenticate, run "hd auth login".');
235+
}
236+
if (error.code === 'FORBIDDEN') {
237+
track('CLI EOL Scan Failed', (context) => ({
238+
command: context.command,
239+
command_flags: context.command_flags,
240+
scan_failure_reason: error.code,
241+
}));
242+
this.error('You do not have permission to perform this action.');
243+
}
244+
}
245+
212246
const errorMessage = getErrorMessage(error);
213247
track('CLI EOL Scan Failed', (context) => ({
214248
command: context.command,

src/service/auth.svc.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ 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';
44

5+
export type AuthErrorCode = 'NOT_LOGGED_IN' | 'SESSION_EXPIRED';
6+
7+
export class AuthError extends Error {
8+
readonly code: AuthErrorCode;
9+
10+
constructor(message: string, code: AuthErrorCode) {
11+
super(message);
12+
this.name = 'AuthError';
13+
this.code = code;
14+
}
15+
}
16+
517
export async function persistTokenResponse(token: TokenResponse) {
618
await saveTokens({
719
accessToken: token.access_token,
@@ -40,3 +52,27 @@ export async function requireAccessToken(): Promise<string> {
4052
export async function logoutLocally() {
4153
await clearStoredTokens();
4254
}
55+
56+
export async function requireAccessTokenForScan(): Promise<string> {
57+
const tokens = await getStoredTokens();
58+
59+
if (!tokens?.accessToken) {
60+
throw new AuthError('Please log in to perform a scan. To authenticate, run "hd auth login".', 'NOT_LOGGED_IN');
61+
}
62+
63+
if (!isAccessTokenExpired(tokens.accessToken)) {
64+
return tokens.accessToken;
65+
}
66+
67+
if (tokens.refreshToken) {
68+
try {
69+
const newTokens = await refreshTokens(tokens.refreshToken);
70+
await persistTokenResponse(newTokens);
71+
return newTokens.access_token;
72+
} catch {
73+
// Refresh failed - fall through to session expired error
74+
}
75+
}
76+
77+
throw new AuthError('Your session is no longer valid. To re-authenticate, run "hd auth login".', 'SESSION_EXPIRED');
78+
}

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)