Skip to content

Commit 05da975

Browse files
committed
feat(login): support MCP-compatible browser OAuth
1 parent ff46ee1 commit 05da975

36 files changed

Lines changed: 2833 additions & 113 deletions

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ socket patch setup --yes
5757

5858
Free patches work without authentication. For paid patches, set `SOCKET_CLI_API_TOKEN` and `SOCKET_CLI_ORG_SLUG`.
5959

60+
`socket login` uses browser OAuth for read-only package, alert, and threat-feed
61+
access. Open the printed authorization URL manually if a browser does not open.
62+
Use `socket login --api-token` or `SOCKET_CLI_API_TOKEN` for scans and write
63+
commands.
64+
6065
### Aliases
6166

6267
All aliases support the flags and arguments of the commands they alias.

‎eslint.config.js‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,8 @@ module.exports = [
223223
// Allow paths like src/commands/manifest/bazel/*.test.mts.
224224
'src/*/*/*/*.test.mts',
225225
'test/*.mts',
226+
'test/src/utils/*.test.mts',
227+
'test/src/commands/logout/*.test.mts',
226228
// Allow loose one-off scripts.
227229
'scripts/*.mts',
228230
],

‎scripts/ci/setup-node.mjs‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
21
import { execFile as execFileCallback } from 'node:child_process'
32
import { createHash } from 'node:crypto'
43
import { constants } from 'node:fs'

‎src/commands/cli.test.mts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ describe('socket root command', async () => {
2828
Note: All commands have their own --help
2929
3030
Main commands
31-
socket login Setup Socket CLI with an API token and defaults
31+
socket login Sign in to Socket and configure the CLI
3232
socket scan create Create a new Socket scan and report
3333
socket npm/lodash@4.17.21 Request the Socket score of a package
3434
socket fix Fix CVEs in dependencies

‎src/commands/config/cmd-config-set.test.mts‎

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
1+
import {
2+
mkdirSync,
3+
mkdtempSync,
4+
readFileSync,
5+
rmSync,
6+
writeFileSync,
7+
} from 'node:fs'
28
import os from 'node:os'
39
import path from 'node:path'
410

@@ -164,4 +170,38 @@ describe('socket config get', async () => {
164170
}
165171
},
166172
)
173+
174+
cmdit(
175+
['config', 'set', 'apiToken', 'skt_faketoken'],
176+
'should activate API-token auth when replacing a saved OAuth login',
177+
async cmd => {
178+
const dataHome = mkdtempSync(path.join(os.tmpdir(), 'socket-cfg-'))
179+
const configPath = path.join(
180+
dataHome,
181+
'socket',
182+
'settings',
183+
'config.json',
184+
)
185+
mkdirSync(path.dirname(configPath), { recursive: true })
186+
writeFileSync(
187+
configPath,
188+
Buffer.from(JSON.stringify({ authType: 'oauth' })).toString('base64'),
189+
)
190+
191+
try {
192+
const { code, stdout } = await spawnSocketCli(binCliPath, cmd, {
193+
env: { XDG_DATA_HOME: dataHome },
194+
})
195+
expect(code, 'a persistable set should exit 0').toBe(0)
196+
expect(stdout).toContain('OK')
197+
198+
const raw = readFileSync(configPath, 'utf8')
199+
const saved = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'))
200+
expect(saved.apiToken).toBe('skt_faketoken')
201+
expect(saved.authType).toBe('api-token')
202+
} finally {
203+
rmSync(dataHome, { recursive: true, force: true })
204+
}
205+
},
206+
)
167207
})

‎src/commands/config/handle-config-set.mts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
22

33
import { outputConfigSet } from './output-config-set.mts'
4+
import { CONFIG_KEY_API_TOKEN, CONFIG_KEY_AUTH_TYPE } from '../../constants.mts'
45
import { updateConfigValue } from '../../utils/config.mts'
56

