// 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, } } // AuthCodeURL builds the provider consent URL the web connect flow redirects to // (internal/web). It reuses oauthConfig and pins access_type=offline + prompt= // consent so Google returns a refresh token even on a repeat authorization — // without one, Exchange would reject the result. state is the per-request CSRF // token the caller binds to the user and verifies on the callback. func AuthCodeURL(c Config, state string) string { return oauthConfig(c).AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent")) } // 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 }