kal

package module
v0.6.0 Latest Latest
Warning

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

Go to latest
Published: Aug 26, 2026 License: MIT Imports: 22 Imported by: 0

README

kal

Authentication and authorization for gqlgen applications on Postgres, as an embedded library rather than a service. Built to sit alongside luima.

Sessions in your database. Identity in the request context. Authorization in the WHERE clause.

Three positions, each one taken against how the rest of the Go ecosystem does it.

Sessions in your database. Opaque server-side sessions, so revoking one is an UPDATE, "log out everywhere" is one statement, and a user can list their own devices — all things that are structurally unimplementable with stateless-only tokens. Kratos, SuperTokens and Zitadel are separate services with separate databases, which costs you the JOIN, the shared transaction, and a second backup and migration story. And because the session cookie is the long-lived credential, kal ships no refresh token at all: nothing to rotate, no reuse-detection family, no two-tab race. That entire subsystem — the largest single chunk of every JWT-first auth library — does not exist here.

Identity in the request context. The middleware is net/http, mounted inside luima's Fiber adaptor, so a resolver reads a typed *kal.Principal from its own ctx. Anonymous is not an error and the middleware never returns 401: one GraphQL endpoint serves public and private fields in the same document, so the graph decides.

Authorization in the WHERE clause. Every authorization library in Go answers "may Alice read document 7". None answers "which documents may Alice read" without N checks or a thousand-item ID list. kal.Scope composes the caller's ownership predicate into the statement, which answers both and applies to a DELETE without a read-then-check round trip that has a TOCTOU window.

Install

go get github.com/ulas96/kal

Requires luima ≥ 0.2.0 for the HTTPMiddleware, Configure and scoped-crud seams — kal is built and tested against v0.2.1. Postgres 13 or newer (gen_random_uuid() is built in from 13).

Wiring

auth, err := kal.New(kal.Config{
    DB:      db,                          // *pg.DB
    BaseURL: "https://app.example.com",   // where emailed links point
    Mailer:  myMailer,                    // one Send method; kal ships no SMTP client
})
if err != nil {
    log.Fatal(err)
}

c := generated.Config{Resolvers: &graph.Resolver{DB: db, Auth: auth}}
c.Directives.Auth = auth.Directive()

app := luima.New(luima.Config{
    Schema:         generated.NewExecutableSchema(c),
    HTTPMiddleware: []func(http.Handler) http.Handler{auth.Middleware()},
    Configure:      auth.Configure(),
    ErrorPresenter: kal.PresentError,
})

Apply the schema with your own migration tool — the SQL is plain files behind an embed.FS (migrations.FS), or auth.Migrate(ctx) runs them in order if you have no tooling yet. Migrate runs the DDL on the connection's search_path and ignores Config.TableSchema; for one schema per tenant use auth.MigrateSchema(ctx, "tenant_a"), which creates the schema and sets search_path transaction-locally so the two cannot drift apart. If you generate code from information_schema, migrations.Tables() is the list to exclude — several kal tables carry a user_id column, and a generator following the usual owner-column convention will happily emit CRUD over auth_user_roles, which is a mutation that grants its caller any role.

Paste authz.DirectiveSDL into your .graphqls, and bind the enum in gqlgen.yml:

models:
  AuthLevel:
    model: github.com/ulas96/kal/authz.AuthLevel

A login resolver is then three lines, because the cookie travels through the context:

func (r *mutationResolver) Login(ctx context.Context, email, password string) (*model.User, error) {
    p, err := r.Auth.Accounts.Login(ctx, r.DB, email, password)
    if err != nil {
        return nil, err   // one INVALID_CREDENTIALS for every way it fails
    }
    return r.userByID(ctx, p.UserID)
}

The three authorization layers

Ship all three. Each catches what the one above it misses.

1 · The @auth directive — coarse and declarative. One composed directive, never a stack, because gqlgen chains directives inside-out and @auth @hasRole(ADMIN) runs hasRole first, which is the opposite of how everyone reads it.

