identity

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Aug 16, 2026 License: Apache-2.0 Imports: 22 Imported by: 0

Documentation

Overview

Package identity is the platform's human-authentication boundary: it verifies the credential a human operator presents and reduces it to a principal holding one of three roles. Machine credentials never come here — the management x-api-key and the BYOC environment key keep exactly the semantics they have always had, which is what keeps every documented CLI and SDK flow unchanged (docs/plan/31_console-sso-rbac.md).

Dependency

The verifier is built directly on github.com/go-jose/go-jose/v4. Two other routes were evaluated and declined:

  • github.com/coreos/go-oidc. Its oidc.NewRemoteKeySet caches until an unknown kid forces a refetch and offers no TTL, so a key the IdP has REMOVED keeps verifying forever — which the plan's own Architecture text forbids. And oidc.Config carries a single ClientID, so multi-audience plus azp needs SkipClientIDCheck and a hand-written audience policy anyway. Adopting it would mean writing the security core regardless, plus a new module.
  • A hand-rolled compact-JWS and JWK parser. go-jose is already linked into cmd/controlplane (go list -deps prints it, and its /cipher package, through internal/blob/gcs → cloud.google.com/go/storage → grpc/xds → go-spiffe), so hand-rolling removes nothing from the binary and adds ~250 statements of security-critical parsing.

go-jose earns its place structurally rather than conveniently: the signature allowlist is a REQUIRED parameter of jwt.ParseSigned, so alg:none and HS256 die inside the parser before any key lookup — stronger than a table we could forget to consult.

What go-jose does not do, and this package therefore does:

  • require exp — ValidateWithLeeway skips it when absent (jwt/validation.go);
  • require a non-empty sub;
  • anything about azp;
  • refuse b64, by either route. go-jose lists b64 in its own supportedCritical set (shared.go), so a crit naming it passes; and computeAuthData (jws.go) honours a bare b64 with no reference to crit, verifying over the raw payload. RFC 7797 §7 says a JWT MUST NOT use b64 at all. Verify refuses any crit and any b64, before the key lookup;
  • fetch, cache, or bound the lifetime of a key set, and OIDC discovery;
  • bound an RSA modulus, require an odd exponent, reject an even modulus, or bound the exponent above (jwk.go rsaPublicKey builds a bare rsa.PublicKey, and decodes e as int(big.Int.Int64()), which silently truncates);
  • parse key_ops at all (rawJSONWebKey has no such field);
  • enforce a JWK's declared alg against the JWS header;
  • decode a JWK Set per entry: jose.JSONWebKeySet has no set-level UnmarshalJSON and JSONWebKey.UnmarshalJSON fails on any kty it cannot build, so one unusable entry the IdP is entitled to publish would fail the whole set. See parseKeySet.

Do NOT add a len(tok.Headers) != 1 guard: compact parsing makes multi-signature unreachable, so the branch is dead code the coverage gate would then carry.

Uniform rejection

Verify returns exactly one error type, whose Error() is a constant string. The detail lives behind Reason(), which the caller logs beside a request id and never renders. An oracle distinguishing expired from bad-signature from wrong-audience must take deliberate code rather than be one careless wrap away.

One timing side channel is accepted and stated rather than missed: an unknown kid within the refresh cooldown answers fast, and past it costs a network round trip, so the two are distinguishable. What leaks is whether a kid is in the current key set — and the key set is a public document. Signature verification uses constant-time stdlib primitives, and every claim comparison is against a public configured value.

Logs and errors

No log line and no error carries a token byte, and no URL reaches either without going through redactURL first — a key-set URL may be a signed URL whose query string IS the credential. That covers the userinfo, the query, the fragment and the opaque form; the scheme, host and path survive on purpose, because which endpoint failed is the diagnostic. An error from the transport or from url.Parse is reduced to its CAUSE rather than wrapped, since both quote the URL verbatim in their own message.

The one attacker-supplied value logged on purpose is the kid, truncated, at Debug: it answers which key a provider rotated to, and it is not a credential. TestLogsCarryNoCredentials reads the actual output.

kid is required

