From 05da975dfd95d3233cafb1797144b44199d5e9a8 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 9 Nov 2026 15:40:27 -0500 Subject: [PATCH] feat(login): support MCP-compatible browser OAuth --- README.md | 5 + eslint.config.js | 2 + scripts/ci/setup-node.mjs | 1 - src/commands/cli.test.mts | 2 +- src/commands/config/cmd-config-set.test.mts | 42 +- src/commands/config/handle-config-set.mts | 6 + src/commands/login/apply-login.mts | 2 + src/commands/login/attempt-login.mts | 144 +++- src/commands/login/attempt-login.test.mts | 232 ++++++ src/commands/login/cmd-login.mts | 19 +- src/commands/login/cmd-login.test.mts | 10 +- src/commands/logout/apply-logout.mts | 2 + src/commands/logout/attempt-logout.mts | 30 +- src/commands/logout/attempt-logout.test.mts | 95 +++ src/commands/logout/cmd-logout.mts | 2 +- src/constants.mts | 4 + src/shadow/npm-base.mts | 18 +- src/shadow/npm/bin.test.mts | 26 + src/shadow/pnpm/bin.mts | 9 +- src/shadow/pnpm/bin.test.mts | 17 + src/shadow/yarn/bin.mts | 17 +- src/shadow/yarn/bin.test.mts | 17 + src/utils/api-transport.mts | 34 + src/utils/api.mts | 124 ++- src/utils/api.test.mts | 59 +- src/utils/cmd.mts | 7 +- src/utils/coana-release.mts | 13 +- src/utils/config.mts | 7 +- src/utils/config.test.mts | 8 + src/utils/oauth-proxy.test.mts | 84 ++ src/utils/oauth.mts | 745 ++++++++++++++++++ src/utils/oauth.test.mts | 477 +++++++++++ src/utils/sdk.mts | 205 +++-- src/utils/sdk.test.mts | 178 ++++- .../logout/attempt-logout.files.test.mts | 138 ++++ .../src/utils/oauth-storage-failures.test.mts | 165 ++++ 36 files changed, 2833 insertions(+), 113 deletions(-) create mode 100644 src/commands/login/attempt-login.test.mts create mode 100644 src/commands/logout/attempt-logout.test.mts create mode 100644 src/utils/api-transport.mts create mode 100644 src/utils/oauth-proxy.test.mts create mode 100644 src/utils/oauth.mts create mode 100644 src/utils/oauth.test.mts create mode 100644 test/src/commands/logout/attempt-logout.files.test.mts create mode 100644 test/src/utils/oauth-storage-failures.test.mts diff --git a/README.md b/README.md index 8ace54a8d1..62f54c2d8f 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ socket patch setup --yes Free patches work without authentication. For paid patches, set `SOCKET_CLI_API_TOKEN` and `SOCKET_CLI_ORG_SLUG`. +`socket login` uses browser OAuth for read-only package, alert, and threat-feed +access. Open the printed authorization URL manually if a browser does not open. +Use `socket login --api-token` or `SOCKET_CLI_API_TOKEN` for scans and write +commands. + ### Aliases All aliases support the flags and arguments of the commands they alias. diff --git a/eslint.config.js b/eslint.config.js index 657a4ec201..fd6393a58f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -223,6 +223,8 @@ module.exports = [ // Allow paths like src/commands/manifest/bazel/*.test.mts. 'src/*/*/*/*.test.mts', 'test/*.mts', + 'test/src/utils/*.test.mts', + 'test/src/commands/logout/*.test.mts', // Allow loose one-off scripts. 'scripts/*.mts', ], diff --git a/scripts/ci/setup-node.mjs b/scripts/ci/setup-node.mjs index bf79a080b0..494de0f609 100644 --- a/scripts/ci/setup-node.mjs +++ b/scripts/ci/setup-node.mjs @@ -1,4 +1,3 @@ - import { execFile as execFileCallback } from 'node:child_process' import { createHash } from 'node:crypto' import { constants } from 'node:fs' diff --git a/src/commands/cli.test.mts b/src/commands/cli.test.mts index 9b3986a758..4cb71338f3 100755 --- a/src/commands/cli.test.mts +++ b/src/commands/cli.test.mts @@ -28,7 +28,7 @@ describe('socket root command', async () => { Note: All commands have their own --help Main commands - socket login Setup Socket CLI with an API token and defaults + socket login Sign in to Socket and configure the CLI socket scan create Create a new Socket scan and report socket npm/lodash@4.17.21 Request the Socket score of a package socket fix Fix CVEs in dependencies diff --git a/src/commands/config/cmd-config-set.test.mts b/src/commands/config/cmd-config-set.test.mts index 8526f68cb1..2f26eac8aa 100644 --- a/src/commands/config/cmd-config-set.test.mts +++ b/src/commands/config/cmd-config-set.test.mts @@ -1,4 +1,10 @@ -import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -164,4 +170,38 @@ describe('socket config get', async () => { } }, ) + + cmdit( + ['config', 'set', 'apiToken', 'skt_faketoken'], + 'should activate API-token auth when replacing a saved OAuth login', + async cmd => { + const dataHome = mkdtempSync(path.join(os.tmpdir(), 'socket-cfg-')) + const configPath = path.join( + dataHome, + 'socket', + 'settings', + 'config.json', + ) + mkdirSync(path.dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + Buffer.from(JSON.stringify({ authType: 'oauth' })).toString('base64'), + ) + + try { + const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { + env: { XDG_DATA_HOME: dataHome }, + }) + expect(code, 'a persistable set should exit 0').toBe(0) + expect(stdout).toContain('OK') + + const raw = readFileSync(configPath, 'utf8') + const saved = JSON.parse(Buffer.from(raw, 'base64').toString('utf8')) + expect(saved.apiToken).toBe('skt_faketoken') + expect(saved.authType).toBe('api-token') + } finally { + rmSync(dataHome, { recursive: true, force: true }) + } + }, + ) }) diff --git a/src/commands/config/handle-config-set.mts b/src/commands/config/handle-config-set.mts index 2256633d6b..7f2d158812 100644 --- a/src/commands/config/handle-config-set.mts +++ b/src/commands/config/handle-config-set.mts @@ -1,6 +1,7 @@ import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' import { outputConfigSet } from './output-config-set.mts' +import { CONFIG_KEY_API_TOKEN, CONFIG_KEY_AUTH_TYPE } from '../../constants.mts' import { updateConfigValue } from '../../utils/config.mts' import type { CResult, OutputKind } from '../../types.mts' @@ -19,6 +20,11 @@ export async function handleConfigSet({ debugDir('inspect', { key, value, outputKind }) const result = updateConfigValue(key, value) + if (result.ok && !result.data && key === CONFIG_KEY_API_TOKEN) { + // An explicit API-token update must take precedence over a saved OAuth + // session, which otherwise remains the active credential source. + updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'api-token') + } // `config set` is a one-shot command: an in-memory-only change is a no-op // because the process exits before anything reads it. updateConfigValue only diff --git a/src/commands/login/apply-login.mts b/src/commands/login/apply-login.mts index b884d5ebb6..8ca7295e0d 100644 --- a/src/commands/login/apply-login.mts +++ b/src/commands/login/apply-login.mts @@ -2,6 +2,7 @@ import { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_ENFORCED_ORGS, } from '../../constants.mts' import { updateConfigValue } from '../../utils/config.mts' @@ -14,6 +15,7 @@ export function applyLogin( ) { updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, enforcedOrgs) updateConfigValue(CONFIG_KEY_API_TOKEN, apiToken) + updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'api-token') updateConfigValue(CONFIG_KEY_API_BASE_URL, apiBaseUrl) updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy) } diff --git a/src/commands/login/attempt-login.mts b/src/commands/login/attempt-login.mts index 79a6a015b2..de96ee0a7a 100644 --- a/src/commands/login/attempt-login.mts +++ b/src/commands/login/attempt-login.mts @@ -7,6 +7,7 @@ import constants, { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_DEFAULT_ORG, } from '../../constants.mts' import { @@ -15,8 +16,18 @@ import { updateConfigValue, } from '../../utils/config.mts' import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts' +import { + OAUTH_ISSUER, + beginOAuthLogin, + logoutOAuth, + saveOAuthCredentials, +} from '../../utils/oauth.mts' import { getEnterpriseOrgs, getOrgSlugs } from '../../utils/organization.mts' -import { setupSdk } from '../../utils/sdk.mts' +import { + getDefaultApiBaseUrl, + getDefaultProxyUrl, + setupSdk, +} from '../../utils/sdk.mts' import { socketDocsLink } from '../../utils/terminal-link.mts' import { setupTabCompletion } from '../install/setup-tab-completion.mts' import { fetchOrganization } from '../organization/fetch-organization-list.mts' @@ -29,9 +40,21 @@ type OrgChoices = Array export async function attemptLogin( apiBaseUrl: string | undefined, apiProxy: string | undefined, + apiTokenMode = false, +) { + if (!apiTokenMode) { + return await attemptOAuthLogin(apiBaseUrl, apiProxy) + } + + return await attemptApiTokenLogin(apiBaseUrl, apiProxy) +} + +async function attemptApiTokenLogin( + apiBaseUrl: string | undefined, + apiProxy: string | undefined, ) { apiBaseUrl ??= getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) ?? undefined - apiProxy ??= getConfigValueOrUndef(CONFIG_KEY_API_PROXY) ?? undefined + apiProxy ||= getDefaultProxyUrl() const apiTokenInput = await password({ message: `Enter your ${socketDocsLink('/docs/api-keys', 'Socket.dev API token')} (leave blank to use a limited public token)`, }) @@ -154,6 +177,18 @@ export async function attemptLogin( const previousPersistedToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) try { applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy) + if (constants.socketAppDataPath && !isConfigFromFlag()) { + try { + const result = await logoutOAuth(constants.socketAppDataPath, { + apiProxy, + }) + if (result.warning) { + logger.warn(result.warning) + } + } catch { + logger.warn('The previous browser sign-in could not be removed.') + } + } logger.success( `API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`, ) @@ -165,6 +200,109 @@ export async function attemptLogin( } } catch { process.exitCode = 1 - logger.fail(`API login failed`) + logger.fail('API login failed') + } +} + +async function attemptOAuthLogin( + apiBaseUrl: string | undefined, + apiProxy: string | undefined, +) { + if (isConfigFromFlag()) { + throw new Error( + 'Browser sign-in cannot be saved while --config or SOCKET_CLI_CONFIG is active. Remove the config override and run `socket login` again.', + ) + } + const configuredBaseUrl = apiBaseUrl || getDefaultApiBaseUrl() + if (configuredBaseUrl) { + let configuredOrigin: string + try { + configuredOrigin = new URL(configuredBaseUrl).origin + } catch { + throw new Error('The configured Socket API URL is invalid for OAuth.') + } + if (configuredOrigin !== OAUTH_ISSUER) { + throw new Error( + 'Browser sign-in is only available with Socket’s trusted API URL. Use `socket login --api-token --api-base-url ` for a custom API URL.', + ) + } + } + + const existingToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) + const activeAuthType = getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) + if (existingToken && activeAuthType !== 'oauth') { + const choice = await select({ + message: + 'An API token is already saved. Keep using it or switch to read-only browser sign-in?', + choices: [ + { name: 'Keep the saved API token', value: 'keep' }, + { + name: 'Switch to browser sign-in (read-only)', + value: 'oauth', + }, + ], + }) + if (choice === undefined) { + logger.fail('Canceled by user') + return { ok: false, message: 'Canceled', cause: 'Canceled by user' } + } + if (choice === 'keep') { + logger.success('Kept the saved API token login') + return { ok: true } + } + } + + apiProxy ||= getDefaultProxyUrl() + const result = await beginOAuthLogin({ + apiProxy, + openBrowser: true, + onAuthorizationUrl(url) { + logger.log('Open this URL in your browser to sign in:') + logger.log(url) + }, + }) + const baseUrl = new URL('/v0/', result.apiOrigin).href + const sdkResult = await setupSdk({ + apiBaseUrl: baseUrl, + apiProxy, + apiToken: result.accessToken, + }) + if (!sdkResult.ok) { + process.exitCode = 1 + logger.fail(failMsgWithBadge(sdkResult.message, sdkResult.cause)) + return + } + const orgsResult = await fetchOrganization({ + description: 'browser sign-in validation', + sdk: sdkResult.data, + }) + if (!orgsResult.ok) { + process.exitCode = 1 + logger.fail(failMsgWithBadge(orgsResult.message, orgsResult.cause)) + return + } + const orgSlugs = getOrgSlugs(orgsResult.data.organizations) + if (orgSlugs.length === 0) { + process.exitCode = 1 + logger.fail( + 'Browser sign-in succeeded, but no accessible organization was returned. Check your organization membership and try again; existing credentials were kept.', + ) + return + } + + await saveOAuthCredentials(constants.socketAppDataPath!, result) + updateConfigValue(CONFIG_KEY_API_TOKEN, null) + updateConfigValue(CONFIG_KEY_DEFAULT_ORG, orgSlugs[0]) + updateConfigValue(CONFIG_KEY_API_BASE_URL, null) + updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy) + updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'oauth') + logger.success(`Browser sign-in saved for ${joinAnd(orgSlugs)}`) + logger.warn( + 'This sign-in is read-only and supports package, alert, and threat-feed queries. Write and scan commands require API-token credentials; use `socket login --api-token` or SOCKET_CLI_API_TOKEN.', + ) + if (constants.ENV.SOCKET_CLI_API_TOKEN) { + logger.warn( + 'SOCKET_CLI_API_TOKEN remains active and takes precedence over browser sign-in for this session.', + ) } } diff --git a/src/commands/login/attempt-login.test.mts b/src/commands/login/attempt-login.test.mts new file mode 100644 index 0000000000..3bd570bb15 --- /dev/null +++ b/src/commands/login/attempt-login.test.mts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks } = vi.hoisted(() => { + const config: Record = {} + return { + mocks: { + config, + beginOAuthLogin: vi.fn(), + confirm: vi.fn(), + fetchOrganization: vi.fn(), + getEnterpriseOrgs: vi.fn(() => []), + getOrgSlugs: vi.fn((orgs: Array<{ slug: string }>) => + orgs.map(org => org.slug), + ), + getDefaultProxyUrl: vi.fn(() => 'http://proxy.example.test:8080'), + getDefaultApiBaseUrl: vi.fn(() => 'https://api.socket.dev/v0/'), + isConfigFromFlag: vi.fn(() => false), + logger: { + fail: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + }, + logoutOAuth: vi.fn(), + password: vi.fn(), + saveOAuthCredentials: vi.fn(), + select: vi.fn(), + setupSdk: vi.fn(), + setupTabCompletion: vi.fn(), + updateConfigValue: vi.fn((key: string, value: unknown) => { + config[key] = value + return { ok: true } + }), + applyLogin: vi.fn(), + }, + } +}) + +vi.mock('@socketsecurity/registry/lib/arrays', () => ({ + joinAnd: (values: string[]) => values.join(', '), +})) +vi.mock('@socketsecurity/registry/lib/logger', () => ({ logger: mocks.logger })) +vi.mock('@socketsecurity/registry/lib/prompts', () => ({ + confirm: mocks.confirm, + password: mocks.password, + select: mocks.select, +})) +vi.mock('./apply-login.mts', () => ({ applyLogin: mocks.applyLogin })) +vi.mock('../../utils/oauth.mts', () => ({ + OAUTH_ISSUER: 'https://api.socket.dev', + beginOAuthLogin: mocks.beginOAuthLogin, + logoutOAuth: mocks.logoutOAuth, + saveOAuthCredentials: mocks.saveOAuthCredentials, +})) +vi.mock('../../constants.mts', () => ({ + default: { + ENV: { + SOCKET_CLI_API_BASE_URL: 'https://api.socket.dev/v0/', + SOCKET_CLI_API_TOKEN: '', + }, + SOCKET_PUBLIC_API_TOKEN: 'public-token', + socketAppDataPath: '/tmp/socket-oauth-login-test', + }, + CONFIG_KEY_API_BASE_URL: 'apiBaseUrl', + CONFIG_KEY_API_PROXY: 'apiProxy', + CONFIG_KEY_API_TOKEN: 'apiToken', + CONFIG_KEY_AUTH_TYPE: 'authType', + CONFIG_KEY_DEFAULT_ORG: 'defaultOrg', + CONFIG_KEY_ENFORCED_ORGS: 'enforcedOrgs', +})) +vi.mock('../../utils/config.mts', () => ({ + getConfigValueOrUndef: (key: string) => mocks.config[key], + isConfigFromFlag: mocks.isConfigFromFlag, + updateConfigValue: mocks.updateConfigValue, +})) +vi.mock('../../utils/fail-msg-with-badge.mts', () => ({ + failMsgWithBadge: (message: string, cause?: string) => + `${message} ${cause ?? ''}`, +})) +vi.mock('../../utils/organization.mts', () => ({ + getEnterpriseOrgs: mocks.getEnterpriseOrgs, + getOrgSlugs: mocks.getOrgSlugs, +})) +vi.mock('../../utils/sdk.mts', () => ({ + getDefaultApiBaseUrl: mocks.getDefaultApiBaseUrl, + getDefaultProxyUrl: mocks.getDefaultProxyUrl, + setupSdk: mocks.setupSdk, +})) +vi.mock('../../utils/terminal-link.mts', () => ({ + socketDocsLink: (_path: string, label: string) => label, +})) +vi.mock('../install/setup-tab-completion.mts', () => ({ + setupTabCompletion: mocks.setupTabCompletion, +})) +vi.mock('../organization/fetch-organization-list.mts', () => ({ + fetchOrganization: mocks.fetchOrganization, +})) + +import { attemptLogin } from './attempt-login.mts' + +const credentials = { + version: 1 as const, + issuer: 'https://api.socket.dev', + resource: 'https://mcp.socket.dev/', + apiOrigin: 'https://api.socket.dev', + clientId: 'public-client', + accessToken: 'oauth-access-token', + refreshToken: 'oauth-refresh-token', + expiresAt: Date.now() + 60_000, + scopes: ['packages:list', 'alerts:list', 'threat-feed:list'], + tokenEndpoint: 'https://api.socket.dev/v1/oauth2/token', +} + +describe('attemptLogin', () => { + beforeEach(() => { + vi.clearAllMocks() + Object.keys(mocks.config).forEach(key => delete mocks.config[key]) + mocks.isConfigFromFlag.mockReturnValue(false) + mocks.getDefaultApiBaseUrl.mockReturnValue('https://api.socket.dev/v0/') + mocks.beginOAuthLogin.mockResolvedValue(credentials) + mocks.logoutOAuth.mockResolvedValue({ revoked: true }) + mocks.setupSdk.mockResolvedValue({ ok: true, data: { sdk: true } }) + mocks.fetchOrganization.mockResolvedValue({ + ok: true, + data: { organizations: [{ slug: 'acme', plan: 'free' }] }, + }) + mocks.select.mockResolvedValue(false) + mocks.password.mockResolvedValue('legacy-api-token') + mocks.setupTabCompletion.mockResolvedValue({ ok: true }) + }) + + it('uses browser OAuth by default and saves only after organization validation', async () => { + await attemptLogin(undefined, '') + + expect(mocks.beginOAuthLogin).toHaveBeenCalledWith( + expect.objectContaining({ openBrowser: true }), + ) + expect(mocks.setupSdk).toHaveBeenCalledWith({ + apiBaseUrl: 'https://api.socket.dev/v0/', + apiProxy: 'http://proxy.example.test:8080', + apiToken: 'oauth-access-token', + }) + expect(mocks.beginOAuthLogin).toHaveBeenCalledWith( + expect.objectContaining({ apiProxy: 'http://proxy.example.test:8080' }), + ) + expect(mocks.fetchOrganization).toHaveBeenCalled() + expect(mocks.saveOAuthCredentials).toHaveBeenCalledWith( + '/tmp/socket-oauth-login-test', + credentials, + ) + expect(mocks.updateConfigValue).toHaveBeenCalledWith('apiToken', null) + expect(mocks.updateConfigValue).toHaveBeenCalledWith('authType', 'oauth') + expect(mocks.applyLogin).not.toHaveBeenCalled() + }) + + it('requires API-token login when the configured API URL is custom', async () => { + mocks.getDefaultApiBaseUrl.mockReturnValue('https://custom.example/v0/') + + await expect(attemptLogin(undefined, undefined)).rejects.toThrow( + 'Use `socket login --api-token --api-base-url ` for a custom API URL.', + ) + + expect(mocks.beginOAuthLogin).not.toHaveBeenCalled() + }) + + it('keeps an existing API token unless the user explicitly switches modes', async () => { + mocks.config['apiToken'] = 'existing-api-token' + mocks.select.mockResolvedValueOnce('keep') + + await attemptLogin(undefined, undefined) + + expect(mocks.beginOAuthLogin).not.toHaveBeenCalled() + expect(mocks.saveOAuthCredentials).not.toHaveBeenCalled() + expect(mocks.config['apiToken']).toBe('existing-api-token') + }) + + it('clears the old API token after a successful OAuth switch', async () => { + mocks.config['apiToken'] = 'existing-api-token' + mocks.select.mockResolvedValueOnce('oauth') + + await attemptLogin(undefined, undefined) + + expect(mocks.saveOAuthCredentials).toHaveBeenCalled() + expect(mocks.updateConfigValue).toHaveBeenCalledWith('apiToken', null) + expect(mocks.updateConfigValue).toHaveBeenCalledWith('authType', 'oauth') + }) + + it('does not save or activate OAuth when organization validation returns no organizations', async () => { + mocks.config['apiToken'] = 'existing-api-token' + mocks.select.mockResolvedValueOnce('oauth') + mocks.fetchOrganization.mockResolvedValueOnce({ + ok: true, + data: { organizations: [] }, + }) + + await attemptLogin(undefined, undefined) + + expect(mocks.saveOAuthCredentials).not.toHaveBeenCalled() + expect(mocks.config['apiToken']).toBe('existing-api-token') + expect(mocks.updateConfigValue).not.toHaveBeenCalledWith('apiToken', null) + expect(mocks.updateConfigValue).not.toHaveBeenCalledWith( + 'authType', + 'oauth', + ) + expect(mocks.logger.fail).toHaveBeenCalledWith( + expect.stringContaining('no accessible organization'), + ) + }) + + it('uses the masked API-token path only when explicitly requested', async () => { + mocks.config['authType'] = 'oauth' + await attemptLogin(undefined, undefined, true) + + expect(mocks.password).toHaveBeenCalled() + expect(mocks.beginOAuthLogin).not.toHaveBeenCalled() + expect(mocks.setupSdk).toHaveBeenCalledWith({ + apiBaseUrl: undefined, + apiProxy: 'http://proxy.example.test:8080', + apiToken: 'legacy-api-token', + }) + expect(mocks.applyLogin).toHaveBeenCalledWith( + 'legacy-api-token', + [], + undefined, + 'http://proxy.example.test:8080', + ) + expect(mocks.logoutOAuth).toHaveBeenCalledWith( + '/tmp/socket-oauth-login-test', + { apiProxy: 'http://proxy.example.test:8080' }, + ) + }) +}) diff --git a/src/commands/login/cmd-login.mts b/src/commands/login/cmd-login.mts index b54ceb2277..ecd3fd93b6 100644 --- a/src/commands/login/cmd-login.mts +++ b/src/commands/login/cmd-login.mts @@ -18,7 +18,7 @@ import type { export const CMD_NAME = 'login' -const description = 'Setup Socket CLI with an API token and defaults' +const description = 'Sign in to Socket and configure the CLI' const hidden = false @@ -39,10 +39,15 @@ async function run( hidden, flags: { ...commonFlags, + apiToken: { + type: 'boolean', + default: false, + description: 'Use masked API-token login for scans and write commands', + }, apiBaseUrl: { type: 'string', default: '', - description: 'API server to connect to for login', + description: 'API server for API-token login', }, apiProxy: { type: 'string', @@ -57,13 +62,16 @@ async function run( API Token Requirements ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - Logs into the Socket API by prompting for an API token + By default, opens a browser for read-only package, alert, and threat-feed + access. Use --api-token for scans and write operations. A sign-in URL is + printed so you can open it manually if the browser does not launch. Options ${getFlagListOutput(config.flags)} Examples $ ${command} + $ ${command} --api-token $ ${command} --api-proxy=http://localhost:1234 `, } @@ -82,9 +90,10 @@ async function run( return } + const apiTokenMode = !!cli.flags['apiToken'] if (!isInteractive()) { throw new InputError( - 'Cannot prompt for credentials in a non-interactive shell. Use SOCKET_CLI_API_TOKEN environment variable instead', + 'Login requires an interactive terminal. Set SOCKET_CLI_API_TOKEN for automation.', ) } @@ -93,5 +102,5 @@ async function run( apiProxy?: string | undefined } - await attemptLogin(apiBaseUrl, apiProxy) + await attemptLogin(apiBaseUrl, apiProxy, apiTokenMode) } diff --git a/src/commands/login/cmd-login.test.mts b/src/commands/login/cmd-login.test.mts index bf4f5fb812..042b8a63b3 100644 --- a/src/commands/login/cmd-login.test.mts +++ b/src/commands/login/cmd-login.test.mts @@ -17,7 +17,7 @@ describe('socket login', async () => { const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) expect(stdout).toMatchInlineSnapshot( ` - "Setup Socket CLI with an API token and defaults + "Sign in to Socket and configure the CLI Usage $ socket login [options] @@ -25,14 +25,18 @@ describe('socket login', async () => { API Token Requirements - Quota: 1 unit - Logs into the Socket API by prompting for an API token + By default, opens a browser for read-only package, alert, and threat-feed + access. Use --api-token for scans and write operations. A sign-in URL is + printed so you can open it manually if the browser does not launch. Options - --api-base-url API server to connect to for login + --api-base-url API server for API-token login --api-proxy Proxy to use when making connection to API server + --api-token Use masked API-token login for scans and write commands Examples $ socket login + $ socket login --api-token $ socket login --api-proxy=http://localhost:1234" `, ) diff --git a/src/commands/logout/apply-logout.mts b/src/commands/logout/apply-logout.mts index 5dc4cf706c..d1034b8117 100644 --- a/src/commands/logout/apply-logout.mts +++ b/src/commands/logout/apply-logout.mts @@ -2,12 +2,14 @@ import { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_ENFORCED_ORGS, } from '../../constants.mts' import { updateConfigValue } from '../../utils/config.mts' export function applyLogout() { updateConfigValue(CONFIG_KEY_API_TOKEN, null) + updateConfigValue(CONFIG_KEY_AUTH_TYPE, null) updateConfigValue(CONFIG_KEY_API_BASE_URL, null) updateConfigValue(CONFIG_KEY_API_PROXY, null) updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, null) diff --git a/src/commands/logout/attempt-logout.mts b/src/commands/logout/attempt-logout.mts index 1e1dd0dc93..e453fdaf65 100644 --- a/src/commands/logout/attempt-logout.mts +++ b/src/commands/logout/attempt-logout.mts @@ -1,19 +1,43 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { applyLogout } from './apply-logout.mts' +import constants from '../../constants.mts' import { isConfigFromFlag } from '../../utils/config.mts' +import { getErrorCause } from '../../utils/errors.mts' +import { logoutOAuth } from '../../utils/oauth.mts' +import { getDefaultProxyUrl } from '../../utils/sdk.mts' -export function attemptLogout() { +export async function attemptLogout() { + let warning: string | undefined try { - applyLogout() + const directory = constants.socketAppDataPath + try { + if (directory && !isConfigFromFlag()) { + const result = await logoutOAuth(directory, { + apiProxy: getDefaultProxyUrl(), + }) + warning = result.warning + } + } finally { + applyLogout() + } logger.success('Successfully logged out') + if (warning) { + logger.warn(`Local sign-out completed. ${warning}`) + } + if (constants.ENV.SOCKET_CLI_API_TOKEN) { + logger.warn( + 'SOCKET_CLI_API_TOKEN remains active and takes precedence until it is unset.', + ) + } if (isConfigFromFlag()) { logger.log('') logger.warn( 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!', ) } - } catch { + } catch (error) { logger.fail('Failed to complete logout steps') + logger.warn(getErrorCause(error)) } } diff --git a/src/commands/logout/attempt-logout.test.mts b/src/commands/logout/attempt-logout.test.mts new file mode 100644 index 0000000000..af30263184 --- /dev/null +++ b/src/commands/logout/attempt-logout.test.mts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { attemptLogout } from './attempt-logout.mts' + +const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => true)) +const mockLogoutOAuth = vi.hoisted(() => vi.fn()) +const mockApplyLogout = vi.hoisted(() => vi.fn()) +const mockLogger = vi.hoisted(() => ({ + fail: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), +})) + +vi.mock('@socketsecurity/registry/lib/logger', () => ({ logger: mockLogger })) +vi.mock('../../constants.mts', () => ({ + default: { + ENV: { SOCKET_CLI_API_TOKEN: '' }, + socketAppDataPath: '/tmp/socket-cli-logout-test', + }, +})) +vi.mock('../../utils/config.mts', () => ({ + isConfigFromFlag: mockIsConfigFromFlag, +})) +vi.mock('../../utils/errors.mts', () => ({ + getErrorCause: (error: unknown) => + error instanceof Error ? error.message : 'Unknown error', +})) +vi.mock('../../utils/oauth.mts', () => ({ logoutOAuth: mockLogoutOAuth })) +vi.mock('./apply-logout.mts', () => ({ applyLogout: mockApplyLogout })) +vi.mock('../../utils/sdk.mts', () => ({ getDefaultProxyUrl: () => undefined })) + +describe('attemptLogout', () => { + afterEach(() => { + vi.clearAllMocks() + mockIsConfigFromFlag.mockReturnValue(true) + }) + + it('does not revoke persisted OAuth credentials with a config override', async () => { + await attemptLogout() + + expect(mockLogoutOAuth).not.toHaveBeenCalled() + expect(mockApplyLogout).toHaveBeenCalledOnce() + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Note: config is in read-only mode, at least one key was overridden through flag/env, so the logout was not persisted!', + ) + }) + + it('clears config after a lock failure without claiming sign-out succeeded', async () => { + mockIsConfigFromFlag.mockReturnValue(false) + mockLogoutOAuth.mockRejectedValueOnce(new Error('sign-in lock is busy')) + + await attemptLogout() + + expect(mockApplyLogout).toHaveBeenCalledOnce() + expect(mockLogger.fail).toHaveBeenCalledWith( + 'Failed to complete logout steps', + ) + expect(mockLogger.warn).toHaveBeenCalledWith('sign-in lock is busy') + expect(mockLogger.success).not.toHaveBeenCalled() + }) + + it('completes local logout when remote revocation returns a warning', async () => { + mockIsConfigFromFlag.mockReturnValue(false) + mockLogoutOAuth.mockResolvedValueOnce({ + revoked: false, + warning: 'Remote session revocation was not confirmed.', + }) + + await attemptLogout() + + expect(mockApplyLogout).toHaveBeenCalledOnce() + expect(mockLogger.success).toHaveBeenCalledWith('Successfully logged out') + expect(mockLogger.warn).toHaveBeenCalledWith( + 'Local sign-out completed. Remote session revocation was not confirmed.', + ) + expect(mockLogger.fail).not.toHaveBeenCalled() + }) + + it('does not report success when local config cleanup throws', async () => { + mockIsConfigFromFlag.mockReturnValue(false) + mockLogoutOAuth.mockResolvedValueOnce({ revoked: true }) + mockApplyLogout.mockImplementationOnce(() => { + throw new Error('config write failed') + }) + + await attemptLogout() + + expect(mockLogger.fail).toHaveBeenCalledWith( + 'Failed to complete logout steps', + ) + expect(mockLogger.warn).toHaveBeenCalledWith('config write failed') + expect(mockLogger.success).not.toHaveBeenCalled() + }) +}) diff --git a/src/commands/logout/cmd-logout.mts b/src/commands/logout/cmd-logout.mts index 0c6c7a5953..5b4cc236f1 100644 --- a/src/commands/logout/cmd-logout.mts +++ b/src/commands/logout/cmd-logout.mts @@ -53,5 +53,5 @@ async function run( return } - attemptLogout() + await attemptLogout() } diff --git a/src/constants.mts b/src/constants.mts index eb31e83edf..a88531c65e 100644 --- a/src/constants.mts +++ b/src/constants.mts @@ -198,6 +198,7 @@ const COANA_CLI_VERSION = '15.14.2' const CONFIG_KEY_API_BASE_URL = 'apiBaseUrl' const CONFIG_KEY_API_PROXY = 'apiProxy' const CONFIG_KEY_API_TOKEN = 'apiToken' +const CONFIG_KEY_AUTH_TYPE = 'authType' const CONFIG_KEY_DEFAULT_ORG = 'defaultOrg' const CONFIG_KEY_ENFORCED_ORGS = 'enforcedOrgs' const CONFIG_KEY_ORG = 'org' @@ -292,6 +293,7 @@ export type Constants = Remap< readonly CONFIG_KEY_API_BASE_URL: typeof CONFIG_KEY_API_BASE_URL readonly CONFIG_KEY_API_PROXY: typeof CONFIG_KEY_API_PROXY readonly CONFIG_KEY_API_TOKEN: typeof CONFIG_KEY_API_TOKEN + readonly CONFIG_KEY_AUTH_TYPE: typeof CONFIG_KEY_AUTH_TYPE readonly CONFIG_KEY_DEFAULT_ORG: typeof CONFIG_KEY_DEFAULT_ORG readonly CONFIG_KEY_ENFORCED_ORGS: typeof CONFIG_KEY_ENFORCED_ORGS readonly CONFIG_KEY_ORG: typeof CONFIG_KEY_ORG @@ -877,6 +879,7 @@ const constants: Constants = createConstantsObject( CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_DEFAULT_ORG, CONFIG_KEY_ENFORCED_ORGS, CONFIG_KEY_ORG, @@ -1146,6 +1149,7 @@ export { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_DEFAULT_ORG, CONFIG_KEY_ENFORCED_ORGS, CONFIG_KEY_ORG, diff --git a/src/shadow/npm-base.mts b/src/shadow/npm-base.mts index 0a9b9ac9a9..f5d3063942 100644 --- a/src/shadow/npm-base.mts +++ b/src/shadow/npm-base.mts @@ -16,10 +16,11 @@ import constants, { NODE_MODULES, NPM, NPX, + SOCKET_PUBLIC_API_TOKEN, } from '../constants.mts' -import { cmdFlagsToString } from '../utils/cmd.mts' +import { cmdFlagsToString, isNpmLockfileScanCommand } from '../utils/cmd.mts' import { findUp } from '../utils/fs.mts' -import { getPublicApiToken } from '../utils/sdk.mts' +import { resolvePublicApiToken } from '../utils/sdk.mts' import { installNpmLinks, installNpxLinks } from '../utils/shadow-links.mts' import type { IpcObject } from '../constants.mts' @@ -126,6 +127,17 @@ export default async function shadowNpmBase( useAudit || !(await findUp(NODE_MODULES, { cwd, onlyDirectories: true })) ? [] : ['--no-audit'] + const command = rawBinArgs[0] + const needsSocketApiToken = + binName === NPX + ? rawBinArgs.length > 0 && + !rawBinArgs.some(arg => + ['--help', '-h', '--version', '-v'].includes(arg), + ) + : isNpmLockfileScanCommand(command ?? '') + const shadowApiToken = needsSocketApiToken + ? await resolvePublicApiToken() + : SOCKET_PUBLIC_API_TOKEN const stdio = ensureIpcInStdio(getOwn(spawnOpts, 'stdio')) @@ -179,7 +191,7 @@ export default async function shadowNpmBase( spawnPromise.process.send({ [constants.SOCKET_IPC_HANDSHAKE]: { - [constants.SOCKET_CLI_SHADOW_API_TOKEN]: getPublicApiToken(), + [constants.SOCKET_CLI_SHADOW_API_TOKEN]: shadowApiToken, [constants.SOCKET_CLI_SHADOW_BIN]: binName, [constants.SOCKET_CLI_SHADOW_PROGRESS]: progressArg, ...ipc, diff --git a/src/shadow/npm/bin.test.mts b/src/shadow/npm/bin.test.mts index e544c0d2cf..ede941c0fd 100644 --- a/src/shadow/npm/bin.test.mts +++ b/src/shadow/npm/bin.test.mts @@ -6,6 +6,7 @@ import shadowNpmBin from './bin.mts' const mockInstallNpmLinks = vi.hoisted(() => vi.fn()) const mockSpawn = vi.hoisted(() => vi.fn()) const mockFindUp = vi.hoisted(() => vi.fn()) +const mockResolvePublicApiToken = vi.hoisted(() => vi.fn()) vi.mock('../../utils/shadow-links.mts', () => ({ installNpmLinks: mockInstallNpmLinks, @@ -19,6 +20,10 @@ vi.mock('../../utils/fs.mts', () => ({ findUp: mockFindUp, })) +vi.mock('../../utils/sdk.mts', () => ({ + resolvePublicApiToken: mockResolvePublicApiToken, +})) + vi.mock('../../constants.mts', async importOriginal => { const actual = (await importOriginal()) as Record return { @@ -50,6 +55,7 @@ describe('shadowNpmBin', () => { // Default mock implementations. mockInstallNpmLinks.mockResolvedValue('/usr/bin/npm') mockFindUp.mockResolvedValue(null) + mockResolvePublicApiToken.mockResolvedValue('public-token') mockSpawn.mockReturnValue({ process: { send: vi.fn(), @@ -94,6 +100,26 @@ describe('shadowNpmBin', () => { expect(mockSpawn).toHaveBeenCalled() }) + it('should not resolve OAuth for local npm commands', async () => { + mockResolvePublicApiToken.mockRejectedValue( + new Error('The saved session expired.'), + ) + + await expect(shadowNpmBin(['--version'])).resolves.toHaveProperty( + 'spawnPromise', + ) + await expect(shadowNpmBin(['run', 'test'])).resolves.toHaveProperty( + 'spawnPromise', + ) + expect(mockResolvePublicApiToken).not.toHaveBeenCalled() + }) + + it('should resolve OAuth for npm install scans', async () => { + await shadowNpmBin(['install']) + + expect(mockResolvePublicApiToken).toHaveBeenCalledOnce() + }) + it('should handle readonly array correctly', async () => { const args: readonly string[] = ['install', '--no-progress'] as const const result = await shadowNpmBin(args) diff --git a/src/shadow/pnpm/bin.mts b/src/shadow/pnpm/bin.mts index ffae846f5f..8353739c8b 100644 --- a/src/shadow/pnpm/bin.mts +++ b/src/shadow/pnpm/bin.mts @@ -10,6 +10,7 @@ import constants, { FLAG_DRY_RUN, PNPM, PNPM_LOCK_YAML, + SOCKET_PUBLIC_API_TOKEN, } from '../../constants.mts' import { getAlertsMapFromPnpmLockfile } from '../../utils/alerts-map.mts' import { @@ -18,7 +19,7 @@ import { isPnpmLockfileScanCommand, } from '../../utils/cmd.mts' import { parsePnpmLockfile, readPnpmLockfile } from '../../utils/pnpm.mts' -import { getPublicApiToken } from '../../utils/sdk.mts' +import { resolvePublicApiToken } from '../../utils/sdk.mts' import { installPnpmLinks } from '../../utils/shadow-links.mts' import { logAlertsMap } from '../../utils/socket-package-alert.mts' import { scanPackagesAndLogAlerts } from '../common.mts' @@ -219,6 +220,10 @@ export default async function shadowPnpmBin( // Set up stdio with IPC channel. const stdio = ensureIpcInStdio(spawnOpts.stdio) + const shadowApiToken = + needsScanning && !rawPnpmArgs.includes(FLAG_DRY_RUN) + ? await resolvePublicApiToken() + : SOCKET_PUBLIC_API_TOKEN const spawnPromise = spawn( realPnpmPath, @@ -242,7 +247,7 @@ export default async function shadowPnpmBin( // Send IPC handshake. spawnPromise.process.send({ [constants.SOCKET_IPC_HANDSHAKE]: { - [constants.SOCKET_CLI_SHADOW_API_TOKEN]: getPublicApiToken(), + [constants.SOCKET_CLI_SHADOW_API_TOKEN]: shadowApiToken, [constants.SOCKET_CLI_SHADOW_BIN]: PNPM, [constants.SOCKET_CLI_SHADOW_PROGRESS]: true, ...ipc, diff --git a/src/shadow/pnpm/bin.test.mts b/src/shadow/pnpm/bin.test.mts index 6bc70dc087..0f20447a24 100644 --- a/src/shadow/pnpm/bin.test.mts +++ b/src/shadow/pnpm/bin.test.mts @@ -26,6 +26,7 @@ const mockParsePnpmLockfile = vi.hoisted(() => vi.fn()) const mockReadPnpmLockfile = vi.hoisted(() => vi.fn()) const mockLogAlertsMap = vi.hoisted(() => vi.fn()) const mockExistsSync = vi.hoisted(() => vi.fn()) +const mockResolvePublicApiToken = vi.hoisted(() => vi.fn()) vi.mock('../../utils/alerts-map.mts', () => ({ getAlertsMapFromPnpmLockfile: mockGetAlertsMapFromPnpmLockfile, @@ -37,6 +38,10 @@ vi.mock('../../utils/pnpm.mts', () => ({ readPnpmLockfile: mockReadPnpmLockfile, })) +vi.mock('../../utils/sdk.mts', () => ({ + resolvePublicApiToken: mockResolvePublicApiToken, +})) + vi.mock('../../utils/socket-package-alert.mts', () => ({ logAlertsMap: mockLogAlertsMap, })) @@ -96,6 +101,7 @@ describe('shadowPnpm', () => { }) mockGetAlertsMapFromPurls.mockResolvedValue(new Map()) mockExistsSync.mockReturnValue(false) + mockResolvePublicApiToken.mockResolvedValue('public-token') // Mock process.env process.env.SOCKET_CLI_ACCEPT_RISKS = '' @@ -220,6 +226,17 @@ describe('shadowPnpm', () => { expect(mockGetAlertsMapFromPurls).not.toHaveBeenCalled() }) + it('should not resolve OAuth for local package-manager commands', async () => { + mockResolvePublicApiToken.mockRejectedValue( + new Error('The saved session expired.'), + ) + + await expect(shadowPnpm(['run', 'test'])).resolves.toHaveProperty( + 'spawnPromise', + ) + expect(mockResolvePublicApiToken).not.toHaveBeenCalled() + }) + it('should filter out command line flags from package names', async () => { await shadowPnpm(['add', 'lodash', '--save-dev', 'axios', '--']) diff --git a/src/shadow/yarn/bin.mts b/src/shadow/yarn/bin.mts index b0b9b9e2e7..9519e402a6 100644 --- a/src/shadow/yarn/bin.mts +++ b/src/shadow/yarn/bin.mts @@ -3,9 +3,13 @@ import { fileURLToPath } from 'node:url' import { debugFn } from '@socketsecurity/registry/lib/debug' import { spawn } from '@socketsecurity/registry/lib/spawn' -import constants, { YARN } from '../../constants.mts' +import constants, { + FLAG_DRY_RUN, + SOCKET_PUBLIC_API_TOKEN, + YARN, +} from '../../constants.mts' import { cmdFlagsToString } from '../../utils/cmd.mts' -import { getPublicApiToken } from '../../utils/sdk.mts' +import { resolvePublicApiToken } from '../../utils/sdk.mts' import { installYarnLinks } from '../../utils/shadow-links.mts' import { scanPackagesAndLogAlerts } from '../common.mts' import { ensureIpcInStdio } from '../stdio-ipc.mts' @@ -103,6 +107,13 @@ export default async function shadowYarnBin( // Set up stdio with IPC channel. const stdio = ensureIpcInStdio(spawnOpts.stdio) + const needsScanning = + command !== undefined && + (DLX_COMMANDS.has(command) || INSTALL_COMMANDS.has(command)) && + !rawYarnArgs.includes(FLAG_DRY_RUN) + const shadowApiToken = needsScanning + ? await resolvePublicApiToken() + : SOCKET_PUBLIC_API_TOKEN const spawnPromise = spawn( realYarnPath, @@ -126,7 +137,7 @@ export default async function shadowYarnBin( // Send IPC handshake. spawnPromise.process.send({ [constants.SOCKET_IPC_HANDSHAKE]: { - [constants.SOCKET_CLI_SHADOW_API_TOKEN]: getPublicApiToken(), + [constants.SOCKET_CLI_SHADOW_API_TOKEN]: shadowApiToken, [constants.SOCKET_CLI_SHADOW_BIN]: YARN, [constants.SOCKET_CLI_SHADOW_PROGRESS]: true, ...ipc, diff --git a/src/shadow/yarn/bin.test.mts b/src/shadow/yarn/bin.test.mts index 2473585529..08d4774a95 100644 --- a/src/shadow/yarn/bin.test.mts +++ b/src/shadow/yarn/bin.test.mts @@ -21,6 +21,7 @@ const mockInstallYarnLinks = vi.hoisted(() => vi.fn()) const mockSpawn = vi.hoisted(() => vi.fn()) const mockGetAlertsMapFromPurls = vi.hoisted(() => vi.fn()) const mockLogAlertsMap = vi.hoisted(() => vi.fn()) +const mockResolvePublicApiToken = vi.hoisted(() => vi.fn()) vi.mock('../../utils/alerts-map.mts', () => ({ getAlertsMapFromPurls: mockGetAlertsMapFromPurls, @@ -30,6 +31,10 @@ vi.mock('../../utils/socket-package-alert.mts', () => ({ logAlertsMap: mockLogAlertsMap, })) +vi.mock('../../utils/sdk.mts', () => ({ + resolvePublicApiToken: mockResolvePublicApiToken, +})) + vi.mock('../../utils/shadow-links.mts', () => ({ installYarnLinks: mockInstallYarnLinks, })) @@ -85,6 +90,7 @@ describe('shadowYarn', () => { }) mockGetAlertsMapFromPurls.mockResolvedValue(new Map()) mockReadPackageJson.mockResolvedValue({ dependencies: {} }) + mockResolvePublicApiToken.mockResolvedValue('public-token') // Mock process.env process.env.SOCKET_CLI_ACCEPT_RISKS = '' @@ -237,6 +243,17 @@ describe('shadowYarn', () => { expect(mockGetAlertsMapFromPurls).not.toHaveBeenCalled() }) + it('should not resolve OAuth for local package-manager commands', async () => { + mockResolvePublicApiToken.mockRejectedValue( + new Error('The saved session expired.'), + ) + + await expect(shadowYarn(['run', 'test'])).resolves.toHaveProperty( + 'spawnPromise', + ) + expect(mockResolvePublicApiToken).not.toHaveBeenCalled() + }) + it('should filter out command line flags from package names', async () => { await shadowYarn(['add', 'lodash', '--save-dev', 'axios', '--']) diff --git a/src/utils/api-transport.mts b/src/utils/api-transport.mts new file mode 100644 index 0000000000..05d8819759 --- /dev/null +++ b/src/utils/api-transport.mts @@ -0,0 +1,34 @@ +import { readFileSync } from 'node:fs' +import { rootCertificates } from 'node:tls' + +import { debugFn } from '@socketsecurity/registry/lib/debug' + +import constants from '../constants.mts' + +// Cached extra CA certificates for SSL_CERT_FILE support. +let extraCaCerts: string[] | undefined +let extraCaCertsResolved = false + +// Node reads NODE_EXTRA_CA_CERTS only at process start. If only SSL_CERT_FILE +// is set, pass the extra certificates to each outbound TLS agent explicitly. +export function getExtraCaCerts(): string[] | undefined { + if (extraCaCertsResolved) { + return extraCaCerts + } + extraCaCertsResolved = true + if (process.env['NODE_EXTRA_CA_CERTS']) { + return undefined + } + const certPath = constants.ENV.NODE_EXTRA_CA_CERTS + if (!certPath) { + return undefined + } + try { + const extraCerts = readFileSync(certPath, 'utf-8') + extraCaCerts = [...rootCertificates, extraCerts] + return extraCaCerts + } catch (error) { + debugFn('warn', `Failed to read certificate file: ${certPath}`, error) + return undefined + } +} diff --git a/src/utils/api.mts b/src/utils/api.mts index a27c88e613..7b6203f598 100644 --- a/src/utils/api.mts +++ b/src/utils/api.mts @@ -31,6 +31,7 @@ import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' import { getConfigValueOrUndef } from './config.mts' import { debugApiRequest, debugApiResponse } from './debug.mts' +import { getErrorCause } from './errors.mts' import constants, { CONFIG_KEY_API_BASE_URL, EMPTY_VALUE, @@ -43,9 +44,10 @@ import constants, { import { getRequirements, getRequirementsKey } from './requirements.mts' import { getCliUserAgent, - getDefaultApiToken, getDefaultProxyUrl, getExtraCaCerts, + isOAuthLoginActive, + resolveDefaultApiToken, } from './sdk.mts' import type { CResult } from '../types.mts' @@ -54,6 +56,24 @@ import type { Spinner } from '@socketsecurity/registry/lib/spinner' const MAX_REDIRECTS = 20 const NO_ERROR_MESSAGE = 'No error message returned' +function getApiErrorText(value: unknown): string { + if (typeof value === 'string') { + return value + } + if (!value || typeof value !== 'object') { + return '' + } + const record = value as Record + const details = record['details'] + const nestedCode = + details && typeof details === 'object' + ? (details as Record)['code'] + : undefined + return [record['code'], nestedCode, record['message'], record['error']] + .filter((part): part is string => typeof part === 'string') + .join(' ') +} + // Cached HTTPS agent for direct API calls. Undefined only until the first // getHttpsAgent() call lazily creates it. let _httpsAgent: HttpsAgent | undefined @@ -223,14 +243,23 @@ async function describeApiFailure( response: Response, path: string, ): Promise { - let serverMessage: unknown + let serverMessage = '' + let serverCode = '' try { - serverMessage = (JSON.parse(await response.text()) as any)?.error?.message + const payload = JSON.parse(await response.text()) as any + const serverError = payload?.error + serverMessage = getApiErrorText(serverError) + serverCode = [payload?.code, serverError?.code, serverError?.details?.code] + .filter((part): part is string => typeof part === 'string') + .join(' ') } catch { // A body that isn't JSON carries no message. } - const reason = await getErrorMessageForHttpStatusCode(response.status) - return typeof serverMessage === 'string' && serverMessage + const reason = await getErrorMessageForHttpStatusCode( + response.status, + [serverCode, serverMessage].filter(Boolean).join(' '), + ) + return serverMessage ? `${response.statusText}: ${serverMessage} (reason: ${reason}) (path: ${path})` : `${response.statusText} (reason: ${reason}) (path: ${path})` } @@ -265,7 +294,16 @@ function getCommandRequirements( /** * Log required permissions for a command when encountering 403 errors. */ -function logPermissionsFor403(cmdPath?: string | undefined): void { +function logPermissionsFor403( + cmdPath?: string | undefined, + apiError?: string | undefined, +): void { + if (isOAuthLoginActive()) { + const guidance = getOAuth403Guidance(apiError) + if (guidance) { + logger.error(guidance) + } + } const requirements = getCommandRequirements(cmdPath) if (!requirements?.permissions?.length) { return @@ -275,7 +313,9 @@ function logPermissionsFor403(cmdPath?: string | undefined): void { for (const permission of requirements.permissions) { logger.error(` - ${permission}`) } - logger.error('Please ensure your API token has the required permissions.') + if (!isOAuthLoginActive()) { + logger.error('Please ensure your API token has the required permissions.') + } } // The Socket API server that should be used for operations. @@ -293,7 +333,22 @@ export function getDefaultApiBaseUrl(): string { /** * Get user-friendly error message for HTTP status codes. */ -export async function getErrorMessageForHttpStatusCode(code: number) { +export function getOAuth403Guidance( + apiError: string | undefined, +): string | undefined { + if (/missing[_ -]capability/i.test(apiError ?? '')) { + return 'This organization does not grant the capability required for this command. Ask an organization admin to enable it.' + } + if (/missing[_ -]scope/i.test(apiError ?? '')) { + return 'Browser sign-in is read-only. Use `socket login --api-token` or SOCKET_CLI_API_TOKEN for scans and write operations.' + } + return undefined +} + +export async function getErrorMessageForHttpStatusCode( + code: number, + apiError?: string, +) { if (code === HTTP_STATUS_BAD_REQUEST) { return 'One of the options passed might be incorrect' } @@ -301,6 +356,13 @@ export async function getErrorMessageForHttpStatusCode(code: number) { return 'Your Socket API token appears to be invalid, expired, or revoked. Please verify your token is correct and active' } if (code === HTTP_STATUS_FORBIDDEN) { + if (isOAuthLoginActive()) { + const guidance = getOAuth403Guidance(apiError) + if (guidance) { + return guidance + } + return 'Browser sign-in is read-only and does not support this operation. Use `socket login --api-token` or SOCKET_CLI_API_TOKEN' + } return 'Your Socket API token may not have the required permissions for this command or you might be trying to access (data from) an organization that is not linked to the API token you are logged in with' } if (code === HTTP_STATUS_NOT_FOUND) { @@ -428,9 +490,16 @@ export async function handleApiCall( debugApiResponse('API', endpoint, sdkResult.status) debugDir('inspect', { sdkResult }) - const errStr = sdkResult.error ? String(sdkResult.error).trim() : '' + const errStr = getApiErrorText(sdkResult.error) const message = errStr || NO_ERROR_MESSAGE - const reason = sdkResult.cause || NO_ERROR_MESSAGE + const reason = getApiErrorText(sdkResult.cause) || NO_ERROR_MESSAGE + const statusReason = + sdkResult.status === HTTP_STATUS_FORBIDDEN && isOAuthLoginActive() + ? await getErrorMessageForHttpStatusCode( + sdkResult.status, + `${errStr} ${reason}`, + ) + : undefined const baseCause = reason && message !== reason ? `${message} (reason: ${reason})` : message const cause = sdkResult.url @@ -439,7 +508,7 @@ export async function handleApiCall( const socketSdkErrorResult: ApiCallResult = { ok: false, message: 'Socket API error', - cause, + cause: statusReason ? `${cause} (reason: ${statusReason})` : cause, data: { code: sdkResult.status, }, @@ -447,7 +516,7 @@ export async function handleApiCall( // Log required permissions for 403 errors when in a command context. if (commandPath && sdkResult.status === 403) { - logPermissionsFor403(commandPath) + logPermissionsFor403(commandPath, `${errStr} ${reason}`) } return socketSdkErrorResult @@ -548,13 +617,24 @@ export async function queryApiSafeTextWithStatus( description?: string | undefined, commandPath?: string | undefined, ): Promise> { - const apiToken = getDefaultApiToken() + let apiToken: string | undefined + try { + apiToken = await resolveDefaultApiToken({ + apiBaseUrl: getDefaultApiBaseUrl(), + }) + } catch (error) { + return { + ok: false, + message: 'Authentication Error', + cause: getErrorCause(error), + } + } if (!apiToken) { return { ok: false, message: 'Authentication Error', cause: - 'User must be authenticated to run this command. Run `socket login` and enter your Socket API token.', + 'User must be authenticated to run this command. Run `socket login` or set SOCKET_CLI_API_TOKEN.', } } @@ -702,18 +782,26 @@ export async function sendApiRequest( path: string, options?: SendApiRequestOptions | undefined, ): Promise> { - const apiToken = getDefaultApiToken() + const baseUrl = getDefaultApiBaseUrl() + let apiToken: string | undefined + try { + apiToken = await resolveDefaultApiToken({ apiBaseUrl: baseUrl }) + } catch (error) { + return { + ok: false, + message: 'Authentication Error', + cause: getErrorCause(error), + } + } if (!apiToken) { return { ok: false, message: 'Authentication Error', cause: - 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your Socket API token.', + 'User must be authenticated to run this command. Run `socket login` or set SOCKET_CLI_API_TOKEN.', } } - const baseUrl = getDefaultApiBaseUrl() - const { body, commandPath, description, method } = { __proto__: null, ...options, diff --git a/src/utils/api.test.mts b/src/utils/api.test.mts index 05882e275c..038c7bb17a 100644 --- a/src/utils/api.test.mts +++ b/src/utils/api.test.mts @@ -31,18 +31,28 @@ import type { EventEmitter } from 'node:events' // Mock getExtraCaCerts from sdk.mts. const mockGetExtraCaCerts = vi.hoisted(() => vi.fn(() => undefined)) const mockGetDefaultApiToken = vi.hoisted(() => vi.fn(() => 'test-api-token')) +const mockResolveDefaultApiToken = vi.hoisted(() => + vi.fn(async () => 'test-api-token'), +) const mockGetCliUserAgent = vi.hoisted(() => vi.fn(() => 'socket-cli/1.0.0 node/v20.0.0 test/x64'), ) const mockGetDefaultProxyUrl = vi.hoisted(() => vi.fn((): string | undefined => undefined), ) +const mockIsOAuthLoginActive = vi.hoisted(() => vi.fn(() => false)) +const mockGetConfigValueOrUndef = vi.hoisted(() => + vi.fn((_key: string): unknown => undefined), +) +const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) vi.mock('./sdk.mts', () => ({ getCliUserAgent: mockGetCliUserAgent, getDefaultApiToken: mockGetDefaultApiToken, getDefaultApiBaseUrl: vi.fn(() => undefined), getDefaultProxyUrl: mockGetDefaultProxyUrl, getExtraCaCerts: mockGetExtraCaCerts, + isOAuthLoginActive: mockIsOAuthLoginActive, + resolveDefaultApiToken: mockResolveDefaultApiToken, })) // Mock node:https request function. @@ -78,6 +88,8 @@ vi.mock('../constants.mts', () => ({ NODE_EXTRA_CA_CERTS: '', SOCKET_CLI_API_BASE_URL: 'https://api.socket.dev/v0/', SOCKET_CLI_API_TIMEOUT: 30_000, + SOCKET_CLI_API_TOKEN: '', + SOCKET_CLI_NO_API_TOKEN: false, }, spinner: { failAndStop: vi.fn(), @@ -89,6 +101,7 @@ vi.mock('../constants.mts', () => ({ CONFIG_KEY_API_BASE_URL: 'apiBaseUrl', EMPTY_VALUE: '', HTTP_STATUS_BAD_REQUEST: 400, + CONFIG_KEY_AUTH_TYPE: 'authType', HTTP_STATUS_FORBIDDEN: 403, HTTP_STATUS_INTERNAL_SERVER_ERROR: 500, HTTP_STATUS_NOT_FOUND: 404, @@ -97,7 +110,12 @@ vi.mock('../constants.mts', () => ({ // Mock config. vi.mock('./config.mts', () => ({ - getConfigValueOrUndef: vi.fn(() => undefined), + getConfigValueOrUndef: mockGetConfigValueOrUndef, + isConfigFromFlag: mockIsConfigFromFlag, +})) +vi.mock('./errors.mts', () => ({ + getErrorCause: (error: unknown) => + error instanceof Error ? error.message : 'Unknown error', })) // Mock debug functions. @@ -120,6 +138,41 @@ vi.mock('./telemetry/integration.mts', () => ({ // Store original fetch for restoration. const originalFetch = globalThis.fetch +describe('OAuth API error guidance', () => { + afterEach(() => mockIsOAuthLoginActive.mockReturnValue(false)) + + it('explains read-only scopes and organization capabilities', async () => { + mockIsOAuthLoginActive.mockReturnValue(true) + const { getErrorMessageForHttpStatusCode } = await import('./api.mts') + + await expect( + getErrorMessageForHttpStatusCode(403, 'missing_scope'), + ).resolves.toMatch(/read-only.*socket login --api-token/i) + await expect( + getErrorMessageForHttpStatusCode(403, 'missing_capability'), + ).resolves.toMatch(/organization.*admin/i) + }) + + it('keeps the generic forbidden message when OAuth is inactive', async () => { + mockIsOAuthLoginActive.mockReturnValue(false) + const { getErrorMessageForHttpStatusCode } = await import('./api.mts') + + await expect( + getErrorMessageForHttpStatusCode(403, 'missing_scope'), + ).resolves.toMatch(/API token may not have the required permissions/i) + }) + + it('keeps OAuth-specific guidance behind its shared classifier', async () => { + const { getOAuth403Guidance } = await import('./api.mts') + + expect(getOAuth403Guidance('missing_scope')).toMatch(/read-only/) + expect(getOAuth403Guidance('missing_capability')).toMatch( + /organization.*admin/i, + ) + expect(getOAuth403Guidance('unrelated')).toBeUndefined() + }) +}) + function stubHttpsResponse() { const mockReq = { end: vi.fn(), @@ -153,6 +206,8 @@ describe('apiFetch with extra CA certificates', () => { beforeEach(() => { vi.clearAllMocks() vi.resetModules() + mockGetConfigValueOrUndef.mockReturnValue(undefined) + mockIsConfigFromFlag.mockReturnValue(false) mockGetExtraCaCerts.mockReturnValue(undefined) mockGetDefaultProxyUrl.mockReturnValue(undefined) }) @@ -670,6 +725,8 @@ describe('apiFetch proxy support', () => { beforeEach(() => { vi.clearAllMocks() vi.resetModules() + mockGetConfigValueOrUndef.mockReturnValue(undefined) + mockIsConfigFromFlag.mockReturnValue(false) mockGetExtraCaCerts.mockReturnValue(undefined) mockGetDefaultProxyUrl.mockReturnValue(undefined) }) diff --git a/src/utils/cmd.mts b/src/utils/cmd.mts index 3a6ee28da8..3e75e9dbd5 100644 --- a/src/utils/cmd.mts +++ b/src/utils/cmd.mts @@ -192,7 +192,12 @@ export function isHelpFlag(cmdArg: string): boolean { * npm uses: install, i, update */ export function isNpmLockfileScanCommand(command: string): boolean { - return command === 'install' || command === 'i' || command === 'update' + return ( + command === 'install' || + command === 'i' || + command === 'ci' || + command === 'update' + ) } /** diff --git a/src/utils/coana-release.mts b/src/utils/coana-release.mts index 14e7354b27..ad860710d3 100644 --- a/src/utils/coana-release.mts +++ b/src/utils/coana-release.mts @@ -34,8 +34,8 @@ import { getErrorCause } from './errors.mts' import { withTmpDir } from './fs.mts' import { getCliUserAgent, - getDefaultApiToken, getDefaultProxyUrl, + resolveDefaultApiToken, } from './sdk.mts' import type { CResult } from '../types.mts' @@ -415,7 +415,16 @@ export async function spawnCoana( // outbound axios User-Agent header. SOCKET_CALLER_USER_AGENT: getCliUserAgent(), } - const defaultApiToken = getDefaultApiToken() + let defaultApiToken: string | undefined + try { + defaultApiToken = await resolveDefaultApiToken() + } catch (error) { + return { + ok: false, + message: 'Socket authentication failed', + cause: error instanceof Error ? error.message : String(error), + } + } if (defaultApiToken) { mixinsEnv['SOCKET_CLI_API_TOKEN'] = defaultApiToken } diff --git a/src/utils/config.mts b/src/utils/config.mts index 57486404a1..899f9edb62 100644 --- a/src/utils/config.mts +++ b/src/utils/config.mts @@ -37,6 +37,7 @@ import constants, { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, CONFIG_KEY_DEFAULT_ORG, CONFIG_KEY_ENFORCED_ORGS, CONFIG_KEY_ORG, @@ -49,6 +50,7 @@ import type { CResult } from '../types.mts' import type { SocketYml } from '@socketsecurity/config' export interface LocalConfig { + authType?: 'api-token' | 'oauth' | null | undefined apiBaseUrl?: string | null | undefined // @deprecated ; use apiToken. when loading a config, if this prop exists it // is deleted and set to apiToken instead, and then persisted. @@ -140,7 +142,10 @@ function normalizeConfigKey( : key === CONFIG_KEY_ORG ? CONFIG_KEY_DEFAULT_ORG : key - if (!isSupportedConfigKey(normalizedKey)) { + if ( + normalizedKey !== CONFIG_KEY_AUTH_TYPE && + !isSupportedConfigKey(normalizedKey) + ) { return { ok: false, message: `Invalid config key: ${normalizedKey}`, diff --git a/src/utils/config.test.mts b/src/utils/config.test.mts index e7c2251ddb..a4f649986d 100644 --- a/src/utils/config.test.mts +++ b/src/utils/config.test.mts @@ -12,6 +12,8 @@ import { beforeEach, describe, expect, it } from 'vitest' import { findSocketYmlSync, + getConfigValueOrUndef, + getSupportedConfigKeys, isConfigFromFlag, overrideCachedConfig, overrideConfigApiToken, @@ -55,6 +57,12 @@ describe('utils/config', () => { } `) }) + + it('stores the active OAuth mode without exposing it as a user config key', () => { + expect(updateConfigValue('authType', 'oauth').ok).toBe(true) + expect(getConfigValueOrUndef('authType')).toBe('oauth') + expect(getSupportedConfigKeys()).not.toContain('authType') + }) }) describe('read-only state', () => { diff --git a/src/utils/oauth-proxy.test.mts b/src/utils/oauth-proxy.test.mts new file mode 100644 index 0000000000..ffef41ca86 --- /dev/null +++ b/src/utils/oauth-proxy.test.mts @@ -0,0 +1,84 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import { afterEach, describe, expect, it } from 'vitest' + +import { + OAUTH_ISSUER, + OAUTH_RESOURCE, + resolveOAuthAccessToken, +} from './oauth.mts' + +let directory: string +afterEach(async () => { + if (directory) { + await rm(directory, { recursive: true, force: true }) + } +}) + +describe('OAuth proxy request deadline', () => { + it('rejects a stalled CONNECT and releases the cross-process lock', async () => { + directory = await mkdtemp(path.join(tmpdir(), 'socket-oauth-proxy-')) + await writeFile( + path.join(directory, 'oauth.json'), + `${JSON.stringify({ + version: 1, + issuer: OAUTH_ISSUER, + resource: OAUTH_RESOURCE, + apiOrigin: OAUTH_ISSUER, + clientId: 'test-client', + accessToken: 'expired-access', + refreshToken: 'one-time-refresh', + expiresAt: Date.now() - 1, + scopes: ['packages:list', 'alerts:list', 'threat-feed:list'], + tokenEndpoint: `${OAUTH_ISSUER}/v1/oauth2/token`, + })}\n`, + { mode: 0o600 }, + ) + const proxy = createServer() + const sockets = new Set() + let connected!: () => void + const didConnect = new Promise(resolve => { + connected = resolve + }) + proxy.on('connect', (_request, socket) => { + sockets.add(socket) + socket.on('close', () => sockets.delete(socket)) + connected() // Intentionally leave CONNECT unanswered. + }) + await new Promise(resolve => proxy.listen(0, '127.0.0.1', resolve)) + const address = proxy.address() + if (!address || typeof address === 'string') { + throw new Error('proxy failed to bind') + } + try { + const pending = resolveOAuthAccessToken(directory, { + apiProxy: `http://127.0.0.1:${address.port}/`, + requestTimeoutMs: 1_000, + }) + const connectObserved = await Promise.race([ + didConnect.then(() => true), + new Promise(resolve => + setTimeout(() => resolve(false), 1_000), + ), + ]) + expect(connectObserved).toBe(true) + await expect(pending).rejects.toThrow('timed out') + await expect( + stat(path.join(directory, 'oauth.json.lock')), + ).rejects.toMatchObject({ code: 'ENOENT' }) + const stored = JSON.parse( + await readFile(path.join(directory, 'oauth.json'), 'utf8'), + ) + expect(stored.refreshToken).toBeUndefined() + } finally { + proxy.closeAllConnections() + for (const socket of sockets) { + socket.destroy() + } + await new Promise(resolve => proxy.close(() => resolve())) + } + }, 5_000) +}) diff --git a/src/utils/oauth.mts b/src/utils/oauth.mts new file mode 100644 index 0000000000..5cf28bcbf3 --- /dev/null +++ b/src/utils/oauth.mts @@ -0,0 +1,745 @@ +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto' +import { promises as fs, readFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { Agent as HttpsAgent, request as httpsRequest } from 'node:https' +import path from 'node:path' + +import { HttpsProxyAgent } from 'hpagent' +import open from 'open' + +import { isObjectObject as isRecord } from '@socketsecurity/registry/lib/objects' + +import { getExtraCaCerts } from './api-transport.mts' + +import type { IncomingMessage, ServerResponse } from 'node:http' + +export const OAUTH_ISSUER = 'https://api.socket.dev' +export const OAUTH_RESOURCE = 'https://mcp.socket.dev/' +const RESOURCE_METADATA = `${OAUTH_RESOURCE}.well-known/oauth-protected-resource` +const ISSUER_METADATA = `${OAUTH_ISSUER}/.well-known/oauth-authorization-server` +const REQUIRED_SCOPES = ['packages:list', 'alerts:list', 'threat-feed:list'] +const FLOW_TIMEOUT = 5 * 60_000 +const REQUEST_TIMEOUT = 20_000 +const MAX_RESPONSE = 256 * 1024 +const LOCK_TIMEOUT = 60_000 +const FILE = 'oauth.json' + +export type OAuthCredentials = { + version: 1 + issuer: string + resource: string + apiOrigin: string + clientId: string + accessToken: string + refreshToken?: string | undefined + expiresAt: number + scopes: string[] + tokenEndpoint: string + revocationEndpoint?: string | undefined +} +export type OAuthRequestOptions = { + signal?: AbortSignal | undefined + apiProxy?: string | undefined + requestTimeoutMs?: number | undefined +} +const error = (message: string) => + new Error(`Socket browser sign-in: ${message}`) +const random = (n = 32) => randomBytes(n).toString('base64url') +const form = (data: Record) => + new URLSearchParams(data).toString() + +function trustedEndpoint(value: unknown): string { + if (typeof value !== 'string') { + throw error('OAuth metadata is invalid.') + } + let u: URL + try { + u = new URL(value) + } catch { + throw error('OAuth metadata is invalid.') + } + if ( + u.protocol !== 'https:' || + u.origin !== OAUTH_ISSUER || + u.username || + u.password || + u.search || + u.hash + ) { + throw error('OAuth metadata contains an untrusted endpoint.') + } + return u.href +} +function agent( + proxy?: string, + timeoutMs = REQUEST_TIMEOUT, +): HttpsAgent | undefined { + const ca = getExtraCaCerts() + return proxy + ? (new HttpsProxyAgent({ + proxy, + timeout: timeoutMs, + ...(ca ? { ca, proxyRequestOptions: { ca } } : {}), + }) as unknown as HttpsAgent) + : ca + ? new HttpsAgent({ ca }) + : undefined +} +type HttpResult = { status: number; body: unknown } +export function oauthCancellationError(signal?: AbortSignal): Error { + return signal?.reason instanceof Error + ? signal.reason + : error('Sign-in was cancelled.') +} +function http( + url: string, + opts: { + method?: string | undefined + body?: string | undefined + headers?: Record | undefined + signal?: AbortSignal | undefined + proxy?: string | undefined + allowEmpty?: boolean | undefined + timeoutMs?: number | undefined + } = {}, +): Promise { + const target = new URL(url) + if ( + target.protocol !== 'https:' || + (target.origin !== OAUTH_ISSUER && + target.origin !== new URL(OAUTH_RESOURCE).origin) + ) { + throw error('Refusing to contact an untrusted OAuth endpoint.') + } + return new Promise((resolve, reject) => { + if (opts.signal?.aborted) { + reject(opts.signal.reason ?? error('Sign-in was cancelled.')) + return + } + let done = false + const finish = (fn: () => void) => { + if (!done) { + done = true + clearTimeout(deadline) + opts.signal?.removeEventListener('abort', abort) + requestAgent?.destroy() + fn() + } + } + const timeoutMs = Math.min( + opts.timeoutMs ?? REQUEST_TIMEOUT, + REQUEST_TIMEOUT, + ) + const requestAgent = agent(opts.proxy, timeoutMs) + const req = httpsRequest( + target, + { + method: opts.method ?? 'GET', + timeout: timeoutMs, + ...(requestAgent ? { agent: requestAgent } : {}), + headers: { + accept: 'application/json', + ...opts.headers, + ...(opts.body + ? { 'content-length': String(Buffer.byteLength(opts.body)) } + : {}), + }, + }, + res => { + const chunks: Buffer[] = [] + let size = 0 + res.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_RESPONSE) { + req.destroy(error('OAuth response was too large.')) + return + } + chunks.push(Buffer.from(chunk)) + }) + res.on('error', () => + finish(() => reject(error('OAuth response could not be read.'))), + ) + res.on('aborted', () => + finish(() => reject(error('OAuth response ended unexpectedly.'))), + ) + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8') + if (!text && opts.allowEmpty) { + finish(() => resolve({ status: res.statusCode ?? 0, body: {} })) + return + } + try { + const body = JSON.parse(text) as unknown + finish(() => resolve({ status: res.statusCode ?? 0, body })) + } catch { + finish(() => + reject(error('OAuth server returned an invalid response.')), + ) + } + }) + }, + ) + const abort = () => { + finish(() => reject(error('Sign-in was cancelled.'))) + req.destroy() + } + const deadline = setTimeout(() => { + finish(() => reject(error('OAuth request timed out.'))) + req.destroy() + }, timeoutMs) + opts.signal?.addEventListener('abort', abort, { once: true }) + req.on('error', () => finish(() => reject(error('OAuth request failed.')))) + if (opts.body) { + req.write(opts.body) + } + req.end() + }) +} +async function discover(opts: OAuthRequestOptions) { + const r = await http(RESOURCE_METADATA, { + signal: opts.signal, + proxy: opts.apiProxy, + }) + const rb = r.body + if ( + r.status !== 200 || + !isRecord(rb) || + rb['resource'] !== OAUTH_RESOURCE || + !Array.isArray(rb['authorization_servers']) || + !rb['authorization_servers'].some( + (server: unknown) => server === OAUTH_ISSUER, + ) || + !Array.isArray(rb['scopes_supported']) || + rb['scopes_supported'].length !== REQUIRED_SCOPES.length || + REQUIRED_SCOPES.some( + s => !(rb['scopes_supported'] as unknown[]).includes(s), + ) + ) { + throw error('Socket OAuth resource metadata could not be verified.') + } + const ir = await http(ISSUER_METADATA, { + signal: opts.signal, + proxy: opts.apiProxy, + }) + const m = ir.body + if ( + ir.status !== 200 || + !isRecord(m) || + m['issuer'] !== OAUTH_ISSUER || + !Array.isArray(m['grant_types_supported']) || + !m['grant_types_supported'].includes('authorization_code') || + !m['grant_types_supported'].includes('refresh_token') || + !Array.isArray(m['code_challenge_methods_supported']) || + !m['code_challenge_methods_supported'].includes('S256') || + !Array.isArray(m['token_endpoint_auth_methods_supported']) || + !m['token_endpoint_auth_methods_supported'].includes('none') + ) { + throw error('Socket OAuth issuer metadata is invalid.') + } + return { + authorize: trustedEndpoint(m['authorization_endpoint']), + register: trustedEndpoint(m['registration_endpoint']), + token: trustedEndpoint(m['token_endpoint']), + ...(m['revocation_endpoint'] + ? { revoke: trustedEndpoint(m['revocation_endpoint']) } + : {}), + } +} +function parseToken( + r: HttpResult, + fallback: string[], + meta: { + clientId: string + tokenEndpoint: string + revocationEndpoint?: string + }, +): OAuthCredentials { + const b = r.body + if ( + r.status < 200 || + r.status >= 300 || + !isRecord(b) || + typeof b['access_token'] !== 'string' || + !b['access_token'].trim() || + typeof b['token_type'] !== 'string' || + b['token_type'].toLowerCase() !== 'bearer' || + typeof b['expires_in'] !== 'number' || + !Number.isFinite(b['expires_in']) || + b['expires_in'] <= 0 || + !Number.isSafeInteger(Date.now() + b['expires_in'] * 1000) || + (b['refresh_token'] !== undefined && + (typeof b['refresh_token'] !== 'string' || !b['refresh_token'])) + ) { + const code = isRecord(b) && b['error'] === 'invalid_grant' + throw error( + code + ? 'The saved session expired or was revoked. Run `socket login` again.' + : 'Token exchange failed. Run `socket login` again if the problem continues.', + ) + } + if (b['scope'] !== undefined && typeof b['scope'] !== 'string') { + throw error('The authorization server returned invalid permissions.') + } + const scopes = + typeof b['scope'] === 'string' + ? b['scope'].split(/\s+/).filter(Boolean) + : fallback + if (scopes.some(s => !REQUIRED_SCOPES.includes(s))) { + throw error('The authorization server returned unsupported permissions.') + } + if (REQUIRED_SCOPES.some(scope => !scopes.includes(scope))) { + throw error( + 'The authorization server did not grant the required read-only permissions.', + ) + } + return { + version: 1, + issuer: OAUTH_ISSUER, + resource: OAUTH_RESOURCE, + apiOrigin: OAUTH_ISSUER, + clientId: meta.clientId, + accessToken: b['access_token'], + ...(typeof b['refresh_token'] === 'string' + ? { refreshToken: b['refresh_token'] } + : {}), + expiresAt: Date.now() + b['expires_in'] * 1000, + scopes, + tokenEndpoint: meta.tokenEndpoint, + ...(meta.revocationEndpoint + ? { revocationEndpoint: meta.revocationEndpoint } + : {}), + } +} +function same(a: string, b: string) { + const x = Buffer.from(a) + const y = Buffer.from(b) + return x.length === y.length && timingSafeEqual(x, y) +} + +export async function beginOAuthLogin( + options: OAuthRequestOptions & { + openBrowser?: boolean + onAuthorizationUrl: (url: string) => void + }, +): Promise { + const controller = new AbortController() + const abort = () => controller.abort(options.signal?.reason) + if (options.signal?.aborted) { + abort() + } + options.signal?.addEventListener('abort', abort, { once: true }) + const timeout = setTimeout( + () => + controller.abort( + error('Sign-in timed out. Run `socket login` to try again.'), + ), + FLOW_TIMEOUT, + ) + const signal = controller.signal + let server: ReturnType | undefined + try { + const meta = await discover({ signal, apiProxy: options.apiProxy }) + const state = random() + const verifier = random(48) + const challenge = createHash('sha256').update(verifier).digest('base64url') + let resolveCode!: (code: string) => void + let rejectCode!: (reason: Error) => void + let settled = false + const codePromise = new Promise((resolve, reject) => { + resolveCode = resolve + rejectCode = reject + }) + // Attach a rejection observer immediately: an OAuth error callback can arrive while DCR is pending. + void codePromise.catch(() => {}) + server = createServer((req: IncomingMessage, res: ServerResponse) => { + let u: URL + try { + u = new URL(req.url ?? '/', 'http://127.0.0.1') + } catch { + res.writeHead(400).end('Invalid callback request.') + return + } + if (req.method !== 'GET' || u.pathname !== '/oauth/callback') { + res.writeHead(404).end() + return + } + if (settled) { + res.writeHead(410).end() + return + } + if (!same(u.searchParams.get('state') ?? '', state)) { + res.writeHead(400).end('Sign-in state did not match.') + return + } + if (u.searchParams.has('error') || !u.searchParams.get('code')) { + settled = true + res.writeHead(400).end('Sign-in was not completed.') + rejectCode(error('Authorization was declined.')) + return + } + settled = true + res + .writeHead(200, { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + }) + .end( + 'Socket CLI