type Query {
  health: String            @auth(requires: ANONYMOUS)
  me: User                  @auth
  auditLog: [Entry!]        @auth(roles: ["admin"])
  billingEmail: String      @auth(mfa: true)
}

The implementation reads only the context and never queries — a directive on a field of a list type runs once per row, so a check that costs a query is an N+1 that appears only under load.

2 · Scope — the real enforcement.

func (r *mutationResolver) DeleteDoc(ctx context.Context, id string) (bool, error) {
    return r.Auth.Delete[model.Doc](ctx, r.DB, "owner_id", &model.Doc{ID: id})
}

Auth.List, Get, Update and Delete are luima's crud helpers with the predicate already folded in — the column is a parameter, so there is no arity that forgets it. They also parenthesise your own query options, which the raw form does not: passed as a sibling to kal.Scope, a single q.WhereOr renders owner_id = $1 or owner_id = $2 and reads another tenant's rows (gotcha 84).

The raw form stays supported, and is the answer when ownership is not one column:

luima.Delete(ctx, r.DB, &model.Doc{ID: id}, kal.Scope(ctx, "owner_id"))

There is no scoped Create: an INSERT has no WHERE, so set the owning column from kal.Require(ctx) in the resolver.

A row that exists but is not yours matches nothing, so the delete reports that nothing happened. "Not yours" and "does not exist" become indistinguishable, which is the correct answer to give an unauthorized caller. An anonymous caller gets a predicate matching nothing — never an open query.

Need something kal does not model? Scope returns a plain func(*orm.Query) *orm.Query, so call OpenFGA inside your own closure and return q.Where("id = any(?)", pg.Array(ids)). There is no Authorizer interface to implement.

3 · Postgres RLS — optional, and the point of it is that it survives a forgotten check in the two above.

err := auth.WithRLS(ctx, func(tx orm.DB) error { /* … */ })

Read authz.WithRLS's doc comment before writing a policy. Four things there have each silently broken a production deployment, and docs/gotchas.md lists them.

The coverage test

The single highest-value thing in this library, and it is forty lines:

func TestAuthCoverage(t *testing.T) {
    schema := generated.NewExecutableSchema(generated.Config{Resolvers: &graph.Resolver{}})
    if err := kal.AssertAuthCoverage(schema, "Query.health", "Mutation.login"); err != nil {
        t.Fatal(err)
    }
    if err := kal.AssertDirectivesWired(generated.DirectiveRoot{Auth: auth.Directive()}); err != nil {
        t.Fatal(err)
    }
}

The failure mode of resolver-level authorization is a forgotten check, and a forgotten check is invisible: it compiles, it passes review, and it returns data. Walking the schema is the only way to see the absence of something. It reports every miss at once, and it is a test rather than a startup check so that adding a public field means a red test you annotate away — not a server that refuses to boot on a Friday.

Transport rules

kal's middleware requires every request to carry a Content-Type outside {text/plain, application/x-www-form-urlencoded, multipart/form-data}, or an X-Kal-Operation / X-Requested-With header. Those content types plus a header-free GET are exactly the CORS simple request set — what a browser sends cross-origin with cookies and no preflight. Requiring anything outside it forces a preflight the attacker's origin cannot pass. No token, no state.

Never register transport.UrlEncodedForm, transport.MultipartForm or transport.GRAPHQL while cookie authentication is on. All three are POST with CORS-simple content types and no operation-type restriction, so a cross-origin form can execute mutations with ambient cookies.

luima sets no Access-Control-Allow-Origin anywhere, so configure cors.New with an explicit origin list — never * with credentials.

What is in the box

