auth

package
v0.3.1 Latest Latest
Warning

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

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package auth issues and verifies the HMAC-SHA256 JWTs that gate access to Parsec. There are three token types, distinguished by the typ claim:

  • access — short-lived, used to connect over the websocket and to subscribe to listed private channels
  • refresh — exchanged at the RefreshToken RPC for a fresh access token
  • mgmt — operator token presented as Authorization: Bearer on the management RPC surface

The wire format is the standard JWT compact serialization (base64url(header).base64url(claims).base64url(hmac)), but Parsec uses a fixed header — alg=HS256, typ=JWT — and refuses any other.

No JWT library: the implementation is ~150 lines of stdlib crypto/hmac and crypto/sha256.

Index

Constants

View Source
const KeyringFileName = "keyring.json"

KeyringFileName is the conventional file name inside the state dir.

Variables

View Source
var (
	ErrMalformedToken   = errors.New("auth: malformed token")
	ErrInvalidSignature = errors.New("auth: signature does not verify")
	ErrUnsupportedAlg   = errors.New("auth: unsupported algorithm")
	ErrExpired          = errors.New("auth: token expired")
	ErrNotYetValid      = errors.New("auth: token not yet valid")
	ErrTypeMismatch     = errors.New("auth: token type mismatch")
	ErrChannelMismatch  = errors.New("auth: token does not authorize this channel")
)

Sentinel errors. Callers errors.Is against these instead of string-matching.

View Source
var (
	// ErrRefreshReused signals that a refresh JTI was presented for
	// redemption after it had already been redeemed. The caller MUST
	// follow up with RevokeFamily on the FID and refuse the request.
	ErrRefreshReused = errors.New("auth: refresh token already redeemed")
	// ErrFamilyRevoked signals that the refresh's family was already
	// marked revoked. The caller refuses the request.
	ErrFamilyRevoked = errors.New("auth: refresh family revoked")
)

Sentinel errors returned by RefreshStore implementations.

AllVerbs lists every recognized verb in stable order. Used by the manifest so client SDKs can discover the surface.

Functions

func GenerateSecret

func GenerateSecret() ([]byte, error)

GenerateSecret returns a 32-byte cryptographically random secret suitable for Signer / Verifier. The caller persists it (env var, secrets store); regenerating wipes every previously-issued token.

func JWKSHandler added in v0.3.0

func JWKSHandler(ring *KeyRing) http.Handler

JWKSHandler returns an http.Handler that serves the asymmetric public keys in ring as a JWKS document (RFC 7517). HMAC keys are NEVER exposed — they are shared secrets, not verifying material a third party should hold. Retired keys are omitted; the active key is included.

The handler is read-only and unauthenticated by design: a JWKS endpoint must be reachable by any party that needs to verify tokens the operator has issued. Mount it on an internet-reachable path only when the keys it advertises are intended for external verification.

func MapErr

func MapErr(err error) error

MapErr translates an auth sentinel into a parsec coded error. Exposed so the library facade and surface code can reuse the mapping.

func NewSubscribeAuthorizer

func NewSubscribeAuthorizer(v *Verifier) func(ctx context.Context, userID string, ch channels.Name, event centrifuge.SubscribeEvent) error

NewSubscribeAuthorizer returns a broker subscribe authorizer that allows any well-formed public channel and verifies an access token for any private channel. The token must list the requested channel in its chs claim.

The returned function matches broker.SubscribeAuthorizer.

func NewSubscribeAuthorizerWithGate added in v0.3.0

func NewSubscribeAuthorizerWithGate(v *Verifier, gate SubscribeRateGate) func(ctx context.Context, userID string, ch channels.Name, event centrifuge.SubscribeEvent) error

NewSubscribeAuthorizerWithGate is the generalized form: the rate-limit gate is supplied as a callback so the caller can resolve per-channel rules (or any other policy) instead of being constrained to a single flat per-subject Limit. Pass gate == nil to disable the gate.

func NewSubscribeAuthorizerWithLimiter

func NewSubscribeAuthorizerWithLimiter(v *Verifier, limiter ratelimit.Limiter, defaultLimit ratelimit.Limit) func(ctx context.Context, userID string, ch channels.Name, event centrifuge.SubscribeEvent) error

NewSubscribeAuthorizerWithLimiter is NewSubscribeAuthorizer plus a per-key rate-limit gate that runs BEFORE token verification (so a stream of bad-token attempts cannot exhaust CPU on HMAC verifies).

The key is the userID when set, otherwise the centrifuge client ID (which encodes the connection — best-effort proxy for IP when running behind the centrifuge transport).

Passing limiter == nil or a zero Limit reverts to the no-rate-limit behaviour.

func ReloadInto

func ReloadInto(path string, ring *KeyRing) error

ReloadInto re-reads path into the existing ring, swapping its contents atomically. The pointer identity is preserved so any Signer / Verifier already bound to ring picks up the new keys.

func SaveKeyRing

func SaveKeyRing(path string, r *KeyRing) error

SaveKeyRing writes the ring's snapshot to path, atomically. The file is created with mode 0600; the parent directory is created with mode 0700 if missing.

func WatchKeyRingFile

func WatchKeyRingFile(ctx context.Context, path string, ring *KeyRing, interval time.Duration, onReload func(activeID string), onError func(error))

WatchKeyRingFile polls path's mtime every interval; when it changes, reload is called with the new contents. The function blocks until ctx is canceled. interval == 0 disables polling (returns immediately).

