Help us shape the future of Go! Take the 2026 Go Developer Survey

authkit

package module
v0.21.1 Latest Latest
Warning

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

Go to latest
Published: Oct 18, 2026 License: Apache-2.0 Imports: 26 Imported by: 0

README

authkit-go

A Go library for identity and authentication management built on a decoupled-identity model: a user is a single global identity, and access to a tenant (workspace) is a separate membership row. It supports API key authentication, invitation-based onboarding, and OTP verification.

CI Go Reference OpenSSF Scorecard Go Version License

Features

  • Decoupled Identities & Memberships: A user is a globally-unique (case-insensitive) identity with no tenant and no roles; per-tenant access is a MembershipService membership row carrying opaque, app-interpreted role strings. authkit owns the membership graph; your application owns the role policy.
  • Workspace-Scoped Tokens: One login opens an identity session (the refresh token) shared across the user's workspaces; from it you mint workspace-scoped access tokens. LoginByEmail authenticates without a known workspace and MintWorkspaceToken selects/switches workspace — no re-login.
  • API Key Management: Secure API key generation and validation with HMAC-SHA256 hashing
  • Invitation System: Invitation-based onboarding with secure access codes (reuses an existing global identity for the invited email, or creates one)
  • OTP Verification: One-Time Password generation and email verification
  • Verified Sign-In: VerifiedLoginService.OpenSession opens a session for a user your app verified another way (OAuth/OIDC, email link, passkey, device code), recording the sign-in method; users may have no password
  • Email Verification State: User.EmailVerifiedAt, set by UserService.MarkEmailVerified after a code or link proves the address; with OTPService and OpenSession it gives passwordless sign-in by emailed code
  • Asymmetric Signing Keys: Optional Ed25519, ECDSA P-256 or RSA access-token signing (any crypto.Signer, KMS keys included) with key ids, a published JSON Web Key Set, and rotation without logging anyone out
  • Linked Sign-In Identities: ExternalIdentityService maps a provider account (provider + subject) to one user, linking by verified email only under safe rules; bring your own OAuth/OIDC client
  • Passkeys: PasskeyService stores WebAuthn credentials and per-user handles for sign-in with passkeys and security keys, refusing a reused credential ID and a signature counter that goes backwards; bring your own WebAuthn library (go-webauthn)
  • Two-Factor Sign-In: Optional authenticator-app (TOTP) codes and single-use recovery codes; password logins of people who turn it on wait for a code (TwoFactorService), with the secret encrypted and the codes hashed
  • Sign-In History: Optional record of every sign-in attempt on an account (method, workspace, session, IP address, user agent, and the reason for a refusal), read with SignInHistoryService
  • Security-First: Cryptographically secure random generation, one-way hashing, UUID v7 string identifiers
  • Pluggable Rate Limiting: Optional Config.RateLimiter hook for Login/LoginByEmail/RefreshToken/OTP generation/CompleteSecondFactor
  • Flexible Logging & Metrics: Dependency-injected, context-aware Logger and an optional Metrics counter sink
  • Health Checks: AuthKit.Ping and fail-fast Config.VerifyConnectivity
  • Read-Write Separation: Optimized database connection management
  • Your Own Transactions: AuthKit.InTx and WithTx run authkit's writes in your transaction, so they commit with your own, such as an outbox row

Installation

go get github.com/modgudinc/authkit-go

Documentation

Quick Start

1. Initialize the Library
package main

import (
	"database/sql"
	"log"
	"os"
	"time"

    authkit "github.com/modgudinc/authkit-go"
    _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver (pgx, database/sql-compatible)
)

