1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
|
package main
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// config holds the helper's runtime settings.
type config struct {
vaultURL string // e.g. "https://vault.cosmocr.at"
audience string // e.g. "gitweb"
client *http.Client // HTTP client (overridable in tests)
openURL func(string) // browser opener (overridable in tests)
stderr io.Writer // progress messages
timeout time.Duration // overall login timeout
}
// login obtains a fresh SSO token, using the loopback browser flow when a
// browser is available and falling back to the device-code flow otherwise.
func (cfg config) login(useDevice bool) (string, error) {
if useDevice {
return cfg.deviceLogin()
}
return cfg.loopbackLogin()
}
// loopbackLogin starts a localhost callback server, opens the vault authorize
// page in a browser, and waits for the redirect carrying the token.
func (cfg config) loopbackLogin() (string, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", err
}
defer ln.Close()
state := randString(16)
tokenCh := make(chan string, 1)
errCh := make(chan error, 1)
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/cb" {
http.NotFound(w, r)
return
}
q := r.URL.Query()
if q.Get("state") != state {
http.Error(w, "state mismatch", http.StatusBadRequest)
errCh <- fmt.Errorf("callback state mismatch")
return
}
tok := q.Get("token")
if tok == "" {
http.Error(w, "no token", http.StatusBadRequest)
errCh <- fmt.Errorf("callback missing token")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.WriteString(w, "<!doctype html><p>Authentication complete. You can close this tab and return to your terminal.</p>")
tokenCh <- tok
})}
go srv.Serve(ln)
defer srv.Close()
redirectURI := fmt.Sprintf("http://%s/cb", ln.Addr().String())
authURL := fmt.Sprintf("%s/authorize?%s", strings.TrimRight(cfg.vaultURL, "/"), url.Values{
"redirect_uri": {redirectURI},
"state": {state},
"aud": {cfg.audience},
}.Encode())
fmt.Fprintf(cfg.stderr, "Opening your browser to sign in:\n %s\n", authURL)
if cfg.openURL != nil {
cfg.openURL(authURL)
}
select {
case tok := <-tokenCh:
return tok, nil
case err := <-errCh:
return "", err
case <-time.After(cfg.timeout):
return "", fmt.Errorf("timed out waiting for browser authentication")
}
}
// deviceLogin runs the RFC 8628-style device flow: request a code, tell the
// user where to approve it, then poll until approved.
func (cfg config) deviceLogin() (string, error) {
var codeResp struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
Interval int `json:"interval"`
ExpiresIn int `json:"expires_in"`
}
if err := cfg.postJSON("/api/device/code", map[string]string{"aud": cfg.audience}, &codeResp); err != nil {
return "", err
}
verify := codeResp.VerificationURI
if strings.HasPrefix(verify, "/") {
verify = strings.TrimRight(cfg.vaultURL, "/") + verify
}
fmt.Fprintf(cfg.stderr,
"To authenticate, open this URL in a browser, sign in with your passkey, and enter the code:\n %s\n code: %s\n",
verify, codeResp.UserCode)
interval := time.Duration(codeResp.Interval) * time.Second
if interval <= 0 {
interval = 5 * time.Second
}
deadline := time.Now().Add(cfg.timeout)
for time.Now().Before(deadline) {
time.Sleep(interval)
var tokResp struct {
Status string `json:"status"`
Token string `json:"token"`
}
if err := cfg.postJSON("/api/device/token", map[string]string{"device_code": codeResp.DeviceCode}, &tokResp); err != nil {
return "", err
}
switch tokResp.Status {
case "approved":
return tokResp.Token, nil
case "slow_down":
interval += 2 * time.Second
case "pending":
// keep polling
case "expired":
return "", fmt.Errorf("device code expired before approval")
}
}
return "", fmt.Errorf("timed out waiting for device approval")
}
func (cfg config) postJSON(path string, body any, out any) error {
buf, err := json.Marshal(body)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "POST", strings.TrimRight(cfg.vaultURL, "/")+path, strings.NewReader(string(buf)))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := cfg.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
data, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", path, strings.TrimSpace(string(data)))
}
return json.Unmarshal(data, out)
}
func randString(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b)
}
|