Skip to content

Commit 978dd94

Browse files
committed
feat(oauth-login): initial oauth login flow to new oauth provider
1 parent 5612087 commit 978dd94

22 files changed

+1303
-56
lines changed

packages/cli/src/commands/login/apply-login.mts

Lines changed: 44 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,52 @@ import {
22
CONFIG_KEY_API_BASE_URL,
33
CONFIG_KEY_API_PROXY,
44
CONFIG_KEY_API_TOKEN,
5+
CONFIG_KEY_AUTH_BASE_URL,
56
CONFIG_KEY_ENFORCED_ORGS,
7+
CONFIG_KEY_OAUTH_CLIENT_ID,
8+
CONFIG_KEY_OAUTH_REDIRECT_URI,
9+
CONFIG_KEY_OAUTH_REFRESH_TOKEN,
10+
CONFIG_KEY_OAUTH_SCOPES,
11+
CONFIG_KEY_OAUTH_TOKEN_EXPIRES_AT,
612
} from '../../constants/config.mts'
713
import { updateConfigValue } from '../../utils/config.mts'
814

9-
export function applyLogin(
10-
apiToken: string,
11-
enforcedOrgs: string[],
12-
apiBaseUrl: string | undefined,
13-
apiProxy: string | undefined,
14-
) {
15-
updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, enforcedOrgs)
16-
updateConfigValue(CONFIG_KEY_API_TOKEN, apiToken)
17-
updateConfigValue(CONFIG_KEY_API_BASE_URL, apiBaseUrl)
18-
updateConfigValue(CONFIG_KEY_API_PROXY, apiProxy)
15+
export function applyLogin(params: {
16+
apiToken: string
17+
enforcedOrgs: string[]
18+
apiBaseUrl: string | undefined
19+
apiProxy: string | undefined
20+
authBaseUrl?: string | null | undefined
21+
oauthClientId?: string | null | undefined
22+
oauthRedirectUri?: string | null | undefined
23+
oauthRefreshToken?: string | null | undefined
24+
oauthScopes?: string[] | readonly string[] | null | undefined
25+
oauthTokenExpiresAt?: number | null | undefined
26+
}) {
27+
updateConfigValue(CONFIG_KEY_ENFORCED_ORGS, params.enforcedOrgs)
28+
updateConfigValue(CONFIG_KEY_API_TOKEN, params.apiToken)
29+
updateConfigValue(CONFIG_KEY_API_BASE_URL, params.apiBaseUrl)
30+
updateConfigValue(CONFIG_KEY_API_PROXY, params.apiProxy)
31+
32+
if (params.authBaseUrl !== undefined) {
33+
updateConfigValue(CONFIG_KEY_AUTH_BASE_URL, params.authBaseUrl)
34+
}
35+
if (params.oauthClientId !== undefined) {
36+
updateConfigValue(CONFIG_KEY_OAUTH_CLIENT_ID, params.oauthClientId)
37+
}
38+
if (params.oauthRedirectUri !== undefined) {
39+
updateConfigValue(CONFIG_KEY_OAUTH_REDIRECT_URI, params.oauthRedirectUri)
40+
}
41+
if (params.oauthRefreshToken !== undefined) {
42+
updateConfigValue(CONFIG_KEY_OAUTH_REFRESH_TOKEN, params.oauthRefreshToken)
43+
}
44+
if (params.oauthScopes !== undefined) {
45+
updateConfigValue(CONFIG_KEY_OAUTH_SCOPES, params.oauthScopes)
46+
}
47+
if (params.oauthTokenExpiresAt !== undefined) {
48+
updateConfigValue(
49+
CONFIG_KEY_OAUTH_TOKEN_EXPIRES_AT,
50+
params.oauthTokenExpiresAt,
51+
)
52+
}
1953
}

packages/cli/src/commands/login/attempt-login.mts

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,27 @@ import { joinAnd } from '@socketsecurity/lib/arrays'
22
import { SOCKET_PUBLIC_API_TOKEN } from '@socketsecurity/lib/constants/socket'
33
import { getDefaultLogger } from '@socketsecurity/lib/logger'
44
import { confirm, password, select } from '@socketsecurity/lib/stdio/prompts'
5+
import { isNonEmptyString } from '@socketsecurity/lib/strings'
56

