Version v0.5.4 of the documentation is no longer actively maintained. The site that you are currently viewing is an archived snapshot. For up-to-date documentation, see the latest version.

internal/auth

import "github.com/dexadata/dexaflow/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

The scopes a trusted-issuer bearer token may carry (ADR 0067). No scope implies another.

const (
    // ScopeRead grants the read routes.
    ScopeRead = "dexaflow:read"
    // ScopeRun grants run-state writes: trigger, clear, mark, pause.
    ScopeRun = "dexaflow:run"
    // ScopeDeploy grants registering a DAG version.
    ScopeDeploy = "dexaflow:deploy"
)

APITokenPrefix starts every API token (ADR 0074). It tells an API token apart from a JWT without parsing it, and lets secret scanners recognize a leaked one.

const APITokenPrefix = "dxf_"

DefaultAdminEmail is the email of the bootstrap admin a new install creates: a fresh Lite setup, and a server booted with a bootstrap password and no bootstrap email.

const DefaultAdminEmail = "admin@dexaflow.local"

DevTokenSubject is the subject of the in-process token that `dexaflow lite` 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"

LegacyAdminEmail is the bootstrap admin email of installs created before the rename to Dexaflow. Those installs keep their admin under this address; see ResolveAdminEmail.

const LegacyAdminEmail = "admin@leoflow.local"

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

The refusals of a start over a cap.

var (
    // ErrDeviceSignInsFull: the cluster holds as many sign-ins in progress
    // as it allows.
    ErrDeviceSignInsFull = errors.New("too many device sign-ins are in progress")
    // ErrDeviceSignInsFromRequester: the starting address holds as many
    // sign-ins in progress as one address may.
    ErrDeviceSignInsFromRequester = errors.New("too many device sign-ins are in progress from this address")
    // ErrDeviceStartsBusy: the start could not take its turn among
    // concurrent starts in time, so it was not recorded.
    ErrDeviceStartsBusy = errors.New("device sign-in starts are busy")
)

ErrAPITokenNotFound is returned by APITokenStore.FindAPITokenByHash when no token has the given hash.

var ErrAPITokenNotFound = errors.New("api token not found")

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")

ErrTenantlessToken is joined with ErrInvalidToken when a token is trusted from its signed claims but names no tenant: such a principal cannot be scoped to any tenant’s data, so it is refused rather than given a default one.

var ErrTenantlessToken = errors.New("token names no tenant")

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 APITokenScopes

func APITokenScopes(scope string) ([]string, bool)

APITokenScopes returns the scopes an API token created for scope carries, and false when scope is not one a token may be created for.

func AddressBlock

func AddressBlock(ip string, v6Bits int) string

AddressBlock is AddressBucket with the IPv6 prefix length v6Bits: an IPv4 address as is, an IPv6 one by its /v6Bits, anything else as is.

func AddressBucket

func AddressBucket(ip string) string

AddressBucket is the rate-limit key of a client address: an IPv4 address (or an IPv4-mapped IPv6 one) as is, and an IPv6 address by its /64, the smallest block one subscriber is usually given, so rotating addresses within it does not buy a fresh budget. Anything that does not parse is its own key.

func HashAPIToken

func HashAPIToken(token string) string

HashAPIToken returns the hex SHA-256 of token, the only form of it the server keeps. A fast hash is enough: the secret is 256 random bits, so there is no dictionary to try, and the hash is the lookup key.

func HashPassword

func HashPassword(password string) (string, error)

HashPassword hashes a plaintext password with bcrypt.

func IsAPIToken

func IsAPIToken(token string) bool

IsAPIToken reports whether token has the API token form.

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 `dexaflow lite`, 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 NewAPIToken

func NewAPIToken() (token, hash string, err error)

NewAPIToken returns a fresh API token and the hash to store for it. The token is shown to its owner once and never stored.

func VerifyPassword

func VerifyPassword(hash, password string) bool

VerifyPassword reports whether password matches the stored bcrypt hash.

type APIToken

APIToken is the stored record of a long-lived API token (ADR 0074). It never holds the token itself, only what describes it.

type APIToken struct {
    ID  string
    // TenantID is the tenant name of the owner, as in User.TenantID.
    TenantID   string
    UserID     string
    UserEmail  string
    Name       string
    Scopes     []string
    CreatedAt  time.Time
    ExpiresAt  time.Time
    LastUsedAt *time.Time
    RevokedAt  *time.Time
}

