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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user