From 2aad79b2a8df623813e3ad110abd01e1c4526183 Mon Sep 17 00:00:00 2001 From: Mathias Date: Wed, 3 Jun 2026 16:12:42 +0200 Subject: [PATCH] feat(web): web-initiated YouTube OAuth connect flow (ADR-006) 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//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) --- cmd/tapir/main.go | 14 +++ docs/homelab-integration.md | 8 ++ internal/auth/auth.go | 11 ++ internal/config/config.go | 51 +++++---- internal/web/connect.go | 215 +++++++++++++++++++++++++++++++++++ internal/web/connect_test.go | 175 ++++++++++++++++++++++++++++ internal/web/handlers.go | 11 ++ 7 files changed, 463 insertions(+), 22 deletions(-) create mode 100644 internal/web/connect.go create mode 100644 internal/web/connect_test.go diff --git a/cmd/tapir/main.go b/cmd/tapir/main.go index 75eb3c0..89ba294 100644 --- a/cmd/tapir/main.go +++ b/cmd/tapir/main.go @@ -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(), diff --git a/docs/homelab-integration.md b/docs/homelab-integration.md index d596d3d..9f00fa8 100644 --- a/docs/homelab-integration.md +++ b/docs/homelab-integration.md @@ -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//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) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 8929dcd..755a4b1 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -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 diff --git a/internal/config/config.go b/internal/config/config.go index ef298e6..527d433 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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) diff --git a/internal/web/connect.go b/internal/web/connect.go new file mode 100644 index 0000000..dea5bdc --- /dev/null +++ b/internal/web/connect.go @@ -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//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, ``+ + `Connection failed`+ + `

Could not connect your YouTube account

`+ + `

`+template.HTMLEscapeString(msg)+`

`+ + `

Back to Tapir

`) +} + +// 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 +} diff --git a/internal/web/connect_test.go b/internal/web/connect_test.go new file mode 100644 index 0000000..50a7d68 --- /dev/null +++ b/internal/web/connect_test.go @@ -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") +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 6c43ef5..efe5e36 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -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