git-credential-polar.ps1
·
155 строк
·
5.3 KB
| 1 | # Git credential helper for Polar (RFC 8628 device flow). |
| 2 | # |
| 3 | # Usage: |
| 4 | # git config --global credential.helper "!powershell -NoProfile -ExecutionPolicy Bypass -File C:/path/to/polar/scripts/git-credential-polar.ps1" |
| 5 | # |
| 6 | # On "get" the helper reuses a cached token for the server, otherwise it starts |
| 7 | # a device authorization flow, opens the verification page in the browser and |
| 8 | # waits until the request is approved. Tokens are cached in |
| 9 | # ~/.polar/credentials.json. |
| 10 | param([Parameter(Position = 0)][string]$Operation = 'get') |
| 11 | |
| 12 | $ErrorActionPreference = 'Stop' |
| 13 | |
| 14 | function Read-CredentialInput { |
| 15 | $values = @{} |
| 16 | while ($null -ne ($line = [Console]::In.ReadLine())) { |
| 17 | if ($line -match '^([^=]+)=(.*)$') { |
| 18 | $values[$Matches[1]] = $Matches[2] |
| 19 | } |
| 20 | } |
| 21 | return $values |
| 22 | } |
| 23 | |
| 24 | function Get-ServerBase([hashtable]$values) { |
| 25 | if ($env:POLAR_URL) { return $env:POLAR_URL.TrimEnd('/') } |
| 26 | $hostName = $values['host'] |
| 27 | if ($hostName) { |
| 28 | $scheme = $values['protocol'] |
| 29 | if (-not $scheme) { $scheme = 'https' } |
| 30 | return ('{0}://{1}' -f $scheme, $hostName).TrimEnd('/') |
| 31 | } |
| 32 | return 'http://localhost:3100' |
| 33 | } |
| 34 | |
| 35 | function Get-CachePath { |
| 36 | $dir = Join-Path $HOME '.polar' |
| 37 | if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null } |
| 38 | return (Join-Path $dir 'credentials.json') |
| 39 | } |
| 40 | |
| 41 | function Read-Cache { |
| 42 | $path = Get-CachePath |
| 43 | if (-not (Test-Path $path)) { return @{} } |
| 44 | try { |
| 45 | $raw = Get-Content -Raw -Path $path | ConvertFrom-Json |
| 46 | $table = @{} |
| 47 | foreach ($prop in $raw.PSObject.Properties) { |
| 48 | $table[$prop.Name] = @{ username = $prop.Value.username; token = $prop.Value.token } |
| 49 | } |
| 50 | return $table |
| 51 | } catch { |
| 52 | return @{} |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | function Write-Cache([hashtable]$cache) { |
| 57 | $obj = [ordered]@{} |
| 58 | foreach ($key in $cache.Keys) { $obj[$key] = @{ username = $cache[$key].username; token = $cache[$key].token } } |
| 59 | ($obj | ConvertTo-Json -Depth 4) | Set-Content -Path (Get-CachePath) -Encoding UTF8 |
| 60 | } |
| 61 | |
| 62 | function Write-Credential([string]$user, [string]$token) { |
| 63 | [Console]::Out.WriteLine('username=' + $user) |
| 64 | [Console]::Out.WriteLine('password=' + $token) |
| 65 | } |
| 66 | |
| 67 | function Read-ErrorBody($exception) { |
| 68 | try { |
| 69 | $stream = $exception.Response.GetResponseStream() |
| 70 | $reader = New-Object System.IO.StreamReader($stream) |
| 71 | return $reader.ReadToEnd() |
| 72 | } catch { |
| 73 | return '' |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | $values = Read-CredentialInput |
| 78 | $base = Get-ServerBase $values |
| 79 | $cache = Read-Cache |
| 80 | |
| 81 | switch ($Operation) { |
| 82 | 'get' { |
| 83 | if ($cache.ContainsKey($base)) { |
| 84 | Write-Credential $cache[$base].username $cache[$base].token |
| 85 | exit 0 |
| 86 | } |
| 87 | |
| 88 | $clientId = 'git-credential-polar' |
| 89 | $code = $null |
| 90 | try { |
| 91 | $code = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/code" -Body @{ client_id = $clientId } |
| 92 | } catch { |
| 93 | [Console]::Error.WriteLine("polar: could not reach $base/api/v1/device/code: $($_.Exception.Message)") |
| 94 | exit 1 |
| 95 | } |
| 96 | |
| 97 | [Console]::Error.WriteLine('') |
| 98 | [Console]::Error.WriteLine("polar: open $($code.verification_uri) and enter code $($code.user_code)") |
| 99 | [Console]::Error.WriteLine('polar: waiting for approval...') |
| 100 | try { Start-Process $code.verification_uri_complete | Out-Null } catch { } |
| 101 | |
| 102 | $interval = 5 |
| 103 | if ($code.interval) { $interval = [int]$code.interval } |
| 104 | if ($interval -lt 3) { $interval = 3 } |
| 105 | $expiresIn = 900 |
| 106 | if ($code.expires_in) { $expiresIn = [int]$code.expires_in } |
| 107 | $deadline = (Get-Date).AddSeconds($expiresIn) |
| 108 | |
| 109 | while ((Get-Date) -lt $deadline) { |
| 110 | Start-Sleep -Seconds $interval |
| 111 | try { |
| 112 | $resp = Invoke-RestMethod -Method Post -Uri "$base/api/v1/device/token" -Body @{ device_code = $code.device_code; client_id = $clientId } |
| 113 | if ($resp.access_token) { |
| 114 | $username = $resp.username |
| 115 | if (-not $username) { $username = 'x-access-token' } |
| 116 | $cache[$base] = @{ username = $username; token = $resp.access_token } |
| 117 | Write-Cache $cache |
| 118 | Write-Credential $username $resp.access_token |
| 119 | exit 0 |
| 120 | } |
| 121 | } catch { |
| 122 | $status = 0 |
| 123 | try { $status = [int]$_.Exception.Response.StatusCode } catch { } |
| 124 | if ($status -eq 429) { continue } |
| 125 | $body = Read-ErrorBody $_.Exception |
| 126 | if ($body -match 'access_denied') { |
| 127 | [Console]::Error.WriteLine('polar: access denied') |
| 128 | exit 1 |
| 129 | } |
| 130 | if ($body -match 'expired_token') { |
| 131 | [Console]::Error.WriteLine('polar: device code expired, run the command again') |
| 132 | exit 1 |
| 133 | } |
| 134 | [Console]::Error.WriteLine("polar: authorization failed: $body") |
| 135 | exit 1 |
| 136 | } |
| 137 | } |
| 138 | [Console]::Error.WriteLine('polar: timed out waiting for approval') |
| 139 | exit 1 |
| 140 | } |
| 141 | 'store' { |
| 142 | # The helper keeps its own cache; nothing to do. |
| 143 | exit 0 |
| 144 | } |
| 145 | 'erase' { |
| 146 | if ($cache.ContainsKey($base)) { |
| 147 | $cache.Remove($base) |
| 148 | Write-Cache $cache |
| 149 | } |
| 150 | exit 0 |
| 151 | } |
| 152 | default { |
| 153 | exit 0 |
| 154 | } |
| 155 | } |