Files

157 lines
5.2 KiB
Go

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"
// AppHeader carries the dokku app name a request belongs to. Every nginx
// location that reaches this service sets it explicitly, which also
// overwrites anything a client tried to send.
AppHeader = "X-Google-Auth-App"
)
// 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
// AppConfigDir holds one subdirectory per app with that app's own
// allow/deny lists, bind-mounted read-only by the plugin. An app's allow
// rules replace the global ones; deny lists are combined. Empty disables
// per-app config, leaving the global lists in charge.
AppConfigDir 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"),
AppConfigDir: os.Getenv("GOOGLE_AUTH_APP_CONFIG_DIR"),
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, "/")
}