diff --git a/.envrc.example b/.envrc.example index 1c139b46..6d8c4af2 100644 --- a/.envrc.example +++ b/.envrc.example @@ -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='https://apps.herodevs.com/eol/api/auth/cli-log-in'; # OAuth (for hd auth login) export OAUTH_CONNECT_URL=''; diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 5094b2f7..4d1b053a 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -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'; @@ -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); @@ -65,7 +64,11 @@ export default class AuthLogin extends Command { this.log('\nLogin completed successfully.'); } - private startServerAndAwaitCode(authUrl: string, expectedState: string): Promise { + private startServerAndAwaitToken( + authUrl: string, + expectedState: string, + codeVerifier: string, + ): Promise { return new Promise((resolve, reject) => { this.server = http.createServer((req, res) => { if (!req.url) { @@ -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((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.'); diff --git a/src/config/constants.ts b/src/config/constants.ts index 2af76b81..d5941a98 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -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('|')}"`; diff --git a/test/commands/auth/login.test.ts b/test/commands/auth/login.test.ts index 796ec813..b5082e2b 100644 --- a/test/commands/auth/login.test.ts +++ b/test/commands/auth/login.test.ts @@ -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 }; @@ -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 } - ).startServerAndAwaitCode(authUrl, state); + const codeVerifier = 'verifier-123'; + + const commandWithInternals = command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + exchangeCodeForToken: (...args: unknown[]) => Promise; + }; + 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); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -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 } - ).startServerAndAwaitCode(authUrl, 'expected-state'); + command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + } + ).startServerAndAwaitToken(authUrl, 'expected-state', 'code-verifier'); const server = getLatestServer(); await flushAsync(); @@ -369,9 +398,15 @@ describe('AuthLogin', () => { const state = 'expected-state'; try { - const pendingCode = ( - command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise } - ).startServerAndAwaitCode(authUrl, state); + const commandWithInternals = command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + exchangeCodeForToken: (...args: unknown[]) => Promise; + }; + 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(); @@ -379,7 +414,7 @@ describe('AuthLogin', () => { 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(); @@ -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 } - ).startServerAndAwaitCode(authUrl, state); + + const commandWithInternals = command as unknown as { + startServerAndAwaitToken: (url: string, state: string, codeVerifier: string) => Promise; + }; + 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') @@ -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 { @@ -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; - exchangeCodeForToken: (...args: unknown[]) => Promise; + startServerAndAwaitToken: (...args: unknown[]) => Promise; }; - vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123'); - vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse); + vi.spyOn(commandWithInternals, 'startServerAndAwaitToken').mockResolvedValue(tokenResponse); await command.run(); @@ -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; - exchangeCodeForToken: (...args: unknown[]) => Promise; + startServerAndAwaitToken: (...args: unknown[]) => Promise; }; - vi.spyOn(commandWithInternals, 'startServerAndAwaitCode').mockResolvedValue('code-123'); - vi.spyOn(commandWithInternals, 'exchangeCodeForToken').mockResolvedValue(tokenResponse); + vi.spyOn(commandWithInternals, 'startServerAndAwaitToken').mockResolvedValue(tokenResponse); await command.run(); @@ -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; - exchangeCodeForToken: (...args: unknown[]) => Promise; + startServerAndAwaitToken: (...args: unknown[]) => Promise; }; - 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();