valiss

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Jul 10, 2026 License: MIT Imports: 13 Imported by: 0

README

valiss

VALidator-ISSuer: decentralized tenant authentication for Go services, gRPC and HTTP, built on a three-level chain of Ed25519 keys: operator → account → user.

Most multi-tenant services end up with a central auth dependency: an OAuth provider, a session store, a per-tenant key registry — something every request has to consult and every deployment has to keep alive. valiss inverts that. Trust is a single public key baked into the server; everything else is self-contained signed credentials that verify offline. There is no auth service to run, no token introspection endpoint to call, and issuing credentials never touches production infrastructure.

Where it fits:

  • Multi-tenant APIs. Each customer is an account with its own keypair. You sign one account credential per customer; they mint their own user and service credentials from it, scoped down as they see fit, without asking you.
  • Machine-to-machine auth. Services and agents authenticate with keys and per-request signatures — no shared secrets, no password rotation, and a stolen token is useless without the key that signs requests.
  • Edge and on-prem. Verifiers need only the operator public key (plus an allowlist and, optionally, account tokens in static config), so isolated deployments authenticate the same way as connected ones.
  • Browsers and other weak clients. Short-lived bearer user tokens cover clients that cannot hold a key.

How it works, in one paragraph: an operator key signs account (tenant) tokens; an account key signs user tokens; every request is signed by the subject's own key over a timestamp. The server verifies the chain against the pinned operator key, checks the account token against a revocation allowlist, and hands the tenant (and user) identity to the handler. Delegation is real: revoking one account cuts off everything under it, and an account can never grant more than it holds.

Key types map to nkeys directly: operator SO.../O..., account SA.../A..., user SU.../U.... Tokens are valiss's own typed claims in an nkey-signed JWT: sub is the subject's public key, name the human-readable label, validity via optional absolute exp/nbf (absent exp = never expires).

Rotation and mass revocation

The operator can publish a self-signed operator token: a policy statement over the trust domain, carrying an epoch counter and an optional validity window. Verifiers configured with it accept only account and user tokens stamped with the current epoch:

opTok, _ := valiss.IssueOperator(operator, valiss.WithEpoch(3))
acct, _  := valiss.Issue(operator, "acme", acctPub, valiss.WithEpoch(3), ...)

verifier := valiss.NewVerifier(operatorPub, allowlist,
    valiss.WithOperatorToken(opTok))

Bumping the epoch and re-minting rotates the whole domain: every token from earlier epochs is rejected cryptographically, no allowlist edits. The operator token's own exp bounds the entire domain, forcing a periodic rotation ceremony. The pinned public key remains the trust anchor — the operator token only carries policy signed by it. Selective revocation stays with the allowlist; the two levers are complementary.

Extensions

All authorization rides named extension claims: signed, typed payloads under the token's ext field. An extension is any struct with an ExtensionName() string method; valiss signs and transports it opaquely, and the same concrete type comes back out on the server. Transport authorization is built on this mechanism:

  • contrib/httpauth defines Ext{Hosts, Methods, Paths} (name http): the middleware rejects requests outside the extension's bounds with 403.
  • contrib/grpcauth defines Ext{Methods} (name grpc): the interceptors reject methods outside the extension with PermissionDenied.

Transport enforcement is fail-closed: every token in the chain must carry the transport extension, an empty grant list grants nothing, and allow-all is the explicit wildcard (Methods: ["*"], Paths: ["*"]). Deployments that authorize entirely outside the transport can opt out with AllowMissingExtension() on the authenticator/middleware. Extensions present on both chain levels are both enforced, so an account-level extension bounds every user of the account.

Domain claims work the same way. Define the type, mint it into the token, recover it in the handler:

// The set of filters this application enforces on data queries.
type QueryFilters struct {
    Regions []string `json:"regions"`
}

func (QueryFilters) ExtensionName() string { return "acme.filters" }

// Mint: transport bounds plus domain claims, typed end to end.
tok, _ := valiss.IssueUser(account, "alice", alicePub,
    valiss.WithExtension(grpcauth.Ext{Methods: []string{"/example.v1.Widgets/*"}}),
    valiss.WithExtension(QueryFilters{Regions: []string{"eu"}}),
    valiss.WithTTL(time.Hour),
)

