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

authkit

package module
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Jul 1, 2026 License: Apache-2.0 Imports: 13 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.

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
  • 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
  • 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

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

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 a password. 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 token from HTTP header (e.g., Authorization: Bearer <token>)
tokenString := req.Header.Get("Authorization")

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

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

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

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 Yes Access-token signing secret. ≥ 32 bytes of CSPRNG output.
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).

All five *_SECRET/*_HMAC_SECRET values and the encryption key 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)
  • 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.AccessExpiry: Access token expiration (default: 15 minutes)
  • JWTConfig.RefreshExpiry: Refresh token expiration (default: 7 days)
  • 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
  • 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_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/RefreshToken/OTPService.Generate (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".
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.
  • 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

Migration scripts are included in migrations/. Migration 000001 is the single consolidated schema (edited in place across 0.x); a fresh migrate up gives the current schema:

migrate -path migrations -database "$DATABASE_URL" up
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. If you use a custom schema, copy the migration and replace authkit with your schema name before applying it.

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)
├── internal/
│   ├── repository/           # Database operations (CRUD) — not importable by consumers
│   └── service/              # Business logic layer — not importable by consumers
├── config.go                 # Configuration structures (public)
└── main.go                   # Library entry point: New + AuthKit accessors (public)

Only the root authkit package, model, and logger 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()
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, ErrInvalidToken, ErrTokenRevoked, ErrInvalidRefreshToken, ErrRefreshTokenReuse, ErrInvalidOTP, ErrOTPLockedOut, ErrOTPRateLimited, ErrOTPSecretMissing, ErrInvalidAPIKey, ErrIncorrectPassword, ErrInvitationInvalid, ErrUserExists, ErrPasswordTooWeak, ErrSecretEncryptionKeyMissing, ErrRateLimited, ErrInvalidInput, ErrValueTooLong, ErrNotFound, and ErrInternal. 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. 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.

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
context, 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, and OTPService.Generate 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>",
// "otp:<email>:<purpose>" (no tenant) — 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/RefreshToken/OTP generation)
  • Metrics hooks (Config.Metrics) and health checks (AuthKit.Ping / Config.VerifyConnectivity)
  • General audit logging beyond status changes (planned)
  • Key Rotation Support (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 (
	DefaultAccessExpiry       = time.Minute * 15
	DefaultRefreshExpiry      = time.Hour * 168
	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
)

Variables

View Source
var (
	ErrNotFound                   = model.ErrNotFound
	ErrInvalidCredentials         = model.ErrInvalidCredentials
	ErrAccountDisabled            = model.ErrAccountDisabled
	ErrAccountLocked              = model.ErrAccountLocked
	ErrInvalidToken               = model.ErrInvalidToken
	ErrTokenRevoked               = model.ErrTokenRevoked
	ErrNoActiveMembership         = model.ErrNoActiveMembership
	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
	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

This section is empty.

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, 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) InvitationService

func (k *AuthKit) InvitationService() InvitationService

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

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) 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 must be configured for this service to work. The encryption key must be a base64-encoded 32-byte (256-bit) key. Operations return ErrSecretEncryptionKeyMissing if it is absent or the wrong length.

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

func (k *AuthKit) UserService() UserService

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

type AuthService

type AuthService interface {
	// Login authenticates into a known workspace (req.TenantID) and returns a
	// workspace-scoped access token + identity-session refresh token.
	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(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 {
	DBConnection   DatabaseConnection
	JWTConfig      JWTConfig
	APIKeyConfig   APIKeyConfig
	PasswordConfig PasswordConfig
	OTPConfig      OTPConfig
	SecretConfig   SecretConfig // Optional: Configuration for SecretService
	// 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, and OTPService.Generate 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
}

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 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 JWTConfig

type JWTConfig struct {
	AccessSecret           string
	RefreshTokenHMACSecret string // Secret for HMAC-SHA256 hashing of refresh tokens in storage
	AccessExpiry           time.Duration
	RefreshExpiry          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. AccessSecret and RefreshTokenHMACSecret are required and must each be at least MinSecretLength bytes of high-entropy random data. Expiries default to DefaultAccessExpiry / DefaultRefreshExpiry when zero.

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
	UpdateRoles(ctx context.Context, userID, tenantID string, roles []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 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 secrets
}

SecretConfig configures the optional SecretService. EncryptionKey is only required if SecretService is used.

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

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