Skip to content

Commit 0aaf1ce

Browse files
authored
feat: auth token priority and ux (#483)
1 parent 5525a33 commit 0aaf1ce

7 files changed

Lines changed: 177 additions & 69 deletions

File tree

e2e/setup/mock-auth-hooks.mjs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,19 @@ export async function load(url, context, nextLoad) {
1515
this.code = code;
1616
}
1717
}
18+
export const AUTH_ERROR_MESSAGES = {
19+
UNAUTHENTICATED: 'Please log in to perform this action. To authenticate, please run an "auth login" command.',
20+
SESSION_EXPIRED: 'Your session has expired. To re-authenticate, please run an "auth login" command.',
21+
INVALID_TOKEN: 'Your session has expired. To re-authenticate, please run an "auth login" command.',
22+
FORBIDDEN: 'You do not have permission to perform this action.',
23+
NOT_LOGGED_IN_GENERIC: 'You are not logged in. Please run an "auth login" command to authenticate.',
24+
};
1825
export function persistTokenResponse() { return Promise.resolve(); }
1926
export function getAccessToken() { return Promise.resolve('test-token'); }
2027
export function requireAccessToken() { return Promise.resolve('test-token'); }
2128
export function logoutLocally() { return Promise.resolve(); }
29+
export function getTokenForScanWithSource() { return Promise.resolve({ token: 'test-token', source: 'oauth' }); }
30+
export function getTokenProvider() { return () => Promise.resolve('test-token'); }
2231
export function requireAccessTokenForScan() { return Promise.resolve('test-token'); }
2332
`,
2433
};

src/api/user-setup.client.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import type { GraphQLFormattedError } from 'graphql';
22
import { config } from '../config/constants.ts';
3-
import { requireAccessToken, requireAccessTokenForScan } from '../service/auth.svc.ts';
4-
import { getCIToken } from '../service/ci-token.svc.ts';
3+
import { getTokenProvider } from '../service/auth.svc.ts';
54
import { debugLogger } from '../service/log.svc.ts';
65
import { withRetries } from '../utils/retry.ts';
76
import { createApollo } from './apollo.client.ts';
@@ -36,12 +35,6 @@ function extractErrorCode(errors: ReadonlyArray<GraphQLFormattedError>): ApiErro
3635
return code;
3736
}
3837

39-
function getTokenProvider(preferOAuth?: boolean) {
40-
if (preferOAuth) return requireAccessToken;
41-
if (getCIToken()) return requireAccessTokenForScan;
42-
return requireAccessToken;
43-
}
44-
4538
export async function getUserSetupStatus(options?: { preferOAuth?: boolean }): Promise<{
4639
isComplete: boolean;
4740
orgId?: number | null;
@@ -81,7 +74,7 @@ export async function completeUserSetup(options?: { preferOAuth?: boolean }): Pr
8174
isComplete: boolean;
8275
orgId?: number | null;
8376
}> {
84-
const tokenProvider = options?.preferOAuth ? requireAccessToken : requireAccessTokenForScan;
77+
const tokenProvider = getTokenProvider(options?.preferOAuth);
8578
const client = createApollo(getGraphqlUrl(), tokenProvider);
8679
const res = await client.mutate<CompleteUserSetupResponse>({ mutation: completeUserSetupMutation });
8780

src/commands/scan/eol.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { ApiError } from '../../api/errors.ts';
66
import { submitScan } from '../../api/nes.client.ts';
77
import { config, filenamePrefix, SCAN_ORIGIN_AUTOMATED, SCAN_ORIGIN_CLI } from '../../config/constants.ts';
88
import { track } from '../../service/analytics.svc.ts';
9-
import { requireAccessTokenForScan } from '../../service/auth.svc.ts';
9+
import { AUTH_ERROR_MESSAGES, getTokenForScanWithSource } from '../../service/auth.svc.ts';
1010
import { createSbom } from '../../service/cdx.svc.ts';
1111
import {
1212
countComponentsByStatus,
@@ -88,7 +88,11 @@ export default class ScanEol extends Command {
8888
public async run(): Promise<EolReport | undefined> {
8989
const { flags } = await this.parse(ScanEol);
9090

91-
await requireAccessTokenForScan();
91+
const { source } = await getTokenForScanWithSource();
92+
if (source === 'ci') {
93+
this.log('CI credentials found');
94+
this.log('Using CI credentials');
95+
}
9296

9397
track('CLI EOL Scan Started', (context) => ({
9498
command: context.command,
@@ -230,13 +234,7 @@ export default class ScanEol extends Command {
230234
number_of_packages: numberOfPackages,
231235
}));
232236

233-
const errorMessages: Record<string, string> = {
234-
SESSION_EXPIRED: 'Your session is no longer valid. To re-authenticate, please run an "auth login" command.',
235-
INVALID_TOKEN: 'Your session is no longer valid. To re-authenticate, please run an "auth login" command.',
236-
UNAUTHENTICATED: 'Please log in to perform a scan. To authenticate, please run an "auth login" command.',
237-
FORBIDDEN: 'You do not have permission to perform this action.',
238-
};
239-
const message = errorMessages[error.code] ?? error.message?.trim();
237+
const message = AUTH_ERROR_MESSAGES[error.code] ?? error.message?.trim();
240238
this.error(message);
241239
}
242240

src/service/auth.svc.ts

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,54 @@ export { CITokenError } from './ci-auth.svc.ts';
1010

1111
export type AuthErrorCode = 'NOT_LOGGED_IN' | 'SESSION_EXPIRED';
1212

13+
export type TokenSource = 'oauth' | 'ci';
14+
15+
export type TokenProvider = (forceRefresh?: boolean) => Promise<string>;
16+
17+
export const AUTH_ERROR_MESSAGES = {
18+
UNAUTHENTICATED: 'Please log in to perform this action. To authenticate, please run an "auth login" command.',
19+
SESSION_EXPIRED: 'Your session has expired. To re-authenticate, please run an "auth login" command.',
20+
INVALID_TOKEN: 'Your session has expired. To re-authenticate, please run an "auth login" command.',
21+
FORBIDDEN: 'You do not have permission to perform this action.',
22+
NOT_LOGGED_IN_GENERIC: 'You are not logged in. Please run an "auth login" command to authenticate.',
23+
} as const;
24+
25+
export async function getTokenForScanWithSource(
26+
preferOAuth?: boolean,
27+
): Promise<{ token: string; source: TokenSource }> {
28+
if (preferOAuth) {
29+
const token = await requireAccessToken();
30+
return { token, source: 'oauth' };
31+
}
32+
33+
const tokens = await getStoredTokens();
34+
if (tokens?.accessToken && !isAccessTokenExpired(tokens.accessToken)) {
35+
return { token: tokens.accessToken, source: 'oauth' };
36+
}
37+
38+
if (tokens?.refreshToken) {
39+
try {
40+
const newTokens = await refreshTokens(tokens.refreshToken);
41+
await persistTokenResponse(newTokens);
42+
return { token: newTokens.access_token, source: 'oauth' };
43+
} catch (error) {
44+
debugLogger('Token refresh failed: %O', error);
45+
}
46+
}
47+
48+
const ciToken = getCIToken();
49+
if (ciToken) {
50+
const accessToken = await requireCIAccessToken();
51+
return { token: accessToken, source: 'ci' };
52+
}
53+
54+
if (!tokens?.accessToken) {
55+
throw new AuthError(AUTH_ERROR_MESSAGES.UNAUTHENTICATED, 'NOT_LOGGED_IN');
56+
}
57+
58+
throw new AuthError(AUTH_ERROR_MESSAGES.SESSION_EXPIRED, 'SESSION_EXPIRED');
59+
}
60+
1361
export class AuthError extends Error {
1462
readonly code: AuthErrorCode;
1563

@@ -46,10 +94,17 @@ export async function getAccessToken(): Promise<string | undefined> {
4694
return refreshed.access_token;
4795
}
4896

97+
export function getTokenProvider(preferOAuth?: boolean): TokenProvider {
98+
return async (_forceRefresh?: boolean): Promise<string> => {
99+
const { token } = await getTokenForScanWithSource(preferOAuth);
100+
return token;
101+
};
102+
}
103+
49104
export async function requireAccessToken(): Promise<string> {
50105
const token = await getAccessToken();
51106
if (!token) {
52-
throw new Error('You are not logged in. Please run an "auth login" command to authenticate.');
107+
throw new Error(AUTH_ERROR_MESSAGES.NOT_LOGGED_IN_GENERIC);
53108
}
54109

55110
return token;
@@ -59,36 +114,4 @@ export async function logoutLocally() {
59114
await clearStoredTokens();
60115
}
61116

62-
export async function requireAccessTokenForScan(): Promise<string> {
63-
if (getCIToken()) {
64-
return requireCIAccessToken();
65-
}
66-
67-
const tokens = await getStoredTokens();
68-
69-
if (!tokens?.accessToken) {
70-
throw new AuthError(
71-
'Please log in to perform a scan. To authenticate, please run an "auth login" command.',
72-
'NOT_LOGGED_IN',
73-
);
74-
}
75-
76-
if (!isAccessTokenExpired(tokens.accessToken)) {
77-
return tokens.accessToken;
78-
}
79-
80-
if (tokens.refreshToken) {
81-
try {
82-
const newTokens = await refreshTokens(tokens.refreshToken);
83-
await persistTokenResponse(newTokens);
84-
return newTokens.access_token;
85-
} catch (error) {
86-
debugLogger('Token refresh failed: %O', error);
87-
}
88-
}
89-
90-
throw new AuthError(
91-
'Your session is no longer valid. To re-authenticate, please run an "auth login" command.',
92-
'SESSION_EXPIRED',
93-
);
94-
}
117+
export const requireAccessTokenForScan = getTokenProvider();

test/api/user-setup.client.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,7 @@ import { completeUserSetup, ensureUserSetup, getUserSetupStatus } from '../../sr
44
import { FetchMock } from '../utils/mocks/fetch.mock.ts';
55

66
vi.mock('../../src/service/auth.svc.ts', () => ({
7-
requireAccessTokenForScan: vi.fn().mockResolvedValue('test-token'),
8-
requireAccessToken: vi.fn().mockResolvedValue('test-token'),
7+
getTokenProvider: vi.fn(() => vi.fn().mockResolvedValue('test-token')),
98
}));
109

1110
describe('user-setup.client', () => {

test/commands/scan/eol.analytics.test.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,15 @@ import type { CdxBom, EolReport } from '@herodevs/eol-shared';
22
import { ApiError } from '../../../src/api/errors.ts';
33
import ScanEol from '../../../src/commands/scan/eol.ts';
44

5-
const { trackMock, requireAccessTokenForScanMock, submitScanMock, countComponentsByStatusMock } = vi.hoisted(() => ({
5+
const {
6+
trackMock,
7+
getTokenForScanWithSourceMock,
8+
requireAccessTokenForScanMock,
9+
submitScanMock,
10+
countComponentsByStatusMock,
11+
} = vi.hoisted(() => ({
612
trackMock: vi.fn(),
13+
getTokenForScanWithSourceMock: vi.fn(),
714
requireAccessTokenForScanMock: vi.fn(),
815
submitScanMock: vi.fn(),
916
countComponentsByStatusMock: vi.fn(),
@@ -17,9 +24,14 @@ vi.mock('../../../src/service/analytics.svc.ts', () => ({
1724
track: trackMock,
1825
}));
1926

20-
vi.mock('../../../src/service/auth.svc.ts', () => ({
21-
requireAccessTokenForScan: requireAccessTokenForScanMock,
22-
}));
27+
vi.mock('../../../src/service/auth.svc.ts', async (importOriginal) => {
28+
const actual = await importOriginal<typeof import('../../../src/service/auth.svc.ts')>();
29+
return {
30+
...actual,
31+
getTokenForScanWithSource: getTokenForScanWithSourceMock,
32+
requireAccessTokenForScan: requireAccessTokenForScanMock,
33+
};
34+
});
2335

2436
vi.mock('../../../src/api/nes.client.ts', () => ({
2537
submitScan: submitScanMock,
@@ -104,7 +116,8 @@ describe('scan:eol analytics timing', () => {
104116

105117
beforeEach(() => {
106118
vi.clearAllMocks();
107-
requireAccessTokenForScanMock.mockResolvedValue(undefined);
119+
getTokenForScanWithSourceMock.mockResolvedValue({ token: 'test-token', source: 'oauth' });
120+
requireAccessTokenForScanMock.mockResolvedValue('test-token');
108121
countComponentsByStatusMock.mockReturnValue({
109122
EOL: 1,
110123
EOL_UPCOMING: 0,

0 commit comments

Comments
 (0)