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:
+29
-22
@@ -42,6 +42,11 @@ type Config struct {
|
||||
// YTTokenRef is the opaque SecretStore reference under which the YouTube
|
||||
// refresh token is persisted/resolved. Not the token itself.
|
||||
YTTokenRef string
|
||||
// YTConnectRedirectURL is the public callback URL the web connect flow
|
||||
// registers with Google, e.g. "https://tapir.d-ma.be/oauth/youtube/callback".
|
||||
// Must be in the OAuth client's authorized redirects. Distinct from the CLI
|
||||
// auth command's localhost listener and from the Dex OIDC redirect.
|
||||
YTConnectRedirectURL string
|
||||
|
||||
// SecretsFile is the path to the local file-backed SecretStore (0600). A
|
||||
// Stage-0 stand-in for op/ESO, swappable behind the SecretStore port.
|
||||
@@ -73,12 +78,13 @@ func (c Config) DexConfigured() bool { return strings.TrimSpace(c.OIDCIssuer) !=
|
||||
|
||||
// Defaults (see docs/homelab-integration.md). All overridable via env.
|
||||
const (
|
||||
defaultGatewayURL = "http://koala:30401/v1"
|
||||
defaultSummarizerModel = "koala/phi4-mini"
|
||||
defaultSummarizerTimeout = 5 * time.Minute
|
||||
defaultYTTokenRef = "youtube/refresh_token"
|
||||
defaultOAuthRedirectAddr = "localhost:8080"
|
||||
defaultHTTPAddr = ":8080"
|
||||
defaultGatewayURL = "http://koala:30401/v1"
|
||||
defaultSummarizerModel = "koala/phi4-mini"
|
||||
defaultSummarizerTimeout = 5 * time.Minute
|
||||
defaultYTTokenRef = "youtube/refresh_token"
|
||||
defaultYTConnectRedirectURL = "https://tapir.d-ma.be/oauth/youtube/callback"
|
||||
defaultOAuthRedirectAddr = "localhost:8080"
|
||||
defaultHTTPAddr = ":8080"
|
||||
)
|
||||
|
||||
// Load reads the environment into a Config, applying defaults. It does not
|
||||
@@ -87,22 +93,23 @@ const (
|
||||
// it needs.
|
||||
func Load() (Config, error) {
|
||||
c := Config{
|
||||
UserID: os.Getenv("TAPIR_USER_ID"),
|
||||
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
|
||||
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
|
||||
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
|
||||
DBDSN: os.Getenv("TAPIR_DB_DSN"),
|
||||
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
|
||||
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
|
||||
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
|
||||
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
|
||||
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
|
||||
UserID: os.Getenv("TAPIR_USER_ID"),
|
||||
GatewayURL: envOr("TAPIR_GATEWAY_URL", defaultGatewayURL),
|
||||
GatewayKey: os.Getenv("TAPIR_GATEWAY_KEY"),
|
||||
SummarizerModel: envOr("TAPIR_SUMMARIZER_MODEL", defaultSummarizerModel),
|
||||
DBDSN: os.Getenv("TAPIR_DB_DSN"),
|
||||
YTClientID: os.Getenv("TAPIR_YT_CLIENT_ID"),
|
||||
YTClientSecret: os.Getenv("TAPIR_YT_CLIENT_SECRET"),
|
||||
YTTokenRef: envOr("TAPIR_YT_TOKEN_REF", defaultYTTokenRef),
|
||||
YTConnectRedirectURL: envOr("TAPIR_YT_CONNECT_REDIRECT_URL", defaultYTConnectRedirectURL),
|
||||
SecretsFile: envOr("TAPIR_SECRETS_FILE", defaultSecretsFile()),
|
||||
OAuthRedirectAddr: envOr("TAPIR_OAUTH_REDIRECT_ADDR", defaultOAuthRedirectAddr),
|
||||
HTTPAddr: envOr("TAPIR_HTTP_ADDR", defaultHTTPAddr),
|
||||
OIDCIssuer: os.Getenv("TAPIR_OIDC_ISSUER"),
|
||||
DexClientID: os.Getenv("TAPIR_DEX_CLIENT_ID"),
|
||||
DexClientSecret: os.Getenv("TAPIR_DEX_CLIENT_SECRET"),
|
||||
OIDCRedirectURL: os.Getenv("TAPIR_OIDC_REDIRECT_URL"),
|
||||
SessionSecret: os.Getenv("TAPIR_SESSION_SECRET"),
|
||||
}
|
||||
|
||||
timeout, err := durationOr("TAPIR_SUMMARIZER_TIMEOUT", defaultSummarizerTimeout)
|
||||
|
||||
Reference in New Issue
Block a user