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

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)
    }
}

"