func main() {
    // Set up database connections (database/sql with the pgx driver).
    rwDB, err := sql.Open("pgx", os.Getenv("RW_DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer rwDB.Close()

    roDB, err := sql.Open("pgx", os.Getenv("RO_DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer roDB.Close()

    // REQUIRED for production: bound the connection pools. database/sql defaults to
    // unlimited open connections and infinite lifetime, so an unconfigured pool can
    // exhaust Postgres max_connections under load and pin connections across
    // failovers. The library does NOT set these for you (it does not own the *sql.DB).
    for _, db := range []*sql.DB{rwDB, roDB} {
        db.SetMaxOpenConns(25)
        db.SetMaxIdleConns(5)
        db.SetConnMaxLifetime(5 * time.Minute)
    }
    // ALSO set a server-side statement_timeout / lock_timeout (e.g. via the DSN:
    // "...&statement_timeout=3000&lock_timeout=3000") and pass deadline-bearing
    // contexts. The library issues no server-side timeout, so without one a request
    // arriving with a non-deadline context can pin a row lock indefinitely under
    // contention. See "Performance Considerations".

    // Initialize the library
    identity, err := authkit.New(authkit.Config{
        DBConnection: authkit.DatabaseConnection{
            IdentityRWDB: rwDB,
            IdentityRODB: roDB,
            IdentitySchema: "authkit", // Optional, defaults to authkit
        },
        JWTConfig: authkit.JWTConfig{
            AccessSecret:           os.Getenv("JWT_ACCESS_SECRET"),
            RefreshTokenHMACSecret: os.Getenv("REFRESH_TOKEN_HMAC_SECRET"), // Required
            AccessExpiry:           time.Minute * 15,  // Optional, defaults to 15 minutes
            RefreshExpiry:          time.Hour * 168,   // Optional, defaults to 7 days
        },
        APIKeyConfig: authkit.APIKeyConfig{
            HMACSecret: os.Getenv("API_KEY_HMAC_SECRET"), // Required
        },
        PasswordConfig: authkit.PasswordConfig{
            BcryptCost: 12, // Optional, defaults to 12
        },
        OTPConfig: authkit.OTPConfig{
            RateLimitDuration: time.Minute * 1,            // Optional, defaults to 1 minute
            HMACSecret:        os.Getenv("OTP_HMAC_SECRET"), // Required when OTPService is used
        },
        Logger: nil, // Optional: inject your logger (zap, logrus, etc.)
    })
    if err != nil {
        log.Fatal(err)
    }

    // Use the library
    apiKeyService := identity.APIKeyService()
    // ... use services
}

Each accessor (AuthService(), UserService(), APIKeyService(), …) returns a named interface from package authkit, so you can store it on a struct, pass it to a function, or mock it in a test:

type Handlers struct {
    auth authkit.AuthService // a named type you can hold and mock
}

h := Handlers{auth: identity.AuthService()}

Each accessor call allocates a fresh service (and, for composite ones like AuthService(), its dependency graph); no new DB connections are opened. Prefer constructing a service once and reusing it rather than calling the accessor on hot request paths. The returned services are safe for concurrent use.

2. Custom Logger Implementation

Implement the Logger interface to integrate with your logging library. Each method receives the call's context.Context first, so you can attach request/trace/correlation IDs from it (as log/slog's *Context methods do):

import (
    "context"
    "log/slog"
)

// SlogLogger adapts log/slog to authkit.Logger. The *Context variants let slog
// handlers pull values (request_id, trace_id, …) out of ctx onto each record.
type SlogLogger struct{ l *slog.Logger }

func (s SlogLogger) Debug(ctx context.Context, msg string, fields ...any) { s.l.DebugContext(ctx, msg, fields...) }
func (s SlogLogger) Info(ctx context.Context, msg string, fields ...any)  { s.l.InfoContext(ctx, msg, fields...) }
func (s SlogLogger) Warn(ctx context.Context, msg string, fields ...any)  { s.l.WarnContext(ctx, msg, fields...) }
func (s SlogLogger) Error(ctx context.Context, msg string, fields ...any) { s.l.ErrorContext(ctx, msg, fields...) }

// Use it
logger := SlogLogger{l: slog.Default()}

identity, err := authkit.New(authkit.Config{
    // ... other config
    Logger: logger,
})

PII in logs: authkit masks sensitive values before logging — email addresses are emitted as u***@acme.com, OTP/invitation codes are truncated, and secrets are never logged. Logs are treated as a hostile surface (see SECURITY.md); keep this in mind for your own log lines.

Identities and Workspaces

Applications that need per-installation login and revocation can opt into Config.SessionConfig and use InstallationAuthService / SessionService. The default remains disabled and needs no session tables (every deployment still applies 000005, see Database Schema). See installation sessions for the additive migrations, mode selection, enrollment contract and rollout instructions.

authkit uses a decoupled-identity model. The two concepts are deliberately separate:

  • A user is a global identity — a globally-unique, case-insensitive email plus an optional password (see verified sign-in for users who sign in another way). It carries no tenant and no roles. There is exactly one identity per email across the whole system (uniqueness is enforced on lower(email)). Identities are managed by UserService.
  • A membership is a (user, tenant) row that grants the identity access to one workspace, with a set of opaque role strings. authkit stores the role strings but never interprets them — authkit owns the membership graph; your application owns the role policy. Memberships are managed by MembershipService.

A single login authenticates the identity and opens one refresh-token identity session that is shared across all of the user's workspaces. From that session you mint workspace-scoped access tokens — each access token is scoped to one active membership (its tenant_id and roles). Switching workspace mints a new access token from the same session, with no re-login (MintWorkspaceToken).

Two token versions. Each access-token JWT carries both token_version (users.token_version, identity-level) and membership_token_version (memberships.token_version, per-membership). A credential event — password change, account disable, logout-all — bumps the identity token_version and invalidates every workspace's access tokens; a role change or membership revoke bumps only that membership's token_version and invalidates only that workspace's tokens. ValidateJWT rejects on either version mismatch, a missing or suspended membership, or a disabled/locked account.

            ┌── membership (tenant A, roles, mtv) ── workspace-scoped access token (A)
identity ───┤
 (session)  └── membership (tenant B, roles, mtv) ── workspace-scoped access token (B)

See Memberships for the MembershipService API.

API Key Management

Create an API Key
apiKeyService := identity.APIKeyService()

expiresIn := 90 * 24 * time.Hour // Optional: expires in 90 days (nil = never)
response, err := apiKeyService.Create(ctx, model.CreateAPIKeyRequest{
    TenantID:  "acme.com",
    CreatedBy: adminUserID, // string user id (uuid7) of the admin (FK to users.id)
    Name:      "Production SDK Key",
    ExpiresIn: &expiresIn, // Optional
})
if err != nil {
    log.Fatal(err)
}

// IMPORTANT: The API key is only returned once
fmt.Printf("API Key: %s\n", response.APIKey)
fmt.Printf("Key ID: %s\n", response.KeyID)

API Key Format: sk_live_<32-random-chars> (40 characters total)

Validate an API Key
apiKey := "sk_live_abc123..." // From HTTP header or SDK

keyCtx, err := apiKeyService.Validate(ctx, apiKey)
if err != nil {
    // Invalid or expired key
    return err
}

// Use tenant context
fmt.Printf("Tenant ID: %s\n", keyCtx.TenantID)
fmt.Printf("Key ID: %s\n", keyCtx.KeyID)
List API Keys
// List all non-expired keys
apiKeys, err := apiKeyService.List(ctx, "acme.com", false)
if err != nil {
    log.Fatal(err)
}

// List including expired keys
allKeys, err := apiKeyService.List(ctx, "acme.com", true)
Revoke an API Key

The revoke (and Get) lookup is scoped to the owning tenant, so pass the tenant ID alongside the key ID. This prevents one tenant from revoking or reading another tenant's key by guessing its ID.

err := apiKeyService.Revoke(ctx, "key-uuid-v7", "acme.com")
if err != nil {
    log.Fatal(err)
}
Cleanup Expired Keys

Run periodically (e.g., via cron job) to maintain database hygiene:

count, err := apiKeyService.CleanupExpired(ctx)
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Deleted %d expired keys\n", count)

Password Management

Hash a Password
passwordService := identity.PasswordService()

// Hash password before storing in database
hash, err := passwordService.HashPassword("SecurePassword123!")
if err != nil {
    log.Fatal(err)
}

// Store hash in database (NEVER store plaintext)
// user.PasswordHash = hash
Verify a Password
// During login - compare user input with stored hash
err := passwordService.VerifyPassword(userInputPassword, user.PasswordHash)
if err != nil {
    // Password is incorrect
    return fmt.Errorf("invalid credentials")
}

// Password is correct - proceed with authentication
Validate Password Strength (Optional)
// Use default requirements (min 8 chars, upper, lower, digit, special)
requirements := model.DefaultPasswordRequirements()

err := passwordService.ValidatePasswordStrength(userPassword, requirements)
if err != nil {
    // Return validation errors to user
    return err
}
Custom Password Requirements
customReqs := model.PasswordStrengthRequirements{
    MinLength:      10,
    RequireUpper:   true,
    RequireLower:   true,
    RequireNumber:  true,
    RequireSpecial: false, // Don't require special characters
}

err := passwordService.ValidatePasswordStrength(password, customReqs)

JWT Token Management

Generate Access Token
jwtService := identity.JWTService()

// Generate a WORKSPACE-SCOPED JWT for an authenticated identity. It needs both the
// global identity (user) and the active membership it is scoped to (tenant + roles).
// Both must be complete, freshly-loaded records — prefer the AuthService flows, which
// load them for you.
accessToken, err := jwtService.GenerateAccessToken(user, membership)
if err != nil {
    log.Fatal(err)
}

// Return token to client (e.g., in HTTP response)

JWT contains custom claims:

  • user_id - Global user UUID (string)
  • email - User email address
  • tenant_id - Active workspace (tenant) the token is scoped to (from the membership)
  • roles - Roles in the active workspace (opaque, app-interpreted; from the membership)
  • token_version - Identity token version (users.token_version) — bumped by credential events to invalidate every workspace's tokens
  • membership_token_version - Per-membership token version (memberships.token_version) — bumped by a role change/revoke to invalidate only this workspace's tokens
  • Standard claims (sub, iat, exp)
Validate Access Token
// Extract the token from the HTTP header (Authorization: Bearer <token>)
tokenString := strings.TrimPrefix(req.Header.Get("Authorization"), "Bearer ")

claims, err := jwtService.ValidateAccessToken(tokenString)
if err != nil {
    // Token is invalid or expired
    return fmt.Errorf("unauthorized: %w", err)
}

// Use claims for authorization
userID := claims.UserID          // Global user UUID
email := claims.Email            // User email
tenantID := claims.TenantID      // Active workspace the token is scoped to
roles := claims.Roles            // []string (opaque, app-interpreted)
tokenVersion := claims.TokenVersion                     // Identity token version
membershipTokenVersion := claims.MembershipTokenVersion // Per-membership token version

JWTService.ValidateAccessToken only checks the token's signature and expiry; it does not consult the database, so it cannot detect a bumped token_version/ membership_token_version, a suspended membership, or a disabled account. For an authorization decision use AuthService.ValidateJWT (below), which re-checks all of these against the store.

Generate Refresh Token
// Generate cryptographically secure random token (NOT a JWT)
refreshToken, err := jwtService.GenerateRefreshToken()
if err != nil {
    log.Fatal(err)
}

// Store HMAC-SHA256(refreshToken) in database (via TokenService)
// Return plaintext refreshToken to client (only time it's visible)
// Refresh tokens are 32 bytes = 44 base64 characters

Note: Refresh tokens are random tokens stored in the database with HMAC-SHA256 hash (for defense in depth), not JWTs. This enables single-use refresh tokens and rotation.

Complete Login Flow Example

The pieces below are the low-level building blocks. In practice use AuthService.Login / LoginByEmail (see Authentication Service), which compose password verification, the active-membership check, and token-pair generation for you.

// 1. Verify password
passwordService := identity.PasswordService()
err := passwordService.VerifyPassword(inputPassword, user.PasswordHash)
if err != nil {
    return fmt.Errorf("invalid credentials")
}

// 2. Generate a WORKSPACE-SCOPED JWT access token. The token is scoped to one active
//    membership, so pass both the global identity (user) and that membership.
jwtService := identity.JWTService()
accessToken, err := jwtService.GenerateAccessToken(user, membership)

// 3. Generate refresh token
refreshToken, err := jwtService.GenerateRefreshToken()

// 4. Store refresh token hash in database with expiry
// refreshTokenHash := sha256Hash(refreshToken)
// Save refreshTokenHash to database

// 5. Return tokens to client
return LoginResponse{
    AccessToken:  accessToken,
    RefreshToken: refreshToken,
    ExpiresIn:    int64(jwtService.GetAccessExpiry().Seconds()),
}

Token Service (Combined JWT + Refresh Token Management)

The TokenService combines JWT and refresh token management with automatic token rotation.

Generate Token Pair
tokenService := identity.TokenService()

// Generate both a WORKSPACE-SCOPED access token (JWT) and the identity-session
// refresh token. The access token is scoped to the active membership, so pass both
// the global identity (user) and that membership.
tokenPair, err := tokenService.GenerateTokenPair(ctx, user, membership, &model.SessionInfo{
    IPAddress:  "192.168.1.1",
    UserAgent:  "Mozilla/5.0...",
    DeviceName: "Chrome Browser",
})
if err != nil {
    log.Fatal(err)
}

// Return both tokens to client
// Access token: JWT for API authentication (scoped to the membership's tenant + roles)
// Refresh token: Random token stored as HMAC-SHA256 hash (the identity session, used for rotation)
Refresh Token Pair (Token Rotation)
// Client sends current refresh token (the identity session) plus the workspace the
// new access token should be scoped to. tenantID must be one the identity has an
// active membership in, or rotation fails with ErrNoActiveMembership.
newTokenPair, err := tokenService.RefreshTokenPair(ctx, oldRefreshToken, tenantID, sessionInfo)
if err != nil {
    log.Fatal(err)
}

// Both tokens are NEW - old refresh token is automatically revoked
// Client must replace BOTH access token and refresh token

Token Rotation Flow:

  1. Client sends current refresh token
  2. Server atomically consumes the current refresh token (a disabled or locked account is refused first and nothing is consumed, so a lock pauses its sessions rather than ending them)
  3. New refresh token is stored in the same transaction
  4. New access token (JWT) is generated
  5. If the old refresh token is missing, expired, or already consumed, rotation fails; a consumed token is treated as theft unless JWTConfig.RefreshGracePeriod lets the client retry (docs/refresh-tokens.md)
  6. Client receives both new tokens
Revoke Refresh Tokens
// Revoke specific refresh token (single session logout)
err := tokenService.RevokeRefreshToken(ctx, refreshToken)

// Revoke all sessions for an identity (logout from all devices, across all of its
// workspaces): use AuthService.LogoutAll, which is identity-level and ALSO invalidates
// every workspace's outstanding access tokens by bumping the identity token_version.
// TokenService intentionally exposes no RevokeAllUserTokens (it would revoke by user_id
// with no authorization check — see the Logout section); the caller authorizes userID.
err := authService.LogoutAll(ctx, userID)

// Cleanup expired tokens (periodic maintenance job)
count, err := tokenService.CleanupExpired(ctx)
fmt.Printf("Deleted %d expired tokens\n", count)

Authentication Service

The AuthService provides complete authentication flows integrating password verification, the active-membership check, JWT generation, and token management.

Login (workspace already known)

Login authenticates the identity and requires an active membership in req.TenantID. On success it returns a workspace-scoped access token plus the identity-session refresh token. If the credentials are valid but the identity has no active membership in that tenant, it returns ErrNoActiveMembership (map to 403).

authService := identity.AuthService()

response, err := authService.Login(ctx, model.LoginRequest{
    Email:    "user@acme.com",
    Password: "SecurePassword123!",
    TenantID: "acme.com", // workspace to authenticate into; an active membership is required
    SessionInfo: &model.SessionInfo{
        IPAddress:  "192.168.1.1",
        UserAgent:  "Mozilla/5.0...",
        DeviceName: "Chrome Browser",
    },
})
if err != nil {
    // errors.Is(err, authkit.ErrInvalidCredentials) => bad email/password/disabled/locked
    // errors.Is(err, authkit.ErrNoActiveMembership) => valid creds, but not a member of acme.com
    log.Fatal(err)
}

// Return tokens and user info to client
fmt.Printf("Access Token: %s\n", response.AccessToken) // scoped to response.TenantID
fmt.Printf("Refresh Token: %s\n", response.RefreshToken) // the identity session
fmt.Printf("Expires In: %d seconds\n", response.ExpiresIn)
fmt.Printf("Active Workspace: %s\n", response.TenantID)
fmt.Printf("User: %+v\n", response.User)
fmt.Printf("Memberships: %+v\n", response.Memberships) // every workspace the identity belongs to

Login Features:

  • Password verification with bcrypt
  • Active-membership check in the requested tenant (else ErrNoActiveMembership)
  • Account status checks (enabled, not locked)
  • Auto-unlock if locked_until has passed
  • Failed login tracking (locks after 5 attempts for 30 minutes)
  • Last login timestamp updates
  • Automatic token pair generation with session tracking
Login by email (workspace not yet known)

LoginByEmail authenticates the identity without a known workspace and opens the identity session. If TenantID is given (or the identity has exactly one active membership) it also mints a workspace-scoped access token; otherwise it returns the identity's Memberships for the caller to choose from (with AccessToken/ExpiresIn empty) and you complete selection with MintWorkspaceToken.

resp, err := authService.LoginByEmail(ctx, model.LoginByEmailRequest{
    Email:    "user@acme.com",
    Password: "SecurePassword123!",
    // TenantID is optional — omit it to discover the user's workspaces first.
    SessionInfo: &model.SessionInfo{IPAddress: "192.168.1.1"},
})
if err != nil {
    // errors.Is(err, authkit.ErrInvalidCredentials) // bad credentials
    // errors.Is(err, authkit.ErrNoActiveMembership)  // TenantID given but not a member there
    log.Fatal(err)
}

if resp.AccessToken == "" {
    // Multiple memberships and no workspace chosen: present resp.Memberships to the user,
    // then mint a token for the picked workspace from the SAME session (no re-login).
    for _, m := range resp.Memberships {
        fmt.Printf("workspace %s, roles %v\n", m.TenantID, m.Roles)
    }
    chosen := resp.Memberships[0].TenantID
    ws, err := authService.MintWorkspaceToken(ctx, resp.RefreshToken, chosen)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("Access Token: %s (workspace %s)\n", ws.AccessToken, ws.TenantID)
} else {
    // Exactly one membership (or TenantID was supplied): a token was minted immediately.
    fmt.Printf("Access Token: %s (workspace %s)\n", resp.AccessToken, resp.TenantID)
}
Switch workspace (MintWorkspaceToken)

MintWorkspaceToken mints a fresh workspace-scoped access token from an existing identity session — the select-/switch-workspace call. The refresh token is not rotated (the identity session is unchanged), so only the new access token and its scope are returned. The identity must have an active membership in tenantID, else ErrNoActiveMembership.

// The user clicks "switch to acme.io" — reuse the same refresh token, no re-login.
ws, err := authService.MintWorkspaceToken(ctx, refreshToken, "acme.io")
if err != nil {
    // errors.Is(err, authkit.ErrNoActiveMembership) => not a member of acme.io
    // errors.Is(err, authkit.ErrInvalidRefreshToken) => unknown/expired session
    log.Fatal(err)
}

fmt.Printf("Access Token: %s\n", ws.AccessToken) // now scoped to acme.io
fmt.Printf("Workspace: %s\n", ws.TenantID)
fmt.Printf("Expires In: %d seconds\n", ws.ExpiresIn)
Logout
// Single session logout (revoke the specific refresh token / identity session)
err := authService.Logout(ctx, refreshToken)
// Note: JWT remains valid until expiry (stateless design)

// Logout from all devices and ALL workspaces. This is identity-level (no tenant arg):
// it revokes every refresh token for the identity and increments the identity
// token_version, immediately invalidating EVERY workspace's outstanding access tokens.
// The caller is responsible for authorizing that it may act on userID; an unknown id is
// rejected (ErrNotFound).
err := authService.LogoutAll(ctx, userID)
Token Refresh
response, err := authService.RefreshToken(ctx, model.RefreshRequest{
    RefreshToken: oldRefreshToken,
    TenantID:     "acme.com", // required: the workspace to scope the new access token to
    SessionInfo:  sessionInfo,
})
if err != nil {
    // errors.Is(err, authkit.ErrInvalidRefreshToken) => invalid/expired session: re-login
    // errors.Is(err, authkit.ErrNoActiveMembership)  => no longer a member of TenantID
    log.Fatal(err)
}

// Return new tokens (old refresh token is automatically revoked)
fmt.Printf("New Access Token: %s\n", response.AccessToken) // scoped to response.TenantID
fmt.Printf("New Refresh Token: %s\n", response.RefreshToken)
fmt.Printf("Workspace: %s\n", response.TenantID)
JWT Validation
claims, err := authService.ValidateJWT(ctx, tokenString)
if err != nil {
    // Token invalid/expired/revoked, membership suspended/removed, or account disabled/locked
    return fmt.Errorf("unauthorized: %w", err)
}

// Use claims for authorization
userID := claims.UserID      // Global user UUID
email := claims.Email        // User email
tenantID := claims.TenantID  // Active workspace the token is scoped to
roles := claims.Roles        // Roles in the active workspace ([]string, opaque/app-interpreted)

Validation Features:

  • JWT signature and expiration verification
  • Identity token version check (users.token_version) — detects credential-revoked tokens
  • Membership token version check (memberships.token_version) — detects role-change/revoke
  • Active-membership check: a missing or suspended membership in the token's tenant is rejected as ErrTokenRevoked
  • Account enabled check
  • Account locked check (with auto-unlock)
  • Returns enriched claims
Change Password
// A credential is global, so ChangePassword is identity-level — keyed by UserID only,
// no tenant.
err := authService.ChangePassword(ctx, model.ChangePasswordRequest{
    UserID:      userID,
    OldPassword: "OldPassword123!",
    NewPassword: "NewSecurePassword456!",
})
if err != nil {
    // Old password incorrect or new password validation failed
    log.Fatal(err)
}

// Password changed successfully
// All sessions invalidated across every workspace (user must re-login on all devices)

Password Change Features:

  • Old password verification; a wrong old password counts toward the failed-login lockout, as a wrong login password does, so a stolen access token cannot be used to guess the current password without limit
  • New password strength validation
  • Automatic logout from all devices across all workspaces (identity token version increment)
  • All refresh tokens revoked

User Service

The UserService manages global identities, queries, and account-status operations. A user carries no tenant and no roles — those live on memberships (see Memberships). Every operation is keyed by the global user id or the globally-unique email; there is no tenant argument. Convention: create/update take a keyed request struct (ids inside); simple get/delete/status operations take a positional userID (or email for lookups).

userService := identity.UserService()

// Create a GLOBAL identity (no tenant, no roles). Email must be globally unique
// (case-insensitive). Grant tenant access separately via MembershipService.Add.
id, err := userService.Create(ctx, model.CreateUserRequest{
    Email:        "user@acme.com",
    PasswordHash: passwordHash, // hash with PasswordService.HashPassword first
    DisplayName:  "Ada Lovelace",
})

// Update / query
name := "Ada L."
err = userService.Update(ctx, model.UpdateUserRequest{UserID: id, DisplayName: &name})
user, err := userService.GetByID(ctx, id)
user, err = userService.GetByEmail(ctx, "user@acme.com")
users, err := userService.List(ctx, limit, offset) // global admin listing; limit/offset clamped

// Account status (security controls) — identity-level; they revoke sessions as needed
err = userService.Disable(ctx, id)        // bumps identity token_version + revokes refresh tokens
err = userService.Lock(ctx, id, &until)

// Account status-change audit trail
history, err := userService.StatusHistory(ctx, id) // []*model.UserStatusChange, newest first
// Prune the audit table periodically (it has no expiry); olderThan must be positive:
removed, err := userService.CleanupStatusChanges(ctx, 90*24*time.Hour)

For a tenant's member list, use MembershipService.ListByTenant rather than UserService.List (which is a global, cross-tenant admin listing). Roles are no longer on the user: change them with MembershipService.UpdateRoles. UserService no longer has UpdateRoles or Count.

StatusHistory returns the enable/disable/lock/unlock transitions (including automatic failed-login lockouts) recorded in user_status_changes. CleanupStatusChanges bounds that table's growth — run it on a schedule alongside the other CleanupExpired jobs.

Memberships

A membership is the (user, tenant) graph row that grants a global identity access to one workspace, with a set of opaque role strings. authkit stores the strings but never interprets them — your application owns the role policy. MembershipService (via identity.MembershipService()) manages the graph.

Key semantics:

  • Add is an idempotent upsert. Granting an existing membership re-applies the roles and reactivates a suspended one (re-grant). The first grant starts at membership token_version 0; a roles change bumps it.
  • Remove is a soft-revoke. It suspends the membership (status → suspended) and bumps its token_version, immediately invalidating that workspace's access tokens — the row is retained, not deleted. Add can later reactivate it.
  • UpdateRoles replaces the membership's roles and bumps its token_version, invalidating only that workspace's tokens (the identity session is untouched).
  • RemoveGuarded / UpdateRolesGuarded are Remove / UpdateRoles plus a last-holder guard: they refuse (ErrLastRoleHolder, map to 409) to remove or demote the tenant's last active member holding a caller-named role — e.g. a workspace's last Admin. Only members whose accounts are enabled count: a disabled or deleted user never keeps a workspace manageable (a locked one still counts, since a lock lifts by itself), while UserService.Disable itself is not guarded. The check-and-mutate is atomic and serialized per tenant (a transaction-scoped advisory lock), so concurrent removals cannot race a workspace below one active holder of the role, even across replicas. The requireRole is opaque; pass "" to disable the guard (equivalent to Remove / UpdateRoles).
  • CountByTenant is status-agnostic (a suspended row still counts).
membershipService := identity.MembershipService()

// Grant a user access to a workspace with opaque, app-interpreted roles.
err := membershipService.Add(ctx, model.AddMembershipRequest{
    UserID:   userID,
    TenantID: "acme.com",
    Roles:    []string{"Member"}, // any strings your app understands
})

// Inspect / change a single membership.
m, err := membershipService.Get(ctx, userID, "acme.com") // *model.Membership; m.IsActive(), m.Roles
err = membershipService.UpdateRoles(ctx, userID, "acme.com", []string{"Admin"})

// List the user's workspaces, or a tenant's members.
workspaces, err := membershipService.ListByUser(ctx, userID)     // []*model.Membership
members, err := membershipService.ListByTenant(ctx, "acme.com")  // []*model.Membership
count, err := membershipService.CountByTenant(ctx, "acme.com")   // int (status-agnostic)

// Soft-revoke access (suspends + bumps the membership token_version; row retained).
err = membershipService.Remove(ctx, userID, "acme.com")

// Guarded variants refuse to drop the workspace's last active holder of a role.
err = membershipService.RemoveGuarded(ctx, userID, "acme.com", "Admin")
err = membershipService.UpdateRolesGuarded(ctx, userID, "acme.com", []string{"Member"}, "Admin")
if errors.Is(err, authkit.ErrLastRoleHolder) {
    // 409: userID is the tenant's last active Admin — refused, nothing changed.
}

A model.Membership carries UserID, TenantID, Roles (opaque strings), Status (model.MembershipActive | model.MembershipSuspended) with an IsActive() helper, and a per-membership token version. Get/Remove on a non-existent membership return ErrNotFound; Add for an unknown user or tenant returns ErrNotFound (the FK is enforced). The guarded mutations additionally return ErrLastRoleHolder (map to 409) when the change would remove the tenant's last active, enabled holder of the required role.

Tenant Service

The TenantService provides tenant management operations including creation, retrieval, settings management, and domain management.

Get Tenant
tenantService := identity.TenantService()

// Retrieve tenant by tenant_id (primary email domain)
tenant, err := tenantService.Get(ctx, "acme.com")
if err != nil {
    log.Fatal(err)
}

// Access tenant data
fmt.Printf("Tenant: %s\n", tenant.Name)
fmt.Printf("Allowed Domains: %v\n", tenant.AllowedDomains)
Create Tenant
// Create new tenant/organization
tenant, err := tenantService.Create(ctx, model.CreateTenantRequest{
    TenantID:       "acme.com",                    // Primary email domain
    Name:           "Acme Corporation",
    AllowedDomains: []string{"acme.com", "acme.io"}, // Multi-domain support
})
if err != nil {
    log.Fatal(err)
}

// Tenant created successfully
fmt.Printf("Created tenant: %s\n", tenant.TenantID)

Creation Features:

  • Domain format validation (ensures valid email domain pattern)
  • Multi-domain validation (validates all allowed_domains)
  • Automatic timestamps (created_at, updated_at)
  • Retrieves created tenant after insertion
Update Tenant Settings
// Update tenant settings (stored as JSONB)
settings := map[string]interface{}{
    "features": map[string]interface{}{
        "api_access":     true,
        "export_data":    true,
        "custom_branding": false,
    },
    "limits": map[string]interface{}{
        "max_users":   100,
        "max_api_keys": 10,
    },
}

err := tenantService.UpdateSettings(ctx, "acme.com", settings)
if err != nil {
    log.Fatal(err)
}

// Settings updated successfully

Settings Features:

  • JSON validation before storage
  • Flexible schema (any valid JSON structure)
  • Automatic marshaling to JSONB
  • Atomic updates with timestamp tracking
Add Allowed Domain
// Add additional email domain to tenant
err := tenantService.AddAllowedDomain(ctx, "acme.com", "acme.io")
if err != nil {
    // Domain already exists or invalid format
    log.Fatal(err)
}

// Domain added successfully (duplicates prevented automatically)

Domain Management Features:

  • Domain format validation (ensures valid email domain)
  • Duplicate prevention (database-level check)
  • Supports subdomains (e.g., "mail.acme.com")
  • Automatic timestamp updates
List Tenants
// List all tenants (admin/debug operation)
tenants, err := tenantService.List(ctx)
if err != nil {
    log.Fatal(err)
}

// Tenants ordered by created_at DESC (newest first)
for _, tenant := range tenants {
    fmt.Printf("Tenant: %s (%s)\n", tenant.Name, tenant.TenantID)
}
Check Tenant Existence
// Check if tenant exists before operations
exists, err := tenantService.Exists(ctx, "acme.com")
if err != nil {
    log.Fatal(err)
}

if !exists {
    return fmt.Errorf("tenant not found")
}
Validate Domain Access
// Validate that an email domain is allowed for a tenant
err := tenantService.ValidateDomainAccess(ctx, "user@acme.io", "acme.com")
if err != nil {
    // Email domain not allowed for this tenant
    return fmt.Errorf("access denied: %w", err)
}

// Email domain is allowed - proceed with operation

Validation Features:

  • Extracts domain from email address
  • Checks against tenant's allowed_domains list
  • Case-insensitive domain matching
  • Used for signup validation and access control
Complete Multi-Tenant Signup Example
// Step 1: Validate email domain is allowed for tenant
func ValidateSignupEmail(ctx context.Context, email, tenantID string) error {
    tenantService := identity.TenantService()

    // Check if domain is allowed for tenant
    err := tenantService.ValidateDomainAccess(ctx, email, tenantID)
    if err != nil {
        return fmt.Errorf("email domain not allowed: %w", err)
    }

    return nil
}

// Step 2: Multi-domain tenant creation during admin signup
func CreateTenantWithMultipleDomains(ctx context.Context, primaryDomain, name string, additionalDomains []string) error {
    tenantService := identity.TenantService()

    // Create tenant with all allowed domains
    allDomains := append([]string{primaryDomain}, additionalDomains...)

    tenant, err := tenantService.Create(ctx, model.CreateTenantRequest{
        TenantID:       primaryDomain,
        Name:           name,
        AllowedDomains: allDomains,
    })
    if err != nil {
        return fmt.Errorf("failed to create tenant: %w", err)
    }

    // Configure initial settings
    defaultSettings := map[string]interface{}{
        "features": map[string]interface{}{
            "api_access": true,
            "export_data": false,
        },
    }

    err = tenantService.UpdateSettings(ctx, tenant.TenantID, defaultSettings)
    if err != nil {
        return fmt.Errorf("failed to update settings: %w", err)
    }

    return nil
}

Tenant Characteristics:

  • Multi-domain support (organizations can register multiple email domains)
  • Flexible settings schema (JSONB for custom configurations)
  • Domain-based tenancy (primary domain as tenant_id)
  • Automatic validation (domain format, JSON schema)
  • Audit timestamps (created_at, updated_at)

Invitation Service

The InvitationService provides invitation management for team member onboarding, including creation, validation, revocation, and lifecycle management.

Create Invitation
invitationService := identity.InvitationService()

// Create invitation with roles
maxUses := 1
expiresIn := 7 * 24 * time.Hour // 7 days

invitation, err := invitationService.Create(ctx, model.CreateInvitationRequest{
    TenantID:  "acme.com",
    CreatedBy: adminUserID,        // string user id (uuid7) of the admin (FK to users.id)
    Email:     "user@acme.com",    // Target email address
    Roles:     []string{"Member"}, // Validated by your app layer
    MaxUses:   &maxUses,           // Optional (nil = default 1)
    ExpiresIn: &expiresIn,         // Optional (nil = default 7 days)
})
if err != nil {
    log.Fatal(err)
}

// Send invitation code to user via email/Slack/etc.
fmt.Printf("Invitation Code: %s\n", invitation.Code)
fmt.Printf("Expires: %s\n", invitation.ExpiresAt.Format(time.RFC3339))

Creation Features:

  • Cryptographically secure 20-character code (A-Z, 0-9)
  • Multi-role support (array of roles, validated by application layer)
  • Default expiration: 7 days
  • Default max uses: 1 (single-use invitation)
  • Automatic code generation
  • Returns created invitation with all metadata
Get Invitation
// Retrieve invitation by code
invitation, err := invitationService.Get(ctx, "ABC1234567890XYZQWER")
if err != nil {
    // Invitation not found
    log.Fatal(err)
}

// Access invitation details
fmt.Printf("Tenant: %s\n", invitation.TenantID)
fmt.Printf("Roles: %v\n", invitation.Roles)
fmt.Printf("Uses: %d/%d\n", invitation.CurrentUses, invitation.MaxUses)
fmt.Printf("Expires: %s\n", invitation.ExpiresAt)

Use Accept to onboard an invited user. It validates the invitation, consumes one use, and grants the invited membership (tenant + roles, from the invitation) in a single transaction, so a single-use invitation can never onboard more than one account (no validate→create→increment race or over-redemption). The supplied email must match the invitation's target.

Because identities are global, the invited email may already have an account. In that case Accept reuses the existing identity (it does not create a second account/password) and simply grants the new membership; otherwise it creates the identity. The boolean second return value tells you which happened.

passwordHash, err := identity.PasswordService().HashPassword(password)
if err != nil {
    return err
}

userID, created, err := invitationService.Accept(ctx, model.AcceptInvitationRequest{
    Code:         invitationCode,
    Email:        email,
    DisplayName:  displayName,
    PasswordHash: passwordHash, // ignored when the identity already exists
})
if err != nil {
    // errors.Is(err, authkit.ErrInvitationInvalid) => unknown, expired, fully used, or email mismatch
    return fmt.Errorf("invitation signup failed: %w", err)
}
if !created {
    // An existing global identity was reused (the invited email already had an account):
    // the supplied PasswordHash was NOT applied. The user keeps their existing password
    // and now also has access to the invitation's tenant.
}
// userID is the (new or existing) identity, now an active member of the invitation's
// tenant — issue tokens (Login into that tenant), send a welcome email, etc.

Prefer this over composing Validate + Get + your own user creation + IncrementUseCount: those are lower-level building blocks (below) and composing them yourself reintroduces a TOCTOU / over-redemption window.

Domain policy is not enforced here. Create and Accept do not check the tenant's allowed_domains — by design, since admins legitimately invite out-of-domain users (contractors, partners). If you require invited emails to stay within the tenant's domain list, call TenantService.ValidateDomainAccess(ctx, email, tenantID) yourself before Create (see the flow example below).

Validate Invitation

Low-level / read-only. This only checks an invitation; it does not consume it. For signup, use Accept (above) so validation and consumption are atomic. Use Validate for display or pre-flight checks only.

// Read-only check (e.g. to show "invitation valid" in a UI before signup).
// The boolean is the answer; a non-nil error means a genuine system fault, not
// "the invitation is invalid".
valid, err := invitationService.Validate(ctx, invitationCode)
if err != nil {
    // Storage/system fault (already sanitized) — surface as a 500.
    return fmt.Errorf("could not validate invitation: %w", err)
}

if !valid {
    // Unknown, expired, or exhausted — a normal "not usable" outcome (false, nil).
    return fmt.Errorf("invitation cannot be used")
}

Validation Checks:

  • Invitation exists
  • Not expired (ExpiresAt > NOW())
  • Has remaining uses (CurrentUses < MaxUses)
  • Unknown/expired/exhausted is reported as (false, nil); err is reserved for genuine storage faults
List Invitations
// List all invitations for tenant (including expired)
allInvitations, err := invitationService.List(ctx, "acme.com")
if err != nil {
    log.Fatal(err)
}

// List only active invitations (not expired, has uses remaining)
activeInvitations, err := invitationService.ListActive(ctx, "acme.com")
if err != nil {
    log.Fatal(err)
}

// Display invitations
for _, inv := range activeInvitations {
    fmt.Printf("Code: %s, Roles: %v, Uses: %d/%d, Expires: %s\n",
        inv.Code, inv.Roles, inv.CurrentUses, inv.MaxUses, inv.ExpiresAt)
}

Listing Features:

  • List: Returns all invitations (for audit/admin)
  • ListActive: Returns only usable invitations (for UI display)
  • Ordered by created_at DESC (newest first)
  • Includes usage statistics
Revoke Invitation
// Soft delete - invalidates invitation but preserves audit trail
err := invitationService.Revoke(ctx, invitationCode)
if err != nil {
    log.Fatal(err)
}

// Invitation cannot be used, but remains in database
// Sets current_uses = max_uses

Revocation Features:

  • Soft delete (preserves audit trail)
  • Sets current_uses = max_uses to prevent use
  • Invitation record remains in database
  • Logged for security audit
Delete Invitation
// Permanent deletion from database
err := invitationService.Delete(ctx, invitationCode)
if err != nil {
    log.Fatal(err)
}

// Invitation completely removed (hard delete)
Increment Use Count

Low-level building block. The single UPDATE is atomic, but composing Validate → create user → IncrementUseCount yourself is not atomic and can over-redeem a single-use invitation under concurrency. Use Accept for signup; reach for IncrementUseCount only for custom redemption flows where you manage the surrounding transaction.

err := invitationService.IncrementUseCount(ctx, invitationCode)
if err != nil {
    // Max uses exceeded or invitation not found
    log.Fatal(err)
}

// Atomic increment with database-level constraint (current_uses < max_uses)

Usage Tracking:

  • The increment statement itself is atomic (database constraint enforced)
  • Automatic invalidation when max uses reached
  • For onboarding, prefer Accept, which performs validate + increment + identity reuse-or-create + membership grant in one transaction
Cleanup Expired Invitations

Run periodically (e.g., daily cron job) to maintain database hygiene:

// Delete all expired invitations
count, err := invitationService.CleanupExpired(ctx)
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Deleted %d expired invitations\n", count)
Complete Invitation Flow Example
// Step 1: Admin creates invitation
func AdminCreateInvitation(ctx context.Context, tenantID string, adminUserID string, inviteeEmail string) (string, error) {
    invitationService := identity.InvitationService()

    // Optional: enforce the tenant's allowed_domains for invited users. The
    // invitation methods do NOT check this themselves; omit this call if you want
    // to allow out-of-domain invitees (contractors, partners).
    if err := identity.TenantService().ValidateDomainAccess(ctx, inviteeEmail, tenantID); err != nil {
        return "", fmt.Errorf("email domain not allowed for tenant: %w", err)
    }

    // Create invitation with defaults (1 use, 7 days expiration)
    invitation, err := invitationService.Create(ctx, model.CreateInvitationRequest{
        TenantID:  tenantID,
        CreatedBy: adminUserID,
        Email:     inviteeEmail,
        Roles:     []string{"Member"},
    })
    if err != nil {
        return "", fmt.Errorf("failed to create invitation: %w", err)
    }

    // Send invitation code to invitee via email/Slack
    emailService.SendInvitation(ctx, inviteeEmail, invitation.Code, invitation.ExpiresAt)

    return invitation.Code, nil
}

// Step 2: User signs up with invitation (atomic — recommended)
func UserSignupWithInvitation(ctx context.Context, email, displayName, password, invitationCode string) error {
    invitationService := identity.InvitationService()
    passwordService := identity.PasswordService()

    // Hash the password before creating the user.
    passwordHash, err := passwordService.HashPassword(password)
    if err != nil {
        return fmt.Errorf("password hashing failed: %w", err)
    }

    // Accept validates the invitation, consumes one use, and grants the invited
    // membership in a SINGLE transaction. Tenant and roles are taken from the invitation,
    // and the email must match the invitation's target — so a single-use invitation can
    // never onboard more than one account and roles can't be tampered with. If the email
    // already has a global identity it is REUSED (created=false; the PasswordHash is not
    // applied) rather than duplicated.
    userID, created, err := invitationService.Accept(ctx, model.AcceptInvitationRequest{
        Code:         invitationCode,
        Email:        email,
        DisplayName:  displayName,
        PasswordHash: passwordHash,
    })
    if err != nil {
        // errors.Is(err, authkit.ErrInvitationInvalid) => unknown/expired/used/email-mismatch
        return fmt.Errorf("invitation signup failed: %w", err)
    }

    _, _ = userID, created // issue tokens, send a welcome (or "added to workspace") email, etc.
    return nil
}

// Step 3: Admin lists active invitations
func AdminListActiveInvitations(ctx context.Context, tenantID string) ([]*model.Invitation, error) {
    invitationService := identity.InvitationService()

    // Get only active invitations for display
    invitations, err := invitationService.ListActive(ctx, tenantID)
    if err != nil {
        return nil, fmt.Errorf("failed to list invitations: %w", err)
    }

    return invitations, nil
}

// Step 4: Admin revokes invitation
func AdminRevokeInvitation(ctx context.Context, invitationCode string) error {
    invitationService := identity.InvitationService()

    // Soft delete - preserves audit trail
    err := invitationService.Revoke(ctx, invitationCode)
    if err != nil {
        return fmt.Errorf("failed to revoke invitation: %w", err)
    }

    return nil
}

Invitation Characteristics:

  • Cryptographically secure codes (20 chars, crypto/rand, uppercase + digits)
  • Multi-role support (array of roles, validated by application layer)
  • Configurable expiration (default 7 days)
  • Multi-use support (default single-use, configurable)
  • Atomic redemption (Accept validates, consumes, reuses-or-creates the identity, and grants the membership in one transaction)
  • Soft delete option (revoke vs delete for audit trail)
  • Automatic cleanup (periodic maintenance job)
  • Comprehensive logging (all operations logged with masked codes — only when a Logger is injected; logging is a no-op otherwise)

Security Considerations:

  • Invitation codes are masked in logs (first 4 chars + ***)
  • Role validation is handled by the application layer (library is app-agnostic)
  • Expiration enforcement prevents stale invitations
  • Usage limits prevent invitation sharing
  • Accept redeems, reuses-or-creates the identity, and grants the membership in one transaction, preventing over-redemption of single-use invitations
  • Soft delete preserves audit trail for compliance

OTP Service (One-Time Password Verification)

The OTPService provides secure OTP generation, validation, and lifecycle management with configurable rate limiting.

Generate OTP
otpService := identity.OTPService()

// Generate 6-digit OTP with 1-hour expiration.
// Codes are keyed by (email, purpose) globally — email is a global identity, so an OTP
// is identity-level, not tenant-scoped.
response, err := otpService.Generate(ctx, model.OTPGenerateRequest{
    Email:              "user@acme.com",
    Purpose:            "signup",
    ExpirationDuration: time.Hour,
})
if err != nil {
    // Rate limit exceeded or generation failed
    log.Fatal(err)
}

// Send OTP via email (use separate email service)
emailService.SendOTP(ctx, email, response.Code, response.ExpiresAt)

// Code: "123456" (6-digit cryptographically secure)
// ExpiresAt: time.Now().Add(time.Hour)

Generation Features:

  • Cryptographically secure 6-digit codes (using crypto/rand)
  • Configurable expiration duration (e.g., 1 hour for signup, 15 minutes for 2FA)
  • Automatic upsert (replaces the existing OTP for the same email + purpose and resets the attempt counter)
  • Rate limiting (default: 1 minute between generation attempts)
  • Purpose tracking (signup, reset-password, 2fa)
Validate OTP
// User provides OTP code from email (keyed by email + purpose)
err := otpService.Validate(ctx, model.OTPValidateRequest{
    Email:   "user@acme.com",
    Code:    "123456",
    Purpose: "signup",
})
if err != nil {
    // Invalid, expired, not found, or locked out after too many attempts
    return fmt.Errorf("OTP validation failed: %w", err)
}

// OTP is valid and has been deleted (single-use enforcement)
// Proceed with signup/password reset/2FA verification

Validation Features:

  • Keyed by (email, purpose): a code is matched by the email and purpose it was issued for
  • Verifies code matches (constant-time comparison)
  • Verifies purpose matches (signup, reset-password, 2fa, etc.)
  • Checks expiration timestamp
  • Single-use enforcement (OTP deleted after successful validation)
  • Brute-force lockout: after MaxOTPValidationAttempts (5) failed guesses the code is invalidated and the caller must request a new one
  • Expired OTPs automatically deleted during validation
Rate Limiting Configuration
identity, err := authkit.New(authkit.Config{
    // ... other config
    OTPConfig: authkit.OTPConfig{
        RateLimitDuration: time.Minute * 2, // 2 minutes between OTP requests
    },
})

If a user requests an OTP too soon, Generate returns an error that wraps authkit.ErrOTPRateLimited (branch on it with errors.Is, don't match the string):

please wait 1m30s before requesting a new OTP: authkit: OTP rate limit exceeded
Complete OTP Flow Example (Signup)
// Step 1: User initiates signup
func InitiateSignup(ctx context.Context, email, name string) error {
    otpService := identity.OTPService()

    // Generate OTP with 1-hour expiration (keyed by email + purpose)
    response, err := otpService.Generate(ctx, model.OTPGenerateRequest{
        Email:              email,
        Purpose:            "signup",
        ExpirationDuration: time.Hour,
    })
    if err != nil {
        return fmt.Errorf("failed to generate OTP: %w", err)
    }

    // Send OTP via email gateway (separate service)
    err = emailGateway.SendOTP(ctx, email, name, response.Code, response.ExpiresAt)
    if err != nil {
        return fmt.Errorf("failed to send OTP email: %w", err)
    }

    return nil
}

// Step 2: User provides OTP and completes signup
func CompleteSignup(ctx context.Context, tenantID, email, displayName, otpCode, password string) error {
    otpService := identity.OTPService()

    // Validate OTP (single-use, auto-deleted on success)
    err := otpService.Validate(ctx, model.OTPValidateRequest{
        Email:   email,
        Code:    otpCode,
        Purpose: "signup",
    })
    if err != nil {
        return fmt.Errorf("invalid or expired OTP: %w", err)
    }

    // OTP validated successfully — create the GLOBAL identity, then grant it the tenant
    // membership (identity and access are separate concerns now).
    passwordService := identity.PasswordService()
    passwordHash, err := passwordService.HashPassword(password)
    if err != nil {
        return fmt.Errorf("password hashing failed: %w", err)
    }

    userID, err := identity.UserService().Create(ctx, model.CreateUserRequest{
        Email:        email,
        PasswordHash: passwordHash,
        DisplayName:  displayName,
    })
    if err != nil {
        return fmt.Errorf("create identity: %w", err)
    }

    return identity.MembershipService().Add(ctx, model.AddMembershipRequest{
        UserID:   userID,
        TenantID: tenantID,
        Roles:    []string{"Member"},
    })
}
Delete OTP
// Explicitly revoke OTP (e.g., user cancels operation). Keyed by (email, purpose).
err := otpService.Delete(ctx, "user@acme.com", "signup")
if err != nil {
    log.Fatal(err)
}
Cleanup Expired OTPs

Run periodically (e.g., hourly cron job) to maintain database hygiene:

count, err := otpService.CleanupExpired(ctx)
if err != nil {
    log.Fatal(err)
}

fmt.Printf("Deleted %d expired OTPs\n", count)

OTP Characteristics:

  • One OTP per (email, purpose) at any time (upsert behavior)
  • Identity-level (codes are keyed by the global email + purpose, not by tenant)
  • Purpose enforced (a code generated for one flow cannot validate another)
  • Cryptographically secure generation (crypto/rand, not math/rand)
  • 6-digit numeric code (easy to type, SMS-friendly)
  • Configurable expiration (typical: 1 hour for signup, 15 minutes for 2FA)
  • Generation rate limiting (prevents spam/abuse)
  • Validation attempt lockout (after MaxOTPValidationAttempts failed guesses the code is invalidated)
  • Single-use enforcement (deleted after validation)

Security Considerations:

  • OTP codes are stored only as a keyed HMAC-SHA256 (OTPConfig.HMACSecret), never in plaintext, so a read-only database/backup/replica disclosure does not expose usable codes; validation hashes the supplied code and compares in constant time
  • Generation rate limiting deters spam; online brute force is bounded by the per-code attempt lockout, not by the generation rate limit
  • Because the code is only 6 digits, prefer a short expiration window and the default attempt lockout; consider a longer code for higher-risk flows
  • Short expiration window limits exposure
  • Single-use prevents replay attacks
  • Purpose tracking for audit trails

Configuration

Full Config Example
identity, err := authkit.New(authkit.Config{
    DBConnection: authkit.DatabaseConnection{
        IdentityRWDB:    rwDB,            // Required: write connection
        IdentityRODB:    roDB,            // Required: read connection; can be same as rwDB
        IdentitySchema: "authkit",        // Optional: defaults to "authkit"
    },
    JWTConfig: authkit.JWTConfig{
        AccessSecret:           os.Getenv("JWT_ACCESS_SECRET"),          // Required unless SigningKey is set
        // SigningKey: &authkit.SigningKey{Key: key},                   // Optional: asymmetric key, see docs/signing-keys.md
        RefreshTokenHMACSecret: os.Getenv("REFRESH_TOKEN_HMAC_SECRET"),  // Required
        AccessExpiry:           15 * time.Minute,                        // Optional: defaults to 15 minutes
        RefreshExpiry:          7 * 24 * time.Hour,                      // Optional: defaults to 7 days
    },
    APIKeyConfig: authkit.APIKeyConfig{
        HMACSecret: os.Getenv("API_KEY_HMAC_SECRET"), // Required
    },
    PasswordConfig: authkit.PasswordConfig{
        BcryptCost: 12, // Optional: defaults to 12
    },
    OTPConfig: authkit.OTPConfig{
        RateLimitDuration: time.Minute,                  // Optional: defaults to 1 minute
        HMACSecret:        os.Getenv("OTP_HMAC_SECRET"), // Required when OTPService is used
    },
    SecretConfig: authkit.SecretConfig{
        EncryptionKey: os.Getenv("SECRET_ENCRYPTION_KEY"), // Optional unless using SecretService
    },
    Logger:  logger,  // Optional (but recommended — some security signals are logger-only)
    Metrics: metrics, // Optional: counter sink for login/lockout/OTP/refresh-reuse events
})
if err != nil {
    return err
}

SecretConfig.EncryptionKey must be a base64-encoded 32-byte AES-256 key when SecretService is used, unless SecretConfig.TenantKey gives each tenant a key of its own (see docs/secrets.md).

Generating secrets

The JWT/HMAC secrets must be at least 32 bytes of high-entropy random data, and SecretConfig.EncryptionKey must be the base64 encoding of exactly 32 random bytes. New rejects anything shorter, but it cannot tell random data from a weak passphrase — always generate these with a CSPRNG, never type them by hand:

# JWT access secret, refresh-token HMAC secret, API-key HMAC secret, and (when using
# OTPService) the OTP HMAC secret — one distinct value each:
openssl rand -base64 32

# SecretConfig.EncryptionKey — must DECODE to 32 bytes, so use base64 of 32 random
# bytes (a 32-character ASCII string is wrong: it base64-decodes to 24 bytes and is rejected):
openssl rand -base64 32

Use a distinct value for each field and a different set per environment. See SECURITY.md for storage and rotation guidance.

Environment variables

The library itself reads no environment variables — it is configured entirely through the Config struct passed to New. The variables below are the convention used by the examples in this README: your application reads them (e.g. with os.Getenv) and wires them into Config. Names are yours to choose; these are just what the snippets use.

Variable Maps to Required Notes
RW_DATABASE_URL DBConnection.IdentityRWDB Yes PostgreSQL DSN for the read-write (primary) pool. Append ?statement_timeout=3000&lock_timeout=3000 (see "Performance Considerations").
RO_DATABASE_URL DBConnection.IdentityRODB Yes DSN for the read-only (replica) pool. May be the same DSN as RW for a single-database deployment.
JWT_ACCESS_SECRET JWTConfig.AccessSecret Unless a signing key is set Access-token signing secret (HS256). ≥ 32 bytes of CSPRNG output.
JWT_SIGNING_KEY JWTConfig.SigningKey (via authkit.ParsePrivateKeyPEM) No PEM private key (Ed25519, P-256 or RSA ≥ 2048) that signs access tokens instead. See docs/signing-keys.md.
REFRESH_TOKEN_HMAC_SECRET JWTConfig.RefreshTokenHMACSecret Yes HMAC secret for refresh-token storage. ≥ 32 bytes.
API_KEY_HMAC_SECRET APIKeyConfig.HMACSecret Yes HMAC secret for API-key storage. ≥ 32 bytes.
OTP_HMAC_SECRET OTPConfig.HMACSecret When OTPService is used HMAC secret for OTP storage. ≥ 32 bytes. Omit only if you never call OTPService.
SECRET_ENCRYPTION_KEY SecretConfig.EncryptionKey When SecretService is used Base64 of exactly 32 random bytes (AES-256).
TWO_FACTOR_ENCRYPTION_KEY TwoFactorConfig.EncryptionKey When two-factor sign-in is on Base64 of exactly 32 random bytes, distinct from SECRET_ENCRYPTION_KEY. Set TwoFactorConfig.Issuer with it.

All five *_SECRET/*_HMAC_SECRET values and the encryption keys must be high-entropy CSPRNG output (openssl rand -base64 32), distinct from one another, and distinct per environment — see "Generating secrets" above and SECURITY.md. The issuer/audience and the various durations have safe defaults and are typically set in code rather than via environment.

Tests/CI only: the integration suite reads IDENTITY_TEST_DATABASE_URL (a PostgreSQL DSN). It defaults to the local docker compose database in the Makefile; see CONTRIBUTING.md and make postgres-up.

Required Configuration
  • IdentityRWDB: Database connection for write operations (required)
  • IdentityRODB: Database connection for read operations (required)
  • JWTConfig.AccessSecret: Secret for access token signing (required unless JWTConfig.SigningKey is set)
  • JWTConfig.RefreshTokenHMACSecret: Secret for HMAC-SHA256 hashing of refresh tokens in storage (required)
  • APIKeyConfig.HMACSecret: Secret for API key hashing (required)
  • OTPConfig.HMACSecret: Secret for HMAC-SHA256 hashing of OTP codes in storage (required when OTPService is used; validated for length at construction when set, and Generate/Validate return ErrOTPSecretMissing if absent)
Optional Configuration
  • JWTConfig.SigningKey: Asymmetric key that signs access tokens instead of AccessSecret, stamping its key id; publish its public half with AuthKit.JSONWebKeySet (see docs/signing-keys.md)
  • JWTConfig.VerificationKeys: Further public keys accepted during a key switch or rotation
  • JWTConfig.AccessExpiry: Access token expiration (default: 15 minutes)
  • JWTConfig.RefreshExpiry: Refresh token expiration (default: 7 days)
  • JWTConfig.RefreshGracePeriod: How long a client may retry a refresh whose response it lost, while the replacement it never received is unused (default: 0, strict rotation; needs migration 000006; see docs/refresh-tokens.md)
  • PasswordConfig.BcryptCost: Bcrypt cost factor (default: 12, range: 4-31)
  • OTPConfig.RateLimitDuration: Minimum time between OTP generation attempts (default: 1 minute)
  • DatabaseConnection.IdentitySchema: PostgreSQL schema name (default: authkit)
  • SecretConfig.EncryptionKey: Base64-encoded 32-byte AES-256 key for SecretService
  • SecretConfig.TenantKey: In place of EncryptionKey, a function giving each tenant's own 32-byte key, so one tenant's key exposes only its secrets and destroying it erases them (see docs/secrets.md)
  • TwoFactorConfig: EncryptionKey (base64 of 32 random bytes, its own key) and Issuer (the name in people's authenticator apps) turn on two-factor sign-in (default: off; needs migration 000009; see docs/two-factor.md)
  • Logger: Logger implementation (default: nil, no logging). Some security signals (OTP lockout, refresh-token reuse, sub-threshold failed logins) are logger-only — inject one in production.
  • Metrics: Counter sink (default: nil, no metrics); survives independently of log level, so it's the recommended basis for alerting. The library emits the metrics.Counter* constants — authkit_login_success_total, authkit_login_failure_total, authkit_account_lockout_total, authkit_otp_failure_total, authkit_otp_lockout_total, authkit_refresh_token_reuse_total, authkit_refresh_token_retry_total, authkit_rate_limited_total, authkit_rate_limiter_error_total, authkit_best_effort_write_failure_total — see the metrics package for the authoritative list.
  • RateLimiter: Optional throttle (ratelimit.RateLimiter) consulted by Login/LoginByEmail/RefreshToken/OTPService.Generate/TwoFactorService.CompleteSecondFactor (default: nil, no limiting). The recommended place to enforce IP/global rate limiting — see "Rate Limiting" below.
  • VerifyConnectivity: When true, New pings both DB handles and fails fast if either is unreachable (default: false, lazy connection). See "Health Checks".
  • RecordSignIns: When true, records every sign-in attempt on an existing account for SignInHistoryService (default: false; needs migration 000007; see docs/sign-in-history.md).
Expiration Duration

CreateAPIKeyRequest.ExpiresIn is a *time.Duration (nil means the key never expires). The key expires at time.Now().Add(*ExpiresIn). Express longer spans as plain durations:

  • 30 * 24 * time.Hour - 30 days
  • 6 * 30 * 24 * time.Hour - ~6 months (30 days per month)
  • 365 * 24 * time.Hour - ~1 year

Database Schema

The library currently requires PostgreSQL with the authkit schema containing the following tables:

  • authkit.tenants: Organization definitions with allowed email domains
  • authkit.users: Global identity profiles. No tenant_id and no roles columns; email is globally unique via a UNIQUE index on lower(email). Carries the identity token_version, and email_verified_at (migration 000005, required).
  • authkit.memberships: The user↔tenant graph. PK (user_id, tenant_id), a roles JSONB column (opaque strings), a per-membership token_version, a status (active/suspended), and FKs to users/tenants that CASCADE on delete.
  • authkit.invitations: Access codes that grant a membership on signup
  • authkit.api_keys: Tenant-scoped API keys (HMAC-SHA256 hashed); created_by is a global user id
  • authkit.refresh_tokens: Identity-session refresh token records (HMAC-SHA256 hashed)
  • authkit.verification_codes: OTP codes keyed by (email, purpose) (no tenant_id), stored as a keyed HMAC-SHA256 (code_hash), never plaintext
  • authkit.provider_keys: Encrypted provider secrets (tenant-scoped)
  • authkit.user_status_changes: Account status-change audit trail (enable/disable/lock/unlock), written in the same transaction as the change; read via UserService.StatusHistory, pruned via UserService.CleanupStatusChanges
  • authkit.sessions (optional, migrations 000002 and 000003): Login records for installation-bound and verified sign-in sessions, with the sign-in method; see installation sessions
  • authkit.external_identities (optional, migration 000004): Outside sign-in accounts keyed by (provider, subject), each linked to one user; see verified sign-in
  • authkit.sign_in_events (optional, migration 000007): One row per sign-in attempt when RecordSignIns is set; read via SignInHistoryService.List, pruned via SignInHistoryService.Cleanup; see sign-in history
  • authkit.totp_factors, authkit.recovery_codes, authkit.second_factor_challenges (optional, migration 000009): Each user's encrypted authenticator-app secret, their hashed recovery codes, and password logins waiting for a code; see two-factor sign-in

The migrations ship inside the module: authkit.Migrations returns them as numbered SQL files for golang-migrate or any tool that runs NNNNNN_name.up.sql files in order. Apply them all; a migration that has shipped in a release never changes. See database migrations.

files, err := authkit.Migrations("") // the default authkit schema
source, err := iofs.New(files, ".")   // github.com/golang-migrate/migrate/v4/source/iofs
Configurable Schema

By default, the repository queries authkit.*. You can configure a different PostgreSQL schema with DatabaseConnection.IdentitySchema:

identity, err := authkit.New(authkit.Config{
    DBConnection: authkit.DatabaseConnection{
        IdentityRWDB:    rwDB,
        IdentityRODB:    roDB,
        IdentitySchema: "my_identity",
    },
    // ...
})

Schema names are validated as simple PostgreSQL identifiers and quoted before being inserted into SQL. For a custom schema, authkit.Migrations("my_identity") returns the migrations with that schema in place of authkit.

Security Best Practices

API Key Storage
  • Never store plaintext API keys - Only HMAC-SHA256 hashes are stored
  • Return plaintext key only once - During creation, after that it's never retrievable
  • Use HTTPS - Always transmit API keys over encrypted connections
  • Rotate keys regularly - Implement key rotation policies
HMAC Secret Management
// ❌ Don't hardcode secrets
APIKeyConfig: authkit.APIKeyConfig{
    HMACSecret: "my-secret-key",
}

// ✅ Load from environment or secret manager
APIKeyConfig: authkit.APIKeyConfig{
    HMACSecret: os.Getenv("API_KEY_HMAC_SECRET"),
}
UUID v7

The library uses UUID v7 for all identifiers, minted in Go and represented as 36-char strings:

  • Time-ordered for better database indexing
  • Chronologically sortable
  • Cryptographically secure

The public Go surface uses string (not uuid.UUID) for every id — user ids, key ids, token/family ids — so callers don't need a uuid package to interoperate. The DB columns are varchar(36) (no SQL-side UUID type or generator); ids are still minted with uuid.NewV7() and stored/returned in their canonical 36-char string form:

import "github.com/google/uuid"

// Minted internally for all IDs, then used as the 36-char string form.
id, err := uuid.NewV7()
if err != nil { /* handle */ }
keyID := id.String() // e.g. "0190bdf2-...": a varchar(36) value in the DB

Architecture

Layered Design
authkit-go/
├── model/                    # Data models, request/response structs, sentinel errors (public)
├── logger/                   # Logger interface + NopLogger (public)
├── metrics/                  # Metrics interface, counter names + no-op default (public)
├── ratelimit/                # RateLimiter interface (public)
├── migrations/               # Numbered SQL migrations, embedded; see Migrations() (public)
├── internal/
│   ├── repository/           # Database operations (CRUD) — not importable by consumers
│   └── service/              # Business logic layer — not importable by consumers
├── config.go                 # Configuration structures (public)
├── services.go               # The service interfaces AuthKit's accessors return (public)
├── errors.go                 # Sentinel errors re-exported from model (public)
├── keys.go                   # PEM parsing for signing keys (public)
├── migrations.go             # Migrations(schema): the embedded SQL for your migration tool (public)
└── main.go                   # Library entry point: New + AuthKit accessors (public)

Only the root authkit package, model, logger, metrics and ratelimit are part of the public import surface; service and repository live under internal/ and are reached exclusively through AuthKit's accessor methods (AuthService(), etc.).

Service Layer Pattern

All business logic is encapsulated in service layer:

// Get service instances from AuthKit
apiKeyService := identity.APIKeyService()
passwordService := identity.PasswordService()
jwtService := identity.JWTService()
tokenService := identity.TokenService()
authService := identity.AuthService()
otpService := identity.OTPService()
tenantService := identity.TenantService()
invitationService := identity.InvitationService()
userService := identity.UserService()
membershipService := identity.MembershipService()
secretService := identity.SecretService()
sessionService := identity.SessionService()                   // Config.SessionConfig
installationAuthService := identity.InstallationAuthService() // Config.SessionConfig
verifiedLoginService := identity.VerifiedLoginService()
externalIdentityService := identity.ExternalIdentityService()
signInHistoryService := identity.SignInHistoryService()       // Config.RecordSignIns
passkeyService := identity.PasskeyService()
twoFactorService := identity.TwoFactorService()               // Config.TwoFactorConfig
Read-Write Connection Separation

The library optimizes database access by separating read and write operations:

  • Write operations (Create, Revoke) use IdentityRWDB
  • Security-sensitive validation (Validate, refresh token validation/rotation) uses IdentityRWDB to avoid replica-lag acceptance after revocation
  • Non-critical reads (List, tenant/user list operations) use IdentityRODB
  • Supports PostgreSQL read replicas for horizontal scaling

Error Handling

Service methods return sentinel errors (authkit.Err*), wrapped with %w for context, so branch on them with errors.Is rather than matching error strings. Map them to status codes — and treat anything you don't recognize (including authkit.ErrInternal) as a 500, not a 401:

claims, err := authService.ValidateJWT(ctx, token)
switch {
case err == nil:
    // authorized — use claims
case errors.Is(err, authkit.ErrInvalidToken), errors.Is(err, authkit.ErrTokenRevoked):
    // ErrTokenRevoked now also covers a bumped membership token_version or a
    // suspended/removed membership in the token's tenant.
    http.Error(w, "unauthorized", http.StatusUnauthorized) // 401
case errors.Is(err, authkit.ErrAccountDisabled), errors.Is(err, authkit.ErrAccountLocked),
    errors.Is(err, authkit.ErrNoActiveMembership):
    // ErrNoActiveMembership: valid credentials, but not (or no longer) a member of the
    // requested workspace — returned by Login/LoginByEmail/MintWorkspaceToken/RefreshToken.
    http.Error(w, "forbidden", http.StatusForbidden) // 403
default:
    // ErrInternal or any unexpected fault: log server-side, do NOT leak err to the client
    log.Error("jwt validation failed", zap.Error(err))
    http.Error(w, "internal error", http.StatusInternalServerError) // 500
}

Exported sentinels include ErrInvalidCredentials, ErrAccountDisabled, ErrAccountLocked, ErrNoActiveMembership, ErrLastRoleHolder, ErrInvalidToken, ErrTokenRevoked, ErrInvalidRefreshToken, ErrRefreshTokenReuse, ErrInvalidOTP, ErrOTPLockedOut, ErrOTPRateLimited, ErrOTPSecretMissing, ErrInvalidAPIKey, ErrIncorrectPassword, ErrInvitationInvalid, ErrUserExists, ErrTenantExists, ErrPasswordTooWeak, ErrSecretEncryptionKeyMissing, ErrRateLimited, ErrInvalidInput, ErrValueTooLong, ErrNotFound, and ErrInternal, plus the session sentinels in installation sessions and ErrIdentityLinkRequired, ErrIdentityAlreadyLinked and ErrEmailNotVerified from verified sign-in. ErrValueTooLong and ErrInvalidInput both map to a 400 — the former when a caller-supplied value exceeds the column/size limit, the latter when input is otherwise malformed (e.g. tenant settings that cannot be serialized to JSON); ErrRateLimited maps to a 429 and is returned by Login/RefreshToken/OTPService.Generate when a configured RateLimiter rejects the call; ErrOTPSecretMissing is returned by OTPService when no OTPConfig.HMACSecret was configured. ErrPasswordTooWeak covers strength failures (too short, too long, or a missing character class) from ChangePassword/ValidatePasswordStrength/ HashPassword; ErrSecretEncryptionKeyMissing is returned by SecretService operations when no valid encryption key was configured. ErrNoActiveMembership maps to a 403 and is the workspace-scoping outcome of the decoupled model — the credentials are valid but the identity has no active membership in the requested tenant (no row, or it is suspended); Login/LoginByEmail/MintWorkspaceToken/RefreshToken return it. ErrLastRoleHolder maps to a 409 and is returned by MembershipService.RemoveGuarded/UpdateRolesGuarded when the change would leave the tenant with no active, enabled member holding the required role (e.g. the workspace's last Admin); nothing is written when it is returned. ValidateJWT additionally returns ErrTokenRevoked when the membership token_version no longer matches or the active membership has been suspended/removed (the workspace's access tokens are revoked without touching the identity session). Login deliberately collapses not-found / wrong-password / disabled / locked into a single ErrInvalidCredentials to avoid account enumeration (it does NOT collapse ErrNoActiveMembership, which is an authorization, not an authentication, outcome); the specific reason is logged server-side only. A storage failure (an unreachable database, a missing migration) is ErrInternal, never ErrInvalidCredentials, and the same holds for APIKeyService.Validate and ErrInvalidAPIKey.

Do not render err.Error() to end users — wrapped errors may carry internal context. Map to a status with errors.Is, and log the raw error server-side.

Performance Considerations

Async Last Used Tracking

API key validation updates last_used_at asynchronously to avoid blocking:

// Validate returns immediately
keyContext, err := apiKeyService.Validate(ctx, apiKey)
// last_used_at updated in background goroutine
Database Connection Pooling (required in production)

The library uses the caller-provided *sql.DB handles as-is and never configures their pools. database/sql defaults to unlimited open connections and infinite connection lifetime, so an unconfigured pool can exceed Postgres max_connections under load and pin stale connections across a failover. Always bound both pools:

rwDB.SetMaxOpenConns(25)
rwDB.SetMaxIdleConns(5)
rwDB.SetConnMaxLifetime(time.Minute * 5)

roDB.SetMaxOpenConns(50)  // More read connections
roDB.SetMaxIdleConns(10)
roDB.SetConnMaxLifetime(time.Minute * 5)
Statement and lock timeouts (required in production)

The library issues no server-side timeout and injects no deadline of its own (only the best-effort last_used_at background write is bounded). With Postgres' default statement_timeout = 0, a request that arrives with a non-deadline context can pin a pooled connection — and, on the FOR UPDATE paths (login lockout, refresh rotation, OTP consume), a row lock — indefinitely under contention. Defend against head-of-line blocking and pool exhaustion by both:

  • Setting server-side timeouts on the rw/ro DSNs, e.g. postgres://.../db?statement_timeout=3000&lock_timeout=3000 (milliseconds), and
  • Always passing a deadline-bearing context.Context (e.g. context.WithTimeout) to the service methods.

Rate Limiting

The library does not rate-limit on its own — the per-account failed-login lockout does not stop password spraying, distributed guessing, refresh abuse, or OTP-email fan-out (see SECURITY.md). Plug in your own throttle via Config.RateLimiter; it is consulted by Login, LoginByEmail, RefreshToken, OTPService.Generate and TwoFactorService.CompleteSecondFactor before they do work, and a rejection surfaces as authkit.ErrRateLimited (map it to HTTP 429):

// A token-bucket / Redis / in-memory limiter, adapted to the ratelimit.RateLimiter
// interface. key is one of "login:<tenant>:<ip|email:...>" (Login),
// "login:<ip|email:...>" (LoginByEmail — no tenant in the key),
// "refresh:<ip|token:...>" (a digest of the token when there is no IP),
// "otp:<email>:<purpose>" (no tenant), "second_factor:<userID>" (CompleteSecondFactor)
// — bucket on it directly.
type myLimiter struct{ /* ... */ }

func (l *myLimiter) Allow(ctx context.Context, key string) (bool, error) {
    return l.bucket.Allow(key), nil // false => rejected (ErrRateLimited)
}

identity, _ := authkit.New(authkit.Config{ /* ... */, RateLimiter: &myLimiter{} })

If Allow returns an error the library fails open (the operation proceeds and the error is logged + metered via authkit_rate_limiter_error_total), so a limiter-backend outage degrades to "no limiting" rather than a hard auth outage. Implement Allow to return (false, nil) on your own backend errors if you prefer fail-closed.

Health Checks

New does not contact the database unless Config.VerifyConnectivity is true (connection errors otherwise surface on the first query). For readiness/liveness probes, call AuthKit.Ping(ctx), which pings both the read-write and read-only handles:

identity, _ := authkit.New(authkit.Config{ /* ... */, VerifyConnectivity: true }) // fail fast at startup

// In a readiness handler:
if err := identity.Ping(ctx); err != nil {
    http.Error(w, "not ready", http.StatusServiceUnavailable)
    return
}

Behavior under database outage: the security-critical read paths (ValidateJWT, Login's status gate, refresh-token lookup) read from the read-write primary and fail closed — a primary outage rejects authentication rather than allowing it. A read-only replica outage affects only replica-served reads (lists/queries); the auth-decision paths do not depend on the replica. Best-effort writes (last-login, failed-count reset, auto-unlock) are swallowed so a transient primary write hiccup does not fail an otherwise-valid login — those failures are surfaced via authkit_best_effort_write_failure_total for alerting.

Testing

Unit Tests
make test
Integration Tests

Integration tests use Docker Compose to run PostgreSQL, apply the migrations, and exercise repository paths against a real schema.

make postgres-up
make test-integration
make postgres-down

The default integration database URL is:

postgres://authkit_test:authkit_test@localhost:55432/authkit_test?sslmode=disable

Override it with IDENTITY_TEST_DATABASE_URL if needed.

To reset the database volume and re-run migrations from scratch:

make postgres-reset
make test-integration

Examples

Per-service usage snippets live in docs/examples.md and integration patterns in docs/patterns.md. Standalone runnable programs are still planned — useful first ones would include:

  • api_key_management.go - API key CRUD operations
  • custom_logger.go - Integrating with zap/logrus (context-aware)
  • multi_tenant.go - Multi-tenant user management

Contributing

Contributions are welcome. See CONTRIBUTING.md for development and pull request guidance.

Development Setup
  1. Clone the repository:
git clone https://github.com/modgudinc/authkit-go.git
cd authkit-go
  1. Install dependencies:
go mod download
  1. Run tests:
make test
  1. Run integration tests:
make postgres-up
make test-integration
  1. Run linter:
make lint

License

This project is licensed under the Apache License, Version 2.0 - see the LICENSE file for details.

Support

Roadmap

  • API Key Management
  • Password Hashing Service (bcrypt)
  • JWT Service (token generation & validation)
  • Token Service (combined JWT + refresh token management)
  • Auth Service (login, login-by-email, workspace-token mint/switch, logout, token refresh, JWT validation)
  • Decoupled identities + memberships (global users, MembershipService, workspace-scoped tokens, dual token_version)
  • OTP Service (one-time password generation & validation with rate limiting)
  • Tenant Service (tenant management, settings, domain validation)
  • Invitation Service (invitation management, validation, revocation)
  • Account status-change audit trail (user_status_changes + UserService.StatusHistory)
  • Rate Limiting (pluggable Config.RateLimiter consulted by Login/LoginByEmail/RefreshToken/OTP generation/CompleteSecondFactor)
  • Metrics hooks (Config.Metrics) and health checks (AuthKit.Ping / Config.VerifyConnectivity)
  • Verified sign-in sessions and linked sign-in identities (OAuth/OIDC client protocol left to the app)
  • Sign-in history (Config.RecordSignIns + SignInHistoryService)
  • Asymmetric access-token signing with key rotation (JWTConfig.SigningKey, VerificationKeys, JSONWebKeySet)
  • Passkeys (PasskeyService) and two-factor sign-in (TwoFactorService)
  • General audit logging beyond status changes (planned)

Changelog

See CHANGELOG.md for version history and release notes.

Documentation

Overview

Package authkit is a multi-tenant identity and authentication library for Go, backed by PostgreSQL. It provides users and tenants, JWT access tokens with rotating refresh tokens, API keys, OTP verification, invitations, and encrypted provider secrets.

Construct an AuthKit with New and a Config, then obtain the individual services (AuthService, UserService, etc.) from it:

kit, err := authkit.New(authkit.Config{ /* ... */ })
if err != nil {
    return err
}
auth := kit.AuthService()

Authentication outcomes are returned as sentinel errors (authkit.Err*), so callers should branch with errors.Is rather than matching error strings.

Index

Constants

View Source
const (
	// SessionDisabled is the default: no session tables, refresh tokens only.
	SessionDisabled = model.SessionDisabled
	// SessionOptional allows logins with and without an installation.
	SessionOptional = model.SessionOptional
	// SessionRequired refuses logins that bind no installation.
	SessionRequired = model.SessionRequired
)

The session modes, for SessionConfig.Mode.

View Source
const (
	// DefaultAccessExpiry is how long an access token lives: 15 minutes.
	DefaultAccessExpiry = time.Minute * 15
	// DefaultRefreshExpiry is how long a refresh token lives: 7 days.
	DefaultRefreshExpiry = time.Hour * 168
	// DefaultOTPRateLimitPeriod is the built-in cooldown between two OTPs for the
	// same email and purpose: one minute.
	DefaultOTPRateLimitPeriod = time.Minute * 1

	// MinSecretLength is the minimum length (in bytes) required for the JWT
	// signing secrets and HMAC secrets. HS256/HMAC-SHA256 security depends on a
	// key with at least the hash output size of entropy (256 bits = 32 bytes,
	// per RFC 7518). Shorter secrets are brute-forceable offline against any
	// captured token, so they are rejected at construction time.
	MinSecretLength = 32

	// HighBcryptCostThreshold is the bcrypt cost above which New logs a warning.
	// Costs above ~15 take well over 1s per hash on typical hardware; because every
	// login runs at least one bcrypt comparison (a real one or DummyVerify), an
	// excessive cost is a self-inflicted denial-of-service rather than added safety.
	HighBcryptCostThreshold = 15
)

Defaults New applies to a zero JWTConfig.AccessExpiry, JWTConfig.RefreshExpiry and OTPConfig.RateLimitPeriod.

Variables

View Source
var (
	ErrSessionDisabled = model.ErrSessionDisabled
	ErrSessionRequired = model.ErrSessionRequired
	ErrSessionRevoked  = model.ErrSessionRevoked
)

Installation-session sentinels (docs/installation-sessions.md).

View Source
var (
	ErrIdentityLinkRequired  = model.ErrIdentityLinkRequired
	ErrIdentityAlreadyLinked = model.ErrIdentityAlreadyLinked
	ErrEmailNotVerified      = model.ErrEmailNotVerified
)

External-identity sentinels (docs/verified-sign-in.md).

View Source
var (
	ErrPasskeyAlreadyRegistered = model.ErrPasskeyAlreadyRegistered
	ErrPasskeyCloned            = model.ErrPasskeyCloned
)

Passkey sentinels (docs/passkeys.md).

View Source
var (
	ErrSecondFactorRequired    = model.ErrSecondFactorRequired
	ErrInvalidSecondFactor     = model.ErrInvalidSecondFactor
	ErrTwoFactorAlreadyEnabled = model.ErrTwoFactorAlreadyEnabled
	ErrTwoFactorNotConfigured  = model.ErrTwoFactorNotConfigured
)

Two-factor sentinels (docs/two-factor.md).

View Source
var (
	ErrNotFound                   = model.ErrNotFound
	ErrInvalidCredentials         = model.ErrInvalidCredentials
	ErrAccountDisabled            = model.ErrAccountDisabled
	ErrAccountLocked              = model.ErrAccountLocked
	ErrInvalidToken               = model.ErrInvalidToken
	ErrTokenRevoked               = model.ErrTokenRevoked
	ErrNoActiveMembership         = model.ErrNoActiveMembership
	ErrLastRoleHolder             = model.ErrLastRoleHolder
	ErrInvalidRefreshToken        = model.ErrInvalidRefreshToken
	ErrRefreshTokenReuse          = model.ErrRefreshTokenReuse
	ErrInvalidOTP                 = model.ErrInvalidOTP
	ErrOTPLockedOut               = model.ErrOTPLockedOut
	ErrOTPRateLimited             = model.ErrOTPRateLimited
	ErrOTPSecretMissing           = model.ErrOTPSecretMissing
	ErrInvalidAPIKey              = model.ErrInvalidAPIKey
	ErrIncorrectPassword          = model.ErrIncorrectPassword
	ErrInvitationInvalid          = model.ErrInvitationInvalid
	ErrUserExists                 = model.ErrUserExists
	ErrTenantExists               = model.ErrTenantExists
	ErrPasswordTooWeak            = model.ErrPasswordTooWeak
	ErrSecretEncryptionKeyMissing = model.ErrSecretEncryptionKeyMissing
	ErrRateLimited                = model.ErrRateLimited
	ErrInvalidInput               = model.ErrInvalidInput
	ErrValueTooLong               = model.ErrValueTooLong
	ErrInternal                   = model.ErrInternal
)

Sentinel errors re-exported from the model package for convenience, so callers can write errors.Is(err, authkit.ErrInvalidCredentials). These are the same values the services return (model.Err*), so matching against either works.

Functions

func Migrations added in v0.21.0

func Migrations(schema string) (fs.FS, error)

Migrations returns authkit's SQL migrations as a flat file system of numbered pairs, 000001_identity_schema.up.sql and 000001_identity_schema.down.sql onwards: the layout golang-migrate reads (source/iofs) and other tools that run NNNNNN_name.up.sql files in order. Apply every up migration in order; the ones only some features need just add tables and columns that stay unused otherwise. See docs/migrations.md.

schema is the DatabaseConnection.IdentitySchema the application configures. "" and "authkit" return the files exactly as shipped. Any other valid identifier returns the same SQL with that schema, quoted, wherever it names the authkit schema; the optional authkit_rw and authkit_ro roles it grants to keep their names.

A migration that has shipped in a release never changes; schema changes arrive as new numbered migrations.

func ParsePrivateKeyPEM added in v0.21.0

func ParsePrivateKeyPEM(data []byte) (crypto.Signer, error)

ParsePrivateKeyPEM parses one PEM-encoded private key for JWTConfig.SigningKey: PKCS #8 ("PRIVATE KEY", what openssl genpkey writes), SEC 1 ("EC PRIVATE KEY") or PKCS #1 ("RSA PRIVATE KEY"). It refuses encrypted keys and anything after the key. New checks the key's type, curve and size.

func ParsePublicKeyPEM added in v0.21.0

func ParsePublicKeyPEM(data []byte) (crypto.PublicKey, error)

ParsePublicKeyPEM parses one PEM-encoded public key for JWTConfig.VerificationKeys: PKIX ("PUBLIC KEY", what openssl pkey -pubout writes) or PKCS #1 ("RSA PUBLIC KEY"). New checks the key's type, curve and size.

func WithTx added in v0.21.0

func WithTx(ctx context.Context, tx *sql.Tx) context.Context

WithTx returns a copy of ctx that makes authkit run its queries in tx, so what a call changes commits or rolls back with the caller's own writes in the same transaction: an outbox row recording the change, say. tx must be a transaction on the identity schema's database (Config.DBConnection). Where authkit would begin a transaction of its own, it sets a savepoint in tx instead, and reads go to tx too, so they see what the caller wrote before them.

Signing in and checking a credential ignore tx and commit on their own, because what they record (failed attempts, lockouts, revoked token families, used codes and signature counters) must stay recorded whatever the caller does with tx: the AuthService, InstallationAuthService and VerifiedLoginService sign-ins, AuthService.MintWorkspaceToken, RefreshToken, ValidateJWT and ChangePassword, the TokenService calls that issue or check a token, OTPService.Generate and Validate, TwoFactorService.ConfirmTOTP, Verify and CompleteSecondFactor, APIKeyService.Validate and PasskeyService.RecordUse.

An error from a call may leave tx unusable, since Postgres aborts a transaction whose statement fails, so roll tx back when a call fails. A transaction-scoped advisory lock authkit takes is held until tx ends. See docs/transactions.md.

Types

type APIKeyConfig

type APIKeyConfig struct {
	HMACSecret string // Secret key for HMAC-SHA256 hashing of API keys
}

APIKeyConfig configures API key hashing. HMACSecret is required and must be at least MinSecretLength bytes of high-entropy random data.

type APIKeyService

type APIKeyService interface {
	Create(ctx context.Context, req model.CreateAPIKeyRequest) (*model.CreateAPIKeyResponse, error)
	Validate(ctx context.Context, apiKey string) (*model.APIKeyContext, error)
	List(ctx context.Context, tenantID string, includeExpired bool) ([]*model.APIKey, error)
	Get(ctx context.Context, keyID, tenantID string) (*model.APIKey, error)
	Revoke(ctx context.Context, keyID, tenantID string) error
	Count(ctx context.Context, tenantID string) (int, error)
	CleanupExpired(ctx context.Context) (int64, error)
}

APIKeyService manages tenant-scoped API keys (creation, validation, revocation).

type AuthKit

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

AuthKit is the library entrypoint created by New. It is safe for concurrent use and holds the configured connections, secrets, and options. Obtain the individual services through its accessor methods (AuthService, UserService, TokenService, etc.).

func New

func New(config Config) (*AuthKit, error)

New validates the Config and returns a configured AuthKit. It fails fast if a required connection or secret is missing, a secret is shorter than MinSecretLength, a signing or verification key has an unsupported type, curve or size or reuses another key's id, the refresh grace period is negative, the identity schema is not a valid identifier, the secret encryption key (when provided) is not a base64-encoded 32-byte key, or the OTP HMAC secret (when provided) is shorter than MinSecretLength. The OTP HMAC secret is only required at use time (OTPService.Generate/Validate), not at construction.

func (*AuthKit) APIKeyService

func (k *AuthKit) APIKeyService() APIKeyService

APIKeyService returns the API key service for managing tenant-scoped API keys (creation, validation, revocation).

func (*AuthKit) AuthService

func (k *AuthKit) AuthService() AuthService

AuthService returns the authentication service for complete authentication flows (login, logout, token refresh, JWT validation).

func (*AuthKit) ExternalIdentityService added in v0.21.0

func (k *AuthKit) ExternalIdentityService() ExternalIdentityService

ExternalIdentityService links accounts at outside sign-in providers (Google, GitHub, enterprise OIDC) to users. Requires migration 000004.

func (*AuthKit) InTx added in v0.21.0

func (k *AuthKit) InTx(ctx context.Context, fn func(ctx context.Context, tx *sql.Tx) error) error

InTx runs fn in a new transaction on the read-write identity database, with a context that makes authkit's calls run in it (WithTx), and commits it if fn returns nil. Otherwise it rolls the transaction back and returns fn's error.

func (*AuthKit) InstallationAuthService added in v0.20.2

func (k *AuthKit) InstallationAuthService() InstallationAuthService

InstallationAuthService adds enrollment-bound login and verified bindings. Existing AuthService interfaces remain unchanged.

func (*AuthKit) InvitationService

func (k *AuthKit) InvitationService() InvitationService

InvitationService returns the invitation service for invitation management, validation, and lifecycle operations.

func (*AuthKit) JSONWebKeySet added in v0.21.0

func (k *AuthKit) JSONWebKeySet() JSONWebKeySet

JSONWebKeySet returns the public keys that validate access tokens, as a JSON Web Key Set (RFC 7517) to serve at a jwks_uri such as /.well-known/jwks.json, so other services and API gateways can check tokens without a shared secret. The signing key comes first, then JWTConfig.VerificationKeys. The HS256 secret is never included: the set is empty for a kit that signs with AccessSecret alone.

A service that checks tokens this way sees only the signature, expiry, issuer and audience. It does not see revocation (token versions, disabled accounts, revoked sessions): AuthService.ValidateJWT checks those against the database.

func (*AuthKit) JWTService

func (k *AuthKit) JWTService() JWTService

JWTService returns the JWT service for generating and validating JWT access tokens and refresh tokens.

func (*AuthKit) MembershipService

func (k *AuthKit) MembershipService() MembershipService

MembershipService returns the membership service for managing the user<->tenant membership graph (grant/revoke, roles, member lists).

func (*AuthKit) OTPService

func (k *AuthKit) OTPService() OTPService

OTPService returns the OTP service for OTP generation, validation, and lifecycle management with rate limiting.

func (*AuthKit) PasskeyService added in v0.21.0

func (k *AuthKit) PasskeyService() PasskeyService

PasskeyService stores WebAuthn credentials (passkeys and security keys) and the user handles they carry; the application runs the WebAuthn ceremonies. Requires migration 000008; see docs/passkeys.md.

func (*AuthKit) PasswordService

func (k *AuthKit) PasswordService() PasswordService

PasswordService returns the password service for password hashing and verification using bcrypt.

func (*AuthKit) Ping

func (k *AuthKit) Ping(ctx context.Context) error

Ping verifies connectivity to both the read-write and read-only identity databases by issuing a PingContext to each. It is intended for readiness/health probes. New performs this check at construction only when Config.VerifyConnectivity is set; otherwise connection errors surface lazily on the first query.

func (*AuthKit) SecretService

func (k *AuthKit) SecretService() SecretService

SecretService returns the secret service for managing encrypted secrets (provider API keys, etc.).

IMPORTANT: SecretConfig.EncryptionKey or SecretConfig.TenantKey must be configured for this service to work. The encryption key must be a base64-encoded 32-byte (256-bit) key, and a tenant's key 32 bytes. Operations return ErrSecretEncryptionKeyMissing if it is absent or the wrong length.

func (*AuthKit) SessionService added in v0.20.2

func (k *AuthKit) SessionService() SessionService

SessionService manages sessions scoped to the configured application namespace. Callers must authorize session-management actions for the supplied user.

func (*AuthKit) SignInHistoryService added in v0.21.0

func (k *AuthKit) SignInHistoryService() SignInHistoryService

SignInHistoryService reads the sign-in history that Config.RecordSignIns records. Requires migration 000007; see docs/sign-in-history.md.

func (*AuthKit) TenantService

func (k *AuthKit) TenantService() TenantService

TenantService returns the tenant service for tenant management, settings, and domain validation.

func (*AuthKit) TokenService

func (k *AuthKit) TokenService() TokenService

TokenService returns the token service for combined JWT and refresh token management with token rotation.

func (*AuthKit) TwoFactorService added in v0.21.0

func (k *AuthKit) TwoFactorService() TwoFactorService

TwoFactorService sets up two-factor sign-in with an authenticator app and recovery codes, and finishes the password logins that wait for a second factor. Requires Config.TwoFactorConfig (without it every call returns ErrTwoFactorNotConfigured) and migration 000009; see docs/two-factor.md.

func (*AuthKit) UserService

func (k *AuthKit) UserService() UserService

UserService returns the user service for global-identity management, queries, and account status operations.

func (*AuthKit) VerifiedLoginService added in v0.21.0

func (k *AuthKit) VerifiedLoginService() VerifiedLoginService

VerifiedLoginService opens sessions for users the application has verified itself (OAuth/OIDC, email links, OTPs, passkeys, device codes). It works in every session mode; see docs/verified-sign-in.md.

type AuthService

type AuthService interface {
	// Login authenticates into a known workspace (req.TenantID) and returns a
	// workspace-scoped access token + identity-session refresh token. A user with
	// two-factor on gets ErrSecondFactorRequired instead (TwoFactorService).
	Login(ctx context.Context, req model.LoginRequest) (*model.LoginResponse, error)
	// LoginByEmail authenticates the identity without a known workspace: it opens the
	// session and either mints a token (when the workspace is given or the identity
	// has exactly one active membership) or returns the membership list to choose from.
	LoginByEmail(ctx context.Context, req model.LoginByEmailRequest) (*model.LoginResponse, error)
	// MintWorkspaceToken mints a fresh workspace-scoped access token from an existing
	// identity session (the refresh token) — the select-/switch-workspace call.
	MintWorkspaceToken(ctx context.Context, refreshToken, tenantID string) (*model.WorkspaceTokenResponse, error)
	Logout(ctx context.Context, refreshToken string) error
	// LogoutAll invalidates every workspace's access tokens and revokes all refresh
	// tokens for an identity (identity-level; the caller authorizes userID).
	LogoutAll(ctx context.Context, userID string) error
	RefreshToken(ctx context.Context, req model.RefreshRequest) (*model.RefreshResponse, error)
	ValidateJWT(ctx context.Context, token string) (*model.JWTClaims, error)
	// ChangePassword sets a new password after checking the old one, and signs the
	// identity out everywhere. A wrong old password counts toward the failed-login
	// lockout.
	ChangePassword(ctx context.Context, req model.ChangePasswordRequest) error
}

AuthService provides the end-to-end authentication flows (login, logout, token refresh, JWT validation, password change).

Rate limiting is caller-owned. Login enforces only a per-account failed-login lockout, which does NOT stop password spraying (a few guesses each across many accounts) or distributed guessing, and RefreshToken has no built-in throttle. The embedding application MUST add IP-/client-based rate limiting in front of these methods. See SECURITY.md ("Caller-Owned Controls").

type Config

type Config struct {
	SessionConfig  SessionConfig // Optional; disabled by default, requires migrations 000002 and 000003 when enabled.
	DBConnection   DatabaseConnection
	JWTConfig      JWTConfig
	APIKeyConfig   APIKeyConfig
	PasswordConfig PasswordConfig
	OTPConfig      OTPConfig
	SecretConfig   SecretConfig // Optional: Configuration for SecretService
	// TwoFactorConfig is optional; it turns on two-factor sign-in (TwoFactorService)
	// and requires migration 000009.
	TwoFactorConfig TwoFactorConfig
	// Logger is optional and defaults to a no-op that discards everything. NOTE
	// that several security signals are logger-only: OTP lockouts, refresh-token
	// reuse, and sub-threshold failed logins are emitted only via the logger and
	// are LOST if none is injected. (Account lock/disable/enable transitions, by
	// contrast, are persisted to the user_status_changes audit table regardless.)
	// Inject a Logger in production, and/or supply Metrics below to capture the
	// theft/abuse counters that are not covered by the status-change audit.
	Logger logger.Logger
	// Metrics is an optional counter sink for auth security/operational events
	// (login success/failure, account lockout, OTP failure/lockout, refresh-token
	// reuse, rate-limit rejections/limiter errors, and best-effort write failures).
	// Defaults to a no-op. See the metrics package Counter* constants for the full,
	// authoritative list. Unlike the logger, these survive independently of log
	// level, so they are the recommended basis for alerting.
	Metrics metrics.Metrics

	// VerifyConnectivity, when true, makes New ping both database handles (with a
	// short internal timeout) and fail if either is unreachable, instead of
	// deferring the first connection error to the first query. Off by default so
	// construction stays cheap and offline-testable; turn it on for fail-fast
	// startup, or call AuthKit.Ping from a readiness probe.
	VerifyConnectivity bool

	// RateLimiter is an optional pluggable throttle consulted by AuthService.Login,
	// RefreshToken, OTPService.Generate, and TwoFactorService.CompleteSecondFactor
	// before they do work. It is the
	// recommended place to enforce the IP/global rate limiting the library does not
	// do itself (the per-account lockout is not enough — see SECURITY.md). Defaults
	// to no limiting. See the RateLimiter interface for the key convention.
	RateLimiter RateLimiter

	// RecordSignIns, when true, records every sign-in attempt on an existing
	// account (password logins and VerifiedLoginService.OpenSession, successful or
	// refused) with its method, workspace, session, and the IP address, user agent
	// and device name from SessionInfo. Read it with SignInHistoryService. Off by
	// default. Needs migration 000007. See docs/sign-in-history.md.
	RecordSignIns bool
}

Config is the configuration passed to New to construct an AuthKit. The DBConnection and the JWT/API-key secrets are required; the remaining fields have sensible defaults. See each sub-struct for details.

type DatabaseConnection

type DatabaseConnection struct {
	// Identity schema connections (authkit)
	IdentityRWDB   *sql.DB // For user/session management and audit logging (read-write)
	IdentityRODB   *sql.DB // For auth lookups (SELECT only)
	IdentitySchema string  // Optional PostgreSQL schema name (defaults to authkit)
}

DatabaseConnection holds the PostgreSQL handles and schema for the identity store. The read-write and read-only handles may be the same *sql.DB.

type ExternalIdentityService added in v0.21.0

type ExternalIdentityService interface {
	Resolve(ctx context.Context, req model.ResolveExternalIdentityRequest) (*model.ResolvedExternalIdentity, error)
	Link(ctx context.Context, req model.LinkExternalIdentityRequest) (*model.ExternalIdentity, error)
	Unlink(ctx context.Context, userID, provider, subject string) error
	Get(ctx context.Context, provider, subject string) (*model.ExternalIdentity, error)
	ListByUser(ctx context.Context, userID string) ([]*model.ExternalIdentity, error)
}

ExternalIdentityService links accounts at outside sign-in providers to authkit users and finds the user for a provider sign-in the application has verified. It does not implement OAuth or OIDC. Requires migration 000004.

type InstallationAuthService added in v0.20.2

type InstallationAuthService interface {
	LoginWithInstallation(context.Context, model.InstallationLoginRequest) (*model.InstallationLoginResponse, error)
	LoginByEmailWithInstallation(context.Context, model.InstallationLoginByEmailRequest) (*model.InstallationLoginResponse, error)
	ValidateInstallationJWT(context.Context, string) (*model.InstallationClaims, error)
}

InstallationAuthService is additive: existing AuthService implementations and mocks need not implement these methods. Enrollment is verified by the caller.

type InvitationService

type InvitationService interface {
	Create(ctx context.Context, req model.CreateInvitationRequest) (*model.Invitation, error)
	Get(ctx context.Context, code string) (*model.Invitation, error)
	Validate(ctx context.Context, code string) (bool, error)
	Accept(ctx context.Context, req model.AcceptInvitationRequest) (userID string, created bool, err error)
	List(ctx context.Context, tenantID string) ([]*model.Invitation, error)
	ListActive(ctx context.Context, tenantID string) ([]*model.Invitation, error)
	Revoke(ctx context.Context, code string) error
	Delete(ctx context.Context, code string) error
	IncrementUseCount(ctx context.Context, code string) error
	CleanupExpired(ctx context.Context) (int64, error)
	HasPending(ctx context.Context, email, tenantID string) (bool, error)
}

InvitationService manages invitation lifecycle and redemption.

The by-code operations (Get, Validate, Accept, Revoke, Delete, IncrementUseCount) are keyed only by the invitation code, which is a high-entropy (~103-bit), non-enumerable bearer capability — possession of the code is the authorization to act on it, and the library does NOT additionally check the caller's tenant. Accept derives the new user's tenant and roles from the invitation row (not from caller input), so there is no privilege escalation. If your model requires that an admin may only manage invitations within their own tenant, resolve the code first with Get and check the returned TenantID before calling Revoke/Delete/IncrementUseCount.

type JSONWebKey added in v0.21.0

type JSONWebKey = model.JSONWebKey

JSONWebKey is one public key as a JSON Web Key.

type JSONWebKeySet added in v0.21.0

type JSONWebKeySet = model.JSONWebKeySet

JSONWebKeySet is what AuthKit.JSONWebKeySet returns, to serve at a jwks_uri.

type JWTConfig

type JWTConfig struct {
	// AccessSecret is the HS256 secret. With SigningKey set it only validates
	// tokens signed before the switch; remove it once they have expired.
	AccessSecret string
	// SigningKey, when set, signs access tokens with an asymmetric key and stamps
	// its key id. Optional.
	SigningKey *SigningKey
	// VerificationKeys are further public keys whose tokens validate: the next
	// key, published before it signs, or the previous one, until its tokens have
	// expired. Optional.
	VerificationKeys       []VerificationKey
	RefreshTokenHMACSecret string // Secret for HMAC-SHA256 hashing of refresh tokens in storage
	AccessExpiry           time.Duration
	RefreshExpiry          time.Duration
	// RefreshGracePeriod lets a client retry a refresh whose response it lost.
	// Rotation consumes the presented refresh token, and presenting a consumed token
	// again is treated as theft: the family is revoked and the user signed out
	// everywhere. With a grace period, a consumed token can be exchanged again for
	// up to this long, as long as the token that replaced it has never been used; the
	// unused replacement is revoked. Once the replacement has been used, the old token
	// is reuse as before, so whichever of a thief and the owner refreshes second still
	// trips detection. Optional; zero keeps strict rotation. Needs migration 000006.
	// See docs/refresh-tokens.md.
	RefreshGracePeriod time.Duration
	Issuer             string // Optional: if set, stamped as the "iss" claim and required on validation
	Audience           string // Optional: if set, stamped as the "aud" claim and required on validation
}

JWTConfig configures access-token signing and refresh-token hashing. RefreshTokenHMACSecret is required, and so is AccessSecret unless SigningKey is set; each secret must be at least MinSecretLength bytes of high-entropy random data. Expiries default to DefaultAccessExpiry / DefaultRefreshExpiry when zero.

Access tokens are signed with SigningKey when it is set, and otherwise with AccessSecret (HS256, no key id). Validation accepts a token with a "kid" header only from the key with that id (SigningKey or one of VerificationKeys), and a token without one only as HS256 with AccessSecret. AuthKit.JSONWebKeySet publishes the public keys. See docs/signing-keys.md for moving off HS256 and for rotating keys without logging anyone out.

Note: refresh tokens are opaque random strings, not JWTs — they are not signed, only HMAC-hashed (with RefreshTokenHMACSecret) for storage. There is therefore no separate refresh-token signing secret.

type JWTService

type JWTService interface {
	GenerateAccessToken(user *model.User, membership *model.Membership) (string, error)
	ValidateAccessToken(tokenString string) (*model.JWTClaims, error)
	GenerateRefreshToken() (string, error)
	GetAccessExpiry() time.Duration
	GetRefreshExpiry() time.Duration
}

JWTService generates and validates JWT access tokens and opaque refresh tokens.

GenerateAccessToken copies the supplied user's identity fields (TokenVersion) and the membership's workspace fields (TenantID, Roles, membership TokenVersion) verbatim into the signed claims, so both must be complete, freshly loaded records — a partial/hand-built record can mint a token that ValidateJWT treats as revoked. Prefer the AuthService flows, which load both.

type MembershipService

type MembershipService interface {
	Add(ctx context.Context, req model.AddMembershipRequest) error
	Remove(ctx context.Context, userID, tenantID string) error
	// RemoveGuarded is Remove but refuses (model.ErrLastRoleHolder) to remove the
	// tenant's last active member holding requireRole whose account is enabled (a
	// disabled or deleted user does not count; a locked one does). The
	// check-and-revoke is atomic and serialized per tenant, so a workspace can never
	// be raced below one such holder of the role (e.g. its last Admin). Passing ""
	// disables the guard. UserService.Disable is not guarded.
	RemoveGuarded(ctx context.Context, userID, tenantID, requireRole string) error
	UpdateRoles(ctx context.Context, userID, tenantID string, roles []string) error
	// UpdateRolesGuarded is UpdateRoles with the same last-holder guard as
	// RemoveGuarded: it refuses (model.ErrLastRoleHolder) to demote the tenant's last
	// active holder of requireRole. Passing "" disables the guard.
	UpdateRolesGuarded(ctx context.Context, userID, tenantID string, roles []string, requireRole string) error
	Get(ctx context.Context, userID, tenantID string) (*model.Membership, error)
	ListByUser(ctx context.Context, userID string) ([]*model.Membership, error)
	ListByTenant(ctx context.Context, tenantID string) ([]*model.Membership, error)
	CountByTenant(ctx context.Context, tenantID string) (int, error)
}

MembershipService manages the user<->tenant membership graph: which global identity belongs to which tenant, with which (opaque) roles. authkit owns the graph; the embedding application interprets the role strings. Remove is a soft-revoke (the membership is suspended and its token_version bumped); Add is an idempotent upsert that re-grants/reactivates.

type OTPConfig

type OTPConfig struct {
	RateLimitDuration time.Duration // Minimum time between OTP generation attempts (default: 1 minute)
	HMACSecret        string        // Required for OTPService: HMAC-SHA256 hashing of OTP codes in storage
}

OTPConfig configures OTP generation behavior.

HMACSecret is required when OTPService is used: OTP codes are stored as a keyed HMAC-SHA256 (not in plaintext), so the secret must be at least MinSecretLength bytes of high-entropy random data. It is validated for length at construction when non-empty; OTPService.Generate/Validate return ErrOTPSecretMissing if it is absent. Use a value distinct from the JWT/API-key/encryption secrets.

type OTPService

type OTPService interface {
	Generate(ctx context.Context, req model.OTPGenerateRequest) (*model.GenerateResponse, error)
	Validate(ctx context.Context, req model.OTPValidateRequest) error
	Delete(ctx context.Context, email, purpose string) error
	CleanupExpired(ctx context.Context) (int64, error)
}

OTPService manages one-time verification codes with rate limiting and lockout.

type PasskeyService added in v0.21.0

type PasskeyService interface {
	// User returns the user, their user handle at rpID (created on first use) and
	// their passkeys there, for a registration or a sign-in that names the user.
	User(ctx context.Context, userID, rpID string) (*model.PasskeyUser, error)
	// UserByHandle is User for the user a discoverable sign-in's user handle names.
	UserByHandle(ctx context.Context, rpID string, userHandle []byte) (*model.PasskeyUser, error)
	// Register stores a passkey from a verified registration ceremony.
	Register(ctx context.Context, req model.RegisterPasskeyRequest) (*model.Passkey, error)
	// RecordUse writes back a verified sign-in's counter and flags. It returns
	// ErrPasskeyCloned, and records nothing, when the counter did not go up.
	RecordUse(ctx context.Context, req model.PasskeyUseRequest) error
	// List returns a user's passkeys at rpID, for display.
	List(ctx context.Context, userID, rpID string) ([]*model.Passkey, error)
	Rename(ctx context.Context, userID, rpID string, credentialID []byte, name string) error
	Remove(ctx context.Context, userID, rpID string, credentialID []byte) error
}

PasskeyService stores WebAuthn credentials (passkeys and security keys) and the user handle each user carries at a relying party. It does not run WebAuthn ceremonies: the application verifies them with its own WebAuthn library and stores and reads the results here, then opens the session with VerifiedLoginService.OpenSession. The embedding application must authorize the actor before passing a userID. Requires migration 000008; see docs/passkeys.md.

type PasswordConfig

type PasswordConfig struct {
	BcryptCost int // Cost factor for bcrypt hashing (default: 12, range: 4-31)
}

PasswordConfig configures password hashing.

type PasswordService

type PasswordService interface {
	HashPassword(password string) (string, error)
	VerifyPassword(password, hash string) error
	DummyVerify(password string)
	ValidatePasswordStrength(password string, requirements model.PasswordStrengthRequirements) error
}

PasswordService hashes and verifies passwords and validates password strength.

type RateLimiter

type RateLimiter = ratelimit.RateLimiter

RateLimiter is the optional throttle injected via Config.RateLimiter. It is an alias for ratelimit.RateLimiter so callers can refer to authkit.RateLimiter.

type SecretConfig

type SecretConfig struct {
	EncryptionKey string // Base64-encoded 32-byte AES-256 key for encrypting every tenant's secrets
	// TenantKey, in place of EncryptionKey, gives each tenant a 32-byte AES-256 key of
	// its own, so a key that leaks exposes only its tenant's secrets, and a tenant's
	// secrets are unreadable once its key is destroyed. SecretService calls it for every
	// Save and Get, so keep keys in memory (unwrapping them from a KMS only now and
	// then). Its error comes back from Save and Get, wrapped; a key that is not 32 bytes
	// is ErrSecretEncryptionKeyMissing. Secrets are stored as with EncryptionKey, so
	// returning the old EncryptionKey for each existing tenant keeps what it stored
	// readable. See docs/secrets.md.
	TenantKey func(ctx context.Context, tenantID string) ([]byte, error)
}

SecretConfig configures the optional SecretService, which needs EncryptionKey or TenantKey, not both.

type SecretService

type SecretService interface {
	Save(ctx context.Context, req model.SaveSecretRequest) (*model.SaveSecretResponse, error)
	Get(ctx context.Context, tenantID, key string) (string, error)
	List(ctx context.Context, tenantID string) ([]*model.Secret, error)
	Delete(ctx context.Context, tenantID, key string) error
	MarkVerified(ctx context.Context, tenantID, key string) error
	Count(ctx context.Context, tenantID string) (int, error)
}

SecretService manages tenant-scoped encrypted secrets (provider API keys, etc.).

type SessionConfig added in v0.20.2

type SessionConfig = model.SessionConfig

SessionConfig turns on installation-bound sessions (docs/installation-sessions.md).

type SessionMode added in v0.20.2

type SessionMode = model.SessionMode

SessionMode is how installation sessions are enforced; see SessionConfig.

type SessionService added in v0.20.2

type SessionService interface {
	Get(ctx context.Context, userID, sessionID string) (*model.Session, error)
	List(ctx context.Context, userID string, limit, offset int) ([]model.Session, error)
	Revoke(ctx context.Context, userID, sessionID string) error
	RevokeInstallation(ctx context.Context, userID, installationID string) error
	CleanupExpired(ctx context.Context) (int64, error)
}

SessionService is scoped to Config.SessionConfig.Namespace. The embedding application must authorize the actor before passing a target userID. List/Get return records, including expired/revoked ones, not authentication.

type SignInHistoryService added in v0.21.0

type SignInHistoryService interface {
	// List returns a page of the user's sign-in attempts, newest first. limit is 1
	// to 200; anything else is ErrInvalidInput.
	List(ctx context.Context, userID string, limit, offset int) ([]*model.SignInEvent, error)
	// Cleanup deletes attempts older than olderThan (which must be positive) and
	// returns how many it removed. Run it periodically to bound the history.
	Cleanup(ctx context.Context, olderThan time.Duration) (int64, error)
}

SignInHistoryService reads the sign-in history recorded when Config.RecordSignIns is set: one SignInEvent per attempt on an existing account, successful or refused. It is identity-wide, covering every application that shares the identity database. The embedding application must authorize the actor before passing a target userID. Requires migration 000007; see docs/sign-in-history.md.

type SigningKey added in v0.21.0

type SigningKey = model.SigningKey

SigningKey is the private key JWTConfig.SigningKey signs access tokens with.

type TenantService

type TenantService interface {
	Get(ctx context.Context, tenantID string) (*model.Tenant, error)
	GetByDomain(ctx context.Context, domain string) (*model.Tenant, error)
	Create(ctx context.Context, req model.CreateTenantRequest) (*model.Tenant, error)
	// UpdateSettings replaces the tenant's settings, stored as JSONB. The map is
	// an open, application-defined key/value bag (keys are arbitrary strings;
	// values must be JSON-serializable) — the library does not interpret or
	// validate its contents beyond requiring that it marshals to JSON. It is
	// returned verbatim as Tenant.Settings (a map[string]any) by Get.
	UpdateSettings(ctx context.Context, tenantID string, settings map[string]interface{}) error
	UpdateName(ctx context.Context, tenantID, name string) error
	SetAllowedDomains(ctx context.Context, tenantID string, domains []string) error
	AddAllowedDomain(ctx context.Context, tenantID, domain string) error
	List(ctx context.Context) ([]*model.Tenant, error)
	Exists(ctx context.Context, tenantID string) (bool, error)
	ValidateDomainAccess(ctx context.Context, email, tenantID string) error
}

TenantService manages tenants, their settings, and allowed email domains.

Get, GetByDomain, and List return the tenant's full Settings map (the application-defined JSONB bag) and apply no caller authorization — they are global/admin reads. GetByDomain in particular resolves a tenant from a caller-supplied email domain, so do not wire it (or the Settings it returns) into an unauthenticated lookup; treat Settings as potentially sensitive.

type TokenService

type TokenService interface {
	GenerateTokenPair(ctx context.Context, user *model.User, membership *model.Membership, sessionInfo *model.SessionInfo) (*model.TokenPairResponse, error)
	ValidateRefreshToken(ctx context.Context, tokenString string) (*model.User, error)
	RefreshTokenPair(ctx context.Context, refreshToken string, tenantID string, sessionInfo *model.SessionInfo) (*model.TokenPairResponse, error)
	RevokeRefreshToken(ctx context.Context, tokenString string) error
	// RevokeAllUserTokens is intentionally NOT exposed here: it revokes every refresh
	// token for a user id without any authorization check. Use AuthService.LogoutAll(ctx,
	// userID), which is identity-level — it bumps the identity token_version (invalidating
	// every workspace's access tokens) and then revokes the user's refresh tokens; the
	// caller is responsible for authorizing that it may act on userID.
	CleanupExpired(ctx context.Context) (int64, error)
}

TokenService provides combined JWT + refresh-token management with rotation.

As with JWTService, GenerateTokenPair stamps the supplied *model.User's fields into the access token, so pass a complete, freshly loaded record (see JWTService).

type TwoFactorConfig added in v0.21.0

type TwoFactorConfig struct {
	// EncryptionKey is a base64-encoded 32-byte random key, used for nothing else
	// (not SecretConfig.EncryptionKey). authkit derives from it the AES-256-GCM key
	// that encrypts authenticator secrets and the HMAC key that recovery codes are
	// stored under. Keep it as you keep your other keys: with a different key, users
	// with two-factor on cannot finish a password login until it is turned off for
	// them (TwoFactorService.Disable).
	EncryptionKey string
	// Issuer names your service in people's authenticator apps, such as "Acme".
	// It is required with EncryptionKey and may not contain a colon.
	Issuer string
}

TwoFactorConfig turns on two-factor sign-in with an authenticator app (TOTP) and recovery codes. Two-factor is off while EncryptionKey is empty: then no login asks for a second factor and TwoFactorService returns ErrTwoFactorNotConfigured. Requires migration 000009. See docs/two-factor.md.

type TwoFactorService added in v0.21.0

type TwoFactorService interface {
	// EnrollTOTP makes a new authenticator secret for the user, not yet on, and
	// returns it to show as a QR code.
	EnrollTOTP(ctx context.Context, userID string) (*model.TOTPEnrollment, error)
	// ConfirmTOTP turns two-factor on with a first code from the app and returns
	// the user's recovery codes, which cannot be read back later.
	ConfirmTOTP(ctx context.Context, userID, code string) ([]string, error)
	// Verify checks a code or recovery code for a signed-in user with two-factor
	// on, before a sensitive change. A wrong one counts toward the failed-login
	// lockout.
	Verify(ctx context.Context, userID, code string) error
	// Disable turns two-factor off and deletes the user's secret and codes.
	Disable(ctx context.Context, userID string) error
	// RegenerateRecoveryCodes replaces the user's recovery codes.
	RegenerateRecoveryCodes(ctx context.Context, userID string) ([]string, error)
	// Status reports whether two-factor is on and how many recovery codes are left.
	Status(ctx context.Context, userID string) (*model.TwoFactorStatus, error)
	// CompleteSecondFactor finishes a login that returned ErrSecondFactorRequired.
	CompleteSecondFactor(ctx context.Context, req model.CompleteSecondFactorRequest) (*model.InstallationLoginResponse, error)
	// CleanupExpired deletes logins that expired waiting for a code.
	CleanupExpired(ctx context.Context) (int64, error)
}

TwoFactorService sets up two-factor sign-in with an authenticator app (TOTP) and recovery codes, and finishes the password logins that then wait for a second factor: Login, LoginByEmail and their installation variants return ErrSecondFactorRequired, carrying a challenge, for a user who has it on. Sessions opened with VerifiedLoginService.OpenSession are not affected. The embedding application must authorize the actor before passing a userID. Requires Config.TwoFactorConfig and migration 000009; see docs/two-factor.md.

type UserService

type UserService interface {
	Create(ctx context.Context, req model.CreateUserRequest) (string, error)
	Update(ctx context.Context, req model.UpdateUserRequest) error
	Delete(ctx context.Context, userID string) error
	GetByID(ctx context.Context, userID string) (*model.User, error)
	GetByEmail(ctx context.Context, email string) (*model.User, error)
	Exists(ctx context.Context, email string) (bool, error)
	// List is a global (cross-tenant) admin listing; for a tenant's members use
	// MembershipService.ListByTenant.
	List(ctx context.Context, limit, offset int) ([]*model.User, error)
	ExistsActive(ctx context.Context, email string) (bool, error)
	GetDisplayName(ctx context.Context, userID string) (string, error)
	GetActiveUserIDByEmail(ctx context.Context, email string) (string, error)
	Enable(ctx context.Context, userID string) error
	Disable(ctx context.Context, userID string) error
	Lock(ctx context.Context, userID string, lockedUntil *time.Time) error
	Unlock(ctx context.Context, userID string) error
	SetPassword(ctx context.Context, userID, hashedPassword string) error
	VerifyPassword(ctx context.Context, userID string) (string, error)
	// MarkEmailVerified records that the user proved they own their email address
	// (a code or link sent to it). Needs migration 000005. See
	// docs/verified-sign-in.md.
	MarkEmailVerified(ctx context.Context, userID string) error
	UpdateLastLogin(ctx context.Context, userID string) error
	IncrementFailedLoginCount(ctx context.Context, userID string) error
	IncrementTokenVersion(ctx context.Context, userID string) error
	// StatusHistory returns a user's account status-change audit trail (newest
	// first). CleanupStatusChanges deletes audit rows older than olderThan (which must
	// be positive) and returns the count removed; run it periodically to bound the
	// otherwise unbounded audit table.
	StatusHistory(ctx context.Context, userID string) ([]*model.UserStatusChange, error)
	CleanupStatusChanges(ctx context.Context, olderThan time.Duration) (int64, error)
}

UserService manages GLOBAL identities, their queries, and account status operations. A user carries no tenant or roles (those live on memberships — see MembershipService); every operation is keyed by the global user id or the globally-unique email.

Convention: create/update operations take a keyed request struct with the identity inside (Create, Update, plus AuthService.ChangePassword); simple get/delete/status operations take a positional userID (or email for lookups).

type VerificationKey added in v0.21.0

type VerificationKey = model.VerificationKey

VerificationKey is a public key JWTConfig.VerificationKeys accepts tokens from.

type VerifiedLoginService added in v0.21.0

type VerifiedLoginService interface {
	OpenSession(ctx context.Context, req model.OpenSessionRequest) (*model.OpenSessionResponse, error)
}

VerifiedLoginService opens sessions for users the application has verified itself: through an OAuth or OIDC provider, a signed email link, an OTP, a passkey or an approved device code. authkit checks no credential here, so OpenSession must only ever receive a user id from the application's own verification, never one taken from a request. It is additive, like InstallationAuthService. See docs/verified-sign-in.md.

Directories

Path Synopsis
internal
Package metrics defines a minimal, optional instrumentation seam for authkit-go.
Package metrics defines a minimal, optional instrumentation seam for authkit-go.
Package model holds the data types that cross authkit-go's public API — the domain entities returned to callers (User, Tenant, APIKey, Invitation, Secret, JWTClaims, UserStatusChange) and the request/response structs (including SessionInfo) used to call the services.
Package model holds the data types that cross authkit-go's public API — the domain entities returned to callers (User, Tenant, APIKey, Invitation, Secret, JWTClaims, UserStatusChange) and the request/response structs (including SessionInfo) used to call the services.
Package ratelimit defines the optional throttle seam for authkit-go.
Package ratelimit defines the optional throttle seam for authkit-go.

Jump to

Keyboard shortcuts

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