package authproxy import ( "fmt" "os" "strings" "time" ) const ( googleAuthorizeURL = "https://accounts.google.com/o/oauth2/v2/auth" googleTokenURL = "https://oauth2.googleapis.com/token" // RoutePrefix is the URL namespace the proxy owns on every protected // host. It must match the locations the plugin writes into nginx. RoutePrefix = "/_google-auth" ) // Config holds everything the auth service needs. It is normally populated // from GOOGLE_AUTH_* environment variables written by the dokku plugin. type Config struct { // ClientID / ClientSecret identify the Google OAuth client. ClientID string ClientSecret string // CookieSecret is the key material used to encrypt and authenticate // sessions, state, and hand-off tokens. Must be at least 32 characters. CookieSecret string // AuthHost is the one hostname whose /_google-auth/callback is // registered with Google as the redirect URI. It must be a domain that // routes to this service through nginx (i.e. a domain of any app that // has google-auth enabled). AuthHost string // AllowedDomains / AllowedEmails are the allowlist: an email is accepted // if its domain is in AllowedDomains or the full address is in // AllowedEmails. Anything else is rejected. AllowedDomains []string AllowedEmails []string // DeniedEmails is the denylist. It is checked before the allowlist and // always wins, so a single address can be revoked without dropping the // whole domain it belongs to. DeniedEmails []string CookieName string SessionTTL time.Duration ListenAddr string // AllowInsecure permits plain-HTTP flows (no Secure cookie flag, http // redirect URI). Only for local testing; Google requires HTTPS redirect // URIs in production anyway. AllowInsecure bool // AuthorizeURL / TokenURL are overridable for tests. AuthorizeURL string TokenURL string } // ConfigFromEnv builds a Config from GOOGLE_AUTH_* environment variables and // validates it. func ConfigFromEnv() (Config, error) { cfg := Config{ ClientID: os.Getenv("GOOGLE_AUTH_CLIENT_ID"), ClientSecret: os.Getenv("GOOGLE_AUTH_CLIENT_SECRET"), CookieSecret: os.Getenv("GOOGLE_AUTH_COOKIE_SECRET"), AuthHost: normalizeHost(os.Getenv("GOOGLE_AUTH_AUTH_HOST")), CookieName: envOr("GOOGLE_AUTH_COOKIE_NAME", "_google_auth"), ListenAddr: envOr("GOOGLE_AUTH_LISTEN", ":2999"), AuthorizeURL: envOr("GOOGLE_AUTH_AUTHORIZE_URL", googleAuthorizeURL), TokenURL: envOr("GOOGLE_AUTH_TOKEN_URL", googleTokenURL), AllowInsecure: os.Getenv("GOOGLE_AUTH_ALLOW_INSECURE") == "true", } ttlRaw := envOr("GOOGLE_AUTH_SESSION_TTL", "24h") ttl, err := time.ParseDuration(ttlRaw) if err != nil || ttl <= 0 { return cfg, fmt.Errorf("GOOGLE_AUTH_SESSION_TTL %q is not a valid positive duration", ttlRaw) } cfg.SessionTTL = ttl for _, d := range splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_DOMAINS")) { cfg.AllowedDomains = append(cfg.AllowedDomains, strings.TrimPrefix(d, "@")) } cfg.AllowedEmails = splitList(os.Getenv("GOOGLE_AUTH_ALLOWED_EMAILS")) cfg.DeniedEmails = splitList(os.Getenv("GOOGLE_AUTH_DENIED_EMAILS")) return cfg, cfg.validate() } func (c Config) validate() error { if c.ClientID == "" { return fmt.Errorf("GOOGLE_AUTH_CLIENT_ID is required") } if c.ClientSecret == "" { return fmt.Errorf("GOOGLE_AUTH_CLIENT_SECRET is required") } if len(c.CookieSecret) < 32 { return fmt.Errorf("GOOGLE_AUTH_COOKIE_SECRET must be at least 32 characters") } if c.AuthHost == "" { return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST is required") } if strings.ContainsAny(c.AuthHost, "/:") { return fmt.Errorf("GOOGLE_AUTH_AUTH_HOST must be a bare hostname, got %q", c.AuthHost) } if len(c.AllowedDomains) == 0 && len(c.AllowedEmails) == 0 { return fmt.Errorf("at least one of GOOGLE_AUTH_ALLOWED_DOMAINS or GOOGLE_AUTH_ALLOWED_EMAILS must be set") } if c.CookieName == "" { return fmt.Errorf("GOOGLE_AUTH_COOKIE_NAME must not be empty") } return nil } func envOr(key, fallback string) string { if v := os.Getenv(key); v != "" { return v } return fallback } // splitList splits a comma- or whitespace-separated list, lowercasing and // trimming each entry. func splitList(raw string) []string { var out []string for _, part := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) { part = strings.ToLower(strings.TrimSpace(part)) if part != "" { out = append(out, part) } } return out } func normalizeHost(raw string) string { h := strings.ToLower(strings.TrimSpace(raw)) h = strings.TrimPrefix(h, "https://") h = strings.TrimPrefix(h, "http://") return strings.TrimSuffix(h, "/") }