Infra ADR-0004 renamed the Gitea host. Bulk replace across go.mod and all .go import paths. Build and tests pass unchanged. Closes #20 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dt6aHEDWRjkK14Voi6HnGh
236 lines
7.9 KiB
Go
236 lines
7.9 KiB
Go
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/<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
|
|
}
|
|
|
|
// 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, `<!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
|
|
}
|