Skip to content

feat(auth): support browser OAuth login - #1708

Open
John-David Dalton (jdalton) wants to merge 1 commit into
v1.xfrom
feat/v1x-oauth-login
Open

John-David Dalton (jdalton) wants to merge 1 commit into
v1.xfrom
feat/v1x-oauth-login

Conversation

@jdalton

@jdalton John-David Dalton (jdalton) commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Socket browser login demo

Terminal demo using fixture authentication and an example organization.

Sign in to Socket CLI through a browser instead of pasting an API token. Uses Socket MCP’s OAuth issuer with PKCE, token refresh, and logout. Browser sign-in is read-only; API-token login remains available for automation, scans, and write operations.

OAuth credentials are stored privately, proxy settings are honored, and saved credentials are isolated from config overrides. Regression tests cover protocol validation, cancellation, refresh concurrency, credential switching, and API-token compatibility.

Reference: SocketDev/socket-mcp#230.

Comment thread src/utils/oauth.mts Fixed
@jdalton
John-David Dalton (jdalton) marked this pull request as ready for review November 9, 2026 20:42

@cursor cursor Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using high effort and found 6 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: OAuth login skips configured proxy
    • Browser sign-in now uses an explicit proxy URL or the env/config proxy for OAuth and SDK calls, and only persists an explicit URL so the empty --api-proxy default cannot wipe a saved proxy.
  • ✅ Fixed: Auth switch keeps old credentials
    • Successful browser sign-in clears the saved API token, and API-token login revokes and deletes oauth.json so the inactive credential does not remain.

Create PR

Or push these changes by commenting:

@cursor push 8eed4d654b
Preview (8eed4d654b)
diff --git a/src/commands/login/apply-login.mts b/src/commands/login/apply-login.mts
--- a/src/commands/login/apply-login.mts
+++ b/src/commands/login/apply-login.mts
@@ -1,19 +1,42 @@
-import {
+import { isUrl } from '@socketsecurity/registry/lib/url'
+
+import constants, {
   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'
+import { logoutOAuth } from '../../utils/oauth.mts'
+import { getDefaultProxyUrl } from '../../utils/sdk.mts'
 
-export function applyLogin(
+export async function applyLogin(
   apiToken: string,
   enforcedOrgs: string[],
   apiBaseUrl: string | undefined,
   apiProxy: string | undefined,
-) {
+): Promise<string | undefined> {
+  // Read the proxy before config writes. An empty flag must not hide it.
+  const proxy = isUrl(apiProxy) ? apiProxy : getDefaultProxyUrl()
+  const directory = constants.socketAppDataPath
+  let warning: string | undefined
+  if (directory) {
+    try {
+      // Revoke and delete browser credentials left by a previous sign-in.
+      const result = await logoutOAuth(directory, { apiProxy: proxy })
+      warning = result.warning
+    } catch (e) {
+      warning =
+        e instanceof Error
+          ? e.message
+          : 'The previous browser sign-in could not be removed.'
+    }
+  }
   updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, enforcedOrgs)
   updateConfigValue(CONFIG_KEY_API_TOKEN, apiToken)
+  updateConfigValue(CONFIG_KEY_AUTH_TYPE, 'apiToken')
   updateConfigValue(CONFIG_KEY_API_BASE_URL, apiBaseUrl)
   updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy)
+  return warning
 }

diff --git a/src/commands/login/attempt-login.mts b/src/commands/login/attempt-login.mts
--- a/src/commands/login/attempt-login.mts
+++ b/src/commands/login/attempt-login.mts
@@ -1,12 +1,14 @@
 import { joinAnd } from '@socketsecurity/registry/lib/arrays'
 import { logger } from '@socketsecurity/registry/lib/logger'
 import { confirm, password, select } from '@socketsecurity/registry/lib/prompts'
+import { isUrl } from '@socketsecurity/registry/lib/url'
 
 import { applyLogin } from './apply-login.mts'
 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 +17,13 @@
   updateConfigValue,
 } from '../../utils/config.mts'
 import { failMsgWithBadge } from '../../utils/fail-msg-with-badge.mts'
