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
+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