67
import type { CResult, OutputKind } from '../../types.mts'
@@ -19,6 +20,11 @@ export async function handleConfigSet({
1920
debugDir('inspect', { key, value, outputKind })
2021

2122
const result = updateConfigValue(key, value)
23+
if (result.ok && !result.data && key === CONFIG_KEY_API_TOKEN) {
24+
// An explicit API-token update must take precedence over a saved OAuth
25+
// session, which otherwise remains the active credential source.
26+
updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'api-token')
27+
}
2228

2329
// `config set` is a one-shot command: an in-memory-only change is a no-op
2430
// because the process exits before anything reads it. updateConfigValue only

‎src/commands/login/apply-login.mts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
CONFIG_KEY_API_BASE_URL,
33
CONFIG_KEY_API_PROXY,
44
CONFIG_KEY_API_TOKEN,
5+
CONFIG_KEY_AUTH_TYPE,
56
CONFIG_KEY_ENFORCED_ORGS,
67
} from '../../constants.mts'
78
import { updateConfigValue } from '../../utils/config.mts'
@@ -14,6 +15,7 @@ export function applyLogin(
1415
) {
1516
updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, enforcedOrgs)
1617
updateConfigValue(CONFIG_KEY_API_TOKEN, apiToken)
18+
updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'api-token')
1719
updateConfigValue(CONFIG_KEY_API_BASE_URL, apiBaseUrl)
1820
updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy)
1921
}

