Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 64 additions & 2 deletions auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ package auth

import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/googleapi"

"github.com/open-cli-collective/google-cli-common/config"
"github.com/open-cli-collective/google-cli-common/keychain"
Expand Down Expand Up @@ -90,9 +93,11 @@ func GetHTTPClient(ctx context.Context) (*http.Client, error) {
tok, err := st.Token()
if err != nil {
_ = st.Close()
return nil, fmt.Errorf("no OAuth token found - please run '%s init' first: %w", config.ProductName(), err)
return nil, fmt.Errorf("no OAuth token stored for credential %s (selected via %s) - run '%s init' first: %w",
st.Ref(), keychain.DescribeRefSource(st.RefSource()), config.ProductName(), err)
}
ref := st.Ref()
refSource := st.RefSource()
_ = st.Close() // do not hold the Store for the client's lifetime

persist := func(t *oauth2.Token) error {
Expand All @@ -105,7 +110,64 @@ func GetHTTPClient(ctx context.Context) (*http.Client, error) {
}

tokenSource := keychain.NewPersistentTokenSource(ctx, oauthCfg, tok, persist)
return oauth2.NewClient(ctx, tokenSource), nil
return oauth2.NewClient(ctx, &attributedTokenSource{base: tokenSource, ref: ref, source: refSource}), nil
}

// attributedTokenSource decorates auth failures from the wrapped source with
// the credential ref that failed and where that ref was selected. Without
// this, an expired/revoked refresh token surfaces as a bare
// `oauth2: "invalid_grant" ...` — which reads as "the tool is broken" when
// the real state is "this one profile is stale and others may be fine".
// Non-auth errors (network, API outages) pass through untouched: naming the
// profile there would misattribute an infrastructure failure to a credential.
type attributedTokenSource struct {
base oauth2.TokenSource
ref string
source config.RefSource
}

func (a *attributedTokenSource) Token() (*oauth2.Token, error) {
tok, err := a.base.Token()
if err != nil && IsAuthError(err) {
prod := config.ProductName()
return nil, fmt.Errorf("credential %s (selected via %s) can no longer authenticate: %w; other profiles may be unaffected - run '%s profiles list' to check them, or '%s init' to re-authenticate this one",
a.ref, keychain.DescribeRefSource(a.source), err, prod, prod)
}
return tok, err
}

// IsAuthError reports whether err means the stored token no longer
// authenticates — i.e. re-auth is the fix — as opposed to a transient
// network/API failure. Shared by init's re-auth gate and the runtime
// error-attribution wrapper so "what counts as an auth failure" has exactly
// one definition.
func IsAuthError(err error) bool {
if err == nil {
return false
}
// An expired/revoked refresh token fails inside the oauth2 transport
// (the request never reaches the API): the token endpoint returns HTTP
// 400 with error code "invalid_grant", surfaced as *oauth2.RetrieveError.
// Testing-mode OAuth apps expire their refresh tokens every 7 days, and
// the resulting error carries no 401 for the fallback below.
var retrieveErr *oauth2.RetrieveError
if ok := errors.As(err, &retrieveErr); ok && retrieveErr.ErrorCode == "invalid_grant" {
return true
}
var apiErr *googleapi.Error
if ok := errors.As(err, &apiErr); ok {
return apiErr.Code == http.StatusUnauthorized
}
// String fallback for wrapped/legacy error shapes. "invalid_grant" and
// "Token has been expired or revoked" only ever come from the OAuth
// token endpoint's error response, so they are safe to treat as
// definitive without a status code; a bare 401 still needs corroboration.
errStr := err.Error()
if strings.Contains(errStr, "invalid_grant") ||
strings.Contains(errStr, "Token has been expired or revoked") {
return true
}
return strings.Contains(errStr, "401") && strings.Contains(errStr, "Invalid Credentials")
}

// GetAuthURL returns the OAuth authorization URL for the given config
Expand Down
133 changes: 133 additions & 0 deletions auth/autherror_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package auth

import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"testing"

"golang.org/x/oauth2"
"google.golang.org/api/googleapi"

"github.com/open-cli-collective/google-cli-common/config"
)

