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