identity

package
v0.6.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 3, 2026 License: Apache-2.0 Imports: 21 Imported by: 0

Documentation

Overview

Package identity verifies who a gateway request is from. Cohort assignment must only ever use a Subject produced here: never a header a client could have set itself.

Index

Constants

View Source
const (
	// DelegatedTokenType is the JWT typ header of a delegated credential.
	DelegatedTokenType = "halo-agent+jwt" //nolint:gosec // JWT typ value, not a credential
	// KindAgent is the kind claim of a delegated credential.
	KindAgent = "agent"
)

Delegated agent credentials (ADR-0024): halo-server exchanges a verified Halos credential (a human session, a workload credential, or another delegated credential) for a short-lived JWT naming the agent that acts for the root principal. The act claim is RFC 8693 section 4.1: the top-level act is the current actor and each nested act a prior one. Signed with the workload key and verified by the same code at halo-server and the gateways.

View Source
const (
	// MCPTokenType is the JWT typ header of an MCP access token.
	MCPTokenType = "halo-mcp+jwt" //nolint:gosec // JWT typ value, not a credential
	// MCPTokenTTL is the lifetime cap of an MCP access token: harness header
	// helpers run on every connection, so a short token costs nothing.
	MCPTokenTTL = 5 * time.Minute
)

MCP access tokens (ADR-0029): for a registry server with auth mode halos, halo-server is the authorization server. It mints a short-lived token whose audience is the server's canonical URL (RFC 8707), so the token is good at that one server only: gateways refuse it (wrong typ and aud) and the server never receives, or passes on, the caller's own Halos credential.

View Source
const (
	ModeJWT           = "jwt"
	ModeTrustedHeader = "trusted_header"
	ModeNone          = "none" // no verification: every caller is anonymous (default routing)
)

Modes.

View Source
const (
	// WorkloadTokenType is the JWT typ header. Gateways dispatch on it; it is
	// never trusted for verification.
	WorkloadTokenType = "halo-workload+jwt" //nolint:gosec // JWT typ value, not a credential
	WorkloadIssuer    = "halos"
	WorkloadAudience  = "halos-gateway"
)

Workload credentials (ADR-0023): halo-server exchanges a CI / cloud OIDC token for a short-lived JWT it signs with an ed25519 key; gateways verify it with the public half. EdDSA only: there is no alg negotiation to confuse.

Variables

View Source
var AllowedAlgs = []string{"RS256", "ES256", "EdDSA"}

AllowedAlgs is the JWT signature allowlist. "none" and HS* are never accepted: a shared-secret alg would let anyone holding the (public) JWKS key forge tokens (key confusion).

View Source
var ClientCredentialHeaders = []string{"Authorization", "X-Api-Key", "Api-Key", "X-Goog-Api-Key", "Proxy-Authorization"}

ClientCredentialHeaders are the headers a caller can use to present a credential to the gateway. None is ever a gateway-to-provider credential.

View Source
var ErrAmbiguous = errors.New("identity: identity header sent more than once")

ErrAmbiguous is returned when a trusted identity/groups header appears more than once; adapters answer 400 rather than guess which value is real.

View Source
var ErrClaimsRejected = errors.New("identity: token signature valid, claims rejected")

ErrClaimsRejected marks a token whose signature verified against a trusted key but whose claims did not pass (expired, wrong audience, not yet valid, a revoked session, ...). It is not a forgery: callers that rate-limit failed attempts per client IP do not count it, because anyone on a shared CI runner can mint such tokens and would otherwise lock out its other jobs.

View Source
var ErrDiscovery = errors.New("identity: oidc discovery")

ErrDiscovery: the issuer's discovery document could not be fetched, so no token was judged (an outage, not a bad credential).

View Source
var ErrNoCredentials = errors.New("identity: no credentials")

ErrNoCredentials is returned when the request carries nothing to verify.

View Source
var ErrTokenExpired = errors.New("expired")

ErrTokenExpired wraps the expiry refusal of a halo-server workload or delegated credential. Its signature has already verified when it is returned, so the holder is genuine and only late.

Functions

func BearerToken

func BearerToken(h http.Header) string

BearerToken returns the caller's token from "Authorization: Bearer" or, for Anthropic SDKs, "x-api-key".

func CacheOnly added in v0.3.0

func CacheOnly(ctx context.Context) context.Context