onReload is invoked on every successful reload (after the snapshot swap). Errors during reload are passed to onError; the watcher keeps running so a transient parse failure does not kill the loop.

Types

type Alg added in v0.3.0

type Alg string

Alg names a token-signing algorithm. The default is HS256 (HMAC over a 32-byte secret); RS256, EdDSA, ES256, and ES384 are the asymmetric alternatives. The set is closed — adding a new alg requires a verifier branch and snapshot-format support.

const (
	AlgHS256 Alg = "HS256"
	AlgRS256 Alg = "RS256"
	AlgEdDSA Alg = "EdDSA"
	AlgES256 Alg = "ES256"
	AlgES384 Alg = "ES384"
)

func SupportedAlgs added in v0.3.0

func SupportedAlgs() []Alg

SupportedAlgs returns the algs Parsec can sign and verify, in the preferred order operators see them in CLI help. Exposed for the manifest layer.

func (Alg) IsAsymmetric added in v0.3.0

func (a Alg) IsAsymmetric() bool

IsAsymmetric reports whether a is one of the public-key algs. HMAC keys are symmetric and are NEVER exposed via the JWKS endpoint.

func (Alg) Valid added in v0.3.0

func (a Alg) Valid() error

Valid returns nil if a is one of the supported algs.

type Claims

type Claims struct {
	// Sub is the subject — user id for client tokens, operator id for mgmt.
	Sub string `json:"sub,omitempty"`
	// Typ is the Parsec token type discriminator.
	Typ Type `json:"typ"`
	// Chs is the list of channels this token authorizes. Empty means
	// "no client-channel authorization." For mgmt tokens it is ignored.
	Chs []string `json:"chs,omitempty"`
	// Iat is the issued-at time (Unix seconds).
	Iat int64 `json:"iat"`
	// Exp is the expiration time (Unix seconds).
	Exp int64 `json:"exp"`
	// RateLimitOverride, when non-nil, overrides the server's default
	// per-subject rate limit for this token. Useful to hand a noisy
	// integration a tighter budget without touching server config. The
	// claim is private to Parsec (no JWT-RFC equivalent); absence means
	// "use the operator-configured default."
	RateLimitOverride *ratelimit.Limit `json:"rl,omitempty"`
	// Scopes is the pattern-based grant set. Each Scope binds a
	// channel-name Pattern to a set of Verbs (subscribe, publish,
	// manage). Empty means "no pattern grants" — only the Chs exact
	// list applies. A token carrying both Chs and Scopes is authorized
	// for the union.
	Scopes []Scope `json:"scopes,omitempty"`
	// JTI is the per-token unique identifier. Present on refresh
	// tokens minted by post-rotation Issuers; an empty JTI marks a
	// legacy token that bypasses rotation (back-compat path). The
	// rotation store tracks redeemed JTIs to detect refresh-token
	// reuse, which triggers revocation of the entire family.
	JTI string `json:"jti,omitempty"`
	// FID is the rotation family ID — every refresh in a rotation
	// chain shares it. When reuse of any one refresh is detected,
	// the store marks the FID revoked so descendants and siblings
	// are rejected even before their JTIs are looked up. Absent on
	// legacy tokens (pre-rotation issuance).
	FID string `json:"fid,omitempty"`
}

Claims is the Parsec payload. Fields use JWT-conventional short names so the wire shape is compact.

func (Claims) Authorizes

func (c Claims) Authorizes(channel string, v Verb) bool

Authorizes reports whether the holder is granted verb v on the named channel. The check unions two grant sources, with deny-wins precedence applied uniformly:

  • Chs (exact match): any name in c.Chs authorizes every verb.
  • Scopes (pattern match): each Scope grants (or, when Deny=true, subtracts) its listed verbs on channel names that match its Pattern.

Evaluation order:

  1. Walk the Scopes for any deny scope whose pattern matches and whose verb list contains v — if found, return false immediately ("deny wins", consistent with AWS IAM and similar systems). Denies override both Chs entries and overlapping allow scopes.
  2. Walk c.Chs for an exact-name match.
  3. Walk the Scopes for an allow scope whose pattern matches and whose verb list contains v.
  4. Otherwise return false.

channel is the raw wire-form channel name; it is parsed once and compared against any applicable Scope. A malformed channel name returns false (fail closed). An empty c.Chs and empty c.Scopes return false for every input.

A Scope with Deny=true is visible in the token like any other claim — operators should not treat deny patterns as confidential channel metadata (see docs/src/channels/acl.md).

func (Claims) ExpiresAt

func (c Claims) ExpiresAt() time.Time

ExpiresAt returns Exp as a time.Time.

func (Claims) IssuedAt

func (c Claims) IssuedAt() time.Time

IssuedAt returns Iat as a time.Time.

type CompositeVerifier

type CompositeVerifier struct {
	// HMAC is the parsec-issued JWT verifier. Required.
	HMAC *Verifier

	// OIDC validates ID tokens from a configured IdP. Optional —
	// when nil the composite is HMAC-only.
	OIDC *OIDCVerifier
}

CompositeVerifier tries the parsec HMAC verifier first; if that fails AND an OIDC verifier is configured, it falls back to OIDC. A success from either path wins. When both fail, the HMAC error is returned (the HMAC path is the primary verification surface — its error code maps cleanly onto PARSEC_AUTH_*).

A nil OIDC field reduces this to a pass-through over HMAC, so deployments without OIDCConfig accept only HMAC-signed tokens.

func NewCompositeVerifier

