From 460f4bb1dee7347c40e53dca18edac789529d2a7 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 2 Jun 2026 20:59:16 +0200 Subject: [PATCH] feat(auth): interactive YouTube OAuth code flow `tapir auth` mints a refresh token for the single Stage-0 user: bind a local redirect listener, print the consent URL (offline access + forced consent so Google returns a refresh token), validate the state param, exchange the code, and persist the refresh token through the SecretStore port. Written fresh on x/oauth2 (ADR-006). Token is never logged or returned. Tests cover exchange, missing-refresh-token rejection, and the full listener flow with httptest. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/auth/auth.go | 176 ++++++++++++++++++++++++++++++++++ internal/auth/auth_test.go | 188 +++++++++++++++++++++++++++++++++++++ 2 files changed, 364 insertions(+) create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..cc00b19 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,176 @@ +// Package auth implements the interactive OAuth 2.0 authorization-code flow used +// by `tapir auth` to mint a YouTube refresh token for the single Stage-0 user. +// It is written fresh on golang.org/x/oauth2 (ADR-006): the ingestion repo's +// oauth package is inbound MCP server auth and unrelated to this outbound +// provider flow. +// +// The minted refresh token is persisted through the SecretStore port (the file +// store at Stage 0) and is never logged or returned to the caller. Only the +// opaque token ref crosses package boundaries afterward. +package auth + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "net/url" + + "golang.org/x/oauth2" +) + +// GoogleEndpoint is Google's OAuth2 endpoint, inlined to avoid the heavy +// golang.org/x/oauth2/google dependency for two URLs (mirrors the youtube +// adapter's choice). +var GoogleEndpoint = oauth2.Endpoint{ + AuthURL: "https://accounts.google.com/o/oauth2/auth", + TokenURL: "https://oauth2.googleapis.com/token", +} + +// DefaultScopes request read access to subscriptions/search and the force-ssl +// scope the Data API captions endpoints require. +var DefaultScopes = []string{ + "https://www.googleapis.com/auth/youtube.readonly", + "https://www.googleapis.com/auth/youtube.force-ssl", +} + +// TokenWriter persists a secret value under an opaque ref. *secrets.FileStore +// satisfies it; tests use a fake. (The read side is ports.SecretStore.) +type TokenWriter interface { + Put(ref, value string) error +} + +// Config wires the flow. Endpoint defaults to GoogleEndpoint when zero, so tests +// can point it at an httptest token server. +type Config struct { + ClientID string + ClientSecret string + RedirectURL string // e.g. "http://localhost:8080/callback" + Scopes []string + TokenRef string // SecretStore ref to persist the refresh token under + Endpoint oauth2.Endpoint +} + +func oauthConfig(c Config) *oauth2.Config { + ep := c.Endpoint + if ep == (oauth2.Endpoint{}) { + ep = GoogleEndpoint + } + scopes := c.Scopes + if len(scopes) == 0 { + scopes = DefaultScopes + } + return &oauth2.Config{ + ClientID: c.ClientID, + ClientSecret: c.ClientSecret, + RedirectURL: c.RedirectURL, + Scopes: scopes, + Endpoint: ep, + } +} + +// Exchange swaps an authorization code for a token and persists the refresh +// token through the writer. It errors if the provider returned no refresh token +// (e.g. consent was not forced with offline access), since without one the +// token is useless for the unattended run loop. The token is never logged. +func Exchange(ctx context.Context, c Config, secrets TokenWriter, code string) error { + conf := oauthConfig(c) + tok, err := conf.Exchange(ctx, code) + if err != nil { + return fmt.Errorf("auth: exchange code: %w", err) + } + if tok.RefreshToken == "" { + return fmt.Errorf("auth: provider returned no refresh token (re-consent with offline access)") + } + if err := secrets.Put(c.TokenRef, tok.RefreshToken); err != nil { + return fmt.Errorf("auth: persist refresh token: %w", err) + } + return nil +} + +// Run performs the full interactive flow: it binds a local listener on the +// redirect URL's host, prints the consent URL to out, waits for the provider's +// redirect (validating the state parameter), then exchanges the captured code +// and persists the refresh token. It blocks until the callback arrives, ctx is +// cancelled, or the listener fails. +func Run(ctx context.Context, c Config, secrets TokenWriter, out io.Writer) error { + conf := oauthConfig(c) + + u, err := url.Parse(c.RedirectURL) + if err != nil { + return fmt.Errorf("auth: parse redirect url %q: %w", c.RedirectURL, err) + } + ln, err := net.Listen("tcp", u.Host) + if err != nil { + return fmt.Errorf("auth: bind redirect listener on %q: %w", u.Host, err) + } + + state, err := randomState() + if err != nil { + return err + } + + type result struct { + code string + err error + } + resCh := make(chan result, 1) + + mux := http.NewServeMux() + mux.HandleFunc(u.Path, func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if e := q.Get("error"); e != "" { + http.Error(w, "authorization failed: "+e, http.StatusBadRequest) + resCh <- result{err: fmt.Errorf("auth: provider returned error %q", e)} + return + } + if q.Get("state") != state { + http.Error(w, "state mismatch", http.StatusBadRequest) + resCh <- result{err: fmt.Errorf("auth: state mismatch (possible CSRF)")} + return + } + code := q.Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + resCh <- result{err: fmt.Errorf("auth: redirect carried no code")} + return + } + _, _ = io.WriteString(w, "Tapir: authorization received. You can close this tab.") + resCh <- result{code: code} + }) + + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + defer func() { _ = srv.Shutdown(context.Background()) }() + + // AccessTypeOffline + ApprovalForce make Google return a refresh token even + // on re-authorization; without them a repeat consent yields only an access + // token and Exchange would reject it. + authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce) + fmt.Fprintf(out, "Open this URL to authorize Tapir, then return here:\n\n%s\n\n", authURL) + + select { + case <-ctx.Done(): + return ctx.Err() + case res := <-resCh: + if res.err != nil { + return res.err + } + if err := Exchange(ctx, c, secrets, res.code); err != nil { + return err + } + fmt.Fprintln(out, "Refresh token stored. You can now run `tapir run`.") + return nil + } +} + +func randomState() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", fmt.Errorf("auth: generate state: %w", err) + } + return hex.EncodeToString(b), nil +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..2008872 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,188 @@ +package auth_test + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "regexp" + "sync" + "testing" + "time" + + "golang.org/x/oauth2" + + "gitea.d-ma.be/mathias/tapir/internal/auth" +) + +// fakeWriter is a TokenWriter capturing the persisted (ref, value). +type fakeWriter struct { + mu sync.Mutex + ref, val string + calls int +} + +func (w *fakeWriter) Put(ref, value string) error { + w.mu.Lock() + defer w.mu.Unlock() + w.ref, w.val, w.calls = ref, value, w.calls+1 + return nil +} + +// tokenServer fakes Google's token endpoint, returning the given JSON body for +// any POST. No live Google contact. +func tokenServer(t *testing.T, body string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) +} + +func cfg(srvURL string) auth.Config { + return auth.Config{ + ClientID: "cid", + ClientSecret: "csecret", + RedirectURL: "http://localhost:18099/callback", + TokenRef: "youtube/refresh_token", + Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"}, + } +} + +func TestExchange_PersistsRefreshToken(t *testing.T) { + srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`) + defer srv.Close() + + w := &fakeWriter{} + if err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code"); err != nil { + t.Fatalf("Exchange: %v", err) + } + if w.ref != "youtube/refresh_token" { + t.Errorf("persisted ref = %q", w.ref) + } + if w.val != "rt-secret" { + t.Errorf("persisted token = %q, want rt-secret", w.val) + } +} + +func TestExchange_RejectsMissingRefreshToken(t *testing.T) { + srv := tokenServer(t, `{"access_token":"at","token_type":"Bearer","expires_in":3600}`) + defer srv.Close() + + w := &fakeWriter{} + err := auth.Exchange(context.Background(), cfg(srv.URL), w, "the-code") + if err == nil { + t.Fatal("want error when no refresh token returned") + } + if w.calls != 0 { + t.Errorf("nothing should be persisted on failure; Put called %d times", w.calls) + } +} + +// stateRe pulls the CSRF state out of the printed consent URL. +var stateRe = regexp.MustCompile(`[?&]state=([a-f0-9]+)`) + +// urlWriter forwards each Write to a channel so the test can read the consent +// URL Run prints before it blocks on the redirect. +type urlWriter struct{ ch chan string } + +func (w urlWriter) Write(p []byte) (int, error) { + w.ch <- string(p) + return len(p), nil +} + +func TestRun_FullFlow(t *testing.T) { + srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`) + defer srv.Close() + + // Ensure the fixed redirect port is free before binding. + if ln, err := net.Listen("tcp", "localhost:18099"); err == nil { + _ = ln.Close() + } else { + t.Skipf("redirect port 18099 unavailable: %v", err) + } + + w := &fakeWriter{} + out := urlWriter{ch: make(chan string, 4)} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + runErr := make(chan error, 1) + go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }() + + // First message carries the consent URL with the state param. + var state string + select { + case msg := <-out.ch: + m := stateRe.FindStringSubmatch(msg) + if m == nil { + t.Fatalf("no state in consent message: %q", msg) + } + state = m[1] + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for consent URL") + } + + // Simulate the browser hitting the local redirect with code + state. + resp, err := http.Get("http://localhost:18099/callback?state=" + state + "&code=the-code") + if err != nil { + t.Fatalf("callback GET: %v", err) + } + _ = resp.Body.Close() + + select { + case err := <-runErr: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not complete after callback") + } + + if w.val != "rt-secret" { + t.Errorf("persisted token = %q, want rt-secret", w.val) + } +} + +func TestRun_RejectsStateMismatch(t *testing.T) { + srv := tokenServer(t, `{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`) + defer srv.Close() + + if ln, err := net.Listen("tcp", "localhost:18099"); err == nil { + _ = ln.Close() + } else { + t.Skipf("redirect port 18099 unavailable: %v", err) + } + + w := &fakeWriter{} + out := urlWriter{ch: make(chan string, 4)} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + runErr := make(chan error, 1) + go func() { runErr <- auth.Run(ctx, cfg(srv.URL), w, out) }() + + select { + case <-out.ch: // drain consent URL + case <-time.After(3 * time.Second): + t.Fatal("timed out waiting for consent URL") + } + + resp, err := http.Get("http://localhost:18099/callback?state=WRONG&code=the-code") + if err != nil { + t.Fatalf("callback GET: %v", err) + } + _ = resp.Body.Close() + + select { + case err := <-runErr: + if err == nil { + t.Fatal("want error on state mismatch") + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not return after bad callback") + } + if w.calls != 0 { + t.Errorf("no token should be persisted on state mismatch; Put called %d times", w.calls) + } +}