157 lines
5.2 KiB
Go
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, "/")
|
|
}
|