CacheOnly marks ctx so OIDC verification uses only discovery documents and JWKS keys already cached, never fetching: a request from a client IP blocked for failed attempts can still prove it holds a signed token without being able to make halo-server call the issuer.

func CheckChain added in v0.3.0

func CheckChain(chain []policy.Actor) error

CheckChain enforces the structure every delegation chain must have: 1 to policy.MaxAgentDepth actors, each a valid agent name, none twice (a cycle), harness and session labels within their length and character set.

func CheckHumanID added in v0.3.0

func CheckHumanID(id string) error

CheckHumanID refuses a human identity that carries the workload subject prefix (any case): only a verified workload credential may produce one, so a human can never take a workload's rules, budgets or audit trail.

func IsWorkloadToken added in v0.3.0

func IsWorkloadToken(tok string) bool

IsWorkloadToken reports whether tok claims (unverified) to be a workload credential. Used only to pick a verifier.

func ParseWorkloadKeys added in v0.3.0

func ParseWorkloadKeys(pemData string) ([]ed25519.PublicKey, error)

ParseWorkloadKeys reads one or more PEM "PUBLIC KEY" blocks (PKIX ed25519, as `halo keys generate` writes). Several keys allow rotation.

func SignDelegatedToken added in v0.3.0

func SignDelegatedToken(key ed25519.PrivateKey, c DelegatedClaims) (string, error)

SignDelegatedToken signs c with the workload key. It refuses claims VerifyDelegatedToken would reject.

func SignMCPToken added in v0.4.0

func SignMCPToken(key ed25519.PrivateKey, c MCPClaims) (string, error)

SignMCPToken signs c with the workload key. It refuses claims VerifyMCPToken would reject.

func SignWorkloadToken added in v0.3.0

func SignWorkloadToken(key ed25519.PrivateKey, c WorkloadClaims) (string, error)

SignWorkloadToken signs c. It refuses claims Verify would reject, so a bug upstream can never mint an over-long or non-workload credential.

func SubjectFromClaims

func SubjectFromClaims(claims map[string]any, cfg Options) (policy.Subject, error)

SubjectFromClaims derives the caller from verified token claims using cfg.UserClaim / cfg.GroupsClaim (defaults "email" / "groups"). When the user claim is "email" the token must also carry email_verified=true (bool or "true"): many IdPs let users set an unverified email, which would let them pick someone else's identity. cfg.TrustUnverifiedEmail opts out.

func TokenType added in v0.3.0

func TokenType(tok string) string

TokenType returns the (unverified) typ header of a compact JWT, "" when unreadable. Used only to pick a verifier.

func WorkloadKeyID added in v0.3.0

func WorkloadKeyID(pub ed25519.PublicKey) string

WorkloadKeyID is the kid for pub: lets gateways hold several keys (rotation).

Types

type Act added in v0.3.0

type Act struct {
	Subject string `json:"sub"`               // "agent:<name>"
	Harness string `json:"harness,omitempty"` // where it runs, e.g. claude-code
	Session string `json:"session,omitempty"` // the harness session id
	Act     *Act   `json:"act,omitempty"`
}

Act is one RFC 8693 actor claim: the actor, and in Act the actor before it.

func ActFromChain added in v0.3.0

func ActFromChain(chain []policy.Actor) *Act

ActFromChain builds the nested act claim from a chain listed root-side first.

type DelegatedClaims added in v0.3.0

type DelegatedClaims struct {
	Issuer   string   `json:"iss"`
	Subject  string   `json:"sub"`
	Audience string   `json:"aud"`
	Groups   []string `json:"groups,omitempty"`
	Kind     string   `json:"kind"`
	RootKind string   `json:"root_kind,omitempty"`
	Act      *Act     `json:"act"`
	// Models narrows the aliases the credential may call; absent = not
	// narrowed by the credential (policy still applies). halo-server never
	// mints an empty scope: an empty intersection is refused, not widened.
	Models []string `json:"models,omitempty"`
	// AuthTime is when the root authenticated (OIDC auth_time): a session
	// revoked after it kills the credential at its next exchange.
	AuthTime int64  `json:"auth_time,omitempty"`
	Parent   string `json:"parent_jti,omitempty"` // jti of the credential it was exchanged from
	// Ancestors are the jtis of the credentials before the parent (its
	// parent first), so revoking any ancestor's jti (ADR-0026) also kills
	// this credential. Absent when the parent was minted from a session or
	// from nothing with a jti.
	Ancestors []string `json:"anc_jti,omitempty"`
	IssuedAt  int64    `json:"iat"`
	NotBefore int64    `json:"nbf"`
	Expiry    int64    `json:"exp"`
	ID        string   `json:"jti"`
}

