rin
Public Open-source lightweight git hosting.
git-credential-polar.ps1 · 155 lines · 5.3 KB
Raw
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 }