A token with no kid, and a JWK with no kid, are both refused. Key selection is this package's, never go-jose's, and it is indexed by kid; every provider in the compatibility set (Casdoor, Keycloak, Entra ID, Cognito, accounts.google.com and Google Cloud IAP) emits one. An OP that does not is a GitHub issue, not a silent fallback whose behaviour would change with the size of the key set.

Index

Constants

This section is empty.

Variables

View Source
var ErrUnauthenticated = errors.New("identity: authentication failed")

ErrUnauthenticated classes every rejection Verify produces. There is no other error path out of Verify, so nothing this package returns from it could render as anything but a 401.

Functions

func LooksLikeJWT

func LooksLikeJWT(s string) bool

LooksLikeJWT reports the compact-JWS silhouette: exactly three non-empty segments separated by two dots, every byte in the base64url alphabet.

This is routing, never security. It is what keeps an sk-map-env01- environment key on the worker lane and off this one; everything past it is fully verified. It lives here so that "what a JWT looks like" has one definition the API layer's lane discrimination inherits instead of re-deriving.

It deliberately does not read an *http.Request: which header a mode owns is Mode and AssertionHeader, and header extraction belongs beside the API layer's existing bearer-token parsing.

Types

type Config

type Config struct {
	Mode     Mode
	Issuer   string // expected iss, compared exactly as configured
	Audience string // must appear in aud; also the expected azp
	JWKSURL  string // set ⇒ discovery is skipped entirely

	AssertionHeader string   // trusted_proxy only; "" in oidc mode
	Algorithms      []string // signature allowlist; empty ⇒ defaultAlgorithms

	RolesClaim string          // default "roles"
	EmailClaim string          // default "email"
	NameClaim  string          // default "name"
	RoleMap    map[string]Role // claim value → role; empty is an error in New

	// HTTPClient replaces the guarded client wholesale. A supplied client gives up
	// everything productionClient carries — the dial-time address guard, the
	// refusal to follow redirects, the proxy-free transport, and the raw
	// header-block cap. Nil selects productionClient, which is what production
	// uses; a test supplies its own to reach an httptest server on loopback.
	HTTPClient *http.Client

	// Now drives token expiry, the key-set TTL and the refresh cooldown from one
	// clock. Nil is time.Now. Must be safe for concurrent use. Exported rather
	// than hidden behind an export_test.go seam because later slices need to drive
	// expiry from outside this package's test binary.
	Now func() time.Time
}

Config is one verifier's whole contract.

Build it with ConfigFromEnv, or literally in a test: the gcp-iap preset's real gstatic key URL cannot point at a fixture, so an exported Config is what makes an IAP-shaped end-to-end test possible at all.

func ConfigFromEnv

func ConfigFromEnv(getenv func(string) string) (Config, error)

ConfigFromEnv parses and validates the IDENTITY_* variables read through getenv. cmd/controlplane passes os.Getenv; a test passes a map lookup, which is what turns the startup-validation rules into pure, parallel table rows.

getenv returning "" means absent: os.Getenv cannot distinguish unset from set-empty, so neither does this, and an empty value takes the default or the missing-value error rather than a third branch unreachable in production.

An unset or "disabled" IDENTITY_MODE yields Config{Mode: ModeDisabled} and a nil error, reading no other variable — so a staged rollout can place the configuration first and flip the mode second. Every other defect is an error the binary must fail startup on.

type Error

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

Error is the only error Verify returns.

Error() is a constant string on purpose; the detail is reachable only through Reason(), and the caller logs it beside a request id rather than rendering it.

func (*Error) Error

func (e *Error) Error() string

func (*Error) Reason

func (e *Error) Reason() string

func (*Error) Unwrap

func (e *Error) Unwrap() error

type Identity

type Identity struct {
	Issuer      string // the verified iss, equal to the configured issuer
	Subject     string // the verified sub, guaranteed non-empty
	Email       string // "" when the claim is absent or not a string
	DisplayName string // "" likewise
	Role        Role   // RoleNone when no claim value mapped
}

Identity is one verified human principal.

