Skip to content

Commit 7ac6a75

Browse files
authored
Merge pull request #215 from karian7/feat/oauth-reauth-on-invalid-grant
feat(oauth): auto re-authenticate on invalid_grant error
2 parents 301e563 + 61aec9f commit 7ac6a75

2 files changed

Lines changed: 229 additions & 5 deletions

File tree

oauth.go

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ import (
2626
"net/http"
2727
"os"
2828
"path/filepath"
29+
"strings"
30+
"sync"
2931
"time"
3032

3133
"github.com/pkg/browser"
@@ -214,6 +216,33 @@ func startCallbackWebServer(ctx context.Context, oAuthPort int) (callbackCh chan
214216
return callbackCh, nil
215217
}
216218

219+
// cachingTokenSource wraps an oauth2.TokenSource and persists refreshed tokens to a Cache.
220+
type cachingTokenSource struct {
221+
base oauth2.TokenSource
222+
cache Cache
223+
mu sync.Mutex
224+
lastToken *oauth2.Token
225+
}
226+
227+
func (s *cachingTokenSource) Token() (*oauth2.Token, error) {
228+
s.mu.Lock()
229+
defer s.mu.Unlock()
230+
231+
token, err := s.base.Token()
232+
if err != nil {
233+
return nil, err
234+
}
235+
236+
if s.lastToken == nil || s.lastToken.AccessToken != token.AccessToken {
237+
if err := s.cache.PutToken(token); err != nil {
238+
log.Printf("Warning: failed to cache refreshed token: %v", err)
239+
}
240+
s.lastToken = token
241+
}
242+
243+
return token, nil
244+
}
245+
217246
// BuildOAuthHTTPClient takes the user through the three-legged OAuth flow.
218247
// It opens a browser in the native OS or outputs a URL, then blocks until
219248
// the redirect completes to the /oauth2callback URI.
@@ -237,8 +266,6 @@ func BuildOAuthHTTPClient(ctx context.Context, scopes []string, oAuthPort int) (
237266
cachePath := filepath.Join(confDir, "youtubeuploader", "request.token")
238267
_, err = os.Stat(cachePath)
239268
if err == nil {
240-
// TODO debug log
241-
//logger.Debugf("Reading token from cache file %q\n", cachePath)
242269
*cache = cachePath
243270
}
244271
}
@@ -249,7 +276,29 @@ func BuildOAuthHTTPClient(ctx context.Context, scopes []string, oAuthPort int) (
249276
tokenCache := CacheFile(*cache)
250277
token, err := tokenCache.Token()
251278
if err == nil {
252-
return config.Client(ctx, token), nil
279+
// Validate the token by attempting a refresh if it's expired
280+
tokenSource := config.TokenSource(ctx, token)
281+
newToken, err := tokenSource.Token()
282+
if err != nil {
283+
if IsInvalidGrant(err) {
284+
log.Printf("Cached token is invalid (refresh token revoked or expired), re-authenticating...")
285+
} else {
286+
return nil, fmt.Errorf("error refreshing token: %w", err)
287+
}
288+
} else {
289+
// Token is valid, save if refreshed and return client
290+
if newToken.AccessToken != token.AccessToken {
291+
if err := tokenCache.PutToken(newToken); err != nil {
292+
log.Printf("Warning: failed to cache refreshed token: %v", err)
293+
}
294+
}
295+
src := &cachingTokenSource{
296+
base: config.TokenSource(ctx, newToken),
297+
cache: tokenCache,
298+
lastToken: newToken,
299+
}
300+
return oauth2.NewClient(ctx, src), nil
301+
}
253302
}
254303

255304
// You must always provide a non-zero string and validate that it matches
@@ -293,7 +342,12 @@ func BuildOAuthHTTPClient(ctx context.Context, scopes []string, oAuthPort int) (
293342
return nil, err
294343
}
295344

296-
return config.Client(ctx, token), nil
345+
src := &cachingTokenSource{
346+
base: config.TokenSource(ctx, token),
347+
cache: tokenCache,
348+
lastToken: token,
349+
}
350+
return oauth2.NewClient(ctx, src), nil
297351
}
298352

299353
// Token retreives the token from the token cache
@@ -310,6 +364,19 @@ func (f CacheFile) Token() (*oauth2.Token, error) {
310364
return tok, nil
311365
}
312366

367+
// IsInvalidGrant checks if the error is an OAuth2 "invalid_grant" error,
368+
// which indicates the refresh token has been revoked or expired.
369+
func IsInvalidGrant(err error) bool {
370+
if err == nil {
371+
return false
372+
}
373+
var retrieveErr *oauth2.RetrieveError
374+
if errors.As(err, &retrieveErr) {
375+
return retrieveErr.ErrorCode == "invalid_grant"
376+
}
377+
return strings.Contains(err.Error(), "invalid_grant")
378+
}
379+
313380
// PutToken stores the token in the token cache
314381
func (f CacheFile) PutToken(tok *oauth2.Token) error {
315382
file, err := os.OpenFile(string(f), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
@@ -324,4 +391,4 @@ func (f CacheFile) PutToken(tok *oauth2.Token) error {
324391
return fmt.Errorf("CacheFile.PutToken: %w", err)
325392
}
326393
return nil
327-
}
394+
}

test/oauth_test.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package test
2+
3+
import (
4+
"encoding/json"
5+
"errors"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
"time"
11+
12+
yt "github.com/porjo/youtubeuploader"
13+
"golang.org/x/oauth2"
14+
)
15+
16+
func TestIsInvalidGrant(t *testing.T) {
17+
tests := []struct {
18+
name string
19+
err error
20+
want bool
21+
}{
22+
{
23+
name: "nil error",
24+
err: nil,
25+
want: false,
26+
},
27+
{
28+
name: "unrelated error",
29+
err: errors.New("connection refused"),
30+
want: false,
31+
},
32+
{
33+
name: "RetrieveError with invalid_grant",
34+
err: &oauth2.RetrieveError{ErrorCode: "invalid_grant", ErrorDescription: "Token has been expired or revoked."},
35+
want: true,
36+
},
37+
{
38+
name: "RetrieveError with different code",
39+
err: &oauth2.RetrieveError{ErrorCode: "invalid_client"},
40+
want: false,
41+
},
42+
{
43+
name: "wrapped RetrieveError with invalid_grant",
44+
err: fmt.Errorf("token refresh failed: %w", &oauth2.RetrieveError{ErrorCode: "invalid_grant"}),
45+
want: true,
46+
},
47+
{
48+
name: "error string containing invalid_grant",
49+
err: errors.New(`oauth2: "invalid_grant" "Bad Request"`),
50+
want: true,
51+
},
52+
}
53+
54+
for _, tt := range tests {
55+
t.Run(tt.name, func(t *testing.T) {
56+
got := yt.IsInvalidGrant(tt.err)
57+
if got != tt.want {
58+
t.Errorf("IsInvalidGrant() = %v, want %v", got, tt.want)
59+
}
60+
})
61+
}
62+
}
63+
64+
func TestCacheFile_RoundTrip(t *testing.T) {
65+
tmpDir := t.TempDir()
66+
path := filepath.Join(tmpDir, "token.json")
67+
cf := yt.CacheFile(path)
68+
69+
token := &oauth2.Token{
70+
AccessToken: "test-access",
71+
TokenType: "Bearer",
72+
RefreshToken: "test-refresh",
73+
Expiry: time.Now().Add(time.Hour).Truncate(time.Second),
74+
}
75+
76+
if err := cf.PutToken(token); err != nil {
77+
t.Fatalf("PutToken failed: %v", err)
78+
}
79+
80+
got, err := cf.Token()
81+
if err != nil {
82+
t.Fatalf("Token failed: %v", err)
83+
}
84+
85+
if got.AccessToken != token.AccessToken {
86+
t.Errorf("AccessToken = %q, want %q", got.AccessToken, token.AccessToken)
87+
}
88+
if got.RefreshToken != token.RefreshToken {
89+
t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, token.RefreshToken)
90+
}
91+
}
92+
93+
func TestCacheFile_ReauthOnInvalidGrant(t *testing.T) {
94+
tmpDir := t.TempDir()
95+
tokenPath := filepath.Join(tmpDir, "request.token")
96+
97+
expiredToken := &oauth2.Token{
98+
AccessToken: "expired-access",
99+
TokenType: "Bearer",
100+
RefreshToken: "revoked-refresh",
101+
Expiry: time.Now().Add(-time.Hour),
102+
}
103+
data, _ := json.Marshal(expiredToken)
104+
if err := os.WriteFile(tokenPath, data, 0600); err != nil {
105+
t.Fatalf("failed to write token file: %v", err)
106+
}
107+
108+
// Verify file exists and is readable
109+
cf := yt.CacheFile(tokenPath)
110+
_, err := cf.Token()
111+
if err != nil {
112+
t.Fatalf("should be able to read token: %v", err)
113+
}
114+
115+
// Simulate invalid_grant error from token refresh
116+
refreshErr := &oauth2.RetrieveError{ErrorCode: "invalid_grant"}
117+
if !yt.IsInvalidGrant(refreshErr) {
118+
t.Fatal("expected IsInvalidGrant to return true for invalid_grant error")
119+
}
120+
121+
// Verify file is NOT deleted; PutToken will overwrite it when new token is written
122+
if _, err := os.Stat(tokenPath); os.IsNotExist(err) {
123+
t.Error("token cache file should NOT be deleted on invalid_grant; re-auth will overwrite it")
124+
}
125+
}
126+
127+
func TestCacheFile_TokenReturnsErrorForMissingFile(t *testing.T) {
128+
cf := yt.CacheFile("/nonexistent/path/token.json")
129+
_, err := cf.Token()
130+
if err == nil {
131+
t.Error("expected error for missing file, got nil")
132+
}
133+
}
134+
135+
func TestCacheFile_PutTokenOverwritesExisting(t *testing.T) {
136+
tmpDir := t.TempDir()
137+
path := filepath.Join(tmpDir, "token.json")
138+
cf := yt.CacheFile(path)
139+
140+
old := &oauth2.Token{AccessToken: "old-token", TokenType: "Bearer"}
141+
if err := cf.PutToken(old); err != nil {
142+
t.Fatalf("PutToken (old) failed: %v", err)
143+
}
144+
145+
updated := &oauth2.Token{AccessToken: "new-token", TokenType: "Bearer"}
146+
if err := cf.PutToken(updated); err != nil {
147+
t.Fatalf("PutToken (new) failed: %v", err)
148+
}
149+
150+
got, err := cf.Token()
151+
if err != nil {
152+
t.Fatalf("Token failed: %v", err)
153+
}
154+
if got.AccessToken != "new-token" {
155+
t.Errorf("AccessToken = %q, want %q", got.AccessToken, "new-token")
156+
}
157+
}

0 commit comments

Comments
 (0)