valiss

package module
v0.5.0 Latest Latest
Warning

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

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

README

valiss

VALidator-ISSuer: decentralized tenant authentication for Go services, gRPC and HTTP, modeled on NATS operator/account/user credentials.

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 takes the NATS approach instead. 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, as in nsc).

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.

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, nsc style)
  • 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 configuration (NATS-resolver style) 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.

Documentation

Overview

Package valiss implements the core of the tenant authentication scheme, modeled on NATS operator/account/user credentials:

  • 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, as in NATS. 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. As in NATS, 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 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. As in NATS, 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
	// 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, matching nsc's default.

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 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), like nsc's --start.

func WithTTL

func WithTTL(ttl time.Duration) IssueOption

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

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
	// 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 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, modeled on the nsc creds format.
Package creds implements the client credentials file: the subject's token plus the seed that signs its requests, modeled on the nsc creds format.
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 (NATS style).
Command minter issues valiss tenant credentials for gRPC and HTTP services using the operator/account/user nkey model (NATS style).

Jump to

Keyboard shortcuts

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