// TestIsAuthError pins the auth-vs-transient classification. Moved from
// initcmd when the helper was promoted to this package so init's re-auth gate
// and the runtime error-attribution wrapper share one definition.
func TestIsAuthError(t *testing.T) {
t.Parallel()
tests := []struct {
name string
err error
expected bool
}{
{"nil error", nil, false},
{"generic error", errors.New("something went wrong"), false},
{"network error", errors.New("connection refused"), false},
{"googleapi 401", &googleapi.Error{Code: http.StatusUnauthorized, Message: "Invalid Credentials"}, true},
{"googleapi 403", &googleapi.Error{Code: http.StatusForbidden, Message: "Access denied"}, false},
{"googleapi 404", &googleapi.Error{Code: http.StatusNotFound, Message: "Not found"}, false},
{"text 401 + Invalid Credentials", errors.New("googleapi: Error 401: Invalid Credentials"), true},
{"text 401 + invalid_grant", errors.New("oauth2: 401 invalid_grant: Token has been expired"), true},
{"text Token has been expired or revoked", errors.New("401: Token has been expired or revoked"), true},
{"text 401 alone", errors.New("HTTP 401 response"), false},
// An expired/revoked refresh token fails token *refresh* (HTTP 400
// invalid_grant from the token endpoint), so it carries no 401 —
// this is the shape a Testing-mode OAuth app produces every 7 days.
{"RetrieveError invalid_grant", &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusBadRequest},
ErrorCode: "invalid_grant",
ErrorDescription: "Token has been expired or revoked.",
}, true},
{"RetrieveError invalid_grant wrapped like production", fmt.Errorf("getting profile: %w",
&url.Error{Op: "Get", URL: "https://gmail.googleapis.com/gmail/v1/users/me/profile", Err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusBadRequest},
ErrorCode: "invalid_grant",
}}), true},
{"RetrieveError other code", &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusServiceUnavailable},
ErrorCode: "temporarily_unavailable",
}, false},
{"text invalid_grant without 401", errors.New(`oauth2: "invalid_grant" "Token has been expired or revoked."`), true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := IsAuthError(tt.err); got != tt.expected {
t.Errorf("IsAuthError(%v) = %v, want %v", tt.err, got, tt.expected)
}
})
}
}

// staticErrSource is a TokenSource that always fails with a fixed error.
type staticErrSource struct{ err error }

func (s staticErrSource) Token() (*oauth2.Token, error) { return nil, s.err }

// TestAttributedTokenSource_NamesRefOnAuthError is the regression test for
// the mis-diagnosis this wrapper exists to prevent: a bare
// `oauth2: "invalid_grant"` with no profile name read as "the tool is dead"
// when only the active profile's token was stale.
func TestAttributedTokenSource_NamesRefOnAuthError(t *testing.T) {
base := staticErrSource{err: &oauth2.RetrieveError{
Response: &http.Response{StatusCode: http.StatusBadRequest},
ErrorCode: "invalid_grant",
ErrorDescription: "Token has been expired or revoked.",
}}
ats := &attributedTokenSource{base: base, ref: "google-readonly/default", source: config.RefSourceConfig}

_, err := ats.Token()
if err == nil {
t.Fatal("expected error")
}
msg := err.Error()
for _, want := range []string{
"credential google-readonly/default",
"config.yml credential_ref",
"invalid_grant",
"profiles list",
"init",
} {
if !strings.Contains(msg, want) {
t.Errorf("error %q missing %q", msg, want)
}
}
// The original error must stay errors.As-able through the wrap so
// callers classifying with IsAuthError (or inspecting RetrieveError)
// still work.
var retrieveErr *oauth2.RetrieveError
if !errors.As(err, &retrieveErr) {
t.Errorf("wrapped error lost the underlying *oauth2.RetrieveError")
}
}

// TestAttributedTokenSource_PassesThroughNonAuthErrors proves a network-class
// failure is NOT attributed to the credential (that would misdiagnose an
// outage as a stale profile).
func TestAttributedTokenSource_PassesThroughNonAuthErrors(t *testing.T) {
netErr := errors.New("dial tcp: connection refused")
ats := &attributedTokenSource{base: staticErrSource{err: netErr}, ref: "google-readonly/default", source: config.RefSourceConfig}

_, err := ats.Token()
if !errors.Is(err, netErr) {
t.Fatalf("err = %v, want the original error", err)
}
if strings.Contains(err.Error(), "credential ") {
t.Errorf("non-auth error must not be attributed to a credential: %q", err)
}
}

