-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathauth.go
More file actions
283 lines (247 loc) · 9.5 KB
/
Copy pathauth.go
File metadata and controls
283 lines (247 loc) · 9.5 KB
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
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"time"
retry "github.com/appleboy/go-httpretry"
"github.com/go-authgate/cli/tui"
"github.com/go-authgate/sdk-go/credstore"
)
// authenticate selects and runs the appropriate OAuth flow:
//
// 1. --device flag → Device Code Flow (forced)
// 2. Environment signals (SSH, no display, port busy) → Device Code Flow
// 3. Browser available → Authorization Code Flow with PKCE
// - openBrowser() error → immediate fallback to Device Code Flow
func authenticate(
ctx context.Context,
ui tui.Manager,
cfg *AppConfig,
) (*credstore.Token, string, error) {
deviceFlow := func(ctx context.Context, updates chan<- tui.FlowUpdate) (*tui.TokenStorage, error) {
return performDeviceFlowWithUpdates(ctx, cfg, updates)
}
browserFlow := func(ctx context.Context, updates chan<- tui.FlowUpdate) (*tui.TokenStorage, bool, error) {
return performBrowserFlowWithUpdates(ctx, cfg, updates)
}
runDeviceFlow := func(reason string) (*credstore.Token, string, error) {
ui.ShowFlowSelection(reason)
tuiStorage, err := ui.RunDeviceFlow(ctx, deviceFlow)
return fromTUITokenStorage(tuiStorage), flowFromTUI(tuiStorage), err
}
if cfg.ForceDevice {
return runDeviceFlow("Device Code Flow (forced via flag)")
}
if avail := checkBrowserAvailability(ctx, cfg.CallbackPort); !avail.Available {
return runDeviceFlow(fmt.Sprintf("Device Code Flow (%s)", avail.Reason))
}
ui.ShowFlowSelection("Authorization Code Flow (browser)")
tuiStorage, ok, err := ui.RunBrowserFlow(ctx, browserFlow)
if err != nil {
return nil, "", err
}
if !ok {
// openBrowser() failed; fall back to Device Code Flow immediately.
return runDeviceFlow("Device Code Flow (browser unavailable)")
}
return fromTUITokenStorage(tuiStorage), flowFromTUI(tuiStorage), nil
}
// needsRefresh reports whether the stored token should be proactively
// refreshed: the access token is empty, or it expires within threshold of now.
// A token still further than threshold from expiry returns false so callers
// can reuse it without any network request.
func needsRefresh(tok credstore.Token, threshold time.Duration, now time.Time) bool {
if tok.AccessToken == "" {
return true
}
// Refresh when expiry is at or before now+threshold (inclusive boundary).
return !now.Add(threshold).Before(tok.ExpiresAt)
}
// tokenUsable reports whether a stored token can be used as-is right now: it
// has a non-empty access token and has not yet expired. Shared by run() and
// ensureFreshToken so the "reuse the old token" condition — including the empty
// access token and exact expiry-boundary edge cases — stays identical in both.
func tokenUsable(tok credstore.Token, now time.Time) bool {
return tok.AccessToken != "" && now.Before(tok.ExpiresAt)
}
// ensureFreshToken loads the stored token for cfg.ClientID and, when it falls
// within cfg.RefreshThreshold of expiry, exchanges the refresh token for a new
// one. On refresh failure it degrades gracefully:
// - old token not yet expired → returns the old token plus a stderr warning
// - old token expired and refresh failed → returns the refresh error
// (e.g. ErrRefreshTokenExpired / ErrNoRefreshToken)
//
// cfg only needs the token store, client ID, and refresh threshold. A network
// refresh requires the full network-capable config (server URL validation,
// retry client, endpoints), which is built lazily via loadFull only once a
// refresh is actually required — so reads far from expiry stay fully offline
// and never validate SERVER_URL or emit transport warnings. When loadFull is
// nil, cfg is used as-is (used by tests that pre-populate everything).
//
// It returns the token to use and whether a refresh actually occurred. The
// load error (including credstore.ErrNotFound) is returned verbatim so callers
// can tailor their diagnostics.
func ensureFreshToken(
ctx context.Context,
cfg *AppConfig,
loadFull func() *AppConfig,
stderr io.Writer,
) (credstore.Token, bool, error) {
tok, err := cfg.Store.Load(cfg.ClientID)
if err != nil {
return credstore.Token{}, false, err
}
// Capture now once so the refresh decision and the graceful-degradation
// check below reason about the same instant.
now := time.Now()
if !needsRefresh(tok, cfg.RefreshThreshold, now) {
// Far from expiry: reuse as-is without resolving endpoints or making
// any network request (offline/common path stays zero-cost).
return tok, false, nil
}
// reuseOrFail applies graceful degradation: keep using the old token only
// while it's still usable — present and not yet expired. An empty or
// already-expired access token is never reusable, so the cause surfaces as
// an error instead of silently succeeding with an unusable token.
reuseOrFail := func(cause error) (credstore.Token, bool, error) {
if tokenUsable(tok, now) {
fmt.Fprintf(stderr, "Warning: token refresh failed, using existing token: %v\n", cause)
return tok, false, nil
}
return credstore.Token{}, false, cause
}
// Without a refresh token a refresh can only fail after a wasted round
// trip, so skip the network entirely and degrade gracefully.
if tok.RefreshToken == "" {
return reuseOrFail(ErrNoRefreshToken)
}
// A network refresh is required, so upgrade to the full network-capable
// config now (deferring SERVER_URL validation and transport setup until
// this point keeps the far-from-expiry path offline).
full := cfg
if loadFull != nil {
full = loadFull()
// loadFull (loadConfig) builds a fresh store instance that could resolve
// to a different backend/path than the one we loaded the token from.
// Pin the store and client ID to the originally loaded config so the
// refreshed token is saved exactly where it came from.
full.Store = cfg.Store
full.ClientID = cfg.ClientID
}
// Resolve endpoints lazily too. Callers that pre-populate Endpoints skip
// this (e.g. tests).
if full.Endpoints.TokenURL == "" {
resolveEndpoints(ctx, full)
}
newTok, err := refreshAccessToken(ctx, full, tok.RefreshToken)
if err != nil {
return reuseOrFail(err)
}
return *newTok, true, nil
}
// refreshAccessToken exchanges a refresh token for a new access token.
func refreshAccessToken(
ctx context.Context,
cfg *AppConfig,
refreshToken string,
) (*credstore.Token, error) {
ctx, cancel := context.WithTimeout(ctx, cfg.RefreshTokenTimeout)
defer cancel()
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
data.Set("client_id", cfg.ClientID)
cfg.setClientSecret(data)
// Server doesn't persist extra_claims across refresh, so re-send them.
cfg.setExtraClaims(data)
tokenResp, err := doTokenExchange(ctx, cfg, cfg.Endpoints.TokenURL, data,
func(errResp ErrorResponse, _ []byte) error {
if errResp.Error == "invalid_grant" || errResp.Error == "invalid_token" {
return ErrRefreshTokenExpired
}
return nil // fall through to default error formatting
},
)
if err != nil {
return nil, err
}
storage := tokenResponseToCredstore(cfg, tokenResp)
// Preserve the old refresh token in fixed-mode (server may not return a new one).
if storage.RefreshToken == "" {
storage.RefreshToken = refreshToken
}
if err := cfg.Store.Save(cfg.ClientID, *storage); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Failed to save refreshed tokens: %v\n", err)
}
return storage, nil
}
// verifyToken verifies an access token with the OAuth server.
func verifyToken(ctx context.Context, cfg *AppConfig, accessToken string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, cfg.TokenVerificationTimeout)
defer cancel()
resp, err := cfg.RetryClient.Get(ctx, cfg.Endpoints.TokenInfoURL,
retry.WithHeader("Authorization", "Bearer "+accessToken),
)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := readResponseBody(resp, cfg.MaxResponseBodySize)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
return "", formatHTTPError(body, resp.StatusCode)
}
return string(body), nil
}
// makeAPICallWithAutoRefresh demonstrates the 401 → refresh → retry pattern.
func makeAPICallWithAutoRefresh(
ctx context.Context,
cfg *AppConfig,
storage *credstore.Token,
ui tui.Manager,
) error {
resp, err := cfg.RetryClient.Get(ctx, cfg.Endpoints.TokenInfoURL,
retry.WithHeader("Authorization", "Bearer "+storage.AccessToken),
)
if err != nil {
return fmt.Errorf("API request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventAccessTokenRejected})
newStorage, err := refreshAccessToken(ctx, cfg, storage.RefreshToken)
if err != nil {
if errors.Is(err, ErrRefreshTokenExpired) {
return ErrRefreshTokenExpired
}
return fmt.Errorf("refresh failed: %w", err)
}
// Adopt every refreshed field (TokenType and ClientID too), rather than a
// partial copy that would silently drop any field added to the token.
*storage = *newStorage
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventTokenRefreshedRetrying})
resp, err = cfg.RetryClient.Get(ctx, cfg.Endpoints.TokenInfoURL,
retry.WithHeader("Authorization", "Bearer "+storage.AccessToken),
)
if err != nil {
return fmt.Errorf("retry failed: %w", err)
}
defer resp.Body.Close()
}
body, err := readResponseBody(resp, cfg.MaxResponseBodySize)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("API call failed with status %d: %s", resp.StatusCode, string(body))
}
ui.ShowStatus(tui.StatusUpdate{Event: tui.EventAPICallSuccess})
return nil
}