// Handler: the concrete type back, no string plumbing.
id, _ := valiss.IdentityFromContext(ctx)
filters, ok, err := valiss.ExtOf[QueryFilters](id.User.Ext)

Optionally validate extensions at auth time instead of in handlers: valiss.WithExtensionType[QueryFilters]() on the verifier rejects requests whose tokens carry a malformed acme.filters claim, and valiss.ExtValidator(func(req, id, acct, user QueryFilters) error { ... }) runs typed domain checks inside the verification pipeline.

Bearer credentials: a user token minted with valiss.WithBearer() authenticates without a per-request signature (token-only, replayable); pair with TLS and a short validity window. Accounts never get bearer tokens.

Layout

  • root (github.com/mikluko/valiss) — token issue/verify (account and user level), request sign/verify, allowlist, the request Verifier, extension plumbing, and IdentityFromContext
  • creds — client creds file (tokens + seed in one marker-delimited file)
  • contrib/httpauth — net/http middleware, client transport, HTTP extension
  • contrib/grpcauth — gRPC interceptors, per-RPC credentials, gRPC extension
  • examples/ — runnable end-to-end demos, including the manifest-driven examples/minter credential minting tool

Library

Server (gRPC):

verifier := valiss.NewVerifier(operatorPubKey, allowlist)
auth := grpcauth.NewAuthenticator(verifier)
srv := grpc.NewServer(
    grpc.UnaryInterceptor(auth.UnaryInterceptor()),
    grpc.StreamInterceptor(auth.StreamInterceptor()),
)
// in a handler:
id, _ := valiss.IdentityFromContext(ctx) // id.Account.Name segments data,
                                         // id.User names the end user (nil
                                         // for account-level requests)

Client (gRPC):

c, _ := creds.Load("alice.creds")
rpcCreds, _ := grpcauth.NewCredentials(c)
conn, _ := grpc.NewClient(addr, grpc.WithPerRPCCredentials(rpcCreds), ...)

Server (HTTP):

mw := httpauth.NewMiddleware(valiss.NewVerifier(operatorPubKey, allowlist))
srv := &http.Server{Handler: mw(mux)}

Client (HTTP):

c, _ := creds.Load("alice.creds")
transport, _ := httpauth.NewTransport(c, nil)
client := &http.Client{Transport: transport}

Runnable versions: go run ./examples/grpcauth, go run ./examples/httpauth.

Servers that hold account tokens in static configuration accept user-token-only requests via valiss.WithAccountTokenResolver(valiss.StaticAccountTokens(...)).

Minter

examples/minter is a manifest-driven credential minting tool (and a worked example of the issuance API). Stateless: key pairs print once and are never stored; signing seeds come from VALISS_SEED_<PUBKEY> environment variables.

go run ./examples/minter keygen operator     # public key = server trust anchor
go run ./examples/minter keygen account      # per-tenant key pair
go run ./examples/minter keygen user         # per-end-user key pair
go run ./examples/minter creds ACCOUNT[/USER]  # mint creds for a manifest entry

creds reads minter.yaml (annotated example in the directory), resolves seeds from the environment, and writes the creds to stdout with metadata — including the allowlist jti — on stderr. User creds carry only the user token by default; -bundle embeds a fresh account token for servers without a resolver. See examples/minter/README.md.

Prior art

valiss stands on well-trodden ground; these shaped it or solve neighboring problems:

  • NATS JWT authentication and nsc — the operator/account/user delegation model and the creds-file format originate here; valiss adapts them to request/response services and strips the messaging-specific parts.
  • RFC 7519 (JWT) — the claims vocabulary (iss, sub, exp, nbf, iat, jti) and token framing.
  • RFC 8032 (Ed25519) — the signature scheme, via nkeys.
  • golang-jwt — the embedded registered-claims struct pattern valiss's typed claims follow.
  • Biscuit and Macaroons — offline attenuation and delegation of bearer credentials; valiss trades their in-token attenuation for a fixed two-hop chain plus typed extension claims.
  • SPIFFE/SPIRE — workload identity with short-lived documents and trust-domain semantics; heavier machinery aimed at infrastructure identity rather than tenant credentials.

Documentation

