Files
gateway/cmd/waitlist-guard/main.go
zeekayandHanzo Dev 60b9474416 gateway: the edge writes identity headers, it does not mint them
Minting is issuing authority. IAM signs the token, and the signature is
where the authority is created. This edge VERIFIES a token and restates
what it proved as headers, so a transport that cannot carry a token can
read the same decision. Calling both operations "mint" put the issuer's
job and the edge's under one word, and a word that covers two operations
stops distinguishing them — a reader who thinks the edge mints reads it
as a second issuer.

So every use of `mint` for writing or stripping identity HEADERS becomes
`write`, which is already the leaf's own word (hanzoai/authz: "strips
every identity header on ingress and rewrites it from verified claims").
The identifiers follow: SetGatewayMinted/AssertGatewayMinted become
SetGatewayWritten/AssertGatewayWritten, mintIdentity becomes
writeIdentity, and waitlistMintedHeaders becomes waitlistHeaders — they
are simply the guard's own response headers, and the adjective added
nothing but the wrong verb.

`mint` stays where it is correct: IAM issuing a token, a sign-in issuing
a session cookie, commerce creating balance, and "the app the token was
minted through".

Two doc citations named symbols the migration removed and are re-pointed
at the live ones: edge.Mint to edge.Render, gatewayMintedIdentityHeaders
to authz.Headers.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-30 16:07:41 -07:00

903 lines
32 KiB
Go

// admin-guard is the single forward-auth gate that restricts Hanzo's RAW
// global-admin surfaces (platform.hanzo.ai, studio, commerce-admin, the raw
// KMS admin UI, the IAM management UI) to GLOBAL ADMINS ONLY — an IAM user
// whose org (`owner`) is the admin org (IAM `IsGlobalAdmin`: owner == AdminOrg).
//
// It is consumed by hanzoai/ingress (the edge router) as a ForwardAuth middleware:
// the ingress forwards each request's headers to GET /__guard/verify and
// enforces the verdict.
//
// verdict 2xx → allow (caller is a global admin)
// verdict 302 → redirect (caller is a non-admin → console.hanzo.ai;
// or anonymous → IAM PKCE login)
//
// One gate, one mechanism, every admin surface. Clients never reach a raw
// admin or a 403 dead-end: a non-admin is always sent to the unified client
// surface, console.hanzo.ai.
//
// Identity resolution is decomplected into three orthogonal sources, tried in
// order, all collapsing to one predicate (owner == AdminOrg):
//
// 1. the guard's own signed session cookie (set after a prior PKCE login) —
// the browser fast path, shared across *.hanzo.ai via a parent-domain cookie;
// 2. a Bearer / Basic JWT validated through the edge (the API path) — the JWT
// already carries `owner`, so no IAM round-trip is needed;
// 3. an IAM session cookie, resolved by calling IAM get-account server-side
// (the path for a browser that has an IAM session but no guard cookie yet).
//
// The login flow is standard OAuth2 Authorization-Code + PKCE against IAM
// (client_id default `hanzo-admin-guard`), mirroring oauth2-proxy.
package main
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/authz"
"github.com/hanzoai/authz/edge"
"github.com/hanzoai/gateway/v2/token"
)
// config holds the guard's runtime configuration, all from the environment so
// the same binary serves every cluster.
type config struct {
// Listen address (forward-auth + login endpoints).
addr string
// IAM OAuth2 authority.
iamPublic string // browser-facing IAM base (e.g. https://hanzo.id)
iamInternal string // in-cluster IAM base for server-side calls (e.g. http://iam.hanzo.svc.cluster.local)
clientID string
clientSecret string
// AdminOrg is the global-admin org slug (IAM conf.AdminOrg). A global admin
// (owner == adminOrg) is ALWAYS approved. Default "admin".
adminOrg string
// failOpen decides the verdict when IAM is UNAVAILABLE (5xx / transport
// error) for an ALREADY-AUTHENTICATED caller (valid JWT or IAM session). The
// waitlist-guard fronts money-path surfaces (billing/console/app/chat/team),
// so a transient IAM blip must NOT blackhole them — an authenticated caller is
// let through (fail-OPEN). A DEFINITIVE IAM negative (4xx) and anonymous
// callers are unaffected: they never fail open. Default true. This is safe
// because (i) the durable fast path is the HMAC-signed guard cookie, which
// needs no IAM call at all, and (ii) downstream money-path services enforce
// their own JWT/API-key auth — the guard is never their sole gate.
failOpen bool
// allowedHosts, when non-empty, is the exact set of hosts the guard will
// honor from X-Forwarded-Host (defense-in-depth against a spoofed forwarded
// host driving redirect_uri / returnTo). Empty ⇒ accept any host that shares
// the cookie-domain suffix (e.g. *.hanzo.ai), else fall back to r.Host.
allowedHosts map[string]bool
// Where UNAPPROVED (waitlisted) callers are sent — the viral waitlist landing.
waitlistURL string
// cookieDomain scopes the guard session cookie so one admin login covers
// every guarded *.hanzo.ai host (e.g. ".hanzo.ai").
cookieDomain string
cookieName string
cookieTTL time.Duration
// hmacKey signs the guard session + PKCE state cookies.
hmacKey []byte
// validator validates Bearer/Basic JWTs (issuer + audience + expiry) and
// exposes claims.owner — the same edge validator the gateway uses.
validator *edge.Verifier
}
const (
verifyPath = "/__guard/verify"
callbackPath = "/__guard/callback"
logoutPath = "/__guard/logout"
healthPath = "/__guard/healthz"
stateCookie = "hanzo_waitlist_guard_state"
)
// waitlistHeaders are the guard's OWN response headers (written on allow).
// A client must never forge them inbound — stripped by stripWaitlistHeaders and
// emitted into the generated ingress strip middleware.
var waitlistHeaders = []string{"X-Waitlist-Guard", "X-Waitlist-Approved"}
func main() {
// DRY generator: emit the ingress `waitlist-strip` middleware from the SINGLE
// source of truth (authz.Headers + waitlistHeaders) so
// the ingress config that fronts a DIRECT-to-backend route cannot drift from
// the guard's own strip. Regenerate the universe canary-ingress waitlist-strip
// block with: `waitlist-guard -print-strip-middleware`.
if len(os.Args) > 1 && os.Args[1] == "-print-strip-middleware" {
printStripMiddleware(os.Stdout)
return
}
cfg := loadConfig()
mux := http.NewServeMux()
mux.HandleFunc(healthPath, func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
mux.HandleFunc(verifyPath, cfg.handleVerify)
mux.HandleFunc(callbackPath, cfg.handleCallback)
mux.HandleFunc(logoutPath, cfg.handleLogout)
srv := &http.Server{
Addr: cfg.addr,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("waitlist-guard listening on %s (adminOrg=%q waitlist=%s cookieDomain=%s iam=%s)",
cfg.addr, cfg.adminOrg, cfg.waitlistURL, cfg.cookieDomain, cfg.iamPublic)
log.Fatal(srv.ListenAndServe())
}
func loadConfig() *config {
hmacKey := []byte(os.Getenv("GUARD_HMAC_KEY"))
if len(hmacKey) < 32 {
log.Fatal("GUARD_HMAC_KEY must be set to a random secret of >=32 bytes")
}
clientSecret := os.Getenv("IAM_CLIENT_SECRET")
if clientSecret == "" {
log.Fatal("IAM_CLIENT_SECRET must be set")
}
iamPublic := envOr("IAM_PUBLIC_URL", "https://hanzo.id")
// the edge validates against the JWKS issuer; reuse its env (AUTH_ISSUER /
// AUTH_JWKS_URL) so the guard and gateway agree on the IAM authority.
vcfg := token.ConfigFromEnv()
return &config{
addr: envOr("GUARD_ADDR", ":8080"),
iamPublic: iamPublic,
iamInternal: envOr("IAM_INTERNAL_URL", "https://iam.hanzo.ai"),
clientID: envOr("IAM_CLIENT_ID", "hanzo-waitlist-guard"),
clientSecret: clientSecret,
adminOrg: envOr("IAM_ADMIN_ORG", "admin"),
failOpen: envBool("GUARD_FAIL_OPEN_ON_IAM_ERROR", true),
allowedHosts: parseHostSet(os.Getenv("GUARD_ALLOWED_HOSTS")),
waitlistURL: envOr("WAITLIST_URL", "https://waitlist.hanzo.ai"),
cookieDomain: envOr("GUARD_COOKIE_DOMAIN", ".hanzo.ai"),
cookieName: envOr("GUARD_COOKIE_NAME", "hanzo_waitlist_guard"),
cookieTTL: envDuration("GUARD_COOKIE_TTL", 1*time.Hour),
hmacKey: hmacKey,
validator: token.NewValidator(vcfg),
}
}
// iamOutcome is the tri-state result of a server-side IAM call, so callers can
// distinguish "IAM is DOWN" (fail-OPEN for authed callers) from "IAM says no"
// (fail-CLOSED) — the two must never collapse to one boolean.
type iamOutcome int
const (
iamOK iamOutcome = iota // 200 with a usable body
iamUnavailable // transport error or 5xx — IAM is down
iamDenied // 4xx / unreadable — a definitive negative
)
// ----------------------------------------------------------------------------
// Forward-auth verdict
// ----------------------------------------------------------------------------
// handleVerify is the ForwardAuth target. It computes the original request URL
// from the X-Forwarded-* headers the ingress sets, then resolves the caller's
// APPROVAL STATUS and renders one of three verdicts.
//
// This is the waitlist gate: an APPROVED identity is allowed onto the product
// surface; an UNAPPROVED (waitlisted) identity is bounced to the viral waitlist
// (browser) or 403'd (API); an anonymous browser is sent to IAM PKCE login.
func (c *config) handleVerify(w http.ResponseWriter, r *http.Request) {
// Trust boundary: the guard is the sole authority for the identity headers it
// writes. Delete every client-supplied copy up front so a forged inbound
// `X-Org-Id: admin` / `X-Waitlist-Guard: allow` can never be read by the
// guard's own logic (defense in depth). The AUTHORITATIVE strip for the
// upstream request is the ingress headers middleware (see the guard's ingress
// wiring) — this mirrors it so the two agree.
edge.Strip(r.Header)
stripWaitlistHeaders(r)
orig := c.originalURL(r)
// (1) Guard session cookie — the browser fast path. The signed cookie encodes
// the approved verdict, so no IAM round-trip on the hot path. This is the
// durable IAM-independent path an approved user rides after one login.
if owner, approved, ok := c.sessionOwner(r); ok {
c.decide(w, r, owner, approved, orig)
return
}
// (2) Bearer/Basic JWT — the API path. The JWT carries `owner`/`isAdmin` but
// NOT the live approval property (a token can lag an approval), so the
// authoritative status comes from IAM. Admins are always approved.
if claims, err := c.validator.Verify(r.Header); err == nil && claims != nil {
if claims.IsAdmin || claims.Owner == c.adminOrg {
c.decide(w, r, claims.Owner, true, orig)
return
}
approved, outcome := c.iamUserApproved(r, claims.Owner+"/"+claims.Name)
switch outcome {
case iamUnavailable:
// IAM is DOWN and the caller holds a VALID JWT (already authenticated).
// Fail-OPEN so a transient IAM blip does not blackhole the money path.
approved = c.failOpen
case iamDenied:
// Definitive IAM negative (4xx) — not approved.
approved = false
}
c.decide(w, r, claims.Owner, approved, orig)
return
} else if err != nil && err != token.ErrNoToken {
if isAPIClient(r) {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Browser with a bad/expired bearer — fall through to interactive login.
}
// (3) IAM session cookie — resolve owner + approval via IAM get-account.
switch owner, approved, outcome := c.iamSessionApproval(r); outcome {
case iamOK:
c.decide(w, r, owner, approved, orig)
return
case iamUnavailable:
// IAM is down and the ONLY signal here is a raw inbound Cookie — which the
// guard has NOT validated (unlike path 2's cryptographically-verified JWT).
// Fail-OPEN here would let an ANONYMOUS caller with a junk cookie ride
// through during an IAM blip (Red R-4). So DO NOT fail open on an
// unvalidated identity: fall through to interactive login / 401. Money-path
// resilience during an IAM outage is preserved by the two VALIDATED paths —
// the HMAC guard cookie (path 1, no IAM call) and a validated JWT (path 2,
// which does fail open). Anonymous never fails open.
//
// fall through
}
// No identity at all.
if isAPIClient(r) {
http.Error(w, "authentication required", http.StatusUnauthorized)
return
}
// Browser → start interactive IAM PKCE login.
c.startLogin(w, r, orig)
}
// decide renders the allow/redirect verdict. APPROVED ⇒ allow; authenticated-
// but-unapproved ⇒ the viral waitlist (browser) / 403 (API).
func (c *config) decide(w http.ResponseWriter, r *http.Request, owner string, approved bool, orig string) {
if approved {
if owner != "" {
w.Header().Set("X-Org-Id", owner)
}
w.Header().Set("X-Waitlist-Guard", "allow")
w.WriteHeader(http.StatusNoContent)
return
}
if isAPIClient(r) {
http.Error(w, "account pending approval — join the waitlist", http.StatusForbidden)
return
}
http.Redirect(w, r, c.waitlistURL, http.StatusFound)
}
// ----------------------------------------------------------------------------
// Identity source (1): the guard's own signed session cookie
// ----------------------------------------------------------------------------
// sessionOwner returns the org + approved verdict from a valid, unexpired guard
// session cookie. Cookie value: base64(owner|user|approved|expiryUnix) "." base64(HMAC).
// Tamper-evident and time-bounded; no server-side store needed. owner + user +
// the approved bit are all bound into the signed payload so none can be forged and
// the cookie is bound to the identity it was minted for (no cross-user replay).
// The TTL bounds the reject-user revocation window (default 1h); a full
// nonce-vs-IAM revocation check is a tracked fast-follow.
func (c *config) sessionOwner(r *http.Request) (string, bool, bool) {
ck, err := r.Cookie(c.cookieName)
if err != nil {
return "", false, false
}
payload, ok := c.verifySigned(ck.Value)
if !ok {
return "", false, false
}
parts := strings.Split(payload, "|")
if len(parts) != 4 {
return "", false, false
}
owner, user, approvedStr, expStr := parts[0], parts[1], parts[2], parts[3]
expUnix, err := parseInt(expStr)
if err != nil || time.Now().Unix() > expUnix {
return "", false, false
}
_ = user // bound into the signed payload; carried for revocation (fast-follow)
return owner, approvedStr == "1", owner != ""
}
func (c *config) setSession(w http.ResponseWriter, owner, user string, approved bool) {
exp := time.Now().Add(c.cookieTTL)
approvedBit := "0"
if approved {
approvedBit = "1"
}
payload := fmt.Sprintf("%s|%s|%s|%d", owner, user, approvedBit, exp.Unix())
http.SetCookie(w, &http.Cookie{
Name: c.cookieName,
Value: c.sign(payload),
Path: "/",
Domain: c.cookieDomain,
Expires: exp,
MaxAge: int(c.cookieTTL.Seconds()),
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (c *config) clearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: c.cookieName,
Value: "",
Path: "/",
Domain: c.cookieDomain,
MaxAge: -1,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
// ----------------------------------------------------------------------------
// Identity source (3): IAM session cookie → get-account
// ----------------------------------------------------------------------------
// iamSessionApproval forwards the inbound cookies to IAM get-account and reads
// the authenticated user's `owner` AND approval status in one round-trip. This
// covers a browser that holds an IAM SSO session but has not yet been issued a
// guard cookie.
func (c *config) iamSessionApproval(r *http.Request) (owner string, approved bool, outcome iamOutcome) {
cookie := r.Header.Get("Cookie")
if cookie == "" {
return "", false, iamDenied // no session to resolve
}
body, oc := c.iamGet(r, strings.TrimRight(c.iamInternal, "/")+"/v1/iam/get-account", cookie)
if oc != iamOK {
return "", false, oc
}
o, a, ok := approvalFromAccount(body, c.adminOrg)
if !ok {
return "", false, iamDenied
}
return o, a, iamOK
}
// iamUserApproved resolves a specific user's approval via IAM get-user (the JWT
// path — the token is validated but its embedded claims may lag a live approval).
// Returns the approval AND the IAM outcome so the caller can fail-OPEN on an IAM
// outage (iamUnavailable) while failing CLOSED on a definitive negative (iamDenied).
func (c *config) iamUserApproved(r *http.Request, id string) (approved bool, outcome iamOutcome) {
if id == "" || id == "/" {
return false, iamDenied
}
u := strings.TrimRight(c.iamInternal, "/") + "/v1/iam/get-user?id=" + url.QueryEscape(id)
body, oc := c.iamGet(r, u, r.Header.Get("Cookie"))
if oc != iamOK {
return false, oc
}
_, a, ok := approvalFromAccount(body, c.adminOrg)
if !ok {
return false, iamDenied
}
return a, iamOK
}
// iamGet performs a server-side GET to IAM, forwarding the caller's cookies, and
// classifies the result: iamOK (200 + body), iamUnavailable (transport error or
// 5xx — IAM is down), or iamDenied (4xx / unreadable). The bounded read + timeout
// mirror the admin-guard get-account call.
func (c *config) iamGet(r *http.Request, urlStr, cookie string) ([]byte, iamOutcome) {
ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, iamDenied // a malformed internal URL is a deploy bug, not an IAM outage — never fail-open on it
}
if cookie != "" {
req.Header.Set("Cookie", cookie)
}
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, iamUnavailable // transport error — IAM unreachable
}
defer resp.Body.Close()
switch {
case resp.StatusCode == http.StatusOK:
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, iamUnavailable
}
return body, iamOK
case resp.StatusCode >= 500:
return nil, iamUnavailable // IAM 5xx — down/blip; fail-OPEN for authed callers
default:
return nil, iamDenied // 4xx — definitive negative; fail-CLOSED
}
}
// approvalFromAccount extracts (owner, approved) from an IAM get-account /
// get-user response. The user object is at the top level or under `data`. A user
// is APPROVED unless properties.approvalStatus == "pending" — fail-OPEN so
// existing users (no property) pass — OR the user is an admin (owner==adminOrg or
// isAdmin). An error/unsigned response (status:"error", no owner) → not-ok.
func approvalFromAccount(body []byte, adminOrg string) (owner string, approved bool, ok bool) {
type acct struct {
Owner string `json:"owner"`
IsAdmin bool `json:"isAdmin"`
Properties map[string]string `json:"properties"`
}
var top struct {
Status string `json:"status"`
acct
Data acct `json:"data"`
}
if err := json.Unmarshal(body, &top); err != nil {
return "", false, false
}
if top.Status == "error" {
return "", false, false
}
a := top.acct
if a.Owner == "" && top.Data.Owner != "" {
a = top.Data
}
if a.Owner == "" {
return "", false, false
}
// Approval predicate — mirrors object.User.IsApproved (fail-open + admin).
approved = true
if a.Owner != adminOrg && !a.IsAdmin {
if a.Properties != nil && a.Properties["approvalStatus"] == "pending" {
approved = false
}
}
return a.Owner, approved, true
}
// ----------------------------------------------------------------------------
// OAuth2 Authorization-Code + PKCE login
// ----------------------------------------------------------------------------
// startLogin redirects the browser to IAM's authorize endpoint with a PKCE
// challenge, stashing the verifier + the post-login return URL in a short-lived
// signed state cookie.
func (c *config) startLogin(w http.ResponseWriter, r *http.Request, returnTo string) {
verifier := randString(48)
challenge := pkceChallenge(verifier)
nonce := randString(16)
// state cookie payload: nonce|verifier|returnTo (signed, 10-minute life).
statePayload := strings.Join([]string{nonce, verifier, returnTo}, "\x1f")
http.SetCookie(w, &http.Cookie{
Name: stateCookie,
Value: c.sign(statePayload),
Path: "/",
Domain: c.cookieDomain,
MaxAge: 600,
Secure: true,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
q := url.Values{}
q.Set("client_id", c.clientID)
// NO org pin. admin-guard pins organization=admin because it gates RAW admin
// surfaces and needs the admin-org identity. The waitlist-guard is the OPPOSITE:
// it fronts CONSUMER product surfaces (console/billing/app/chat/team) whose
// users log in under THEIR OWN org. Pinning `organization=admin` would force
// every consumer login to resolve against the admin org they are not a member
// of → login failure / mis-scoped identity. A genuine global admin still
// resolves to owner==adminOrg from their own credentials and is approved.
q.Set("response_type", "code")
q.Set("scope", "openid profile email")
q.Set("redirect_uri", c.callbackURI(r))
q.Set("state", nonce)
q.Set("code_challenge", challenge)
q.Set("code_challenge_method", "S256")
authorize := strings.TrimRight(c.iamPublic, "/") + "/v1/iam/oauth/authorize?" + q.Encode()
http.Redirect(w, r, authorize, http.StatusFound)
}
// handleCallback completes the PKCE exchange, derives the org, sets the guard
// session, and returns the browser to where it started.
func (c *config) handleCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
gotState := r.URL.Query().Get("state")
if code == "" || gotState == "" {
http.Error(w, "missing code/state", http.StatusBadRequest)
return
}
ck, err := r.Cookie(stateCookie)
if err != nil {
http.Error(w, "missing state cookie", http.StatusBadRequest)
return
}
payload, ok := c.verifySigned(ck.Value)
if !ok {
http.Error(w, "invalid state", http.StatusBadRequest)
return
}
parts := strings.SplitN(payload, "\x1f", 3)
if len(parts) != 3 || subtleNE(parts[0], gotState) {
http.Error(w, "state mismatch", http.StatusBadRequest)
return
}
verifier, returnTo := parts[1], parts[2]
// Clear the state cookie.
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: "/", Domain: c.cookieDomain, MaxAge: -1, Secure: true, HttpOnly: true})
owner, name, approved, err := c.exchange(r.Context(), code, verifier, c.callbackURI(r))
if err != nil {
log.Printf("waitlist-guard: token exchange failed: %v", err)
http.Error(w, "login failed", http.StatusBadGateway)
return
}
// Set a session encoding the approved verdict either way, so the fast path
// works for approved users AND an unapproved user isn't re-prompted to log in
// on every request (they get bounced to the waitlist instead).
c.setSession(w, owner, name, approved)
if !approved {
http.Redirect(w, r, c.waitlistURL, http.StatusFound)
return
}
if returnTo == "" || !strings.HasPrefix(returnTo, "https://") {
returnTo = "https://console.hanzo.ai"
}
http.Redirect(w, r, returnTo, http.StatusFound)
}
// exchange swaps the auth code for tokens and resolves (owner, user, approved)
// from the validated ID/access token claims + an authoritative IAM get-user
// lookup. Admins (owner==adminOrg or isAdmin) are approved without the lookup.
// The user name is returned so the session cookie can be bound to the identity.
func (c *config) exchange(ctx context.Context, code, verifier, redirectURI string) (string, string, bool, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
form.Set("client_id", c.clientID)
form.Set("client_secret", c.clientSecret)
form.Set("code_verifier", verifier)
tokenURL := strings.TrimRight(c.iamInternal, "/") + "/v1/iam/oauth/token"
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return "", "", false, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", false, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return "", "", false, fmt.Errorf("token endpoint status %d: %s", resp.StatusCode, truncate(body, 256))
}
var tok struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &tok); err != nil {
return "", "", false, fmt.Errorf("decode token response: %w", err)
}
// Resolve validated claims (owner, name, isAdmin) from the ID or access token.
var owner, name string
var isAdmin bool
for _, t := range []string{tok.IDToken, tok.AccessToken} {
if t == "" {
continue
}
if claims, verr := c.validator.VerifyRaw(t); verr == nil && claims.Owner != "" {
owner, name, isAdmin = claims.Owner, claims.Name, claims.IsAdmin
break
}
}
if owner == "" && tok.AccessToken != "" {
if o, ok := c.userinfoOwner(ctx, tok.AccessToken); ok {
owner = o
}
}
if owner == "" {
return "", "", false, fmt.Errorf("could not resolve owner from token or userinfo")
}
// Admins are always approved. Otherwise resolve the authoritative approval
// property from IAM get-user (Bearer the freshly-minted access token).
if owner == c.adminOrg || isAdmin {
return owner, name, true, nil
}
approved := c.getUserApprovedBearer(ctx, owner+"/"+name, tok.AccessToken)
return owner, name, approved, nil
}
// getUserApprovedBearer reads a user's approval from IAM get-user authenticated
// with a Bearer access token. Fail-closed (false) on any error.
func (c *config) getUserApprovedBearer(ctx context.Context, id, accessToken string) bool {
if id == "" || id == "/" || accessToken == "" {
return false
}
u := strings.TrimRight(c.iamInternal, "/") + "/v1/iam/get-user?id=" + url.QueryEscape(id)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
_, approved, ok := approvalFromAccount(body, c.adminOrg)
return ok && approved
}
func (c *config) userinfoOwner(ctx context.Context, accessToken string) (string, bool) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
strings.TrimRight(c.iamInternal, "/")+"/v1/iam/oauth/userinfo", nil)
if err != nil {
return "", false
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", false
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var ui struct {
Owner string `json:"owner"`
}
if err := json.Unmarshal(body, &ui); err != nil {
return "", false
}
return ui.Owner, ui.Owner != ""
}
func (c *config) handleLogout(w http.ResponseWriter, r *http.Request) {
c.clearSession(w)
http.Redirect(w, r, c.waitlistURL, http.StatusFound)
}
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
// originalURL reconstructs the URL the client requested from the ingress's
// X-Forwarded-* headers (ingress forward-auth sets these).
func (c *config) originalURL(r *http.Request) string {
proto := firstNonEmpty(r.Header.Get("X-Forwarded-Proto"), "https")
uri := firstNonEmpty(r.Header.Get("X-Forwarded-Uri"), r.URL.RequestURI())
return proto + "://" + c.safeHost(r) + uri
}
// safeHost returns the request host to use, honoring X-Forwarded-Host ONLY when
// it passes hostAllowed — otherwise falling back to the connection host. This
// stops a spoofed X-Forwarded-Host from steering redirect_uri / returnTo to an
// attacker origin. (The ingress already sets X-Forwarded-Host to the real host;
// this is defense in depth for a misconfigured or bypassed ingress.)
func (c *config) safeHost(r *http.Request) string {
if xfh := r.Header.Get("X-Forwarded-Host"); xfh != "" && c.hostAllowed(xfh) {
return xfh
}
return r.Host
}
// hostAllowed reports whether h may be honored from X-Forwarded-Host. With an
// explicit GUARD_ALLOWED_HOSTS set, only those exact hosts pass; otherwise any
// host sharing the cookie-domain registrable suffix (e.g. *.hanzo.ai) passes.
func (c *config) hostAllowed(h string) bool {
h = strings.ToLower(strings.TrimSpace(strings.Split(h, ",")[0]))
if h == "" {
return false
}
if len(c.allowedHosts) > 0 {
return c.allowedHosts[h]
}
suffix := strings.ToLower(strings.TrimPrefix(c.cookieDomain, "."))
return suffix != "" && (h == suffix || strings.HasSuffix(h, "."+suffix))
}
// callbackURI is the absolute redirect_uri for the host being guarded. Each
// guarded host serves the guard's callback path through the same ingress route,
// so the callback lands back on the originating host (and its IAM client must
// allowlist https://<host>/__guard/callback).
func (c *config) callbackURI(r *http.Request) string {
proto := firstNonEmpty(r.Header.Get("X-Forwarded-Proto"), "https")
return proto + "://" + c.safeHost(r) + callbackPath
}
// isAPIClient reports whether the caller looks like a non-browser API client
// (so we fail closed with a status code instead of an interactive redirect).
func isAPIClient(r *http.Request) bool {
if edge.Bearer(r.Header) != "" || edge.Basic(r.Header) != "" {
return true
}
accept := r.Header.Get("X-Forwarded-Accept")
if accept == "" {
accept = r.Header.Get("Accept")
}
// A browser navigation sends Accept: text/html...; pure JSON/无 Accept = API.
if accept != "" && !strings.Contains(accept, "text/html") {
return true
}
return false
}
func (c *config) sign(payload string) string {
mac := hmac.New(sha256.New, c.hmacKey)
mac.Write([]byte(payload))
sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + sig
}
func (c *config) verifySigned(v string) (string, bool) {
b64payload, sig, ok := strings.Cut(v, ".")
if !ok {
return "", false
}
payload, err := base64.RawURLEncoding.DecodeString(b64payload)
if err != nil {
return "", false
}
mac := hmac.New(sha256.New, c.hmacKey)
mac.Write(payload)
want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(want), []byte(sig)) {
return "", false
}
return string(payload), true
}
func pkceChallenge(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
func randString(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
log.Fatalf("crypto/rand: %v", err)
}
return base64.RawURLEncoding.EncodeToString(b)[:n]
}
func envOr(k, d string) string {
if v := os.Getenv(k); v != "" {
return v
}
return d
}
// envBool reads a boolean env var (1/t/true/on ⇒ true, 0/f/false/off ⇒ false),
// returning the default when unset or unparseable.
func envBool(k string, d bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(k))) {
case "1", "t", "true", "on", "yes":
return true
case "0", "f", "false", "off", "no":
return false
default:
return d
}
}
// parseHostSet turns a comma-separated host list into a lower-cased set.
func parseHostSet(v string) map[string]bool {
set := map[string]bool{}
for _, h := range strings.Split(v, ",") {
if h = strings.ToLower(strings.TrimSpace(h)); h != "" {
set[h] = true
}
}
if len(set) == 0 {
return nil
}
return set
}
// stripWaitlistHeaders deletes the guard's OWN response headers from an inbound
// request so a client can never forge the allow verdict the guard later stamps.
// Complements edge.Strip (which covers X-Org-Id et al.).
func stripWaitlistHeaders(r *http.Request) {
for h := range r.Header {
if ch := http.CanonicalHeaderKey(h); ch == "X-Waitlist-Guard" || strings.HasPrefix(ch, "X-Waitlist-") {
r.Header.Del(h)
}
}
}
// printStripMiddleware writes the ingress `waitlist-strip` middleware YAML from
// the authoritative header set, so the ingress config fronting a direct-to-backend
// route is GENERATED, never hand-maintained (no drift with the guard's own
// edge.Strip). Every identity header is now brand-neutral and
// EXACT (no X-VENDOR-* wildcard family), so the generated strip covers the FULL
// set with NO gap — an ingress `headers` middleware can remove every one by name.
func printStripMiddleware(w io.Writer) {
fmt.Fprintln(w, "# GENERATED by `waitlist-guard -print-strip-middleware` — DO NOT hand-edit.")
fmt.Fprintln(w, "# Source of truth: authz.Headers + waitlistHeaders.")
fmt.Fprintln(w, "# All names are brand-neutral + exact — no wildcard family, so this covers")
fmt.Fprintln(w, "# the complete identity-header set with no gap.")
fmt.Fprintln(w, "http:")
fmt.Fprintln(w, " middlewares:")
fmt.Fprintln(w, " waitlist-strip:")
fmt.Fprintln(w, " headers:")
fmt.Fprintln(w, " customRequestHeaders:")
names := append(append([]string{}, authz.Headers...), waitlistHeaders...)
for _, h := range names {
fmt.Fprintf(w, " %s: \"\"\n", h)
}
}
func envDuration(k string, d time.Duration) time.Duration {
if v := os.Getenv(k); v != "" {
if parsed, err := time.ParseDuration(v); err == nil {
return parsed
}
}
return d
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
func parseInt(s string) (int64, error) {
var n int64
_, err := fmt.Sscan(s, &n)
return n, err
}
func truncate(b []byte, n int) string {
if len(b) > n {
return string(b[:n])
}
return string(b)
}
func subtleNE(a, b string) bool { return !hmac.Equal([]byte(a), []byte(b)) }