Skip to content
1 change: 1 addition & 0 deletions .envrc.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ 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 EOL_LOG_IN_URL='http://eol-report-card.apps.herodevs.com/eol/api/auth/cli-log-in';

# OAuth (for hd auth login)
export OAUTH_CONNECT_URL='';
Expand Down
27 changes: 19 additions & 8 deletions src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { createInterface } from 'node:readline';
import { URL } from 'node:url';
import { Command } from '@oclif/core';
import { ensureUserSetup } from '../../api/user-setup.client.ts';
import { OAUTH_CALLBACK_ERROR_CODES } from '../../config/constants.ts';
import { EOL_LOG_IN_URL, OAUTH_CALLBACK_ERROR_CODES } from '../../config/constants.ts';
import { refreshIdentityFromStoredToken } from '../../service/analytics.svc.ts';
import { persistTokenResponse } from '../../service/auth.svc.ts';
import { getClientId, getRealmUrl } from '../../service/auth-config.svc.ts';
Expand Down Expand Up @@ -40,8 +40,7 @@ export default class AuthLogin extends Command {
`&code_challenge_method=S256` +
`&state=${state}`;

const code = await this.startServerAndAwaitCode(authUrl, state);
const token = await this.exchangeCodeForToken(code, codeVerifier);
const token = await this.startServerAndAwaitToken(authUrl, state, codeVerifier);

try {
await persistTokenResponse(token);
Expand All @@ -65,7 +64,11 @@ export default class AuthLogin extends Command {
this.log('\nLogin completed successfully.');
}

private startServerAndAwaitCode(authUrl: string, expectedState: string): Promise<string> {
private startServerAndAwaitToken(
authUrl: string,
expectedState: string,
codeVerifier: string,
): Promise<TokenResponse> {
return new Promise((resolve, reject) => {
this.server = http.createServer((req, res) => {
if (!req.url) {
Expand Down Expand Up @@ -138,10 +141,18 @@ export default class AuthLogin extends Command {
}

if (code) {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Login successful. You can close this window.');
this.stopServer();
resolve(code);
this.exchangeCodeForToken(code, codeVerifier)
.then(async (token) => {
res.writeHead(302, {
Location: `${EOL_LOG_IN_URL}`,
});
res.end();
resolve(token);
})
.catch((error) => reject(error))
.finally(() => {
this.stopServer();
});
} else {
res.writeHead(400, { 'Content-Type': 'text/plain' });
res.end('No authorization code returned. Please try again.');
Expand Down
9 changes: 5 additions & 4 deletions src/config/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
export const EOL_REPORT_URL = 'https://apps.herodevs.com/eol/reports';
export const GRAPHQL_HOST = 'https://gateway.prod.apps.herodevs.io';
export const GRAPHQL_PATH = '/graphql';
export const ANALYTICS_URL = 'https://apps.herodevs.com/api/eol/track';
export const EOL_REPORT_URL = process.env.EOL_REPORT_URL || 'https://apps.herodevs.com/eol/reports';
export const GRAPHQL_HOST = process.env.GRAPHQL_HOST || 'https://gateway.prod.apps.herodevs.io';
export const GRAPHQL_PATH = process.env.GRAPHQL_PATH || '/graphql';
export const ANALYTICS_URL = process.env.ANALYTICS_URL || 'https://apps.herodevs.com/api/eol/track';
export const EOL_LOG_IN_URL = process.env.EOL_LOG_IN_URL || 'https://apps.herodevs.com/eol/api/auth/cli-log-in';
export const CONCURRENT_PAGE_REQUESTS = 3;
export const PAGE_SIZE = 500;
export const GIT_OUTPUT_FORMAT = `"${['%h', '%an', '%ad'].join('|')}"`;
Expand Down
124 changes: 78 additions & 46 deletions test/commands/auth/login.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ensureUserSetup } from '../../../src/api/user-setup.client.ts';
import AuthLogin from '../../../src/commands/auth/login.ts';
import { refreshIdentityFromStoredToken } from '../../../src/service/analytics.svc.ts';
import { persistTokenResponse } from '../../../src/service/auth.svc.ts';
import type { TokenResponse } from '../../../src/types/auth.js';
import { openInBrowser } from '../../../src/utils/open-in-browser.ts';

type ServerRequest = { url?: string };
Expand Down Expand Up @@ -167,22 +168,30 @@ describe('AuthLogin', () => {
persistTokenResponseMock.mockClear();
});

describe('startServerAndAwaitCode', () => {
describe('startServerAndAwaitToken', () => {
const authUrl = 'https://login.example/auth';
const basePort = 4900;

it('resolves with the authorization code when the callback is valid', async () => {
it('resolves with the token response when the callback is valid', async () => {
const command = createCommand(basePort);
const state = 'expected-state';
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, state);
const codeVerifier = 'verifier-123';

const commandWithInternals = command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<TokenResponse>;
exchangeCodeForToken: (...args: unknown[]) => Promise<TokenResponse>;
};
const tokenResponse = { access_token: 'access', refresh_token: 'refresh' };
vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse);

const pendingCode = commandWithInternals.startServerAndAwaitToken(authUrl, state, codeVerifier);

const server = getLatestServer();

await flushAsync();
sendCallbackThroughStub({ code: 'test-code', state });

await expect(pendingCode).resolves.toBe('test-code');
await expect(pendingCode).resolves.toBe(tokenResponse);
expect(questionMock).toHaveBeenCalledWith(expect.stringContaining(authUrl), expect.any(Function));
expect(closeMock).toHaveBeenCalledTimes(1);
expect(openMock).toHaveBeenCalledWith(authUrl);
Expand All @@ -192,8 +201,10 @@ describe('AuthLogin', () => {
it('rejects when the callback is missing the state parameter', async () => {
const command = createCommand(basePort + 1);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -206,8 +217,10 @@ describe('AuthLogin', () => {
it('rejects when the callback state does not match', async () => {
const command = createCommand(basePort + 2);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -220,8 +233,10 @@ describe('AuthLogin', () => {
it('rejects with guidance when callback returns already_logged_in', async () => {
const command = createCommand(basePort + 3);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -238,8 +253,10 @@ describe('AuthLogin', () => {
it('rejects when callback returns a generic OAuth error', async () => {
const command = createCommand(basePort + 4);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -258,8 +275,10 @@ describe('AuthLogin', () => {
it('rejects with guidance when callback returns different_user_authenticated', async () => {
const command = createCommand(basePort + 5);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -280,8 +299,10 @@ describe('AuthLogin', () => {
it('rejects when the callback omits the authorization code', async () => {
const command = createCommand(basePort + 6);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -294,8 +315,10 @@ describe('AuthLogin', () => {
it('rejects when the callback URL is invalid', async () => {
const command = createCommand(basePort + 7);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -310,8 +333,10 @@ describe('AuthLogin', () => {
it('returns a 400 response when the incoming request is missing a URL', async () => {
const command = createCommand(basePort + 8);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -329,8 +354,10 @@ describe('AuthLogin', () => {
it('responds with not found for unrelated paths', async () => {
const command = createCommand(basePort + 9);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -348,8 +375,10 @@ describe('AuthLogin', () => {
it('rejects when the local HTTP server emits an error', async () => {
const command = createCommand(basePort + 10);
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, 'expected-state');
command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<string>;
}
).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');
const server = getLatestServer();

await flushAsync();
Expand All @@ -369,17 +398,23 @@ describe('AuthLogin', () => {
const state = 'expected-state';

try {
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, state);
const commandWithInternals = command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<TokenResponse>;
exchangeCodeForToken: (...args: unknown[]) => Promise<TokenResponse>;
};
const tokenResponse = { access_token: 'access', refresh_token: 'refresh' };
vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse);

const pendingCode = commandWithInternals.startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');

const server = getLatestServer();

await flushAsync();

expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Failed to open browser automatically'));

sendCallbackThroughStub({ code: 'manual-code', state });
await expect(pendingCode).resolves.toBe('manual-code');
await expect(pendingCode).resolves.toBe(tokenResponse);
expect(server.close).toHaveBeenCalledTimes(1);
} finally {
warnSpy.mockRestore();
Expand All @@ -389,9 +424,12 @@ describe('AuthLogin', () => {
it('deduplicates shutdown when callback success and server error race', async () => {
const command = createCommand(basePort + 12);
const state = 'expected-state';
const pendingCode = (
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
).startServerAndAwaitCode(authUrl, state);

const commandWithInternals = command as unknown as {
startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise<TokenResponse>;
};
const pendingCode = commandWithInternals.startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier');

const server = getLatestServer();
const warnSpy = vi
.spyOn(command as unknown as { warn: (...args: unknown[]) => unknown }, 'warn')
Expand All @@ -402,7 +440,7 @@ describe('AuthLogin', () => {
sendCallbackThroughStub({ code: 'race-code', state });
server.emitError(new Error('late listener error'));

await expect(pendingCode).resolves.toBe('race-code');
await expect(pendingCode).rejects.toThrow('late listener error');
expect(server.close).toHaveBeenCalledTimes(1);
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Failed to stop local OAuth callback server'));
} finally {
Expand Down Expand Up @@ -470,11 +508,9 @@ describe('AuthLogin', () => {
const command = createCommand(6000);
const tokenResponse = { access_token: 'access', refresh_token: 'refresh' };
const commandWithInternals = command as unknown as {
startServerAndAwaitCode: (...args: unknown[]) => Promise<string>;
exchangeCodeForToken: (...args: unknown[]) => Promise<unknown>;
startServerAndAwaitToken: (...args: unknown[]) => Promise<unknown>;
};
vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123');
vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse);
vi.spyOn(commandWithInternals, 'startServerAndAwaitToken').mockResolvedValue(tokenResponse);

await command.run();

Expand All @@ -488,11 +524,9 @@ describe('AuthLogin', () => {
const command = createCommand(6001);
const tokenResponse = { access_token: 'access', refresh_token: 'refresh' };
const commandWithInternals = command as unknown as {
startServerAndAwaitCode: (...args: unknown[]) => Promise<string>;
exchangeCodeForToken: (...args: unknown[]) => Promise<unknown>;
startServerAndAwaitToken: (...args: unknown[]) => Promise<unknown>;
};
vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123');
vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse);
vi.spyOn(commandWithInternals, 'startServerAndAwaitToken').mockResolvedValue(tokenResponse);

await command.run();

Expand All @@ -506,11 +540,9 @@ describe('AuthLogin', () => {
const command = createCommand(6002);
const tokenResponse = { access_token: 'access', refresh_token: 'refresh' };
const commandWithInternals = command as unknown as {
startServerAndAwaitCode: (...args: unknown[]) => Promise<string>;
exchangeCodeForToken: (...args: unknown[]) => Promise<unknown>;
startServerAndAwaitToken: (...args: unknown[]) => Promise<unknown>;
};
vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123');
vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse);
vi.spyOn(commandWithInternals, 'startServerAndAwaitToken').mockResolvedValue(tokenResponse);

await expect(command.run()).rejects.toThrow('User setup failed');
expect(refreshIdentityFromStoredTokenMock).not.toHaveBeenCalled();
Expand Down
Loading