internal/auth
import "github.com/neochaotic/leoflow/internal/auth"
Package auth provides JWT authentication, password hashing, the RBAC permission model, and login rate limiting for the control plane (ADR 0008).
Index
- Constants
- Variables
- func HashPassword(password string) (string, error)
- func MintUserToken(secret string, ttl time.Duration, user User) (string, error)
- func VerifyPassword(hash, password string) bool
- type AgentIdentity
- type Authenticator
- type Credentials
- type JWTAuthenticator
- func NewJWTAuthenticator(store UserStore, secret string, ttl time.Duration) *JWTAuthenticator
- func (a *JWTAuthenticator) Authenticate(ctx context.Context, token string) (*User, error)
- func (a *JWTAuthenticator) AuthenticateAgent(token string) (*AgentIdentity, error)
- func (a *JWTAuthenticator) IssueAgentToken(id AgentIdentity, ttl time.Duration) (string, error)
- func (a *JWTAuthenticator) IssueToken(ctx context.Context, creds Credentials) (string, error)
- func (a *JWTAuthenticator) RenewAgentToken(token string, ttl, maxLifetime time.Duration) (renewed string, ok bool, err error)
- func (a *JWTAuthenticator) RenewUserToken(token string, ttl, maxLifetime time.Duration) (renewed string, ok bool, err error)
- type Permission
- type RateLimiter
- type User
- type UserStore
Constants
DevTokenSubject is the subject of the in-process token that `leoflow dev` mints for its admin. It intentionally has no user row, so Authenticate trusts its signed claims as the ONLY subject exempt from the per-request DB authz reload.
const DevTokenSubject = "leoflow-dev"
ScopeWarmWorker marks an agent token that authorizes ONLY the warm-worker control channel — Register + AwaitAssignment — and NOTHING that resolves secrets or a task (ADR 0058 D2). It is the value of AgentIdentity.Scope for a warm worker’s bootstrap credential; the empty scope is the default task credential, which resolves secrets exactly as before. The control plane gates every secret/task RPC on this value (see agentrpc.requireAttemptToken).
const ScopeWarmWorker = "warm-worker"
Variables
ErrInvalidCredentials is returned when a username/password pair is rejected.
var ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidToken is returned when a token is malformed, expired, or unsigned by us.
var ErrInvalidToken = errors.New("invalid token")
ErrUserNotFound is returned by UserStore.FindUserByID when no user has the given id. Authenticate treats it as a signal to trust the token’s signed claims (the in-process minting path has no backing row), distinct from a store failure, which fails closed.
var ErrUserNotFound = errors.New("user not found")
func HashPassword
func HashPassword(password string) (string, error)
HashPassword hashes a plaintext password with bcrypt.
func MintUserToken
func MintUserToken(secret string, ttl time.Duration, user User) (string, error)
MintUserToken signs a user JWT directly, without checking credentials against a store. It is for trusted in-process callers only — notably `leoflow dev`, which runs its own control plane and must register DAGs without a login round-trip. The token validates under Authenticate using the same secret.
func VerifyPassword
func VerifyPassword(hash, password string) bool
VerifyPassword reports whether password matches the stored bcrypt hash.
type AgentIdentity
AgentIdentity is the identity a verified agent token represents. By default (Scope == “”) it is a single task instance — the task-scoped credential that resolves secrets. When Scope == ScopeWarmWorker it is instead a warm worker’s bootstrap credential, which names its dag_version pool (DagVersionID) and its worker id (WorkerID, the token Subject) and carries NO task claims.
type AgentIdentity struct {
TaskInstanceID string
TenantID string
DagID string
RunID string
TaskID string
TryNumber int
// Scope is "" for a task credential (the default, byte-compatible with every
// token minted before this field existed) or ScopeWarmWorker for a warm
// worker's control-channel-only bootstrap credential.
Scope string
// DagVersionID names the warm pool a warm-worker credential serves. Empty on a
// task credential.
DagVersionID string
// WorkerID is the warm worker's stable identifier (its pod name), minted as the
// token Subject for a warm-worker credential. Empty on a task credential (whose
// Subject is TaskInstanceID instead).
WorkerID string
}
type Authenticator
Authenticator issues and validates authentication tokens. The MVP ships a JWT implementation; the interface keeps OIDC/LDAP pluggable (ADR 0008).
type Authenticator interface {
Authenticate(ctx context.Context, token string) (*User, error)
IssueToken(ctx context.Context, creds Credentials) (string, error)
}
type Credentials
Credentials are the inputs to token issuance.
type Credentials struct {
Tenant string
Username string
Password string
}
type JWTAuthenticator
JWTAuthenticator issues and validates HS256 JWTs against a UserStore.
type JWTAuthenticator struct {
// contains filtered or unexported fields
}
func NewJWTAuthenticator
func NewJWTAuthenticator(store UserStore, secret string, ttl time.Duration) *JWTAuthenticator
NewJWTAuthenticator builds a JWTAuthenticator with the given user store, HS256 secret, and token lifetime.
func (*JWTAuthenticator) Authenticate
func (a *JWTAuthenticator) Authenticate(ctx context.Context, token string) (*User, error)
Authenticate validates a bearer token and resolves the current principal. After the signature and registered claims check out, it reloads the user’s roles, permissions, and active flag from the store keyed by the token subject (the user id). The store — not the token — is the source of truth for authorization: this is what makes non-admin role grants gate anything (the token carries roles but never permissions) and lets deactivating a user revoke their live tokens within the token TTL.
Two cases fall back to the signed claims instead of the reload: a nil store (no data plane bound — the trusted in-process minting context) and a subject with no backing row (a directly-minted token, e.g. `leoflow dev`). Any other store failure fails closed, so a flaky database cannot silently disable revocation.
func (*JWTAuthenticator) AuthenticateAgent
func (a *JWTAuthenticator) AuthenticateAgent(token string) (*AgentIdentity, error)
AuthenticateAgent validates an agent bearer token and returns the task instance it identifies.
func (*JWTAuthenticator) IssueAgentToken
func (a *JWTAuthenticator) IssueAgentToken(id AgentIdentity, ttl time.Duration) (string, error)
IssueAgentToken mints a signed token that identifies a single task instance, valid for the given TTL. The control plane passes it to the worker pod.
The token’s origin (oiat) is set to the mint time — this is a fresh dispatch. Renewal (RenewAgentToken) preserves that origin instead of resetting it.
func (*JWTAuthenticator) IssueToken
func (a *JWTAuthenticator) IssueToken(ctx context.Context, creds Credentials) (string, error)
IssueToken validates the credentials against the store and returns a signed JWT.
func (*JWTAuthenticator) RenewAgentToken
func (a *JWTAuthenticator) RenewAgentToken(token string, ttl, maxLifetime time.Duration) (renewed string, ok bool, err error)
RenewAgentToken validates an in-flight agent bearer token and re-mints it for the SAME task instance with a fresh short TTL, refreshing the live window without ever changing what AuthenticateAgent verifies (signature, issuer, leoflow-agent audience, HS256). It is the heartbeat-driven half of the short-TTL-plus-renewal design (ADR 0055 Fix #4): the short TTL bounds a stolen or finished token, while renewal keeps a genuinely live task’s credential working.
The attempt’s original dispatch time is preserved across every renewal (the oiat claim). maxLifetime is a hard ceiling on that total age: once the attempt has been alive longer than maxLifetime since first dispatch, renewal is refused (ok=false, empty token) so a runaway attempt’s credential lapses instead of being kept alive forever. A non-positive maxLifetime disables the ceiling. exp is always now+ttl — never accumulated onto the previous exp.
An invalid incoming token (bad signature, wrong audience, expired) returns an error and is never re-minted.
func (*JWTAuthenticator) RenewUserToken
func (a *JWTAuthenticator) RenewUserToken(token string, ttl, maxLifetime time.Duration) (renewed string, ok bool, err error)
RenewUserToken validates a still-valid user bearer token and re-mints it for the SAME principal (subject, tenant, email, roles) with a fresh short TTL, without ever changing what Authenticate verifies (signature, issuer, leoflow-user audience, HS256). It is the server half of transparent CLI token renewal (EKS validation aresta #5): the short access-token TTL still bounds a stolen token, while renewal keeps a genuinely live session working so a long dev session never has to `leoflow auth login` again on the hour. It is modeled directly on RenewAgentToken.
The session’s original login time is preserved across every renewal (the oiat claim, falling back to iat for a token minted before that claim existed). maxLifetime is a hard ceiling on that total age: once the session has been alive longer than maxLifetime since first login, renewal is refused (ok=false, empty token, no error) so the user must re-authenticate. A non-positive maxLifetime disables the ceiling. exp is always now+ttl — never accumulated.
An invalid incoming token (bad signature, wrong audience, expired) returns an error and is never re-minted. Roles are copied from the incoming token, exactly as they were signed; Authenticate still reloads authorization from the store on every request, so a renewed token confers no more than the original did.
type Permission
Permission is an action on a resource (e.g. {Action: “read”, Resource: “dag”}).
type Permission struct {
Action string `json:"action"`
Resource string `json:"resource"`
}
type RateLimiter
RateLimiter is a per-key fixed-window limiter used to throttle failed logins per client IP (ADR 0008).
type RateLimiter struct {
// contains filtered or unexported fields
}
func NewRateLimiter
func NewRateLimiter(limit int, window time.Duration) *RateLimiter
NewRateLimiter builds a limiter allowing limit events per window per key.
func (*RateLimiter) Allow
func (r *RateLimiter) Allow(key string) bool
Allow records an event for key and reports whether it is within the limit.
func (*RateLimiter) Blocked
func (r *RateLimiter) Blocked(key string) bool
Blocked reports whether key has already reached its limit in the current window, WITHOUT recording an attempt (a peek). The login handler uses it to reject an over-limit caller up front while calling Allow only for actual failures — so a successful login never consumes the budget and a user who mistypes a few times is not locked out the moment they finally get it right.
type User
User is an authenticated principal with its tenant, roles, and permissions.
type User struct {
ID string
TenantID string
Email string
Roles []string
Permissions []Permission
}
func (*User) HasPermission
func (u *User) HasPermission(action, resource string) bool
HasPermission reports whether the user may perform action on resource. The admin role, or an admin action / wildcard resource permission, grants access.
type UserStore
UserStore loads users for authentication. storage implements it.
type UserStore interface {
FindUserByLogin(ctx context.Context, tenant, username string) (user *User, passwordHash string, err error)
// FindUserByID reloads a user's current authorization state (tenant, roles,
// permissions) by id, along with whether the account is active. It is the
// per-request source of truth for token validation: it makes non-admin role
// grants take effect and lets deactivating a user revoke live tokens within
// the token TTL. It returns ErrUserNotFound when no user has the id.
FindUserByID(ctx context.Context, id string) (user *User, isActive bool, err error)
}
Generated by gomarkdoc