auth: silent login-token refresh for git-remote-entire · Entire
auth: silent login-token refresh for git-remote-entire
5e3ab86→main·
toothbrush·1mo ago·10 files·+424 added/-61 removed
Device-flow login now requests offline_access, captures the returned
refresh token through the poll plumbing, and persists it to the paired
<service>:refresh keyring slot. git-remote-entire's login-JWT provider
is now refresh-aware: an expired login JWT is re-minted from the refresh
token instead of failing the operation with a re-login.
The provider is backed by auth-go's tokenmanager, which serialises refreshes across processes (advisory file lock) and goroutines and persists the rotated refresh token. That matters because the server issues single-use refresh tokens with reuse detection + family revocation: two concurrent git-remote-entire processes (e.g. a recursive submodule fetch) racing a naive refresh would replay the same token and get the whole family revoked. A thin per-context tokenstore.Store adapter maps the keyring slots onto tokenmanager.
Behaviour is a strict superset of before: a still-valid token is returned with no network call, and a context with no refresh token (a login predating this change) behaves exactly as it did — valid token used, expired token surfaces a re-login error.
Server-side support already existed in entire-core (offline_access on the device grant, refresh_token grant for the public client, rotating single-use families), so no server changes are required.
Sessions
0192d919e856View transcript
Changes
10
cmd
entire/cli
auth
Mclient.go+14/-8
Mcontexts.go+16/-2
Mcontexts_test.go+15/-15
Arefresh.go+147
Arefresh_test.go+190
Mauth_context_test.go+4/-4
Mlogin.go+13/-13
Mlogin_test.go+13/-13
git-remote-entire
Mmain.go+11/-5
Mmain_test.go+1/-1
27 unmodified lines
28
29
30
31
32
33
34
27 unmodified lines
62
63
64
64
65
66
67
65
66
67
68
69
70
71
72
73
74
75
30 unmodified lines
106
107
108
104
105
106
107
109
110
111
112
113
114
115
116
27 unmodified lines
// callers a more actionable message than the bare error code.
type DeviceAuthPoll struct {
AccessToken string
RefreshToken string
TokenType string
ExpiresIn int
Scope string
27 unmodified lines
transport = httpClient.Transport
}
return &Client{inner: &deviceflow.Client{
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
Scope: "cli",
Transport: transport,
BaseURL: issuer,
ClientID: p.ClientID,
// offline_access asks the authorization server for a refresh token.
// The server only mints one when it's requested (it's client-gated),
// so without this the device login is access-token-only and silent
// refresh is impossible.
Scope: "cli offline_access",
UserAgent: p.ClientID,
DeviceCodePath: p.DeviceCodePath,
TokenPath: p.TokenPath,
30 unmodified lines
}
return &DeviceAuthPoll{
AccessToken: t.AccessToken,
TokenType: t.TokenType,
ExpiresIn: secondsUntil(t),
Scope: t.Scope,
AccessToken: t.AccessToken,
RefreshToken: t.RefreshToken,
TokenType: t.TokenType,
ExpiresIn: secondsUntil(t),
Scope: t.Scope,
}, nil
}
Mcmd/entire/cli/auth/client.go+14/-8
43 unmodified lines
44
45
46
47
47
48
49
50
24 unmodified lines
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
91 unmodified lines
185
186
187
175
188
189
190
191
192
43 unmodified lines
//
// Returns the context name on success. Errors are returned (not swallowed)
// so the caller can warn; login still succeeds on the legacy entry.
func RecordLoginContext(rawToken string, activate bool) (string, error) {
func RecordLoginContext(rawToken, refreshToken string, activate bool) (string, error) {
claims, err := tokens.ParseClaims(rawToken)
if err != nil {
return "", fmt.Errorf("parse login token claims: %w", err)
}
24 unmodified lines
return "", fmt.Errorf("store login token in keyring: %w", err)
}
// The refresh token lives in the paired "<service>:refresh" slot (raw,
// no expiry suffix). Clear any prior one when this login carries none,
// so a stale token from an earlier session can't later be replayed
// against the server's single-use rotation and revoke the family.
refreshSlot := keychainService + ":refresh"
if refreshToken != "" {
if err := tokenstore.Set(refreshSlot, handle, refreshToken); err != nil {
return "", fmt.Errorf("store refresh token in keyring: %w", err)
}
} else {
_ = tokenstore.Delete(refreshSlot, handle) //nolint:errcheck // best-effort cleanup of a stale refresh token
}
var name string
cfgDir := contexts.DefaultConfigDir()
if modErr := contexts.Modify(cfgDir, func(f *contexts.File) (bool, error) {
91 unmodified lines
// activate=false: migrating an old login (e.g. on first `git clone`) must
// not silently switch the user's active context. RecordLoginContext still
// sets current_context when none exists yet.
if _, err := RecordLoginContext(legacy, false); err != nil {
// No refresh token: the legacy entry is access-token-only.
if _, err := RecordLoginContext(legacy, "", false); err != nil {
return false, err
}
return true, nil
}
Mcmd/entire/cli/auth/contexts.go+16/-2
36 unmodified lines
37
38
39
40
40
41
42
43
132 unmodified lines
176
177
178
179
179
180
181
182
13 unmodified lines
196
197
198
199
199
200
201
202
20 unmodified lines
223
224
225
226
226
227
228
229
229
230
231
232
28 unmodified lines
261
262
263
264
264
265
266
267
267
268
269
270
26 unmodified lines
297
298
299
300
300
301
302
303
303
304
305
306
35 unmodified lines
342
343
344
345
345
346
347
348
349
349
350
351
352
21 unmodified lines
374
375
376
377
377
378
379
380
19 unmodified lines
400
401
402
403
403
404
405
406
34 unmodified lines
441
442
443
444
444
445
446
447
26 unmodified lines
474
475
476
477
477
478
479
480
36 unmodified lines
exp := time.Now().Add(2 * time.Hour).Unix()
token := makeJWT(t, fmt.Sprintf(`{"iss":%q,"handle":%q,"exp":%d}`, coreURL, handle, exp))
name, err := RecordLoginContext(token, true)
name, err := RecordLoginContext(token, "", true)
if err != nil {
t.Fatalf("RecordLoginContext: %v", err)
}
132 unmodified lines
// Record a context: its token now wins over the legacy entry.
exp := time.Now().Add(time.Hour).Unix()
cctxToken := makeJWT(t, fmt.Sprintf(`{"iss":"https://core.example.com","handle":"alice","exp":%d}`, exp))
if _, err := RecordLoginContext(ctxToken, true); err != nil {
if _, err := RecordLoginContext(ctxToken, "", true); err != nil {
t.Fatalf("RecordLoginContext: %v", err)
}
}
"