func NewCompositeVerifier(hmac *Verifier, oidc *OIDCVerifier) (*CompositeVerifier, error)

NewCompositeVerifier returns a verifier that composes an HMAC verifier with an optional OIDC verifier. hmac must be non-nil (parsec.New always constructs one).

func (*CompositeVerifier) HMACVerify

func (c *CompositeVerifier) HMACVerify(token string, expected Type) (Claims, error)

HMACVerify validates token strictly via the HMAC path. Useful for surface code that must reject OIDC-shaped tokens (e.g. refresh-token RPC, which has no OIDC analog).

func (*CompositeVerifier) OIDCEnabled

func (c *CompositeVerifier) OIDCEnabled() bool

OIDCEnabled reports whether an OIDC verifier is wired.

func (*CompositeVerifier) Verify

func (c *CompositeVerifier) Verify(ctx context.Context, token string, expected Type) (Claims, error)

Verify tries the HMAC verifier first; on any failure, and if OIDC is wired, it tries the OIDC verifier. The first success wins. When both fail, the HMAC error is returned because it is the more specific path (operator-minted tokens dominate inbound traffic on a typical deployment).

Verify is safe for concurrent use. The expected Type is enforced against the HMAC payload; OIDC tokens always synthesize Typ=TypeMgmt and so are only honored when expected is empty or TypeMgmt.

type FileKeyRingStore

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

FileKeyRingStore reads and writes the KeyRing as a JSON file at <state-dir>/keyring.json. Single-node deployments use this; multi-node deployments swap for RedisKeyRingStore.

func NewFileKeyRingStore

func NewFileKeyRingStore(path string, pollEvery time.Duration) *FileKeyRingStore

NewFileKeyRingStore constructs a store at path. pollEvery controls how frequently Watch checks the file's mtime. Pass 0 to disable polling (Watch becomes a no-op).

func (*FileKeyRingStore) Ensure

func (s *FileKeyRingStore) Ensure(ctx context.Context) (*KeyRing, bool, error)

Ensure satisfies the bootstrap pattern: load or generate a fresh ring, persist, return.

func (*FileKeyRingStore) Load

func (s *FileKeyRingStore) Load(_ context.Context) (*KeyRing, error)

Load reads the file.

func (*FileKeyRingStore) Path

func (s *FileKeyRingStore) Path() string

Path returns the on-disk path.

func (*FileKeyRingStore) Save

func (s *FileKeyRingStore) Save(_ context.Context, r *KeyRing) error

Save writes the ring atomically.

func (*FileKeyRingStore) Watch

func (s *FileKeyRingStore) Watch(ctx context.Context, onChange func(*KeyRing)) error

Watch polls the file's mtime and re-loads on change.

type Issuer

type Issuer struct {

	// AccessTTL is the lifetime of access tokens minted for clients.
	// Default 5 minutes. Clamped to [1m, 1h].
	AccessTTL time.Duration
	// MaxRefreshTTL is the upper bound on refresh tokens. The actual
	// refresh TTL is min(channelTTL, MaxRefreshTTL). Default 1h.
	MaxRefreshTTL time.Duration
	// contains filtered or unexported fields
}

Issuer mints Parsec JWTs with the right TTL bounds. It wraps a Signer with policy: refresh tokens cannot outlive their channel; access tokens cannot outlive their refresh token.

func NewIssuer

func NewIssuer(signer *Signer) *Issuer

NewIssuer constructs an Issuer over signer.

func (*Issuer) IssueAccess

func (i *Issuer) IssueAccess(sub, channel string, refreshExp time.Time, scopes []Scope) (string, time.Time, error)

IssueAccess mints a single access token from a verified refresh. The caller is responsible for verifying the refresh token first; this method trusts its inputs (sub, channel, refreshExp, scopes).

The new access expiry is min(now+AccessTTL, refreshExp) so a refresh can never extend a session past the refresh token's own lifetime.

A nil/empty scopes slice produces a token with no pattern grants — the channel listed in Chs is still authorized for every verb. Each scope's Pattern is validated against the channel grammar before signing.

func (*Issuer) IssueAccessForChannels

func (i *Issuer) IssueAccessForChannels(sub string, chs []string, refreshExp time.Time) (string, time.Time, error)

IssueAccessForChannels mints a fresh access token authorizing chs. The expiry is min(now+AccessTTL, refreshExp).

func (*Issuer) IssueMgmt

func (i *Issuer) IssueMgmt(sub string, ttl time.Duration) (string, time.Time, error)

IssueMgmt mints an operator token. ttl is clamped to [1h, 7d]; default 24h when zero.

func (*Issuer) IssuePair

func (i *Issuer) IssuePair(sub, channel string, channelTTL time.Duration, scopes []Scope) (PairResult, error)

IssuePair mints an access + refresh token pair for sub on the named channel and stamps the provided scope grants into both tokens. The refresh token is bounded by min(channelTTL, MaxRefreshTTL); the access token by min(AccessTTL, refreshTTL).

A nil/empty scopes slice produces tokens with no pattern grants — the channel listed in Chs is still authorized for every verb.

Each scope's Pattern is validated against the channel grammar before signing; an invalid pattern yields PARSEC_INVALID_ARGUMENT.

func (*Issuer) IssuePairForChannels

func (i *Issuer) IssuePairForChannels(sub string, chs []string, ttl time.Duration, scopes []Scope) (PairResult, error)

IssuePairForChannels mints an access + refresh token pair that authorizes the listed channels (rather than a single channel like IssuePair). The refresh TTL is bounded by min(ttl, MaxRefreshTTL); the access TTL by min(AccessTTL, refresh). Used by the token broker for multi-channel issuance.

