Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/commands/login.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ netlify login

**Flags**

- `check` (*string*) - Check the status of a login ticket created with --request
- `json` (*boolean*) - Output as JSON (for use with --request or --check)
- `new` (*boolean*) - Login to new Netlify account
- `request` (*string*) - Create a login ticket for agent/human-in-the-loop auth
- `debug` (*boolean*) - Print debugging information
- `auth` (*string*) - Netlify auth token - can be used to run this command without logging in

Expand Down
23 changes: 0 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

45 changes: 28 additions & 17 deletions src/commands/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ type Analytics = {
inquirer.registerPrompt('autocomplete', inquirerAutocompletePrompt)
/** Netlify CLI client id. Lives in bot@netlify.com */
// TODO: setup client for multiple environments
const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'
export const CLIENT_ID = 'd6f37de6614df7ae58664cfca524744d73807a377f5ee71f1a254f78412e3750'

const NANO_SECS_TO_MSECS = 1e6
/** The fallback width for the help terminal */
Expand Down Expand Up @@ -175,6 +175,26 @@ export type BaseOptionValues = {
verbose?: boolean
}

export function storeToken(
globalConfig: Awaited<ReturnType<typeof getGlobalConfigStore>>,
{ userId, name, email, accessToken }: { userId: string; name?: string; email?: string; accessToken: string },
) {
const userData = merge(globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
auth: {
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
})
Comment on lines +182 to +193
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

lodash merge undefined values overwrite destination property

💡 Result:

_.merge() is designed to skip undefined values from the source when the destination already has a value: “Source properties that resolve to undefined are skipped if a destination value exists.” [1]. So { a: undefined } in the source will not overwrite an existing dest.a.

Also, _.mergeWith() won’t help you force “write undefined”, because if the customizer returns undefined, Lodash treats that as “fall back to normal merge behavior” [1]—which still skips undefined.

Workarounds (pick one):

  • Use null (or another sentinel) instead of undefined when you want to clear a value, so merge will overwrite.
  • If you want assignment semantics (including overwriting with undefined), use a non-merge approach (e.g., Object.assign / spread) for those fields (note: shallow) [1].
  • Post-process after merge: set keys that are explicitly undefined in the source onto the result (works for shallow keys; for deep paths you’d need to walk the object).

Example (shallow keys):

const out = _.merge({}, dest, src);
for (const k of Object.keys(src)) {
  if (src[k] === undefined) out[k] = undefined;
}

Sources: Lodash docs for _.merge / _.mergeWith behavior [1]; discussion/example of merge() skipping undefined [2].

[1] (lodash.com)
[2] (onlinecode.org)

Citations:


Reset token-reset semantics explicitly instead of relying on merge with undefined.

lodash/merge skips source properties that are undefined when a destination value already exists. This means stale auth.github credentials will not be cleared by the current code, creating a security risk where old tokens can survive token-reset operations.

Replace the merge with explicit property assignment:

Explicit overwrite approach
-  const userData = merge(globalConfig.get(`users.${userId}`), {
+  const existingUser = globalConfig.get(`users.${userId}`) ?? {}
+  const userData = {
+    ...existingUser,
     id: userId,
     name,
     email,
     auth: {
+      ...(existingUser.auth ?? {}),
       token: accessToken,
       github: {
         user: undefined,
         token: undefined,
       },
     },
-  })
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const userData = merge(globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
auth: {
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
})
const existingUser = globalConfig.get(`users.${userId}`) ?? {}
const userData = {
...existingUser,
id: userId,
name,
email,
auth: {
...(existingUser.auth ?? {}),
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/commands/base-command.ts` around lines 182 - 193, The code uses
lodash/merge to build userData which leaves existing auth.github.user and
auth.github.token intact when source values are undefined; replace the
merge-based construction in the userData assignment with an explicit object
creation/assignment that sets auth.github.user and auth.github.token to null (or
empty string) to ensure prior credentials are cleared—locate the userData
variable assignment and the call to merge(globalConfig.get(`users.${userId}`),
...) and change it to build a plain object that explicitly overwrites auth and
auth.github fields (including setting github.user and github.token to null/''),
then write that object back to globalConfig as before.

globalConfig.set('userId', userId)
globalConfig.set(`users.${userId}`, userData)
}

/** Base command class that provides tracking and config initialization */
export default class BaseCommand extends Command {
/** The netlify object inside each command with the state */
Expand Down Expand Up @@ -441,30 +461,21 @@ export default class BaseCommand extends Command {

log(`Opening ${authLink}`)
await openBrowser({ url: authLink })
log()
log(`To request authorization from a human, run: ${chalk.cyanBright('netlify login --request "<msg>"')}`)
log()

const accessToken = await pollForToken({
api: this.netlify.api,
ticket,
})

const { email, full_name: name, id: userId } = await this.netlify.api.getCurrentUser()
if (!userId) {
return logAndThrowError('Could not retrieve user ID from Netlify API')
}

const userData = merge(this.netlify.globalConfig.get(`users.${userId}`), {
id: userId,
name,
email,
auth: {
token: accessToken,
github: {
user: undefined,
token: undefined,
},
},
})
// Set current userId
this.netlify.globalConfig.set('userId', userId)
// Set user data
this.netlify.globalConfig.set(`users.${userId}`, userData)
storeToken(this.netlify.globalConfig, { userId, name, email, accessToken })

await identify({
name,
Expand Down
3 changes: 3 additions & 0 deletions src/commands/login/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ export const createLoginCommand = (program: BaseCommand) =>
Opens a web browser to acquire an OAuth token.`,
)
.option('--new', 'Login to new Netlify account')
.option('--request <message>', 'Create a login ticket for agent/human-in-the-loop auth')
.option('--check <ticket-id>', 'Check the status of a login ticket created with --request')
.option('--json', 'Output as JSON (for use with --request or --check)')
.addHelpText('after', () => {
const docsUrl = 'https://docs.netlify.com/cli/get-started/#authentication'
return `
Expand Down
63 changes: 63 additions & 0 deletions src/commands/login/login-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NetlifyAPI } from '@netlify/api'
import { OptionValues } from 'commander'

import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
import { storeToken } from '../base-command.js'
import type { NetlifyOptions } from '../types.js'

export const loginCheck = async (
options: OptionValues,
apiOpts: NetlifyOptions['apiOpts'],
globalConfig: NetlifyOptions['globalConfig'],
) => {
const ticketId = options.check as string

const api = new NetlifyAPI('', apiOpts)

let ticket: { authorized?: boolean }
try {
ticket = await api.showTicket({ ticketId })
} catch (error) {
const status = (error as { status?: number }).status
if (status === 401 || status === 404) {
logJson({ status: 'denied' })
log('Status: denied')
return
}
throw error
}

if (!ticket.authorized) {
logJson({ status: 'pending' })
log('Status: pending')
return
}

const tokenResponse = await api.exchangeTicket({ ticketId })
const accessToken = tokenResponse.access_token
if (!accessToken) {
return logAndThrowError('Could not retrieve access token')
}

api.accessToken = accessToken
const user = await api.getCurrentUser()
if (!user.id) {
return logAndThrowError('Could not retrieve user ID from Netlify API')
}

storeToken(globalConfig, {
userId: user.id,
name: user.full_name,
email: user.email,
accessToken,
})

logJson({
status: 'authorized',
user: { id: user.id, email: user.email, name: user.full_name },
})

log('Status: authorized')
log(`Name: ${user.full_name ?? ''}`)
log(`Email: ${user.email ?? ''}`)
}
26 changes: 26 additions & 0 deletions src/commands/login/login-request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { NetlifyAPI } from '@netlify/api'

import { log, logAndThrowError, logJson } from '../../utils/command-helpers.js'
import { CLIENT_ID } from '../base-command.js'
import type { NetlifyOptions } from '../types.js'

export const loginRequest = async (message: string, apiOpts: NetlifyOptions['apiOpts']) => {
const webUI = process.env.NETLIFY_WEB_UI || 'https://app.netlify.com'

const api = new NetlifyAPI('', apiOpts)

const ticket = await api.createTicket({ clientId: CLIENT_ID, body: { message } })

if (!ticket.id) {
return logAndThrowError('Failed to create login ticket')
}
const ticketId = ticket.id
const url = `${webUI}/authorize?response_type=ticket&ticket=${ticketId}`

logJson({ ticket_id: ticketId, url, check_command: `netlify login --check ${ticketId}` })

log(`Ticket ID: ${ticketId}`)
log(`Authorize URL: ${url}`)
log()
log(`After authorizing, run: netlify login --check ${ticketId}`)
}
Loading
Loading