Add Codex OAuth provider flow for ChatGPT account sign-in (#503)
* feat: add Codex OAuth provider flow * fix: harden Codex OAuth storage, session activation, and UI
This commit is contained in:
committed by
GitHub
parent
252808bbd0
commit
fc7dc9ca0d
@@ -1,4 +1,6 @@
|
||||
import { execaSync } from 'execa'
|
||||
import { join } from 'path'
|
||||
import { getClaudeConfigHomeDir } from '../envUtils.js'
|
||||
import { jsonParse, jsonStringify } from '../slowOperations.js'
|
||||
import {
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
@@ -8,90 +10,216 @@ import {
|
||||
import type { SecureStorage, SecureStorageData } from './index.js'
|
||||
|
||||
/**
|
||||
* Windows-specific secure storage implementation using the Windows Credential Locker.
|
||||
* Accessed via PowerShell's [Windows.Security.Credentials.PasswordVault].
|
||||
* Windows-specific secure storage implementation using DPAPI for new writes,
|
||||
* with best-effort reads/deletes from the legacy PasswordVault path.
|
||||
*/
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker',
|
||||
read(): SecureStorageData | null {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to retrieve password from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
function escapePowerShellSingleQuoted(value: string): string {
|
||||
return value.replace(/'/g, "''")
|
||||
}
|
||||
|
||||
function getLegacyResourceName(): string {
|
||||
return getSecureStorageServiceName(CREDENTIALS_SERVICE_SUFFIX)
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageEntropy(): string {
|
||||
return `${getLegacyResourceName()}:${getUsername()}`
|
||||
}
|
||||
|
||||
function getWindowsSecureStorageFilePath(): string {
|
||||
const resourceName = getLegacyResourceName().replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
return join(getClaudeConfigHomeDir(), `${resourceName}.secure.dpapi`)
|
||||
}
|
||||
|
||||
function runPowerShell(
|
||||
script: string,
|
||||
options?: { input?: string },
|
||||
): ReturnType<typeof execaSync> | null {
|
||||
try {
|
||||
return execaSync('powershell.exe', ['-Command', script], {
|
||||
input: options?.input,
|
||||
reject: false,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getFailureWarning(
|
||||
result: ReturnType<typeof execaSync> | null,
|
||||
fallback: string,
|
||||
): string {
|
||||
const stderr = result?.stderr?.trim()
|
||||
if (stderr) {
|
||||
return stderr
|
||||
}
|
||||
|
||||
if (typeof result?.exitCode === 'number' && result.exitCode !== 0) {
|
||||
return `${fallback} (exit code ${result.exitCode}).`
|
||||
}
|
||||
|
||||
return fallback
|
||||
}
|
||||
|
||||
function readLegacyPasswordVault(): SecureStorageData | null {
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
[Console]::Out.Write($cred.Password)
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const windowsCredentialStorage: SecureStorage = {
|
||||
name: 'credential-locker-dpapi',
|
||||
read(): SecureStorageData | null {
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const script = `
|
||||
try {
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$cred.FillPassword()
|
||||
$cred.Password
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
if (!(Test-Path -LiteralPath $path)) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBase64 = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
).Trim()
|
||||
if (-not $protectedBase64) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$protectedBytes = [Convert]::FromBase64String($protectedBase64)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$bytes = [System.Security.Cryptography.ProtectedData]::Unprotect(
|
||||
$protectedBytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
[Console]::Out.Write([System.Text.Encoding]::UTF8.GetString($bytes))
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
|
||||
const result = runPowerShell(script)
|
||||
if (result?.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
return jsonParse(result.stdout)
|
||||
} catch {
|
||||
return readLegacyPasswordVault()
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return null
|
||||
|
||||
return readLegacyPasswordVault()
|
||||
},
|
||||
async readAsync(): Promise<SecureStorageData | null> {
|
||||
return this.read()
|
||||
},
|
||||
update(data: SecureStorageData): { success: boolean; warning?: string } {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// Use single quotes for the payload and escape ' by doubling it ('').
|
||||
// This prevents PowerShell from expanding $... inside the string.
|
||||
const payload = jsonStringify(data).replace(/'/g, "''")
|
||||
// PowerShell script to add/update credential in vault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const entropy = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageEntropy(),
|
||||
)
|
||||
const payload = jsonStringify(data)
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = New-Object Windows.Security.Credentials.PasswordCredential("${resourceName}", "${username}", '${payload}')
|
||||
$vault.Add($cred)
|
||||
try {
|
||||
Add-Type -AssemblyName System.Security
|
||||
$path = '${filePath}'
|
||||
$directory = [System.IO.Path]::GetDirectoryName($path)
|
||||
if ($directory) {
|
||||
[System.IO.Directory]::CreateDirectory($directory) | Out-Null
|
||||
}
|
||||
|
||||
$payload = [Console]::In.ReadToEnd()
|
||||
$bytes = [System.Text.Encoding]::UTF8.GetBytes($payload)
|
||||
$entropyBytes = [System.Text.Encoding]::UTF8.GetBytes('${entropy}')
|
||||
$protectedBytes = [System.Security.Cryptography.ProtectedData]::Protect(
|
||||
$bytes,
|
||||
$entropyBytes,
|
||||
[System.Security.Cryptography.DataProtectionScope]::CurrentUser
|
||||
)
|
||||
$protectedBase64 = [Convert]::ToBase64String($protectedBytes)
|
||||
[System.IO.File]::WriteAllText(
|
||||
$path,
|
||||
$protectedBase64,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
} catch {
|
||||
Write-Error $_.Exception.Message
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return { success: result.exitCode === 0 }
|
||||
} catch {
|
||||
return { success: false }
|
||||
const result = runPowerShell(script, { input: payload })
|
||||
if (result?.exitCode === 0) {
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
warning: getFailureWarning(
|
||||
result,
|
||||
'Windows secure storage could not encrypt credentials with DPAPI',
|
||||
),
|
||||
}
|
||||
},
|
||||
delete(): boolean {
|
||||
const resourceName = getSecureStorageServiceName(
|
||||
CREDENTIALS_SERVICE_SUFFIX,
|
||||
).replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
// PowerShell script to remove credential from vault
|
||||
const script = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
const filePath = escapePowerShellSingleQuoted(
|
||||
getWindowsSecureStorageFilePath(),
|
||||
)
|
||||
const removeDpapiScript = `
|
||||
try {
|
||||
$path = '${filePath}'
|
||||
if (Test-Path -LiteralPath $path) {
|
||||
Remove-Item -LiteralPath $path -Force
|
||||
}
|
||||
} catch {
|
||||
exit 1
|
||||
}
|
||||
`
|
||||
const removeDpapiResult = runPowerShell(removeDpapiScript)
|
||||
|
||||
const resourceName = getLegacyResourceName().replace(/"/g, '`"')
|
||||
const username = getUsername().replace(/"/g, '`"')
|
||||
const removeLegacyScript = `
|
||||
Add-Type -AssemblyName System.Runtime.WindowsRuntime
|
||||
try {
|
||||
$vault = New-Object Windows.Security.Credentials.PasswordVault
|
||||
$cred = $vault.Retrieve("${resourceName}", "${username}")
|
||||
$vault.Remove($cred)
|
||||
} catch {
|
||||
exit 0
|
||||
}
|
||||
`
|
||||
try {
|
||||
const result = execaSync('powershell.exe', ['-Command', script], {
|
||||
reject: false,
|
||||
})
|
||||
return result.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const removeLegacyResult = runPowerShell(removeLegacyScript)
|
||||
|
||||
void removeLegacyResult
|
||||
|
||||
return (removeDpapiResult?.exitCode ?? 1) === 0
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user