func (*Issuer) IssuePairWithRateLimit

func (i *Issuer) IssuePairWithRateLimit(sub, channel string, channelTTL time.Duration, override ratelimit.Limit) (PairResult, error)

IssuePairWithRateLimit is IssuePair with an additional per-token rate limit embedded in the access + refresh claims (rl). When the rate limiter is consulted at publish/subscribe time the override beats the server default for this subject.

Passing a zero Limit (Rate == 0) is the same as IssuePair — no override claim is written.

func (*Issuer) IssueRotatedPair added in v0.3.0

func (i *Issuer) IssueRotatedPair(sub, channel, oldFID string, channelTTL time.Duration, scopes []Scope) (PairResult, error)

IssueRotatedPair mints a fresh access + refresh pair that continues an existing rotation family. The new refresh inherits oldFID so the store can revoke the entire chain on reuse; a fresh JTI prevents the new refresh from colliding with the redeemed predecessor.

channelTTL bounds the refresh expiry the same way IssuePair does (min(channelTTL, MaxRefreshTTL)). The caller has already verified the old refresh; this method does NOT itself touch the rotation store — the wrapping parsec.RefreshAccess flow does.

func (*Issuer) SetClock

func (i *Issuer) SetClock(c func() time.Time)

SetClock overrides the time source. Used in tests.

type JWK added in v0.3.0

type JWK struct {
	Kty string `json:"kty"`
	Kid string `json:"kid"`
	Alg string `json:"alg"`
	Use string `json:"use"`
	// EdDSA (OKP) and ECDSA (EC)
	Crv string `json:"crv,omitempty"`
	X   string `json:"x,omitempty"`
	// ECDSA y coordinate (EC only — OKP keys are single-coord)
	Y string `json:"y,omitempty"`
	// RSA
	N string `json:"n,omitempty"`
	E string `json:"e,omitempty"`
}

JWK is one entry. Only the fields relevant to the supported algs are emitted; conformant consumers ignore unknown fields.

type JWKS added in v0.3.0

type JWKS struct {
	Keys []JWK `json:"keys"`
}

JWKS is the wire shape produced by JWKSHandler.

type Key

type Key struct {
	ID        string
	Alg       Alg
	Secret    []byte
	Private   crypto.Signer
	Public    crypto.PublicKey
	Role      Role
	CreatedAt time.Time
	RetiredAt *time.Time
	// contains filtered or unexported fields
}

Key is one signing key with rotation metadata. The material it carries depends on Alg:

  • AlgHS256: Secret is the 32+ byte HMAC secret. Private/Public are nil.
  • AlgRS256: Private is an *rsa.PrivateKey, Public is its *rsa.PublicKey. Secret is nil.
  • AlgEdDSA: Private is an ed25519.PrivateKey, Public is its ed25519.PublicKey. Secret is nil.

Construct via KeyRing methods, never directly — the headerB64 cache is populated at install time.

type KeyRing

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

KeyRing is the live set of HMAC keys parsec uses to sign and verify tokens. Exactly one key holds RoleActive. The ring is safe for concurrent use.

func EnsureKeyRing

func EnsureKeyRing(path string) (*KeyRing, bool, error)

EnsureKeyRing returns a usable KeyRing for path. If the file exists, it is loaded. If not, a fresh ring with one active key is generated and saved. The bool return is true when bootstrap happened.

func LoadKeyRing

func LoadKeyRing(path string) (*KeyRing, error)

LoadKeyRing reads path into a fresh ring. If the file does not exist, returns (nil, os.ErrNotExist) so the caller can decide whether to bootstrap.

func NewKeyRing

func NewKeyRing() *KeyRing

NewKeyRing returns an empty KeyRing.

func NewKeyRingFromSecret

func NewKeyRingFromSecret(secret []byte) (*KeyRing, error)

NewKeyRingFromSecret seeds a ring with one active key whose Secret is the supplied bytes. Convenient for tests that want a stable HMAC across a parsec.New / recreate pair without using a state directory.

func (*KeyRing) Active

func (r *KeyRing) Active() (Key, error)

Active returns a copy of the active key. Errors if no key is active.

func (*KeyRing) ActiveID

func (r *KeyRing) ActiveID() string

ActiveID returns the active key's ID, or "" if there is none.

func (*KeyRing) Add

func (r *KeyRing) Add(id string, secret []byte) (Key, error)

Add installs an HS256 id+secret into the ring. If the ring was empty the new key becomes active; otherwise it joins as verify-only and must be Promoted before it signs.

func (*KeyRing) AddECDSA added in v0.3.0

func (r *KeyRing) AddECDSA(id string, priv *ecdsa.PrivateKey) (Key, error)

AddECDSA installs an ECDSA private key into the ring. The curve must be P-256 or P-384.

func (*KeyRing) AddEd25519 added in v0.3.0

func (r *KeyRing) AddEd25519(id string, priv ed25519.PrivateKey) (Key, error)

AddEd25519 installs an Ed25519 keypair into the ring. Same role semantics as Add.

func (*KeyRing) AddRSA added in v0.3.0

func (r *KeyRing) AddRSA(id string, priv *rsa.PrivateKey) (Key, error)

AddRSA installs an RSA private key into the ring. The caller must have generated a 2048+ bit key.

func (*KeyRing) Generate

func (r *KeyRing) Generate() (Key, error)