67
import { applyLogin } from './apply-login.mts'
8+
import { oauthLogin } from './oauth-login.mts'
79
import {
810
CONFIG_KEY_API_BASE_URL,
911
CONFIG_KEY_API_PROXY,
1012
CONFIG_KEY_API_TOKEN,
13+
CONFIG_KEY_AUTH_BASE_URL,
1114
CONFIG_KEY_DEFAULT_ORG,
15+
CONFIG_KEY_OAUTH_CLIENT_ID,
16+
CONFIG_KEY_OAUTH_REDIRECT_URI,
17+
CONFIG_KEY_OAUTH_SCOPES,
1218
} from '../../constants/config.mts'
19+
import ENV from '../../constants/env.mts'
1320
import {
1421
getConfigValueOrUndef,
1522
isConfigFromFlag,
1623
updateConfigValue,
1724
} from '../../utils/config.mts'
25+
import { deriveAuthBaseUrlFromApiBaseUrl } from '../../utils/auth/oauth.mts'
1826
import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts'
1927
import { getEnterpriseOrgs, getOrgSlugs } from '../../utils/organization.mts'
2028
import { setupSdk } from '../../utils/socket/sdk.mjs'
@@ -23,27 +31,150 @@ import { setupTabCompletion } from '../install/setup-tab-completion.mts'
2331
import { fetchOrganization } from '../organization/fetch-organization-list.mts'
2432

2533
import type { Choice } from '@socketsecurity/lib/stdio/prompts'
34+
import requirements from '../../../data/command-api-requirements.json' with {
35+
type: 'json',
36+
}
2637
const logger = getDefaultLogger()
2738

2839
type OrgChoice = Choice<string>
2940
type OrgChoices = OrgChoice[]
3041