The four strings are exactly the principals-table columns upsertPrincipal writes (internal/api); Role is re-derived from the token per request and never stored, because the IdP stays authoritative. A field that is not here cannot be persisted or logged by accident: no raw claims, no token, no expiry.

type Mode

type Mode string

Mode is the deployment's identity mode.

const (
	ModeDisabled     Mode = "disabled"
	ModeOIDC         Mode = "oidc"
	ModeTrustedProxy Mode = "trusted_proxy"
)

The three modes. ModeDisabled is the default and is byte-for-byte the platform without this package: no lane exists, and x-api-key remains the only management credential.

type ProxyPreset

type ProxyPreset string

ProxyPreset names a shipped trusted-proxy configuration.

const (
	PresetGCPIAP ProxyPreset = "gcp-iap"
	PresetCustom ProxyPreset = "custom"
)

The shipped presets. An aws-alb preset is deliberately absent: ALB's assertion is not JWKS-shaped (a per-kid PEM endpoint, signer and issuer in the JWS header, and a mandatory expected-signer check), so it needs its own key source rather than a table entry.

type Role

type Role string

Role is a platform authority level for an authenticated human.

Deliberately not in internal/domain: that package holds Anthropic-native types matching the wire schema (CLAUDE.md principle 1), and the three-role model is a declared divergence that never appears on a /v1 path or in a /v1 body.

const (
	RoleNone      Role = "" // authenticated, nothing mapped — satisfies nothing
	RoleViewer    Role = "viewer"
	RoleDeveloper Role = "developer"
	RoleAdmin     Role = "admin"
)

The three roles, plus the absence of one.

func ParseRole

func ParseRole(s string) (Role, bool)

ParseRole accepts exactly the three role names.

func (Role) AtLeast

func (r Role) AtLeast(min Role) bool

AtLeast reports whether r satisfies a route's minimum role, on the fixed order admin > developer > viewer.

Fail-closed at both ends: RoleNone satisfies nothing, and a minimum that is not one of the three — including "" — is satisfied by nothing, so a typo in a route annotation denies rather than admits.

type Verifier

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

Verifier verifies this deployment's identity credential. Safe for concurrent use; built once per process.

func FromEnv

func FromEnv(ctx context.Context) (*Verifier, error)

FromEnv builds the verifier from the IDENTITY_* environment — the one construction cmd/controlplane uses, so nothing else re-derives what "configured" means.

It returns (nil, nil) when IDENTITY_MODE is unset or disabled: identity is optional exactly as the cipher and the blob store are, and the caller decides what its absence means. The return type is the concrete *Verifier rather than an interface precisely so that nil compares as nil at the consumer — an interface-typed nil would be a non-nil interface holding a nil pointer, and disabled is the one state that must be byte-for-byte today's platform.

func New

func New(ctx context.Context, cfg Config) (*Verifier, error)

New builds the verifier, performing every network call a misconfiguration could fail: OIDC discovery when JWKSURL is unset, then one warming key-set fetch. Both are boot errors, so an unreachable issuer, a discovery document naming a different issuer, a non-https key URL, or a key set that parses to nothing fails the process rather than the first human's first request.

cfg.Mode must not be ModeDisabled — FromEnv owns that case.

func (*Verifier) AssertionHeader

func (v *Verifier) AssertionHeader() string

AssertionHeader is the request header carrying the proxy's assertion in trusted_proxy mode, and "" in oidc mode.

func (*Verifier) Mode

func (v *Verifier) Mode() Mode

Mode reports the deployment's mode, so the API layer's dispatch knows which credential to look for.

func (*Verifier) Verify

func (v *Verifier) Verify(ctx context.Context, token string) (Identity, error)

Verify authenticates one compact JWT and maps it to an Identity.

Every failure returns *Error, whose Error() is one constant string. The order of the steps below is the security property, not an implementation detail.

Directories

Path Synopsis
Package identitytest is a fake OpenID Provider for the identity verifier's tests and for the API layer's real-token tests (api/identitylane_test.go).
Package identitytest is a fake OpenID Provider for the identity verifier's tests and for the API layer's real-token tests (api/identitylane_test.go).

Jump to

Keyboard shortcuts

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