‎src/commands/login/attempt-login.mts‎

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import constants, {
77
CONFIG_KEY_API_BASE_URL,
88
CONFIG_KEY_API_PROXY,
99
CONFIG_KEY_API_TOKEN,
10+
CONFIG_KEY_AUTH_TYPE,
1011
CONFIG_KEY_DEFAULT_ORG,
1112
} from '../../constants.mts'
1213
import {
@@ -15,8 +16,18 @@ import {
1516
updateConfigValue,
1617
} from '../../utils/config.mts'
1718
import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts'
19+
import {
20+
OAUTH_ISSUER,
21+
beginOAuthLogin,
22+
logoutOAuth,
23+
saveOAuthCredentials,
24+
} from '../../utils/oauth.mts'
1825
import { getEnterpriseOrgs, getOrgSlugs } from '../../utils/organization.mts'
19-
import { setupSdk } from '../../utils/sdk.mts'
26+
import {
27+
getDefaultApiBaseUrl,
28+
getDefaultProxyUrl,
29+
setupSdk,
30+
} from '../../utils/sdk.mts'
2031
import { socketDocsLink } from '../../utils/terminal-link.mts'
2132
import { setupTabCompletion } from '../install/setup-tab-completion.mts'
2233
import { fetchOrganization } from '../organization/fetch-organization-list.mts'
@@ -29,9 +40,21 @@ type OrgChoices = Array<Separator | OrgChoice>
2940
export async function attemptLogin(
3041
apiBaseUrl: string | undefined,
3142
apiProxy: string | undefined,
43+
apiTokenMode = false,
44+
) {
45+
if (!apiTokenMode) {
46+
return await attemptOAuthLogin(apiBaseUrl, apiProxy)
47+
}
48+
49+
return await attemptApiTokenLogin(apiBaseUrl, apiProxy)
50+
}
51+
52+
async function attemptApiTokenLogin(
53+
apiBaseUrl: string | undefined,
54+
apiProxy: string | undefined,
3255
) {
3356
apiBaseUrl ??= getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) ?? undefined
34-
apiProxy ??= getConfigValueOrUndef(CONFIG_KEY_API_PROXY) ?? undefined
57+
apiProxy ||= getDefaultProxyUrl()
3558
const apiTokenInput = await password({
3659
message: `Enter your ${socketDocsLink('/docs/api-keys', 'Socket.dev API token')} (leave blank to use a limited public token)`,
3760
})
@@ -154,6 +177,18 @@ export async function attemptLogin(
154177
const previousPersistedToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN)
155178
try {
156179
applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy)
180+
if (constants.socketAppDataPath && !isConfigFromFlag()) {
181+
try {
182+
const result = await logoutOAuth(constants.socketAppDataPath, {
183+
apiProxy,
184+
})
185+
if (result.warning) {
186+
logger.warn(result.warning)
187+
}
188+
} catch {
189+
logger.warn('The previous browser sign-in could not be removed.')
190+
}
191+
}
157192
logger.success(
158193
`API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`,
159194
)
@@ -165,6 +200,109 @@ export async function attemptLogin(
165200
}
166201
} catch {
167202
process.exitCode = 1
168-
logger.fail(`API login failed`)
203+
logger.fail('API login failed')
204+
}
205+
}
206+
207+
async function attemptOAuthLogin(
208+
apiBaseUrl: string | undefined,
209+
apiProxy: string | undefined,
210+
) {
211+
if (isConfigFromFlag()) {
212+
throw new Error(
213+
'Browser sign-in cannot be saved while --config or SOCKET_CLI_CONFIG is active. Remove the config override and run `socket login` again.',
214+
)
215+
}
216+
const configuredBaseUrl = apiBaseUrl || getDefaultApiBaseUrl()
217+
if (configuredBaseUrl) {
218+
let configuredOrigin: string
219+
try {
220+
configuredOrigin = new URL(configuredBaseUrl).origin
221+
} catch {
222+
throw new Error('The configured Socket API URL is invalid for OAuth.')
223+
}
224+
if (configuredOrigin !== OAUTH_ISSUER) {
225+
throw new Error(
226+
'Browser sign-in is only available with Socket’s trusted API URL. Use `socket login --api-token --api-base-url <url>` for a custom API URL.',
227+
)
228+
}
229+
}
230+
231+
const existingToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN)
232+
const activeAuthType = getConfigValueOrUndef(CONFIG_KEY_AUTH_TYPE)
233+
if (existingToken && activeAuthType !== 'oauth') {
234+
const choice = await select({
235+
message:
236+
'An API token is already saved. Keep using it or switch to read-only browser sign-in?',
237+
choices: [
238+
{ name: 'Keep the saved API token', value: 'keep' },
239+
{
240+
name: 'Switch to browser sign-in (read-only)',
241+
value: 'oauth',
242+
},
243+
],
244+
})
245+
if (choice === undefined) {
246+
logger.fail('Canceled by user')
247+
return { ok: false, message: 'Canceled', cause: 'Canceled by user' }
248+
}
249+
if (choice === 'keep') {
250+
logger.success('Kept the saved API token login')
251+
return { ok: true }
252+
}
253+
}
254+
255+
apiProxy ||= getDefaultProxyUrl()
256+
const result = await beginOAuthLogin({
257+
apiProxy,
258+
openBrowser: true,
259+
onAuthorizationUrl(url) {
260+
logger.log('Open this URL in your browser to sign in:')
261+
logger.log(url)
262+
},
263+
})
264+
const baseUrl = new URL('/v0/', result.apiOrigin).href
265+
const sdkResult = await setupSdk({
266+
apiBaseUrl: baseUrl,
267+
apiProxy,
268+
apiToken: result.accessToken,
269+
})
270+
if (!sdkResult.ok) {
271+
process.exitCode = 1
272+
logger.fail(failMsgWithBadge(sdkResult.message, sdkResult.cause))
273+
return
274+
}
275+
const orgsResult = await fetchOrganization({
276+
description: 'browser sign-in validation',
277+
sdk: sdkResult.data,
278+
})
279+
if (!orgsResult.ok) {
280+
process.exitCode = 1
281+
logger.fail(failMsgWithBadge(orgsResult.message, orgsResult.cause))
282+
return
283+
}
284+
const orgSlugs = getOrgSlugs(orgsResult.data.organizations)
285+
if (orgSlugs.length === 0) {
286+
process.exitCode = 1
287+
logger.fail(
288+
'Browser sign-in succeeded, but no accessible organization was returned. Check your organization membership and try again; existing credentials were kept.',
289+
)
290+
return
291+
}
292+
293+
await saveOAuthCredentials(constants.socketAppDataPath!, result)
294+
updateConfigValue(CONFIG_KEY_API_TOKEN, null)
295+
updateConfigValue(CONFIG_KEY_DEFAULT_ORG, orgSlugs[0])
296+
updateConfigValue(CONFIG_KEY_API_BASE_URL, null)
297+
updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy)
298+
updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'oauth')
299+
logger.success(`Browser sign-in saved for ${joinAnd(orgSlugs)}`)
300+
logger.warn(
301+
'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.',
302+
)
303+
if (constants.ENV.SOCKET_CLI_API_TOKEN) {
304+
logger.warn(
305+
'SOCKET_CLI_API_TOKEN remains active and takes precedence over browser sign-in for this session.',
306+
)
169307
}
170308
}

0 commit comments

Comments
 (0)