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/<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:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user