DelegatedClaims is the payload of a delegated credential. Subject and Groups are the root principal's (RootKind "" human, "workload"); Act is the chain.

func VerifyDelegatedToken added in v0.3.0

func VerifyDelegatedToken(keys []ed25519.PublicKey, tok string, now time.Time) (DelegatedClaims, error)

VerifyDelegatedToken checks the signature against keys (by kid), then the claims: iss, aud, kind, root subject, act chain (depth <= policy.MaxAgentDepth, no agent twice), exp, nbf, iat and the lifetime cap.

func (DelegatedClaims) AsSubject added in v0.3.0

func (c DelegatedClaims) AsSubject() policy.Subject

AsSubject is the verified caller the gateway routes: the root principal with the delegation and the credential's lineage (for revocation) attached.

func (DelegatedClaims) Chain added in v0.3.0

func (c DelegatedClaims) Chain() []policy.Actor

Chain returns the actors root-side first (the current actor last).

func (DelegatedClaims) Lineage added in v0.4.0

func (c DelegatedClaims) Lineage() []string

Lineage is the credential's jti, then its parent's and the ancestors'.

func (DelegatedClaims) ModelScope added in v0.3.0

func (c DelegatedClaims) ModelScope() []string

ModelScope returns the credential's model scope (nil = not narrowed).

type MCPClaims added in v0.4.0

type MCPClaims struct {
	Issuer   string   `json:"iss"`
	Subject  string   `json:"sub"`
	Audience string   `json:"aud"` // canonical server URL (policy.CanonicalResource)
	Groups   []string `json:"groups,omitempty"`
	// Kind is the subject's kind ("" human, workload); Actors the delegation
	// chain when an agent asked, root-side first. Attribution for the server.
	Kind   string   `json:"kind,omitempty"`
	Actors []string `json:"actors,omitempty"`
	// Server is the registry id the token was minted for.
	Server    string `json:"mcp_server"`
	IssuedAt  int64  `json:"iat"`
	NotBefore int64  `json:"nbf"`
	Expiry    int64  `json:"exp"`
	ID        string `json:"jti"`
}

MCPClaims is the payload of an MCP access token.

func VerifyMCPToken added in v0.4.0

func VerifyMCPToken(keys []ed25519.PublicKey, tok, resource string, now time.Time) (MCPClaims, error)

VerifyMCPToken is what an MCP server whose authorization server is Halos runs on each request: the signature against halo-server's workload public keys (by kid), then iss, aud (must be resource, canonicalised), exp, nbf, iat and the lifetime cap.

type OIDCJWT

type OIDCJWT struct {
	Issuer, Audience       string
	UserClaim, GroupsClaim string
	// TrustUnverifiedEmail accepts an "email" user claim without
	// email_verified=true. Off by default: see SubjectFromClaims.
	TrustUnverifiedEmail bool
	// contains filtered or unexported fields
}

OIDCJWT verifies a bearer JWT issued by the org's IdP: discovery + JWKS (cached, refetched when no cached key verifies so key rotation works; never fetched under CacheOnly), then iss / aud / exp / nbf and the alg allowlist.

func NewOIDCJWT

func NewOIDCJWT(issuer, audience, userClaim, groupsClaim string, client *http.Client) (*OIDCJWT, error)

NewOIDCJWT never touches the network; discovery happens on first Verify (and is retried while it fails), so a proxy can start before the IdP is reachable. client may be nil.

func (*OIDCJWT) Claims added in v0.3.0

func (o *OIDCJWT) Claims(ctx context.Context, tok string) (map[string]any, error)

Claims verifies tok (signature via JWKS, alg allowlist, iss, aud, exp, nbf) and returns its claims. A token whose signature verified but whose claims failed is reported with ErrClaimsRejected. Under CacheOnly(ctx) nothing is fetched: no discovery, no JWKS refetch.

func (*OIDCJWT) Verify

func (o *OIDCJWT) Verify(r *http.Request) (policy.Subject, error)

type Options

