auth

package
v1.0.1 Latest Latest
Warning

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

Go to latest
Published: May 15, 2026 License: MIT Imports: 26 Imported by: 0

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

View Source
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.

View Source
const DefaultAPITokenTTL = 90 * 24 * time.Hour

DefaultAPITokenTTL is what IssueAPIToken uses if the caller passes 0.

View Source
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.

View Source
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

View Source
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

func ScopesFromContext(ctx context.Context) []string

ScopesFromContext returns the actor scopes (copy, safe for caller mutation). v1.0 does not enforce scopes; this is forward-compat plumbing.

func TenantIDFromContext

func TenantIDFromContext(ctx context.Context) string

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:

  1. Bypasses verification on allowlisted methods (no Actor injected).
  2. Extracts the Bearer token from the `authorization` metadata header.
  3. Calls authn.Authenticate; on success injects the Actor into ctx.
  4. Calls auditHook.Record on every decision.
  5. 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.

func WithActor

func WithActor(ctx context.Context, a *Actor) context.Context

WithActor returns a copy of ctx carrying the verified Actor and (as a convenience) its TenantID + Scopes under separate keys so handlers that only care about tenant_id don't need to type-assert the whole Actor.

Types

type Actor

type Actor struct {
	Type     ActorType
	ID       string
	TenantID string
	Scopes   []string
	Email    string
}

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

func ActorFromContext(ctx context.Context) *Actor

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

func NewAllowlist(paths ...string) *Allowlist

NewAllowlist constructs an Allowlist from the supplied method names. Empty strings are ignored so callers can splat optional config without nil-checks.

func (*Allowlist) Has

func (a *Allowlist) Has(fullMethod string) bool

Has reports whether the given fully-qualified method is allowlisted. A nil receiver returns false (deny-by-default — never silently bypass auth when the allowlist is missing).

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

func (*AuthError) Error

func (e *AuthError) Error() string

func (*AuthError) Unwrap

func (e *AuthError) Unwrap() error

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

func (c *ChainAuthenticator) Authenticate(ctx context.Context, bearer string) (*Actor, error)

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

func NewIssuerFromEnv(getenv func(string) string, store Store) (*Issuer, error)

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.

func (*Issuer) RevokeAPIToken

func (i *Issuer) RevokeAPIToken(ctx context.Context, jti string, expiresAt time.Time) error

RevokeAPIToken adds the jti to the denylist with the given expires_at. Idempotent.

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

func (v *JWTVerifier) Authenticate(ctx context.Context, bearer string) (*Actor, error)

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

type LoggingAuditHook struct {
	Logger *zap.Logger
}

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

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

func (v *OIDCVerifier) Authenticate(ctx context.Context, bearer string) (*Actor, error)

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

type Revocation struct {
	JTI       string
	ExpiresAt time.Time
}

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

func (s *SQLiteStore) Close() error

Close releases the DB handle.

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

func (s *SQLiteStore) IsRevoked(ctx context.Context, jti string) (bool, error)

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

func (s *SQLiteStore) PurgeExpiredRevocations(ctx context.Context, before time.Time) (int64, error)

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.

Jump to

Keyboard shortcuts

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