# Git credential helper for Polar (RFC 8628 device flow). # # Usage: # git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1" # # On "get" the helper reuses a cached token for the server, otherwise it starts # a device authorization flow, opens the verification page in the browser and # waits until the request is approved. Tokens are cached in # ~/.polar/credentials.json. param([Parameter(Position = 0)][string]$Operation = 'get') $ErrorActionPreference = 'Stop' function Read-CredentialInput { $values = @{} while ($null -ne ($line = [Console]::In.ReadLine())) { if ($line -match '^([^=]+)=(.*)$') { $values[$Matches[1]] = $Matches[2] } } return $values } function Get-ServerBase([hashtable]$values) { if ($env:POLAR_URL) { return $env:POLAR_URL.TrimEnd('/') } $hostName = $values['host'] if ($hostName) { $scheme = $values['protocol'] if (-not $scheme) { $scheme = 'https' } return ('{0}://{1}' -f $scheme, $hostName).TrimEnd('/') } return 'http://localhost:3100' } function Get-CachePath { $dir = Join-Path $HOME '.polar' if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } return (Join-Path $dir 'credentials.json') } function Read-Cache { $path = Get-CachePath if (-not (Test-Path $path)) { return @{} } try { $raw = Get-Content -Raw -Path $path | ConvertFrom-Json $table = @{} foreach ($prop in $raw.PSObject.Properties) { $table[$prop.Name] = @{ username = $prop.Value.username; token = $prop.Value.token } } return $table } catch { return @{} } } function Write-Cache([hashtable]$cache) { $obj = [ordered]@{} foreach ($key in $cache.Keys) { $obj[$key] = @{ username = $cache[$key].username; token = $cache[$key].token } } ($obj | ConvertTo-Json -Depth 4) | Set-Content -Path (Get-CachePath) -Encoding UTF8 } function Write-Credential([string]$user, [string]$token) { [Console]::Out.WriteLine('username=' + $user) [Console]::Out.WriteLine('password=' + $token) } function Read-ErrorBody($exception) { try { $stream = $exception.Response.GetResponseStream() $reader = New-Object System.IO.StreamReader($stream) return $reader.ReadToEnd() } catch { return '' } } $values = Read-CredentialInput $base = Get-ServerBase $values $cache = Read-Cache switch ($Operation) { 'get' { if ($cache.ContainsKey($base)) { Write-Credential $cache[$base].username $cache[$base].token exit 0 } $clientId = 'git-credential-polar' $code = $null try { $code = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/code" -Body @{ client_id = $clientId } } catch { [Console]::Error.WriteLine("polar: could not reach $base/api/v1/device/code: $($_.Exception.Message)") exit 1 } [Console]::Error.WriteLine('') [Console]::Error.WriteLine("polar: open $($code.verification_uri) and enter code $($code.user_code)") [Console]::Error.WriteLine('polar: waiting for approval...') try { Start-Process $code.verification_uri_complete | Out-Null } catch { } $interval = 5 if ($code.interval) { $interval = [int]$code.interval } if ($interval -lt 3) { $interval = 3 } $expiresIn = 900 if ($code.expires_in) { $expiresIn = [int]$code.expires_in } $deadline = (Get-Date).AddSeconds($expiresIn) while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $interval try { $resp = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/token" -Body @{ device_code = $code.device_code; client_id = $clientId } if ($resp.access_token) { $username = $resp.username if (-not $username) { $username = 'x-access-token' } $cache[$base] = @{ username = $username; token = $resp.access_token } Write-Cache $cache Write-Credential $username $resp.access_token exit 0 } } catch { $status = 0 try { $status = [int]$_.Exception.Response.StatusCode } catch { } if ($status -eq 429) { continue } $body = Read-ErrorBody $_.Exception if ($body -match 'access_denied') { [Console]::Error.WriteLine('polar: access denied') exit 1 } if ($body -match 'expired_token') { [Console]::Error.WriteLine('polar: device code expired, run the command again') exit 1 } [Console]::Error.WriteLine("polar: authorization failed: $body") exit 1 } } [Console]::Error.WriteLine('polar: timed out waiting for approval') exit 1 } 'store' { # The helper keeps its own cache; nothing to do. exit 0 } 'erase' { if ($cache.ContainsKey($base)) { $cache.Remove($base) Write-Cache $cache } exit 0 } default { exit 0 } }