mirror of
https://github.com/hanzoai/gateway.git
synced 2026-10-30 15:04:44 +00:00
Every surface signs people in through Hanzo IAM itself, so neither gate has a host to front. The token package no longer accepts hanzo-admin-guard as an audience; IAM is deleting that client. Co-authored-by: Hanzo Dev <[email protected]>
140 lines
4.9 KiB
Go
140 lines
4.9 KiB
Go
// Package token is the gateway's credential check: which issuer it trusts, which
|
|
// audiences it accepts, and the keys it verifies against.
|
|
//
|
|
// It is deliberately thin. The identity DECISION — what a claim means, which org a
|
|
// request acts in, who pays, which headers get written — lives in hanzoai/authz and
|
|
// hanzoai/authz/edge, because three consumers each having their own reading of one
|
|
// contract is what drifted into a live escalation. What is left here is the part
|
|
// that is genuinely this deployment's: the ISSUER and AUDIENCE policy, named by the
|
|
// environment variables this deployment already sets.
|
|
package token
|
|
|
|
import (
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hanzoai/authz/edge"
|
|
)
|
|
|
|
// Config is the edge's verification policy.
|
|
type Config struct {
|
|
JWKSURL string
|
|
|
|
// Issuers is the trusted-issuer allowlist. It is a SET because one deployment
|
|
// fronts several brands, each signing under its own issuer; an empty set refuses
|
|
// every token rather than silently disabling the check.
|
|
Issuers []string
|
|
|
|
// Audiences is the allowlist of acceptable `aud` values, with OR semantics: a
|
|
// token passes when its audience matches ANY entry.
|
|
//
|
|
// It is enforced HERE rather than in authz.Verify, which deliberately does not
|
|
// check audience — IAM sets `aud` per RFC 8707 to the requesting CLIENT, so the
|
|
// claim names which client asked for the token, not which resource server may
|
|
// accept it. Checking it is therefore a POLICY an edge may hold ("I accept tokens
|
|
// minted for these consoles") and not part of reading a token. An empty list
|
|
// disables the check; AudiencesFromEnv never returns empty, so it is always on.
|
|
Audiences []string
|
|
|
|
JWKSTTL time.Duration
|
|
}
|
|
|
|
// DefaultAudiences is the baked allowlist: the known IAM client_ids (each app's
|
|
// `aud` is its client_id) plus the API origin. A client_id is appended when a
|
|
// surface starts minting it and removed when IAM no longer holds the client.
|
|
// Override entirely with GATEWAY_ALLOWED_AUDIENCES.
|
|
var DefaultAudiences = []string{
|
|
"hanzo-app",
|
|
"hanzo-console",
|
|
"hanzo-chat",
|
|
"hanzo-ai",
|
|
"hanzo-id",
|
|
"admin-console",
|
|
"hanzo-world",
|
|
"cowork",
|
|
"https://api.hanzo.ai",
|
|
}
|
|
|
|
// AudiencesFromEnv resolves the audience allowlist. GATEWAY_ALLOWED_AUDIENCES
|
|
// (comma-separated), when set, fully replaces the baked default. Otherwise the
|
|
// baked list is used, with the legacy single-value AUTH_AUDIENCE folded IN when set
|
|
// — it widens, never narrows, so a deployment still pinned to AUTH_AUDIENCE keeps
|
|
// that value inside an already-inclusive set rather than collapsing the allowlist to
|
|
// one entry. The result is never empty, so the check is always enforced.
|
|
func AudiencesFromEnv() []string {
|
|
if v := os.Getenv("GATEWAY_ALLOWED_AUDIENCES"); v != "" {
|
|
if list := split(v); len(list) > 0 {
|
|
return list
|
|
}
|
|
}
|
|
out := append([]string(nil), DefaultAudiences...)
|
|
if legacy := strings.TrimSpace(os.Getenv("AUTH_AUDIENCE")); legacy != "" {
|
|
if !accepts(out, legacy) {
|
|
out = append(out, legacy)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ConfigFromEnv reads the AUTH_* variables, so every binary in this module agrees
|
|
// on the IAM authority.
|
|
func ConfigFromEnv() Config {
|
|
return Config{
|
|
JWKSURL: envOr("AUTH_JWKS_URL", "https://hanzo.id/v1/iam/.well-known/jwks"),
|
|
Issuers: IssuersFromEnv(),
|
|
Audiences: AudiencesFromEnv(),
|
|
JWKSTTL: 15 * time.Minute,
|
|
}
|
|
}
|
|
|
|
// IssuersFromEnv resolves the trusted-issuer allowlist: AUTH_ISSUER (the primary),
|
|
// widened by WHITELABEL_ISSUERS (comma-separated) so a brand this edge fronts can be
|
|
// added without a code change. It never returns empty, so the check is always on.
|
|
func IssuersFromEnv() []string {
|
|
out := []string{envOr("AUTH_ISSUER", "https://hanzo.id")}
|
|
for _, iss := range split(os.Getenv("WHITELABEL_ISSUERS")) {
|
|
if !accepts(out, iss) {
|
|
out = append(out, iss)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func envOr(key, dflt string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return dflt
|
|
}
|
|
|
|
func split(s string) []string {
|
|
out := make([]string, 0, 4)
|
|
for p := range strings.SplitSeq(s, ",") {
|
|
if p = strings.TrimSpace(p); p != "" {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// accepts reports whether want is in the allowlist. The comparison is VERBATIM, for
|
|
// the same reason every identifier comparison in authz is: folding case would make
|
|
// two registered client ids one.
|
|
func accepts(allow []string, want string) bool {
|
|
return slices.Contains(allow, want)
|
|
}
|
|
|
|
// NewValidator builds the edge's verifier from cfg. The check itself lives in
|
|
// hanzoai/authz/edge — this package's whole job is turning THIS deployment's
|
|
// environment into the values it takes, which is the only part that is ours.
|
|
func NewValidator(cfg Config) *edge.Verifier {
|
|
return edge.NewVerifier(cfg.JWKSURL, cfg.Issuers, cfg.Audiences, cfg.JWKSTTL)
|
|
}
|
|
|
|
// ErrNoToken is the edge's sentinel, re-exported so a caller that already imports
|
|
// this package for its config can compare against it without importing the edge for
|
|
// one variable. It is the same value, not a copy.
|
|
var ErrNoToken = edge.ErrNoToken
|