Skip to content

Commit 2df8ece

Browse files
salmonumbrellaclaudesteipete
authored
fix(auth): enforce remote manual auth state (#187)
* fix(gmail): fallback to send-as list for display name * refactor(gmail): remove dead code in primarySendAsDisplayNameFromList The condition `primary == nil && sa.IsPrimary` inside the email-matching block can never be true because `primary` is already unconditionally set to `sa` when `sa.IsPrimary` is true earlier in the same loop iteration. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(gmail): add --from display name fallback to list test Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(auth): persist manual oauth state * feat(cli): add remote manual auth flow * fix(auth): enforce remote manual auth state * fix(auth): satisfy lint for manual auth flow * fix(auth): harden remote manual auth state cache * chore: update changelog for remote manual auth (#187) (thanks @salmonumbrella) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Peter Steinberger <steipete@gmail.com>
1 parent e3cb940 commit 2df8ece

11 files changed

Lines changed: 1233 additions & 63 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@
66

77
- Gmail: add `--exclude-labels` to `watch serve` (defaults: `SPAM,TRASH`). (#194) — thanks @salmonumbrella.
88
- Drive: share files with an entire Workspace domain via `drive share --to domain`. (#192) — thanks @Danielkweber.
9+
10+
### Fixed
11+
12+
- Auth: improve remote/server-friendly manual OAuth flow (`auth add --remote`). (#187) — thanks @salmonumbrella.
13+
914
## 0.9.0 - 2026-01-22
1015

1116
### Highlights

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ gog auth add you@gmail.com
106106

107107
This will open a browser window for OAuth authorization. The refresh token is stored securely in your system keychain.
108108

109+
Headless / remote server flow (no browser on the server):
110+
111+
```bash
112+
# Step 1: print auth URL (open it locally in a browser)
113+
gog auth add you@gmail.com --services user --remote --step 1
114+
115+
# Step 2: paste the full redirect URL from your browser address bar
116+
gog auth add you@gmail.com --services user --remote --step 2 --auth-url 'http://localhost:1/?code=...&state=...'
117+
```
118+
119+
Notes:
120+
121+
- The `state` is cached on disk for a short time (about 10 minutes). If it expires, rerun step 1.
122+
- Remote step 2 requires a redirect URL that includes `state` (state check mandatory).
123+
109124
### 4. Test Authentication
110125

111126
```bash

docs/spec.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,9 @@ Implementation: `internal/secrets/store.go`.
9999

100100
- Desktop OAuth 2.0 flow using local HTTP redirect on an ephemeral port.
101101
- Supports a browserless/manual flow (paste redirect URL) for headless environments.
102+
- Supports a remote/server-friendly 2-step manual flow:
103+
- Step 1 prints an auth URL (`gog auth add ... --remote --step 1`)
104+
- Step 2 exchanges the pasted redirect URL and requires `state` validation (`--remote --step 2 --auth-url ...`)
102105
- Refresh token issuance:
103106
- requests `access_type=offline`
104107
- supports `--force-consent` to force the consent prompt when Google doesn't return a refresh token
@@ -119,6 +122,7 @@ Scope selection note:
119122
- `credentials-<client>.json` (OAuth client id/secret; named clients)
120123
- State:
121124
- `state/gmail-watch/<account>.json` (Gmail watch state)
125+
- `oauth-manual-state-<state>.json` (temporary manual OAuth state cache; expires quickly; no tokens)
122126
- Secrets:
123127
- refresh tokens in keyring
124128

@@ -148,7 +152,7 @@ Flag aliases:
148152
- `gog auth credentials <credentials.json|->`
149153
- `gog auth credentials list`
150154
- `gog --client <name> auth credentials <credentials.json|->`
151-
- `gog auth add <email> [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--force-consent]`
155+
- `gog auth add <email> [--services user|all|gmail,calendar,classroom,drive,docs,contacts,tasks,sheets,people,groups] [--readonly] [--drive-scope full|readonly|file] [--manual] [--remote] [--step 1|2] [--auth-url URL] [--timeout DURATION] [--force-consent]`
152156
- `gog auth services [--markdown]`
153157
- `gog auth keep <email> --key <service-account.json>` (Google Keep; Workspace only)
154158
- `gog auth list`

internal/cmd/auth.go

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ var (
2626
checkRefreshToken = googleauth.CheckRefreshToken
2727
ensureKeychainAccess = secrets.EnsureKeychainAccess
2828
fetchAuthorizedEmail = googleauth.EmailForRefreshToken
29+
manualAuthURL = googleauth.ManualAuthURL
2930
)
3031

3132
func ensureKeychainAccessIfNeeded() error {
@@ -479,12 +480,17 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
479480
}
480481

481482
type AuthAddCmd struct {
482-
Email string `arg:"" name:"email" help:"Email"`
483-
Manual bool `name:"manual" help:"Browserless auth flow (paste redirect URL)"`
484-
ForceConsent bool `name:"force-consent" help:"Force consent screen to obtain a refresh token"`
485-
ServicesCSV string `name:"services" help:"Services to authorize: user|all or comma-separated ${auth_services} (Keep uses service account: gog auth service-account set)" default:"user"`
486-
Readonly bool `name:"readonly" help:"Use read-only scopes where available (still includes OIDC identity scopes)"`
487-
DriveScope string `name:"drive-scope" help:"Drive scope mode: full|readonly|file" enum:"full,readonly,file" default:"full"`
483+
Email string `arg:"" name:"email" help:"Email"`
484+
Manual bool `name:"manual" help:"Browserless auth flow (paste redirect URL)"`
485+
Remote bool `name:"remote" help:"Remote/server-friendly manual flow (print URL, then exchange code)"`
486+
Step int `name:"step" help:"Remote auth step: 1=print URL, 2=exchange code"`
487+
AuthURL string `name:"auth-url" help:"Redirect URL from browser (manual flow; required for --remote --step 2)"`
488+
AuthCode string `name:"auth-code" hidden:"" help:"UNSAFE: Authorization code from browser (manual flow; skips state check; not valid with --remote)"`
489+
Timeout time.Duration `name:"timeout" help:"Authorization timeout (manual flows default to 5m)"`
490+
ForceConsent bool `name:"force-consent" help:"Force consent screen to obtain a refresh token"`
491+
ServicesCSV string `name:"services" help:"Services to authorize: user|all or comma-separated ${auth_services} (Keep uses service account: gog auth service-account set)" default:"user"`
492+
Readonly bool `name:"readonly" help:"Use read-only scopes where available (still includes OIDC identity scopes)"`
493+
DriveScope string `name:"drive-scope" help:"Drive scope mode: full|readonly|file" enum:"full,readonly,file" default:"full"`
488494
}
489495

490496
func (c *AuthAddCmd) Run(ctx context.Context) error {
@@ -515,6 +521,69 @@ func (c *AuthAddCmd) Run(ctx context.Context) error {
515521
return err
516522
}
517523

524+
authURL := strings.TrimSpace(c.AuthURL)
525+
authCode := strings.TrimSpace(c.AuthCode)
526+
if authURL != "" && authCode != "" {
527+
return usage("cannot combine --auth-url with --auth-code")
528+
}
529+
if c.Step != 0 && c.Step != 1 && c.Step != 2 {
530+
return usage("step must be 1 or 2")
531+
}
532+
if c.Step != 0 && !c.Remote {
533+
return usage("--step requires --remote")
534+
}
535+
536+
manual := c.Manual || c.Remote || authURL != "" || authCode != ""
537+
538+
if c.Remote {
539+
step := c.Step
540+
if step == 0 {
541+
if authURL != "" || authCode != "" {
542+
step = 2
543+
} else {
544+
step = 1
545+
}
546+
}
547+
switch step {
548+
case 1:
549+
if authURL != "" || authCode != "" {
550+
return usage("remote step 1 does not accept --auth-url or --auth-code")
551+
}
552+
result, manualErr := manualAuthURL(ctx, googleauth.AuthorizeOptions{
553+
Services: services,
554+
Scopes: scopes,
555+
Manual: true,
556+
ForceConsent: c.ForceConsent,
557+
Client: client,
558+
})
559+
if manualErr != nil {
560+
return manualErr
561+
}
562+
if outfmt.IsJSON(ctx) {
563+
return outfmt.WriteJSON(os.Stdout, map[string]any{
564+
"auth_url": result.URL,
565+
"state_reused": result.StateReused,
566+
})
567+
}
568+
u.Out().Printf("auth_url\t%s", result.URL)
569+
u.Out().Printf("state_reused\t%t", result.StateReused)
570+
u.Err().Println("Run again with --remote --step 2 --auth-url <redirect-url>")
571+
return nil
572+
case 2:
573+
if authCode != "" {
574+
return usage("--auth-code is not valid with --remote (state check is mandatory)")
575+
}
576+
if authURL == "" {
577+
return usage("remote step 2 requires --auth-url")
578+
}
579+
}
580+
}
581+
582+
timeout := c.Timeout
583+
if timeout == 0 && manual {
584+
timeout = 5 * time.Minute
585+
}
586+
518587
// Pre-flight: ensure keychain is accessible before starting OAuth
519588
if keychainErr := ensureKeychainAccessIfNeeded(); keychainErr != nil {
520589
return fmt.Errorf("keychain access: %w", keychainErr)
@@ -523,9 +592,13 @@ func (c *AuthAddCmd) Run(ctx context.Context) error {
523592
refreshToken, err := authorizeGoogle(ctx, googleauth.AuthorizeOptions{
524593
Services: services,
525594
Scopes: scopes,
526-
Manual: c.Manual,
595+
Manual: manual,
527596
ForceConsent: c.ForceConsent,
597+
Timeout: timeout,
528598
Client: client,
599+
AuthURL: authURL,
600+
AuthCode: authCode,
601+
RequireState: c.Remote,
529602
})
530603
if err != nil {
531604
return err

internal/cmd/auth_add_test.go

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,180 @@ func TestAuthAddCmd_SheetsDriveScopeFile(t *testing.T) {
473473
}
474474
}
475475

476+
func TestAuthAddCmd_RemoteStep1_PrintsAuthURL(t *testing.T) {
477+
origManualURL := manualAuthURL
478+
origAuth := authorizeGoogle
479+
origKeychain := ensureKeychainAccess
480+
t.Cleanup(func() {
481+
manualAuthURL = origManualURL
482+
authorizeGoogle = origAuth
483+
ensureKeychainAccess = origKeychain
484+
})
485+
486+
manualCalled := false
487+
manualAuthURL = func(context.Context, googleauth.AuthorizeOptions) (googleauth.ManualAuthURLResult, error) {
488+
manualCalled = true
489+
return googleauth.ManualAuthURLResult{
490+
URL: "https://example.com/auth",
491+
StateReused: true,
492+
}, nil
493+
}
494+
authorizeGoogle = func(context.Context, googleauth.AuthorizeOptions) (string, error) {
495+
t.Fatal("authorizeGoogle should not be called in remote step 1")
496+
return "", nil
497+
}
498+
ensureKeychainAccess = func() error {
499+
t.Fatal("keychain access should not be checked in remote step 1")
500+
return nil
501+
}
502+
503+
out := captureStdout(t, func() {
504+
_ = captureStderr(t, func() {
505+
if err := Execute([]string{
506+
"auth",
507+
"add",
508+
"user@example.com",
509+
"--services",
510+
"gmail",
511+
"--remote",
512+
"--step",
513+
"1",
514+
}); err != nil {
515+
t.Fatalf("Execute: %v", err)
516+
}
517+
})
518+
})
519+
520+
if !manualCalled {
521+
t.Fatalf("expected manualAuthURL to be called")
522+
}
523+
if !strings.Contains(out, "auth_url\thttps://example.com/auth") {
524+
t.Fatalf("unexpected output: %q", out)
525+
}
526+
if !strings.Contains(out, "state_reused\ttrue") {
527+
t.Fatalf("expected state_reused output, got: %q", out)
528+
}
529+
}
530+
531+
func TestAuthAddCmd_RemoteStep2_RejectsAuthCode(t *testing.T) {
532+
err := Execute([]string{
533+
"auth",
534+
"add",
535+
"user@example.com",
536+
"--services",
537+
"gmail",
538+
"--remote",
539+
"--step",
540+
"2",
541+
"--auth-code",
542+
"abc123",
543+
})
544+
if err == nil {
545+
t.Fatalf("expected error")
546+
}
547+
var ee *ExitError
548+
if !errors.As(err, &ee) || ee.Code != 2 {
549+
t.Fatalf("expected exit code 2, got %T %#v", err, err)
550+
}
551+
if !strings.Contains(err.Error(), "--auth-code is not valid with --remote") {
552+
t.Fatalf("unexpected error: %v", err)
553+
}
554+
}
555+
556+
func TestAuthAddCmd_RemoteStep2_PassesAuthURL(t *testing.T) {
557+
origAuth := authorizeGoogle
558+
origOpen := openSecretsStore
559+
origKeychain := ensureKeychainAccess
560+
origFetch := fetchAuthorizedEmail
561+
t.Cleanup(func() {
562+
authorizeGoogle = origAuth
563+
openSecretsStore = origOpen
564+
ensureKeychainAccess = origKeychain
565+
fetchAuthorizedEmail = origFetch
566+
})
567+
568+
ensureKeychainAccess = func() error { return nil }
569+
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
570+
571+
var gotOpts googleauth.AuthorizeOptions
572+
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
573+
gotOpts = opts
574+
return "rt", nil
575+
}
576+
fetchAuthorizedEmail = func(context.Context, string, string, []string, time.Duration) (string, error) {
577+
return "user@example.com", nil
578+
}
579+
580+
if err := Execute([]string{
581+
"auth",
582+
"add",
583+
"user@example.com",
584+
"--services",
585+
"gmail",
586+
"--remote",
587+
"--step",
588+
"2",
589+
"--auth-url",
590+
"http://localhost:1/?code=abc&state=state123",
591+
}); err != nil {
592+
t.Fatalf("Execute: %v", err)
593+
}
594+
595+
if !gotOpts.Manual {
596+
t.Fatalf("expected manual auth in remote step 2")
597+
}
598+
if !gotOpts.RequireState {
599+
t.Fatalf("expected require state in remote step 2")
600+
}
601+
if gotOpts.AuthURL == "" {
602+
t.Fatalf("expected auth URL to be passed through")
603+
}
604+
}
605+
606+
func TestAuthAddCmd_AuthCode_PassesThrough(t *testing.T) {
607+
origAuth := authorizeGoogle
608+
origOpen := openSecretsStore
609+
origKeychain := ensureKeychainAccess
610+
origFetch := fetchAuthorizedEmail
611+
t.Cleanup(func() {
612+
authorizeGoogle = origAuth
613+
openSecretsStore = origOpen
614+
ensureKeychainAccess = origKeychain
615+
fetchAuthorizedEmail = origFetch
616+
})
617+
618+
ensureKeychainAccess = func() error { return nil }
619+
openSecretsStore = func() (secrets.Store, error) { return newMemSecretsStore(), nil }
620+
621+
var gotOpts googleauth.AuthorizeOptions
622+
authorizeGoogle = func(ctx context.Context, opts googleauth.AuthorizeOptions) (string, error) {
623+
gotOpts = opts
624+
return "rt", nil
625+
}
626+
fetchAuthorizedEmail = func(context.Context, string, string, []string, time.Duration) (string, error) {
627+
return "user@example.com", nil
628+
}
629+
630+
if err := Execute([]string{
631+
"auth",
632+
"add",
633+
"user@example.com",
634+
"--services",
635+
"gmail",
636+
"--auth-code",
637+
"abc123",
638+
}); err != nil {
639+
t.Fatalf("Execute: %v", err)
640+
}
641+
642+
if !gotOpts.Manual {
643+
t.Fatalf("expected manual auth when auth-code is provided")
644+
}
645+
if gotOpts.AuthCode != "abc123" {
646+
t.Fatalf("expected auth-code to be passed through, got %q", gotOpts.AuthCode)
647+
}
648+
}
649+
476650
func containsStringInSlice(items []string, want string) bool {
477651
for _, it := range items {
478652
if it == want {

0 commit comments

Comments
 (0)