Skip to content

Commit 66a26d6

Browse files
committed
Credential enhancements
The default was clashing with another tool's credentials. Adds a new default env token COSTPULLER_CREDENTIALS which will take precedence over GOOGLE_APPLICATION_CREDENTIALS, if present. Enhances logging around credential discovery and usage, and moves those under `-debug` output when needed. This helped me diagnose the clash. Assisted by: Cursor
1 parent e7bfc76 commit 66a26d6

3 files changed

Lines changed: 150 additions & 29 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ option to list all command line options.
3636
OAuth client. The client configuration is provided in the conventional
3737
location (e.g., `${HOME}/.config/gcloud/application_default_credentials.json`
3838
or pointed to by the `GOOGLE_APPLICATION_CREDENTIALS` environment variable;
39+
if you set `COSTPULLER_CREDENTIALS` to a JSON file path, costpuller uses only
40+
that file and ignores `GOOGLE_APPLICATION_CREDENTIALS` in this process;
3941
see the Google [ADC documentation](https://cloud.google.com/docs/authentication/set-up-adc-local-dev-environment))
4042
and can be downloaded from a project on https://console.developers.google.com,
4143
under "Credentials". The access token and refresh token are cached in a

costpuller.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ func newOutputObject(options CommandLineOptions, accountsFile AccountsFile) *Out
163163
obj.csvFile = getCsvFile(options)
164164
} else if *options.outputTypePtr == "gsheet" {
165165
oauthConfig := getMapKeyValue(accountsFile.Configuration, "oauth", "configuration")
166-
obj.httpClient = getGoogleOAuthHttpClient(oauthConfig)
166+
obj.httpClient = getGoogleOAuthHttpClient(oauthConfig, *options.debugPtr)
167167
obj.gsheetConfig = getMapKeyValue(accountsFile.Configuration, "gsheet", "configuration")
168168
} else {
169169
log.Fatalf("[main] Unexpected value for output type, %q", *options.outputTypePtr)

gcloud_oauth.go

Lines changed: 147 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"os"
1515
"path/filepath"
1616
"regexp"
17+
"strings"
1718
"time"
1819

1920
"golang.org/x/oauth2"
@@ -28,6 +29,71 @@ const defaultTokenCachePath = "gcloud"
2829
// OAuth 2.0 access and refresh token values.
2930
const tokenFileName = "costpuller_token.json"
3031

32+
// costpullerCredentialsEnv is the environment variable that points to the
33+
// credentials JSON file for this program only. When set, Application Default
34+
// Credentials do not use GOOGLE_APPLICATION_CREDENTIALS, so other tools on the
35+
// same machine can keep using that variable without affecting costpuller.
36+
const costpullerCredentialsEnv = "COSTPULLER_CREDENTIALS"
37+
38+
const googleSheetsScope = "https://www.googleapis.com/auth/spreadsheets"
39+
40+
// oauthDebugf logs only when -debug is enabled (credential and token tracing).
41+
func oauthDebugf(debug bool, format string, args ...any) {
42+
if debug {
43+
log.Printf(format, args...)
44+
}
45+
}
46+
47+
// logCredentialSearchLocations logs where costpuller looks for credentials.
48+
func logCredentialSearchLocations(debug bool) {
49+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Credential search (Google Sheets OAuth client JSON)")
50+
if p := strings.TrimSpace(os.Getenv(costpullerCredentialsEnv)); p != "" {
51+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] %s is set to %q (this path is used; GOOGLE_APPLICATION_CREDENTIALS is ignored)", costpullerCredentialsEnv, p)
52+
if _, err := os.Stat(p); err == nil {
53+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File exists at %s path", costpullerCredentialsEnv)
54+
} else {
55+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File does NOT exist at %s path: %v", costpullerCredentialsEnv, err)
56+
}
57+
return
58+
}
59+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] %s is not set; using Application Default Credentials", costpullerCredentialsEnv)
60+
envCreds := os.Getenv("GOOGLE_APPLICATION_CREDENTIALS")
61+
if envCreds != "" {
62+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] GOOGLE_APPLICATION_CREDENTIALS is set to: %q", envCreds)
63+
if _, err := os.Stat(envCreds); err == nil {
64+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File exists at GOOGLE_APPLICATION_CREDENTIALS path")
65+
} else {
66+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File does NOT exist at GOOGLE_APPLICATION_CREDENTIALS path: %v", err)
67+
}
68+
} else {
69+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] GOOGLE_APPLICATION_CREDENTIALS environment variable is not set")
70+
}
71+
homeDir, err := os.UserHomeDir()
72+
if err == nil {
73+
defaultPath := filepath.Join(homeDir, ".config", "gcloud", "application_default_credentials.json")
74+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Checking default ADC location: %q", defaultPath)
75+
if _, err := os.Stat(defaultPath); err == nil {
76+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File exists at default ADC location")
77+
} else {
78+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] File does NOT exist at default ADC location: %v", err)
79+
}
80+
}
81+
}
82+
83+
// loadGoogleCredentials loads credentials for the Sheets scope. If
84+
// COSTPULLER_CREDENTIALS is set, that file is read; otherwise standard ADC
85+
// resolution is used (including GOOGLE_APPLICATION_CREDENTIALS).
86+
func loadGoogleCredentials(ctx context.Context) (*google.Credentials, error) {
87+
if p := strings.TrimSpace(os.Getenv(costpullerCredentialsEnv)); p != "" {
88+
b, err := os.ReadFile(p)
89+
if err != nil {
90+
return nil, fmt.Errorf("read %s %q: %w", costpullerCredentialsEnv, p, err)
91+
}
92+
return google.CredentialsFromJSON(ctx, b, googleSheetsScope)
93+
}
94+
return google.FindDefaultCredentials(ctx, googleSheetsScope)
95+
}
96+
3197
// getGoogleOAuthHttpClient accepts a mapping of configuration value strings
3298
// and returns an HTTP client which can be used to make authorized Google API
3399
// requests. The token is obtained either using values cached in a local file
@@ -37,23 +103,47 @@ const tokenFileName = "costpuller_token.json"
37103
// The Google OAuth 2.0 Client configuration is constructed from a local
38104
// credentials file (which can be downloaded from https://console.developers.google.com,
39105
// under "Credentials"). It is located using the default mechanisms (e.g., in
40-
// ${HOME}/.config/gcloud/application_default_credentials.json). (Currently,
41-
// the scope of the authorization is limited to the Google Sheets APIs.)
42-
func getGoogleOAuthHttpClient(oauthConfigMap Configuration) *http.Client {
106+
// ${HOME}/.config/gcloud/application_default_credentials.json). Set
107+
// COSTPULLER_CREDENTIALS to a JSON path to use a dedicated file and ignore
108+
// GOOGLE_APPLICATION_CREDENTIALS for this process. (Currently, the scope of
109+
// the authorization is limited to the Google Sheets APIs.)
110+
func getGoogleOAuthHttpClient(oauthConfigMap Configuration, debug bool) *http.Client {
43111
ctx := context.Background()
44112

45-
credObj, err := google.FindDefaultCredentials(ctx, "https://www.googleapis.com/auth/spreadsheets")
113+
logCredentialSearchLocations(debug)
114+
115+
credObj, err := loadGoogleCredentials(ctx)
46116
if err != nil {
47-
log.Fatalf("Unable to read OAuth client credentials file: %v", err)
117+
log.Printf("[getGoogleOAuthHttpClient] Error loading credentials: %v", err)
118+
log.Fatalf("[getGoogleOAuthHttpClient] Unable to read OAuth client credentials file: %v", err)
48119
}
49120

50-
config, err := google.ConfigFromJSON(credObj.JSON, "https://www.googleapis.com/auth/spreadsheets")
121+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Credentials found successfully")
122+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Project ID: %q", credObj.ProjectID)
123+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Credentials JSON length: %d bytes", len(credObj.JSON))
124+
125+
// Try to determine credential type from JSON
126+
var credType struct {
127+
Type string `json:"type"`
128+
}
129+
if err := json.Unmarshal(credObj.JSON, &credType); err == nil {
130+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Credential type: %q", credType.Type)
131+
} else {
132+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Unable to determine credential type from JSON: %v", err)
133+
}
134+
135+
config, err := google.ConfigFromJSON(credObj.JSON, googleSheetsScope)
51136
if err != nil {
52-
log.Fatalf("Unable to construct a client configuration: %v", err)
137+
log.Printf("[getGoogleOAuthHttpClient] Error from ConfigFromJSON: %v", err)
138+
log.Printf("[getGoogleOAuthHttpClient] Credential type was: %q", credType.Type)
139+
log.Fatalf("[getGoogleOAuthHttpClient] Unable to construct a client configuration: %v", err)
53140
}
141+
142+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] OAuth2 config created successfully")
143+
oauthDebugf(debug, "[getGoogleOAuthHttpClient] Client ID: %q", config.ClientID)
54144

55-
token, tokenCachePath := getToken(oauthConfigMap, config, ctx)
56-
cacheToken(token, tokenCachePath)
145+
token, tokenCachePath := getToken(oauthConfigMap, config, ctx, debug)
146+
cacheToken(token, tokenCachePath, debug)
57147

58148
return config.Client(ctx, token)
59149
}
@@ -65,36 +155,56 @@ func getToken(
65155
oauthConfigMap Configuration,
66156
config *oauth2.Config,
67157
ctx context.Context,
158+
debug bool,
68159
) (token *oauth2.Token, tokenCachePath string) {
69-
var tokenCacheFile *os.File
70160
path := getMapKeyString(oauthConfigMap, "tokenCachePath", "")
71161
tokenCachePath, err := getCacheFileName(path)
72-
if err == nil {
73-
tokenCacheFile, err = os.Open(tokenCachePath)
162+
if err != nil {
163+
// Can't determine cache path, get a new token
164+
oauthDebugf(debug, "[getToken] Unable to determine cache path, getting new token")
165+
port := getMapKeyString(oauthConfigMap, "port", "")
166+
return getNewToken(config, port, ctx), tokenCachePath
74167
}
168+
169+
// Try to open the cache file
170+
tokenCacheFile, err := os.Open(tokenCachePath)
75171
if err == nil {
76-
token = getCachedToken(config, tokenCacheFile, ctx)
172+
// Try to use cached token, but if it fails due to invalid credentials,
173+
// fall through to getting a new token
174+
token, err = getCachedTokenSafe(config, tokenCacheFile, ctx, debug)
77175
closeFile(tokenCacheFile)
78-
} else if errors.Is(err, os.ErrNotExist) {
79-
port := getMapKeyString(oauthConfigMap, "port", "")
80-
token = getNewToken(config, port, ctx)
81-
} else {
82-
log.Fatalf("Unexpected error accessing the token cache file, %q: %v", tokenCachePath, err)
176+
if err == nil {
177+
oauthDebugf(debug, "[getToken] Using cached token from %q", tokenCachePath)
178+
return token, tokenCachePath
179+
}
180+
// If we get here, the cached token was invalid - delete it and get a new one
181+
oauthDebugf(debug, "[getToken] Cached token invalid, will get a new token")
182+
if removeErr := os.Remove(tokenCachePath); removeErr != nil {
183+
log.Printf("[getToken] Warning: unable to delete invalid cached token: %v", removeErr)
184+
}
185+
} else if !errors.Is(err, os.ErrNotExist) {
186+
// Unexpected error opening cache file
187+
log.Fatalf("[getToken] Unexpected error accessing the token cache file, %q: %v", tokenCachePath, err)
83188
}
84-
return
189+
190+
// Get a new token (either cache doesn't exist or was invalid)
191+
oauthDebugf(debug, "[getToken] Getting new OAuth token")
192+
port := getMapKeyString(oauthConfigMap, "port", "")
193+
token = getNewToken(config, port, ctx)
194+
return token, tokenCachePath
85195
}
86196

87197
// cacheToken is a helper function which accepts a token and a file path and
88198
// stores the token in the indicated file. The contents of the file are
89199
// replaced with the new value. If the path is blank, the function prints a
90200
// message and returns; other errors result in exiting the process.
91-
func cacheToken(token *oauth2.Token, tokenCachePath string) {
201+
func cacheToken(token *oauth2.Token, tokenCachePath string, debug bool) {
92202
if tokenCachePath == "" {
93-
log.Println("The token will not be cached.")
203+
oauthDebugf(debug, "The token will not be cached.")
94204
} else {
95205
newTokenCacheFile, err := os.OpenFile(tokenCachePath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
96206
if err == nil {
97-
log.Printf("Caching oauth token in %q.", tokenCachePath)
207+
oauthDebugf(debug, "Caching oauth token in %q.", tokenCachePath)
98208
err = json.NewEncoder(newTokenCacheFile).Encode(token)
99209
closeFile(newTokenCacheFile)
100210
}
@@ -129,22 +239,31 @@ func getCacheFileName(tokenCachePath string) (string, error) {
129239
return filepath.Join(tokenCachePath, tokenFileName), nil
130240
}
131241

132-
// getCachedToken is a helper function which reads a cached token from the
242+
// getCachedTokenSafe is a helper function which reads a cached token from the
133243
// provided file, refreshes it using the provided configuration and context,
134-
// and returns the resulting token.
135-
func getCachedToken(config *oauth2.Config, cacheFile *os.File, ctx context.Context) *oauth2.Token {
244+
// and returns the resulting token. If the token is invalid (e.g., created with
245+
// different credentials), it returns an error instead of fatally exiting.
246+
func getCachedTokenSafe(config *oauth2.Config, cacheFile *os.File, ctx context.Context, debug bool) (*oauth2.Token, error) {
136247
token := &oauth2.Token{}
137248
err := json.NewDecoder(cacheFile).Decode(token)
138249
if err != nil {
139-
log.Fatalf("Unable to parse cached OAuth tokens, %q: %v", cacheFile.Name(), err)
250+
return nil, fmt.Errorf("unable to parse cached OAuth tokens: %w", err)
140251
}
141252

253+
oauthDebugf(debug, "[getCachedTokenSafe] Attempting to refresh cached token from %q", cacheFile.Name())
142254
token, err = config.TokenSource(ctx, token).Token()
143255
if err != nil {
144-
log.Fatalf("Unable to refresh the cached OAuth tokens: %v", err)
256+
// If the error is "unauthorized_client", it likely means the cached token
257+
// was created with different OAuth client credentials.
258+
if strings.Contains(err.Error(), "unauthorized_client") {
259+
oauthDebugf(debug, "[getCachedTokenSafe] Cached token is invalid (likely created with different credentials): %v", err)
260+
return nil, fmt.Errorf("cached token invalid: %w", err)
261+
}
262+
return nil, fmt.Errorf("unable to refresh cached token: %w", err)
145263
}
146264

147-
return token
265+
oauthDebugf(debug, "[getCachedTokenSafe] Successfully refreshed cached token")
266+
return token, nil
148267
}
149268

150269
// getNewToken is a helper function which prompts the user to use their browser

0 commit comments

Comments
 (0)