package web import ( "context" "crypto/rand" "encoding/hex" "fmt" "html/template" "io" "log/slog" "net/http" "sync" "time" "git.d-ma.be/mathias/tapir/internal/adapters/store" "git.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 } // DiscoveryTrigger requests an out-of-band discovery pass for a user. The connect // flow fires it the moment a YouTube account is linked so videos appear promptly // instead of waiting for the next scheduled pass (#6). Enqueue must be // non-blocking and safe to call from the request goroutine; the implementation // owns serialization with the scheduler (one pass at a time). nil = no trigger. type DiscoveryTrigger interface { Enqueue(userID string) } // 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 // Discovery, when set, is fired after a successful connect so the new // connection's videos are discovered immediately (#6). Optional. Discovery DiscoveryTrigger 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 } // Discover this user's videos now rather than waiting for the next scheduled // pass (#6). Non-blocking; the trigger serializes with the scheduler. if h.Discovery != nil { h.Discovery.Enqueue(userID) } setFlash(w, flashConnected) 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 }