// TestAttributedTokenSource_PassesThroughSuccess proves the happy path is
// untouched.
func TestAttributedTokenSource_PassesThroughSuccess(t *testing.T) {
want := &oauth2.Token{AccessToken: "at"}
ats := &attributedTokenSource{base: oauth2.StaticTokenSource(want), ref: "google-readonly/default", source: config.RefSourceDefault}
got, err := ats.Token()
if err != nil || got.AccessToken != want.AccessToken {
t.Fatalf("Token() = (%v, %v), want (%v, nil)", got, err, want)
}
}
35 changes: 35 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,40 @@ type Config struct {
GrantedScopes []string `yaml:"granted_scopes,omitempty" json:"granted_scopes,omitempty"`
// Keyring carries the optional §1.4 explicit file-backend opt-in.
Keyring KeyringConfig `yaml:"keyring,omitempty" json:"-"`

// credentialRefSource records where the resolved CredentialRef came from
// (config.yml vs the built-in default; the keychain layer upgrades it to
// flag/env when a per-invocation override applies). Unexported so it is
// never serialized: it is provenance for error attribution and `config
// show`, not configuration.
credentialRefSource RefSource
}

// RefSource identifies where the resolved CredentialRef came from, so auth
// errors and `config show` can attribute the active profile to its source
// instead of leaving the user to guess which of flag/env/config selected it.
type RefSource string

// RefSource values, in precedence order (flag > env > config > default).
// RefSourceExplicit marks a caller-supplied ref (set-credential --ref, the
// refresh persister) that bypassed the precedence chain.
const (
RefSourceFlag RefSource = "flag"
RefSourceEnv RefSource = "env"
RefSourceConfig RefSource = "config"
RefSourceDefault RefSource = "default"
RefSourceExplicit RefSource = "explicit"
)

// CredentialRefSource returns the recorded provenance of CredentialRef.
// Empty for a Config constructed directly (tests) rather than loaded.
func (c *Config) CredentialRefSource() RefSource { return c.credentialRefSource }

// SetCredentialRefSource records CredentialRef provenance. Called by the
// keychain layer when a per-invocation override (flag/env) or an explicit
// caller-supplied ref replaces the loaded value.
func (c *Config) SetCredentialRefSource(s RefSource) { c.credentialRefSource = s }