Generate creates a fresh HS256 key (32-byte secret) and installs it. Kept for source compatibility with callers that predate the alg-aware API; prefer GenerateAlg for new code.

func (*KeyRing) GenerateAlg added in v0.3.0

func (r *KeyRing) GenerateAlg(alg Alg) (Key, error)

GenerateAlg creates a fresh key of the requested algorithm and installs it as verify-only (or active if the ring was empty). For AlgRS256 the default modulus is 2048 bits — call GenerateRSA for other sizes.

func (*KeyRing) GenerateECDSA added in v0.3.0

func (r *KeyRing) GenerateECDSA(curve elliptic.Curve) (Key, error)

GenerateECDSA creates a fresh ECDSA keypair on curve and installs it. Only P-256 (ES256) and P-384 (ES384) are supported — JOSE does not define a canonical signing form for other curves. P-521 is excluded because variable-length JOSE signatures would complicate the wire format; operators wanting larger keys should use RSA.

func (*KeyRing) GenerateEd25519 added in v0.3.0

func (r *KeyRing) GenerateEd25519() (Key, error)

GenerateEd25519 creates a fresh Ed25519 keypair and installs it.

func (*KeyRing) GenerateRSA added in v0.3.0

func (r *KeyRing) GenerateRSA(bits int) (Key, error)

GenerateRSA creates a fresh RSA keypair of bits modulus and installs it. Valid sizes are 2048, 3072, and 4096; anything else errors so an operator cannot accidentally land a sub-2048 key.

func (*KeyRing) Get

func (r *KeyRing) Get(id string) (Key, error)

Get returns a copy of the key with id, or an error if no such key exists OR the key is retired (callers should treat both the same).

func (*KeyRing) List

func (r *KeyRing) List() []Key

List returns a snapshot of every key in the ring, sorted by CreatedAt. Retired keys are included so the operator can audit recent deletions.

func (*KeyRing) LoadSnapshot

func (r *KeyRing) LoadSnapshot(s Snapshot) error

LoadSnapshot replaces the ring's contents with the snapshot's. Safe to call on a running ring — the swap is atomic from the caller's perspective. Legacy entries (Alg empty / empty PrivatePEM) are read as HS256 + secret_hex.

func (*KeyRing) Promote

func (r *KeyRing) Promote(id string) error

Promote makes id the active key. The previously-active key (if any) transitions to verify-only. Promoting an unknown or retired key errors.

func (*KeyRing) Retire

func (r *KeyRing) Retire(id string) error

Retire marks id as retired. Retiring the active key errors — Promote another key first. Retiring an already-retired key is a no-op.

func (*KeyRing) Snapshot

func (r *KeyRing) Snapshot() Snapshot

Snapshot returns a deep copy of the ring suitable for persistence or transfer. Excludes retired keys.

type KeyRingStore

type KeyRingStore interface {
	// Load returns the persisted ring, or (nil, os.ErrNotExist) when the
	// store is empty so the caller can decide whether to bootstrap.
	Load(ctx context.Context) (*KeyRing, error)

	// Save replaces the persisted snapshot with the supplied ring's.
	Save(ctx context.Context, r *KeyRing) error

	// Watch subscribes to remote modifications. onChange fires with each
	// freshly loaded ring snapshot after a remote write. Blocks until ctx
	// is canceled or the underlying transport errors fatally. File-backed
	// implementations may emit periodically via mtime polling; Redis
	// implementations use pub/sub.
	Watch(ctx context.Context, onChange func(*KeyRing)) error
}

KeyRingStore is the persistence interface for the HMAC KeyRing. Two implementations ship: a file-backed store (single-node) and a Redis- backed store that lets multiple Parsec nodes share the same keys and observe rotation events without manual SIGHUPs.

type MemoryRefreshStore added in v0.3.0

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

MemoryRefreshStore is the single-node RefreshStore. Records are held in maps; a periodic pruner clears entries past their exp. The zero value is unusable — construct via NewMemoryRefreshStore.

func NewMemoryRefreshStore added in v0.3.0

func NewMemoryRefreshStore(interval time.Duration) *MemoryRefreshStore

NewMemoryRefreshStore constructs a MemoryRefreshStore with a background pruner that wakes every interval. interval <= 0 disables background pruning (entries are still cleaned lazily on every read/write path).

func (*MemoryRefreshStore) Close added in v0.3.0

func (s *MemoryRefreshStore) Close()

Close stops the pruner. Safe to call multiple times.

func (*MemoryRefreshStore) IsFamilyRevoked added in v0.3.0

func (s *MemoryRefreshStore) IsFamilyRevoked(_ context.Context, fid string) (bool, error)

IsFamilyRevoked implements RefreshStore.

func (*MemoryRefreshStore) MarkRedeemed added in v0.3.0

func (s *MemoryRefreshStore) MarkRedeemed(_ context.Context, jti string, exp time.Time) error

MarkRedeemed implements RefreshStore.

func (*MemoryRefreshStore) RevokeFamily added in v0.3.0

func (s *MemoryRefreshStore) RevokeFamily(_ context.Context, fid string, exp time.Time) error

RevokeFamily implements RefreshStore.

func (*MemoryRefreshStore) SetClock added in v0.3.0

func (s *MemoryRefreshStore) SetClock(c func() time.Time)

SetClock overrides the time source. Used in tests.

type OIDCConfig