Overview

Package valiss implements the core of the tenant authentication scheme, a three-level chain of Ed25519 keys:

  • An operator holds an Ed25519 nkey; its public key is the trust anchor servers pin.
  • The operator signs each tenant an account token that the tenant's own account nkey must back. Issued account tokens are recorded in a server-side allowlist.
  • A tenant may delegate: it signs user tokens with its account seed.
  • The subject signs every request with its nkey; the server verifies the token chain up to the pinned operator key, the request signature against the token's subject key, and the account token against the allowlist, then hands the verified identity to the handler for data segmentation.

Tokens are this scheme's own typed claims carried in an nkey-signed JWT: the sub claim is the subject's public key and name carries the human-readable label. Key levels are strict: operator keys (SO.../O...) sign account tokens for account keys (SA.../A...), account keys sign user tokens for user keys (SU.../U...).

Authorization rides named extension claims (Extension, WithExtension): typed payloads the scheme signs and transports but assigns no meaning. The contrib transports enforce their extensions (HTTP hosts/methods/paths, gRPC methods); consumers add domain extensions the same way and read them back in handlers with ExtOf.

Index

Constants

View Source
const (
	HeaderAccountToken = "valiss-account-token"
	HeaderUserToken    = "valiss-user-token"
	HeaderTimestamp    = "valiss-timestamp"
	HeaderSignature    = "valiss-signature"
)

Header field names carrying the credential on each request. Used as gRPC metadata keys and HTTP header names alike.

View Source
const DefaultSkew = 2 * time.Minute

DefaultSkew bounds request-timestamp drift and token-expiry slack.

Variables

This section is empty.

Functions

func ContextWithIdentity

func ContextWithIdentity(ctx context.Context, id *Identity) context.Context

ContextWithIdentity returns a context carrying the verified identity. Transport middlewares call this after verification.

func Covered

func Covered(granted []string, required string) bool

Covered reports whether any granted pattern covers the required value, honoring trailing-"*" prefix wildcards. Transport extensions use it for paths and methods.

func ExtOf

func ExtOf[T Extension](exts Extensions) (T, bool, error)

ExtOf decodes the extension claim named by T's zero value. An absent name yields the zero value and false.

func Issue

func Issue(operator nkeys.KeyPair, name, tenantPubKey string, opts ...IssueOption) (string, error)

Issue mints an account token signed by the operator key. The token subject is the tenant's account public key and name carries the tenant id; the tenant signs requests with the seed matching the subject key.

func IssueOperator added in v0.6.0

func IssueOperator(operator nkeys.KeyPair, opts ...IssueOption) (string, error)

IssueOperator mints the self-signed operator token: the trust domain's policy statement (epoch, validity window, extensions), signed by the operator key over its own public key. Servers configured with it via WithOperatorToken enforce the policy on every request; the pinned public key remains the trust anchor.

func IssueUser

func IssueUser(account nkeys.KeyPair, name, userPubKey string, opts ...IssueOption) (string, error)

IssueUser mints a user token signed by a tenant's account key, delegating to an end user. The token subject is the user's public key and name carries the user id. WithBearer produces a token the server accepts without per-request signatures.

func IssuerOf

func IssuerOf(token string) (string, error)

IssuerOf returns the public key that signed a token, after checking the token's own signature against it. It does not establish trust: the caller must still verify the issuer's place in the chain.

func SignRequest

func SignRequest(tenant nkeys.KeyPair, now time.Time) (timestamp, signature string, err error)

SignRequest produces the timestamp and base64 signature a tenant attaches to a request, signing with its nkey seed.

func VerifySignature

func VerifySignature(tenantPubKey, timestamp, signature string, now time.Time, skew time.Duration) error

VerifySignature checks a request signature against the tenant public key and bounds the timestamp to a symmetric skew window around now.

Types

type AccountClaims

type AccountClaims struct {
	Claims
	// Name is the tenant's human-readable label; it segments all stored
	// data. Falls back to the subject key when the token carries no name.
	Name string
	// Epoch is the trust-domain epoch the token was issued in (WithEpoch).
	Epoch uint64
	// Ext carries the named extension claims (WithExtension).
	Ext Extensions
}

AccountClaims is the verified content of an account (tenant) token.