// KeyringConfig is the §1.4 backend selector. Backend == "file" forces the
// encrypted-file backend; empty means OS default selection (fail-closed on
// Linux when no Secret Service is available).
Expand Down Expand Up @@ -349,6 +381,9 @@ func loadLegacyJSON(cfg *Config) error {
func (c *Config) applyDefaults() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applyDefaults sets credentialRefSource to RefSourceConfig or RefSourceDefault depending on whether CredentialRef was already populated when LoadConfig ran — this is the base-case provenance the rest of the attribution feature (auth error messages, config show) relies on when no --ref/env override applies. None of the existing TestLoadConfig subtests in config/config_test.go (unmodified by this PR) assert CredentialRefSource() after a load with an explicit config.yml credential_ref vs. a fresh install with none; keychain/credref_test.go only covers the override path using a directly-constructed *Config, which bypasses applyDefaults entirely. A regression here (e.g. accidentally swapping the two branches, or a code path that skips applyDefaults) would silently mislabel every unwrapped auth error as coming from the wrong source and would not be caught by any test in this diff. Add a subtest to TestLoadConfig asserting cfg.CredentialRefSource() == RefSourceDefault when credential_ref is absent and == RefSourceConfig when it is set in config.yml.

Reply inline to this comment.

if c.CredentialRef == "" {
c.CredentialRef = DefaultCredentialRef
c.credentialRefSource = RefSourceDefault
} else {
c.credentialRefSource = RefSourceConfig
}
if c.OAuthClientPath == "" {
if p, err := DefaultOAuthClientPath(); err == nil {
Expand Down
18 changes: 10 additions & 8 deletions configcmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ The OAuth client JSON (deployment material) is never removed.`,
// masked prefix.
type showStatus struct {
CredentialRef string `json:"credential_ref"`
CredentialRefSource string `json:"credential_ref_source,omitempty"`
Backend string `json:"backend"`
BackendSource string `json:"backend_source"`
KeyringBackend string `json:"keyring_backend,omitempty"` // selector from config.yml (keyring.backend)
Expand Down Expand Up @@ -157,13 +158,14 @@ func runShow(jsonOut, verbose bool) error {
}
backend, src := st.Backend()
status := showStatus{
CredentialRef: st.Ref(),
Backend: string(backend),
BackendSource: string(src),
KeyringBackend: cfg.Keyring.Backend, // selector value from config.yml; "" if unset
OAuthTokenPresent: hasTok,
OAuthClientPath: config.ShortenPath(cfg.OAuthClientPath),
OAuthClientPresent: false,
CredentialRef: st.Ref(),
CredentialRefSource: string(st.RefSource()),
Backend: string(backend),
BackendSource: string(src),
KeyringBackend: cfg.Keyring.Backend, // selector value from config.yml; "" if unset
OAuthTokenPresent: hasTok,
OAuthClientPath: config.ShortenPath(cfg.OAuthClientPath),
OAuthClientPresent: false,
}
if backend == credstore.BackendFile {
status.PassphraseSource = keychain.PassphraseSource(st.Service())
Expand All @@ -180,7 +182,7 @@ func runShow(jsonOut, verbose bool) error {
return output.JSONStdout(status)
}

fmt.Printf("Credential ref: %s\n", status.CredentialRef)
fmt.Printf("Credential ref: %s (via %s)\n", status.CredentialRef, keychain.DescribeRefSource(st.RefSource()))
fmt.Printf("Backend: %s (%s)\n", status.Backend, status.BackendSource)
if status.KeyringBackend != "" {
fmt.Printf("keyring.backend: %s (config.yml)\n", status.KeyringBackend)
Expand Down
39 changes: 1 addition & 38 deletions initcmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
Expand All @@ -19,7 +18,6 @@ import (
"github.com/spf13/cobra"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/googleapi"

"github.com/open-cli-collective/google-cli-common/auth"
"github.com/open-cli-collective/google-cli-common/config"
Expand Down Expand Up @@ -525,7 +523,7 @@ func tryExistingToken(ctx context.Context, d initDeps, opts *initOptions) (bool,

email, err := d.GmailVerify(ctx)
if err != nil {
if isAuthError(err) {
if auth.IsAuthError(err) {
d.View.Error("Stored token is expired or revoked.")
if err := promptAndDeleteForReauth(d, opts); err != nil {
return false, err
Expand Down Expand Up @@ -769,41 +767,6 @@ func extractAuthCode(input string) string {
return input
}

// isAuthError reports whether err means the stored token no longer
// authenticates — i.e. re-auth is the fix — as opposed to a transient
// network/API failure.
func isAuthError(err error) bool {
if err == nil {
return false
}
// An expired/revoked refresh token fails inside the oauth2 transport
// (the request never reaches the API): the token endpoint returns HTTP
// 400 with error code "invalid_grant", surfaced as *oauth2.RetrieveError.
// Without this branch, init hard-fails on the exact scenario it exists
// to fix — Testing-mode OAuth apps expire their refresh tokens every 7
// days, and the resulting error carries no 401 for the fallback below.
var retrieveErr *oauth2.RetrieveError
if ok := errorAs(err, &retrieveErr); ok && retrieveErr.ErrorCode == "invalid_grant" {
return true
}
var apiErr *googleapi.Error
if ok := errorAs(err, &apiErr); ok {
return apiErr.Code == http.StatusUnauthorized
}
// String fallback for wrapped/legacy error shapes. "invalid_grant" and
// "Token has been expired or revoked" only ever come from the OAuth
// token endpoint's error response, so they are safe to treat as
// definitive without a status code; a bare 401 still needs corroboration.
errStr := err.Error()
if strings.Contains(errStr, "invalid_grant") ||
strings.Contains(errStr, "Token has been expired or revoked") {
return true
}
return strings.Contains(errStr, "401") && strings.Contains(errStr, "Invalid Credentials")
}

var errorAs = errors.As

// workspaceAdminsURL points to the repo's Workspace-admin walkthrough.
// Referenced from both cmd.Long and the runtime wizard, so installed-CLI
// users (Homebrew/Chocolatey/Winget) reach it without a local checkout.
Expand Down
Loading
Loading