type OIDCConfig struct {
	// Issuer is the IdP's OpenID issuer URL (e.g.
	// "https://accounts.google.com"). NewOIDCVerifier fetches
	// <Issuer>/.well-known/openid-configuration to discover the
	// JWKS endpoint and supported algorithms.
	Issuer string

	// Audience is the expected `aud` claim. Set to the parsec
	// deployment's client identifier (e.g. "parsec-prod"). If empty
	// the verifier rejects every token (defensive — every IdP
	// requires an audience).
	Audience string

	// SubjectClaim names the ID-token claim to copy into the
	// synthetic Claims.Sub field. Defaults to "sub" when empty.
	// Operators frequently set this to "email" so the access log
	// records the operator's address.
	SubjectClaim string

	// ScopesClaim names the ID-token claim that carries the
	// operator's group memberships. Defaults to "groups" when
	// empty. The claim is expected to be a JSON array of strings.
	ScopesClaim string

	// Grants maps IdP group names onto parsec scope patterns. When
	// an incoming token's ScopesClaim contains a group listed here,
	// the matching grant's Scope + Verbs is added to the synthetic
	// Claims.Scopes set. Multiple grants may match a single token.
	Grants []OIDCGrant

	// Clock overrides time.Now for verification, primarily for
	// tests. nil = time.Now.
	Clock func() time.Time
}

OIDCConfig captures the operator-supplied configuration for an OIDC identity provider. Empty Issuer means "OIDC is disabled" — the composite verifier short-circuits past it.

type OIDCGrant

type OIDCGrant struct {
	// IfGroup is the literal group name to match against the
	// token's ScopesClaim list. Comparison is byte-exact — no
	// wildcards, no case folding.
	IfGroup string

	// Scope is the channel-name pattern to grant (parsec scope
	// grammar — see channels.ParsePattern).
	Scope string

	// Verbs is the set of actions the matching operator may take
	// on channels covered by Scope.
	Verbs []Verb
}

OIDCGrant maps one IdP group name onto one parsec scope. When the incoming ID token's group list contains IfGroup, the synthetic Claims gains a Scope with Pattern=Scope and Verbs=Verbs.

type OIDCVerifier

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

OIDCVerifier wraps go-oidc's provider + id-token verifier behind a parsec-friendly Verify call that returns synthetic auth.Claims.

One OIDCVerifier is constructed per parsec.New (issuer discovery happens at boot, NOT per request). The underlying *oidc.IDTokenVerifier is safe for concurrent use, so a single OIDCVerifier services every inbound request.

func NewOIDCVerifier

func NewOIDCVerifier(ctx context.Context, cfg OIDCConfig) (*OIDCVerifier, error)

NewOIDCVerifier discovers the issuer's OpenID configuration and returns a verifier ready to validate ID tokens. The ctx is used for the discovery roundtrip only — once NewOIDCVerifier returns, the verifier is decoupled from ctx and can outlive it.

Returns an error when cfg.Issuer is empty (callers should construct no verifier in that case), when discovery fails, or when cfg.Audience is empty.

func (*OIDCVerifier) Audience

func (v *OIDCVerifier) Audience() string

Audience reports the configured audience. Exposed for the manifest and tests.

func (*OIDCVerifier) Issuer

func (v *OIDCVerifier) Issuer() string

Issuer reports the configured issuer URL. Exposed for the manifest.

func (*OIDCVerifier) Verify

func (v *OIDCVerifier) Verify(ctx context.Context, token string) (Claims, error)

Verify validates token against the configured IdP (signature, exp, iat, aud, iss) and translates the result into a synthetic mgmt Claims. Returns one of the auth sentinel errors on failure so the composite verifier and the HTTP middleware can map to PARSEC_AUTH_*.

The returned Claims always carries Typ=TypeMgmt. OIDC IdPs do not issue refresh / access tokens in the parsec sense; clients hit `parsec login oidc` once per session and present the resulting ID token as a mgmt bearer.

type PairResult

type PairResult struct {
	AccessToken    string
	RefreshToken   string
	AccessExpires  time.Time
	RefreshExpires time.Time
}

PairResult is what IssuePair returns: both tokens and their absolute expirations.

type RedisKeyRingStore

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

RedisKeyRingStore persists the KeyRing as a JSON blob in Redis with a monotonic version counter. Save uses WATCH/MULTI to detect concurrent writes; Watch listens on a pub/sub channel for cross-node fanout of rotation events.

func NewRedisKeyRingStore

func NewRedisKeyRingStore(client redis.UniversalClient) *RedisKeyRingStore

NewRedisKeyRingStore constructs a store backed by client. The default key prefix is "parsec"; the on-disk layout becomes:

<prefix>:keyring          string (JSON snapshot)
<prefix>:keyring:version  integer (monotonic)
<prefix>:keyring:events   pub/sub channel (version-number payloads)

func (*RedisKeyRingStore) Ensure

func (s *RedisKeyRingStore) Ensure(ctx context.Context) (*KeyRing, bool, error)

Ensure returns a ring, bootstrapping if empty. Mirrors the file store bootstrap pattern; the resulting ring is persisted on bootstrap.

func (*RedisKeyRingStore) Load

func (s *RedisKeyRingStore) Load(ctx context.Context) (*KeyRing, error)

Load reads the persisted snapshot. Returns (nil, os.ErrNotExist) when the key does not exist so the caller can bootstrap.

func (*RedisKeyRingStore) Save

func (s *RedisKeyRingStore) Save(ctx context.Context, r *KeyRing) error

Save persists ring with optimistic concurrency. Uses WATCH on the version key so concurrent writes from two nodes are detected and the loser must reload + retry. Bumps the version and publishes it on the pub/sub channel so other nodes can react.

