Compare commits

...

12 Commits

Author SHA1 Message Date
Peter Steinberger
2bf1f417a3 docs: update changelog for keychain unlock
Some checks failed
ci / test (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-01-03 06:51:55 +01:00
Peter Steinberger
58d38ad057 fix: respect no-input for keychain access 2026-01-03 06:51:28 +01:00
salmonumbrella
1f88ef1956
fix: remove stale nolint directive
Main branch fixed the contextcheck issue by using ctx instead of
context.Background(), so the nolint directive is no longer needed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:30:24 -08:00
salmonumbrella
4ffe49a398
fix: suppress contextcheck for intentional fresh shutdown context
The server shutdown intentionally uses a fresh context.Background()
so it can complete gracefully even if the parent context is canceled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:19:40 -08:00
salmonumbrella
11f62e7086
fix: suppress nolintlint for platform-specific contextcheck
The contextcheck linter only triggers on macOS (where EnsureKeychainAccess
has a real implementation), but on Linux the stub doesn't trigger it,
causing nolintlint to complain the directive is unused.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:19:40 -08:00
salmonumbrella
efd0a8077f
fix: skip keychain test on non-macOS platforms
The wrapKeychainError test was failing on Linux CI because
IsKeychainLockedError always returns false on non-Darwin platforms.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:18:45 -08:00
salmonumbrella
c1f2fd5bfc
fix: address lint issues in keychain checks 2026-01-02 11:18:45 -08:00
salmonumbrella
f51536e042
feat(auth): check keychain in manage server flow 2026-01-02 11:17:38 -08:00
salmonumbrella
fd652aadd7
feat(auth): check keychain before token import 2026-01-02 11:17:37 -08:00
salmonumbrella
c02aecf12c
feat(auth): check keychain access before OAuth flow
Fixes #22 - prevents 'User Interaction is not allowed (-25308)' error
by checking keychain accessibility before starting OAuth. If locked,
prompts user for macOS password to unlock.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:17:37 -08:00
salmonumbrella
4dfbf9d706
feat(secrets): wrap keychain errors with unlock guidance
When SetToken encounters a locked keychain error (-25308), the error
message now includes instructions for how to unlock the keychain
manually using the security command.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:17:37 -08:00
salmonumbrella
a441ed4448
feat(secrets): add macOS keychain lock detection and unlock helpers
Add platform-specific helpers to detect when the macOS keychain is locked
and prompt the user to unlock it. This is the foundation for fixing GitHub
issue #22 where `gog auth` fails with "User Interaction is not allowed.
(-25308)" on macOS 26.2.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 11:17:37 -08:00
14 changed files with 381 additions and 12 deletions

View File

@ -7,6 +7,7 @@
- Homebrew: tap now installs GitHub release binaries (macOS) to reduce Keychain prompt churn.
- Secrets: add `GOG_KEYRING_BACKEND={auto|keychain|file}` to force backend (use `file` to avoid Keychain prompts; pair with `GOG_KEYRING_PASSWORD`).
- Docs: explain macOS Keychain prompts and backend options.
- Auth: proactive macOS keychain unlock checks for auth flows (#28) — thanks @salmonumbrella.
## 0.4.2 - 2025-12-31

View File

@ -19,10 +19,11 @@ import (
)
var (
openSecretsStore = secrets.OpenDefault
authorizeGoogle = googleauth.Authorize
startManageServer = googleauth.StartManageServer
checkRefreshToken = googleauth.CheckRefreshToken
openSecretsStore = secrets.OpenDefault
authorizeGoogle = googleauth.Authorize
startManageServer = googleauth.StartManageServer
checkRefreshToken = googleauth.CheckRefreshToken
ensureKeychainAccess = secrets.EnsureKeychainAccess
)
type AuthCmd struct {
@ -223,7 +224,7 @@ type AuthTokensImportCmd struct {
InPath string `arg:"" name:"inPath" help:"Input path or '-' for stdin"`
}
func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
func (c *AuthTokensImportCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
inPath := c.InPath
var b []byte
@ -264,6 +265,11 @@ func (c *AuthTokensImportCmd) Run(ctx context.Context) error {
createdAt = parsed
}
// Pre-flight: ensure keychain is accessible before storing token
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
return fmt.Errorf("keychain access: %w", keychainErr)
}
store, err := openSecretsStore()
if err != nil {
return err
@ -297,7 +303,7 @@ type AuthAddCmd struct {
ServicesCSV string `name:"services" help:"Services to authorize: all or comma-separated gmail,calendar,drive,contacts,tasks,sheets,people" default:"all"`
}
func (c *AuthAddCmd) Run(ctx context.Context) error {
func (c *AuthAddCmd) Run(ctx context.Context, flags *RootFlags) error {
u := ui.FromContext(ctx)
var services []googleauth.Service
@ -327,6 +333,11 @@ func (c *AuthAddCmd) Run(ctx context.Context) error {
return err
}
// Pre-flight: ensure keychain is accessible before starting OAuth
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
return fmt.Errorf("keychain access: %w", keychainErr)
}
refreshToken, err := authorizeGoogle(ctx, googleauth.AuthorizeOptions{
Services: services,
Scopes: scopes,
@ -478,7 +489,7 @@ type AuthManageCmd struct {
Timeout time.Duration `name:"timeout" help:"Server timeout duration" default:"10m"`
}
func (c *AuthManageCmd) Run(ctx context.Context) error {
func (c *AuthManageCmd) Run(ctx context.Context, flags *RootFlags) error {
var services []googleauth.Service
if strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "") || strings.EqualFold(strings.TrimSpace(c.ServicesCSV), "all") {
services = googleauth.AllServices()
@ -498,9 +509,14 @@ func (c *AuthManageCmd) Run(ctx context.Context) error {
}
}
if keychainErr := ensureKeychainAccess(flags != nil && flags.NoInput); keychainErr != nil {
return fmt.Errorf("keychain access: %w", keychainErr)
}
return startManageServer(ctx, googleauth.ManageServerOptions{
Timeout: c.Timeout,
Services: services,
ForceConsent: c.ForceConsent,
NoInput: flags != nil && flags.NoInput,
})
}

View File

@ -3,6 +3,7 @@ package cmd
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
@ -13,11 +14,15 @@ import (
func TestAuthAddCmd_JSON(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
})
ensureKeychainAccess = func(bool) error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
@ -70,3 +75,46 @@ func TestAuthAddCmd_JSON(t *testing.T) {
t.Fatalf("unexpected token: %#v", tok)
}
}
func TestAuthAddCmd_KeychainError(t *testing.T) {
origAuth := authorizeGoogle
origOpen := openSecretsStore
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
authorizeGoogle = origAuth
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
})
// Simulate keychain locked error
var gotNoInput bool
ensureKeychainAccess = func(noInput bool) error {
gotNoInput = noInput
return errors.New("keychain is locked")
}
authCalled := false
authorizeGoogle = func(_ context.Context, _ googleauth.AuthorizeOptions) (string, error) {
authCalled = true
return "rt", nil
}
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }
cmd := &AuthAddCmd{Email: "test@example.com", ServicesCSV: "gmail"}
err := cmd.Run(context.Background(), &RootFlags{NoInput: true})
if err == nil {
t.Fatal("expected error when keychain is locked")
}
if !strings.Contains(err.Error(), "keychain") {
t.Errorf("expected error to mention keychain, got: %v", err)
}
if authCalled {
t.Error("authorizeGoogle should not be called when keychain check fails")
}
if !gotNoInput {
t.Error("expected noInput to be forwarded to keychain access")
}
}

View File

@ -91,8 +91,13 @@ func (s *memSecretsStore) SetDefaultAccount(email string) error {
func TestAuthTokens_ExportImportRoundtrip_JSON(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
})
ensureKeychainAccess = func(bool) error { return nil }
store := newMemSecretsStore()
createdAt := time.Date(2025, 12, 12, 0, 0, 0, 0, time.UTC)
if err := store.SetToken("A@B.COM", secrets.Token{

View File

@ -0,0 +1,18 @@
//go:build darwin
package cmd
import (
"testing"
"github.com/steipete/gogcli/internal/secrets"
)
func TestAuthAddCmd_ChecksKeychainFirst(t *testing.T) {
// Verify the EnsureKeychainAccess function exists and is callable
err := secrets.EnsureKeychainAccess(true)
if err != nil {
// If this fails, keychain might be locked - that's expected in some test environments
t.Skipf("Keychain appears to be locked, skipping: %v", err)
}
}

View File

@ -10,15 +10,18 @@ import (
func TestAuthManageCmd_ServicesAndOptions(t *testing.T) {
orig := startManageServer
origKeychain := ensureKeychainAccess
t.Cleanup(func() { startManageServer = orig })
t.Cleanup(func() { ensureKeychainAccess = origKeychain })
var got googleauth.ManageServerOptions
startManageServer = func(ctx context.Context, opts googleauth.ManageServerOptions) error {
got = opts
return nil
}
ensureKeychainAccess = func(bool) error { return nil }
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "gmail,drive,gmail", "--force-consent", "--timeout", "2m"}, context.Background(), nil); err != nil {
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "gmail,drive,gmail", "--force-consent", "--timeout", "2m"}, context.Background(), &RootFlags{NoInput: true}); err != nil {
t.Fatalf("execute: %v", err)
}
@ -31,14 +34,20 @@ func TestAuthManageCmd_ServicesAndOptions(t *testing.T) {
if len(got.Services) != 2 {
t.Fatalf("expected de-duped services, got %#v", got.Services)
}
if !got.NoInput {
t.Fatalf("expected no-input forwarded")
}
}
func TestAuthManageCmd_InvalidService(t *testing.T) {
orig := startManageServer
origKeychain := ensureKeychainAccess
t.Cleanup(func() { startManageServer = orig })
t.Cleanup(func() { ensureKeychainAccess = origKeychain })
startManageServer = func(context.Context, googleauth.ManageServerOptions) error { return nil }
ensureKeychainAccess = func(bool) error { return nil }
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "nope"}, context.Background(), nil); err == nil {
if err := runKong(t, &AuthManageCmd{}, []string{"--services", "nope"}, context.Background(), &RootFlags{}); err == nil {
t.Fatalf("expected error")
}
}

View File

@ -144,8 +144,13 @@ func TestAuthList_Check_Text(t *testing.T) {
func TestAuthTokensExportImport_Text(t *testing.T) {
origOpen := openSecretsStore
t.Cleanup(func() { openSecretsStore = origOpen })
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
openSecretsStore = origOpen
ensureKeychainAccess = origKeychain
})
ensureKeychainAccess = func(bool) error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }

View File

@ -12,11 +12,15 @@ import (
func TestExecute_AuthAdd_JSON(t *testing.T) {
origOpen := openSecretsStore
origAuth := authorizeGoogle
origKeychain := ensureKeychainAccess
t.Cleanup(func() {
openSecretsStore = origOpen
authorizeGoogle = origAuth
ensureKeychainAccess = origKeychain
})
ensureKeychainAccess = func(bool) error { return nil }
store := newMemSecretsStore()
openSecretsStore = func() (secrets.Store, error) { return store, nil }

View File

@ -30,6 +30,7 @@ type ManageServerOptions struct {
Timeout time.Duration
Services []Service
ForceConsent bool
NoInput bool
}
// ManageServer handles the accounts management UI
@ -293,6 +294,14 @@ func (ms *ManageServer) handleOAuthCallback(w http.ResponseWriter, r *http.Reque
email = "user@gmail.com"
}
// Pre-flight: ensure keychain is accessible before storing token
if err := secrets.EnsureKeychainAccess(ms.opts.NoInput); err != nil { //nolint:contextcheck,nolintlint // keychain ops don't use context; nolint unused on non-Darwin
w.WriteHeader(http.StatusInternalServerError)
renderErrorPage(w, "Keychain is locked: "+err.Error())
return
}
// Store the token
serviceNames := make([]string, 0, len(services))
for _, svc := range services {

View File

@ -0,0 +1,106 @@
//go:build darwin
package secrets
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"golang.org/x/term"
)
const (
// errSecInteractionNotAllowed is macOS Security framework error -25308
errSecInteractionNotAllowed = "-25308"
)
var (
errKeychainPathUnknown = errors.New("cannot determine login keychain path")
errKeychainNoTTY = errors.New("keychain is locked and no TTY available for password prompt")
errKeychainNoInput = errors.New("keychain is locked and prompting is disabled")
errKeychainUnlock = errors.New("unlock keychain: incorrect password or keychain error")
)
// IsKeychainLockedError returns true if the error string indicates a locked keychain.
func IsKeychainLockedError(errStr string) bool {
return strings.Contains(errStr, errSecInteractionNotAllowed)
}
// loginKeychainPath returns the path to the user's login keychain.
func loginKeychainPath() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "Library", "Keychains", "login.keychain-db")
}
// CheckKeychainLocked checks if the login keychain is locked.
// Returns true if locked, false if unlocked or on error detecting status.
func CheckKeychainLocked() bool {
path := loginKeychainPath()
if path == "" {
return false
}
cmd := exec.CommandContext(context.Background(), "security", "show-keychain-info", path) //nolint:gosec // path is from os.UserHomeDir, not user input
err := cmd.Run()
// Exit code 0 = unlocked, non-zero = locked or error
return err != nil
}
// UnlockKeychain prompts for password and unlocks the login keychain.
// Returns nil on success, error on failure.
func UnlockKeychain() error {
path := loginKeychainPath()
if path == "" {
return errKeychainPathUnknown
}
// Check if we have a TTY for password input
if !term.IsTerminal(int(syscall.Stdin)) {
return fmt.Errorf("%w\n\nTo unlock manually, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", errKeychainNoTTY)
}
fmt.Fprint(os.Stderr, "Keychain is locked. Enter your macOS login password to unlock: ")
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Fprintln(os.Stderr) // newline after password input
if err != nil {
return fmt.Errorf("read password: %w", err)
}
// Pass password via stdin to avoid exposing it in process list (ps aux)
cmd := exec.CommandContext(context.Background(), "security", "unlock-keychain", path) //nolint:gosec // path is from os.UserHomeDir
cmd.Stdin = strings.NewReader(string(password) + "\n")
if err := cmd.Run(); err != nil {
return errKeychainUnlock
}
return nil
}
// EnsureKeychainAccess checks if the keychain is accessible and unlocks it if needed.
// Returns nil if keychain is accessible (unlocked or successfully unlocked).
// Returns error if keychain cannot be unlocked.
func EnsureKeychainAccess(noInput bool) error {
if !CheckKeychainLocked() {
return nil
}
if noInput {
return fmt.Errorf("%w\n\nTo unlock manually, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", errKeychainNoInput)
}
return UnlockKeychain()
}

View File

@ -0,0 +1,49 @@
//go:build darwin
package secrets
import (
"strings"
"testing"
)
func TestIsKeychainLockedError(t *testing.T) {
tests := []struct {
name string
err string
expected bool
}{
{"locked error", "store token: User Interaction is not allowed. (-25308)", true},
{"just error code", "some error (-25308)", true},
{"different error", "store token: some other error", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := IsKeychainLockedError(tt.err)
if got != tt.expected {
t.Errorf("IsKeychainLockedError(%q) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}
func TestKeychainPath(t *testing.T) {
path := loginKeychainPath()
if path == "" {
t.Error("keychainPath() returned empty string")
}
if !strings.HasSuffix(path, "login.keychain-db") {
t.Errorf("unexpected keychain path: %s", path)
}
}
func TestEnsureKeychainAccess_UnlockedKeychain(t *testing.T) {
// On a normal dev machine, keychain should be unlocked
err := EnsureKeychainAccess(true)
if err != nil {
t.Skipf("Keychain appears to be locked, skipping: %v", err)
}
}

View File

@ -0,0 +1,23 @@
//go:build !darwin
package secrets
// IsKeychainLockedError returns false on non-macOS platforms.
func IsKeychainLockedError(_ string) bool {
return false
}
// CheckKeychainLocked returns false on non-macOS platforms.
func CheckKeychainLocked() bool {
return false
}
// UnlockKeychain is a no-op on non-macOS platforms.
func UnlockKeychain() error {
return nil
}
// EnsureKeychainAccess is a no-op on non-macOS platforms.
func EnsureKeychainAccess(_ bool) error {
return nil
}

View File

@ -63,6 +63,19 @@ func allowedBackendsFromEnv() ([]keyring.BackendType, error) {
}
}
// wrapKeychainError wraps keychain errors with helpful guidance on macOS.
func wrapKeychainError(err error) error {
if err == nil {
return nil
}
if IsKeychainLockedError(err.Error()) {
return fmt.Errorf("%w\n\nYour macOS keychain is locked. To unlock it, run:\n security unlock-keychain ~/Library/Keychains/login.keychain-db", err)
}
return err
}
func fileKeyringPasswordFuncFrom(password string, isTTY bool) keyring.PromptFunc {
if password != "" {
return keyring.FixedStringPrompt(password)
@ -153,7 +166,7 @@ func (s *KeyringStore) SetToken(email string, tok Token) error {
Key: tokenKey(email),
Data: payload,
}); err != nil {
return fmt.Errorf("store token: %w", err)
return wrapKeychainError(fmt.Errorf("store token: %w", err))
}
return nil

View File

@ -1,6 +1,8 @@
package secrets
import (
"errors"
"strings"
"testing"
"time"
@ -98,3 +100,64 @@ func TestKeyringStore_DefaultAccount_Roundtrip(t *testing.T) {
t.Fatalf("unexpected default: %q", got)
}
}
var (
errTestKeychainLocked = errors.New("store token: User Interaction is not allowed. (-25308)")
errTestOther = errors.New("store token: some other error")
)
func TestWrapKeychainError(t *testing.T) {
if !IsKeychainLockedError("User Interaction is not allowed. (-25308)") {
t.Skip("wrapKeychainError only wraps errors on macOS")
}
tests := []struct {
name string
err error
wantWrapped bool
wantContain string
}{
{
name: "nil error",
err: nil,
wantWrapped: false,
},
{
name: "keychain locked error",
err: errTestKeychainLocked,
wantWrapped: true,
wantContain: "security unlock-keychain",
},
{
name: "other error",
err: errTestOther,
wantWrapped: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wrapped := wrapKeychainError(tt.err)
if tt.err == nil {
if wrapped != nil {
t.Error("expected nil error to remain nil")
}
return
}
if tt.wantWrapped {
if errors.Is(wrapped, tt.err) && wrapped.Error() == tt.err.Error() {
t.Error("expected error to be wrapped with additional context")
}
if tt.wantContain != "" && !strings.Contains(wrapped.Error(), tt.wantContain) {
t.Errorf("wrapped error %q should contain %q", wrapped.Error(), tt.wantContain)
}
} else if !errors.Is(wrapped, tt.err) {
t.Error("expected error to remain unchanged")
}
})
}
}