42+
type LoginMethod = 'oauth' | 'token'
43+
44+
function getDefaultOAuthScopes(): string[] {
45+
const permissions: string[] = []
46+
const api = (requirements as any)?.api ?? {}
47+
for (const value of Object.values(api) as any[]) {
48+
const perms = (value?.permissions ?? []) as unknown
49+
if (Array.isArray(perms)) {
50+
for (const p of perms) {
51+
if (typeof p === 'string' && p) {
52+
permissions.push(p)
53+
}
54+
}
55+
}
56+
}
57+
return [...new Set(permissions)].sort()
58+
}
59+
60+
function parseScopes(value: unknown): string[] | undefined {
61+
if (Array.isArray(value)) {
62+
return value
63+
.filter((v): v is string => typeof v === 'string' && v.length > 0)
64+
.sort()
65+
}
66+
if (!isNonEmptyString(String(value ?? ''))) {
67+
return undefined
68+
}
69+
const raw = String(value)
70+
return raw
71+
.split(/[,\s]+/u)
72+
.map(s => s.trim())
73+
.filter(Boolean)
74+
}
75+
3176
export async function attemptLogin(
3277
apiBaseUrl: string | undefined,
3378
apiProxy: string | undefined,
79+
options?: {
80+
method?: LoginMethod | undefined
81+
authBaseUrl?: string | undefined
82+
oauthClientId?: string | undefined
83+
oauthRedirectUri?: string | undefined
84+
oauthScopes?: string | undefined
85+
},
3486
) {
3587
apiBaseUrl ??= getConfigValueOrUndef(CONFIG_KEY_API_BASE_URL) ?? undefined
3688
apiProxy ??= getConfigValueOrUndef(CONFIG_KEY_API_PROXY) ?? undefined
37-
const apiTokenInput = await password({
38-
message: `Enter your ${socketDocsLink('/docs/api-keys', 'Socket.dev API token')} (leave blank to use a limited public token)`,
39-
})
89+
const method: LoginMethod = options?.method ?? 'oauth'
4090

41-
if (apiTokenInput === undefined) {
42-
logger.fail('Canceled by user')
43-
return { ok: false, message: 'Canceled', cause: 'Canceled by user' }
44-
}
91+
let apiToken: string
92+
let oauthRefreshToken: string | null | undefined
93+
let oauthTokenExpiresAt: number | null | undefined
94+
let authBaseUrl: string | null | undefined
95+
let oauthClientId: string | null | undefined
96+
let oauthRedirectUri: string | null | undefined
97+
let oauthScopes: string[] | null | undefined
4598

46-
const apiToken = apiTokenInput || SOCKET_PUBLIC_API_TOKEN
99+
if (method === 'token') {
100+
const apiTokenInput = await password({
101+
message: `Enter your ${socketDocsLink('/docs/api-keys', 'Socket.dev API token')} (leave blank to use a limited public token)`,
102+
})
103+
104+
if (apiTokenInput === undefined) {
105+
logger.fail('Canceled by user')
106+
return { ok: false, message: 'Canceled', cause: 'Canceled by user' }
107+
}
108+
109+
apiToken = apiTokenInput || SOCKET_PUBLIC_API_TOKEN
110+
111+
// Explicitly disable OAuth refresh flow when using a legacy org-wide token.
112+
oauthRefreshToken = null
113+
oauthTokenExpiresAt = null
114+
authBaseUrl = null
115+
oauthClientId = null
116+
oauthRedirectUri = null
117+
oauthScopes = null
118+
} else {
119+
const resolvedAuthBaseUrl =
120+
options?.authBaseUrl ||
121+
ENV.SOCKET_CLI_AUTH_BASE_URL ||
122+
getConfigValueOrUndef(CONFIG_KEY_AUTH_BASE_URL) ||
123+
deriveAuthBaseUrlFromApiBaseUrl(apiBaseUrl)
124+
125+
if (!isNonEmptyString(resolvedAuthBaseUrl)) {
126+
process.exitCode = 1
127+
logger.fail(
128+
'OAuth auth base URL is not configured. Provide --auth-base-url or set SOCKET_CLI_AUTH_BASE_URL.',
129+
)
130+
return
131+
}
132+
133+
const resolvedClientId =
134+
options?.oauthClientId ||
135+
ENV.SOCKET_CLI_OAUTH_CLIENT_ID ||
136+
getConfigValueOrUndef(CONFIG_KEY_OAUTH_CLIENT_ID) ||
137+
'socket-cli'
138+
139+
const resolvedRedirectUri =
140+
options?.oauthRedirectUri ||
141+
ENV.SOCKET_CLI_OAUTH_REDIRECT_URI ||
142+
getConfigValueOrUndef(CONFIG_KEY_OAUTH_REDIRECT_URI) ||
143+
'http://127.0.0.1:53682/callback'
144+
145+
const resolvedScopes =
146+
parseScopes(
147+
options?.oauthScopes ||
148+
ENV.SOCKET_CLI_OAUTH_SCOPES ||
149+
getConfigValueOrUndef(CONFIG_KEY_OAUTH_SCOPES) ||
150+
getDefaultOAuthScopes(),
151+
) ?? []
152+
153+
logger.log(
154+
`Opening your browser to complete login (client_id: ${resolvedClientId})...`,
155+
)
156+
157+
const oauthResult = await oauthLogin({
158+
authBaseUrl: resolvedAuthBaseUrl,
159+
clientId: resolvedClientId,
160+
redirectUri: resolvedRedirectUri,
161+
scopes: resolvedScopes,
162+
apiProxy,
163+
})
164+
if (!oauthResult.ok) {
165+
process.exitCode = 1
166+
logger.fail(failMsgWithBadge(oauthResult.message, oauthResult.cause))
167+
return
168+
}
169+
170+
apiToken = oauthResult.data.accessToken
171+
oauthRefreshToken = oauthResult.data.refreshToken
172+
oauthTokenExpiresAt = oauthResult.data.expiresAt
173+
authBaseUrl = resolvedAuthBaseUrl
174+
oauthClientId = resolvedClientId
175+
oauthRedirectUri = resolvedRedirectUri
176+
oauthScopes = resolvedScopes
177+
}
47178

48179
const sockSdkCResult = await setupSdk({ apiBaseUrl, apiProxy, apiToken })
49180
if (!sockSdkCResult.ok) {
@@ -155,7 +286,18 @@ export async function attemptLogin(
155286

156287
const previousPersistedToken = getConfigValueOrUndef(CONFIG_KEY_API_TOKEN)
157288
try {
158-
applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy)
289+
applyLogin({
290+
apiToken,
291+
enforcedOrgs,
292+
apiBaseUrl,
293+
apiProxy,
294+
authBaseUrl,
295+
oauthClientId,
296+
oauthRedirectUri,
297+
oauthRefreshToken,
298+
oauthScopes,
299+
oauthTokenExpiresAt,
300+
})
159301
logger.success(
160302
`API credentials ${previousPersistedToken === apiToken ? 'refreshed' : previousPersistedToken ? 'updated' : 'set'}`,
161303
)

packages/cli/src/commands/login/cmd-login.mts

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const logger = getDefaultLogger()
2020

2121
export const CMD_NAME = 'login'
2222

23-
const description = 'Setup Socket CLI with an API token and defaults'
23+
const description = 'Authenticate Socket CLI and store credentials'
2424

2525
const hidden = false
2626

@@ -41,6 +41,11 @@ async function run(
4141
hidden,
4242
flags: {
4343
...commonFlags,
44+
method: {
45+
type: 'string',
46+
default: 'oauth',
47+
description: 'Login method: oauth (default) or token (legacy)',
48+
},
4449
apiBaseUrl: {
4550
type: 'string',
4651
default: '',
@@ -51,6 +56,29 @@ async function run(
5156
default: '',
5257
description: 'Proxy to use when making connection to API server',
5358
},
59+
authBaseUrl: {
60+
type: 'string',
61+
default: '',
62+
description:
63+
'OAuth authorization server base URL (defaults to derived from apiBaseUrl)',
64+
},
65+
oauthClientId: {
66+
type: 'string',
67+
default: '',
68+
description: 'OAuth client_id (defaults to socket-cli)',
69+
},
70+
oauthRedirectUri: {
71+
type: 'string',
72+
default: '',
73+
description:
74+
'OAuth redirect URI (must match registered redirect URIs for client)',
75+
},
76+
oauthScopes: {
77+
type: 'string',
78+
default: '',
79+
description:
80+
'OAuth scopes to request (space or comma separated; defaults to CLI-required scopes)',
81+
},
5482
},
5583
help: (command, config) => `
5684
Usage
@@ -59,13 +87,16 @@ async function run(
5987
API Token Requirements
6088
${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)}
6189
62-
Logs into the Socket API by prompting for an API token
90+
Logs into the Socket API using a browser-based OAuth flow (default).
91+
Use --method=token to enter an API token manually (legacy).
6392
6493
Options
6594
${getFlagListOutput(config.flags)}
6695
6796
Examples
6897
$ ${command}
98+
$ ${command} --method=token
99+
$ ${command} --auth-base-url=https://api.socket.dev --oauth-client-id=socket-cli
69100
$ ${command} --api-proxy=http://localhost:1234
70101
`,
71102
}
@@ -86,14 +117,47 @@ async function run(
86117

87118
if (!isInteractive()) {
88119
throw new InputError(
89-
'Cannot prompt for credentials in a non-interactive shell. Use SOCKET_CLI_API_TOKEN environment variable instead',
120+
'Cannot complete interactive login in a non-interactive shell. Use SOCKET_CLI_API_TOKEN environment variable instead',
90121
)
91122
}
92123

93-
const { apiBaseUrl, apiProxy } = cli.flags as unknown as {
124+
const {
125+
apiBaseUrl,
126+
apiProxy,
127+
authBaseUrl,
128+
method,
129+
oauthClientId,
130+
oauthRedirectUri,
131+
oauthScopes,
132+
} = cli.flags as unknown as {
94133
apiBaseUrl?: string | undefined
95134
apiProxy?: string | undefined
135+
authBaseUrl?: string | undefined
136+
method?: string | undefined
137+
oauthClientId?: string | undefined
138+
oauthRedirectUri?: string | undefined
139+
oauthScopes?: string | undefined
140+
}
141+
142+
let normalizedMethod: 'oauth' | 'token' | undefined
143+
if (method === 'oauth' || method === 'token') {
144+
normalizedMethod = method
145+
} else if (!method) {
146+
normalizedMethod = undefined
147+
} else {
148+
normalizedMethod = undefined
149+
}
150+
if (method && !normalizedMethod) {
151+
throw new InputError(
152+
`Invalid --method value: ${method}. Expected "oauth" or "token".`,
153+
)
96154
}
97155

98-
await attemptLogin(apiBaseUrl, apiProxy)
156+
await attemptLogin(apiBaseUrl, apiProxy, {
157+
method: normalizedMethod,
158+
authBaseUrl: authBaseUrl || undefined,
159+
oauthClientId: oauthClientId || undefined,
160+
oauthRedirectUri: oauthRedirectUri || undefined,
161+
oauthScopes: oauthScopes || undefined,
162+
})
99163
}

0 commit comments

Comments
 (0)