Sign-in complete. Return to your terminal.

', + ) + resolveCode(u.searchParams.get('code')!) + }) + signal.addEventListener( + 'abort', + () => { + if (!settled) { + settled = true + rejectCode(oauthCancellationError(signal)) + } + }, + { once: true }, + ) + if (signal.aborted && !settled) { + settled = true + rejectCode(oauthCancellationError(signal)) + } + const redirectUri = await new Promise((resolve, reject) => { + server!.once('error', () => + reject(error('Could not start the local callback listener.')), + ) + server!.listen(0, '127.0.0.1', () => { + const a = server!.address() + if (!a || typeof a === 'string') { + reject(error('Could not start the local callback listener.')) + } else { + resolve(`http://127.0.0.1:${a.port}/oauth/callback`) + } + }) + }) + const registration = await http(meta.register, { + method: 'POST', + signal, + proxy: options.apiProxy, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Socket CLI', + redirect_uris: [redirectUri], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }), + }) + const reg = registration.body + if ( + registration.status < 200 || + registration.status >= 300 || + !isRecord(reg) || + typeof reg['client_id'] !== 'string' || + !reg['client_id'].trim() || + (reg['token_endpoint_auth_method'] !== undefined && + reg['token_endpoint_auth_method'] !== 'none') || + (reg['redirect_uris'] !== undefined && + (!Array.isArray(reg['redirect_uris']) || + !reg['redirect_uris'].some(value => value === redirectUri))) || + (reg['grant_types'] !== undefined && + (!Array.isArray(reg['grant_types']) || + !['authorization_code', 'refresh_token'].every(grant => + (reg['grant_types'] as unknown[]).some(value => value === grant), + ))) || + (reg['response_types'] !== undefined && + (!Array.isArray(reg['response_types']) || + !(reg['response_types'] as unknown[]).some( + value => value === 'code', + ))) + ) { + throw error('The OAuth client could not be registered.') + } + const clientId = reg['client_id'] + const url = new URL(meta.authorize) + url.search = form({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: REQUIRED_SCOPES.join(' '), + state, + code_challenge: challenge, + code_challenge_method: 'S256', + resource: OAUTH_RESOURCE, + }) + options.onAuthorizationUrl(url.href) + if (options.openBrowser !== false) { + try { + await open(url.href) + } catch { + /* URL was printed; manual browser completion remains available. */ + } + } + const code = await codePromise + const token = await http(meta.token, { + method: 'POST', + signal, + proxy: options.apiProxy, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form({ + grant_type: 'authorization_code', + code, + client_id: clientId, + redirect_uri: redirectUri, + code_verifier: verifier, + resource: OAUTH_RESOURCE, + }), + }) + return parseToken(token, REQUIRED_SCOPES, { + clientId, + tokenEndpoint: meta.token, + ...(meta.revoke ? { revocationEndpoint: meta.revoke } : {}), + }) + } finally { + clearTimeout(timeout) + options.signal?.removeEventListener('abort', abort) + server?.close() + server?.closeAllConnections() + } +} + +const filePath = (dir: string) => path.join(dir, FILE) +function validCredentials(value: unknown): value is OAuthCredentials { + if ( + !isRecord(value) || + value['version'] !== 1 || + value['issuer'] !== OAUTH_ISSUER || + value['resource'] !== OAUTH_RESOURCE || + value['apiOrigin'] !== OAUTH_ISSUER || + typeof value['clientId'] !== 'string' || + !value['clientId'] || + typeof value['accessToken'] !== 'string' || + !value['accessToken'] || + typeof value['expiresAt'] !== 'number' || + !Number.isFinite(value['expiresAt']) || + value['expiresAt'] <= 0 || + !Array.isArray(value['scopes']) || + value['scopes'].some( + (s: unknown) => typeof s !== 'string' || !REQUIRED_SCOPES.includes(s), + ) || + (value['refreshToken'] !== undefined && + (typeof value['refreshToken'] !== 'string' || !value['refreshToken'])) + ) { + return false + } + try { + trustedEndpoint(value['tokenEndpoint']) + if (value['revocationEndpoint'] !== undefined) { + trustedEndpoint(value['revocationEndpoint']) + } + return true + } catch { + return false + } +} +export function readOAuthCredentials( + directory: string, +): OAuthCredentials | undefined { + try { + const x: unknown = JSON.parse(readFileSync(filePath(directory), 'utf8')) + if (!validCredentials(x)) { + throw error( + 'Saved OAuth credentials are invalid. Run `socket login` again.', + ) + } + return x + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return undefined + } + if (e instanceof SyntaxError) { + throw error( + 'Saved OAuth credentials are corrupt. Run `socket login` again.', + ) + } + throw e + } +} +async function locked( + directory: string, + action: () => Promise, + signal?: AbortSignal | undefined, +): Promise { + if (signal?.aborted) { + throw oauthCancellationError(signal) + } + await fs.mkdir(directory, { recursive: true, mode: 0o700 }) + const lock = `${filePath(directory)}.lock` + const until = Date.now() + LOCK_TIMEOUT + while (true) { + try { + // This await performs the inter-process mutex acquisition. + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(lock, { mode: 0o700 }) + break + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') { + throw e + } + if (Date.now() >= until) { + throw error( + `Could not acquire sign-in lock ${lock}. After confirming no other Socket CLI process is running, remove this lock directory and retry.`, + ) + } + if (signal?.aborted) { + throw oauthCancellationError(signal) + } + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, 100) + const onAbort = () => { + clearTimeout(timer) + reject(oauthCancellationError(signal)) + } + signal?.addEventListener('abort', onAbort, { once: true }) + if (signal?.aborted) { + onAbort() + } + }) + } + } + try { + return await action() + } finally { + await fs.rm(lock, { recursive: true, force: true }) + } +} +async function writeAtomic(directory: string, credentials: OAuthCredentials) { + await fs.chmod(directory, 0o700).catch(() => {}) + const target = filePath(directory) + const tmp = `${target}.${process.pid}.${random(6)}.tmp` + try { + await fs.writeFile(tmp, `${JSON.stringify(credentials, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }) + await fs.chmod(tmp, 0o600) + await fs.rename(tmp, target) + } finally { + await fs.rm(tmp, { force: true }).catch(() => {}) + } +} +export async function saveOAuthCredentials( + directory: string, + credentials: OAuthCredentials, +): Promise { + if (!validCredentials(credentials)) { + throw error('Refusing to save invalid OAuth credentials.') + } + await locked(directory, () => writeAtomic(directory, credentials)) +} +export async function resolveOAuthAccessToken( + directory: string, + options: OAuthRequestOptions = {}, +): Promise { + if (options.signal?.aborted) { + throw oauthCancellationError(options.signal) + } + const current = readOAuthCredentials(directory) + if (!current) { + return undefined + } + if (current.expiresAt > Date.now() + 30_000) { + return current.accessToken + } + return await locked( + directory, + async () => { + if (options.signal?.aborted) { + throw oauthCancellationError(options.signal) + } + const latest = readOAuthCredentials(directory) + if (!latest) { + throw error('The saved session was removed. Run `socket login` again.') + } + if (latest.expiresAt > Date.now() + 30_000) { + return latest.accessToken + } + if (!latest.refreshToken) { + throw error('The saved session expired. Run `socket login` again.') + } + if (options.signal?.aborted) { + throw oauthCancellationError(options.signal) + } + // Durably discard the rotating token before sending it. A timeout/crash can + // never cause another process to replay a token the server may have used. + const refreshToken = latest.refreshToken + await writeAtomic(directory, { ...latest, refreshToken: undefined }) + const result = await http(latest.tokenEndpoint, { + method: 'POST', + signal: options.signal, + proxy: options.apiProxy, + timeoutMs: options.requestTimeoutMs, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form({ + grant_type: 'refresh_token', + client_id: latest.clientId, + refresh_token: refreshToken, + resource: OAUTH_RESOURCE, + }), + }) + const fresh = parseToken(result, latest.scopes, { + clientId: latest.clientId, + tokenEndpoint: latest.tokenEndpoint, + ...(latest.revocationEndpoint + ? { revocationEndpoint: latest.revocationEndpoint } + : {}), + }) + await writeAtomic(directory, fresh) + return fresh.accessToken + }, + options.signal, + ) +} +export async function logoutOAuth( + directory: string, + options: OAuthRequestOptions = {}, +): Promise<{ revoked: boolean; warning?: string }> { + return await locked(directory, async () => { + let creds: OAuthCredentials | undefined + let warning: string | undefined + try { + creds = readOAuthCredentials(directory) + } catch { + warning = + 'Saved credentials were invalid; local sign-in data was removed.' + } + let revoked = false + if (creds) { + if (!creds.revocationEndpoint) { + warning = 'Remote session revocation was not confirmed.' + } else { + try { + const token = creds.refreshToken ?? creds.accessToken + const hint = creds.refreshToken ? 'refresh_token' : 'access_token' + const r = await http(creds.revocationEndpoint, { + method: 'POST', + signal: options.signal, + proxy: options.apiProxy, + allowEmpty: true, + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: form({ + client_id: creds.clientId, + token, + token_type_hint: hint, + }), + }) + revoked = r.status >= 200 && r.status < 300 + if (!revoked) { + warning = 'Remote session revocation was not confirmed.' + } + } catch { + warning = 'Remote session revocation was not confirmed.' + } + } + } + await fs.rm(filePath(directory), { force: true }) + return { revoked, ...(warning ? { warning } : {}) } + }) +} diff --git a/src/utils/oauth.test.mts b/src/utils/oauth.test.mts new file mode 100644 index 0000000000..e5ee320182 --- /dev/null +++ b/src/utils/oauth.test.mts @@ -0,0 +1,477 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { createConnection } from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('open', () => ({ + default: vi.fn().mockRejectedValue(new Error('browser unavailable')), +})) + +import { + OAUTH_ISSUER, + OAUTH_RESOURCE, + beginOAuthLogin, + logoutOAuth, + oauthCancellationError, + readOAuthCredentials, + resolveOAuthAccessToken, + saveOAuthCredentials, +} from './oauth.mts' + +import type { OAuthCredentials } from './oauth.mts' + +const requiredScopes = ['packages:list', 'alerts:list', 'threat-feed:list'] +const credentials = (expiresAt = Date.now() + 60_000): OAuthCredentials => ({ + version: 1, + issuer: OAUTH_ISSUER, + resource: OAUTH_RESOURCE, + apiOrigin: OAUTH_ISSUER, + clientId: 'public-client', + accessToken: 'access-one', + refreshToken: 'refresh-one', + expiresAt, + scopes: requiredScopes, + tokenEndpoint: `${OAUTH_ISSUER}/v1/oauth2/token`, + revocationEndpoint: `${OAUTH_ISSUER}/v1/oauth2/revoke`, +}) +function mockDiscoveryAndRegistration() { + nock('https://mcp.socket.dev') + .get('/.well-known/oauth-protected-resource') + .reply(200, { + resource: OAUTH_RESOURCE, + authorization_servers: [OAUTH_ISSUER], + scopes_supported: requiredScopes, + }) + nock(OAUTH_ISSUER) + .get('/.well-known/oauth-authorization-server') + .reply(200, { + issuer: OAUTH_ISSUER, + authorization_endpoint: `${OAUTH_ISSUER}/v1/oauth2/authorize`, + token_endpoint: `${OAUTH_ISSUER}/v1/oauth2/token`, + registration_endpoint: `${OAUTH_ISSUER}/v1/oauth2/register`, + revocation_endpoint: `${OAUTH_ISSUER}/v1/oauth2/revoke`, + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }) + nock(OAUTH_ISSUER).post('/v1/oauth2/register').reply(201, { + client_id: 'test-client', + token_endpoint_auth_method: 'none', + }) +} +function sendMalformedCallback(redirectUri: string): Promise { + const redirect = new URL(redirectUri) + return new Promise((resolve, reject) => { + const socket = createConnection(Number(redirect.port), '127.0.0.1') + let response = '' + socket.on('connect', () => { + socket.write( + 'GET http://[ HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n', + ) + }) + socket.on('data', chunk => { + response += chunk.toString('utf8') + }) + socket.on('end', () => + resolve(Number(response.match(/^HTTP\/1\.1 (\d+)/)?.[1])), + ) + socket.on('error', reject) + }) +} +let dir: string +afterEach(async () => { + nock.cleanAll() + vi.restoreAllMocks() + if (dir) { + await rm(dir, { recursive: true, force: true }) + } +}) + +describe('OAuth credential storage and rotation', () => { + it('registers a loopback redirect and exchanges an authorization code with PKCE', async () => { + const resource = nock('https://mcp.socket.dev') + .get('/.well-known/oauth-protected-resource') + .reply(200, { + resource: OAUTH_RESOURCE, + authorization_servers: [OAUTH_ISSUER], + scopes_supported: requiredScopes, + }) + const issuer = nock(OAUTH_ISSUER) + .get('/.well-known/oauth-authorization-server') + .reply(200, { + issuer: OAUTH_ISSUER, + authorization_endpoint: `${OAUTH_ISSUER}/v1/oauth2/authorize`, + token_endpoint: `${OAUTH_ISSUER}/v1/oauth2/token`, + registration_endpoint: `${OAUTH_ISSUER}/v1/oauth2/register`, + revocation_endpoint: `${OAUTH_ISSUER}/v1/oauth2/revoke`, + grant_types_supported: ['authorization_code', 'refresh_token'], + code_challenge_methods_supported: ['S256'], + token_endpoint_auth_methods_supported: ['none'], + }) + let redirect = '' + let registrationBody = '' + const registration = nock(OAUTH_ISSUER) + .post('/v1/oauth2/register') + .reply(201, (_uri, body) => { + registrationBody = JSON.stringify(body) + return { client_id: 'test-client' } + }) + let tokenBody = '' + const token = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(200, (_uri, body) => { + tokenBody = String(body) + return { + access_token: 'oauth-access', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'oauth-refresh', + scope: requiredScopes.join(' '), + } + }) + let callbackTask: Promise | undefined + let codeChallenge = '' + const resultPromise = beginOAuthLogin({ + openBrowser: false, + onAuthorizationUrl: url => { + const auth = new URL(url) + redirect = auth.searchParams.get('redirect_uri')! + codeChallenge = auth.searchParams.get('code_challenge')! + expect(auth.searchParams.get('resource')).toBe(OAUTH_RESOURCE) + expect(auth.searchParams.get('scope')).toBe(requiredScopes.join(' ')) + expect(auth.searchParams.get('code_challenge_method')).toBe('S256') + callbackTask = new Promise((resolve, reject) => + setTimeout(() => { + const callback = new URL(redirect) + void (async () => { + expect(await sendMalformedCallback(redirect)).toBe(400) + callback.searchParams.set('state', 'wrong') + callback.searchParams.set('code', 'ignored') + expect((await fetch(callback)).status).toBe(400) + callback.searchParams.set( + 'state', + auth.searchParams.get('state')!, + ) + expect((await fetch(callback)).status).toBe(200) + })().then(resolve, reject) + }, 0), + ) + }, + }) + const result = await resultPromise + await callbackTask + expect(JSON.parse(registrationBody)).toEqual({ + client_name: 'Socket CLI', + redirect_uris: [redirect], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }) + const verifier = new URLSearchParams(tokenBody).get('code_verifier')! + expect(new URLSearchParams(tokenBody).get('resource')).toBe(OAUTH_RESOURCE) + expect(createHash('sha256').update(verifier).digest('base64url')).toBe( + codeChallenge, + ) + expect(result.accessToken).toBe('oauth-access') + expect( + resource.isDone() && + issuer.isDone() && + registration.isDone() && + token.isDone(), + ).toBe(true) + }) + + it('stores credentials atomically with private permissions', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials()) + expect(readOAuthCredentials(dir)?.accessToken).toBe('access-one') + if (process.platform !== 'win32') { + expect((await stat(path.join(dir, 'oauth.json'))).mode & 0o777).toBe( + 0o600, + ) + } + }) + + it.each([null, []])( + 'rejects malformed saved credentials with %s as the top-level value', + async value => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await writeFile(path.join(dir, 'oauth.json'), JSON.stringify(value)) + expect(() => readOAuthCredentials(dir)).toThrow( + 'Saved OAuth credentials are invalid', + ) + }, + ) + + it.each([null, []])( + 'rejects malformed resource metadata with %s as the top-level value', + async body => { + nock('https://mcp.socket.dev') + .get('/.well-known/oauth-protected-resource') + .reply(200, body) + await expect( + beginOAuthLogin({ openBrowser: false, onAuthorizationUrl: () => {} }), + ).rejects.toThrow('resource metadata could not be verified') + }, + ) + + it('uses the supplied Error cancellation reason and prefixes other reasons', () => { + const reason = new Error('stopped by caller') + const controller = new AbortController() + controller.abort(reason) + expect(oauthCancellationError(controller.signal)).toBe(reason) + + const nonErrorController = new AbortController() + nonErrorController.abort('stopped') + expect(oauthCancellationError(nonErrorController.signal).message).toBe( + 'Socket browser sign-in: Sign-in was cancelled.', + ) + }) + + it('rejects issuer strings that only contain the trusted issuer as a prefix', async () => { + nock('https://mcp.socket.dev') + .get('/.well-known/oauth-protected-resource') + .reply(200, { + resource: OAUTH_RESOURCE, + authorization_servers: [`${OAUTH_ISSUER}.evil.example`], + scopes_supported: requiredScopes, + }) + await expect( + beginOAuthLogin({ openBrowser: false, onAuthorizationUrl: () => {} }), + ).rejects.toThrow('resource metadata could not be verified') + }) + + it('rejects authorization denial and closes the loopback listener', async () => { + mockDiscoveryAndRegistration() + let callbackUrl = '' + let state = '' + const flow = beginOAuthLogin({ + openBrowser: false, + onAuthorizationUrl: url => { + const authorization = new URL(url) + state = authorization.searchParams.get('state')! + callbackUrl = authorization.searchParams.get('redirect_uri')! + setTimeout(() => { + const callback = new URL(callbackUrl) + callback.searchParams.set('state', state) + callback.searchParams.set('error', 'access_denied') + void fetch(callback) + }, 0) + }, + }) + await expect(flow).rejects.toThrow('Authorization was declined') + const after = new URL(callbackUrl) + after.searchParams.set('state', state) + after.searchParams.set('code', 'late') + await expect(fetch(after)).rejects.toThrow() + }) + + it('cancels after binding and closes the loopback listener', async () => { + mockDiscoveryAndRegistration() + const controller = new AbortController() + let callbackUrl = '' + const flow = beginOAuthLogin({ + signal: controller.signal, + openBrowser: false, + onAuthorizationUrl: url => { + callbackUrl = new URL(url).searchParams.get('redirect_uri')! + controller.abort(new Error('user cancelled')) + }, + }) + await expect(flow).rejects.toThrow('user cancelled') + const callback = new URL(callbackUrl) + callback.searchParams.set('state', 'late') + callback.searchParams.set('code', 'late') + await expect(fetch(callback)).rejects.toThrow() + }) + + it('accepts manual completion after the browser opener fails', async () => { + mockDiscoveryAndRegistration() + nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(200, { + access_token: 'oauth-access', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'oauth-refresh', + scope: requiredScopes.join(' '), + }) + let callbackTask: Promise | undefined + const result = await beginOAuthLogin({ + onAuthorizationUrl: url => { + const authorization = new URL(url) + const callback = new URL( + authorization.searchParams.get('redirect_uri')!, + ) + callback.searchParams.set( + 'state', + authorization.searchParams.get('state')!, + ) + callback.searchParams.set('code', 'manual-code') + callbackTask = new Promise(resolve => + setTimeout(() => resolve(fetch(callback)), 0), + ) + }, + }) + expect((await callbackTask)?.status).toBe(200) + expect(result.accessToken).toBe('oauth-access') + }) + + it('times out a pending browser flow and closes the callback listener', async () => { + mockDiscoveryAndRegistration() + vi.useFakeTimers() + try { + let callbackUrl = '' + let announce!: () => void + const announced = new Promise(resolve => { + announce = resolve + }) + const flow = beginOAuthLogin({ + openBrowser: false, + onAuthorizationUrl: url => { + callbackUrl = new URL(url).searchParams.get('redirect_uri')! + announce() + }, + }) + await announced + const rejected = expect(flow).rejects.toThrow('Sign-in timed out') + await vi.advanceTimersByTimeAsync(5 * 60_000) + await rejected + const callback = new URL(callbackUrl) + callback.searchParams.set('state', 'late') + callback.searchParams.set('code', 'late') + await expect(fetch(callback)).rejects.toThrow() + } finally { + vi.useRealTimers() + } + }) + + it('coordinates refresh and persists the rotated token before returning', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + let refreshBody = '' + let receivedRefresh!: () => void + const refreshReceived = new Promise(resolve => { + receivedRefresh = resolve + }) + let releaseResponse!: () => void + const responseReleased = new Promise(resolve => { + releaseResponse = resolve + }) + const refresh = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(200, async (_uri, body) => { + refreshBody = String(body) + receivedRefresh() + await responseReleased + return { + access_token: 'access-two', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'refresh-two', + scope: requiredScopes.join(' '), + } + }) + const first = resolveOAuthAccessToken(dir) + await refreshReceived + const second = resolveOAuthAccessToken(dir) + releaseResponse() + const tokens = await Promise.all([first, second]) + expect(tokens).toEqual(['access-two', 'access-two']) + expect(new URLSearchParams(refreshBody).get('resource')).toBe( + OAUTH_RESOURCE, + ) + expect(readOAuthCredentials(dir)?.refreshToken).toBe('refresh-two') + expect(refresh.isDone()).toBe(true) + }) + + it('does not replay a rotating refresh token after an ambiguous failure', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .replyWithError('connection reset') + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'OAuth request failed', + ) + expect(readOAuthCredentials(dir)?.refreshToken).toBeUndefined() + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'session expired', + ) + }) + + it('preserves the refresh token when resolution starts pre-aborted', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + const before = await readFile(path.join(dir, 'oauth.json'), 'utf8') + const controller = new AbortController() + controller.abort(new Error('cancelled before start')) + await expect( + resolveOAuthAccessToken(dir, { signal: controller.signal }), + ).rejects.toThrow('cancelled before start') + expect(await readFile(path.join(dir, 'oauth.json'), 'utf8')).toBe(before) + }) + + it('cancels while waiting for the refresh lock without consuming the refresh token', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + await mkdir(path.join(dir, 'oauth.json.lock')) + const controller = new AbortController() + const pending = resolveOAuthAccessToken(dir, { signal: controller.signal }) + controller.abort(new Error('cancelled while waiting')) + await expect(pending).rejects.toThrow('cancelled while waiting') + expect(readOAuthCredentials(dir)?.refreshToken).toBe('refresh-one') + expect((await stat(path.join(dir, 'oauth.json.lock'))).isDirectory()).toBe( + true, + ) + }) + + it('preserves credentials and the existing lock after logout lock timeout', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials()) + const original = await readFile(path.join(dir, 'oauth.json'), 'utf8') + const lock = path.join(dir, 'oauth.json.lock') + await mkdir(lock) + let clock = 0 + const now = vi.spyOn(Date, 'now').mockImplementation(() => { + clock += 61_000 + return clock + }) + + try { + await expect(logoutOAuth(dir)).rejects.toThrow( + 'After confirming no other Socket CLI process is running, remove this lock directory and retry.', + ) + expect(await readFile(path.join(dir, 'oauth.json'), 'utf8')).toBe( + original, + ) + expect((await stat(lock)).isDirectory()).toBe(true) + } finally { + now.mockRestore() + } + }) + + it('clears corrupt credentials during logout', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await writeFile(path.join(dir, 'oauth.json'), '{bad json') + const result = await logoutOAuth(dir) + expect(result.warning).toContain('invalid') + await expect(readFile(path.join(dir, 'oauth.json'))).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + it('removes local credentials even when remote revocation fails', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials()) + nock(OAUTH_ISSUER).post('/v1/oauth2/revoke').reply(503, '') + const result = await logoutOAuth(dir) + expect(result.revoked).toBe(false) + expect(result.warning).toContain('not confirmed') + expect(readOAuthCredentials(dir)).toBeUndefined() + }) +}) diff --git a/src/utils/sdk.mts b/src/utils/sdk.mts index 06614697c3..97bbd932d6 100644 --- a/src/utils/sdk.mts +++ b/src/utils/sdk.mts @@ -24,27 +24,26 @@ * - Includes CLI version and platform information */ -import { readFileSync } from 'node:fs' import { Agent as HttpAgent } from 'node:http' import { Agent as HttpsAgent } from 'node:https' -import { rootCertificates } from 'node:tls' import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent' -import isInteractive from '@socketregistry/is-interactive/index.cjs' -import { debugFn } from '@socketsecurity/registry/lib/debug' import { logger } from '@socketsecurity/registry/lib/logger' -import { password } from '@socketsecurity/registry/lib/prompts' import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' import { isUrl } from '@socketsecurity/registry/lib/url' import { SocketSdk, createUserAgentFromPkgJson } from '@socketsecurity/sdk' -import { getConfigValueOrUndef } from './config.mts' +import { getExtraCaCerts } from './api-transport.mts' +import { getConfigValueOrUndef, isConfigFromFlag } from './config.mts' import { debugApiRequest, debugApiResponse } from './debug.mts' +import { getErrorCause } from './errors.mts' +import { readOAuthCredentials, resolveOAuthAccessToken } from './oauth.mts' import constants, { CONFIG_KEY_API_BASE_URL, CONFIG_KEY_API_PROXY, CONFIG_KEY_API_TOKEN, + CONFIG_KEY_AUTH_TYPE, } from '../constants.mts' import { trackCliEvent } from './telemetry/integration.mts' @@ -105,68 +104,72 @@ export function getDefaultProxyUrl(): string | undefined { return isUrl(apiProxy) ? apiProxy : undefined } -// Cached extra CA certificates for SSL_CERT_FILE support. -let _extraCaCerts: string[] | undefined -let _extraCaCertsResolved = false - -// Returns combined root and extra CA certificates when SSL_CERT_FILE is set -// but NODE_EXTRA_CA_CERTS is not. Node.js loads NODE_EXTRA_CA_CERTS at process -// startup, so setting SSL_CERT_FILE alone does not affect the current process. -// This function reads the certificate file manually and combines it with the -// default root certificates for use in HTTPS agents. -export function getExtraCaCerts(): string[] | undefined { - if (_extraCaCertsResolved) { - return _extraCaCerts - } - _extraCaCertsResolved = true - // Node.js already loaded extra CA certs at startup. - if (process.env['NODE_EXTRA_CA_CERTS']) { +export { getExtraCaCerts } + +export function getDefaultApiToken(): string | undefined { + if (constants.ENV.SOCKET_CLI_NO_API_TOKEN) { return undefined } - // Check for SSL_CERT_FILE fallback via constants. - const certPath = constants.ENV.NODE_EXTRA_CA_CERTS - if (!certPath) { - return undefined + + const environmentToken = constants.ENV.SOCKET_CLI_API_TOKEN + if (isNonEmptyString(environmentToken)) { + return environmentToken } - try { - const extraCerts = readFileSync(certPath, 'utf-8') - // Combine default root certificates with extra certificates. Specifying ca - // in an agent replaces the default trust store, so both must be included. - _extraCaCerts = [...rootCertificates, extraCerts] - return _extraCaCerts - } catch (e) { - debugFn('warn', `Failed to read certificate file: ${certPath}`, e) + if (getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) === 'oauth') { return undefined } + const key = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) + + return isNonEmptyString(key) ? key : undefined } -// This Socket API token should be stored globally for the duration of the CLI execution. -let _defaultToken: string | undefined +/** Whether saved OAuth auth is active instead of a flag or explicit token. */ +export function isOAuthLoginActive(): boolean { + return ( + !isConfigFromFlag() && + !constants.ENV.SOCKET_CLI_NO_API_TOKEN && + !constants.ENV.SOCKET_CLI_API_TOKEN && + getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) === 'oauth' + ) +} -export function getDefaultApiToken(): string | undefined { - if (constants.ENV.SOCKET_CLI_NO_API_TOKEN) { - _defaultToken = undefined - return _defaultToken +export function getPublicApiToken(): string { + if (isOAuthLoginActive()) { + throw new Error( + 'OAuth credentials must be resolved asynchronously before launching a Socket helper.', + ) } - - const key = - constants.ENV.SOCKET_CLI_API_TOKEN || - getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) || - _defaultToken - - _defaultToken = isNonEmptyString(key) ? key : undefined - return _defaultToken + return getDefaultApiToken() || constants.SOCKET_PUBLIC_API_TOKEN } -export function getPublicApiToken(): string { - return ( - getDefaultApiToken() || - constants.ENV.SOCKET_CLI_API_TOKEN || - constants.SOCKET_PUBLIC_API_TOKEN - ) +export async function resolvePublicApiToken(options?: { + apiBaseUrl?: string | undefined + apiProxy?: string | undefined + signal?: AbortSignal | undefined +}): Promise { + const token = await resolveDefaultApiToken(options) + if (token) { + return token + } + if ( + !constants.ENV.SOCKET_CLI_NO_API_TOKEN && + getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) === 'oauth' + ) { + throw new Error( + 'Browser sign-in has no usable access token. Run `socket login` to sign in again.', + ) + } + return constants.SOCKET_PUBLIC_API_TOKEN } export function getVisibleTokenPrefix(): string { + if ( + !constants.ENV.SOCKET_CLI_NO_API_TOKEN && + getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) === 'oauth' && + !constants.ENV.SOCKET_CLI_API_TOKEN + ) { + return 'oauth' + } const apiToken = getDefaultApiToken() return apiToken ? apiToken.slice( @@ -177,7 +180,77 @@ export function getVisibleTokenPrefix(): string { } export function hasDefaultApiToken(): boolean { - return !!getDefaultApiToken() + if (getDefaultApiToken()) { + return true + } + const directory = constants.socketAppDataPath + return Boolean( + isOAuthLoginActive() && directory && hasOAuthCredentialFile(directory), + ) +} + +function hasOAuthCredentialFile(directory: string): boolean { + try { + return Boolean(readOAuthCredentials(directory)) + } catch { + // Keep preflight from crashing; async token resolution reports a useful + // error and must not fall back to another credential. + return true + } +} + +export async function resolveDefaultApiToken(options?: { + apiBaseUrl?: string | undefined + apiProxy?: string | undefined + signal?: AbortSignal | undefined +}): Promise { + if (constants.ENV.SOCKET_CLI_NO_API_TOKEN) { + return undefined + } + const environmentToken = constants.ENV.SOCKET_CLI_API_TOKEN + if (isNonEmptyString(environmentToken)) { + return environmentToken + } + if (getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE) !== 'oauth') { + const configured = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN) + return isNonEmptyString(configured) ? configured : undefined + } + + // Explicit config is read-only and must never consult persisted OAuth state. + if (isConfigFromFlag()) { + return undefined + } + + const directory = constants.socketAppDataPath + if (!directory) { + throw new Error( + 'OAuth credentials are unavailable in this environment. Run `socket login` in a supported user session.', + ) + } + const credentials = readOAuthCredentials(directory) + if (!credentials) { + throw new Error( + 'Browser sign-in is selected but its credentials are missing or invalid. Run `socket login` to sign in again.', + ) + } + const apiBaseUrl = options?.apiBaseUrl ?? getDefaultApiBaseUrl() + if (apiBaseUrl) { + let origin: string + try { + origin = new URL(apiBaseUrl).origin + } catch { + throw new Error('The configured Socket API URL is invalid for OAuth.') + } + if (origin !== credentials.apiOrigin) { + throw new Error( + 'Browser sign-in can only be used with Socket’s trusted API origin. Use `socket login --api-token` for a custom API URL.', + ) + } + } + return await resolveOAuthAccessToken(directory, { + apiProxy: options?.apiProxy ?? getDefaultProxyUrl(), + signal: options?.signal, + }) } export type SetupSdkOptions = { @@ -190,14 +263,20 @@ export async function setupSdk( options?: SetupSdkOptions | undefined, ): Promise> { const opts = { __proto__: null, ...options } as SetupSdkOptions - let { apiToken = getDefaultApiToken() } = opts - - if (typeof apiToken !== 'string' && isInteractive()) { - apiToken = await password({ - message: - 'Enter your Socket.dev API token (not saved, use socket login to persist)', - }) - _defaultToken = apiToken + let apiToken: string | undefined + try { + apiToken = + opts.apiToken ?? + (await resolveDefaultApiToken({ + apiBaseUrl: opts.apiBaseUrl, + apiProxy: opts.apiProxy, + })) + } catch (error) { + return { + ok: false, + message: 'Auth Error', + cause: getErrorCause(error), + } } if (!apiToken) { diff --git a/src/utils/sdk.test.mts b/src/utils/sdk.test.mts index 69a4ae4579..4cb4fdc1c1 100644 --- a/src/utils/sdk.test.mts +++ b/src/utils/sdk.test.mts @@ -29,6 +29,9 @@ import { clampApiTimeout, getCliUserAgent, getExtraCaCerts, + hasDefaultApiToken, + isOAuthLoginActive, + resolveDefaultApiToken, setupSdk, } from './sdk.mts' @@ -89,9 +92,29 @@ vi.mock('@socketsecurity/registry/lib/debug', () => ({ })) // Mock config. -const mockGetConfigValueOrUndef = vi.hoisted(() => vi.fn(() => undefined)) +const mockGetConfigValueOrUndef = vi.hoisted(() => + vi.fn((_key: string): unknown => undefined), +) +const mockIsConfigFromFlag = vi.hoisted(() => vi.fn(() => false)) vi.mock('./config.mts', () => ({ getConfigValueOrUndef: mockGetConfigValueOrUndef, + isConfigFromFlag: mockIsConfigFromFlag, +})) + +vi.mock('./errors.mts', () => ({ + getErrorCause: (error: unknown) => + error instanceof Error ? error.message : 'Unknown error', +})) + +const mockReadOAuthCredentials = vi.hoisted(() => + vi.fn((): unknown => undefined), +) +const mockResolveOAuthAccessToken = vi.hoisted(() => + vi.fn(async () => 'oauth-access-token'), +) +vi.mock('./oauth.mts', () => ({ + readOAuthCredentials: mockReadOAuthCredentials, + resolveOAuthAccessToken: mockResolveOAuthAccessToken, })) // Mock SocketSdk. @@ -121,18 +144,27 @@ vi.mock('../constants.mts', () => ({ NODE_EXTRA_CA_CERTS: '', SOCKET_CLI_API_TIMEOUT: 30_000, SOCKET_CLI_DEBUG: false, + SOCKET_CLI_NO_API_TOKEN: false, + SOCKET_CLI_API_TOKEN: '', }, + socketAppDataPath: '/tmp/socket-sdk-test', }, CONFIG_KEY_API_BASE_URL: 'apiBaseUrl', CONFIG_KEY_API_PROXY: 'apiProxy', CONFIG_KEY_API_TOKEN: 'apiToken', + CONFIG_KEY_AUTH_TYPE: 'authType', })) describe('SDK setup with telemetry hooks', () => { beforeEach(() => { vi.clearAllMocks() mockGetConfigValueOrUndef.mockReturnValue(undefined) + mockIsConfigFromFlag.mockReturnValue(false) + mockReadOAuthCredentials.mockReturnValue(undefined) + mockResolveOAuthAccessToken.mockResolvedValue('oauth-access-token') constants.ENV.SOCKET_CLI_DEBUG = false + constants.ENV.SOCKET_CLI_NO_API_TOKEN = false + constants.ENV.SOCKET_CLI_API_TOKEN = '' }) describe('setupSdk', () => { @@ -165,6 +197,150 @@ describe('SDK setup with telemetry hooks', () => { } }) + it('uses the active OAuth access token for SDK requests', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockReturnValue({ + apiOrigin: 'https://api.socket.dev', + accessToken: 'stored-access-token', + }) + + const result = await setupSdk() + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.token).toBe('oauth-access-token') + } + expect(mockResolveOAuthAccessToken).toHaveBeenCalledWith( + '/tmp/socket-sdk-test', + expect.objectContaining({ apiProxy: undefined }), + ) + }) + + it('keeps an explicit environment API token ahead of active OAuth', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + constants.ENV.SOCKET_CLI_API_TOKEN = 'explicit-env-token' + + const result = await setupSdk() + + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.data.token).toBe('explicit-env-token') + } + expect(mockResolveOAuthAccessToken).not.toHaveBeenCalled() + }) + + it('does not fall back when an active OAuth session cannot refresh', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockReturnValue({ + apiOrigin: 'https://api.socket.dev', + accessToken: 'stale-token', + }) + mockResolveOAuthAccessToken.mockRejectedValueOnce( + new Error('refresh failed'), + ) + + const result = await setupSdk() + + expect(result).toMatchObject({ + ok: false, + message: 'Auth Error', + cause: 'refresh failed', + }) + expect(MockSocketSdk).not.toHaveBeenCalled() + }) + + it('returns OAuth credential read errors as an auth result', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockImplementationOnce(() => { + throw new Error('saved credentials are corrupt') + }) + + const result = await setupSdk() + + expect(result).toMatchObject({ + ok: false, + message: 'Auth Error', + cause: 'saved credentials are corrupt', + }) + expect(MockSocketSdk).not.toHaveBeenCalled() + }) + + it('recognizes a stored OAuth session for command preflight', () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockReturnValue({ accessToken: 'oauth-token' }) + expect(hasDefaultApiToken()).toBe(true) + }) + + it('classifies OAuth only when config and explicit token overrides are absent', () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + expect(isOAuthLoginActive()).toBe(true) + + mockIsConfigFromFlag.mockReturnValue(true) + expect(isOAuthLoginActive()).toBe(false) + mockIsConfigFromFlag.mockReturnValue(false) + + constants.ENV.SOCKET_CLI_API_TOKEN = 'explicit-env-token' + expect(isOAuthLoginActive()).toBe(false) + constants.ENV.SOCKET_CLI_API_TOKEN = '' + + constants.ENV.SOCKET_CLI_NO_API_TOKEN = true + expect(isOAuthLoginActive()).toBe(false) + }) + + it('does not read persisted OAuth credentials for a config override', async () => { + mockIsConfigFromFlag.mockReturnValue(true) + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + + expect(hasDefaultApiToken()).toBe(false) + await expect(resolveDefaultApiToken()).resolves.toBeUndefined() + expect(mockReadOAuthCredentials).not.toHaveBeenCalled() + expect(mockResolveOAuthAccessToken).not.toHaveBeenCalled() + }) + + it('keeps corrupt OAuth state from crashing preflight or falling back', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockImplementation(() => { + throw new Error('Saved OAuth credentials are corrupt') + }) + + expect(hasDefaultApiToken()).toBe(true) + await expect(resolveDefaultApiToken()).rejects.toThrow( + 'Saved OAuth credentials are corrupt', + ) + expect(mockResolveOAuthAccessToken).not.toHaveBeenCalled() + }) + + it('refuses to send the OAuth access token to a custom API origin', async () => { + mockGetConfigValueOrUndef.mockImplementation((key: string) => + key === 'authType' ? 'oauth' : undefined, + ) + mockReadOAuthCredentials.mockReturnValue({ + apiOrigin: 'https://api.socket.dev', + accessToken: 'oauth-token', + }) + + await expect( + resolveDefaultApiToken({ apiBaseUrl: 'https://example.test/v0/' }), + ).rejects.toThrow(/trusted API origin/) + expect(mockResolveOAuthAccessToken).not.toHaveBeenCalled() + }) + it('should configure hooks for telemetry and debugging', async () => { const result = await setupSdk({ apiToken: 'test-token' }) diff --git a/test/src/commands/logout/attempt-logout.files.test.mts b/test/src/commands/logout/attempt-logout.files.test.mts new file mode 100644 index 0000000000..67b2ea95de --- /dev/null +++ b/test/src/commands/logout/attempt-logout.files.test.mts @@ -0,0 +1,138 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const mockAppDataPath = vi.hoisted(() => ({ value: '' })) +const mockLogger = vi.hoisted(() => ({ + fail: vi.fn(), + log: vi.fn(), + success: vi.fn(), + warn: vi.fn(), +})) +const mockProxyUrl = vi.hoisted(() => vi.fn(() => undefined)) + +vi.mock('../../../../src/constants.mts', async importOriginal => { + const original = + await importOriginal() + return { + ...original, + default: new Proxy(original.default, { + get(target, key, receiver) { + if (key === 'socketAppDataPath') { + return mockAppDataPath.value + } + if (key === 'ENV') { + return { + ...Reflect.get(target, key, receiver), + SOCKET_CLI_API_TOKEN: '', + } + } + return Reflect.get(target, key, receiver) + }, + }), + } +}) +vi.mock('@socketsecurity/registry/lib/logger', () => ({ logger: mockLogger })) +vi.mock('../../../../src/utils/sdk.mts', () => ({ + getDefaultProxyUrl: mockProxyUrl, +})) + +import { attemptLogout } from '../../../../src/commands/logout/attempt-logout.mts' +import { resetConfigForTesting } from '../../../../src/utils/config.mts' + +let appDataPath: string | undefined + +afterEach(() => { + resetConfigForTesting() + if (appDataPath) { + rmSync(appDataPath, { recursive: true, force: true }) + appDataPath = undefined + } + mockAppDataPath.value = '' + vi.clearAllMocks() + nock.cleanAll() +}) + +describe('attemptLogout filesystem integration', () => { + it.each([ + { status: 200, warning: undefined }, + { + status: 503, + warning: + 'Local sign-out completed. Remote session revocation was not confirmed.', + }, + ])( + 'clears local credentials after remote revocation returns $status', + async ({ status, warning }) => { + appDataPath = mkdtempSync(path.join(os.tmpdir(), 'socket-cli-logout-')) + mockAppDataPath.value = appDataPath + + const configPath = path.join(appDataPath, 'config.json') + const oauthPath = path.join(appDataPath, 'oauth.json') + const config = { + apiToken: 'local-test-token', + authType: 'oauth', + apiBaseUrl: 'https://api.socket.dev', + apiProxy: 'http://127.0.0.1:1', + enforcedOrgs: ['local-test-org'], + defaultOrg: 'keep-this-unrelated-setting', + } + writeFileSync( + configPath, + Buffer.from(JSON.stringify(config)).toString('base64'), + ) + writeFileSync( + oauthPath, + JSON.stringify({ + version: 1, + issuer: 'https://api.socket.dev', + resource: 'https://mcp.socket.dev/', + apiOrigin: 'https://api.socket.dev', + clientId: 'local-test-client', + accessToken: 'local-test-access-token', + refreshToken: 'local-test-refresh-token', + expiresAt: Date.now() + 60_000, + scopes: ['packages:list', 'alerts:list', 'threat-feed:list'], + tokenEndpoint: 'https://api.socket.dev/oauth/token', + revocationEndpoint: 'https://api.socket.dev/oauth/revoke', + }), + ) + + const revocation = nock('https://api.socket.dev') + .post('/oauth/revoke') + .reply(status) + + await attemptLogout() + await new Promise(resolve => setImmediate(resolve)) + + const persistedConfig = JSON.parse( + Buffer.from(readFileSync(configPath, 'utf8'), 'base64').toString(), + ) as Record + expect(persistedConfig).toEqual({ + apiToken: null, + authType: null, + apiBaseUrl: null, + apiProxy: null, + enforcedOrgs: null, + defaultOrg: 'keep-this-unrelated-setting', + }) + expect(() => readFileSync(oauthPath)).toThrow( + expect.objectContaining({ code: 'ENOENT' }), + ) + expect(() => readFileSync(`${oauthPath}.lock`)).toThrow( + expect.objectContaining({ code: 'ENOENT' }), + ) + expect(revocation.isDone()).toBe(true) + expect(mockLogger.success).toHaveBeenCalledWith('Successfully logged out') + if (warning) { + expect(mockLogger.warn).toHaveBeenCalledWith(warning) + } else { + expect(mockLogger.warn).not.toHaveBeenCalled() + } + expect(mockLogger.fail).not.toHaveBeenCalled() + }, + ) +}) diff --git a/test/src/utils/oauth-storage-failures.test.mts b/test/src/utils/oauth-storage-failures.test.mts new file mode 100644 index 0000000000..977d160d90 --- /dev/null +++ b/test/src/utils/oauth-storage-failures.test.mts @@ -0,0 +1,165 @@ +import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' + +import nock from 'nock' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const fsFault = vi.hoisted(() => ({ + failRenameAt: 0, + renameCalls: 0, +})) +vi.mock('node:fs', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + promises: { + ...actual.promises, + rename: async (...args: Parameters) => { + fsFault.renameCalls += 1 + if (fsFault.failRenameAt === fsFault.renameCalls) { + throw new Error('injected rename failure') + } + return await actual.promises.rename(...args) + }, + }, + } +}) + +import { + OAUTH_ISSUER, + OAUTH_RESOURCE, + readOAuthCredentials, + resolveOAuthAccessToken, + saveOAuthCredentials, +} from '../../../src/utils/oauth.mts' + +import type { OAuthCredentials } from '../../../src/utils/oauth.mts' + +const requiredScopes = ['packages:list', 'alerts:list', 'threat-feed:list'] +const credentials = (expiresAt = Date.now() + 60_000): OAuthCredentials => ({ + version: 1, + issuer: OAUTH_ISSUER, + resource: OAUTH_RESOURCE, + apiOrigin: OAUTH_ISSUER, + clientId: 'public-client', + accessToken: 'access-one', + refreshToken: 'refresh-one', + expiresAt, + scopes: requiredScopes, + tokenEndpoint: `${OAUTH_ISSUER}/v1/oauth2/token`, + revocationEndpoint: `${OAUTH_ISSUER}/v1/oauth2/revoke`, +}) + +let dir: string +afterEach(async () => { + nock.cleanAll() + fsFault.failRenameAt = 0 + fsFault.renameCalls = 0 + if (dir) { + await rm(dir, { recursive: true, force: true }) + } +}) + +describe('OAuth credential failure safety', () => { + it('does not restore a consumed refresh token after a request timeout', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + const refresh = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .delay(500) + .reply(200, { + access_token: 'access-two', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'refresh-two', + scope: requiredScopes.join(' '), + }) + await expect( + resolveOAuthAccessToken(dir, { requestTimeoutMs: 100 }), + ).rejects.toThrow('timed out') + expect(readOAuthCredentials(dir)?.refreshToken).toBeUndefined() + expect(refresh.isDone()).toBe(true) + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'session expired', + ) + }) + + it('does not replay a rotating refresh token after invalid_grant', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + const refresh = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(400, { error: 'invalid_grant' }) + + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'expired or was revoked', + ) + expect(readOAuthCredentials(dir)?.refreshToken).toBeUndefined() + expect(refresh.isDone()).toBe(true) + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'session expired', + ) + }) + + it('preserves credentials and releases the lock when discarding the token fails', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + const file = path.join(dir, 'oauth.json') + const original = await readFile(file, 'utf8') + const unusedRefresh = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(200, {}) + fsFault.renameCalls = 0 + fsFault.failRenameAt = 1 + + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'injected rename failure', + ) + expect(await readFile(file, 'utf8')).toBe(original) + await expect(stat(`${file}.lock`)).rejects.toMatchObject({ code: 'ENOENT' }) + expect(unusedRefresh.isDone()).toBe(false) + }) + + it('does not restore a consumed token when saving the refresh response fails', async () => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await saveOAuthCredentials(dir, credentials(Date.now() - 1)) + const refresh = nock(OAUTH_ISSUER) + .post('/v1/oauth2/token') + .reply(200, { + access_token: 'access-two', + token_type: 'Bearer', + expires_in: 900, + refresh_token: 'refresh-two', + scope: requiredScopes.join(' '), + }) + fsFault.renameCalls = 0 + fsFault.failRenameAt = 2 + + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'injected rename failure', + ) + expect(readOAuthCredentials(dir)?.refreshToken).toBeUndefined() + await expect(stat(path.join(dir, 'oauth.json.lock'))).rejects.toMatchObject( + { code: 'ENOENT' }, + ) + expect(refresh.isDone()).toBe(true) + fsFault.failRenameAt = 0 + await expect(resolveOAuthAccessToken(dir)).rejects.toThrow( + 'session expired', + ) + }) + + it.each([ + `${OAUTH_ISSUER}.evil.example/v1/oauth2/token`, + 'https://api.socket.dev:444/v1/oauth2/token', + 'https://user:pass@api.socket.dev/v1/oauth2/token', + 'http://api.socket.dev/v1/oauth2/token', + ])('rejects an untrusted credential endpoint: %s', async tokenEndpoint => { + dir = await mkdtemp(path.join(tmpdir(), 'socket-oauth-')) + await expect( + saveOAuthCredentials(dir, { ...credentials(), tokenEndpoint }), + ).rejects.toThrow('Refusing to save invalid OAuth credentials') + expect(readOAuthCredentials(dir)).toBeUndefined() + }) +})