Skip to content
Open
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
62 changes: 62 additions & 0 deletions oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ https://developers.google.com/api-client-library/python/guide/aaa_client_secrets
var (
clientSecretsFile = flag.String("secrets", "client_secrets.json", "Client Secrets configuration")
cache = flag.String("cache", "request.token", "token cache file")
tokenJSON = flag.String("tokenJSON", "", "OAuth token as JSON string (alternative to -cache file)")
tokenOut = flag.String("tokenOut", "", "write refreshed token to: 'stdout', 'stderr', or a file path (default: stderr when -tokenJSON is used)")
)

// CallbackStatus is returned from the oauth2 callback
Expand All @@ -73,6 +75,37 @@ type Cache interface {
// the Token is stored in JSON format.
type CacheFile string

// CacheString implements Cache using an in-memory JSON string.
// Refreshed tokens are written to the configured output (stderr by default).
type CacheString struct {
input string
output string
}

func (s *CacheString) Token() (*oauth2.Token, error) {
tok := &oauth2.Token{}
if err := json.Unmarshal([]byte(s.input), tok); err != nil {
return nil, fmt.Errorf("CacheString.Token: %w", err)
}
return tok, nil
}

func (s *CacheString) PutToken(tok *oauth2.Token) error {
b, err := json.Marshal(tok)
if err != nil {
return fmt.Errorf("CacheString.PutToken: %w", err)
}
switch s.output {
case "stdout":
fmt.Fprintf(os.Stdout, "REFRESHED_TOKEN=%s\n", b)
case "stderr", "":
fmt.Fprintf(os.Stderr, "REFRESHED_TOKEN=%s\n", b)
default:
return os.WriteFile(s.output, b, 0600)
}
return nil
}

// oAuthClientConfig is a data structure definition for the client_secrets.json file.
// The code unmarshals the JSON configuration file into this structure.
type oAuthClientConfig struct {
Expand Down Expand Up @@ -255,6 +288,35 @@ func BuildOAuthHTTPClient(ctx context.Context, scopes []string, oAuthPort int) (
return nil, errors.New(msg)
}

// When -tokenJSON is provided, use CacheString instead of CacheFile.
// This avoids writing tokens to disk, useful for CI/CD and secret managers.
if *tokenJSON != "" {
tokenCache := &CacheString{input: *tokenJSON, output: *tokenOut}
token, err := tokenCache.Token()
if err != nil {
return nil, fmt.Errorf("error parsing -tokenJSON: %w", err)
}

tokenSource := config.TokenSource(ctx, token)
newToken, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("error refreshing token from -tokenJSON: %w", err)
}

if newToken.AccessToken != token.AccessToken {
if err := tokenCache.PutToken(newToken); err != nil {
log.Printf("Warning: failed to output refreshed token: %v", err)
}
}

src := &cachingTokenSource{
base: config.TokenSource(ctx, newToken),
cache: tokenCache,
lastToken: newToken,
}
return oauth2.NewClient(ctx, src), nil
}

// Check if supplied token cache file exists
// fallback to reading from OS specific default config dir
_, err = os.Stat(*cache)
Expand Down
163 changes: 163 additions & 0 deletions oauth_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
package youtubeuploader

import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

"golang.org/x/oauth2"
)

func testToken() *oauth2.Token {
return &oauth2.Token{
AccessToken: "ya29.test-access-token",
TokenType: "Bearer",
RefreshToken: "1//test-refresh-token",
Expiry: time.Date(2026, 12, 31, 0, 0, 0, 0, time.UTC),
}
}

func TestCacheStringToken(t *testing.T) {
tok := testToken()
b, err := json.Marshal(tok)
if err != nil {
t.Fatal(err)
}

cs := &CacheString{input: string(b)}
got, err := cs.Token()
if err != nil {
t.Fatalf("CacheString.Token() error: %v", err)
}

if got.AccessToken != tok.AccessToken {
t.Errorf("AccessToken = %q, want %q", got.AccessToken, tok.AccessToken)
}
if got.RefreshToken != tok.RefreshToken {
t.Errorf("RefreshToken = %q, want %q", got.RefreshToken, tok.RefreshToken)
}
}

func TestCacheStringTokenInvalidJSON(t *testing.T) {
cs := &CacheString{input: "not-json"}
_, err := cs.Token()
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}

func TestCacheStringPutTokenStderr(t *testing.T) {
tok := testToken()

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
origStderr := os.Stderr
os.Stderr = w

cs := &CacheString{input: "{}", output: "stderr"}
err = cs.PutToken(tok)

os.Stderr = origStderr
w.Close()

if err != nil {
t.Fatalf("PutToken() error: %v", err)
}

var buf bytes.Buffer
buf.ReadFrom(r)
output := buf.String()

if len(output) == 0 {
t.Fatal("expected output on stderr, got nothing")
}
if !bytes.Contains(buf.Bytes(), []byte("REFRESHED_TOKEN=")) {
t.Errorf("expected REFRESHED_TOKEN= prefix, got %q", output)
}
}

func TestCacheStringPutTokenStdout(t *testing.T) {
tok := testToken()

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
origStdout := os.Stdout
os.Stdout = w

cs := &CacheString{input: "{}", output: "stdout"}
err = cs.PutToken(tok)

os.Stdout = origStdout
w.Close()

if err != nil {
t.Fatalf("PutToken() error: %v", err)
}

var buf bytes.Buffer
buf.ReadFrom(r)
if !bytes.Contains(buf.Bytes(), []byte("REFRESHED_TOKEN=")) {
t.Errorf("expected REFRESHED_TOKEN= prefix on stdout, got %q", buf.String())
}
}

func TestCacheStringPutTokenFile(t *testing.T) {
tok := testToken()

dir := t.TempDir()
outPath := filepath.Join(dir, "token.json")

cs := &CacheString{input: "{}", output: outPath}
err := cs.PutToken(tok)
if err != nil {
t.Fatalf("PutToken() error: %v", err)
}

data, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}

var got oauth2.Token
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("Unmarshal error: %v", err)
}

if got.AccessToken != tok.AccessToken {
t.Errorf("AccessToken = %q, want %q", got.AccessToken, tok.AccessToken)
}
}

func TestCacheStringPutTokenDefaultStderr(t *testing.T) {
tok := testToken()

r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
origStderr := os.Stderr
os.Stderr = w

cs := &CacheString{input: "{}", output: ""}
err = cs.PutToken(tok)

os.Stderr = origStderr
w.Close()

if err != nil {
t.Fatalf("PutToken() error: %v", err)
}

var buf bytes.Buffer
buf.ReadFrom(r)
if !bytes.Contains(buf.Bytes(), []byte("REFRESHED_TOKEN=")) {
t.Errorf("empty output should default to stderr, got %q", buf.String())
}
}