type APITokenAuthenticator

APITokenAuthenticator authenticates API tokens and hands every other token to the next Authenticator (ADR 0074). An API token resolves to its owner, reloaded from the store on every request like a JWT subject, narrowed to the token’s scopes.

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

func NewAPITokenAuthenticator

func NewAPITokenAuthenticator(next Authenticator, tokens APITokenStore, users UserStore) *APITokenAuthenticator

NewAPITokenAuthenticator wraps next so that API tokens are checked against tokens and their owners reloaded from users.

func (*APITokenAuthenticator) Authenticate

func (a *APITokenAuthenticator) Authenticate(ctx context.Context, token string) (*User, error)

Authenticate resolves an API token to its scoped owner, or delegates any other token. A token that is unknown, revoked or expired, or whose owner is missing, inactive or in another tenant, is ErrInvalidToken. A store failure is returned as it is, so the request answers 503 rather than 401.

func (*APITokenAuthenticator) IssueToken

func (a *APITokenAuthenticator) IssueToken(ctx context.Context, creds Credentials) (string, error)

IssueToken delegates to the next authenticator: API tokens are created through the API by a signed-in user, never from a password.

type APITokenStore

APITokenStore resolves API tokens by the hash of their secret and records their use. storage implements it.

type APITokenStore interface {
    // FindAPITokenByHash returns the token whose secret hashes to hash, revoked
    // and expired ones included, or ErrAPITokenNotFound.
    FindAPITokenByHash(ctx context.Context, hash string) (*APIToken, error)
    // TouchAPIToken records that the token was used at at, unless its last
    // recorded use is less than five minutes before at, and reports whether
    // it wrote. The store decides, so concurrent requests write once.
    TouchAPIToken(ctx context.Context, id string, at time.Time) (bool, error)
}

type AdminLookup

AdminLookup reports the AdminState of an email address in the tenant the caller is bootstrapping.

type AdminLookup func(ctx context.Context, email string) (AdminState, error)

type AdminResolution

AdminResolution is the address ResolveAdminEmail chose and why.

type AdminResolution struct {
    // Email is the address the bootstrap admin lives under.
    Email string
    // KeptLegacy reports that Email is LegacyAdminEmail, chosen in place of the
    // default address because the install predates the rename.
    KeptLegacy bool
    // BothExist reports that users exist under both addresses, which the
    // caller should warn about: only Email is bootstrapped.
    BothExist bool
}

func ResolveAdminEmail

func ResolveAdminEmail(ctx context.Context, configured string, lookup AdminLookup) (AdminResolution, error)

ResolveAdminEmail returns the address the bootstrap admin lives under.

An empty configured email means DefaultAdminEmail. Any address other than DefaultAdminEmail is returned unchanged, with no lookup: an operator who named an address gets exactly that address. For DefaultAdminEmail the user holding the admin role wins, the new address first, so an install created before the rename (or a reinstall over its kept database) keeps the admin it has instead of gaining a second one, and the bootstrap never writes a password to a non-admin account at the new address while the legacy admin exists. Without an admin at either address, an existing user wins, the new address first; with no user at all, the new address is created. The existing user is never renamed.

type AdminState

AdminState is what the user store knows about one email address.

type AdminState int

const (
    // UserAbsent means no user has the address.
    UserAbsent AdminState = iota
    // UserWithoutAdminRole means a user has the address but not the admin role.
    UserWithoutAdminRole
    // UserIsAdmin means a user has the address and holds the admin role.
    UserIsAdmin
)

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
    // AttemptEpoch identifies which execution of TryNumber this credential
    // belongs to (ADR 0051 amendment): an infra re-place, a reschedule poke or a
    // repeated dispatch reuses the try but never the epoch. It is meaningful
    // only when HasAttemptEpoch is set.
    AttemptEpoch int
    // HasAttemptEpoch is the presence bit for AttemptEpoch. It is false for a
    // token minted before the epoch existed (a legacy token, which the fence
    // treats as epoch 0) and for a warm-worker credential, so "absent" stays
    // distinguishable from epoch 0.
    HasAttemptEpoch bool
    // 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 DeviceAuthorization