package what it holds
kal Config, New, the guard extension, the re-export shim
authn Argon2id, registration, login, backoff, verification, reset, invite
authz Principal, @auth, Scope, AssertAuthCoverage, roles, RLS
session tokens, the store, the cookie, the middleware, the JWT leg
kalerr the error contract
e2ee the client-side encryption vault: per-user KDF parameters and one wrapped key kal cannot open
migrations the schema, as .sql behind an embed.FS
tests every test, outside the packages it exercises
Operating seams

Optional Config fields for deployments that outgrow the defaults. Every one of them is a named field rather than a mode, because the zero Config is the production posture and nothing here relaxes a security property.

field what it is for
Audit A func(ctx, kal.Event) called for the security-relevant things kal sees and a consumer structurally cannot: login.ok, login.fail, backoff.open, password.rehash, mail.fail, session.lookup.fail, session.revoked. Synchronous, so a hook that talks to the network must queue internally. Nil discards. Role grants and e2ee are not in the vocabulary yet.
Hasher A pre-built kal.NewHasher(params, max) shared between instances. Without it every New builds its own Argon2 semaphore, so a process holding one instance per tenant schema holds N × the limit in flight at ~19 MiB each — the bound stops bounding at the scale where it matters. Setting it beside Argon2 or MaxConcurrentHashes is an error, not a silently ignored cost parameter.
RLSSettings func(ctx) map[string]string of extra app.-prefixed settings carried on auth.WithRLS's transaction, for tenancy that is not a single owner column. app.user_id and app.roles are refused — they carry the caller. authz.WithRLSSettings is the direct form.
Proofs func(ctx, []string) error — what @auth(proves:) resolves through, as a plain func so kal carries no dependency on a proof module. Nil denies every non-empty proves requirement: an installed schema whose proof module was never wired must refuse, not run unguarded. kal-zk supplies one.
ExtraMiddleware []func(http.Handler) http.Handler mounted inside the session middleware, index 0 outermost. Inside, not around: anything here that reads the caller runs after the cookie has resolved into a Principal, and would see anonymous if it ran first.
SensitiveFields Fields no document may select twice. It replaces kal's defaults rather than extending them — DefaultSensitiveFields() returns a fresh copy so you can append instead of restating the list. A module beside kal has mutations kal cannot know the names of; adding your own without appending silently drops the aliasing guard on login.

A second factor is auth.Sessions.RecordMFA(ctx, db, sessionID, userID): verify whatever factor you ship, call it, and @auth(mfa: true) passes for MFAWindow. It does not rotate the session, and it takes no timestamp — now() is the database clock.

What it costs you to depend on kal

Beyond luima's graph, kal adds two direct requires — read go.mod for the pinned versions and go.sum for the full transitive set:

module why who pays
github.com/golang-jwt/jwt/v5 the optional JWT leg everyone
golang.org/x/sync the Argon2 semaphore that bounds concurrent hashing everyone

Argon2 costs nothing new — golang.org/x/crypto is already there.

"Who pays: everyone" is literal, which is why this list is now short. Until 0.5.0 it also held consensys/gnark and consensys/gnark-crypto. They were direct requires, so they entered the module graph of every consumer and were recorded in every consumer's go.sum116 packages and four modules — whether or not a single line of zkauthn was imported and whether or not Config.ZK was ever set. Leaving it nil turned the feature off at runtime; it did not remove the modules, and no build tag could, because build tags select which files compile and do not change what go.mod requires.

0.5.0 moved zkauthn and zkauthz to github.com/ulas96/kal-zk, which is the only thing that does remove them. go list -deps github.com/ulas96/kal | grep gnark now returns nothing, and go list -m all reports zero gnark modules where it used to report four. Measured rather than predicted: a program whose entire body is _ = kal.Config{} links 398 packages into 8,438,898 bytes against 0.4.0 and 258 packages into 6,965,890 against 0.5.0 — 1.4 MiB and 140 packages every consumer used to carry to link a feature it may never have called. (darwin/arm64, go1.26, no build flags.)

If you use proofs, add that module and read its README for the wiring; if you do not, upgrading makes your go.sum shorter and costs you nothing.

