Documentation
¶
Overview ¶
Package auth implements the dual-mode authentication middleware required by ADR-0006: external OIDC ID tokens (for human users) plus self-signed ES256 JWTs (for service accounts / programmatic callers).
Boundaries:
- This package owns identity *verification* and ctx injection. It does NOT own RPC handlers, transport setup, or any tenant-specific business logic. The ScheduleService implementation (M2-T03) plugs the UnaryServerInterceptor returned here into its grpc.NewServer call.
- There is NO public user CRUD: human users live in the customer's IdP per ADR-0006 §4. The only identities this package persists are service accounts (their id + tenant binding) and their JWT revocations.
- Audit log persistence is deferred to M2-T07. We expose the AuditHook interface here so the middleware can emit events from day one; T07 swaps in a SQLite/ClickHouse-backed implementation.
Anti-patterns this package guards against (do not weaken without ADR):
- HS256 is rejected even if signed with a key we know — RFC 8725 §3.1.
- Verification errors return one externally-visible message ("invalid credentials"); the *internal* reason (expired vs revoked vs unknown issuer) goes to the audit hook. Distinguishing them client-side is an oracle.
- Tokens are never logged, returned in error messages, or used as metric labels.
- Configuration is deny-by-default: a nil Authenticator rejects every request that is not on the allowlist.
Index ¶
- Constants
- Variables
- func HTTPMiddleware(authn Authenticator, allowlist *Allowlist, auditHook AuditHook) func(http.Handler) http.Handler
- func ScopesFromContext(ctx context.Context) []string
- func TenantIDFromContext(ctx context.Context) string
- func UnaryServerInterceptor(authn Authenticator, allowlist *Allowlist, auditHook AuditHook) grpc.UnaryServerInterceptor
- func WithActor(ctx context.Context, a *Actor) context.Context
- type Actor
- type ActorType
- type Allowlist
- type AuditEvent
- type AuditHook
- type AuthError
- type Authenticator
- type ChainAuthenticator
- type Issuer
- type IssuerConfig
- type JWTVerifier
- type LoggingAuditHook
- type NopAuditHook
- type OIDCVerifier
- type Revocation
- type SQLiteStore
- func (s *SQLiteStore) Close() error
- func (s *SQLiteStore) CreateServiceAccount(ctx context.Context, sa ServiceAccount) error
- func (s *SQLiteStore) DeleteServiceAccount(ctx context.Context, id string) error
- func (s *SQLiteStore) GetServiceAccount(ctx context.Context, id string) (*ServiceAccount, error)
- func (s *SQLiteStore) IsRevoked(ctx context.Context, jti string) (bool, error)
- func (s *SQLiteStore) ListServiceAccounts(ctx context.Context) ([]ServiceAccount, error)
- func (s *SQLiteStore) PurgeExpiredRevocations(ctx context.Context, before time.Time) (int64, error)
- func (s *SQLiteStore) Revoke(ctx context.Context, r Revocation) error
- type ServiceAccount
- type Store
Constants ¶
const ( ReasonMissingCreds = "MISSING_CREDS" ReasonInsecureAlg = "INSECURE_ALG" ReasonUnknownIssuer = "UNKNOWN_ISSUER" ReasonMissingTenant = "MISSING_TENANT" ReasonInvalidToken = "INVALID_TOKEN" ReasonTokenExpired = "TOKEN_EXPIRED" ReasonTokenRevoked = "TOKEN_REVOKED" ReasonAuthnDisabled = "AUTHN_DISABLED" ReasonAllowed = "ALLOWLIST" ReasonOK = "OK" )
Internal reason codes. Externally we always return a single opaque "invalid credentials" status; these codes are for the audit hook only.
const DefaultAPITokenTTL = 90 * 24 * time.Hour
DefaultAPITokenTTL is what IssueAPIToken uses if the caller passes 0.
const MaxAPITokenTTL = 365 * 24 * time.Hour
MaxAPITokenTTL caps how far in the future a self-signed JWT can be valid. Operators MUST NOT issue longer tokens — the longer the TTL, the bigger the blast radius of a leak.
const SelfIssuer = "scheduler"
Issuer is "scheduler" — a stable, vendor-namespaced string we emit in our self-signed JWTs. The OIDCVerifier uses this to skip OIDC verification on our own tokens (saves a JWKS lookup) and the JWTVerifier asserts on it.
Variables ¶
var ErrNotFound = errors.New("auth: not found")
ErrNotFound is returned by Store.GetServiceAccount when no row matches.
Functions ¶
func HTTPMiddleware ¶
func HTTPMiddleware(authn Authenticator, allowlist *Allowlist, auditHook AuditHook) func(http.Handler) http.Handler
HTTPMiddleware returns an http.Handler middleware suitable for a grpc-gateway in front of the gRPC server. Same semantics as the unary interceptor: extract Bearer, verify, inject Actor; on failure return 401 with the uniform message.
Allowlist matching is by URL path (e.g. "/healthz") — gateway methods aren't gRPC FullMethod-named so the allowlist entries used here are HTTP-path-shaped.
func ScopesFromContext ¶
ScopesFromContext returns the actor scopes (copy, safe for caller mutation). v1.0 does not enforce scopes; this is forward-compat plumbing.
func TenantIDFromContext ¶
TenantIDFromContext is a convenience wrapper. Returns "" if the request has no Actor bound (allowlist) or the Actor lacks a tenant claim.
func UnaryServerInterceptor ¶
func UnaryServerInterceptor(authn Authenticator, allowlist *Allowlist, auditHook AuditHook) grpc.UnaryServerInterceptor
UnaryServerInterceptor returns a grpc.UnaryServerInterceptor that:
- Bypasses verification on allowlisted methods (no Actor injected).
- Extracts the Bearer token from the `authorization` metadata header.
- Calls authn.Authenticate; on success injects the Actor into ctx.
- Calls auditHook.Record on every decision.
- On any failure returns codes.Unauthenticated with a uniform message.
Nil-safe: a nil authn or nil allowlist is a misconfiguration. We choose deny-by-default — the only way to bypass auth is via an explicit allowlist entry. A nil auditHook is silently treated as NopAuditHook.
Types ¶
type Actor ¶
Actor is the verified identity injected into ctx by the auth middleware. All exported RPC handlers MUST read it via ActorFromContext rather than trusting any request-side metadata.
Scopes is reserved for future RBAC use (ADR-0006 §5.2). v1.0 does not enforce scope-based access control: every actor has full CRUD inside their tenant.
func ActorFromContext ¶
ActorFromContext returns the verified Actor (or nil if the request bypassed auth via the allowlist).
type ActorType ¶
type ActorType string
ActorType discriminates between the two identity classes ADR-0006 supports.
const ( // ActorHuman is an OIDC-authenticated end user (token came from a customer // IdP). Email is populated; the lifecycle is the IdP's responsibility. ActorHuman ActorType = "human" // ActorService is a programmatic caller using a self-signed JWT we issued // via IssueAPIToken. Email is empty; lifecycle (issue/revoke) is owned by // this package. ActorService ActorType = "service" )
type Allowlist ¶
type Allowlist struct {
// contains filtered or unexported fields
}
Allowlist is a set of fully-qualified gRPC method names (e.g. "/scheduler.v1.Health/Ping") that bypass authentication entirely.
The set is constructed once at server startup and read concurrently from every interceptor invocation. Reads are lock-free because the underlying map is never mutated after NewAllowlist returns; if you need dynamic reload, build a new Allowlist and swap it atomically at the call site.
func DefaultAllowlist ¶
func DefaultAllowlist() *Allowlist
DefaultAllowlist returns the v1.0 baseline: the unauthenticated probes declared in proto/scheduler/v1/scheduler.proto plus the M1 admin-server HTTP paths (which never reach this interceptor but are listed for operator clarity).
func NewAllowlist ¶
NewAllowlist constructs an Allowlist from the supplied method names. Empty strings are ignored so callers can splat optional config without nil-checks.
type AuditEvent ¶
type AuditEvent struct {
Time time.Time `json:"time"`
Actor Actor `json:"actor"` // zero value for ALLOWLIST / failed auth
Action string `json:"action"` // "auth.allow" | "auth.deny" | RPC method (T07)
Target string `json:"target,omitempty"` // populated by RPC handlers (T07); empty for auth events
Result string `json:"result"` // "ok" | "error"
Reason string `json:"reason,omitempty"` // see Reason* constants in auth.go
RequestIP string `json:"request_ip,omitempty"`
}
AuditEvent is the structured record emitted on every authentication decision (and, post-T07, on every write RPC). Field set is the union of ADR-0006 §5.4 + the auth middleware's needs.
Tokens are NOT a field. Even a prefix of a token is correlation-prone and must not appear in audit storage.
type AuditHook ¶
type AuditHook interface {
Record(ctx context.Context, event AuditEvent)
}
AuditHook is the persistence seam the middleware drives. T07 will land a SQLite/ClickHouse-backed implementation; v1.0 ships NopAuditHook (default) and LoggingAuditHook (zap fallback for ops debugging).
Implementations MUST be nil-safe at the call site: the middleware checks for a nil hook before invoking, but defensive implementations should also tolerate zero-value AuditEvent.
type AuthError ¶
type AuthError struct {
Reason string // see Reason* constants below
Err error // optional underlying cause, for tests / structured logs
}
AuthError carries an internal-only reason code (used by audit hooks) plus the wrapped underlying error (used by tests). The middleware deliberately does NOT propagate Reason into client-visible error messages — that would be an oracle (e.g. "expired" vs "revoked" tells an attacker which jti to keep trying).
type Authenticator ¶
type Authenticator interface {
// Authenticate verifies a bearer token and returns the resolved Actor.
// Implementations MUST return AuthError so the middleware can surface
// the internal reason to the audit hook without leaking it to the
// client.
Authenticate(ctx context.Context, bearer string) (*Actor, error)
}
Authenticator is the verification surface the middleware calls. Concrete implementations live in oidc.go (OIDCVerifier) and jwt.go (JWTVerifier). A ChainAuthenticator (this file) tries each in order, mirroring the dual-mode design of ADR-0006 Option C.
type ChainAuthenticator ¶
type ChainAuthenticator struct {
Members []Authenticator
}
ChainAuthenticator tries each Authenticator in order. The first one to return a non-mismatch error or a successful Actor wins. If every member returns errAuthenticatorMismatch the chain returns ReasonInvalidToken.
This is how ADR-0006 §5.1 + §5.2 plug together: OIDCVerifier first (recognises issuers it knows), JWTVerifier as fallback (recognises scheduler-signed tokens).
func (*ChainAuthenticator) Authenticate ¶
Authenticate runs each member in order.
type Issuer ¶
type Issuer struct {
// contains filtered or unexported fields
}
Issuer is the signing side. Holds the ES256 private key + a Store reference for revocation lifecycle. Built from env via NewIssuerFromEnv on the signing node only.
func NewIssuer ¶
func NewIssuer(priv *ecdsa.PrivateKey, store Store) *Issuer
NewIssuer constructs an Issuer with explicit dependencies. Useful for tests; production code should prefer NewIssuerFromEnv.
func NewIssuerFromEnv ¶
NewIssuerFromEnv reads SCHEDULER_JWT_SIGNING_KEY (PEM ES256 private) and returns an Issuer. Returns an error if the env var is missing or the key is not ES256 — fail-closed configuration per ADR-0006.
func (*Issuer) IssueAPIToken ¶
func (i *Issuer) IssueAPIToken(serviceAccountID, tenantID string, scopes []string, ttl time.Duration) (token string, jti string, err error)
IssueAPIToken signs a fresh ES256 JWT for the given service account. The returned token MUST be handed directly to the operator (typically via the CLI stdout) and never written to a log or DB beyond what the test fixture needs.
Returns the encoded JWT and its jti so the caller can reference it for revocation later.
type IssuerConfig ¶
type IssuerConfig struct {
Issuer string `json:"issuer"`
Audience string `json:"audience"`
TenantClaim string `json:"tenant_claim"`
}
IssuerConfig is one entry in the SCHEDULER_OIDC_ISSUERS trust list. The JSON shape is:
[
{
"issuer": "https://corp-sso.example.com",
"audience": "scheduler",
"tenant_claim": "tenant_id"
}
]
All three fields are required. tenant_claim names the JWT claim path that carries the customer's tenant id (ADR-0006 §5.3); we use a per-deployment configurable name because some IdPs ship custom URI claims (e.g. "https://scheduler.kybs/tenant").
func ParseIssuerConfigs ¶
func ParseIssuerConfigs(raw string) ([]IssuerConfig, error)
ParseIssuerConfigs parses the JSON array env var. Returns an error if the list is empty or any field is blank — fail-closed by design (ADR-0006).
type JWTVerifier ¶
type JWTVerifier struct {
// contains filtered or unexported fields
}
JWTVerifier verifies self-signed scheduler tokens against the configured public key, asserts iss == SelfIssuer, and checks the denylist.
Implements Authenticator.
func NewJWTVerifier ¶
func NewJWTVerifier(pub *ecdsa.PublicKey, store Store) *JWTVerifier
NewJWTVerifier constructs a verifier from an explicit public key. Tests pass the pair generated by the same fixture used in jwt_test.go.
func NewJWTVerifierFromEnv ¶
func NewJWTVerifierFromEnv(getenv func(string) string, store Store) (*JWTVerifier, error)
NewJWTVerifierFromEnv reads SCHEDULER_JWT_PUBLIC_KEY (PEM ES256 public). Like the Issuer constructor, it fails closed on missing/malformed input.
func (*JWTVerifier) Authenticate ¶
Authenticate verifies the bearer token. Returns errAuthenticatorMismatch if the token's iss is not SelfIssuer (so ChainAuthenticator can fall through to the OIDC verifier).
type LoggingAuditHook ¶
LoggingAuditHook writes one structured zap line per event. Intended as a stop-gap for development environments before T07 lands persistence.
func (LoggingAuditHook) Record ¶
func (h LoggingAuditHook) Record(_ context.Context, event AuditEvent)
Record emits the event as a single info-level zap entry. Falls back to no-op if Logger is nil so callers don't have to guard.
type NopAuditHook ¶
type NopAuditHook struct{}
NopAuditHook discards every event. Used when the host application has not configured a hook; preferred over a nil interface so call sites can call Record unconditionally.
func (NopAuditHook) Record ¶
func (NopAuditHook) Record(context.Context, AuditEvent)
Record discards.
type OIDCVerifier ¶
type OIDCVerifier struct {
// contains filtered or unexported fields
}
OIDCVerifier is the Authenticator implementation for OIDC ID tokens. It holds one *providerHandle per trusted issuer and routes the incoming token to the right one based on its `iss` claim.
func NewOIDCVerifier ¶
func NewOIDCVerifier(ctx context.Context, configs []IssuerConfig, httpCli *http.Client) (*OIDCVerifier, error)
NewOIDCVerifier constructs an OIDCVerifier from a parsed trust list. The http.Client is used both for OIDC discovery and JWKS fetch; pass nil to use http.DefaultClient. Requires a context for the initial discovery roundtrip.
func (*OIDCVerifier) Authenticate ¶
Authenticate verifies an OIDC ID token. Returns errAuthenticatorMismatch if the token's `iss` is SelfIssuer (i.e. one of our self-signed JWTs) so ChainAuthenticator can route it to JWTVerifier instead.
type Revocation ¶
Revocation is one row in the JWT denylist. ExpiresAt mirrors the original token's `exp` so the row can be GC'd once the token would have expired anyway (cleaner than keeping forever).
type SQLiteStore ¶
type SQLiteStore struct {
// contains filtered or unexported fields
}
SQLiteStore is the v1.0 Store implementation. The DB layout:
service_accounts(id TEXT PRIMARY KEY, name TEXT, tenant_id TEXT,
created_at INTEGER, created_by TEXT)
revocations(jti TEXT PRIMARY KEY, expires_at INTEGER)
Both tables are tiny by design: ADR-0006 §5.2 caps service accounts at "tens to hundreds" and the denylist GCs against expires_at. We use INTEGER (Unix epoch nanoseconds) rather than TEXT for time columns so range queries don't pay parsing cost on every lookup.
func OpenSQLiteStore ¶
func OpenSQLiteStore(dsn string) (*SQLiteStore, error)
OpenSQLiteStore opens (or creates) a SQLite DB at dsn and ensures schema exists. Pass ":memory:" or "file::memory:?cache=shared" for tests.
The pragma settings are aggressive on durability + WAL — appropriate for a security boundary where we'd rather lose perf than lose denylist rows.
func (*SQLiteStore) CreateServiceAccount ¶
func (s *SQLiteStore) CreateServiceAccount(ctx context.Context, sa ServiceAccount) error
CreateServiceAccount inserts a row. Returns an error if id collides.
func (*SQLiteStore) DeleteServiceAccount ¶
func (s *SQLiteStore) DeleteServiceAccount(ctx context.Context, id string) error
DeleteServiceAccount is idempotent: deleting a non-existent row is OK.
func (*SQLiteStore) GetServiceAccount ¶
func (s *SQLiteStore) GetServiceAccount(ctx context.Context, id string) (*ServiceAccount, error)
GetServiceAccount returns the row or ErrNotFound.
func (*SQLiteStore) IsRevoked ¶
IsRevoked reports whether jti is in the denylist AND its expires_at is in the future. Past-expiry rows are treated as not revoked because the JWT itself is already invalid via `exp`.
func (*SQLiteStore) ListServiceAccounts ¶
func (s *SQLiteStore) ListServiceAccounts(ctx context.Context) ([]ServiceAccount, error)
ListServiceAccounts returns every row sorted by created_at desc (newest first — matches what an operator would expect from `list-sa`).
func (*SQLiteStore) PurgeExpiredRevocations ¶
PurgeExpiredRevocations deletes rows whose expires_at <= before. Returns the deleted row count.
func (*SQLiteStore) Revoke ¶
func (s *SQLiteStore) Revoke(ctx context.Context, r Revocation) error
Revoke writes the jti to the denylist. Uses INSERT OR REPLACE so calling Revoke twice with the same jti is idempotent (and lets a caller bump expires_at if they need to).
type ServiceAccount ¶
type ServiceAccount struct {
ID string
Name string
TenantID string
CreatedAt time.Time
CreatedBy string
}
ServiceAccount is the persistent record an admin creates via cmd/auth-admin before issuing any JWT. ID is the value that ends up in the JWT `sub` claim; TenantID is bound at creation and immutable (ADR-0006 §5.3).
type Store ¶
type Store interface {
// CreateServiceAccount inserts a new row. Returns an error if the ID is
// already taken.
CreateServiceAccount(ctx context.Context, sa ServiceAccount) error
// GetServiceAccount returns the row identified by id. Returns ErrNotFound
// if no such row exists.
GetServiceAccount(ctx context.Context, id string) (*ServiceAccount, error)
// ListServiceAccounts returns all service accounts visible in the store.
// CLI-only API; not exposed via RPC in v1.0.
ListServiceAccounts(ctx context.Context) ([]ServiceAccount, error)
// DeleteServiceAccount removes a row. Idempotent: deleting a non-existent
// row is not an error.
DeleteServiceAccount(ctx context.Context, id string) error
// Revoke records that the given jti is denied until expires_at. Calling
// Revoke twice for the same jti is idempotent.
Revoke(ctx context.Context, r Revocation) error
// IsRevoked reports whether the given jti is in the denylist AND not
// past its expiry. Past-expiry rows are treated as not revoked (the
// underlying token is already invalid via `exp`); GC removes them
// lazily.
IsRevoked(ctx context.Context, jti string) (bool, error)
// PurgeExpiredRevocations deletes denylist rows whose expires_at is
// older than `before`. Called from the CLI; can also be invoked
// periodically by the host server (not done in v1.0).
PurgeExpiredRevocations(ctx context.Context, before time.Time) (int64, error)
// Close releases the underlying database handle. Idempotent.
Close() error
}
Store is the persistence seam for the auth package. v1.0 ships a SQLite implementation (store_sqlite.go) chosen over Redis (ADR-0006 §5.2) because we don't want to introduce a new ops surface for an MVP-scale use case. The interface is small enough that swapping in Redis or Postgres later is a single-file change.