+import {
+  OAUTH_ISSUER,
+  beginOAuthLogin,
+  saveOAuthCredentials,
+} from '../../utils/oauth.mts'
 import { getEnterpriseOrgs, getOrgSlugs } from '../../utils/organization.mts'
-import { setupSdk } from '../../utils/sdk.mts'
+import { 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,7 +36,24 @@
 export async function attemptLogin(
   apiBaseUrl: string | undefined,
   apiProxy: string | undefined,
+  apiTokenMode = false,
 ) {
+  if (!apiTokenMode) {
+    if (apiBaseUrl) {
+      throw new Error(
+        'A custom API URL requires API-token login. Run `socket login --api-token --api-base-url <url>`.',
+      )
+    }
+    return await attemptOAuthLogin(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
   const apiTokenInput = await password({
@@ -153,10 +177,18 @@
 
   const previousPersistedToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN)
   try {
-    applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy)
+    const oauthWarning = await applyLogin(
+      apiToken,
+      enforcedOrgs,
+      apiBaseUrl,
+      apiProxy,
+    )
     logger.success(
       `API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`,
     )
+    if (oauthWarning) {
+      logger.warn(oauthWarning)
+    }
     if (isConfigFromFlag()) {
       logger.log('')
       logger.warn(
@@ -168,3 +200,108 @@
     logger.fail(`API login failed`)
   }
 }
+
+async function attemptOAuthLogin(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 = constants.ENV.SOCKET_CLI_API_BASE_URL
+  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` 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 }
+    }
+  }
+
+  // An omitted --api-proxy is the empty default, not a configured proxy.
+  const resolvedProxy = isUrl(apiProxy) ? apiProxy : getDefaultProxyUrl()
+  const result = await beginOAuthLogin({
+    apiProxy: resolvedProxy,
+    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: resolvedProxy,
+    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_DEFAULT_ORG, orgSlugs[0])
+  updateConfigValue(CONFIG_KEY_API_BASE_URL, null)
+  // Browser sign-in is read-only, so drop any previously saved API token.
+  updateConfigValue(CONFIG_KEY_API_TOKEN, null)
+  // Keep a saved proxy unless this login passed an explicit proxy URL.
+  if (isUrl(apiProxy)) {
+    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/cmd-login.mts b/src/commands/login/cmd-login.mts
--- a/src/commands/login/cmd-login.mts
+++ b/src/commands/login/cmd-login.mts
@@ -39,6 +39,11 @@
     hidden,
     flags: {
       ...commonFlags,
+      apiToken: {
+        type: 'boolean',
+        default: false,
+        description: 'Use API-token login instead of browser sign-in',
+      },
       apiBaseUrl: {
         type: 'string',
         default: '',
@@ -57,13 +62,15 @@
     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 Socket access. Use --api-token
+    to enter an API token for scans and write operations.
 
     Options
       ${getFlagListOutput(config.flags)}
 
     Examples
       $ ${command}
+      $ ${command} --api-token
       $ ${command} --api-proxy=http://localhost:1234
   `,
   }
@@ -82,9 +89,10 @@
     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 +101,5 @@
     apiProxy?: string | undefined
   }
 
-  await attemptLogin(apiBaseUrl, apiProxy)
+  await attemptLogin(apiBaseUrl, apiProxy, apiTokenMode)
 }

diff --git a/src/commands/logout/apply-logout.mts b/src/commands/logout/apply-logout.mts
--- a/src/commands/logout/apply-logout.mts
+++ b/src/commands/logout/apply-logout.mts
@@ -2,12 +2,14 @@
   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
--- a/src/commands/logout/attempt-logout.mts
+++ b/src/commands/logout/attempt-logout.mts
@@ -1,19 +1,41 @@
 import { logger } from '@socketsecurity/registry/lib/logger'
 
 import { applyLogout } from './apply-logout.mts'
+import { logoutOAuth } from '../../utils/oauth.mts'
+import constants from '../../constants.mts'
 import { isConfigFromFlag } from '../../utils/config.mts'
+import { getDefaultProxyUrl } from '../../utils/sdk.mts'
 
-export function attemptLogout() {
+export async function attemptLogout() {
+  let warning: string | undefined
   try {
+    const directory = constants.socketAppDataPath
+    if (directory) {
+      const result = await logoutOAuth(directory, {
+        apiProxy: getDefaultProxyUrl(),
+      })
+      warning = result.warning
+    }
     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')
+    if (error instanceof Error) {
+      logger.warn(error.message)
+    }
   }
 }

diff --git a/src/commands/logout/cmd-logout.mts b/src/commands/logout/cmd-logout.mts
--- a/src/commands/logout/cmd-logout.mts
+++ b/src/commands/logout/cmd-logout.mts
@@ -53,5 +53,5 @@
     return
   }
 
-  attemptLogout()
+  await attemptLogout()
 }

diff --git a/src/constants.mts b/src/constants.mts
--- a/src/constants.mts
+++ b/src/constants.mts
@@ -198,6 +198,7 @@
 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 @@
     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 @@
     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 @@
   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
--- a/src/shadow/npm-base.mts
+++ b/src/shadow/npm-base.mts
@@ -19,7 +19,7 @@
 } from '../constants.mts'
 import { cmdFlagsToString } 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 +126,7 @@
     useAudit || !(await findUp(NODE_MODULES, { cwd, onlyDirectories: true }))
       ? []
       : ['--no-audit']
+  const shadowApiToken = await resolvePublicApiToken()
 
   const stdio = ensureIpcInStdio(getOwn(spawnOpts, 'stdio'))
 
@@ -179,7 +180,7 @@
 
   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/pnpm/bin.mts b/src/shadow/pnpm/bin.mts
--- a/src/shadow/pnpm/bin.mts
+++ b/src/shadow/pnpm/bin.mts
@@ -18,7 +18,7 @@
   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 +219,7 @@
 
   // Set up stdio with IPC channel.
   const stdio = ensureIpcInStdio(spawnOpts.stdio)
+  const shadowApiToken = await resolvePublicApiToken()
 
   const spawnPromise = spawn(
     realPnpmPath,
@@ -242,7 +243,7 @@
   // 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/yarn/bin.mts b/src/shadow/yarn/bin.mts
--- a/src/shadow/yarn/bin.mts
+++ b/src/shadow/yarn/bin.mts
@@ -5,7 +5,7 @@
 
 import constants, { 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 +103,7 @@
 
   // Set up stdio with IPC channel.
   const stdio = ensureIpcInStdio(spawnOpts.stdio)
+  const shadowApiToken = await resolvePublicApiToken()
 
   const spawnPromise = spawn(
     realYarnPath,
@@ -126,7 +127,7 @@
   // 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/utils/api-transport.mts b/src/utils/api-transport.mts
new file mode 100644
--- /dev/null
+++ b/src/utils/api-transport.mts
@@ -1,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, 'utf8')
+    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
--- a/src/utils/api.mts
+++ b/src/utils/api.mts
@@ -43,9 +43,9 @@
 import { getRequirements, getRequirementsKey } from './requirements.mts'
 import {
   getCliUserAgent,
-  getDefaultApiToken,
   getDefaultProxyUrl,
   getExtraCaCerts,
+  resolveDefaultApiToken,
 } from './sdk.mts'
 
 import type { CResult } from '../types.mts'
@@ -548,13 +548,24 @@
   description?: string | undefined,
   commandPath?: string | undefined,
 ): Promise<CResult<ApiTextResult>> {
-  const apiToken = getDefaultApiToken()
+  let apiToken: string | undefined
+  try {
+    apiToken = await resolveDefaultApiToken({
+      apiBaseUrl: getDefaultApiBaseUrl(),
+    })
+  } catch (error) {
+    return {
+      ok: false,
+      message: 'Authentication Error',
+      cause: error instanceof Error ? error.message : String(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 +713,26 @@
   path: string,
   options?: SendApiRequestOptions | undefined,
 ): Promise<CResult<T>> {
-  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: error instanceof Error ? error.message : String(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
--- a/src/utils/api.test.mts
+++ b/src/utils/api.test.mts
@@ -31,6 +31,9 @@
 // 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'),
 )
@@ -43,6 +46,7 @@
   getDefaultApiBaseUrl: vi.fn(() => undefined),
   getDefaultProxyUrl: mockGetDefaultProxyUrl,
   getExtraCaCerts: mockGetExtraCaCerts,
+  resolveDefaultApiToken: mockResolveDefaultApiToken,
 }))
 
 // Mock node:https request function.

diff --git a/src/utils/coana-release.mts b/src/utils/coana-release.mts
--- a/src/utils/coana-release.mts
+++ b/src/utils/coana-release.mts
@@ -34,8 +34,8 @@
 import { withTmpDir } from './fs.mts'
 import {
   getCliUserAgent,
-  getDefaultApiToken,
   getDefaultProxyUrl,
+  resolveDefaultApiToken,
 } from './sdk.mts'
 
 import type { CResult } from '../types.mts'
@@ -415,7 +415,16 @@
     // 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
--- a/src/utils/config.mts
+++ b/src/utils/config.mts
@@ -37,6 +37,7 @@
   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 { SocketYml } from '@socketsecurity/config'
 
 export interface LocalConfig {
+  authType?: 'apiToken' | '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 @@
       : 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
--- a/src/utils/config.test.mts
+++ b/src/utils/config.test.mts
@@ -12,6 +12,8 @@
 
 import {
   findSocketYmlSync,
+  getConfigValueOrUndef,
+  getSupportedConfigKeys,
   isConfigFromFlag,
   overrideCachedConfig,
   overrideConfigApiToken,
@@ -55,6 +57,12 @@
         }
       `)
     })
+
+    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.mts b/src/utils/oauth.mts
new file mode 100644
--- /dev/null
+++ b/src/utils/oauth.mts
@@ -1,0 +1,660 @@
+import { randomBytes, createHash, timingSafeEqual } from 'node:crypto'
+import { readFileSync, promises as fs } 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 { 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
+}
+const error = (message: string) =>
+  new Error(`Socket browser sign-in: ${message}`)
+const random = (n = 32) => randomBytes(n).toString('base64url')
+const form = (data: Record<string, string>) =>
+  new URLSearchParams(data).toString()
+
+function trustedEndpoint(value: unknown, issuer = OAUTH_ISSUER): 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 !== issuer ||
+    u.username ||
+    u.password ||
+    u.search ||
+    u.hash
+  ) {
+    throw error('OAuth metadata contains an untrusted endpoint.')
+  }
+  return u.href
+}
+function agent(proxy?: string): HttpsAgent | undefined {
+  const ca = getExtraCaCerts()
+  return proxy
+    ? (new HttpsProxyAgent({
+        proxy,
+        ...(ca ? { ca, proxyRequestOptions: { ca } } : {}),
+      }) as unknown as HttpsAgent)
+    : ca
+      ? new HttpsAgent({ ca })
... diff truncated: showing 800 of 1969 lines

You can send follow-ups to the cloud agent here.

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 13d0177. Configure here.

Comment thread src/commands/login/attempt-login.mts Resolved
Comment thread src/commands/login/attempt-login.mts Resolved
Comment thread src/utils/sdk.mts Resolved
Comment thread src/utils/api.mts Resolved
Comment thread src/utils/oauth.mts Resolved
Comment thread src/utils/oauth.mts Resolved
@jdalton John-David Dalton (jdalton) changed the title Support browser OAuth login on v1.x Support browser OAuth login Nov 9, 2026
@jdalton John-David Dalton (jdalton) changed the title Support browser OAuth login feat(auth): support browser OAuth login Nov 9, 2026
@jdalton
John-David Dalton (jdalton) force-pushed the feat/v1x-oauth-login branch 3 times, most recently from 85bf227 to 9b3f429 Compare November 9, 2026 21:55

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants