Files
gateway/auth_middleware.go

415 lines
14 KiB
Go

package gateway
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/hanzoai/gateway/v2/token"
)
// AuthConfig holds configuration for the auth middleware.
type AuthConfig struct {
// Enabled controls whether the auth middleware is active.
// Default: true. Set to false via AUTH_ENABLED=false to disable
// all auth checks (useful for integration tests and development).
Enabled bool
// JWKS URL to fetch signing keys (default: https://hanzo.id/v1/iam/.well-known/jwks)
JWKSURL string
// Expected JWT issuer (default: https://hanzo.id)
Issuer string
// Audiences is the allowlist of acceptable JWT `aud` values. A token
// passes when its audience matches ANY entry. IAM stamps user tokens with
// aud=<client_id>, so a single fixed audience rejects every user JWT; the
// allowlist (token.AudiencesFromEnv) is the fix. Override entirely with
// GATEWAY_ALLOWED_AUDIENCES.
Audiences []string
// Billing check endpoint (default: http://commerce.hanzo.svc.cluster.local:8001)
BillingURL string
// BillingToken is the COMMERCE_SERVICE_TOKEN for authenticating with Commerce.
BillingToken string
// BillingEnabled controls whether billing checks are performed.
// Default: true (checks enabled). Set to false to disable.
BillingEnabled bool
// BillingPaths scopes balance enforcement to request paths matching one
// of these prefixes. This is the one knob that keeps billing OFF for the
// AI/validated (per-token billed downstream) and public routes while
// ON for the metered must-gate platform surface (cloud/tasks/insights/
// o11y/mpc/evals/licensing/product/provisioning/…). The /v1/commerce
// funding surface is hard-excluded in code (billingPathMatch) regardless
// of this list. Empty list enforces on every non-funding, non-public
// route. Set BILLING_PATHS to the must-gate prefixes. One scope.
BillingPaths []string
// Paths that bypass auth entirely (exact prefix match)
PublicPaths []string
// Hosts that bypass auth entirely (e.g. hanzo.id for login)
PublicHosts []string
// If true, requests without a token are rejected (402/401).
// If false (default), requests without a token pass through without headers.
RequireAuth bool
}
// hanzoJWTClaims and the JWKS cache, token validation, extraction, and the
// identity-header trust boundary live in hanzoai/authz + hanzoai/authz/edge (the
// edge-auth implementation shared with the estate). Thin shims preserving
// the symbols this file and its tests use are in auth_compat.go.
// permissionBits is the canonical name → bit-position map. Values MUST
// match commerce/util/permission/permission.go exactly; the iota order
// there is the single source of truth. Only stable, well-known names
// are translated — every other token (e.g. ad-hoc authz policy names)
// maps to zero. Lookups are case-insensitive.
//
// To extend: add a constant in commerce, then add the matching entry
// here. Forwards-only — never re-number existing entries or downstream
// services break.
var permissionBits = map[string]int64{
"live": 1 << 2, // 4
"test": 1 << 3, // 8
"admin": 1 << 4, // 16
"published": 1 << 5, // 32
"secret": 1 << 6, // 64
"authorize": 1 << 7, // 128
"capture": 1 << 8, // 256
"bundle": 1 << 9,
"campaign": 1 << 10,
"collection": 1 << 11,
"coupon": 1 << 12,
"form": 1 << 13,
"order": 1 << 14,
"organization": 1 << 15,
"payment": 1 << 16,
"plan": 1 << 17,
"product": 1 << 18,
"referral": 1 << 19,
"referrer": 1 << 20,
"store": 1 << 21,
"subscriber": 1 << 22,
"user": 1 << 23,
"variant": 1 << 24,
"readbundle": 1 << 25,
"readcampaign": 1 << 26,
"readcollection": 1 << 27,
"readcoupon": 1 << 28,
"readform": 1 << 29,
"readorder": 1 << 30,
"readorganization": 1 << 31,
"readpayment": 1 << 32,
"readplan": 1 << 33,
"readproduct": 1 << 34,
"readreferral": 1 << 35,
"readreferrer": 1 << 36,
"readstore": 1 << 37,
"readsubscriber": 1 << 38,
"readuser": 1 << 39,
"readvariant": 1 << 40,
"writebundle": 1 << 41,
"writecampaign": 1 << 42,
"writecollection": 1 << 43,
"writecoupon": 1 << 44,
"writeform": 1 << 45,
"writeorder": 1 << 46,
"writeorganization": 1 << 47,
"writepayment": 1 << 48,
"writeplan": 1 << 49,
"writeproduct": 1 << 50,
"writereferral": 1 << 51,
"writereferrer": 1 << 52,
"writestore": 1 << 53,
"writesubscriber": 1 << 54,
"writeuser": 1 << 55,
"writevariant": 1 << 56,
"return": 1 << 57,
"readreturn": 1 << 58,
"writereturn": 1 << 59,
}
// billingChecker checks user billing status against Commerce API.
//
// Commerce endpoints used:
//
// GET /v1/billing/balance?user={user}&currency=usd
// -> { "user": "...", "currency": "usd", "balance": 5000, "holds": 0, "available": 5000 }
//
// All amounts are in cents. available = balance - holds.
// A user can proceed if available > 0.
type billingChecker struct {
baseURL string
token string // COMMERCE_SERVICE_TOKEN for S2S auth
client *http.Client
}
type billingResponse struct {
User string `json:"user"`
Currency string `json:"currency"`
Balance int64 `json:"balance"`
Holds int64 `json:"holds"`
Available int64 `json:"available"`
}
func newBillingChecker(baseURL, token string) *billingChecker {
return &billingChecker{
baseURL: baseURL,
token: token,
client: &http.Client{
Timeout: 5 * time.Second,
},
}
}
// checkBalance reports whether the user has positive available balance.
//
// Contract — the caller distinguishes three states:
//
// (true, nil) commerce confirmed available > 0 → allow.
// (false, nil) commerce confirmed available <= 0 → deny 402 (out of funds).
// (false, err) balance could not be determined → deny 503 (fail-closed).
//
// FAIL-CLOSED is deliberate: this is a paid, no-free product, so we never
// serve AI to a user whose balance we cannot verify. The only allow-without-
// proof case is when no billing URL is configured at all (not enforced).
//
// The user identifier is the IAM "org/sub" (e.g. "hanzo/alice").
func (b *billingChecker) checkBalance(userID string) (bool, error) {
if b.baseURL == "" {
return true, nil // Billing not configured -> not enforced.
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// Commerce mounts its API under /v1 (commerce mount.go: Router.Group("/v1")
// -> billing.Route -> GET /balance), NOT /api/v1. A wrong prefix 404s and,
// fail-closed, denies every request — keep this in lockstep with commerce.
u := fmt.Sprintf("%s/v1/billing/balance?user=%s&currency=usd", b.baseURL, url.QueryEscape(userID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return false, err
}
// Authenticate with the Commerce service token (admin-scoped S2S).
if b.token != "" {
req.Header.Set("Authorization", "Bearer "+b.token)
}
resp, err := b.client.Do(req)
if err != nil {
return false, err // Commerce unreachable -> deny.
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("commerce billing status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<16))
if err != nil {
return false, err
}
var result billingResponse
if err := json.Unmarshal(body, &result); err != nil {
return false, err
}
return result.Available > 0, nil
}
// commercePathPrefix is the funding surface — the routes a user adds funds
// through. It is NEVER balance-gated: a 402 here would lock a zero-balance
// user out of the only page that lets them pay. The exclusion lives in code,
// not operator discipline, so it cannot be misconfigured away by BILLING_PATHS.
const commercePathPrefix = "/v1/commerce"
// billingPathMatch reports whether path falls under balance enforcement.
// The funding surface (commercePathPrefix) is always excluded. An empty prefix
// list enforces on every non-funding path; a non-empty list scopes enforcement
// to those prefixes — the metered must-gate surface — so the AI/validated and
// public routes are never balance-gated.
func billingPathMatch(path string, prefixes []string) bool {
// Funding surface is never balance-gated, even when BILLING_PATHS lists it.
if path == commercePathPrefix || strings.HasPrefix(path, commercePathPrefix+"/") {
return false
}
if len(prefixes) == 0 {
return true
}
for _, p := range prefixes {
if p != "" && strings.HasPrefix(path, p) {
return true
}
}
return false
}
// DefaultAuthConfig returns the default auth configuration from environment variables.
func DefaultAuthConfig() AuthConfig {
enabled := authEnabled()
jwksURL := os.Getenv("AUTH_JWKS_URL")
if jwksURL == "" {
jwksURL = "https://hanzo.id/v1/iam/.well-known/jwks"
}
issuer := os.Getenv("AUTH_ISSUER")
if issuer == "" {
issuer = "https://hanzo.id"
}
// Audience is validated against an allowlist (token.AudiencesFromEnv):
// the known Hanzo client_ids + the gateway origin, overridable via
// GATEWAY_ALLOWED_AUDIENCES. IAM stamps user tokens with aud=<client_id>,
// so the prior single fixed audience rejected every user JWT. Audience
// validation is ALWAYS enforced (the allowlist is never empty).
audiences := token.AudiencesFromEnv()
billingURL := os.Getenv("AUTH_BILLING_URL")
if billingURL == "" {
billingURL = "http://commerce.hanzo.svc.cluster.local:8001"
}
billingToken := os.Getenv("COMMERCE_SERVICE_TOKEN")
// Billing enforcement is the single responsibility of cloud's
// balance_gate, which resolves sk- API keys, applies per-model pricing,
// and exempts service keys — none of which the edge gateway can see.
// The gateway must NOT double-gate balance, so billing is OFF by default.
// Opt in only for edge-only deployments that front no balance_gate, via
// BILLING_ENABLED=true. One way; one enforcement point.
billingEnabled := os.Getenv("BILLING_ENABLED") == "true"
// BILLING_PATHS scopes balance enforcement to these path prefixes (CSV).
// Set this to the must-gate surface so the AI/validated and public routes
// are never balance-gated. Empty = legacy global enforcement.
var billingPaths []string
if bp := os.Getenv("BILLING_PATHS"); bp != "" {
billingPaths = strings.Split(bp, ",")
for i := range billingPaths {
billingPaths[i] = strings.TrimSpace(billingPaths[i])
}
}
publicPathsStr := os.Getenv("AUTH_PUBLIC_PATHS")
var publicPaths []string
if publicPathsStr != "" {
publicPaths = strings.Split(publicPathsStr, ",")
for i := range publicPaths {
publicPaths[i] = strings.TrimSpace(publicPaths[i])
}
} else {
// Default public paths
publicPaths = []string{
"/healthz",
"/__stats",
"/.well-known/",
"/favicon",
}
}
publicHostsStr := os.Getenv("AUTH_PUBLIC_HOSTS")
var publicHosts []string
if publicHostsStr != "" {
publicHosts = strings.Split(publicHostsStr, ",")
for i := range publicHosts {
publicHosts[i] = strings.TrimSpace(publicHosts[i])
}
} else {
// Default public hosts: IAM/login domains don't need gateway auth
publicHosts = []string{
"hanzo.id",
"lux.id",
"pars.id",
"id.bootno.de",
"id.zoo.network",
"iam.hanzo.ai",
}
}
requireAuth := os.Getenv("AUTH_REQUIRE") == "true"
return AuthConfig{
Enabled: enabled,
JWKSURL: jwksURL,
Issuer: issuer,
Audiences: audiences,
BillingURL: billingURL,
BillingToken: billingToken,
BillingEnabled: billingEnabled,
BillingPaths: billingPaths,
PublicPaths: publicPaths,
PublicHosts: publicHosts,
RequireAuth: requireAuth,
}
}
// Validate reports a fatal misconfiguration: billing enabled without the
// Commerce endpoint and service token it depends on. Enforced so enabling
// billing can never silently fail open (empty URL → checkBalance allows) nor
// 503-storm the whole metered surface (empty token → Commerce 401). The one
// caller that can return an error — gateway.Mount — refuses to start in this
// state; NewAuthMiddleware additionally fails the balance gate closed.
func (c AuthConfig) Validate() error {
if !c.BillingEnabled {
return nil
}
if c.BillingURL == "" {
return fmt.Errorf("auth: BILLING_ENABLED=true requires AUTH_BILLING_URL (Commerce endpoint)")
}
if c.BillingToken == "" {
return fmt.Errorf("auth: BILLING_ENABLED=true requires COMMERCE_SERVICE_TOKEN (from KMS)")
}
return nil
}
// NewAuthMiddleware creates a gin middleware that validates IAM JWT tokens,
// checks billing status, and injects identity headers for downstream services.
//
// Canonical identity headers (the only ones downstream services should rely on):
// - X-User-Id: user ID from JWT "sub" (fallback: preferred_username, name)
// - X-Org-Id: org slug from JWT "owner" claim
// - X-Roles: comma-joined role names from JWT "roles" claim
// - X-User-Permissions: base-10 int64 bit-field derived from JWT permissions
// - isAdmin (commerce treats absent/0 as no rights).
//
// Auxiliary headers (derivatives of the JWT for convenience):
// - X-User-Email: email from JWT "email" claim
// - X-Phone-Number: phone from JWT "phone_number" or "phone" claim
// - X-User-IsAdmin: "true" if the JWT asserts isAdmin
//
// Trust boundary: all of the above are stripped on ingress (see
// stripIdentityHeaders) and only re-set after the JWT is validated. A
// client-supplied X-User-Permissions can NEVER reach a downstream
// service — Red P0-1 (2026-04-27).
//
// Billing:
// - Checks commerce service for positive balance
// - Fail-open: if billing service is unreachable, request proceeds
// - If balance <= 0: returns 402 Payment Required
//
// The ALLOW/DENY ladder itself — strip, route class, public allowlists, token
// extraction, JWT validation, the identity write, the balance gate — is
// [authGate.admit] in authpolicy.go, which knows about no framework. Its
// transports are a few lines each: native zip for the HIP-0106 unified binary
// (mount.go) and gin for the legacy Lura edge (legacy_transports.go).
//
// validateToken, the JWKS cache, the identity-header trust boundary,
// the API-key test, and the token extractors live in hanzoai/authz/edge — the one
// edge-auth implementation shared with the estate. The thin shims that keep
// this file's symbols stable are in auth_compat.go.