Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f012ab5

Browse files
committed
fix(auth): harden account manager token migration
1 parent b1e143b commit f012ab5

12 files changed

Lines changed: 235 additions & 49 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
- CLI: make dry-runs for Admin group/user/org-unit edits, Contacts delete, Docs tab export, Drive tab download/share/unshare, and Gmail watch renew stay offline before auth/API calls; redact Admin user create passwords in dry-run output.
3030
- Auth: keep fresh OAuth saves working even when old file-keyring token entries are unreadable, and clarify that `--services all` means all user OAuth services while Workspace-only services use service accounts.
3131
- Auth: include Chat reaction scopes in `--services chat` and keep the generated auth scope table freshness-tested.
32+
- Auth: keep the accounts manager bound to loopback addresses, generate callback URLs from the actual listener host, and avoid deleting renamed-account tokens before replacements are stored.
3233
- Gmail: reject off-palette `gmail labels style` colors locally instead of forwarding an opaque Gmail API error.
3334
- Drive: make `drive share --dry-run` stop before permission creation for user and domain shares, including `--notify`.
3435
- Forms: make `forms create --description` apply the description with a follow-up batch update, and preserve zero-valued indexes in `forms move-question`.

docs/commands/gog-auth-manage.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ gog auth manage (login) [flags]
3030
| `--gmail-no-send` | `bool` | false | Block Gmail send operations (agent safety) |
3131
| `-h`<br>`--help` | `kong.helpFlag` | | Show context-sensitive help. |
3232
| `-j`<br>`--json`<br>`--machine` | `bool` | false | Output JSON to stdout (best for scripting) |
33-
| `--listen-addr` | `string` | | Address to listen on for OAuth callback (for example 0.0.0.0 or 0.0.0.0:8080) |
33+
| `--listen-addr` | `string` | | Loopback address to listen on for the accounts manager (for example 127.0.0.1:8080 or [::1]:8080) |
3434
| `--no-input`<br>`--non-interactive`<br>`--noninteractive` | `bool` | | Never prompt; fail instead (useful for CI) |
3535
| `-p`<br>`--plain`<br>`--tsv` | `bool` | false | Output stable, parseable text to stdout (TSV; no colors) |
3636
| `--redirect-host` | `string` | | Hostname for OAuth callback; builds https://{host}/oauth2/callback |

internal/cmd/auth_accounts.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ type AuthManageCmd struct {
258258
ForceConsent bool `name:"force-consent" help:"Force consent screen when adding accounts"`
259259
ServicesCSV string `name:"services" help:"Services to authorize: user|all-user or comma-separated ${auth_services}; all means all user OAuth services. Workspace service-account-only services: admin, groups, keep" default:"user"`
260260
Timeout time.Duration `name:"timeout" help:"Server timeout duration" default:"10m"`
261-
ListenAddr string `name:"listen-addr" help:"Address to listen on for OAuth callback (for example 0.0.0.0 or 0.0.0.0:8080)"`
261+
ListenAddr string `name:"listen-addr" help:"Loopback address to listen on for the accounts manager (for example 127.0.0.1:8080 or [::1]:8080)"`
262262
RedirectHost string `name:"redirect-host" help:"Hostname for OAuth callback; builds https://{host}/oauth2/callback"`
263263
}
264264

internal/cmd/auth_add.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -263,13 +263,10 @@ func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
263263
}
264264
sort.Strings(serviceNames)
265265

266-
migratedEmail, err := googleauth.MigrateStoredSubjectIdentity(store, client, identity)
266+
migratedEmail, err := googleauth.FindStoredSubjectIdentityEmail(store, client, identity)
267267
if err != nil {
268268
return wrapAuthAddStoreError(err)
269269
}
270-
if migratedEmail != "" {
271-
u.Err().Linef("Migrated auth account from %s to %s", migratedEmail, authorizedEmail)
272-
}
273270

