feat(web): web-initiated YouTube OAuth connect flow (ADR-006)
CI / Lint / Test / Vet (push) Successful in 10s
CI / Build & Import (push) Successful in 10s
CI / Mirror to GitHub (push) Failing after 3s

Add GET /oauth/youtube/connect and /oauth/youtube/callback, mounted inside
the login + registration guard so CurrentUserID is always set and every
connection binds to the authenticated tapir user.

- connect: generate a per-user CSRF state (single-use, short TTL, bound to
  the user), redirect to Google consent with access_type=offline and
  prompt=consent so a refresh token comes back.
- callback: verify the state belongs to this user, exchange the code via the
  existing auth.Exchange, persist the refresh token under a PER-USER ref
  (web.YouTubeTokenRef = "youtube/<userID>/refresh_token") so tenants never
  collide, then UpsertConnection (provider=youtube, status=active). Any
  failure renders a clean error page and leaves no half-written state.

Reuses auth.Exchange and adds auth.AuthCodeURL (offline + consent) rather
than the CLI's listener/terminal flow (ADR-006: web flow, not CLI). The
ConnectHandler depends on a narrow web.Connections port, not the concrete
store. Wired in cmdServe only when YT client credentials are present;
TAPIR_YT_CONNECT_REDIRECT_URL configures the callback URL. Per-user token-ref
scheme documented in docs/homelab-integration.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:12:42 +02:00
co-authored by Claude Opus 4.8
parent 0c9531a9b8
commit 2aad79b2a8
7 changed files with 463 additions and 22 deletions
+14
View File
@@ -187,6 +187,20 @@ func cmdServe(ctx context.Context, log *slog.Logger) error {
}
app := &web.App{Store: st, Identity: st, Auth: authn, Log: log}
// Web-initiated YouTube connect (ADR-006). Mounted only when the OAuth client
// credentials are present; the refresh token persists through the SecretStore
// under a per-user ref (web.YouTubeTokenRef). Live connect also needs the
// callback URL registered in the Google OAuth client's authorized redirects.
if cfg.YTClientID != "" && cfg.YTClientSecret != "" {
app.Connect = web.NewConnectHandler(auth.Config{
ClientID: cfg.YTClientID,
ClientSecret: cfg.YTClientSecret,
RedirectURL: cfg.YTConnectRedirectURL,
}, secrets.NewFileStore(cfg.SecretsFile), st, log)
log.Info("web youtube connect enabled", "redirect", cfg.YTConnectRedirectURL)
}
srv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: app.Router(),
+8
View File
@@ -79,6 +79,14 @@ This maps directly onto the copied `llm` package: `Client` is the OpenAI-compati
`SecretStore` port (`youtube.New(cfg, secrets)`). Pinning the actual vault-item name only
changes wiring/config, not the adapter — so this `confirm` does not block the adapter. Decide
the name when wiring the live connection and record it here.
- **Per-user token-ref scheme (Stage 1 web connect):** the web connect flow
(`/oauth/youtube/connect``/oauth/youtube/callback`) persists each user's refresh token
under a **per-user ref `youtube/<userID>/refresh_token`** (`web.YouTubeTokenRef`), not the
Stage-0 single `youtube/refresh_token`. This is what keeps tokens isolated across tenants
behind the `SecretStore` port; the `video_connections` row stores only this opaque
`token_ref`, never the token. The connect callback URL is
`TAPIR_YT_CONNECT_REDIRECT_URL` (default `https://tapir.d-ma.be/oauth/youtube/callback`) and
must be in the Google OAuth client's authorized redirects for live connect.
## Hosts (for reference)
+11
View File
@@ -72,6 +72,17 @@ func oauthConfig(c Config) *oauth2.Config {
}
}
// 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
+29 -22
View File
@@ -42,6 +42,11 @@ type Config struct {
// YTTokenRef is the opaque SecretStore reference under which the YouTube
// refresh token is persisted/resolved. Not the token itself.
YTTokenRef string
// YTConnectRedirectURL is the public callback URL the web connect flow
// registers with Google, e.g. "https://tapir.d-ma.be/oauth/youtube/callback".
// Must be in the OAuth client's authorized redirects. Distinct from the CLI
// auth command's localhost listener and from the Dex OIDC redirect.
YTConnectRedirectURL string
// SecretsFile is the path to the local file-backed SecretStore (0600). A
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
@@ -73,12 +78,13 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
// Defaults (see docs/homelab-integration.md). All overridable via env.
const (
defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini"
defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
defaultGatewayURL = "http://koala:30401/v1"
defaultSummarizerModel = "koala/phi4-mini"
defaultSummarizerTimeout = 5 * time.Minute
defaultYTTokenRef = "youtube/refresh_token"
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
defaultOAuthRedirectAddr = "localhost:8080"
defaultHTTPAddr = ":8080"
)
// Load reads the environment into a Config, applying defaults. It does not
@@ -87,22 +93,23 @@ const (
// it needs.
func Load() (Config, error) {
c := Config{
UserID: os.Getenv("TAPIR_USER_ID"),
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
DBDSN: os.Getenv("TAPIR_DB_DSN"),
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
UserID: os.Getenv("TAPIR_USER_ID"),
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
DBDSN: os.Getenv("TAPIR_DB_DSN"),
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
}
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
+215
View File
@@ -0,0 +1,215 @@
package web
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io"
"log/slog"
"net/http"
"sync"
"time"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
)
// Connections is the narrow write port the connect flow depends on (Clean
// Architecture: the handler depends on this interface, not the concrete store).
// *store.Store satisfies it; tests substitute a fake.
type Connections interface {
UpsertConnection(ctx context.Context, userID string, c store.Connection) error
}
// connectStateTTL bounds how long a generated CSRF state is valid between the
// connect redirect and the provider callback.
const connectStateTTL = 10 * time.Minute
// ConnectHandler runs the web-initiated YouTube OAuth connect flow. It is mounted
// INSIDE the login + registration guard (Router), so CurrentUserID is always set
// — every connection is bound to the authenticated tapir user. It reuses
// auth.AuthCodeURL / auth.Exchange (ADR-006: the web flow, not the CLI listener).
//
// The minted refresh token is persisted under a PER-USER SecretStore ref
// (YouTubeTokenRef) so tenants never share or overwrite each other's token.
type ConnectHandler struct {
// OAuth carries the registered client id/secret, the callback RedirectURL,
// and (in tests) the Endpoint override. TokenRef is set per-user per request,
// not here.
OAuth auth.Config
Secrets auth.TokenWriter // persists the refresh token (secrets.FileStore)
Conns Connections
Log *slog.Logger
states *connectStateStore
now func() time.Time
}
// NewConnectHandler wires the connect flow. now defaults to time.Now; the CSRF
// state store is in-memory (single-instance Stage 1).
func NewConnectHandler(oauth auth.Config, secrets auth.TokenWriter, conns Connections, log *slog.Logger) *ConnectHandler {
return &ConnectHandler{
OAuth: oauth,
Secrets: secrets,
Conns: conns,
Log: log,
states: newConnectStateStore(),
now: time.Now,
}
}
// YouTubeTokenRef is the per-user SecretStore reference under which a user's
// YouTube OAuth refresh token is persisted: "youtube/<userID>/refresh_token".
// Per-user (not the Stage-0 single "youtube/refresh_token") so connections never
// collide across tenants.
func YouTubeTokenRef(userID string) string {
return "youtube/" + userID + "/refresh_token"
}
// handleConnect generates a per-user CSRF state, stores it bound to the user with
// a short TTL, and redirects to Google's consent screen (offline + prompt=consent
// so a refresh token comes back).
func (h *ConnectHandler) handleConnect(w http.ResponseWriter, r *http.Request) {
userID, ok := CurrentUserID(r)
if !ok {
h.serverError(w, r, "current user", errNoCurrentUser)
return
}
state, err := randomState()
if err != nil {
h.serverError(w, r, "generate state", err)
return
}
h.states.put(state, userID, h.now().Add(connectStateTTL))
http.Redirect(w, r, auth.AuthCodeURL(h.OAuth, state), http.StatusFound)
}
// handleCallback verifies the CSRF state (present, unexpired, bound to THIS user),
// exchanges the code for a refresh token under the per-user ref, and records the
// connection. Any failure renders a clean error page and leaves no half-written
// state (Exchange persists nothing without a refresh token; the connection row is
// only written after a successful exchange).
func (h *ConnectHandler) handleCallback(w http.ResponseWriter, r *http.Request) {
userID, ok := CurrentUserID(r)
if !ok {
h.serverError(w, r, "current user", errNoCurrentUser)
return
}
q := r.URL.Query()
if e := q.Get("error"); e != "" {
h.failure(w, http.StatusBadRequest, "Authorization was declined.")
return
}
boundUser, ok := h.states.take(q.Get("state"), h.now())
if !ok || boundUser != userID {
// Missing, unknown, expired, or another user's state — reject as CSRF.
h.failure(w, http.StatusBadRequest, "Invalid or expired authorization state. Please try connecting again.")
return
}
code := q.Get("code")
if code == "" {
h.failure(w, http.StatusBadRequest, "Authorization returned no code.")
return
}
oauthCfg := h.OAuth
oauthCfg.TokenRef = YouTubeTokenRef(userID)
if err := auth.Exchange(r.Context(), oauthCfg, h.Secrets, code); err != nil {
h.logger().Error("connect: exchange code", "err", err)
h.failure(w, http.StatusBadGateway, "Could not complete authorization with YouTube. Please try again.")
return
}
if err := h.Conns.UpsertConnection(r.Context(), userID, store.Connection{
Provider: "youtube",
TokenRef: oauthCfg.TokenRef,
Status: "active",
}); err != nil {
h.logger().Error("connect: upsert connection", "err", err)
h.failure(w, http.StatusInternalServerError, "Authorized, but could not save the connection. Please try again.")
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (h *ConnectHandler) logger() *slog.Logger {
if h.Log != nil {
return h.Log
}
return slog.Default()
}
func (h *ConnectHandler) serverError(w http.ResponseWriter, r *http.Request, op string, err error) {
h.logger().Error("connect handler error", "op", op, "path", r.URL.Path, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
// failure renders a minimal, self-contained error page with a link back. No
// templ dependency so it can render even if a connection is half-set-up upstream.
func (h *ConnectHandler) failure(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
_, _ = io.WriteString(w, `<!doctype html><html lang="en"><head><meta charset="utf-8">`+
`<title>Connection failed</title></head><body>`+
`<h1>Could not connect your YouTube account</h1>`+
`<p>`+template.HTMLEscapeString(msg)+`</p>`+
`<p><a href="/">Back to Tapir</a></p></body></html>`)
}
// randomState returns a 128-bit hex CSRF token.
func randomState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("web: generate state: %w", err)
}
return hex.EncodeToString(b), nil
}
// connectStateEntry binds a CSRF state to the user who initiated the connect and
// when it expires.
type connectStateEntry struct {
userID string
expiry time.Time
}
// connectStateStore maps a CSRF state to its bound user between the connect
// redirect and the callback. Entries are one-time (take deletes) and short-lived,
// defeating replay and CSRF on the callback.
type connectStateStore struct {
mu sync.Mutex
m map[string]connectStateEntry
}
func newConnectStateStore() *connectStateStore {
return &connectStateStore{m: make(map[string]connectStateEntry)}
}
func (s *connectStateStore) put(state, userID string, expiry time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[state] = connectStateEntry{userID: userID, expiry: expiry}
}
// take consumes the user bound to state, returning ok=false if state is empty,
// unknown, or expired.
func (s *connectStateStore) take(state string, now time.Time) (string, bool) {
if state == "" {
return "", false
}
s.mu.Lock()
defer s.mu.Unlock()
e, ok := s.m[state]
if !ok {
return "", false
}
delete(s.m, state)
if !now.Before(e.expiry) {
return "", false
}
return e.userID, true
}
+175
View File
@@ -0,0 +1,175 @@
package web_test
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/oauth2"
"gitea.d-ma.be/mathias/tapir/internal/adapters/store"
"gitea.d-ma.be/mathias/tapir/internal/auth"
"gitea.d-ma.be/mathias/tapir/internal/web"
)
// 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
}
// fakeConns captures UpsertConnection calls without a database.
type fakeConns struct {
mu sync.Mutex
calls int
userID string
conn store.Connection
}
func (c *fakeConns) UpsertConnection(_ context.Context, userID string, conn store.Connection) error {
c.mu.Lock()
defer c.mu.Unlock()
c.calls, c.userID, c.conn = c.calls+1, userID, conn
return nil
}
// tokenServer fakes Google's token endpoint, returning body for any POST.
func tokenServer(t *testing.T, body string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(body))
}))
t.Cleanup(srv.Close)
return srv
}
// newConnectApp builds a registered-stub-user App with a wired ConnectHandler.
// The OAuth endpoint points at srvURL so Exchange never contacts live Google.
func newConnectApp(t *testing.T, srvURL string, secrets auth.TokenWriter, conns web.Connections) *web.App {
t.Helper()
s := newStore(t) // applies migrations
resetDB(t, rawPool(t)) // seeds stubSubject -> userID so the gate resolves a user
connect := web.NewConnectHandler(auth.Config{
ClientID: "cid",
ClientSecret: "csecret",
RedirectURL: "https://tapir.d-ma.be/oauth/youtube/callback",
Endpoint: oauth2.Endpoint{AuthURL: srvURL + "/auth", TokenURL: srvURL + "/token"},
}, secrets, conns, nil)
return &web.App{
Store: s,
Identity: s,
Auth: web.StubAuth{U: web.User{Subject: stubSubject}},
Connect: connect,
}
}
// connectState drives GET /oauth/youtube/connect and returns the CSRF state from
// the consent redirect, so the callback test can present a valid state.
func connectState(t *testing.T, app *web.App) string {
t.Helper()
rec := do(t, app, httptest.NewRequest(http.MethodGet, "/oauth/youtube/connect", nil))
require.Equal(t, http.StatusFound, rec.Code)
loc := rec.Header().Get("Location")
u, err := url.Parse(loc)
require.NoError(t, err)
q := u.Query()
require.Equal(t, "offline", q.Get("access_type"), "must request offline access for a refresh token")
require.Equal(t, "consent", q.Get("prompt"), "must force consent for a refresh token")
state := q.Get("state")
require.NotEmpty(t, state, "consent URL must carry a CSRF state")
return state
}
func TestConnectRedirectsToConsent(t *testing.T) {
srv := tokenServer(t, `{}`)
app := newConnectApp(t, srv.URL, &fakeWriter{}, &fakeConns{})
_ = connectState(t, app) // assertions live in the helper
}
func TestCallbackExchangesAndRecordsConnection(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
state := connectState(t, app)
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?state="+state+"&code=the-code", nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
require.Equal(t, "/", rec.Header().Get("Location"))
// Token persisted under the per-user ref.
wantRef := web.YouTubeTokenRef(userID)
require.Equal(t, wantRef, w.ref, "refresh token stored under the per-user ref")
require.Equal(t, "rt-secret", w.val)
// Connection recorded for the authenticated user.
require.Equal(t, 1, conns.calls)
require.Equal(t, userID, conns.userID)
require.Equal(t, "youtube", conns.conn.Provider)
require.Equal(t, "active", conns.conn.Status)
require.Equal(t, wantRef, conns.conn.TokenRef)
}
func TestCallbackRejectsMissingState(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?code=the-code", nil)) // no state
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 0, w.calls, "nothing persisted on missing state")
require.Equal(t, 0, conns.calls, "no connection recorded on missing state")
}
func TestCallbackRejectsUnknownState(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
// A state never issued by connect must be rejected (CSRF).
rec := do(t, app, httptest.NewRequest(http.MethodGet,
"/oauth/youtube/callback?state=deadbeef&code=the-code", nil))
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Equal(t, 0, w.calls)
require.Equal(t, 0, conns.calls)
}
func TestCallbackStateIsSingleUse(t *testing.T) {
srv := tokenServer(t,
`{"access_token":"at","refresh_token":"rt-secret","token_type":"Bearer","expires_in":3600}`)
w := &fakeWriter{}
conns := &fakeConns{}
app := newConnectApp(t, srv.URL, w, conns)
state := connectState(t, app)
url := "/oauth/youtube/callback?state=" + state + "&code=the-code"
rec := do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
require.Equal(t, http.StatusSeeOther, rec.Code)
// Replaying the same state must fail — it was consumed.
rec = do(t, app, httptest.NewRequest(http.MethodGet, url, nil))
require.Equal(t, http.StatusBadRequest, rec.Code, "state is single-use")
require.Equal(t, 1, conns.calls, "replay must not record a second connection")
}
+11
View File
@@ -33,6 +33,10 @@ type App struct {
Identity Identity
Auth Auth
Log *slog.Logger
// Connect runs the web-initiated YouTube OAuth connect flow. Optional: when
// nil (e.g. dev without YouTube client credentials), the /oauth/youtube/*
// routes are not mounted.
Connect *ConnectHandler
}
func (a *App) logger() *slog.Logger {
@@ -58,6 +62,13 @@ func (a *App) Router() http.Handler {
app.HandleFunc("GET /register", a.handleRegisterForm)
app.HandleFunc("POST /register", a.handleRegister)
// Web-initiated YouTube connect (ADR-006). Gated like every app route, so
// CurrentUserID is set and the connection binds to the authenticated user.
if a.Connect != nil {
app.HandleFunc("GET /oauth/youtube/connect", a.Connect.handleConnect)
app.HandleFunc("GET /oauth/youtube/callback", a.Connect.handleCallback)
}
// Two layers: Auth.Middleware requires a Dex session (you must be logged in);
// registrationGate requires a tapir user (else → /register) and stashes the
// resolved user_id. /register lives inside the auth guard but is exempt from