DeviceAuthorization is a device sign-in of the CLI waiting for a person to approve it (`dexaflow auth login --device`, ADR 0081), as storage returns it. Neither of its codes is here: storage keeps only their keyed hashes.

type DeviceAuthorization struct {
    // ID names the pending sign-in between the verification page, the IdP
    // round trip and the consent page.
    ID  string
    // CreatedAt is when the CLI started the sign-in.
    CreatedAt time.Time
    // ExpiresAt is when the sign-in can no longer be approved or redeemed.
    ExpiresAt time.Time
    // RequesterAddr is the address that started the sign-in, as the server
    // saw it, shown on the consent page.
    RequesterAddr string
}

type DeviceAuthorizationRequest

DeviceAuthorizationRequest is what starting a device sign-in stores: the keyed hashes of its codes and of the address that started it, that address for the consent page, and the limits it starts under.

type DeviceAuthorizationRequest struct {
    DeviceCodeHash string
    UserCodeHash   string
    // RequesterHash is the keyed hash of the starting address (its /56 for
    // IPv6), which MaxLivePerRequester counts by.
    RequesterHash string
    RequesterAddr string
    TTL           time.Duration
    Interval      time.Duration
    // MaxLive caps the sign-ins in progress in all, and
    // MaxLivePerRequester those from one requester.
    MaxLive             int
    MaxLivePerRequester int
}

type DevicePoll

DevicePoll is the result of one poll: its outcome and, for an approved sign-in, the user it was approved as, with the roles the sign-in resolved.

type DevicePoll struct {
    Outcome DevicePollOutcome
    User    User
}

type DevicePollOutcome

DevicePollOutcome is what one poll of a device sign-in found.

type DevicePollOutcome int

The outcomes of a poll. Each maps to one answer of the token route (RFC 8628 section 3.5).

const (
    // DevicePollUnknown is a device code nobody issued, or one already
    // redeemed or refused: invalid_grant.
    DevicePollUnknown DevicePollOutcome = iota
    // DevicePollPending is a sign-in nobody approved yet:
    // authorization_pending.
    DevicePollPending
    // DevicePollSlowDown is a poll that came sooner than the interval: the
    // interval grew by five seconds, slow_down.
    DevicePollSlowDown
    // DevicePollExpired is a sign-in past its expiry: expired_token.
    DevicePollExpired
    // DevicePollDenied is a sign-in the person canceled: access_denied. It
    // is answered once; later polls find nothing.
    DevicePollDenied
    // DevicePollApproved is an approved sign-in, redeemed by this poll: the
    // token is minted for User. Later polls find nothing.
    DevicePollApproved
)

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. `dexaflow lite`). 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(ctx context.Context, 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 `dexaflow 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.

Renewal RE-PROVES the principal: after the claims check and the ceiling check it reloads the user from the store, exactly as Authenticate does, and refuses (ErrInvalidToken) when the account is inactive or its row is gone. Issuing a credential is not weaker than using one, so a deactivated user must stop receiving fresh tokens, not merely stop being able to spend them. The same two carve-outs Authenticate makes apply verbatim: a nil store (no data plane bound — the trusted in-process minting context) and the dev-token subject, which has no backing row by design. Any other store failure fails closed; that adds no failure mode, because a database blip that refuses this renewal would refuse the following request anyway, and the client’s refresh is best-effort. Roles on the re-minted token come from the reload, so a role revoked mid-session is reflected at once rather than carried forward from the old claims.

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. The check and the spend are one step under the lock, so concurrent callers of a key never get more than the limit between them: a caller that must bound concurrent work spends with Allow before the work, not after a Blocked peek, and gives the event back with Refund when it should not count.

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.

func (*RateLimiter) Refund

func (r *RateLimiter) Refund(key string)

Refund gives back one event of key’s window, never going below zero. A window that ended needs none: the next Allow starts a fresh one.

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
    // Scoped marks a principal authenticated by a client token that carries
    // OAuth scopes (ADR 0067): the scopes then narrow what its roles allow.
    // Engine tokens and sessions are not scoped; only their roles decide.
    Scoped bool
    // Scopes are the scopes a Scoped principal's token carries.
    Scopes []string
}

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.

func (*User) HasScope

func (u *User) HasScope(scope string) bool

HasScope reports whether the principal’s token carries scope.

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