Documentation
¶
Overview ¶
Package kal @notice Authentication and authorization for gqlgen applications on Postgres, as an embedded library rather than a service.
@dev Three positions, each argued against how the rest of the ecosystem does it:
**Sessions in your database.** Opaque server-side sessions, so revoking one is an UPDATE and "log out everywhere" is one statement. Kratos, SuperTokens and Zitadel take your users table into a separate service, which costs you the JOIN, the shared transaction, and a second backup and migration story. Because the session cookie is the long-lived credential, kal ships no refresh token at all — the rotating-family subsystem every JWT-first library must build does not exist here.
**Identity in the request context.** The middleware is net/http, mounted inside luima's adaptor, so a resolver reads a typed Principal from its own ctx. Anonymous is not an error: one endpoint serves public and private fields in the same document, and the graph decides.
**Authorization in the WHERE clause.** Scope composes the caller's ownership predicate into the statement. A policy engine answers "may Alice read document 7"; it does not stop a list query returning everything, and it cannot apply to a DELETE without a read-then-check round trip that has a TOCTOU window.
The packages ¶
This package re-exports the ones below, so the common case needs one import:
[github.com/ulas96/kal/authn] passwords, registration, login, recovery [github.com/ulas96/kal/authz] Principal, @auth, Scope, coverage, RLS [github.com/ulas96/kal/session] sessions, the cookie, the middleware, the JWT leg [github.com/ulas96/kal/kalerr] the error contract [github.com/ulas96/kal/e2ee] client-side encryption, which kal cannot undo [github.com/ulas96/kal/migrations] the schema, as .sql behind an embed.FS
The types below are aliases, not copies, so the two spellings are interchangeable. The cost, stated plainly: a genuinely new sub-package export is invisible from here until it is added by hand, and tests/ asserts the identity of the ones that exist.
Wiring ¶
Requires luima ≥ 0.2.0 for the HTTPMiddleware and Configure seams:
auth, err := kal.New(kal.Config{
DB: db,
BaseURL: "https://app.example.com",
Mailer: myMailer,
})
app := luima.New(luima.Config{
Schema: generated.NewExecutableSchema(c),
HTTPMiddleware: []func(http.Handler) http.Handler{auth.Middleware()},
Configure: auth.Configure(),
ErrorPresenter: kal.PresentError,
})
with `c.Directives.Auth = auth.Directive()` and authz.DirectiveSDL pasted into the schema.
Index ¶
- Constants
- func AssertAuthCoverage(schema graphql.ExecutableSchema, exempt ...string) error
- func AssertDirectivesWired(directiveRoot any) error
- func DefaultSensitiveFields() []string
- func HasRole(ctx context.Context, role string) bool
- func NewRecoveryCode() (string, error)
- func PresentError(ctx context.Context, err error) *gqlerror.Error
- func Scope(ctx context.Context, column string) func(*orm.Query) *orm.Query
- func ValidateAuthSecret(s string) error
- func ValidatePassword(password string) error
- func WithRLSSettings(ctx context.Context, db *pg.DB, extra map[string]string, fn func(orm.DB) error) error
- type Audit
- type Auth
- func (a *Auth) Configure() func(*handler.Server)
- func (a *Auth) Delete[T any](ctx context.Context, db orm.DB, column string, key *T, ...) (bool, error)
- func (a *Auth) Directive() ...
- func (a *Auth) Get[T any](ctx context.Context, db orm.DB, column string, key *T, ...) (*T, error)
- func (a *Auth) List[T any](ctx context.Context, db orm.DB, column string, ...) ([]*T, error)
- func (a *Auth) Middleware() func(http.Handler) http.Handler
- func (a *Auth) Migrate(ctx context.Context) error
- func (a *Auth) MigrateSchema(ctx context.Context, schema string) error
- func (a *Auth) Update[T any](ctx context.Context, db orm.DB, column string, m *T, label string, ...) (*T, error)
- func (a *Auth) WithRLS(ctx context.Context, fn func(orm.DB) error) error
- type AuthLevel
- type Config
- type Error
- type Event
- type Hasher
- type LogMailer
- type Mailer
- type Message
- type Params
- type Principal
- type SessionInfo
- type Vault
- type VaultOptions
- type VaultParams
- type Vaults
Constants ¶
const ( // DefaultMaxAliases @notice Selections allowed in one document. DefaultMaxAliases = 100 // DefaultMaxDepth @notice Nesting allowed in one document. // // @dev luima's ComplexityLimit does not cover this: gqlgen's complexity is per selected // field, so 400 levels of nesting through a cyclic schema costs about 400 and sails past a // 1000 limit. DefaultMaxDepth = 15 )
Guard defaults. Every one of them is a limit on the document, not on the request, because GraphQL executes many operations per request and an HTTP-request rate limiter counts none of them.
const ( KDFArgon2id = e2ee.KDFArgon2id KDFPBKDF2 = e2ee.KDFPBKDF2 )
The client KDF names, re-exported so a consumer need not import e2ee to switch on one.
const ( LevelAnonymous = authz.LevelAnonymous LevelAuthenticated = authz.LevelAuthenticated )
The AuthLevel values, re-exported so a consumer need not import authz for a switch.
Variables ¶
This section is empty.
Functions ¶
func AssertAuthCoverage ¶
func AssertAuthCoverage(schema graphql.ExecutableSchema, exempt ...string) error
AssertAuthCoverage @notice Fails if any field is reachable without an @auth annotation. Call it from a test. See authz.AssertAuthCoverage.
func AssertDirectivesWired ¶
AssertDirectivesWired @notice Fails if any directive implementation is nil. See authz.AssertDirectivesWired.
func DefaultSensitiveFields ¶ added in v0.5.0
func DefaultSensitiveFields() []string
DefaultSensitiveFields @notice The fields Config.SensitiveFields defaults to, as a fresh copy.
@dev Config.SensitiveFields replaces this list rather than extending it, so a deployment adding its own names had to restate kal's or silently lose the aliasing guard on login. Returning a copy keeps a caller's append from writing through into kal's own defaults.
@return []string a copy of kal's default sensitive-field list
func HasRole ¶
HasRole @notice Whether the caller holds the named role. See authz.HasRole.
func NewRecoveryCode ¶ added in v0.3.0
NewRecoveryCode @notice Mints a vault recovery code, shown once. See e2ee.NewRecoveryCode.
func PresentError ¶
PresentError @notice luima's presenter plus an extensions.code for kal's errors. Set it as luima's Config.ErrorPresenter. See kalerr.PresentError.
func Scope ¶
Scope @notice The caller's ownership predicate, for luima's crud options. See authz.Scope.
func ValidateAuthSecret ¶ added in v0.3.0
ValidateAuthSecret @notice Applies the client-derived secret's shape. See e2ee.ValidateAuthSecret.
func ValidatePassword ¶
ValidatePassword @notice Applies the password policy. See authn.ValidatePassword.
func WithRLSSettings ¶ added in v0.4.0
func WithRLSSettings(ctx context.Context, db *pg.DB, extra map[string]string, fn func(orm.DB) error) error
WithRLSSettings @notice WithRLS, plus consumer settings on the same transaction. See authz.WithRLSSettings.
@dev The direct form, for a caller with its own pool or one resolving settings at the call site rather than through Config.RLSSettings.
Types ¶
type Audit ¶ added in v0.4.0
Audit @notice Called for every security-relevant event. See authz.Audit.
type Auth ¶
type Auth struct {
// Sessions @notice Issue, look up, rotate, revoke and list sessions.
Sessions *session.Sessions
// Accounts @notice Register, log in, recover, change a password.
Accounts *authn.Accounts
// Roles @notice Grant and revoke role membership.
Roles *authz.Roles
// Hasher @notice Password hashing, for importing an existing user base.
Hasher *authn.Hasher
// JWT @notice The optional bearer-token leg. Nil unless JWTIssuer was set.
JWT *session.JWT
// Vaults @notice The optional client-encryption vault. Nil unless Config.E2EE was set.
Vaults *e2ee.Vaults
// contains filtered or unexported fields
}
Auth @notice Everything kal exposes to an application, wired and validated.
func New ¶
New @notice Validates the configuration and wires everything up.
@dev Fails loudly on anything that cannot have a safe default. Every other field has one, and every default is the production posture.
@param cfg the configuration; DB, BaseURL and Mailer are required @return *Auth the wired library @return error a description of what is missing or contradictory
func (*Auth) Configure ¶
Configure @notice Applies kal's gqlgen extensions: the anti-batching guard, conditional introspection, and suggestion suppression.
Pass it to luima's Config.Configure (luima ≥ 0.2.0).
@dev SetDisableSuggestion is here rather than optional because gqlparser's "Did you mean …?" text passes straight through luima's presenter by design, so a caller guessing a field name still learns the real one with introspection off.
@return func(*handler.Server) applied immediately before the handler is mounted
func (*Auth) Delete ¶ added in v0.6.0
func (a *Auth) Delete[T any](ctx context.Context, db orm.DB, column string, key *T, opts ...func(*orm.Query) *orm.Query) (bool, error)
Delete @notice Removes the caller's row, reporting whether one was there. See luima.Delete.
@dev False for someone else's row, and the row stays in the table. That pair is the property worth testing: a delete that reports nothing while the row quietly disappears passes any test that only checks the return value.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param column the owning column on T's table @param key a model with only its primary key populated @param opts further query modifiers, applied after the ownership predicate @return bool true when a row was deleted, false when none matched or the caller does not own it @return error any driver error
func (*Auth) Directive ¶
func (a *Auth) Directive() func(context.Context, any, graphql.Resolver, AuthLevel, []string, *bool, []string) (any, error)
Directive @notice The @auth implementation for your generated DirectiveRoot.
c.Directives.Auth = auth.Directive()
Paste authz.DirectiveSDL into your schema for the matching declaration.
func (*Auth) Get ¶ added in v0.6.0
func (a *Auth) Get[T any](ctx context.Context, db orm.DB, column string, key *T, opts ...func(*orm.Query) *orm.Query) (*T, error)
Get @notice Selects one row by primary key, if the caller owns it. See luima.Get.
@dev A row the caller does not own is (nil, nil) — the same answer as a row that does not exist, which is the correct thing to tell an unauthorized caller and the reason this does not return a NOT_FOUND the caller could use to probe for the existence of other people's rows.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param column the owning column on T's table @param key a model with only its primary key populated @param opts further query modifiers, applied after the ownership predicate @return *T the stored row, or nil when no row matched or the caller does not own it @return error any driver error other than pg.ErrNoRows
func (*Auth) List ¶ added in v0.6.0
func (a *Auth) List[T any](ctx context.Context, db orm.DB, column string, opts ...func(*orm.Query) *orm.Query) ([]*T, error)
List @notice Selects the caller's rows, and only the caller's. See luima.List.
@dev The read that leaks the most when its predicate is forgotten: one missing option and the resolver answers with every tenant's rows, with no error and a passing test.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param column the owning column on T's table @param opts further query modifiers, applied after the ownership predicate @return []*T the caller's rows, never nil; empty for an anonymous caller @return error any driver error
func (*Auth) Middleware ¶
Middleware @notice The net/http middleware that resolves the session cookie into a Principal, carries the cookie jar, and enforces the cross-site transport guard.
Pass it to luima's Config.HTTPMiddleware (luima ≥ 0.2.0), or mount it in any net/http stack.
@return func(http.Handler) http.Handler outermost-first middleware
func (*Auth) Migrate ¶
Migrate @notice Applies every embedded migration in order.
@dev A convenience, not a migration framework: it runs the .sql files and nothing else — no version table, no down migrations, no locking. Applications with their own tooling should feed it migrations.FS instead. Each file is idempotent only in the sense that a second run fails loudly on the existing tables rather than corrupting them.
The DDL runs on the connection's search_path and this method ignores Config.TableSchema, which is correct for one application and a footgun for a fan-out loop. Use Auth.MigrateSchema for the multi-schema case; gotcha 80 is what happens otherwise.
@param ctx the context for the statements @return error the first failure, naming the file
func (*Auth) MigrateSchema ¶ added in v0.4.0
MigrateSchema @notice Applies every embedded migration into the named schema, creating it if it does not exist.
@dev Auth.Migrate runs the DDL on the connection's search_path, which is correct for a single application and a footgun for a fan-out loop: the caller must keep search_path and Config.TableSchema in agreement across N iterations, and a mismatch provisions one tenant's auth tables into another tenant's schema with no error at all. Taking the name here makes the two agree by construction. Still not a migration framework — no version table, no down migrations, no locking.
@param ctx the context for the statements @param schema the Postgres schema to create and migrate into; must match ^[a-z_][a-z0-9_]*$ @return error the first failure, naming the file or the schema
func (*Auth) Update ¶ added in v0.6.0
func (a *Auth) Update[T any](ctx context.Context, db orm.DB, column string, m *T, label string, opts ...func(*orm.Query) *orm.Query) (*T, error)
Update @notice Replaces every column of the caller's row. See luima.Update.
@param ctx the resolver context @param db orm.DB — *pg.DB, *pg.Conn and *pg.Tx all satisfy it @param column the owning column on T's table @param m the complete model, primary key included; every column is written @param label names the thing in the not-found message @param opts further query modifiers, applied after the ownership predicate; q.Column(...) narrows the SET clause @return *T the stored row @return error a *kalerr-presentable not-found when no row matched or the caller does not own it, the bare driver error otherwise
func (*Auth) WithRLS ¶
WithRLS @notice Runs fn in a transaction whose Postgres session variables carry the caller, plus whatever Config.RLSSettings resolves for this request. See authz.WithRLS for the four ways an RLS deployment breaks silently.
type AuthLevel ¶
AuthLevel @notice The @auth directive's requires argument. See authz.AuthLevel.
type Config ¶
type Config struct {
// DB @notice The pool everything runs on. Required.
DB *pg.DB
// BaseURL @notice The origin every emailed link is built under. Required.
//
// @dev Cannot be derived from the request: a link origin taken from the Host header is
// Host-header injection, and a password-reset email is the last place to accept
// attacker-controlled input. Must be https outside loopback.
BaseURL string
// Mailer @notice Delivers verification, reset and invite messages. Required.
//
// @dev No default, and deliberately no silent no-op: "I forgot to configure email" must
// fail at construction, not at 3am when nobody can reset a password. [LogMailer] is the
// development answer, and its name says not to ship it.
Mailer Mailer
// TableSchema @notice Postgres schema holding the auth_* tables. Empty means search_path.
TableSchema string
// IdleTimeout @notice Session inactivity timeout. Default 12h.
IdleTimeout time.Duration
// AbsoluteTimeout @notice Hard session lifetime, never extended. Default 14d.
AbsoluteTimeout time.Duration
// CookieName @notice The session cookie. Default "__Host-kal_session".
//
// @dev Change it only to run two kal instances on one origin. Keep the __Host- prefix: it
// is what stops a sibling subdomain overwriting the cookie.
CookieName string
// Argon2 @notice Password hashing parameters. Zero fields take the OWASP defaults.
Argon2 authn.Params
// MaxConcurrentHashes @notice In-flight Argon2 ceiling. Zero means GOMAXPROCS.
//
// @dev Each hash holds ~19 MiB, so this is what stops concurrent logins from becoming a
// remote OOM. Per replica: behind N replicas the real ceiling is N times this. Per *instance*
// too — see Hasher below, which is the seam that makes it process-wide.
MaxConcurrentHashes int64
// Hasher @notice A pre-built password hasher to use instead of building one. Nil builds one
// from Argon2 and MaxConcurrentHashes.
//
// @dev The seam multi-instance deployments need. Every New otherwise builds its own Hasher
// with its own semaphore, so a process holding N instances — one per tenant schema, which is
// what TableSchema exists for — holds N × MaxConcurrentHashes in-flight hashes at ~19 MiB
// each. Zero means GOMAXPROCS *per instance*, so the parameter that bounds a remote OOM stops
// bounding it at exactly the point a deployment gets big enough to care (gotcha 79).
//
// Sharing one Hasher makes the bound process-wide, and the cost is honest: one instance's
// login storm queues another's. Take it. A queue recovers; an OOM kills every tenant in the
// process.
Hasher *authn.Hasher
// BypassRole @notice A role for which [Scope] is a no-op and any @auth roles requirement
// is satisfied. Empty means none — there is no implicit "admin".
BypassRole string
// Audit @notice Called for every security-relevant event kal sees. Nil discards them.
//
// @dev kal observes things a consumer structurally cannot — the backoff window opening, a
// login succeeding after N failures, rehash-on-login firing, the session lookup failing on
// the driver — and until this existed they went to log.Printf or nowhere. Grepping a log
// stream for "kal/authn:" is not an audit trail.
//
// Same shape as Mailer: kal ships no sink, because a sink is a dependency and every
// deployment already has one. Called synchronously, so an implementation that talks to the
// network must queue internally. See [authz.Event] for the vocabulary.
Audit authz.Audit
// RLSSettings @notice Extra Postgres settings [Auth.WithRLS] carries, resolved per request.
// Nil carries only app.user_id and app.roles.
//
// @dev The config-level form rather than a second entry point, so tenancy resolution lives in
// one place at construction and a resolver cannot forget to pass it. Keys must be
// app.-prefixed; see [authz.WithRLSSettings] for why that is checked.
RLSSettings func(context.Context) map[string]string
// MFAWindow @notice How recently MFA must have been satisfied for @auth(mfa: true).
// Default 15m.
MFAWindow time.Duration
// AllowUnverifiedLogin @notice Lets accounts log in before verifying their email. Off by
// default.
AllowUnverifiedLogin bool
// ClientIP @notice How to attribute a request to a client address. Default: the host part
// of RemoteAddr.
//
// @dev Not X-Forwarded-For by default — that header is client-supplied unless a trusted
// proxy overwrites it, and a spoofable address turns per-IP rate limiting into a bypass.
ClientIP func(*http.Request) string
// AllowIntrospection @notice Decides per request whether introspection is answered. Nil
// means never.
//
// @dev luima turns introspection on and, since 0.2.0, offers Config.DisableIntrospection to
// turn it off again — an all-or-nothing deploy-time switch. This is the per-request form, so
// it can be role-gated: func(ctx) bool { return authz.HasRole(ctx, "admin") }. Off by
// default, because the zero Config is the production posture.
AllowIntrospection func(context.Context) bool
// SensitiveFields @notice Fields that may be selected at most once per document. Nil takes
// kal's defaults (login, register, the recovery mutations).
//
// @dev Set this if your login mutation has another name, or the aliasing guard protects
// nothing.
SensitiveFields []string
// MaxAliases @notice Selections allowed per document. Zero means 100. Negative disables.
MaxAliases int
// MaxDepth @notice Nesting allowed per document. Zero means 15. Negative disables.
MaxDepth int
// JWTIssuer @notice The iss claim for the optional JWT leg. Empty disables it.
JWTIssuer string
// JWTKeys @notice Ed25519 signing keys, newest first. Required when JWTIssuer is set.
//
// @dev Two keys make rotation a deploy rather than an outage: the first signs, all verify.
JWTKeys []ed25519.PrivateKey
// Proofs @notice Satisfies @auth(proves:) requirements from request context. Nil denies
// every non-empty proves requirement.
//
// @dev The seam a proof module plugs into, as a plain func so kal carries no dependency on
// one. Nil failing closed is the point: an installed schema whose proof module was not wired
// must refuse, not run unguarded. See [github.com/ulas96/kal-zk/zkauthz] for an implementation.
Proofs func(context.Context, []string) error
// ExtraMiddleware @notice Middleware mounted inside the session middleware, index 0
// outermost. Nil mounts nothing.
//
// @dev Inside, not around: anything here that reads the caller runs after
// session.Middleware has resolved the cookie into a Principal, and resolves to anonymous if
// it runs first. Inside the session middleware and *outside* the BypassRole wrapper, so an
// entry here sees the caller but not the bypass role — the same position the ZK claims
// middleware held before it moved to its own module.
ExtraMiddleware []func(http.Handler) http.Handler
// E2EE @notice Optional client-side encryption. Nil keeps today's posture exactly: no vault,
// and nothing about authn changes.
//
// @dev Non-nil *tightens* the accepted secret from an 8–64 character password to 32 bytes of
// derived entropy, so this does not weaken the zero Config. What it removes is the server's
// ability to judge password strength, which is a consequence of never seeing a password and is
// documented as gotcha 76 rather than papered over. Schema is taken from TableSchema and
// whatever is set here is ignored.
E2EE *e2ee.Options
}
Config @notice Assembles kal.
@dev The zero value is the good *production* configuration, and there is no development mode that weakens a security property. This is a deliberate inversion of luima's invariant, where a zero Config is the good development configuration with the playground and introspection on. For an auth library that polarity is wrong: a Dev bool that relaxes a cookie attribute or skips a check is a vulnerability shipped as a convenience, and it reaches production, because that is what environment flags do. Anything a developer needs is an ordinary field with an obvious name.
type Error ¶
Error @notice A client-visible auth error with a stable code. See kalerr.Error.
type Event ¶ added in v0.4.0
Event @notice One security-relevant thing kal did. See authz.Event.
type Hasher ¶ added in v0.4.0
Hasher @notice Password hashing with the Argon2 work bounded. See authn.Hasher.
func NewHasher ¶ added in v0.4.0
NewHasher @notice Builds a Hasher to share across instances. See authn.NewHasher.
@dev Exported here because Config.Hasher is unusable otherwise: a consumer running one instance per tenant schema would have to import authn to build the one value that makes the Argon2 bound process-wide rather than per instance.
@param p cost parameters; zero fields take the OWASP defaults @param maxConcurrent the in-flight hash ceiling; ≤ 0 means GOMAXPROCS @return *Hasher safe for concurrent use, and for sharing between New calls @return error only a CSPRNG failure
type LogMailer ¶
LogMailer @notice A development Mailer that logs messages. See authn.LogMailer.
type Mailer ¶
Mailer @notice Delivers kal's transactional messages. See authn.Mailer.
type Principal ¶
Principal @notice The authenticated caller. See authz.Principal.
func From ¶
From @notice Returns the caller, and whether there is one. See authz.From.
type SessionInfo ¶
SessionInfo @notice One live session, as shown to its owner. See session.Info.
type Vault ¶ added in v0.3.0
Vault @notice One user's wrapped root key, opaque to kal. See e2ee.Vault.
type VaultOptions ¶ added in v0.3.0
VaultOptions @notice Configuration for the vault service. See e2ee.Options.
type VaultParams ¶ added in v0.3.0
VaultParams @notice One account's client-side KDF parameters. See e2ee.Params.
@dev Not kal.Params: that name is authn.Params, the server's Argon2id cost, and the two must never be confused for each other — they answer to different limits and feeding one from the other is how a deployment ends up with vaults nobody can open. Same renaming as SessionInfo.
type Vaults ¶ added in v0.3.0
Vaults @notice The vault service. See e2ee.Vaults.
func NewVaults ¶ added in v0.3.0
func NewVaults(opts VaultOptions) (*Vaults, error)
NewVaults @notice Builds the vault service directly. See e2ee.NewVaults.
@dev New does this for you from Config.E2EE; this is for a consumer wiring the packages separately.
Directories
¶
| Path | Synopsis |
|---|---|
|
Package authn @notice Proving who a caller is.
|
Package authn @notice Proving who a caller is. |
|
Package authz @notice Who the caller is: the Principal, carried in the request context.
|
Package authz @notice Who the caller is: the Principal, carried in the request context. |
|
Package e2ee @notice Client-side encryption: the per-user KDF parameters a browser needs, and one opaque wrapped root key per user.
|
Package e2ee @notice Client-side encryption: the per-user KDF parameters a browser needs, and one opaque wrapped root key per user. |
|
Package kalerr @notice kal's error contract: a client-visible auth error with a stable machine-readable code, and the presenter that puts it on the wire.
|
Package kalerr @notice kal's error contract: a client-visible auth error with a stable machine-readable code, and the presenter that puts it on the wire. |
|
Package migrations @notice The auth schema, as plain SQL behind an embed.FS.
|
Package migrations @notice The auth schema, as plain SQL behind an embed.FS. |
|
Package session @notice Opaque server-side sessions in Postgres — kal's primary credential.
|
Package session @notice Opaque server-side sessions in Postgres — kal's primary credential. |