func VerifyAccount

func VerifyAccount(token, operatorPubKey string) (*AccountClaims, error)

VerifyAccount decodes an account token, checks its type, signature, and issuer, and returns the claims. It does NOT check expiry, activation, or the allowlist; the Verifier layers those so callers get precise errors.

type AccountTokenResolver

type AccountTokenResolver func(accountPubKey string) (string, error)

AccountTokenResolver supplies the operator-signed account token for an account public key, serving requests that carry only a user token (the default creds shape). The resolved token goes through the full verification: operator signature, expiry, allowlist.

func StaticAccountTokens

func StaticAccountTokens(tokens ...string) (AccountTokenResolver, error)

StaticAccountTokens builds a resolver over a fixed token set, e.g. from server configuration. Tokens are indexed by their subject account key; their signatures are checked here, their trust is established per request.

type AllowAll

type AllowAll struct{}

AllowAll accepts every token; for local development where no allowlist is configured. The token signature and expiry still gate access.

func (AllowAll) Allowed

func (AllowAll) Allowed(string) bool

type Allowlist

type Allowlist interface {
	Allowed(jti string) bool
}

Allowlist decides whether an issued token (by jti) is still accepted. Only tokens the issuer explicitly deposited server-side pass, so a token can be revoked by removing it even before expiry.

type Claims

type Claims struct {
	// ID is the token's unique identifier (jti), the allowlist key for
	// account tokens.
	ID string
	// Issuer is the issuer public key that signed the token (iss).
	Issuer string
	// Subject is the subject's nkey public key (sub) that must sign
	// requests.
	Subject string
	// IssuedAt is the token mint time (iat).
	IssuedAt time.Time
	// ExpiresAt is the token expiry (exp); zero means the token never
	// expires.
	ExpiresAt time.Time
	// NotBefore is the token activation time (nbf); zero means immediately
	// valid.
	NotBefore time.Time
}

Claims is the verified RFC 7519 registered-claims content of a token. Level-specific data lives on AccountClaims and UserClaims, which embed it.

func Decode

func Decode(token string) (*Claims, error)

Decode parses a token of either level without establishing trust: the signature is checked against the token's own embedded issuer only. For inspection and tooling; servers must use VerifyAccount or VerifyUser.

func (*Claims) Expired

func (c *Claims) Expired(now time.Time, skew time.Duration) bool

Expired reports whether the token has passed its expiry (with skew slack).

func (*Claims) NotYetValid

func (c *Claims) NotYetValid(now time.Time, skew time.Duration) bool

NotYetValid reports whether the token's not-before still lies in the future (with skew slack).

type ClaimsValidator

type ClaimsValidator func(req Request, id *Identity) error

ClaimsValidator is custom validation logic injected into the Verifier. It runs after the token chain is verified and the identity is assembled, and before the request signature check. A non-nil error rejects the request as unauthenticated.

func ExtValidator

func ExtValidator[T Extension](fn func(req Request, id *Identity, acct, user T) error) ClaimsValidator

ExtValidator adapts a typed validator over the extension claim named by T's zero value into a ClaimsValidator: the extension is decoded from both the account and user tokens before fn runs. Missing extensions pass zero values.

type Extension

type Extension interface {
	ExtensionName() string
}

Extension is a named claim payload carried in a token's ext field. Implementations are value struct types whose zero value reports the name.

type Extensions

type Extensions map[string]json.RawMessage

Extensions is the named extension claims of a token: consumer- or transport-defined claim bodies keyed by extension name. This scheme signs and transports them; meaning is assigned by whoever registered the name (e.g. the httpauth and grpcauth packages, or the library consumer).

type Identity

type Identity struct {
	// Account is the tenant the request acts under; always present.
	Account *AccountClaims
	// User is the delegated end user; nil for account-level requests.
	User *UserClaims
}

Identity is the verified result of a request.

func IdentityFromContext

func IdentityFromContext(ctx context.Context) (*Identity, bool)

IdentityFromContext returns the verified identity a handler uses to segment data. The bool is false on unauthenticated contexts.

type IssueOption

type IssueOption func(*issueConfig)

IssueOption customizes a minted token. Without a WithTTL or WithExpiry option the token never expires.