274271
if err := store.SetToken(client, authorizedEmail, secrets.Token{
275272
Client: client,
@@ -281,6 +278,15 @@ func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
281278
}); err != nil {
282279
return wrapAuthAddStoreError(err)
283280
}
281+
if migratedEmail != "" {
282+
if err := googleauth.MigrateStoredEmailReferences(store, client, migratedEmail, authorizedEmail); err != nil {
283+
return wrapAuthAddStoreError(err)
284+
}
285+
if err := googleauth.DeleteStoredEmailAlias(store, client, migratedEmail); err != nil {
286+
u.Err().Linef("Warning: failed to remove stale auth account %s: %v", migratedEmail, err)
287+
}
288+
u.Err().Linef("Migrated auth account from %s to %s", migratedEmail, authorizedEmail)
289+
}
284290
if override != "" {
285291
cfg, err := config.ReadConfig()
286292
if err != nil {

internal/googleapi/client_auth.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ func (p *persistingTokenSource) Token() (*oauth2.Token, error) {
102102
}
103103

104104
if !strings.EqualFold(p.email, persistEmail) {
105+
if err := googleauth.MigrateStoredEmailReferences(p.store, p.client, p.email, persistEmail); err != nil {
106+
slog.Warn("migrate renamed token email references failed", "old_email", p.email, "new_email", persistEmail, "client", p.client, "err", err)
107+
}
108+
105109
aliasDeleter, ok := p.store.(tokenAliasDeleter)
106110
if !ok {
107111
slog.Debug("token store cannot delete renamed email alias", "old_email", p.email, "new_email", persistEmail, "client", p.client)

internal/googleapi/client_more_test.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ type stubStore struct {
4545
deleteEmail string
4646
deleteCalls int
4747
deleteErr error
48+
49+
defaultEmail string
50+
setDefaultClient string
51+
setDefaultEmail string
52+
setDefaultCalls int
4853
}
4954

5055
func (s *stubStore) Keys() ([]string, error) { return nil, nil }
@@ -79,9 +84,20 @@ func (s *stubStore) DeleteTokenAlias(client string, email string) error {
7984
return s.DeleteToken(client, email)
8085
}
8186

82-
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
83-
func (s *stubStore) GetDefaultAccount(string) (string, error) { return "", nil }
84-
func (s *stubStore) SetDefaultAccount(string, string) error { return nil }
87+
func (s *stubStore) ListTokens() ([]secrets.Token, error) { return nil, nil }
88+
func (s *stubStore) GetDefaultAccount(string) (string, error) {
89+
return s.defaultEmail, nil
90+
}
91+
92+
func (s *stubStore) SetDefaultAccount(client string, email string) error {
93+
s.setDefaultClient = client
94+
s.setDefaultEmail = email
95+
s.setDefaultCalls++
96+
s.defaultEmail = email
97+
98+
return nil
99+
}
100+
85101
func (s *stubStore) GetToken(client string, email string) (secrets.Token, error) {
86102
s.lastClient = client
87103
s.lastEmail = email
@@ -264,8 +280,20 @@ func TestPersistingTokenSource_BackfillsSubjectFromIDToken(t *testing.T) {
264280
}
265281

266282
func TestPersistingTokenSource_MigratesRenamedEmailFromIDToken(t *testing.T) {
283+
home := t.TempDir()
284+
t.Setenv("HOME", home)
285+
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "xdg"))
286+
287+
cfg := config.File{
288+
AccountAliases: map[string]string{"work": "[email protected]"},
289+
AccountClients: map[string]string{"[email protected]": "work-client"},
290+
}
291+
if err := config.WriteConfig(cfg); err != nil {
292+
t.Fatalf("WriteConfig: %v", err)
293+
}
294+
267295
stored := secrets.Token{Email: "[email protected]", RefreshToken: "same-token", Subject: "sub-123"}
268-
store := &stubStore{tok: stored}
296+
store := &stubStore{tok: stored, defaultEmail: "[email protected]"}
269297
base := oauth2.StaticTokenSource((&oauth2.Token{
270298
AccessToken: "access",
271299
RefreshToken: "same-token",
@@ -294,6 +322,27 @@ func TestPersistingTokenSource_MigratesRenamedEmailFromIDToken(t *testing.T) {
294322
t.Fatalf("expected old alias delete, got calls=%d client=%q email=%q", store.deleteCalls, store.deleteClient, store.deleteEmail)
295323
}
296324

325+
if store.setDefaultCalls != 1 || store.setDefaultClient != config.DefaultClientName || store.setDefaultEmail != "[email protected]" {
326+
t.Fatalf("expected default migration, got calls=%d client=%q email=%q", store.setDefaultCalls, store.setDefaultClient, store.setDefaultEmail)
327+
}
328+
329+
updated, err := config.ReadConfig()
330+
if err != nil {
331+
t.Fatalf("ReadConfig: %v", err)
332+
}
333+
334+
if updated.AccountAliases["work"] != "[email protected]" {
335+
t.Fatalf("expected alias migrated, got %#v", updated.AccountAliases)
336+
}
337+
338+
if updated.AccountClients["[email protected]"] != "work-client" {
339+
t.Fatalf("expected account client migrated, got %#v", updated.AccountClients)
340+
}
341+
342+
if _, ok := updated.AccountClients["[email protected]"]; ok {
343+
t.Fatalf("expected old account client removed, got %#v", updated.AccountClients)
344+
}
345+
297346
pts, ok := ts.(*persistingTokenSource)
298347
if !ok {
299348
t.Fatalf("expected persistingTokenSource")

internal/googleauth/accounts_server.go

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"fmt"
1212
"html/template"
1313
"io"
14+
"log/slog"
1415
"net"
1516
"net/http"
1617
"os"
@@ -100,6 +101,15 @@ func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
100101

101102
opts.Client = client
102103

104+
listenAddr, err := normalizeListenAddr(opts.ListenAddr)
105+
if err != nil {
106+
return err
107+
}
108+
109+
if validationErr := validateManagementListenAddr(listenAddr); validationErr != nil {
110+
return validationErr
111+
}
112+
103113
if strings.TrimSpace(opts.RedirectURI) != "" {
104114
resolvedRedirectURI, normalizeErr := normalizeRedirectURI(opts.RedirectURI)
105115
if normalizeErr != nil {
@@ -118,11 +128,6 @@ func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
118128
return fmt.Errorf("failed to generate CSRF token: %w", err)
119129
}
120130

121-
listenAddr, err := normalizeListenAddr(opts.ListenAddr)
122-
if err != nil {
123-
return err
124-
}
125-
126131
ln, err := (&net.ListenConfig{}).Listen(ctx, "tcp", listenAddr)
127132
if err != nil {
128133
return fmt.Errorf("failed to start listener: %w", err)
@@ -170,8 +175,7 @@ func StartManageServer(ctx context.Context, opts ManageServerOptions) error {
170175
}
171176
}()
172177

173-
port := ln.Addr().(*net.TCPAddr).Port
174-
url := fmt.Sprintf("http://127.0.0.1:%d", port)
178+
url := listenerBaseURL(ln)
175179

176180
fmt.Fprintln(os.Stderr, "Opening accounts manager in browser...")
177181
fmt.Fprintln(os.Stderr, "If the browser doesn't open, visit:", url)
@@ -438,9 +442,9 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
438442
}
439443

440444
if needKeychain {
441-
if err := ensureKeychainAccess(); err != nil { //nolint:contextcheck,nolintlint // keychain ops don't use context; nolint unused on non-Darwin
445+
if keychainErr := ensureKeychainAccess(); keychainErr != nil { //nolint:contextcheck,nolintlint // keychain ops don't use context; nolint unused on non-Darwin
442446
w.WriteHeader(http.StatusInternalServerError)
443-
renderErrorPage(w, "Keychain is locked: "+err.Error())
447+
renderErrorPage(w, "Keychain is locked: "+keychainErr.Error())
444448

445449
return
446450
}
@@ -451,15 +455,14 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
451455
serviceNames = append(serviceNames, string(svc))
452456
}
453457

454-
if _, err := MigrateStoredSubjectIdentity(ms.store, ms.client, identity); err != nil {
458+
migratedEmail, err := FindStoredSubjectIdentityEmail(ms.store, ms.client, identity)
459+
if err != nil {
455460
w.WriteHeader(http.StatusInternalServerError)
456-
renderErrorPage(w, "Failed to migrate stored token: "+err.Error())
461+
renderErrorPage(w, "Failed to inspect stored token: "+err.Error())
457462

458463
return
459464
}
460465

461-
// Store the token after subject migration so deleting the old email alias
462-
// cannot remove the freshly written subject-keyed token.
463466
if err := ms.store.SetToken(ms.client, email, secrets.Token{
464467
Subject: identity.Subject,
465468
Email: email,
@@ -473,6 +476,19 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
473476
return
474477
}
475478

479+
if migratedEmail != "" {
480+
if err := MigrateStoredEmailReferences(ms.store, ms.client, migratedEmail, email); err != nil {
481+
w.WriteHeader(http.StatusInternalServerError)
482+
renderErrorPage(w, "Failed to migrate stored token references: "+err.Error())
483+
484+
return
485+
}
486+
487+
if err := DeleteStoredEmailAlias(ms.store, ms.client, migratedEmail); err != nil {
488+
slog.Warn("delete migrated token alias failed", "old_email", migratedEmail, "new_email", email, "client", ms.client, "err", err)
489+
}
490+
}
491+
476492
// Render success page with the new template
477493
w.WriteHeader(http.StatusOK)
478494
renderSuccessPageWithDetails(w, email, serviceNames)

internal/googleauth/accounts_server_test.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ func (s *fakeStore) DeleteToken(client string, email string) error {
7575
return nil
7676
}
7777

78+
func (s *fakeStore) DeleteTokenAlias(client string, email string) error {
79+
return s.DeleteToken(client, email)
80+
}
81+
7882
func (s *fakeStore) DeleteDefaultAccount(client string) error {
7983
s.deleteDefault = true
8084
s.deleteDefaultCalled = client
@@ -758,7 +762,7 @@ func TestManageServer_HandleOAuthCallback_Success(t *testing.T) {
758762
}
759763
}
760764

761-
func TestManageServer_HandleOAuthCallback_MigratesBeforeSetToken(t *testing.T) {
765+
func TestManageServer_HandleOAuthCallback_MigratesAndDeletesAliasAfterSetToken(t *testing.T) {
762766
t.Setenv("HOME", t.TempDir())
763767
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
764768

@@ -831,12 +835,26 @@ func TestManageServer_HandleOAuthCallback_MigratesBeforeSetToken(t *testing.T) {
831835
t.Fatalf("status: %d body: %s", rr.Code, rr.Body.String())
832836
}
833837

834-
wantOps := []string{"delete:old@example.com", "set:new@example.com"}
838+
wantOps := []string{"set:new@example.com", "delete:old@example.com"}
835839
if len(store.ops) != len(wantOps) || store.ops[0] != wantOps[0] || store.ops[1] != wantOps[1] {
836840
t.Fatalf("unexpected store ops: %#v", store.ops)
837841
}
838842
}
839843

844+
func TestStartManageServerRejectsNonLoopbackListenAddr(t *testing.T) {
845+
err := StartManageServer(context.Background(), ManageServerOptions{
846+
ListenAddr: "0.0.0.0:0",
847+
Timeout: 50 * time.Millisecond,
848+
})
849+
if err == nil {
850+
t.Fatalf("expected non-loopback listen addr error")
851+
}
852+
853+
if !errors.Is(err, errNonLoopbackManageAddr) {
854+
t.Fatalf("expected errNonLoopbackManageAddr, got %v", err)
855+
}
856+
}
857+
840858
func TestManageServer_HandleOAuthCallback_FileBackendSkipsKeychain(t *testing.T) {
841859
origRead := readClientCredentials
842860
origEndpoint := oauthEndpoint

internal/googleauth/identity_migration.go

Lines changed: 57 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import (
99
)
1010

1111
func MigrateStoredSubjectIdentity(store secrets.Store, client string, identity Identity) (string, error) {
12+
oldEmail, err := FindStoredSubjectIdentityEmail(store, client, identity)
13+
if err != nil || oldEmail == "" {
14+
return oldEmail, err
15+
}
16+
17+
newEmail := normalizeEmail(identity.Email)
18+
if err := MigrateStoredEmailReferences(store, client, oldEmail, newEmail); err != nil {
19+
return "", err
20+
}
21+
22+
return oldEmail, nil
23+
}
24+
25+
func FindStoredSubjectIdentityEmail(store secrets.Store, client string, identity Identity) (string, error) {
1226
subject := strings.TrimSpace(identity.Subject)
1327
newEmail := normalizeEmail(identity.Email)
1428

@@ -34,20 +48,6 @@ func MigrateStoredSubjectIdentity(store secrets.Store, client string, identity I
3448
continue
3549
}
3650

37-
if err := store.DeleteToken(client, oldEmail); err != nil {
38-
return "", fmt.Errorf("delete stale token for %s: %w", oldEmail, err)
39-
}
40-
41-
if defaultEmail, getErr := store.GetDefaultAccount(client); getErr == nil && normalizeEmail(defaultEmail) == oldEmail {
42-
if setErr := store.SetDefaultAccount(client, newEmail); setErr != nil {
43-
return "", fmt.Errorf("set migrated default account: %w", setErr)
44-
}
45-
}
46-
47-
if err := migrateStoredSubjectConfig(oldEmail, newEmail); err != nil {
48-
return "", err
49-
}
50-
5151
return oldEmail, nil
5252
}
5353

@@ -85,6 +85,49 @@ func tokensForSubjectMigration(store secrets.Store, client string) ([]secrets.To
8585
return tokens, nil
8686
}
8787

88+
type tokenAliasDeleter interface {
89+
DeleteTokenAlias(client string, email string) error
90+
}
91+
92+
func DeleteStoredEmailAlias(store secrets.Store, client string, email string) error {
93+
email = normalizeEmail(email)
94+
if email == "" {
95+
return nil
96+
}
97+
98+
aliasDeleter, ok := store.(tokenAliasDeleter)
99+
if !ok {
100+
return nil
101+
}
102+
103+
if err := aliasDeleter.DeleteTokenAlias(client, email); err != nil {
104+
return fmt.Errorf("delete stale token alias for %s: %w", email, err)
105+
}
106+
107+
return nil
108+
}
109+
110+
func MigrateStoredEmailReferences(store secrets.Store, client string, oldEmail string, newEmail string) error {
111+
oldEmail = normalizeEmail(oldEmail)
112+
newEmail = normalizeEmail(newEmail)
113+
114+
if oldEmail == "" || newEmail == "" || oldEmail == newEmail {
115+
return nil
116+
}
117+
118+
if defaultEmail, getErr := store.GetDefaultAccount(client); getErr == nil && normalizeEmail(defaultEmail) == oldEmail {
119+
if setErr := store.SetDefaultAccount(client, newEmail); setErr != nil {
120+
return fmt.Errorf("set migrated default account: %w", setErr)
121+
}
122+
}
123+
124+
if err := migrateStoredSubjectConfig(oldEmail, newEmail); err != nil {
125+
return err
126+
}
127+
128+
return nil
129+
}
130+
88131
func migrateStoredSubjectConfig(oldEmail string, newEmail string) error {
89132
if err := config.UpdateConfig(func(cfg *config.File) error {
90133
for alias, target := range cfg.AccountAliases {

0 commit comments

Comments
 (0)