Deliberately not here

WebAuthn/passkeys (a second authentication system's worth of surface; verify the factor yourself and call Sessions.RecordMFA to stamp auth_sessions.mfa_at, which is what @auth(mfa:) reads), an admin UI, a scaffolding CLI, email templating beyond a one-method Mailer, avatar storage, a policy DSL, SMS as a second factor, magic links as a primary factor, and a pluggable Store interface — Postgres is the premise, so that interface would have one implementation and would forbid the JOIN that is the entire point.

Zero-knowledge proofs are not here either, as of 0.5.0: zkauthn and zkauthz are github.com/ulas96/kal-zk.

OAuth/OIDC and TOTP MFA are planned as separate modules, not merely separate packages, because a separate package in this module would still put its dependencies in every consumer's graph. That is not a prediction: it is what zkauthn did with gnark for three minor releases, and extracting it is what the two Config seams above exist for. The next optional subsystem starts outside.

Zero-knowledge proofs

Moved out in 0.5.0. zkauthn (Groth16/BN254 knowledge and membership proofs, the credential tree, pseudonymous login) and zkauthz (proven claims behind @auth(proves:)) are github.com/ulas96/kal-zk, which requires kal ≥ 0.5.1 and plugs in through Config.Proofs, Config.ExtraMiddleware and DefaultSensitiveFields(). Its README carries the wiring, the operating notes and the anonymity qualification.

kal keeps migrations/0002_zk.sql unchanged and its eight tables in migrations.Tables() — production databases have applied it and their trackers hold a row for it. SQL text carries no Go dependency, so keeping the file removes nothing from anyone's module graph.

kal v0.5.1 adds the strictly additive 0004_zk_hardening.sql. Before applying it to an existing proof deployment, run the preflight query in that file and resolve every row it returns. The new constraint permits exactly the two protocol shapes: recurring nullifiers have user_id only, and one-shot nullifiers have consumed_at only.

Operating the E2EE module

Browser-delivered end-to-end encryption does not protect against the server that serves the JavaScript. An operator who wants the plaintext ships one line of JS to one user and has their master key on the next page load, and no amount of care in this Go package changes that. Anyone who tells you otherwise is selling something. A consumer who deploys this believing it defends against their own server has deployed the wrong control, and the failure mode is that they stop doing the thing that would have worked.

The honest claim is: kal cannot read your data, and neither can anyone who reads your database. That claim is true, it is worth a lot, and it is the only one to make. A stolen dump, a stolen backup, a compromised replica, a subpoenaed snapshot and a curious operator all yield ciphertext plus a per-user Argon2id-wrapped key.

What it costs. Forgetting the password means losing the data — that is not a bug to be fixed later, it is the property. Encrypted columns cannot be indexed, sorted, filtered or full-text searched, and a blind index that restores equality lookup is an offline dictionary oracle on any low-entropy field, which is most of the fields anyone wants to encrypt. Server-side features that read user data — digest emails, admin support tooling, analytics, a report generator — stop working, permanently. And kal can no longer enforce a password policy, because it no longer sees a password. Each of these is a product decision wearing a technical costume, and each belongs in your own README before a line is written.

The client is yours. kal ships docs/e2ee-client.ts and docs/e2ee-client.md as a reference to copy, not a package to install — the format is pinned, so there is no version to keep in sync. Every client that touches the password field must be updated in the same release as Config.E2EE. ValidateAuthSecret turns a missed one into a login failure rather than a login that succeeds over a vault that then never opens, which is the right failure, but it is still a failure.

Encryption is not authorization. A ciphertext row is still a row with an owner and still needs kal.Scope(ctx, "owner_id"). The two controls are orthogonal and each fails open with respect to the other.

Development

make test      # go test ./...          — the TestDB* tests SKIP without a database
make test-db   # same, with .env loaded — they run
make check     # fmt + vet + lint + test-db + audit
make audit     # govulncheck + gosec

A green go test ./... proves less than it looks: the TestDB* tests skip without DATABASE_URL, and a skip still reports ok. In this library that silence would cover session revocation, token single-use and the unique index. Copy .env.example to .env and run make test-db; CI pins it with a postgres:16 service container and greps --- PASS: TestDB out of the -v output.

Licence

MIT. See LICENSE.

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

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

View Source
const (
	KDFArgon2id = e2ee.KDFArgon2id
	KDFPBKDF2   = e2ee.KDFPBKDF2
)

The client KDF names, re-exported so a consumer need not import e2ee to switch on one.

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

func AssertDirectivesWired(directiveRoot any) error

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

func HasRole(ctx context.Context, role string) bool

HasRole @notice Whether the caller holds the named role. See authz.HasRole.

func NewRecoveryCode added in v0.3.0

func NewRecoveryCode() (string, error)

NewRecoveryCode @notice Mints a vault recovery code, shown once. See e2ee.NewRecoveryCode.

func PresentError

func PresentError(ctx context.Context, err error) *gqlerror.Error

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

func Scope(ctx context.Context, column string) func(*orm.Query) *orm.Query

Scope @notice The caller's ownership predicate, for luima's crud options. See authz.Scope.

func ValidateAuthSecret added in v0.3.0

func ValidateAuthSecret(s string) error

ValidateAuthSecret @notice Applies the client-derived secret's shape. See e2ee.ValidateAuthSecret.

func ValidatePassword

func ValidatePassword(password string) error

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

type Audit = authz.Audit

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

func New(cfg Config) (*Auth, error)

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

func (a *Auth) Configure() func(*handler.Server)

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

func (a *Auth) Middleware() func(http.Handler) http.Handler

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

func (a *Auth) Migrate(ctx context.Context) error

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

func (a *Auth) MigrateSchema(ctx context.Context, schema string) error

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

func (a *Auth) WithRLS(ctx context.Context, fn func(orm.DB) error) error

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

type AuthLevel = authz.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

type Error = kalerr.Error

Error @notice A client-visible auth error with a stable code. See kalerr.Error.

type Event added in v0.4.0

type Event = authz.Event

Event @notice One security-relevant thing kal did. See authz.Event.

type Hasher added in v0.4.0

type Hasher = authn.Hasher

Hasher @notice Password hashing with the Argon2 work bounded. See authn.Hasher.

func NewHasher added in v0.4.0

func NewHasher(p Params, maxConcurrent int64) (*Hasher, error)

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

type LogMailer = authn.LogMailer

LogMailer @notice A development Mailer that logs messages. See authn.LogMailer.

type Mailer

type Mailer = authn.Mailer

Mailer @notice Delivers kal's transactional messages. See authn.Mailer.

type Message

type Message = authn.Message

Message @notice What to send. See authn.Message.

type Params

type Params = authn.Params

Params @notice Argon2id cost parameters. See authn.Params.

type Principal

type Principal = authz.Principal

Principal @notice The authenticated caller. See authz.Principal.

func From

func From(ctx context.Context) (*Principal, bool)

From @notice Returns the caller, and whether there is one. See authz.From.

func Require

func Require(ctx context.Context) (*Principal, error)

Require @notice Returns the caller, or a typed UNAUTHENTICATED error. See authz.Require.

type SessionInfo

type SessionInfo = session.Info

SessionInfo @notice One live session, as shown to its owner. See session.Info.

type Vault added in v0.3.0

type Vault = e2ee.Vault

Vault @notice One user's wrapped root key, opaque to kal. See e2ee.Vault.

type VaultOptions added in v0.3.0

type VaultOptions = e2ee.Options

VaultOptions @notice Configuration for the vault service. See e2ee.Options.

type VaultParams added in v0.3.0

type VaultParams = e2ee.Params

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

type Vaults = e2ee.Vaults

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.

Jump to

Keyboard shortcuts

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