@heroku-cli/command
Advanced tools
| import { KeychainAuthEntry } from '../lib/types.js'; | ||
| /** | ||
| * Handles credential storage, removal, and retrieval using the Linux Secret Service API. | ||
| * Uses the secret-tool command-line utility (part of libsecret) to interact with desktop keyrings. | ||
| */ | ||
| export declare class LinuxHandler { | ||
| private readonly scrubber; | ||
| /** | ||
| * Retrieves the authentication token from the Linux keyring. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account: string, service: string): string; | ||
| /** | ||
| * Lists all accounts stored in the Linux keyring for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service: string): string[]; | ||
| /** | ||
| * Removes the authentication token from the Linux keyring. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account: string, service: string): void; | ||
| /** | ||
| * Saves an authentication entry to the Linux keyring. | ||
| * If a credential with the same attributes already exists, it is updated with the new token. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth: KeychainAuthEntry): void; | ||
| /** | ||
| * secret-tool clear fails when no matching credential exists; treat as successful no-op for logout. | ||
| */ | ||
| private isMissingSecretClearFailure; | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| private scrubError; | ||
| } |
| import { Scrubber } from '@heroku/js-blanket'; | ||
| import childProcess from 'node:child_process'; | ||
| /** | ||
| * Handles credential storage, removal, and retrieval using the Linux Secret Service API. | ||
| * Uses the secret-tool command-line utility (part of libsecret) to interact with desktop keyrings. | ||
| */ | ||
| export class LinuxHandler { | ||
| scrubber = new Scrubber({ | ||
| patterns: [ | ||
| /account\s+"[^"]*"/g, // Scrub account value | ||
| ], | ||
| }); | ||
| /** | ||
| * Retrieves the authentication token from the Linux keyring. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account, service) { | ||
| try { | ||
| const output = childProcess.execSync(`secret-tool lookup service "${service}" account "${account}"`, { encoding: 'utf8' }); | ||
| const token = output.trim(); | ||
| if (!token) { | ||
| throw new Error('Token not found'); | ||
| } | ||
| return token; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to retrieve token from Linux keyring: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Lists all accounts stored in the Linux keyring for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service) { | ||
| try { | ||
| const spawnResult = childProcess.spawnSync('secret-tool', ['search', '--all', 'service', service], { encoding: 'utf8' }); | ||
| if (spawnResult.error) { | ||
| throw spawnResult.error; | ||
| } | ||
| if (spawnResult.status !== 0) { | ||
| const stderr = spawnResult.stderr || 'Unknown error'; | ||
| throw new Error(stderr); | ||
| } | ||
| /* | ||
| * Expected output format: | ||
| * stdout: label, secret, created, modified, schema lines | ||
| * stderr: attribute.service / attribute.account lines | ||
| */ | ||
| const accounts = []; | ||
| const lines = (spawnResult.stderr ?? '').split('\n'); | ||
| for (const line of lines) { | ||
| const match = line.trim().match(/^attribute\.account\s*=\s*(.+)$/); | ||
| if (match) { | ||
| const account = match[1].trim(); | ||
| if (account) { | ||
| accounts.push(account); | ||
| } | ||
| } | ||
| } | ||
| return accounts; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to list accounts in Linux keyring: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Removes the authentication token from the Linux keyring. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account, service) { | ||
| const spawnResult = childProcess.spawnSync('secret-tool', ['clear', 'service', service, 'account', account], { encoding: 'utf8', env: { ...process.env, LC_ALL: 'C' } }); | ||
| if (spawnResult.error) { | ||
| const { message } = spawnResult.error; | ||
| throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(message)}`); | ||
| } | ||
| if (spawnResult.status === 0) { | ||
| return; | ||
| } | ||
| const status = spawnResult.status ?? -1; | ||
| const stderr = (spawnResult.stderr ?? '').toString(); | ||
| if (this.isMissingSecretClearFailure(status, stderr)) { | ||
| return; | ||
| } | ||
| throw new Error(`Failed to remove token from Linux keyring: ${this.scrubError(stderr || `exit ${status}`)}`); | ||
| } | ||
| /** | ||
| * Saves an authentication entry to the Linux keyring. | ||
| * If a credential with the same attributes already exists, it is updated with the new token. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth) { | ||
| try { | ||
| const spawnResult = childProcess.spawnSync('secret-tool', [ | ||
| 'store', | ||
| '--label=Heroku CLI', | ||
| 'service', | ||
| auth.service, | ||
| 'account', | ||
| auth.account, | ||
| ], { | ||
| encoding: 'utf8', | ||
| input: auth.token, | ||
| }); | ||
| if (spawnResult.error) { | ||
| throw spawnResult.error; | ||
| } | ||
| if (spawnResult.status !== 0) { | ||
| const stderr = spawnResult.stderr || 'Unknown error'; | ||
| throw new Error(stderr); | ||
| } | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to store token in Linux keyring: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * secret-tool clear fails when no matching credential exists; treat as successful no-op for logout. | ||
| */ | ||
| isMissingSecretClearFailure(status, stderr) { | ||
| if (status === 0) { | ||
| return false; | ||
| } | ||
| // secret-tool clear exits 1 with no output when nothing matched (locale-independent). | ||
| if (status === 1 && stderr.trim() === '') { | ||
| return true; | ||
| } | ||
| const text = stderr.toLowerCase(); | ||
| return /no matching|could not find|not found|unknown attribute|does not exist|cannot remove/i.test(text); | ||
| } | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| scrubError(message) { | ||
| const result = this.scrubber.scrub({ message }); | ||
| return result.data.message; | ||
| } | ||
| } |
| import { KeychainAuthEntry } from '../lib/types.js'; | ||
| /** | ||
| * Handles credential storage, removal, and retrieval using the macOS Keychain. | ||
| * Uses the macOS security command-line tool to interact with the Keychain. | ||
| */ | ||
| export declare class MacOSHandler { | ||
| private readonly scrubber; | ||
| /** | ||
| * Retrieves the authentication token from macOS Keychain. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account: string, service: string): string; | ||
| /** | ||
| * Lists all accounts stored in macOS Keychain for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service: string): string[]; | ||
| /** | ||
| * Removes the authentication token from macOS Keychain. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account: string, service: string): void; | ||
| /** | ||
| * Saves an authentication entry to macOS Keychain. | ||
| * If a credential with the same name already exists, it is updated with the new token. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth: KeychainAuthEntry): void; | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| private scrubError; | ||
| } |
| import { Scrubber } from '@heroku/js-blanket'; | ||
| import childProcess from 'node:child_process'; | ||
| /** | ||
| * Handles credential storage, removal, and retrieval using the macOS Keychain. | ||
| * Uses the macOS security command-line tool to interact with the Keychain. | ||
| */ | ||
| export class MacOSHandler { | ||
| scrubber = new Scrubber({ | ||
| patterns: [ | ||
| /-a\s+"[^"]*"/g, // Scrub account (-a flag) | ||
| /-w\s+"[^"]*"/g, // Scrub password/token (-w flag) | ||
| ], | ||
| }); | ||
| /** | ||
| * Retrieves the authentication token from macOS Keychain. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account, service) { | ||
| try { | ||
| const output = childProcess.execSync(`security find-generic-password -a "${account}" -s "${service}" -w`, { | ||
| encoding: 'utf8', | ||
| stdio: ['pipe', 'pipe', 'ignore'], | ||
| }); | ||
| const token = output.trim(); | ||
| if (!token) { | ||
| throw new Error('Token not found'); | ||
| } | ||
| return token; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to retrieve token from macOS Keychain: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Lists all accounts stored in macOS Keychain for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service) { | ||
| try { | ||
| const output = childProcess.execSync('security dump-keychain', { | ||
| encoding: 'utf8', | ||
| stdio: ['pipe', 'pipe', 'ignore'], | ||
| }); | ||
| // Expected output format: | ||
| // keychain: "/path/to/keychain" | ||
| // version: 512 | ||
| // class: "genp" | ||
| // attributes: | ||
| // 0x00000007 <blob>="service-name" | ||
| // "acct"<blob>="account-name" | ||
| // "svce"<blob>="service-name" | ||
| // ... | ||
| const accounts = []; | ||
| // Split by keychain entry boundaries | ||
| const entries = output.split(/^keychain:/m); | ||
| for (const entry of entries) { | ||
| // Only process generic password entries | ||
| if (!entry.includes('class: "genp"')) | ||
| continue; | ||
| // Extract service name | ||
| const serviceMatch = entry.match(/"svce"<blob>="([^"]+)"/); | ||
| if (!serviceMatch || serviceMatch[1] !== service) | ||
| continue; | ||
| // Extract account name | ||
| const accountMatch = entry.match(/"acct"<blob>="([^"]+)"/); | ||
| if (accountMatch) { | ||
| accounts.push(accountMatch[1]); | ||
| } | ||
| } | ||
| return accounts; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to list accounts in macOS Keychain: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Removes the authentication token from macOS Keychain. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account, service) { | ||
| try { | ||
| childProcess.execSync(`security delete-generic-password -a "${account}" -s "${service}"`, { | ||
| encoding: 'utf8', | ||
| stdio: ['pipe', 'pipe', 'ignore'], | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const execError = error; | ||
| // security exits 44 when the generic password does not exist (e.g. netrc-only login) | ||
| if (execError.status === 44) { | ||
| return; | ||
| } | ||
| const { message } = error; | ||
| throw new Error(`Failed to remove token from macOS Keychain: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Saves an authentication entry to macOS Keychain. | ||
| * If a credential with the same name already exists, it is updated with the new token. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth) { | ||
| try { | ||
| childProcess.execSync(`security add-generic-password -U -a "${auth.account}" -s "${auth.service}" -w "${auth.token}"`, { | ||
| encoding: 'utf8', | ||
| stdio: ['pipe', 'pipe', 'ignore'], | ||
| }); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to store token in macOS Keychain: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| scrubError(message) { | ||
| const result = this.scrubber.scrub({ message }); | ||
| return result.data.message; | ||
| } | ||
| } |
| import { Netrc } from '../lib/netrc-parser.js'; | ||
| import { NetrcAuthEntry } from '../lib/types.js'; | ||
| export declare class NetrcHandler { | ||
| readonly netrc: Netrc; | ||
| /** @param file - Optional netrc path; otherwise uses the default location. */ | ||
| constructor(file?: string); | ||
| /** | ||
| * Retrieves authentication credentials for a given host. | ||
| * @param host - The hostname to retrieve credentials for. | ||
| * @returns The authentication entry for the host, or undefined if not found. | ||
| */ | ||
| getAuth(host: string): Promise<{ | ||
| [key: string]: string | undefined; | ||
| account?: string; | ||
| login?: string; | ||
| password?: string; | ||
| }>; | ||
| /** | ||
| * Removes authentication credentials for a given host. | ||
| * @param host - The hostname to remove credentials for. | ||
| * @returns A promise that resolves when the credentials are removed. | ||
| */ | ||
| removeAuth(host: string): Promise<void>; | ||
| /** | ||
| * Removes credentials for multiple hosts with a single netrc load/save. | ||
| */ | ||
| removeAuthForHosts(hosts: string[]): Promise<void>; | ||
| /** | ||
| * Saves authentication credentials for a given host. | ||
| * @param auth - The authentication entry containing login and password. | ||
| * @param host - The hostname to save credentials for. | ||
| * @returns A promise that resolves when the credentials are saved. | ||
| */ | ||
| saveAuth(auth: NetrcAuthEntry, host: string): Promise<void>; | ||
| /** | ||
| * Saves the same credentials for multiple hosts with a single netrc load/save. | ||
| */ | ||
| saveAuthForHosts(auth: NetrcAuthEntry, hosts: string[]): Promise<void>; | ||
| private applyAuthToHost; | ||
| } |
| import debug from 'debug'; | ||
| import { Netrc } from '../lib/netrc-parser.js'; | ||
| const credDebug = debug('heroku-credential-manager'); | ||
| export class NetrcHandler { | ||
| netrc; | ||
| /** @param file - Optional netrc path; otherwise uses the default location. */ | ||
| constructor(file) { | ||
| this.netrc = new Netrc(file); | ||
| } | ||
| /** | ||
| * Retrieves authentication credentials for a given host. | ||
| * @param host - The hostname to retrieve credentials for. | ||
| * @returns The authentication entry for the host, or undefined if not found. | ||
| */ | ||
| async getAuth(host) { | ||
| await this.netrc.load(); | ||
| const auth = this.netrc.machines[host]; | ||
| if (!auth) { | ||
| throw new Error(`No auth found for ${host}`); | ||
| } | ||
| return auth; | ||
| } | ||
| /** | ||
| * Removes authentication credentials for a given host. | ||
| * @param host - The hostname to remove credentials for. | ||
| * @returns A promise that resolves when the credentials are removed. | ||
| */ | ||
| async removeAuth(host) { | ||
| await this.removeAuthForHosts([host]); | ||
| } | ||
| /** | ||
| * Removes credentials for multiple hosts with a single netrc load/save. | ||
| */ | ||
| async removeAuthForHosts(hosts) { | ||
| if (hosts.length === 0) | ||
| return; | ||
| await this.netrc.load(); | ||
| let changed = false; | ||
| for (const host of hosts) { | ||
| if (!this.netrc.machines[host]) { | ||
| credDebug(`No credentials to logout for ${host}`); | ||
| continue; | ||
| } | ||
| delete this.netrc.machines[host]; | ||
| changed = true; | ||
| } | ||
| if (changed) | ||
| await this.netrc.save(); | ||
| } | ||
| /** | ||
| * Saves authentication credentials for a given host. | ||
| * @param auth - The authentication entry containing login and password. | ||
| * @param host - The hostname to save credentials for. | ||
| * @returns A promise that resolves when the credentials are saved. | ||
| */ | ||
| async saveAuth(auth, host) { | ||
| await this.saveAuthForHosts(auth, [host]); | ||
| } | ||
| /** | ||
| * Saves the same credentials for multiple hosts with a single netrc load/save. | ||
| */ | ||
| async saveAuthForHosts(auth, hosts) { | ||
| if (hosts.length === 0) | ||
| return; | ||
| await this.netrc.load(); | ||
| for (const host of hosts) { | ||
| this.applyAuthToHost(auth, host); | ||
| } | ||
| await this.netrc.save(); | ||
| } | ||
| applyAuthToHost(auth, host) { | ||
| if (!this.netrc.machines[host]) | ||
| this.netrc.machines[host] = {}; | ||
| this.netrc.machines[host] = { | ||
| login: auth.login, | ||
| password: auth.password, | ||
| }; | ||
| delete this.netrc.machines[host].method; | ||
| delete this.netrc.machines[host].org; | ||
| if (this.netrc.machines._tokens) { | ||
| for (const token of this.netrc.machines._tokens) { | ||
| if (token.type === 'machine' && host === token.host) { | ||
| token.internalWhitespace = '\n '; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
| import { KeychainAuthEntry } from '../lib/types.js'; | ||
| /** | ||
| * Handles credential storage and retrieval using the Windows Credential Manager. | ||
| * Uses PowerShell commands to interact with the Windows.Security.Credentials.PasswordVault API. | ||
| */ | ||
| export declare class WindowsHandler { | ||
| private readonly scrubber; | ||
| /** | ||
| * Retrieves the authentication token from Windows Credential Manager. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account: string, service: string): string; | ||
| /** | ||
| * Lists all accounts stored in Windows Credential Manager for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service: string): string[]; | ||
| /** | ||
| * Removes the authentication token from Windows Credential Manager. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account: string, service: string): void; | ||
| /** | ||
| * Saves an authentication entry to Windows Credential Manager. | ||
| * If a credential with the same name already exists, it is removed before saving the new one. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth: KeychainAuthEntry): void; | ||
| /** | ||
| * PasswordVault.Retrieve throws when the credential is absent (e.g. netrc-only login). | ||
| */ | ||
| private isMissingVaultCredential; | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| private scrubError; | ||
| } |
| import { Scrubber } from '@heroku/js-blanket'; | ||
| import childProcess from 'node:child_process'; | ||
| /** | ||
| * Handles credential storage and retrieval using the Windows Credential Manager. | ||
| * Uses PowerShell commands to interact with the Windows.Security.Credentials.PasswordVault API. | ||
| */ | ||
| export class WindowsHandler { | ||
| scrubber = new Scrubber({ | ||
| patterns: [ | ||
| /Retrieve\("([^"]+)",\s*"([^"]+)"\)/g, // Scrub account in Retrieve("service", "account") | ||
| /PasswordCredential\("([^"]+)",\s*"([^"]+)",\s*"([^"]+)"\)/g, // Scrub account and token in PasswordCredential | ||
| ], | ||
| }); | ||
| /** | ||
| * Retrieves the authentication token from Windows Credential Manager. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns The stored authentication token. | ||
| * @throws Error if the token is not found or retrieval fails. | ||
| */ | ||
| getAuth(account, service) { | ||
| try { | ||
| const psCommand = ` | ||
| $ErrorActionPreference = 'Stop' | ||
| [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | ||
| $vault = New-Object Windows.Security.Credentials.PasswordVault | ||
| $credential = $vault.Retrieve("${service}", "${account}") | ||
| $credential.Password | ||
| `; | ||
| const output = childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] }); | ||
| const token = output.trim(); | ||
| if (!token) { | ||
| throw new Error('Token not found'); | ||
| } | ||
| return token; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to retrieve token from Windows Credential Manager: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Lists all accounts stored in Windows Credential Manager for a given service. | ||
| * @param service - The service name to search for | ||
| * @returns Array of account names found for the service | ||
| * @throws Error if the search operation fails | ||
| */ | ||
| listAccounts(service) { | ||
| try { | ||
| const psCommand = ` | ||
| $ErrorActionPreference = 'Stop' | ||
| [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | ||
| $vault = New-Object Windows.Security.Credentials.PasswordVault | ||
| try { | ||
| $creds = $vault.FindAllByResource("${service}") | ||
| $creds | ForEach-Object { $_.UserName } | ||
| } catch { | ||
| # No credentials found for this resource | ||
| exit 0 | ||
| } | ||
| `; | ||
| const output = childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] }); | ||
| // Expected output format: | ||
| // user1@example.com | ||
| // user2@example.com | ||
| // ... | ||
| const accounts = output | ||
| .split('\n') | ||
| .map(line => line.trim()) | ||
| .filter(line => line.length > 0); | ||
| return accounts; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to list accounts in Windows Credential Manager: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Removes the authentication token from Windows Credential Manager. | ||
| * @param account - The account login to use (e.g. 'test@example.com') | ||
| * @param service - The service name to use | ||
| * @returns void | ||
| * @throws Error if the removal operation fails. | ||
| */ | ||
| removeAuth(account, service) { | ||
| try { | ||
| const psCommand = ` | ||
| $ErrorActionPreference = 'Stop' | ||
| [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | ||
| $vault = New-Object Windows.Security.Credentials.PasswordVault | ||
| $credential = $vault.Retrieve("${service}", "${account}") | ||
| $vault.Remove($credential) | ||
| `; | ||
| childProcess.execSync(psCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] }); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| if (this.isMissingVaultCredential(message)) { | ||
| return; | ||
| } | ||
| throw new Error(`Failed to remove token from Windows Credential Manager: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * Saves an authentication entry to Windows Credential Manager. | ||
| * If a credential with the same name already exists, it is removed before saving the new one. | ||
| * @param auth - The authentication entry containing account and token information to store. | ||
| * @returns void | ||
| * @throws Error if the save operation fails. | ||
| */ | ||
| saveAuth(auth) { | ||
| try { | ||
| try { | ||
| const removeCommand = ` | ||
| $ErrorActionPreference = 'Stop' | ||
| [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | ||
| $vault = New-Object Windows.Security.Credentials.PasswordVault | ||
| $credential = $vault.Retrieve("${auth.service}", "${auth.account}") | ||
| $vault.Remove($credential) | ||
| `; | ||
| childProcess.execSync(removeCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] }); | ||
| } | ||
| catch { | ||
| // noop - item does not exist | ||
| } | ||
| const addCommand = ` | ||
| $ErrorActionPreference = 'Stop' | ||
| [void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime] | ||
| $vault = New-Object Windows.Security.Credentials.PasswordVault | ||
| $credential = New-Object Windows.Security.Credentials.PasswordCredential("${auth.service}", "${auth.account}", "${auth.token}") | ||
| $vault.Add($credential) | ||
| `; | ||
| childProcess.execSync(addCommand, { encoding: 'utf8', shell: 'powershell', stdio: ['pipe', 'pipe', 'ignore'] }); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| throw new Error(`Failed to store token in Windows Credential Manager: ${this.scrubError(message)}`); | ||
| } | ||
| } | ||
| /** | ||
| * PasswordVault.Retrieve throws when the credential is absent (e.g. netrc-only login). | ||
| */ | ||
| isMissingVaultCredential(message) { | ||
| const lower = message.toLowerCase(); | ||
| return lower.includes('element not found') | ||
| || lower.includes('specified credential could not be found') | ||
| || lower.includes('0x80070490'); | ||
| } | ||
| /** | ||
| * Scrubs account names and passwords/tokens from error messages. | ||
| * | ||
| * @param message - The error message to scrub | ||
| * @returns The scrubbed error message with sensitive data replaced by "[SCRUBBED]" | ||
| */ | ||
| scrubError(message) { | ||
| const result = this.scrubber.scrub({ message }); | ||
| return result.data.message; | ||
| } | ||
| } |
| import { LinuxHandler } from './credential-handlers/linux-handler.js'; | ||
| import { MacOSHandler } from './credential-handlers/macos-handler.js'; | ||
| import { WindowsHandler } from './credential-handlers/windows-handler.js'; | ||
| import { CredentialStore } from './lib/credential-storage-selector.js'; | ||
| import { AuthEntry } from './lib/types.js'; | ||
| /** | ||
| * Saves authentication credentials to the native credential store (if available) and .netrc file. | ||
| * | ||
| * @param account - User's account (email) | ||
| * @param token - Authentication token | ||
| * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves when credentials are saved | ||
| */ | ||
| export declare function saveAuth(account: string, token: string, hosts: string[], service?: string): Promise<void>; | ||
| /** | ||
| * Retrieves authentication credentials from the native credential store (if available) or .netrc file. | ||
| * | ||
| * @param account - User's account (email), or undefined to search for account | ||
| * @param host - Hostname for netrc lookup (e.g., 'api.heroku.com') | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves with the authentication account and token. | ||
| * @throws Error if no credentials are found in either location. | ||
| */ | ||
| export declare function getAuth(account: string | undefined, host: string, service?: string): Promise<AuthEntry>; | ||
| /** | ||
| * Lists all accounts stored in the native credential store for a given service. | ||
| * | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Array of account names, or empty array if no native credential store is available | ||
| */ | ||
| export declare function listKeychainAccounts(service?: string): Promise<string[]>; | ||
| /** | ||
| * Removes authentication credentials from the platform native store (when present) and .netrc. | ||
| * Uses {@link getNativeCredentialStore} so legacy HEROKU_NETRC_WRITE-only mode does not skip Keychain/vault cleanup after a mixed login. | ||
| * | ||
| * @param account - User's account (email), or undefined when using HEROKU_API_KEY only (native removal is skipped) | ||
| * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves when credentials are removed | ||
| */ | ||
| export declare function removeAuth(account: string | undefined, hosts: string[], service?: string): Promise<void>; | ||
| /** | ||
| * Factory function to create the appropriate credential handler based on platform. | ||
| * @private | ||
| * @param store - The type of credential store to use | ||
| * @returns A handler instance for the specified store | ||
| */ | ||
| export declare function getCredentialHandler(store: CredentialStore): LinuxHandler | MacOSHandler | WindowsHandler; | ||
| export { LinuxHandler } from './credential-handlers/linux-handler.js'; | ||
| export { MacOSHandler } from './credential-handlers/macos-handler.js'; | ||
| export { NetrcHandler } from './credential-handlers/netrc-handler.js'; | ||
| export { WindowsHandler } from './credential-handlers/windows-handler.js'; | ||
| export { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js'; | ||
| export type { StorageConfig } from './lib/credential-storage-selector.js'; | ||
| export { deleteLoginState, readLoginState, writeLoginState } from './lib/login-state.js'; | ||
| export { Netrc, parse } from './lib/netrc-parser.js'; | ||
| export type { Machines, MachinesWithTokens, MachineToken, Token, } from './lib/netrc-parser.js'; | ||
| export type { AuthEntry, KeychainAuthEntry, NetrcAuthEntry } from './lib/types.js'; |
| import { ux } from '@oclif/core'; | ||
| import debug from 'debug'; | ||
| import tsheredoc from 'tsheredoc'; | ||
| import { LinuxHandler } from './credential-handlers/linux-handler.js'; | ||
| import { MacOSHandler } from './credential-handlers/macos-handler.js'; | ||
| import { NetrcHandler } from './credential-handlers/netrc-handler.js'; | ||
| import { WindowsHandler } from './credential-handlers/windows-handler.js'; | ||
| import { reportCredentialStoreError } from './lib/cli-command-telemetry.js'; | ||
| import { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js'; | ||
| const credDebug = debug('heroku-credential-manager'); | ||
| const heredoc = tsheredoc.default; | ||
| const SERVICE_NAME = 'heroku-cli'; | ||
| /** | ||
| * Saves authentication credentials to the native credential store (if available) and .netrc file. | ||
| * | ||
| * @param account - User's account (email) | ||
| * @param token - Authentication token | ||
| * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves when credentials are saved | ||
| */ | ||
| export async function saveAuth(account, token, hosts, service = SERVICE_NAME) { | ||
| const config = getStorageConfig(); | ||
| const netrcHandler = new NetrcHandler(); | ||
| if (config.credentialStore) { | ||
| try { | ||
| const handler = getCredentialHandler(config.credentialStore); | ||
| handler.saveAuth({ account, service, token }); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| credDebug(message); | ||
| if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') { | ||
| ux.warn(heredoc(` | ||
| We can't save the Heroku token to your computer's keychain. | ||
| We'll save the token to the .netrc file instead. | ||
| To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`)); | ||
| } | ||
| await reportCredentialStoreError(error, { | ||
| credentialStore: config.credentialStore, | ||
| operation: 'saveAuth', | ||
| }); | ||
| } | ||
| } | ||
| if (config.useNetrc && hosts.length > 0) { | ||
| const netrcAuth = { | ||
| login: account, | ||
| password: token, | ||
| }; | ||
| await netrcHandler.saveAuthForHosts(netrcAuth, hosts); | ||
| } | ||
| } | ||
| /** | ||
| * Retrieves authentication credentials from the native credential store (if available) or .netrc file. | ||
| * | ||
| * @param account - User's account (email), or undefined to search for account | ||
| * @param host - Hostname for netrc lookup (e.g., 'api.heroku.com') | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves with the authentication account and token. | ||
| * @throws Error if no credentials are found in either location. | ||
| */ | ||
| export async function getAuth(account, host, service = SERVICE_NAME) { | ||
| const config = getStorageConfig(); | ||
| const netrcHandler = new NetrcHandler(); | ||
| if (config.credentialStore && account) { | ||
| try { | ||
| const handler = getCredentialHandler(config.credentialStore); | ||
| const token = handler.getAuth(account, service); | ||
| return { account, token }; | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| credDebug(message); | ||
| if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') { | ||
| ux.warn(heredoc(` | ||
| We can't retrieve the Heroku token from your computer's keychain. | ||
| We'll try to retrieve the token from the .netrc file instead. | ||
| To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`)); | ||
| } | ||
| await reportCredentialStoreError(error, { | ||
| credentialStore: config.credentialStore, | ||
| operation: 'getAuth', | ||
| }); | ||
| } | ||
| } | ||
| if (config.useNetrc) { | ||
| const auth = await netrcHandler.getAuth(host); | ||
| if (!auth.password) { | ||
| throw new Error('No auth found'); | ||
| } | ||
| return { account: auth.login, token: auth.password }; | ||
| } | ||
| throw new Error('No auth found'); | ||
| } | ||
| /** | ||
| * Lists all accounts stored in the native credential store for a given service. | ||
| * | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Array of account names, or empty array if no native credential store is available | ||
| */ | ||
| export async function listKeychainAccounts(service = SERVICE_NAME) { | ||
| const config = getStorageConfig(); | ||
| if (config.credentialStore) { | ||
| try { | ||
| const handler = getCredentialHandler(config.credentialStore); | ||
| return handler.listAccounts(service); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| credDebug(message); | ||
| await reportCredentialStoreError(error, { | ||
| credentialStore: config.credentialStore, | ||
| operation: 'listKeychainAccounts', | ||
| }); | ||
| return []; | ||
| } | ||
| } | ||
| return []; | ||
| } | ||
| /** | ||
| * Removes authentication credentials from the platform native store (when present) and .netrc. | ||
| * Uses {@link getNativeCredentialStore} so legacy HEROKU_NETRC_WRITE-only mode does not skip Keychain/vault cleanup after a mixed login. | ||
| * | ||
| * @param account - User's account (email), or undefined when using HEROKU_API_KEY only (native removal is skipped) | ||
| * @param hosts - Hostname(s) for netrc storage (e.g., ['api.heroku.com']) | ||
| * @param service - Service name (defaults to 'heroku-cli') | ||
| * @returns Promise that resolves when credentials are removed | ||
| */ | ||
| export async function removeAuth(account, hosts, service = SERVICE_NAME) { | ||
| const config = getStorageConfig(); | ||
| const netrcHandler = new NetrcHandler(); | ||
| const nativeStore = getNativeCredentialStore(); | ||
| if (nativeStore && account) { | ||
| try { | ||
| const handler = getCredentialHandler(nativeStore); | ||
| handler.removeAuth(account, service); | ||
| } | ||
| catch (error) { | ||
| const { message } = error; | ||
| credDebug(message); | ||
| if (process.env.HEROKU_KEYCHAIN_WARNINGS !== 'off') { | ||
| ux.warn(heredoc(` | ||
| We can't remove the Heroku token from your computer's keychain. | ||
| We'll remove the token from the .netrc file instead. | ||
| To turn off this warning, set HEROKU_KEYCHAIN_WARNINGS to "off".`)); | ||
| } | ||
| await reportCredentialStoreError(error, { | ||
| credentialStore: nativeStore, | ||
| operation: 'removeAuth', | ||
| }); | ||
| } | ||
| } | ||
| if (config.useNetrc && hosts.length > 0) { | ||
| await netrcHandler.removeAuthForHosts(hosts); | ||
| } | ||
| } | ||
| /** | ||
| * Factory function to create the appropriate credential handler based on platform. | ||
| * @private | ||
| * @param store - The type of credential store to use | ||
| * @returns A handler instance for the specified store | ||
| */ | ||
| export function getCredentialHandler(store) { | ||
| switch (store) { | ||
| case CredentialStore.LinuxSecretService: { | ||
| return new LinuxHandler(); | ||
| } | ||
| case CredentialStore.MacOSKeychain: { | ||
| return new MacOSHandler(); | ||
| } | ||
| case CredentialStore.WindowsCredentialManager: { | ||
| return new WindowsHandler(); | ||
| } | ||
| } | ||
| } | ||
| export { LinuxHandler } from './credential-handlers/linux-handler.js'; | ||
| export { MacOSHandler } from './credential-handlers/macos-handler.js'; | ||
| export { NetrcHandler } from './credential-handlers/netrc-handler.js'; | ||
| export { WindowsHandler } from './credential-handlers/windows-handler.js'; | ||
| export { CredentialStore, getNativeCredentialStore, getStorageConfig } from './lib/credential-storage-selector.js'; | ||
| export { deleteLoginState, readLoginState, writeLoginState } from './lib/login-state.js'; | ||
| export { Netrc, parse } from './lib/netrc-parser.js'; |
| import * as Sentry from '@sentry/node'; | ||
| import type { CredentialStore } from './credential-storage-selector.js'; | ||
| /** Indirection so tests can `sinon.stub` without stubbing the ESM `@sentry/node` namespace. */ | ||
| export declare const credentialSentrySdk: { | ||
| captureException: typeof Sentry.captureException; | ||
| flush: typeof Sentry.flush; | ||
| getClient: typeof Sentry.getClient; | ||
| init: typeof Sentry.init; | ||
| }; | ||
| export declare function shouldReportCredentialErrorsToSentry(): boolean; | ||
| export type CredentialSentryOperation = 'getAuth' | 'listKeychainAccounts' | 'removeAuth' | 'saveAuth'; | ||
| export declare function reportCredentialStoreError(error: unknown, context: { | ||
| credentialStore: CredentialStore; | ||
| operation: CredentialSentryOperation; | ||
| }): Promise<void>; |
| import { GDPR_FIELDS, HEROKU_FIELDS, PCI_FIELDS, PII_PATTERNS, Scrubber, } from '@heroku/js-blanket'; | ||
| import * as Sentry from '@sentry/node'; | ||
| import { readFileSync } from 'node:fs'; | ||
| import { dirname, join } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
| const DSN = 'https://4eb3812769d649a09ae76ef3fcd03dbb@o4508609692368896.ingest.us.sentry.io/4511095245832192'; | ||
| const scrubber = new Scrubber({ | ||
| fields: [...HEROKU_FIELDS, ...GDPR_FIELDS, ...PCI_FIELDS], | ||
| patterns: [...PII_PATTERNS], | ||
| }); | ||
| /** Indirection so tests can `sinon.stub` without stubbing the ESM `@sentry/node` namespace. */ | ||
| export const credentialSentrySdk = { | ||
| captureException: Sentry.captureException.bind(Sentry), | ||
| flush: Sentry.flush.bind(Sentry), | ||
| getClient: Sentry.getClient.bind(Sentry), | ||
| init: Sentry.init.bind(Sentry), | ||
| }; | ||
| let releaseCache; | ||
| let sentryClient; | ||
| function readPackageVersion() { | ||
| if (releaseCache !== undefined) { | ||
| return releaseCache; | ||
| } | ||
| const dir = dirname(fileURLToPath(import.meta.url)); | ||
| const pkgPath = join(dir, '../../../package.json'); | ||
| const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); | ||
| releaseCache = pkg.version ?? 'unknown'; | ||
| return releaseCache; | ||
| } | ||
| export function shouldReportCredentialErrorsToSentry() { | ||
| if (process.env.CI === 'true') { | ||
| return false; | ||
| } | ||
| if (process.env.NODE_ENV === 'test') { | ||
| return false; | ||
| } | ||
| if (process.env.IS_HEROKU_TEST_ENV === 'true') { | ||
| return false; | ||
| } | ||
| if (process.env.DISABLE_TELEMETRY === 'true') { | ||
| return false; | ||
| } | ||
| return true; | ||
| } | ||
| function ensureCredentialSentryInitialized() { | ||
| if (!shouldReportCredentialErrorsToSentry()) { | ||
| return; | ||
| } | ||
| if (sentryClient || credentialSentrySdk.getClient()) { | ||
| return; | ||
| } | ||
| const isDev = process.env.IS_DEV_ENVIRONMENT === 'true'; | ||
| sentryClient = credentialSentrySdk.init({ | ||
| beforeSend(event) { | ||
| const scrubbed = scrubber.scrub(event).data; | ||
| return scrubbed; | ||
| }, | ||
| dsn: DSN, | ||
| environment: isDev ? 'development' : 'production', | ||
| release: `@heroku-cli/command@${readPackageVersion()}`, | ||
| skipOpenTelemetrySetup: true, | ||
| }); | ||
| } | ||
| export async function reportCredentialStoreError(error, context) { | ||
| if (!shouldReportCredentialErrorsToSentry()) { | ||
| return; | ||
| } | ||
| try { | ||
| ensureCredentialSentryInitialized(); | ||
| credentialSentrySdk.captureException(error, { | ||
| tags: { | ||
| component: 'heroku-cli-command', | ||
| credential_operation: context.operation, | ||
| credential_store: context.credentialStore, | ||
| }, | ||
| }); | ||
| await credentialSentrySdk.flush(2000); | ||
| } | ||
| catch { | ||
| // avoid impacting credential flows if Sentry fails | ||
| } | ||
| } |
| export declare const CredentialStore: { | ||
| readonly LinuxSecretService: "linux-secret-service"; | ||
| readonly MacOSKeychain: "macos-keychain"; | ||
| readonly WindowsCredentialManager: "windows-credential-manager"; | ||
| }; | ||
| export type CredentialStore = typeof CredentialStore[keyof typeof CredentialStore]; | ||
| export type StorageConfig = { | ||
| credentialStore: CredentialStore | null; | ||
| useNetrc: boolean; | ||
| }; | ||
| /** | ||
| * Native credential backend for this platform (Keychain, Secret Service, Windows vault). | ||
| * Ignores HEROKU_NETRC_WRITE so logout can clear credentials written before that mode was used. | ||
| */ | ||
| export declare function getNativeCredentialStore(): CredentialStore | null; | ||
| /** | ||
| * Determines whether to use OS-native credential storage, .netrc file, or both. | ||
| * | ||
| * `HEROKU_NETRC_WRITE=true` alone selects legacy netrc-only reads/writes (no native store on the primary path). | ||
| * `HEROKU_NATIVE_STORE_WRITE=true` skips .netrc on the primary path so the OS native store (Keychain, Secret Service, Windows Credential Manager) can be tested in isolation. | ||
| * When both are `true`, credentials use the native store and .netrc (dual path). | ||
| * | ||
| * @returns Object containing storage configuration | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const config = getStorageConfig() | ||
| * if (config.credentialStore === CredentialStore.MacOSKeychain) { | ||
| * // Use macOS handler | ||
| * } | ||
| * if (config.useNetrc) { | ||
| * // Also use netrc handler | ||
| * } | ||
| * ``` | ||
| */ | ||
| export declare function getStorageConfig(): StorageConfig; |
| import childProcess from 'node:child_process'; | ||
| export const CredentialStore = { | ||
| LinuxSecretService: 'linux-secret-service', | ||
| MacOSKeychain: 'macos-keychain', | ||
| WindowsCredentialManager: 'windows-credential-manager', | ||
| }; | ||
| /** | ||
| * Determines whether the secret-tool command is accessible. | ||
| * | ||
| * @returns True if secret-tool is installed and accessible, false otherwise | ||
| */ | ||
| function hasSecretTool() { | ||
| try { | ||
| childProcess.execSync('which secret-tool', { | ||
| stdio: 'ignore', | ||
| }); | ||
| return true; | ||
| } | ||
| catch { | ||
| return false; | ||
| } | ||
| } | ||
| /** | ||
| * Native credential backend for this platform (Keychain, Secret Service, Windows vault). | ||
| * Ignores HEROKU_NETRC_WRITE so logout can clear credentials written before that mode was used. | ||
| */ | ||
| export function getNativeCredentialStore() { | ||
| const { platform } = process; | ||
| switch (platform) { | ||
| case 'darwin': { | ||
| return CredentialStore.MacOSKeychain; | ||
| } | ||
| case 'linux': { | ||
| return hasSecretTool() ? CredentialStore.LinuxSecretService : null; | ||
| } | ||
| case 'win32': { | ||
| return CredentialStore.WindowsCredentialManager; | ||
| } | ||
| default: { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Determines whether to use OS-native credential storage, .netrc file, or both. | ||
| * | ||
| * `HEROKU_NETRC_WRITE=true` alone selects legacy netrc-only reads/writes (no native store on the primary path). | ||
| * `HEROKU_NATIVE_STORE_WRITE=true` skips .netrc on the primary path so the OS native store (Keychain, Secret Service, Windows Credential Manager) can be tested in isolation. | ||
| * When both are `true`, credentials use the native store and .netrc (dual path). | ||
| * | ||
| * @returns Object containing storage configuration | ||
| * | ||
| * @example | ||
| * ```typescript | ||
| * const config = getStorageConfig() | ||
| * if (config.credentialStore === CredentialStore.MacOSKeychain) { | ||
| * // Use macOS handler | ||
| * } | ||
| * if (config.useNetrc) { | ||
| * // Also use netrc handler | ||
| * } | ||
| * ``` | ||
| */ | ||
| export function getStorageConfig() { | ||
| const netrcWriteLegacy = process.env.HEROKU_NETRC_WRITE?.toLowerCase() === 'true'; | ||
| const nativeStoreWriteEnabled = process.env.HEROKU_NATIVE_STORE_WRITE?.toLowerCase() === 'true'; | ||
| if (netrcWriteLegacy && !nativeStoreWriteEnabled) { | ||
| return { | ||
| credentialStore: null, | ||
| useNetrc: true, | ||
| }; | ||
| } | ||
| return { | ||
| credentialStore: getNativeCredentialStore(), | ||
| useNetrc: !nativeStoreWriteEnabled || netrcWriteLegacy, | ||
| }; | ||
| } |
| type LoginState = { | ||
| account: string; | ||
| }; | ||
| export declare function readLoginState(dataDir: string): Promise<LoginState | undefined>; | ||
| export declare function writeLoginState(dataDir: string, account: string): Promise<void>; | ||
| export declare function deleteLoginState(dataDir: string): Promise<void>; | ||
| export {}; |
| import debug from 'debug'; | ||
| import * as fs from 'node:fs'; | ||
| import { join } from 'node:path'; | ||
| const credDebug = debug('heroku-credential-manager'); | ||
| const LOGIN_STATE_FILE = 'login.json'; | ||
| export async function readLoginState(dataDir) { | ||
| const filePath = join(dataDir, LOGIN_STATE_FILE); | ||
| try { | ||
| const content = await fs.promises.readFile(filePath, 'utf8'); | ||
| const parsed = JSON.parse(content); | ||
| if (typeof parsed?.account === 'string' && parsed.account.length > 0) { | ||
| return { account: parsed.account }; | ||
| } | ||
| credDebug('login state file missing valid account field: %s', filePath); | ||
| } | ||
| catch (error) { | ||
| if (error.code !== 'ENOENT') { | ||
| credDebug('failed to read login state file: %s', error.message); | ||
| } | ||
| } | ||
| } | ||
| export async function writeLoginState(dataDir, account) { | ||
| const filePath = join(dataDir, LOGIN_STATE_FILE); | ||
| await fs.promises.mkdir(dataDir, { mode: 0o700, recursive: true }); | ||
| await fs.promises.writeFile(filePath, JSON.stringify({ account }) + '\n', { encoding: 'utf8', mode: 0o600 }); | ||
| } | ||
| export async function deleteLoginState(dataDir) { | ||
| const filePath = join(dataDir, LOGIN_STATE_FILE); | ||
| try { | ||
| await fs.promises.unlink(filePath); | ||
| } | ||
| catch (error) { | ||
| if (error.code !== 'ENOENT') { | ||
| credDebug('failed to delete login state file: %s', error.message); | ||
| } | ||
| } | ||
| } |
| export type Token = MachineToken | { | ||
| content: string; | ||
| type: 'other'; | ||
| }; | ||
| export type MachineToken = { | ||
| comment?: string; | ||
| host: string; | ||
| internalWhitespace: string; | ||
| pre?: string; | ||
| props: { | ||
| [key: string]: { | ||
| comment?: string; | ||
| value: string; | ||
| }; | ||
| }; | ||
| type: 'machine'; | ||
| }; | ||
| export type Machines = { | ||
| [key: string]: { | ||
| [key: string]: string | undefined; | ||
| account?: string; | ||
| login?: string; | ||
| password?: string; | ||
| }; | ||
| }; | ||
| export type MachinesWithTokens = Machines & { | ||
| _tokens?: Token[]; | ||
| }; | ||
| /** | ||
| * Parses a netrc file body into a structured MachinesWithTokens object. | ||
| * Handles both inline and multiline machine definitions, including comments. | ||
| * @param body - The raw string content of a netrc file | ||
| * @returns A proxied MachinesWithTokens object containing all parsed machine entries | ||
| */ | ||
| export declare function parse(body: string): MachinesWithTokens; | ||
| export declare class Netrc { | ||
| file: string; | ||
| machines: MachinesWithTokens; | ||
| /** | ||
| * Creates a new Netrc instance. | ||
| * @param file - Optional path to the netrc file. If not provided, uses the default location. | ||
| */ | ||
| constructor(file?: string); | ||
| /** | ||
| * Gets the default netrc file path based on the operating system. | ||
| * Checks for GPG-encrypted version first. | ||
| * @returns The path to the default netrc file | ||
| */ | ||
| private get defaultFile(); | ||
| /** | ||
| * Gets the GPG command arguments for decrypting the netrc file. | ||
| * @returns Array of GPG command-line arguments for decryption | ||
| */ | ||
| private get gpgDecryptArgs(); | ||
| /** | ||
| * Gets the GPG command arguments for encrypting the netrc file. | ||
| * @returns Array of GPG command-line arguments for encryption | ||
| */ | ||
| private get gpgEncryptArgs(); | ||
| /** | ||
| * Generates the string representation of all machines for writing to file. | ||
| * @returns The formatted netrc file content as a string | ||
| */ | ||
| private get output(); | ||
| /** | ||
| * Asynchronously loads and parses the netrc file. | ||
| * Handles both plain text and GPG-encrypted files. | ||
| * @returns A promise that resolves when loading is complete, or throws on error | ||
| */ | ||
| load(): Promise<never | void>; | ||
| /** | ||
| * Synchronously loads and parses the netrc file. | ||
| * Handles both plain text and GPG-encrypted files. | ||
| * @returns void, or throws on error | ||
| */ | ||
| loadSync(): never | void; | ||
| /** | ||
| * Asynchronously saves the current machines to the netrc file. | ||
| * Handles GPG encryption if the file has a .gpg extension. | ||
| * @returns A promise that resolves when saving is complete | ||
| */ | ||
| save(): Promise<void>; | ||
| /** | ||
| * Synchronously saves the current machines to the netrc file. | ||
| * Handles GPG encryption if the file has a .gpg extension. | ||
| * @returns void, or throws on error | ||
| */ | ||
| saveSync(): void; | ||
| /** | ||
| * Appends a machine token's comment to the output array. | ||
| * @param t - The machine token containing the comment | ||
| * @param output - The output string array to append to | ||
| * @returns void | ||
| */ | ||
| private addCommentToOutput; | ||
| /** | ||
| * Appends a machine token's properties to the output array. | ||
| * Login and password are added first, followed by other properties. | ||
| * @param t - The machine token containing the properties | ||
| * @param output - The output string array to append to | ||
| * @returns void | ||
| */ | ||
| private addPropsToOutput; | ||
| /** | ||
| * Wraps and throws an error with additional context about the netrc file. | ||
| * @param err - The original error | ||
| * @returns Never returns; always throws | ||
| */ | ||
| private throw; | ||
| } | ||
| declare const _default: Netrc; | ||
| export default _default; |
| import debug from 'debug'; | ||
| import { execa, execaSync } from 'execa'; | ||
| import fs from 'node:fs'; | ||
| import os from 'node:os'; | ||
| import path from 'node:path'; | ||
| const credDebug = debug('heroku-credential-manager'); | ||
| /** | ||
| * Creates ES6 proxy objects from parsed tokens to allow easy modification by consumers. | ||
| * This is somewhat complicated, but it takes the array of parsed tokens from parse() | ||
| * and wraps them in proxies that intercept get/set/delete operations. | ||
| * @param tokens - Array of parsed tokens from the netrc file | ||
| * @returns A proxied MachinesWithTokens object that allows direct property access and modification | ||
| */ | ||
| function proxify(tokens) { | ||
| const proxifyProps = (t) => new Proxy(t.props, { | ||
| get(_, key) { | ||
| if (key === 'host') | ||
| return t.host; | ||
| if (typeof key !== 'string') | ||
| return t.props[key]; | ||
| const prop = t.props[key]; | ||
| if (!prop) | ||
| return; | ||
| return prop.value; | ||
| }, | ||
| set(_, key, value) { | ||
| if (key === 'host') { | ||
| t.host = value; | ||
| } | ||
| else if (value) { | ||
| t.props[key] = t.props[key] || (t.props[key] = { value: '' }); | ||
| t.props[key].value = value; | ||
| } | ||
| else { | ||
| delete t.props[key]; | ||
| } | ||
| return true; | ||
| }, | ||
| }); | ||
| const machineTokens = tokens.filter((m) => m.type === 'machine'); | ||
| const machines = machineTokens.map(t => proxifyProps(t)); | ||
| const getWhitespace = () => { | ||
| if (machineTokens.length === 0) | ||
| return ' '; | ||
| return machineTokens.at(-1).internalWhitespace; | ||
| }; | ||
| const obj = {}; | ||
| obj._tokens = tokens; | ||
| for (const m of machines) | ||
| obj[m.host] = m; | ||
| return new Proxy(obj, { | ||
| deleteProperty(obj, host) { | ||
| delete obj[host]; | ||
| const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host); | ||
| if (idx === -1) | ||
| return true; | ||
| tokens.splice(idx, 1); | ||
| return true; | ||
| }, | ||
| ownKeys() { | ||
| return machines.map(m => m.host); | ||
| }, | ||
| set(obj, host, props) { | ||
| if (!props) { | ||
| delete obj[host]; | ||
| const idx = tokens.findIndex(m => m.type === 'machine' && m.host === host); | ||
| if (idx === -1) | ||
| return true; | ||
| tokens.splice(idx, 1); | ||
| return true; | ||
| } | ||
| let machine = machines.find(m => m.host === host); | ||
| if (!machine) { | ||
| const token = { | ||
| host, internalWhitespace: getWhitespace(), props: {}, type: 'machine', | ||
| }; | ||
| tokens.push(token); | ||
| machine = proxifyProps(token); | ||
| machines.push(machine); | ||
| obj[host] = machine; | ||
| } | ||
| for (const [k, v] of Object.entries(props)) { | ||
| machine[k] = v; | ||
| } | ||
| return true; | ||
| }, | ||
| }); | ||
| } | ||
| /** | ||
| * Parses a netrc file body into a structured MachinesWithTokens object. | ||
| * Handles both inline and multiline machine definitions, including comments. | ||
| * @param body - The raw string content of a netrc file | ||
| * @returns A proxied MachinesWithTokens object containing all parsed machine entries | ||
| */ | ||
| export function parse(body) { | ||
| const lines = body.split('\n'); | ||
| let pre = []; | ||
| const machines = []; | ||
| while (lines.length > 0) { | ||
| const line = lines.shift(); | ||
| const match = line.match(/machine\s+((?:[^\s#]+\s*)+)(#.*)?$/); | ||
| if (!match) { | ||
| pre.push(line); | ||
| continue; | ||
| } | ||
| const [, body, comment] = match; | ||
| const machine = { | ||
| comment, | ||
| host: body.split(' ')[0], | ||
| internalWhitespace: '\n ', | ||
| pre: pre.join('\n'), | ||
| props: {}, | ||
| type: 'machine', | ||
| }; | ||
| pre = []; | ||
| // do not read other machines with same host | ||
| if (!machines.some(m => m.type === 'machine' && m.host === machine.host)) | ||
| machines.push(machine); | ||
| if (body.trim().includes(' ')) { // inline machine | ||
| const [host, ...propStrings] = body.split(' '); | ||
| for (let a = 0; a < propStrings.length; a += 2) { | ||
| machine.props[propStrings[a]] = { value: propStrings[a + 1] }; | ||
| } | ||
| machine.host = host; | ||
| machine.internalWhitespace = ' '; | ||
| } | ||
| else { // multiline machine | ||
| while (lines.length > 0) { | ||
| const line = lines.shift(); | ||
| const match = line.match(/^(\s+)(\S+)\s+(\S+)(\s+#.*)?$/); | ||
| if (!match) { | ||
| lines.unshift(line); | ||
| break; | ||
| } | ||
| const [, ws, key, value, comment] = match; | ||
| machine.props[key] = { comment, value }; | ||
| machine.internalWhitespace = `\n${ws}`; | ||
| } | ||
| } | ||
| } | ||
| return proxify([...machines, { content: pre.join('\n'), type: 'other' }]); | ||
| } | ||
| export class Netrc { | ||
| file; | ||
| machines; | ||
| /** | ||
| * Creates a new Netrc instance. | ||
| * @param file - Optional path to the netrc file. If not provided, uses the default location. | ||
| */ | ||
| constructor(file) { | ||
| this.file = file || this.defaultFile; | ||
| } | ||
| /** | ||
| * Gets the default netrc file path based on the operating system. | ||
| * Checks for GPG-encrypted version first. | ||
| * @returns The path to the default netrc file | ||
| */ | ||
| get defaultFile() { | ||
| let home; | ||
| if (os.platform() === 'win32') { | ||
| const fromDrive = process.env.HOMEDRIVE && process.env.HOMEPATH | ||
| ? path.join(process.env.HOMEDRIVE, process.env.HOMEPATH) | ||
| : undefined; | ||
| home = process.env.HOME || fromDrive || process.env.USERPROFILE; | ||
| } | ||
| const resolved = home || os.homedir() || os.tmpdir(); | ||
| const file = path.join(resolved, os.platform() === 'win32' ? '_netrc' : '.netrc'); | ||
| const gpgFile = `${file}.gpg`; | ||
| return fs.existsSync(gpgFile) ? gpgFile : file; | ||
| } | ||
| /** | ||
| * Gets the GPG command arguments for decrypting the netrc file. | ||
| * @returns Array of GPG command-line arguments for decryption | ||
| */ | ||
| get gpgDecryptArgs() { | ||
| const args = ['--batch', '--quiet', '--decrypt', this.file]; | ||
| credDebug('running gpg with args %o', args); | ||
| return args; | ||
| } | ||
| /** | ||
| * Gets the GPG command arguments for encrypting the netrc file. | ||
| * @returns Array of GPG command-line arguments for encryption | ||
| */ | ||
| get gpgEncryptArgs() { | ||
| const args = ['-a', '--batch', '--default-recipient-self', '-e']; | ||
| credDebug('running gpg with args %o', args); | ||
| return args; | ||
| } | ||
| /** | ||
| * Generates the string representation of all machines for writing to file. | ||
| * @returns The formatted netrc file content as a string | ||
| */ | ||
| get output() { | ||
| const output = []; | ||
| if (this.machines._tokens) { | ||
| for (const t of this.machines._tokens) { | ||
| if (t.type === 'other') { | ||
| output.push(t.content); | ||
| continue; | ||
| } | ||
| if (t.pre) { | ||
| output.push(t.pre + '\n'); | ||
| } | ||
| output.push(`machine ${t.host}`); | ||
| if (t.internalWhitespace.includes('\n')) { | ||
| this.addCommentToOutput(t, output); | ||
| this.addPropsToOutput(t, output); | ||
| output.push('\n'); | ||
| } | ||
| else { | ||
| this.addPropsToOutput(t, output); | ||
| this.addCommentToOutput(t, output); | ||
| output.push('\n'); | ||
| } | ||
| } | ||
| } | ||
| return output.join(''); | ||
| } | ||
| /** | ||
| * Asynchronously loads and parses the netrc file. | ||
| * Handles both plain text and GPG-encrypted files. | ||
| * @returns A promise that resolves when loading is complete, or throws on error | ||
| */ | ||
| async load() { | ||
| try { | ||
| credDebug('load', this.file); | ||
| const decryptFile = async () => { | ||
| const { exitCode, stdout } = await execa('gpg', this.gpgDecryptArgs, { reject: false, stdio: ['inherit', 'pipe', 'inherit'] }); | ||
| if (exitCode !== 0) | ||
| throw new Error(`gpg exited with code ${exitCode}`); | ||
| return stdout; | ||
| }; | ||
| const body = await (path.extname(this.file) === '.gpg' | ||
| ? decryptFile() | ||
| : new Promise((resolve, reject) => { | ||
| fs.readFile(this.file, { encoding: 'utf8' }, (err, data) => { | ||
| if (err && err.code !== 'ENOENT') | ||
| reject(err); | ||
| debug('ENOENT'); | ||
| resolve(data || ''); | ||
| }); | ||
| })); | ||
| this.machines = parse(body); | ||
| credDebug('machines: %o', Object.keys(this.machines)); | ||
| } | ||
| catch (error) { | ||
| return this.throw(error); | ||
| } | ||
| } | ||
| /** | ||
| * Synchronously loads and parses the netrc file. | ||
| * Handles both plain text and GPG-encrypted files. | ||
| * @returns void, or throws on error | ||
| */ | ||
| loadSync() { | ||
| try { | ||
| credDebug('loadSync', this.file); | ||
| const decryptFile = () => { | ||
| const { exitCode, stdout } = execaSync('gpg', this.gpgDecryptArgs, { reject: false, stdio: ['inherit', 'pipe', 'inherit'] }); | ||
| if (exitCode !== 0) | ||
| throw new Error(`gpg exited with code ${exitCode}`); | ||
| return stdout; | ||
| }; | ||
| let body = ''; | ||
| if (path.extname(this.file) === '.gpg') { | ||
| body = decryptFile(); | ||
| } | ||
| else { | ||
| try { | ||
| body = fs.readFileSync(this.file, 'utf8'); | ||
| } | ||
| catch (error) { | ||
| if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') | ||
| throw error; | ||
| } | ||
| } | ||
| this.machines = parse(body); | ||
| credDebug('machines: %o', Object.keys(this.machines)); | ||
| } | ||
| catch (error) { | ||
| return this.throw(error); | ||
| } | ||
| } | ||
| /** | ||
| * Asynchronously saves the current machines to the netrc file. | ||
| * Handles GPG encryption if the file has a .gpg extension. | ||
| * @returns A promise that resolves when saving is complete | ||
| */ | ||
| async save() { | ||
| credDebug('save', this.file); | ||
| let body = this.output; | ||
| if (this.file.endsWith('.gpg')) { | ||
| const { exitCode, stdout } = await execa('gpg', this.gpgEncryptArgs, { input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit'] }); | ||
| if (exitCode !== 0) | ||
| throw new Error(`gpg exited with code ${exitCode}`); | ||
| body = stdout; | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
| fs.writeFile(this.file, body, { mode: 0o600 }, err => (err ? reject(err) : resolve())); | ||
| }); | ||
| } | ||
| /** | ||
| * Synchronously saves the current machines to the netrc file. | ||
| * Handles GPG encryption if the file has a .gpg extension. | ||
| * @returns void, or throws on error | ||
| */ | ||
| saveSync() { | ||
| credDebug('saveSync', this.file); | ||
| let body = this.output; | ||
| if (this.file.endsWith('.gpg')) { | ||
| const { exitCode, stdout } = execaSync('gpg', this.gpgEncryptArgs, { input: body, reject: false, stdio: ['pipe', 'pipe', 'inherit'] }); | ||
| if (exitCode !== 0) | ||
| throw new Error(`gpg exited with code ${exitCode}`); | ||
| body = stdout; | ||
| } | ||
| fs.writeFileSync(this.file, body, { mode: 0o600 }); | ||
| } | ||
| /** | ||
| * Appends a machine token's comment to the output array. | ||
| * @param t - The machine token containing the comment | ||
| * @param output - The output string array to append to | ||
| * @returns void | ||
| */ | ||
| addCommentToOutput(t, output) { | ||
| if (t.comment) | ||
| output.push(' ' + t.comment); | ||
| } | ||
| /** | ||
| * Appends a machine token's properties to the output array. | ||
| * Login and password are added first, followed by other properties. | ||
| * @param t - The machine token containing the properties | ||
| * @param output - The output string array to append to | ||
| * @returns void | ||
| */ | ||
| addPropsToOutput(t, output) { | ||
| const addProp = (k) => output.push(`${t.internalWhitespace}${k} ${t.props[k].value}${t.props[k].comment || ''}`); | ||
| // do login/password first | ||
| if (t.props.login) | ||
| addProp('login'); | ||
| if (t.props.password) | ||
| addProp('password'); | ||
| for (const k of Object.keys(t.props).filter(k => !['login', 'password'].includes(k))) { | ||
| addProp(k); | ||
| } | ||
| } | ||
| /** | ||
| * Wraps and throws an error with additional context about the netrc file. | ||
| * @param err - The original error | ||
| * @returns Never returns; always throws | ||
| */ | ||
| throw(err) { | ||
| const error = (err instanceof Error ? err : new Error(String(err))); | ||
| if (error.detail) | ||
| error.detail += '\n'; | ||
| else | ||
| error.detail = ''; | ||
| error.detail += `Error occurred during reading netrc file: ${this.file}`; | ||
| throw error; | ||
| } | ||
| } | ||
| export default new Netrc(); |
| export type NetrcAuthEntry = { | ||
| login: string; | ||
| password: string; | ||
| }; | ||
| export type KeychainAuthEntry = { | ||
| account: string; | ||
| service: string; | ||
| token: string; | ||
| }; | ||
| export type AuthEntry = { | ||
| account: string | undefined; | ||
| token: string | undefined; | ||
| }; |
| export {}; |
| /** | ||
| * Thin wrapper around credential-manager-core so tests can inject a mock | ||
| * (ESM module exports cannot be stubbed with sinon). | ||
| */ | ||
| import { type AuthEntry } from './credential-manager-core/index.js'; | ||
| export interface CredentialManagerProvider { | ||
| getAuth: (account: string | undefined, host: string, service?: string) => Promise<AuthEntry>; | ||
| removeAuth: (account: string | undefined, hosts: string[], service?: string) => Promise<void>; | ||
| saveAuth: (account: string, token: string, hosts: string[], service?: string) => Promise<void>; | ||
| } | ||
| export declare function setCredentialManagerProvider(p: CredentialManagerProvider): void; | ||
| export declare function getAuth(account: string | undefined, host: string, service?: string): Promise<AuthEntry>; | ||
| export declare function removeAuth(account: string | undefined, hosts: string[], service?: string): Promise<void>; | ||
| export declare function saveAuth(account: string, token: string, hosts: string[], service?: string): Promise<void>; | ||
| export type { AuthEntry } from './credential-manager-core/index.js'; |
| /** | ||
| * Thin wrapper around credential-manager-core so tests can inject a mock | ||
| * (ESM module exports cannot be stubbed with sinon). | ||
| */ | ||
| import { getAuth as realGetAuth, removeAuth as realRemoveAuth, saveAuth as realSaveAuth, } from './credential-manager-core/index.js'; | ||
| let provider = { | ||
| getAuth: realGetAuth, | ||
| removeAuth: realRemoveAuth, | ||
| saveAuth: realSaveAuth, | ||
| }; | ||
| export function setCredentialManagerProvider(p) { | ||
| provider = p; | ||
| } | ||
| export async function getAuth(account, host, service) { | ||
| return provider.getAuth(account, host, service); | ||
| } | ||
| export async function removeAuth(account, hosts, service) { | ||
| return provider.removeAuth(account, hosts, service); | ||
| } | ||
| export async function saveAuth(account, token, hosts, service) { | ||
| return provider.saveAuth(account, token, hosts, service); | ||
| } |
+11
-0
| import type { Config } from '@oclif/core/interfaces'; | ||
| import { HTTP, HTTPError, HTTPRequestOptions } from '@heroku/http-call'; | ||
| import { CLIError } from '@oclif/core/errors'; | ||
| import { type AuthEntry } from './credential-manager.js'; | ||
| import { Login } from './login.js'; | ||
@@ -43,5 +44,10 @@ import { Mutex } from './mutex.js'; | ||
| }; | ||
| private _account?; | ||
| private _auth?; | ||
| private readonly _login; | ||
| private _particleboard; | ||
| /** In-flight dedupe for concurrent getAuthEntry() calls before resolution completes. */ | ||
| private _storedAuthPromise?; | ||
| /** After a failed read from storage, skip re-querying until state is reset (login/logout/auth setter). */ | ||
| private _storedAuthResolvedAbsent; | ||
| private _twoFactorMutex; | ||
@@ -56,2 +62,4 @@ constructor(config: Config, options?: IOptions); | ||
| get<T>(url: string, options?: APIClient.Options): Promise<HTTP<T>>; | ||
| getAuth(): Promise<string | undefined>; | ||
| getAuthEntry(): Promise<AuthEntry | undefined>; | ||
| login(opts?: Login.Options): Promise<void>; | ||
@@ -64,4 +72,7 @@ logout(): Promise<void>; | ||
| request<T>(url: string, options?: APIClient.Options): Promise<HTTP<T>>; | ||
| setAuthEntry(entry: AuthEntry | undefined): void; | ||
| stream(url: string, options?: APIClient.Options): Promise<HTTP<unknown>>; | ||
| twoFactorPrompt(): Promise<string>; | ||
| private clearLoginState; | ||
| private resetStoredAuthResolution; | ||
| } |
+81
-29
| import { HTTP, HTTPError } from '@heroku/http-call'; | ||
| import { CLIError, warn } from '@oclif/core/errors'; | ||
| import debug from 'debug'; | ||
| import { Netrc } from 'netrc-parser'; | ||
| import * as url from 'node:url'; | ||
| import { getStorageConfig } from './credential-manager-core/lib/credential-storage-selector.js'; | ||
| import { deleteLoginState, readLoginState } from './credential-manager-core/lib/login-state.js'; | ||
| import { getAuth as getStoredAuth, removeAuth } from './credential-manager.js'; | ||
| import { Login } from './login.js'; | ||
@@ -13,10 +15,2 @@ import { Mutex } from './mutex.js'; | ||
| import { yubikey } from './yubikey.js'; | ||
| // Defer netrc instantiation to avoid eager .netrc file operations at module load time | ||
| let _netrc; | ||
| function getNetrc() { | ||
| if (!_netrc) { | ||
| _netrc = new Netrc(); | ||
| } | ||
| return _netrc; | ||
| } | ||
| export const ALLOWED_HEROKU_DOMAINS = Object.freeze(['heroku.com', 'herokai.com', 'herokuspace.com', 'herokudev.com']); | ||
@@ -54,5 +48,10 @@ export const LOCALHOST_DOMAINS = Object.freeze(['localhost', '127.0.0.1']); | ||
| preauthPromises; | ||
| _account; | ||
| _auth; | ||
| _login; | ||
| _particleboard; | ||
| /** In-flight dedupe for concurrent getAuthEntry() calls before resolution completes. */ | ||
| _storedAuthPromise; | ||
| /** After a failed read from storage, skip re-querying until state is reset (login/logout/auth setter). */ | ||
| _storedAuthResolvedAbsent = false; | ||
| _twoFactorMutex; | ||
@@ -146,2 +145,3 @@ constructor(config, options = {}) { | ||
| } | ||
| let auth; | ||
| if (!Object.keys(opts.headers).some(h => h.toLowerCase() === 'authorization')) { | ||
@@ -161,3 +161,4 @@ // Handle both relative and absolute URLs for validation | ||
| if (isHerokuApi || isLocalhost) { | ||
| opts.headers.authorization = `Bearer ${self.auth}`; | ||
| auth = await self.getAuth(); | ||
| opts.headers.authorization = `Bearer ${auth}`; | ||
| } | ||
@@ -170,5 +171,5 @@ } | ||
| let particleboardResponse; | ||
| const particleboardClient = self.particleboard; | ||
| if (delinquencyConfig.fetch_delinquency && !delinquencyConfig.warning_shown) { | ||
| self._particleboard.auth = self.auth; | ||
| const particleboardClient = self.particleboard; | ||
| particleboardClient.auth = auth ?? await self.getAuth(); | ||
| const settledResponses = await Promise.allSettled([ | ||
@@ -201,3 +202,3 @@ super.request(url, opts), | ||
| if (retries > 0) { | ||
| if (opts.retryAuth !== false && error.http.statusCode === 401 && error.body.id === 'unauthorized') { | ||
| if (opts.retryAuth !== false && error.http.statusCode === 401) { | ||
| if (process.env.HEROKU_API_KEY) { | ||
@@ -209,3 +210,4 @@ throw new Error('The token provided to HEROKU_API_KEY is invalid. Please double-check that you have the correct token, or run `heroku login` without HEROKU_API_KEY set.'); | ||
| await self.authPromise; | ||
| opts.headers.authorization = `Bearer ${self.auth}`; | ||
| const retryAuth = await self.getAuth(); | ||
| opts.headers.authorization = `Bearer ${retryAuth}`; | ||
| return this.request(url, opts, retries); | ||
@@ -260,12 +262,2 @@ } | ||
| get auth() { | ||
| if (!this._auth) { | ||
| if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY) | ||
| warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY'); | ||
| this._auth = process.env.HEROKU_API_KEY; | ||
| if (!this._auth) { | ||
| const netrc = getNetrc(); | ||
| netrc.loadSync(); | ||
| this._auth = netrc.machines[vars.apiHost] && netrc.machines[vars.apiHost].password; | ||
| } | ||
| } | ||
| return this._auth; | ||
@@ -298,2 +290,46 @@ } | ||
| } | ||
| async getAuth() { | ||
| const authEntry = await this.getAuthEntry(); | ||
| return authEntry?.token; | ||
| } | ||
| async getAuthEntry() { | ||
| if (this._auth) | ||
| return { account: this._account, token: this._auth }; | ||
| if (process.env.HEROKU_API_TOKEN && !process.env.HEROKU_API_KEY) | ||
| warn('HEROKU_API_TOKEN is set but you probably meant HEROKU_API_KEY'); | ||
| if (process.env.HEROKU_API_KEY) { | ||
| this._account = undefined; | ||
| this._auth = process.env.HEROKU_API_KEY; | ||
| return { account: this._account, token: this._auth }; | ||
| } | ||
| if (this._storedAuthResolvedAbsent) | ||
| return undefined; | ||
| if (!this._storedAuthPromise) { | ||
| this._storedAuthPromise = (async () => { | ||
| const { credentialStore } = getStorageConfig(); | ||
| const useLoginState = Boolean(credentialStore && this.config.dataDir); | ||
| try { | ||
| const cachedAccount = useLoginState | ||
| ? (await readLoginState(this.config.dataDir))?.account | ||
| : undefined; | ||
| const { account, token } = await getStoredAuth(cachedAccount, vars.apiHost); | ||
| this._auth = token; | ||
| this._account = account; | ||
| this._storedAuthResolvedAbsent = false; | ||
| return { account: this._account, token: this._auth }; | ||
| } | ||
| catch { | ||
| if (useLoginState) { | ||
| await deleteLoginState(this.config.dataDir); | ||
| } | ||
| this._storedAuthResolvedAbsent = true; | ||
| return undefined; | ||
| } | ||
| finally { | ||
| this._storedAuthPromise = undefined; | ||
| } | ||
| })(); | ||
| } | ||
| return this._storedAuthPromise; | ||
| } | ||
| login(opts = {}) { | ||
@@ -303,4 +339,5 @@ return this._login.login(opts); | ||
| async logout() { | ||
| const entry = await this.getAuthEntry(); | ||
| try { | ||
| await this._login.logout(); | ||
| await this._login.logout(entry?.token); | ||
| } | ||
@@ -311,6 +348,5 @@ catch (error) { | ||
| } | ||
| const netrc = getNetrc(); | ||
| delete netrc.machines['api.heroku.com']; | ||
| delete netrc.machines['git.heroku.com']; | ||
| await netrc.save(); | ||
| this.setAuthEntry(undefined); | ||
| await removeAuth(entry?.account, [vars.apiHost, vars.httpGitHost]); | ||
| await this.clearLoginState(); | ||
| } | ||
@@ -334,2 +370,8 @@ patch(url, options = {}) { | ||
| } | ||
| setAuthEntry(entry) { | ||
| delete this.authPromise; | ||
| this._auth = entry?.token; | ||
| this._account = entry?.account; | ||
| this.resetStoredAuthResolution(); | ||
| } | ||
| stream(url, options = {}) { | ||
@@ -357,2 +399,12 @@ return this.http.stream(url, options); | ||
| } | ||
| async clearLoginState() { | ||
| const config = getStorageConfig(); | ||
| if (config.credentialStore && this.config.dataDir) { | ||
| await deleteLoginState(this.config.dataDir); | ||
| } | ||
| } | ||
| resetStoredAuthResolution() { | ||
| this._storedAuthPromise = undefined; | ||
| this._storedAuthResolvedAbsent = false; | ||
| } | ||
| } |
+2
-0
@@ -49,2 +49,4 @@ import { Command as Base } from '@oclif/core/command'; | ||
| await super.init(); | ||
| // Preload credentials so this.heroku.auth is set before run() (async credential-manager + sync checks). | ||
| await this.heroku.getAuth(); | ||
| if (!this.isPromptModeActive()) { | ||
@@ -51,0 +53,0 @@ return; |
+1
-0
| export * from './api-client.js'; | ||
| export { Command, Command as default } from './command.js'; | ||
| export * from './completions.js'; | ||
| export * from './credential-manager-core/index.js'; | ||
| export * as flags from './flags/index.js'; | ||
@@ -5,0 +6,0 @@ export * from './git.js'; |
+1
-0
| export * from './api-client.js'; | ||
| export { Command, Command as default } from './command.js'; | ||
| export * from './completions.js'; | ||
| export * from './credential-manager-core/index.js'; | ||
| export * as flags from './flags/index.js'; | ||
@@ -5,0 +6,0 @@ export * from './git.js'; |
+2
-1
@@ -16,3 +16,3 @@ import type { Config } from '@oclif/core/interfaces'; | ||
| login(opts?: Login.Options): Promise<void>; | ||
| logout(token?: string | undefined): Promise<void>; | ||
| logout(token?: string): Promise<void>; | ||
| private browser; | ||
@@ -23,2 +23,3 @@ private createOAuthToken; | ||
| private interactive; | ||
| private isCurrentOAuthToken; | ||
| private saveToken; | ||
@@ -25,0 +26,0 @@ private showManualBrowserLoginUrl; |
+43
-54
@@ -5,6 +5,8 @@ import { HTTP } from '@heroku/http-call'; | ||
| import debug from 'debug'; | ||
| import { Netrc } from 'netrc-parser'; | ||
| import os from 'node:os'; | ||
| import * as readline from 'node:readline'; | ||
| import { HerokuAPIError } from './api-client.js'; | ||
| import { getStorageConfig } from './credential-manager-core/lib/credential-storage-selector.js'; | ||
| import { writeLoginState } from './credential-manager-core/lib/login-state.js'; | ||
| import { saveAuth } from './credential-manager.js'; | ||
| import { prompter } from './prompter.js'; | ||
@@ -15,10 +17,3 @@ import { vars } from './vars.js'; | ||
| const thirtyDays = 60 * 60 * 24 * 30; | ||
| // Defer netrc instantiation to avoid eager file operations | ||
| let _netrc; | ||
| function getNetrc() { | ||
| if (!_netrc) { | ||
| _netrc = new Netrc(); | ||
| } | ||
| return _netrc; | ||
| } | ||
| const REDACTED_TOKEN_ASTERISKS = '*'.repeat(10); | ||
| const headers = (token) => ({ headers: { accept: 'application/vnd.heroku+json; version=3', authorization: `Bearer ${token}` } }); | ||
@@ -45,5 +40,6 @@ export class Login { | ||
| ux.error('Cannot set an expiration longer than thirty days'); | ||
| const netrc = getNetrc(); | ||
| await netrc.load(); | ||
| const previousEntry = netrc.machines['api.heroku.com']; | ||
| const previousToken = await this.heroku.getAuth(); | ||
| const previousAccount = previousToken | ||
| ? (await this.heroku.getAuthEntry())?.account?.trim() || undefined | ||
| : undefined; | ||
| let input = opts.method; | ||
@@ -80,10 +76,2 @@ if (!input) { | ||
| } | ||
| try { | ||
| if (previousEntry && previousEntry.password) | ||
| await this.logout(previousEntry.password); | ||
| } | ||
| catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| ux.warn(message); | ||
| } | ||
| let auth; | ||
@@ -98,3 +86,3 @@ switch (input) { | ||
| case 'interactive': { | ||
| auth = await this.interactive(previousEntry && previousEntry.login, opts.expiresIn); | ||
| auth = await this.interactive(previousAccount, opts.expiresIn); | ||
| break; | ||
@@ -120,4 +108,5 @@ } | ||
| } | ||
| async logout(token = this.heroku.auth) { | ||
| if (!token) | ||
| async logout(token) { | ||
| const resolvedToken = token ?? await this.heroku.getAuth(); | ||
| if (!resolvedToken) | ||
| return cliDebug('no credentials to logout'); | ||
@@ -127,3 +116,3 @@ const requests = []; | ||
| // authorizations because they are created a trusted client | ||
| requests.push(HTTP.delete(`${vars.apiUrl}/oauth/sessions/~`, headers(token)) | ||
| requests.push(HTTP.delete(`${vars.apiUrl}/oauth/sessions/~`, headers(resolvedToken)) | ||
| .catch(error => { | ||
@@ -135,3 +124,3 @@ if (!error.http) | ||
| } | ||
| if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { | ||
| if (error.http.statusCode === 401) { | ||
| return; | ||
@@ -144,3 +133,3 @@ } | ||
| // the ~ is the API Key, not the authorization that is currently requesting | ||
| requests.push(HTTP.get(`${vars.apiUrl}/oauth/authorizations`, headers(token)) | ||
| requests.push(HTTP.get(`${vars.apiUrl}/oauth/authorizations`, headers(resolvedToken)) | ||
| .then(async ({ body: authorizations }) => { | ||
@@ -150,8 +139,8 @@ // grab the default authorization because that is the token shown in the | ||
| // would unwittingly break an integration that they are depending on | ||
| const d = await this.defaultToken(); | ||
| if (d === token) | ||
| const defaultApiToken = await this.defaultToken(); | ||
| if (defaultApiToken && this.isCurrentOAuthToken(resolvedToken, defaultApiToken)) | ||
| return; | ||
| return Promise.all(authorizations | ||
| .filter(a => a.access_token && a.access_token.token === this.heroku.auth) | ||
| .map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(token)))); | ||
| .filter(a => a.access_token?.token && this.isCurrentOAuthToken(resolvedToken, a.access_token.token)) | ||
| .map(a => HTTP.delete(`${vars.apiUrl}/oauth/authorizations/${a.id}`, headers(resolvedToken)))); | ||
| }) | ||
@@ -161,3 +150,3 @@ .catch(error => { | ||
| throw error; | ||
| if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') { | ||
| if (error.http.statusCode === 401) { | ||
| return []; | ||
@@ -211,6 +200,6 @@ } | ||
| ux.error(auth.error); | ||
| this.heroku.auth = auth.access_token; | ||
| ux.action.start('Logging in'); | ||
| const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(auth.access_token)); | ||
| ux.action.stop(); | ||
| this.heroku.setAuthEntry({ account: account.email, token: auth.access_token }); | ||
| return { | ||
@@ -244,4 +233,7 @@ login: account.email, | ||
| async defaultToken() { | ||
| const token = await this.heroku.getAuth(); | ||
| if (!token) | ||
| return; | ||
| try { | ||
| const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(this.heroku.auth)); | ||
| const { body: authorization } = await HTTP.get(`${vars.apiUrl}/oauth/authorizations/~`, headers(token)); | ||
| return authorization.access_token && authorization.access_token.token; | ||
@@ -252,5 +244,5 @@ } | ||
| throw error; | ||
| if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.body.resource === 'authorization') | ||
| if (error.http.statusCode === 404 && error.http.body && error.http.body.id === 'not_found' && error.http.body.resource === 'authorization') | ||
| return; | ||
| if (error.http.statusCode === 401 && error.http.body && error.http.body.id === 'unauthorized') | ||
| if (error.http.statusCode === 401) | ||
| return; | ||
@@ -302,24 +294,21 @@ throw error; | ||
| } | ||
| this.heroku.auth = auth.password; | ||
| this.heroku.setAuthEntry({ account: auth.login, token: auth.password }); | ||
| return auth; | ||
| } | ||
| isCurrentOAuthToken(localToken, apiToken) { | ||
| const asteriskIndex = apiToken.indexOf(REDACTED_TOKEN_ASTERISKS); | ||
| if (asteriskIndex === -1) { | ||
| // raw value stored, direct match works | ||
| return localToken === apiToken; | ||
| } | ||
| const prefix = apiToken.slice(0, asteriskIndex); | ||
| const suffix = apiToken.slice(asteriskIndex + REDACTED_TOKEN_ASTERISKS.length); | ||
| return localToken.startsWith(prefix) && (suffix === '' || localToken.endsWith(suffix)); | ||
| } | ||
| async saveToken(entry) { | ||
| const netrc = getNetrc(); | ||
| const hosts = [vars.apiHost, vars.httpGitHost]; | ||
| for (const host of hosts) { | ||
| if (!netrc.machines[host]) | ||
| netrc.machines[host] = {}; | ||
| netrc.machines[host].login = entry.login; | ||
| netrc.machines[host].password = entry.password; | ||
| delete netrc.machines[host].method; | ||
| delete netrc.machines[host].org; | ||
| await saveAuth(entry.login, entry.password, [vars.apiHost, vars.httpGitHost]); | ||
| const config = getStorageConfig(); | ||
| if (config.credentialStore && this.config.dataDir) { | ||
| await writeLoginState(this.config.dataDir, entry.login); | ||
| } | ||
| if (netrc.machines._tokens) { | ||
| netrc.machines._tokens.forEach((token) => { | ||
| if (hosts.includes(token.host)) { | ||
| token.internalWhitespace = '\n '; | ||
| } | ||
| }); | ||
| } | ||
| await netrc.save(); | ||
| } | ||
@@ -356,5 +345,5 @@ showManualBrowserLoginUrl(url) { | ||
| ux.action.start('Validating token'); | ||
| this.heroku.auth = password; | ||
| const { body: account } = await HTTP.get(`${vars.apiUrl}/account`, headers(password)); | ||
| ux.action.stop(); | ||
| this.heroku.setAuthEntry({ account: account.email, token: password }); | ||
| return { | ||
@@ -361,0 +350,0 @@ login: account.email, |
+11
-3
| { | ||
| "name": "@heroku-cli/command", | ||
| "description": "base class for Heroku CLI commands", | ||
| "version": "12.3.3", | ||
| "version": "12.4.0-beta.0", | ||
| "author": "Heroku", | ||
| "bugs": "https://github.com/heroku/heroku-cli-command/issues", | ||
| "dependencies": { | ||
| "@heroku/js-blanket": "^1.0.0", | ||
| "@heroku/http-call": "^5.4.0", | ||
| "@oclif/core": "^4.3.0", | ||
| "@sentry/node": "^10.45.0", | ||
| "ansis": "^4", | ||
| "debug": "^4.4.0", | ||
| "execa": "^9.6.1", | ||
| "inquirer": "^12.11.1", | ||
| "netrc-parser": "^3.1.6", | ||
| "open": "^11.0.0", | ||
| "tsheredoc": "^1.0.1", | ||
| "yargs-parser": "^20.2.9", | ||
@@ -22,3 +25,5 @@ "yargs-unparser": "^2.0.0" | ||
| "@types/chai": "^5.2.2", | ||
| "@types/chai-as-promised": "^8.0.2", | ||
| "@types/debug": "^4.1.12", | ||
| "@types/fs-extra": "^11.0.4", | ||
| "@types/mocha": "^10.0.6", | ||
@@ -34,5 +39,7 @@ "@types/node": "22.15.21", | ||
| "chai": "^6.2.2", | ||
| "chai-as-promised": "^8.0.1", | ||
| "eslint": "^9", | ||
| "eslint-config-oclif": "^6.0.152", | ||
| "fancy-test": "^3.0.16", | ||
| "fs-extra": "^11.3.0", | ||
| "mocha": "^11", | ||
@@ -65,4 +72,5 @@ "nock": "^14.0.1", | ||
| "prepare": "npm run build", | ||
| "test": "c8 --all --check-coverage --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.test.ts\"", | ||
| "test": "c8 --all --check-coverage --reporter=lcov --reporter=text-summary mocha --forbid-only \"test/**/*.test.ts\" --ignore \"test/**/acceptance/**/*.test.ts\"", | ||
| "test:file": "c8 mocha", | ||
| "test:ci:acceptance": "c8 mocha --forbid-only \"test/credential-manager/acceptance/index.acceptance.test.ts\"", | ||
| "example": "sh examples/run.sh" | ||
@@ -69,0 +77,0 @@ }, |
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 29 instances in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 2 instances in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 17 instances in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
158109
84.93%67
48.89%3680
95.54%1
-66.67%12
33.33%28
16.67%1
Infinity%50
85.19%+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed
- Removed