func (*RedisKeyRingStore) Watch

func (s *RedisKeyRingStore) Watch(ctx context.Context, onChange func(*KeyRing)) error

Watch subscribes to the events channel and re-loads + fires onChange on every version bump it sees from another node. Own writes are observable too — callers may dedupe by comparing the resulting snapshot. Blocks until ctx is canceled.

func (*RedisKeyRingStore) WithKeyPrefix

func (s *RedisKeyRingStore) WithKeyPrefix(p string) *RedisKeyRingStore

WithKeyPrefix overrides the namespace.

type RedisRefreshStore added in v0.3.0

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

RedisRefreshStore implements RefreshStore against Redis using SETNX for redemption atomicity and an EX TTL matched to the refresh's own expiry so storage self-prunes. Keyspace (with default prefix "parsec"):

<prefix>:refresh:jti:<jti>  "1"  (TTL = exp - now)
<prefix>:refresh:fid:<fid>  "1"  (TTL = exp - now)

Both keys are flag-style — their presence is the signal; the value is a placeholder.

func NewRedisRefreshStore added in v0.3.0

func NewRedisRefreshStore(client redis.UniversalClient) *RedisRefreshStore

NewRedisRefreshStore constructs a store backed by client.

func (*RedisRefreshStore) IsFamilyRevoked added in v0.3.0

func (s *RedisRefreshStore) IsFamilyRevoked(ctx context.Context, fid string) (bool, error)

IsFamilyRevoked implements RefreshStore.

func (*RedisRefreshStore) MarkRedeemed added in v0.3.0

func (s *RedisRefreshStore) MarkRedeemed(ctx context.Context, jti string, exp time.Time) error

MarkRedeemed implements RefreshStore. SETNX returns false when the key already exists, which the caller surfaces as ErrRefreshReused.

func (*RedisRefreshStore) RevokeFamily added in v0.3.0

func (s *RedisRefreshStore) RevokeFamily(ctx context.Context, fid string, exp time.Time) error

RevokeFamily implements RefreshStore. Set is idempotent; we use a straight SET with EX so a longer remaining TTL replaces a shorter one if a later revocation extends the window.

func (*RedisRefreshStore) SetClock added in v0.3.0

func (s *RedisRefreshStore) SetClock(c func() time.Time)

SetClock overrides the time source. Used in tests.

func (*RedisRefreshStore) WithKeyPrefix added in v0.3.0

func (s *RedisRefreshStore) WithKeyPrefix(p string) *RedisRefreshStore

WithKeyPrefix overrides the namespace. Empty resets to "parsec".

type RefreshStore added in v0.3.0

type RefreshStore interface {
	// MarkRedeemed records jti as redeemed. Returns ErrRefreshReused
	// if jti was already marked, leaving the store unchanged.
	MarkRedeemed(ctx context.Context, jti string, exp time.Time) error
	// RevokeFamily marks fid as revoked until exp. Idempotent. Used
	// when reuse detection trips on any JTI in the chain.
	RevokeFamily(ctx context.Context, fid string, exp time.Time) error
	// IsFamilyRevoked reports whether fid is currently revoked. Used
	// on every refresh redemption before MarkRedeemed.
	IsFamilyRevoked(ctx context.Context, fid string) (bool, error)
}

RefreshStore tracks refresh-token rotation state. Two records per rotation chain:

  • JTI redemption: each refresh's unique JTI is marked redeemed on successful exchange. Re-presenting the same JTI must be treated as a credential leak — the typical operator response is to revoke the family.
  • Family revocation: the FID shared by every refresh in a chain. A revoked family rejects every descendant and sibling refresh for the remaining lifetime of the chain.

Implementations MUST be safe for concurrent use. Storage backends: MemoryRefreshStore (single-node, in-process) and RedisRefreshStore (multi-node, shared TTL via Redis EXPIRE).

All methods accept an absolute exp time so the store can self-prune (memory) or set the right TTL (Redis). Records before time.Now() are no-ops — the predecessor refresh has already aged out and there is no value left to protect.

type Role

type Role string

Role is a key's lifecycle position in a KeyRing.

const (
	// RoleActive is the signing key. Exactly one key in a ring holds this
	// role at a time. New tokens are minted with this key's kid.
	RoleActive Role = "active"
	// RoleVerifyOnly accepts existing tokens but does not sign new ones.
	// A key transitions to verify-only when superseded by Promote, or when
	// added via Add (which never installs as active).
	RoleVerifyOnly Role = "verify-only"
	// RoleRetired is a deleted key. Retired keys are dropped from the next
	// snapshot and stop verifying immediately.
	RoleRetired Role = "retired"
)

type Scope

type Scope struct {
	Pattern string `json:"pat"`
	Verbs   []Verb `json:"v"`
	Deny    bool   `json:"deny,omitempty"`
	// contains filtered or unexported fields
}

Scope is one entry in a Claims.Scopes set: a channel pattern plus the verbs the holder is granted on names that match.

The Pattern field stays as raw text on the wire so the token shape matches the spec; Compiled() lazily parses and caches the structured Pattern for the matcher's hot path.

A scope with Deny=true inverts the meaning: a matching (channel, verb) pair is REMOVED from the grant set with "deny wins" precedence. See Claims.Authorizes for the full evaluation order. The Deny field is omitted from the wire when false so allow-only tokens stay compact.

func (*Scope) AllowsVerb

func (s *Scope) AllowsVerb(v Verb) bool

AllowsVerb is a cheap check that only inspects the verb list, used by callers that have already matched the pattern.

func (*Scope) Authorizes

func (s *Scope) Authorizes(name channels.Name, v Verb) bool

Authorizes reports whether this scope grants verb v on the channel name. A malformed Pattern fails closed (returns false, never panics).

func (*Scope) Compiled

func (s *Scope) Compiled() (channels.Pattern, error)

Compiled returns the parsed channels.Pattern, caching it on first call. Errors are sticky: a Scope with a malformed Pattern returns the same error every time without re-parsing.

func (*Scope) IsDeny

func (s *Scope) IsDeny() bool

IsDeny reports whether the scope is a negative grant. Convenience accessor so callers don't need to reach for the field directly when iterating a Claims.Scopes slice.

func (*Scope) MatchesVerb

func (s *Scope) MatchesVerb(name channels.Name, v Verb) bool

MatchesVerb is the shared "pattern matches AND verb is listed" check. Used by both the allow-pass and the deny-pass in Claims.Authorizes so the precedence logic stays in one place. Returns false on any compile / match / verb miss (fail closed).

type Signer

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

Signer produces signed Parsec JWTs from Claims using a KeyRing's active key.

func NewSigner

func NewSigner(ring *KeyRing) (*Signer, error)

NewSigner returns a Signer bound to ring. The ring must have at least one active key — checked lazily at sign time.

func (*Signer) Sign

func (s *Signer) Sign(c Claims) (string, error)

Sign serializes claims and signs them with the keyring's currently-active key. The returned token's header embeds that key's kid and alg.

type Snapshot

type Snapshot struct {
	FormatVersion string        `json:"format_version"`
	ActiveKeyID   string        `json:"active_key_id"`
	Keys          []SnapshotKey `json:"keys"`
}

Snapshot is the serializable view of a KeyRing.

type SnapshotKey

type SnapshotKey struct {
	ID         string `json:"id"`
	Alg        Alg    `json:"alg,omitempty"`
	SecretHex  string `json:"secret_hex,omitempty"`
	PrivatePEM string `json:"private_pem,omitempty"`
	Role       Role   `json:"role"`
	CreatedAt  string `json:"created_at"`
	RetiredAt  string `json:"retired_at,omitempty"`
}

SnapshotKey is one persisted key entry. Algorithm-specific material:

  • HS256: SecretHex is the 32+ byte HMAC secret (legacy entries without Alg are also read as HS256).
  • RS256 / EdDSA: PrivatePEM is a PKCS#8 PEM blob.

type SubscribeRateGate added in v0.3.0

type SubscribeRateGate func(ctx context.Context, userID string, ch channels.Name) error

SubscribeRateGate is the per-subscribe rate-limit callback. It runs BEFORE token verification with the authenticated userID + parsed channel; an empty userID means the connection is anonymous and operator-side L7 rate limiting is responsible for protection. Returning a coded *errors.Error denies the subscribe with that code; returning nil allows the authorizer to proceed.

type Type

type Type string

Type is the JWT typ-extension claim Parsec uses to disambiguate token purpose. The standard JWT typ header stays "JWT"; the discriminator lives in the payload's "typ" field.

const (
	TypeAccess  Type = "access"
	TypeRefresh Type = "refresh"
	TypeMgmt    Type = "mgmt"
)

func (Type) Valid

func (t Type) Valid() error

Valid returns nil if t is one of the recognized token types.

type Verb

type Verb string

Verb is the typed action a Scope grants on a channel pattern. The wire values are the lowercase strings — they appear inside JWT payloads and the manifest, so changing them is a wire break.

const (
	// VerbSubscribe authorizes a subscribe over the websocket / SSE
	// surface. This is the verb the broker's subscribe authorizer
	// checks.
	VerbSubscribe Verb = "subscribe"
	// VerbPublish authorizes a client-side publish (server-side
	// publishes via the management RPC still go through the mgmt
	// bearer).
	VerbPublish Verb = "publish"
	// VerbManage authorizes per-channel management RPCs called with
	// an access token instead of a mgmt token — delegated administration.
	VerbManage Verb = "manage"
)

func (Verb) Valid

func (v Verb) Valid() bool

Valid reports whether v is one of the recognized verbs. Unknown verbs silently fail-closed at the authorize call — Scope.Authorizes will not honor them — but Valid is exposed for callers that want to validate at the boundary.

type Verifier

type Verifier struct {
	Clock  func() time.Time
	Leeway time.Duration
	// OnVerify, when non-nil, is invoked once per Verify call with the
	// token type that was checked (or the empty string when the token
	// failed to parse before the typ claim could be read), the expected
	// type (may be empty), and the resulting error (nil on success).
	// Used by the metrics layer to record verifications by type+result
	// without coupling the auth package to prometheus.
	OnVerify func(parsed Type, expected Type, err error)
	// contains filtered or unexported fields
}

Verifier validates Parsec JWTs against a KeyRing. The ring is followed by reference, so a reload that swaps the ring's contents takes effect on the next call.

func NewVerifier

func NewVerifier(ring *KeyRing) (*Verifier, error)

NewVerifier returns a Verifier bound to ring.

func (*Verifier) Verify

func (v *Verifier) Verify(token string, expected Type) (Claims, error)

Verify parses token, validates the header (must include kid, must use HS256+JWT), looks up the key in the ring, verifies the signature, and checks expiry. When expected != "" it also enforces the typ claim.

Jump to

Keyboard shortcuts

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