func WithBearer

func WithBearer() IssueOption

WithBearer marks a user token as a bearer token: the server accepts it without per-request signatures. Bearer tokens are replayable until they expire or their account leaves the allowlist, so pair them with TLS and a short validity window. Only IssueUser accepts this option.

func WithEpoch added in v0.6.0

func WithEpoch(epoch uint64) IssueOption

WithEpoch stamps the trust-domain epoch on the token. On an operator token it declares the domain's current epoch; on account and user tokens it must echo it. Verifiers configured with the operator token reject tokens from any other epoch, so bumping the epoch and re-minting rotates the whole domain at once. Unstamped tokens are epoch 0.

func WithExpiry

func WithExpiry(t time.Time) IssueOption

WithExpiry makes the token expire at t (the JWT exp claim).

func WithExtension

func WithExtension(v Extension) IssueOption

WithExtension embeds a named extension claim into the token's ext field; the name comes from the value's ExtensionName. Repeat the option for multiple extensions; a duplicate name is an error. The scheme signs and transports the value untouched; servers read it back with ExtOf or validate it with an ExtValidator.

func WithNotBefore

func WithNotBefore(t time.Time) IssueOption

WithNotBefore makes the token invalid before t (the JWT nbf claim).

func WithTTL

func WithTTL(ttl time.Duration) IssueOption

WithTTL makes the token expire ttl from now (the JWT exp claim).

type OperatorClaims added in v0.6.0

type OperatorClaims struct {
	Claims
	// Epoch is the trust domain's current epoch. A verifier configured with
	// the operator token accepts only account and user tokens that echo it.
	Epoch uint64
	// Ext carries the named extension claims (WithExtension).
	Ext Extensions
}

OperatorClaims is the verified content of a self-signed operator token: the trust domain's policy statement, signed by the pinned anchor key.

func VerifyOperator added in v0.6.0

func VerifyOperator(token, operatorPubKey string) (*OperatorClaims, error)

VerifyOperator decodes a self-signed operator token, checks its type and that it is signed by the pinned operator key over itself, and returns the claims. Expiry and activation checks belong to the Verifier.

type Request

type Request struct {
	// AccountToken is the operator-signed account token.
	AccountToken string
	// UserToken is the account-signed user token on chain credentials; empty
	// when the tenant itself makes the request.
	UserToken string
	// Timestamp and Signature are the per-request signing proof; both empty
	// on bearer requests.
	Timestamp string
	Signature string
}

Request is the per-request material a transport extracts from headers.

type StaticAllowlist

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

StaticAllowlist is an in-memory set of accepted token IDs.

func LoadAllowlistFile

func LoadAllowlistFile(path string) (*StaticAllowlist, error)

LoadAllowlistFile reads a newline-delimited allowlist file of token IDs. Blank lines and lines beginning with '#' are ignored.

func NewStaticAllowlist

func NewStaticAllowlist(ids ...string) *StaticAllowlist

func (*StaticAllowlist) Allowed

func (a *StaticAllowlist) Allowed(jti string) bool

func (*StaticAllowlist) Set

func (a *StaticAllowlist) Set(ids []string)

Set replaces the accepted set, e.g. after reloading the file.

type UserClaims

type UserClaims struct {
	Claims
	// Name is the user's human-readable label. Falls back to the subject
	// key when the token carries no name.
	Name string
	// Epoch is the trust-domain epoch the token was issued in (WithEpoch).
	Epoch uint64
	// Bearer marks a token whose holder authenticates by the token alone,
	// without per-request signatures.
	Bearer bool
	// Ext carries the named extension claims (WithExtension).
	Ext Extensions
}

UserClaims is the verified content of a user token.

func VerifyUser

func VerifyUser(token, accountPubKey string) (*UserClaims, error)

VerifyUser decodes a user token, checks its type, signature, and issuer (the account public key that delegated it), and returns the claims. Expiry and activation checks belong to the Verifier.

type Verifier

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

Verifier checks the full per-request credential: account token signature against the pinned operator key, expiry and activation, allowlist membership, the optional user-token chain, registered extension types, custom validators, and the request signature within the skew window. Requests without a signature pass only when the effective token is a bearer user token. Transport layers (gRPC interceptor, HTTP middleware) wrap it with header extraction and error mapping.

