Files
zero-scale-platform/cmd/zero-scale-ctl/oauth.go
claude e33abed59b Add QA and feature-branch environments
env create/delete/gc/list manage a namespace per environment, labelled
zero-scale.io/env, with a ResourceQuota, its own gateway and an optional
wildcard route. A zero-scale-env ConfigMap records the env and may override
the platform's database clusters. Hosts use ${ENV_SUFFIX}; databases in a
non-primary env are named db_<env>_<app>_<hash> and carry an owner comment,
so env delete drops only what the env owns. oauth-setup --auth-host serves
every app's login from one callback host. deploy/ manifests no longer
hardcode a namespace; deploy/kustomization.yaml sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016uKdmp7tLySXvQXALz2x9V
2026-10-27 06:44:55 -04:00

618 lines
22 KiB
Go

package main
import (
"bufio"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"os/exec"
"sort"
"strings"
"time"
"git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform/internal/platform"
)
// sharedOAuth returns the platform's shared auth host settings; Host is empty when
// each app host has its own callback (the original mode).
func sharedOAuth() platform.OAuthConfig {
if p, err := loadPlatform(); err == nil && p != nil && p.OAuth.Host != "" {
o := p.OAuth
if o.Namespace == "" {
o.Namespace = platformNamespace()
}
return o
}
return platform.OAuthConfig{}
}
// checkSharedOAuthHosts makes sure every app host can use the shared auth cookie.
func checkSharedOAuthHosts(shared platform.OAuthConfig, hosts []string) error {
for _, h := range hosts {
if !strings.HasSuffix(h, shared.CookieDomain()) {
return fmt.Errorf("host %s is outside %s, so it cannot use the shared auth host %s", h, shared.CookieDomain(), shared.Host)
}
}
return nil
}
// OAuthSetupSecretName returns the Kubernetes Secret name for a shared oauth-setup profile.
func OAuthSetupSecretName(setupName string) string {
return fmt.Sprintf("zero-scale-oauth-%s", strings.ToLower(strings.TrimSpace(setupName)))
}
// OAuthSetupConfigMapName returns the Kubernetes ConfigMap name for a shared oauth-setup profile.
func OAuthSetupConfigMapName(setupName string) string {
return fmt.Sprintf("zero-scale-oauth-%s", strings.ToLower(strings.TrimSpace(setupName)))
}
// OAuthProxyServiceName returns the shared oauth2-proxy Service/Deployment name.
func OAuthProxyServiceName(setupName string) string {
return fmt.Sprintf("oauth2-proxy-%s", strings.ToLower(strings.TrimSpace(setupName)))
}
// readLine reads a line from stdin.
func readLine(prompt string) (string, error) {
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(line), nil
}
// readSecretLine reads a sensitive value from stdin with terminal echo disabled when possible.
func readSecretLine(prompt string) (string, error) {
fmt.Print(prompt)
// Attempt to disable echo on /dev/tty
sttyOff := exec.Command("stty", "-F", "/dev/tty", "-echo")
echoDisabled := sttyOff.Run() == nil
if echoDisabled {
defer func() {
_ = exec.Command("stty", "-F", "/dev/tty", "echo").Run()
fmt.Println()
}()
}
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
return "", err
}
return strings.TrimSpace(line), nil
}
// verifyOAuthClientWithProvider checks OIDC discovery and probes /login/oauth/access_token
// to verify that OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET are valid on Gitea.
// If GITEA_TOKEN is available, it also queries /api/v1/user/applications/oauth2 to verify exact redirect_uris.
func verifyOAuthClientWithProvider(oauthURL, setupName, clientID, clientSecret string, requiredCallbacks []string) (bool, []string, error) {
httpClient := &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
}
// 1. Check OIDC discovery endpoint
discURL := strings.TrimRight(oauthURL, "/") + "/.well-known/openid-configuration"
resp, err := httpClient.Get(discURL)
if err != nil {
return false, nil, fmt.Errorf("failed to reach OIDC discovery endpoint %s: %w", discURL, err)
}
discBody, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Printf(" [debug] GET %s -> HTTP %d\n [debug] Response body: %s\n", discURL, resp.StatusCode, strings.TrimSpace(string(discBody)))
return false, nil, fmt.Errorf("OIDC discovery endpoint %s returned HTTP %d", discURL, resp.StatusCode)
}
// 2. Probe token endpoint with form parameters (client_id + client_secret).
// In Gitea's AccessTokenOAuth:
// - Bad client_id -> {"error":"invalid_client","error_description":"cannot load client with client id"}
// - Bad client_secret -> {"error":"unauthorized_client","error_description":"invalid client secret"}
// - Valid client_id + client_secret with dummy code -> progresses past ValidateClientSecret to code check
// ({"error":"invalid_grant",...} or {"error":"unauthorized_client","error_description":"unexpected redirect URI"})
tokenURL := strings.TrimRight(oauthURL, "/") + "/login/oauth/access_token"
form := url.Values{}
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("grant_type", "authorization_code")
form.Set("code", "zs_client_probe")
tReq, _ := http.NewRequest("POST", tokenURL, strings.NewReader(form.Encode()))
tReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
tReq.Header.Set("Accept", "application/json")
tResp, err := httpClient.Do(tReq)
if err != nil {
return false, nil, fmt.Errorf("failed to contact token endpoint %s: %w", tokenURL, err)
}
body, _ := io.ReadAll(tResp.Body)
_ = tResp.Body.Close()
var errRes struct {
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
_ = json.Unmarshal(body, &errRes)
descLower := strings.ToLower(errRes.ErrorDesc)
if errRes.Error == "invalid_client" || strings.Contains(descLower, "client secret") || strings.Contains(descLower, "client id") {
fmt.Printf(" [debug] POST %s -> HTTP %d\n [debug] Response body: %s\n", tokenURL, tResp.StatusCode, strings.TrimSpace(string(body)))
return false, nil, fmt.Errorf("provider %s rejected OAUTH_CLIENT_ID / OAUTH_CLIENT_SECRET (%s: %s)", oauthURL, errRes.Error, errRes.ErrorDesc)
}
fmt.Printf(" [debug] POST %s -> HTTP %d (client_id & client_secret accepted; probe response: %s)\n", tokenURL, tResp.StatusCode, strings.TrimSpace(string(body)))
// 3. If GITEA_TOKEN is available, query Gitea's OAuth2 Applications API to verify redirect_uris directly
giteaToken := strings.TrimSpace(os.Getenv("GITEA_TOKEN"))
if giteaToken != "" {
apiURL := strings.TrimRight(oauthURL, "/") + "/api/v1/user/applications/oauth2"
aReq, _ := http.NewRequest("GET", apiURL, nil)
aReq.Header.Set("Authorization", "token "+giteaToken)
if aResp, err := httpClient.Do(aReq); err == nil {
defer aResp.Body.Close()
if aResp.StatusCode == http.StatusOK {
var apps []struct {
ID int64 `json:"id"`
Name string `json:"name"`
ClientID string `json:"client_id"`
RedirectURIs []string `json:"redirect_uris"`
}
if json.NewDecoder(aResp.Body).Decode(&apps) == nil {
for _, app := range apps {
if app.ClientID == clientID || strings.EqualFold(app.Name, setupName) {
return true, app.RedirectURIs, nil
}
}
}
}
}
}
return false, nil, nil
}
// collectAllSetupCallbacks returns all required https://<host>/oauth2/callback URLs across
// existing apps using this setupName and the newly applied hosts.
func collectAllSetupCallbacks(setupName string, existingCM map[string]string, newHosts []string) []string {
cbSet := make(map[string]bool)
if existingCM != nil && existingCM["redirect_uris"] != "" {
for _, u := range strings.Split(existingCM["redirect_uris"], ",") {
u = strings.TrimSpace(u)
if u != "" {
cbSet[u] = true
}
}
}
for _, h := range newHosts {
h = strings.TrimSpace(h)
if h != "" {
cbSet[fmt.Sprintf("https://%s/oauth2/callback", h)] = true
}
}
var res []string
for u := range cbSet {
res = append(res, u)
}
sort.Strings(res)
return res
}
// ReconcileOAuthSetup provisions or updates a shared oauth-setup profile (Secret, ConfigMap,
// shared oauth2-proxy Deployment & Service) and validates credentials and Redirect URIs.
func ReconcileOAuthSetup(setupName, oauthURL, oauthGroup string, appHosts []string, namespace string) error {
setupName = strings.ToLower(strings.TrimSpace(setupName))
if setupName == "" {
return fmt.Errorf("oauth_setup name cannot be empty")
}
cmName := OAuthSetupConfigMapName(setupName)
secName := OAuthSetupSecretName(setupName)
existingCM, _ := GetConfigMapData(cmName, namespace)
existingSec, _ := GetSecretData(secName, namespace)
if oauthURL == "" && existingCM != nil {
oauthURL = existingCM["oauth_url"]
}
if oauthGroup == "" && existingCM != nil {
oauthGroup = existingCM["oauth_group"]
}
if oauthURL == "" {
return fmt.Errorf("oauth_url is required for oauth-setup '%s' (specify oauth_url under gateway: in .conf or run 'zero-scale-ctl oauth-setup %s --url <url>')", setupName, setupName)
}
oauthURL = strings.TrimRight(strings.TrimSpace(oauthURL), "/")
requiredCallbacks := collectAllSetupCallbacks(setupName, existingCM, appHosts)
shared := sharedOAuth()
callbackHosts := appHosts
if shared.Host != "" {
if err := checkSharedOAuthHosts(shared, appHosts); err != nil {
return err
}
if namespace != shared.Namespace {
// Apps in other namespaces (QA, branches) use the one oauth2-proxy behind the shared host.
if _, err := GetConfigMapData(cmName, shared.Namespace); err != nil {
return fmt.Errorf("oauth-setup '%s' does not exist in %s, where the shared auth host %s is served\n--> Run: zero-scale-ctl oauth-setup %s --url <oauth-url> --auth-host %s -n %s",
setupName, shared.Namespace, shared.Host, setupName, shared.Host, shared.Namespace)
}
fmt.Printf("--> Using shared OAuth2 setup '%s' at https://%s (namespace %s)\n", setupName, shared.Host, shared.Namespace)
return nil
}
requiredCallbacks = []string{fmt.Sprintf("https://%s/oauth2/callback", shared.Host)}
callbackHosts = []string{shared.Host}
}
// Credential resolution order:
// 1. Environment variables OAUTH_CLIENT_ID & OAUTH_CLIENT_SECRET
// 2. Existing Kubernetes Secret (zero-scale-oauth-<setupName>)
// 3. Guided interactive CLI prompt
clientID := strings.TrimSpace(os.Getenv("OAUTH_CLIENT_ID"))
clientSecret := strings.TrimSpace(os.Getenv("OAUTH_CLIENT_SECRET"))
if clientID != "" && clientSecret != "" {
fmt.Printf("--> Using OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET from environment for shared oauth-setup '%s'.\n", setupName)
} else if existingSec != nil && existingSec["OAUTH_CLIENT_ID"] != "" && existingSec["OAUTH_CLIENT_SECRET"] != "" {
clientID = existingSec["OAUTH_CLIENT_ID"]
clientSecret = existingSec["OAUTH_CLIENT_SECRET"]
fmt.Printf("--> Found existing credentials in Kubernetes Secret '%s'.\n", secName)
}
for {
if clientID == "" || clientSecret == "" {
fmt.Println("")
fmt.Println("==============================================================================")
fmt.Printf("🔐 Shared OAuth2 Setup Required: '%s'\n", setupName)
fmt.Println("==============================================================================")
fmt.Printf(" 1. Open Gitea Applications: %s/-/admin/applications\n", oauthURL)
fmt.Printf(" 2. Application Name: %s\n", setupName)
fmt.Printf(" 3. Confidential Client: Checked (Yes)\n")
fmt.Printf(" 4. Redirect URI(s) to add:\n")
for _, cb := range requiredCallbacks {
fmt.Printf(" %s\n", cb)
}
fmt.Println("------------------------------------------------------------------------------")
var err error
for clientID == "" {
clientID, err = readLine("Enter OAUTH_CLIENT_ID: ")
if err != nil {
return fmt.Errorf("failed reading OAUTH_CLIENT_ID: %w", err)
}
}
for clientSecret == "" {
clientSecret, err = readSecretLine("Enter OAUTH_CLIENT_SECRET (hidden): ")
if err != nil {
return fmt.Errorf("failed reading OAUTH_CLIENT_SECRET: %w", err)
}
}
}
fmt.Printf("--> Verifying OAuth2 application '%s' against %s...\n", setupName, oauthURL)
apiVerified, remoteRedirects, err := verifyOAuthClientWithProvider(oauthURL, setupName, clientID, clientSecret, requiredCallbacks)
if err != nil {
fmt.Printf("❌ %v\n", err)
retryAns, _ := readLine("Re-enter OAuth client credentials? [Y/n]: ")
if strings.ToLower(strings.TrimSpace(retryAns)) == "n" {
return fmt.Errorf("OAuth verification failed: %w", err)
}
clientID = ""
clientSecret = ""
continue
}
fmt.Printf("--> OAuth2 client '%s' authenticated successfully against %s/login/oauth/introspect.\n", clientID, oauthURL)
if apiVerified {
for {
remoteSet := make(map[string]bool)
for _, r := range remoteRedirects {
remoteSet[strings.TrimSpace(r)] = true
}
var missing []string
for _, reqCB := range requiredCallbacks {
if !remoteSet[reqCB] {
missing = append(missing, reqCB)
}
}
if len(missing) == 0 {
fmt.Printf("--> Verified all required Redirect URIs on Gitea app '%s': %v\n", setupName, requiredCallbacks)
break
}
fmt.Printf("⚠️ Gitea OAuth application '%s' is missing required Redirect URI(s):\n", setupName)
for _, m := range missing {
fmt.Printf(" - %s\n", m)
}
fmt.Printf(" Update the Redirect URIs at %s/-/admin/applications so it includes:\n", oauthURL)
for _, cb := range requiredCallbacks {
fmt.Printf(" %s\n", cb)
}
ans, _ := readLine("Press [Enter] after updating the Redirect URI in Gitea to re-check (or 's' to skip): ")
if strings.ToLower(strings.TrimSpace(ans)) == "s" {
break
}
_, remoteRedirects, _ = verifyOAuthClientWithProvider(oauthURL, setupName, clientID, clientSecret, requiredCallbacks)
}
} else {
prevSet := make(map[string]bool)
if existingCM != nil {
for _, u := range strings.Split(existingCM["redirect_uris"], ",") {
if strings.TrimSpace(u) != "" {
prevSet[strings.TrimSpace(u)] = true
}
}
}
var newlyAdded []string
for _, reqCB := range requiredCallbacks {
if !prevSet[reqCB] {
newlyAdded = append(newlyAdded, reqCB)
}
}
if len(newlyAdded) > 0 {
fmt.Printf("--> Required Redirect URI(s) for '%s' (%s/-/admin/applications):\n", setupName, oauthURL)
for _, cb := range requiredCallbacks {
fmt.Printf(" • %s\n", cb)
}
}
}
break
}
cookieSecret := ""
if existingSec != nil && len(existingSec["OAUTH_COOKIE_SECRET"]) == 32 {
cookieSecret = existingSec["OAUTH_COOKIE_SECRET"]
} else {
// 32 ASCII bytes is directly accepted by oauth2-proxy as a 32-byte AES cipher key
cookieSecret = GeneratePassword(32)
}
labels := map[string]string{
"zero-scale-oauth": "true",
"zero-scale-oauth-setup": setupName,
}
if err := CreateOrUpdateSecret(secName, namespace, map[string]string{
"OAUTH_CLIENT_ID": clientID,
"OAUTH_CLIENT_SECRET": clientSecret,
"OAUTH_COOKIE_SECRET": cookieSecret,
}, labels); err != nil {
return fmt.Errorf("failed to save OAuth secret %s: %w", secName, err)
}
if err := CreateOrUpdateConfigMap(cmName, namespace, map[string]string{
"setup_name": setupName,
"oauth_url": oauthURL,
"oauth_group": oauthGroup,
"redirect_uris": strings.Join(requiredCallbacks, ","),
}, labels); err != nil {
return fmt.Errorf("failed to save OAuth configmap %s: %w", cmName, err)
}
// Deploy or update shared oauth2-proxy-<setupName> Deployment & Service
if err := deploySharedOAuth2Proxy(setupName, oauthURL, oauthGroup, requiredCallbacks, namespace, shared); err != nil {
return fmt.Errorf("failed to deploy shared oauth2-proxy for '%s': %w", setupName, err)
}
// Route /oauth2 on the app hosts (or on the shared auth host) to the proxy
if len(callbackHosts) > 0 {
if err := ApplyOAuth2CallbackIngress(setupName, namespace, callbackHosts); err != nil {
return fmt.Errorf("failed to apply /oauth2 ingress: %w", err)
}
}
return nil
}
// deploySharedOAuth2Proxy deploys the shared oauth2-proxy Deployment and Service for setupName.
func deploySharedOAuth2Proxy(setupName, oauthURL, oauthGroup string, requiredCallbacks []string, namespace string, shared platform.OAuthConfig) error {
svcName := OAuthProxyServiceName(setupName)
secName := OAuthSetupSecretName(setupName)
if !ResourceExists("service", "shared-redis", namespace) {
fmt.Printf("⚠️ Warning: Service 'shared-redis' was not found in namespace '%s'.\n", namespace)
fmt.Printf("--> oauth2-proxy requires shared-redis for session storage.\n")
fmt.Printf("--> Deploy with: kubectl apply -f deploy/shared-data-services.yaml -n %s\n", namespace)
fmt.Println("--> Documentation: https://git.fairfaxmedia.net/open-cream-cheese/zero-scale-platform#oauth2-authentication")
}
// Extract unique parent domains (e.g. .ffxm.cc, .fairfaxmedia.net) for --whitelist-domain and --cookie-domain
domainSet := make(map[string]bool)
for _, cb := range requiredCallbacks {
if u, err := url.Parse(cb); err == nil && u.Hostname() != "" {
parts := strings.Split(u.Hostname(), ".")
if len(parts) >= 2 {
parentDomain := "." + strings.Join(parts[1:], ".")
domainSet[parentDomain] = true
}
domainSet[u.Hostname()] = true
}
}
var whitelistArgs []string
for d := range domainSet {
whitelistArgs = append(whitelistArgs, fmt.Sprintf(" - --whitelist-domain=%s", d))
}
sort.Strings(whitelistArgs)
// Build --allowed-group flags for Gitea OIDC
// Note: Gitea OIDC /login/oauth/userinfo returns groups in "<org>:<Team>" format (e.g. "open-cream-cheese:Owners")
// as well as "<org>", so for any group without ":" we include <group>, <group>:Owners, and <group>:owners.
var groupArgs []string
if strings.TrimSpace(oauthGroup) != "" {
seenGroups := make(map[string]bool)
for _, rawG := range strings.FieldsFunc(oauthGroup, func(r rune) bool { return r == ',' || r == ';' || r == ' ' }) {
g := strings.TrimSpace(rawG)
if g == "" {
continue
}
candidates := []string{g}
if !strings.Contains(g, ":") {
candidates = append(candidates,
g+":Owners",
g+":owners",
g+":Members",
g+":members",
g+":Admin",
g+":admin",
)
}
for _, c := range candidates {
if !seenGroups[c] {
seenGroups[c] = true
groupArgs = append(groupArgs, fmt.Sprintf(" - --allowed-group=%s", c))
}
}
}
}
extraArgs := ""
if shared.Host != "" {
// One callback host; the session cookie covers every host under its parent domain.
extraArgs += fmt.Sprintf("\n - --redirect-url=https://%s/oauth2/callback\n - --cookie-domain=%s", shared.Host, shared.CookieDomain())
}
if len(whitelistArgs) > 0 {
extraArgs += "\n" + strings.Join(whitelistArgs, "\n")
}
if len(groupArgs) > 0 {
extraArgs += "\n" + strings.Join(groupArgs, "\n")
}
manifest := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
labels:
app: %s
zero-scale-oauth-setup: %s
spec:
replicas: 1
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
zero-scale-oauth-setup: %s
spec:
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
args:
- --provider=oidc
- --oidc-issuer-url=%s
- --scope=openid profile email groups
- --oidc-groups-claim=groups
- --oidc-email-claim=email
- --prefer-email-to-user=false
- --user-id-claim=preferred_username
- --email-domain=*
- --upstream=static://202
- --http-address=0.0.0.0:4180
- --reverse-proxy=true
- --set-xauthrequest=true
- --pass-user-headers=true
- --skip-provider-button=true
- --session-store-type=redis
- --redis-connection-url=redis://shared-redis.%s.svc.cluster.local:6379
- --cookie-name=_zs_oauth_%s
- --cookie-secure=true
- --cookie-samesite=lax%s
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: %s
key: OAUTH_CLIENT_ID
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: %s
key: OAUTH_CLIENT_SECRET
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: %s
key: OAUTH_COOKIE_SECRET
ports:
- containerPort: 4180
name: http
resources:
requests:
cpu: 10m
memory: 16Mi
limits:
cpu: 100m
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
labels:
app: %s
zero-scale-oauth-setup: %s
spec:
selector:
app: %s
ports:
- name: http
port: 4180
targetPort: 4180
protocol: TCP
`, svcName, namespace, svcName, setupName, svcName, svcName, setupName,
oauthURL, namespace, setupName, extraArgs,
secName, secName, secName,
svcName, namespace, svcName, setupName, svcName)
return ApplyYAML(manifest, namespace)
}
// ApplyOAuth2CallbackIngress creates/updates an Ingress routing /oauth2 on the app's hosts
// directly to the shared oauth2-proxy-<setupName> Service.
func ApplyOAuth2CallbackIngress(setupName, namespace string, hosts []string) error {
if len(hosts) == 0 {
return nil
}
svcName := OAuthProxyServiceName(setupName)
primaryShort := strings.Split(hosts[0], ".")[0]
ingName := fmt.Sprintf("zero-scale-%s-oauth2", primaryShort)
var b strings.Builder
b.WriteString(fmt.Sprintf(`apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: %s
namespace: %s
labels:
app: zero-scale-gateway
zero-scale-oauth-setup: %s
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "false"
nginx.ingress.kubernetes.io/proxy-buffer-size: "16k"
spec:
`, ingName, namespace, setupName))
if cls := resolveIngressClass(); cls != "" {
b.WriteString(fmt.Sprintf(" ingressClassName: %s\n", cls))
}
b.WriteString(" rules:\n")
for _, host := range hosts {
b.WriteString(fmt.Sprintf(` - host: %s
http:
paths:
- path: /oauth2
pathType: Prefix
backend:
service:
name: %s
port:
number: 4180
`, host, svcName))
}
return ApplyYAML(b.String(), namespace)
}