type Options struct {
	Mode                   string
	Issuer, Audience       string
	UserClaim, GroupsClaim string
	IdentityHeader         string
	GroupsHeader           string
	TrustedProxyCIDRs      []string
	// TrustUnverifiedEmail: accept userClaim "email" without email_verified=true.
	// Default false; only for IdPs that never issue unverified emails.
	TrustUnverifiedEmail bool
	// WorkloadKeys is PEM (one or more ed25519 PUBLIC KEY blocks) of
	// halo-server's workload signing key(s). When set, workload credentials
	// (ADR-0023) are accepted besides the mode's own callers. Ignored in mode none.
	WorkloadKeys string
}

Options is per-binary identity config. Empty fields fall back to the org's policy (Org.Identity / Org.Gateway.Auth), so most deployments set nothing.

func (Options) Effective

func (o Options) Effective(org *policy.Org) Options

Effective fills gaps from the org and picks the mode: jwt when an issuer is known, otherwise trusted_header (which then demands CIDRs and fails closed).

func (Options) HeaderNames

func (o Options) HeaderNames(org *policy.Org) []string

HeaderNames are the identity/groups headers (effective for org) that must be cleared from every upstream request once the subject is derived.

type Resolver

type Resolver struct {
	// contains filtered or unexported fields
}

Resolver caches the Verifier across policy hot-reloads: it is rebuilt only when the effective options change (so the JWKS cache survives reloads).

func (*Resolver) Get

func (r *Resolver) Get(base Options, org *policy.Org) (Verifier, error)

Get returns the verifier for base options + org. A nil Verifier with nil error means ModeNone.

type TrustedHeader

type TrustedHeader struct {
	IdentityHeader string
	GroupsHeader   string
	// contains filtered or unexported fields
}

TrustedHeader reads identity from headers set by an auth gateway. That is only safe when the request provably came from that gateway, so it refuses to exist without a CIDR allowlist for the peer address.

func NewTrustedHeader

func NewTrustedHeader(identityHeader, groupsHeader string, trustedProxyCIDRs []string) (*TrustedHeader, error)

func (*TrustedHeader) Verify

func (t *TrustedHeader) Verify(r *http.Request) (policy.Subject, error)

type Verifier

type Verifier interface {
	Verify(r *http.Request) (policy.Subject, error)
}

Verifier authenticates a request and returns the caller. Any error means the caller is unverified.

func New

func New(o Options) (Verifier, error)

New builds the Verifier for o (already Effective). ModeNone returns nil, nil.

type WorkloadClaims added in v0.3.0

type WorkloadClaims struct {
	Issuer    string   `json:"iss"`
	Subject   string   `json:"sub"`
	Audience  string   `json:"aud"`
	Groups    []string `json:"groups,omitempty"`
	Kind      string   `json:"kind"`
	Rule      string   `json:"rule"` // workloads.rules name that matched
	IssuedAt  int64    `json:"iat"`
	NotBefore int64    `json:"nbf"`
	Expiry    int64    `json:"exp"`
	ID        string   `json:"jti"`
}

WorkloadClaims is the payload of a workload credential.

func VerifyWorkloadToken added in v0.3.0

func VerifyWorkloadToken(keys []ed25519.PublicKey, tok string, now time.Time) (WorkloadClaims, error)

VerifyWorkloadToken checks the signature against keys (by kid), then iss, aud, kind, subject prefix, exp, nbf, iat and the lifetime cap.

func (WorkloadClaims) AsSubject added in v0.4.0

func (c WorkloadClaims) AsSubject() policy.Subject

AsSubject is the verified workload caller, with its credential attached for revocation checks (ADR-0026).

type WorkloadJWT added in v0.3.0

type WorkloadJWT struct {
	Keys []ed25519.PublicKey
	Next Verifier
	Now  func() time.Time // nil = time.Now
}

WorkloadJWT accepts halo-server workload and delegated agent credentials and hands every other caller to Next (the human verifier). A token whose typ claims to be either is verified as such or refused: never a fall-through.

func (*WorkloadJWT) Verify added in v0.3.0

func (w *WorkloadJWT) Verify(r *http.Request) (policy.Subject, error)

Directories

Path Synopsis
Package identitytest is a tiny OIDC issuer (discovery + JWKS + token minting + an auto-approving authorization-code/PKCE flow) for tests and the compose demo.
Package identitytest is a tiny OIDC issuer (discovery + JWKS + token minting + an auto-approving authorization-code/PKCE flow) for tests and the compose demo.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL