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
- Variables
- func GenerateSecret() ([]byte, error)
- func JWKSHandler(ring *KeyRing) http.Handler
- func MapErr(err error) error
- func NewSubscribeAuthorizer(v *Verifier) func(ctx context.Context, userID string, ch channels.Name, ...) error
- func NewSubscribeAuthorizerWithGate(v *Verifier, gate SubscribeRateGate) func(ctx context.Context, userID string, ch channels.Name, ...) error
- func NewSubscribeAuthorizerWithLimiter(v *Verifier, limiter ratelimit.Limiter, defaultLimit ratelimit.Limit) func(ctx context.Context, userID string, ch channels.Name, ...) error
- func ReloadInto(path string, ring *KeyRing) error
- func SaveKeyRing(path string, r *KeyRing) error
- func WatchKeyRingFile(ctx context.Context, path string, ring *KeyRing, interval time.Duration, ...)
- type Alg
- type Claims
- type CompositeVerifier
- type FileKeyRingStore
- func (s *FileKeyRingStore) Ensure(ctx context.Context) (*KeyRing, bool, error)
- func (s *FileKeyRingStore) Load(_ context.Context) (*KeyRing, error)
- func (s *FileKeyRingStore) Path() string
- func (s *FileKeyRingStore) Save(_ context.Context, r *KeyRing) error
- func (s *FileKeyRingStore) Watch(ctx context.Context, onChange func(*KeyRing)) error
- type Issuer
- func (i *Issuer) IssueAccess(sub, channel string, refreshExp time.Time, scopes []Scope) (string, time.Time, error)
- func (i *Issuer) IssueAccessForChannels(sub string, chs []string, refreshExp time.Time) (string, time.Time, error)
- func (i *Issuer) IssueMgmt(sub string, ttl time.Duration) (string, time.Time, error)
- func (i *Issuer) IssuePair(sub, channel string, channelTTL time.Duration, scopes []Scope) (PairResult, error)
- func (i *Issuer) IssuePairForChannels(sub string, chs []string, ttl time.Duration, scopes []Scope) (PairResult, error)
- func (i *Issuer) IssuePairWithRateLimit(sub, channel string, channelTTL time.Duration, override ratelimit.Limit) (PairResult, error)
- func (i *Issuer) IssueRotatedPair(sub, channel, oldFID string, channelTTL time.Duration, scopes []Scope) (PairResult, error)
- func (i *Issuer) SetClock(c func() time.Time)
- type JWK
- type JWKS
- type Key
- type KeyRing
- func (r *KeyRing) Active() (Key, error)
- func (r *KeyRing) ActiveID() string
- func (r *KeyRing) Add(id string, secret []byte) (Key, error)
- func (r *KeyRing) AddECDSA(id string, priv *ecdsa.PrivateKey) (Key, error)
- func (r *KeyRing) AddEd25519(id string, priv ed25519.PrivateKey) (Key, error)
- func (r *KeyRing) AddRSA(id string, priv *rsa.PrivateKey) (Key, error)
- func (r *KeyRing) Generate() (Key, error)
- func (r *KeyRing) GenerateAlg(alg Alg) (Key, error)
- func (r *KeyRing) GenerateECDSA(curve elliptic.Curve) (Key, error)
- func (r *KeyRing) GenerateEd25519() (Key, error)
- func (r *KeyRing) GenerateRSA(bits int) (Key, error)
- func (r *KeyRing) Get(id string) (Key, error)
- func (r *KeyRing) List() []Key
- func (r *KeyRing) LoadSnapshot(s Snapshot) error
- func (r *KeyRing) Promote(id string) error
- func (r *KeyRing) Retire(id string) error
- func (r *KeyRing) Snapshot() Snapshot
- type KeyRingStore
- type MemoryRefreshStore
- func (s *MemoryRefreshStore) Close()
- func (s *MemoryRefreshStore) IsFamilyRevoked(_ context.Context, fid string) (bool, error)
- func (s *MemoryRefreshStore) MarkRedeemed(_ context.Context, jti string, exp time.Time) error
- func (s *MemoryRefreshStore) RevokeFamily(_ context.Context, fid string, exp time.Time) error
- func (s *MemoryRefreshStore) SetClock(c func() time.Time)
- type OIDCConfig
- type OIDCGrant
- type OIDCVerifier
- type PairResult
- type RedisKeyRingStore
- func (s *RedisKeyRingStore) Ensure(ctx context.Context) (*KeyRing, bool, error)
- func (s *RedisKeyRingStore) Load(ctx context.Context) (*KeyRing, error)
- func (s *RedisKeyRingStore) Save(ctx context.Context, r *KeyRing) error
- func (s *RedisKeyRingStore) Watch(ctx context.Context, onChange func(*KeyRing)) error
- func (s *RedisKeyRingStore) WithKeyPrefix(p string) *RedisKeyRingStore
- type RedisRefreshStore
- func (s *RedisRefreshStore) IsFamilyRevoked(ctx context.Context, fid string) (bool, error)
- func (s *RedisRefreshStore) MarkRedeemed(ctx context.Context, jti string, exp time.Time) error
- func (s *RedisRefreshStore) RevokeFamily(ctx context.Context, fid string, exp time.Time) error
- func (s *RedisRefreshStore) SetClock(c func() time.Time)
- func (s *RedisRefreshStore) WithKeyPrefix(p string) *RedisRefreshStore
- type RefreshStore
- type Role
- type Scope
- type Signer
- type Snapshot
- type SnapshotKey
- type SubscribeRateGate
- type Type
- type Verb
- type Verifier
Constants ¶
const KeyringFileName = "keyring.json"
KeyringFileName is the conventional file name inside the state dir.
Variables ¶
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.
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.
var AllVerbs = []Verb{VerbSubscribe, VerbPublish, VerbManage}
AllVerbs lists every recognized verb in stable order. Used by the manifest so client SDKs can discover the surface.
Functions ¶
func GenerateSecret ¶
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
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 ¶
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 ¶
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 ¶
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.
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
IsAsymmetric reports whether a is one of the public-key algs. HMAC keys are symmetric and are NEVER exposed via the JWKS endpoint.
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 ¶
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:
- 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.
- Walk c.Chs for an exact-name match.
- Walk the Scopes for an allow scope whose pattern matches and whose verb list contains v.
- 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).
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 ¶
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.
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 (*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 ¶
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.
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 ¶
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 ¶
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 NewKeyRingFromSecret ¶
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) Add ¶
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
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
AddEd25519 installs an Ed25519 keypair into the ring. Same role semantics as Add.
func (*KeyRing) AddRSA ¶ added in v0.3.0
AddRSA installs an RSA private key into the ring. The caller must have generated a 2048+ bit key.
func (*KeyRing) Generate ¶
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
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
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
GenerateEd25519 creates a fresh Ed25519 keypair and installs it.
func (*KeyRing) GenerateRSA ¶ added in v0.3.0
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 ¶
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 ¶
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 ¶
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 ¶
Promote makes id the active key. The previously-active key (if any) transitions to verify-only. Promoting an unknown or retired key errors.
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
IsFamilyRevoked implements RefreshStore.
func (*MemoryRefreshStore) MarkRedeemed ¶ added in v0.3.0
MarkRedeemed implements RefreshStore.
func (*MemoryRefreshStore) RevokeFamily ¶ added in v0.3.0
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 ¶
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 ¶
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
IsFamilyRevoked implements RefreshStore.
func (*RedisRefreshStore) MarkRedeemed ¶ added in v0.3.0
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
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 ¶
AllowsVerb is a cheap check that only inspects the verb list, used by callers that have already matched the pattern.
func (*Scope) Authorizes ¶
Authorizes reports whether this scope grants verb v on the channel name. A malformed Pattern fails closed (returns false, never panics).
func (*Scope) Compiled ¶
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 ¶
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 ¶
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.
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
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.
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" )
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 ¶
NewVerifier returns a Verifier bound to ring.