func NewVerifier

func NewVerifier(operatorPubKey string, allowlist Allowlist, opts ...VerifierOption) *Verifier

func (*Verifier) VerifyRequest

func (v *Verifier) VerifyRequest(req Request) (*Identity, error)

VerifyRequest authenticates a request credential and returns the verified identity. Any error means the request must be rejected as unauthenticated.

A credential with a user token is verified as a chain: the account token against the operator key and the allowlist, then the user token against the account token's subject key. An empty timestamp and signature is a bearer request, accepted only when the effective token is a bearer user token; account-level requests must always sign.

type VerifierOption

type VerifierOption func(*Verifier)

VerifierOption configures a Verifier.

func WithAccountTokenResolver

func WithAccountTokenResolver(fn AccountTokenResolver) VerifierOption

WithAccountTokenResolver accepts requests that carry only a user token, resolving the account token server-side. Without it such requests are rejected.

func WithClaimsValidator

func WithClaimsValidator(fn ClaimsValidator) VerifierOption

WithClaimsValidator injects custom validation into the verification pipeline. Validators run in registration order; the first error wins.

func WithClock

func WithClock(now func() time.Time) VerifierOption

WithClock overrides the time source; for tests.

func WithExtensionType

func WithExtensionType[T Extension]() VerifierOption

WithExtensionType registers an extension type for eager validation: when either token carries the extension named by T's zero value, it must decode into T or the request is rejected. Retrieval via ExtOf never requires registration; this only moves malformed-extension failures to auth time.

func WithOperatorToken added in v0.6.0

func WithOperatorToken(token string) VerifierOption

WithOperatorToken supplies the trust domain's self-signed operator token and enforces its policy on every request: the operator token must be within its own validity window, and every account and user token must echo its epoch. Bumping the epoch in a fresh operator token therefore revokes everything minted in earlier epochs at once. The pinned operator public key remains the trust anchor; a token not self-signed by it poisons the verifier so every request fails rather than silently skipping policy.

func WithSkew

func WithSkew(d time.Duration) VerifierOption

WithSkew overrides the DefaultSkew window for timestamp drift and token expiry slack.

Directories

Path Synopsis
contrib
grpcauth
Package grpcauth wires the tenant authentication scheme into gRPC: server interceptors that verify the per-request credential and enforce the gRPC extension claim (Ext), and a client per-RPC credential that attaches the credential.
Package grpcauth wires the tenant authentication scheme into gRPC: server interceptors that verify the per-request credential and enforce the gRPC extension claim (Ext), and a client per-RPC credential that attaches the credential.
httpauth
Package httpauth wires the tenant authentication scheme into net/http: a server middleware that verifies the per-request credential and enforces the HTTP extension claim (Ext), and a client http.RoundTripper that attaches the credential.
Package httpauth wires the tenant authentication scheme into net/http: a server middleware that verifies the per-request credential and enforces the HTTP extension claim (Ext), and a client http.RoundTripper that attaches the credential.
Package creds implements the client credentials file: the subject's token plus the seed that signs its requests, in one marker-delimited text file.
Package creds implements the client credentials file: the subject's token plus the seed that signs its requests, in one marker-delimited text file.
examples
grpcauth command
Example grpcauth shows the full tenant-auth wiring for gRPC: an operator issues an account token, the server installs the auth interceptors, and the client attaches the credential to every call.
Example grpcauth shows the full tenant-auth wiring for gRPC: an operator issues an account token, the server installs the auth interceptors, and the client attaches the credential to every call.
httpauth command
Example httpauth shows the full tenant-auth wiring for net/http: an operator signs an account token carrying an HTTP extension, the server wraps its mux with the auth middleware, and the client signs every request via the transport.
Example httpauth shows the full tenant-auth wiring for net/http: an operator signs an account token carrying an HTTP extension, the server wraps its mux with the auth middleware, and the client signs every request via the transport.
minter command
Command minter issues valiss tenant credentials for gRPC and HTTP services using the operator/account/user nkey model.
Command minter issues valiss tenant credentials for gRPC and HTTP services using the operator/account/user nkey model.

Jump to

Keyboard shortcuts

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