@@ -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.
2930const 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