authkit

package module
v1.0.3 Latest Latest
Warning

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

Go to latest
Published: Jul 1, 2026 License: MIT Imports: 35 Imported by: 0

README

CI CodeQL Coverage Status Open Issues Go Report Card GitHub release (latest by date)

authkit

Plug-and-play authentication with YAML-based RBAC for Small Go HTTP services.

  • OAuth 2.0 via markbates/goth — supports GitHub, Google, GitLab, Bitbucket out of the box, plus 80+ more providers via GothProviders
  • Email/password authentication with bcrypt hashing
  • API key authentication — plug in any key store via a single-method interface
  • Two-factor auth (TOTP) — per-role 2FA enforcement with authenticator apps + single-use recovery codes
  • Mobile token layer — OAuth2 Authorization Code + PKCE, Ed25519-signed access JWTs, rotating refresh tokens with reuse detection, and a JWKS endpoint
  • Three modes: OAuth only, password only, or both simultaneously
  • Multi-tenant aware — every principal carries a TenantID (and optional BranchID) for RLS-scoped data access and per-tenant role resolution
  • Server-side sessions (optional) — revocable, opaque-ID sessions with idle + absolute timeouts and "log out everywhere"; falls back to encrypted cookie sessions
  • CSRF protection — signed double-submit middleware for cookie-authenticated requests
  • Login throttling — pluggable per-account+IP rate limiting against brute force / credential stuffing
  • Platform super-admin axis — separate platform principals with capability checks and audited, break-glass tenant impersonation
  • Device principals — confined print-agent tokens with a fixed capability set, isolated from the human/API-key path
  • Audit sink — emit structured security events (login, logout, refresh, revoke, 2FA, role/permission change, impersonate)
  • Encrypted cookie sessions via gorilla/sessions
  • Role-Based Access Control via YAML file or a pluggable PolicyProvider interface
  • Layered RBAC — seed roles from YAML, then let a UI override individual users via a database
  • Live permission resolution (optional) — re-resolve a session's permissions per request through a short TTL cache so role changes take effect without re-login
  • Live policy reload without restarts (WatchRBAC)
  • Works with Go 1.22+ stdlib net/http (no external router required)
  • Storage-agnostic — implement small interfaces for your database
  • Pluggable logger — bring your own (slog, zap, zerolog) or use the default

Installation

go get github.com/tlmanz/authkit

Quick Start

1. Define your policy (policy.yaml)
roles:
  admin:
    permissions: ["*"]         # wildcard grants every permission
    members:
      - alice@company.com

  developer:
    permissions: ["view", "upload"]
    members:
      - bob@company.com
      - carol@company.com

  viewer:
    permissions: ["view"]

# Fallback role for authenticated users not listed in any role.
# Omit to deny access to unlisted users entirely.
default_role: viewer
2. Choose your auth mode
OAuth only (default)
auth, err := authkit.New(authkit.Config{
    Providers: []authkit.ProviderConfig{
        {
            Name:         "github",
            ClientID:     os.Getenv("GITHUB_CLIENT_ID"),
            ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
        },
    },
    CallbackBaseURL: "https://example.com",
    SessionSecret:   os.Getenv("SESSION_SECRET"),
    SecureCookie:    true,
    AfterLoginURL:   "/dashboard",
    RBAC:            authkit.RBACConfig{FilePath: "policy.yaml"},
})
Password only
auth, err := authkit.New(authkit.Config{
    Mode:          authkit.AuthModePassword,
    SessionSecret: os.Getenv("SESSION_SECRET"),
    SecureCookie:  true,
    AfterLoginURL: "/dashboard",
    UserStore:     myUserStore, // implements authkit.UserStore
    RBAC:          authkit.RBACConfig{FilePath: "policy.yaml"},
})
Both (OAuth + password)
auth, err := authkit.New(authkit.Config{
    Mode: authkit.AuthModeBoth,
    Providers: []authkit.ProviderConfig{
        {Name: "github", ClientID: "...", ClientSecret: "..."},
    },
    CallbackBaseURL: "https://example.com",
    SessionSecret:   os.Getenv("SESSION_SECRET"),
    SecureCookie:    true,
    AfterLoginURL:   "/dashboard",
    UserStore:       myUserStore,
    RBAC:            authkit.RBACConfig{FilePath: "policy.yaml"},
})
3. Wire up routes
mux := http.NewServeMux()

// OAuth routes (when OAuth is enabled)
mux.HandleFunc("GET /auth/{provider}",          auth.BeginAuth)
mux.HandleFunc("GET /auth/{provider}/callback", auth.Callback)

// Password routes (when password auth is enabled)
mux.HandleFunc("POST /auth/register", auth.Register)
mux.HandleFunc("POST /auth/login",    auth.Login)

// Common routes (work with all modes)
mux.HandleFunc("POST /auth/logout", auth.Logout)
mux.HandleFunc("GET /auth/me",      auth.Me)

// Protected routes
mux.Handle("GET /api/reports",   auth.RequireAuth(http.HandlerFunc(reportsHandler)))
mux.Handle("POST /api/projects", auth.Require("projects:write")(http.HandlerFunc(createHandler)))

Auth Modes

Mode Constant Providers required UserStore required Use case
OAuth only authkit.AuthModeOAuth Yes No SSO with GitHub/Google/GitLab/Bitbucket/etc.
Password only authkit.AuthModePassword No Yes Traditional email/password
Both authkit.AuthModeBoth Yes Yes Let users choose their method

When Mode is not set, it defaults to AuthModeOAuth for backward compatibility.


Password Authentication

UserStore interface

Implement this 2-method interface with your database of choice:

type UserStore interface {
    CreateUser(ctx context.Context, email, name, hashedPassword string) error
    GetUserByEmail(ctx context.Context, email string) (*authkit.PasswordUser, error)
}
  • CreateUser must return authkit.ErrUserExists if the email is already taken.
  • GetUserByEmail must return authkit.ErrUserNotFound if no user matches.
  • Passwords are pre-hashed with bcrypt before being passed to CreateUser.
  • The returned *PasswordUser should populate TenantID (and optional BranchID) — authkit copies these onto the authenticated User and uses them for tenant-scoped permission resolution and RLS. See Multi-Tenancy.
Password policy
authkit.Config{
    PasswordPolicy: &authkit.PasswordPolicy{
        MinLength: 12, // default: 8
    },
}
Hashing utility

HashPassword is exported for use in admin tooling or seed scripts:

hashed, err := authkit.HashPassword("user-password")
Security features
  • bcrypt cost 12 (~250ms per hash on modern hardware)
  • Constant-time responses — failed logins for unknown users take the same time as wrong-password failures, preventing user enumeration
  • Generic error messages — both wrong-user and wrong-password return "invalid email or password"
  • Rate limiting — not built in (to stay storage-agnostic). Apply your own middleware:
    mux.Handle("POST /auth/login", rateLimiter(http.HandlerFunc(auth.Login)))
    

Custom Logger

By default, authkit logs to Go's standard log package. You can plug in your own logger by implementing the Logger interface:

type Logger interface {
    Info(msg string, args ...any)
    Error(msg string, args ...any)
}
Using the default logger
// No Logger field needed — uses standard log package automatically.
auth, err := authkit.New(authkit.Config{...})
Using slog
type slogAdapter struct {
    l *slog.Logger
}

func (s slogAdapter) Info(msg string, args ...any)  { s.l.Info(fmt.Sprintf(msg, args...)) }
func (s slogAdapter) Error(msg string, args ...any) { s.l.Error(fmt.Sprintf(msg, args...)) }

auth, err := authkit.New(authkit.Config{
    Logger: slogAdapter{l: slog.Default()},
    // ...
})
Using zap
type zapAdapter struct {
    l *zap.SugaredLogger
}

func (z zapAdapter) Info(msg string, args ...any)  { z.l.Infof(msg, args...) }
func (z zapAdapter) Error(msg string, args ...any) { z.l.Errorf(msg, args...) }

auth, err := authkit.New(authkit.Config{
    Logger: zapAdapter{l: zapLogger.Sugar()},
    // ...
})

OAuth Providers

Bitbucket
authkit.ProviderConfig{
    Name:         "bitbucket",
    ClientID:     os.Getenv("BITBUCKET_CLIENT_ID"),
    ClientSecret: os.Getenv("BITBUCKET_CLIENT_SECRET"),
    // Default scopes: ["account", "email"]
}

Where to register: Bitbucket → Settings → Workspace Settings → OAuth consumers → Add consumer

Field Value
Callback URL https://example.com/auth/bitbucket/callback
Callback URL (local dev) http://localhost:8080/auth/bitbucket/callback
Permissions Account: Read, Email addresses: Read
GitHub
authkit.ProviderConfig{
    Name:         "github",
    ClientID:     os.Getenv("GITHUB_CLIENT_ID"),
    ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"),
    // Default scope: ["user:email"]
}

Where to register: github.com/settings/developers → New OAuth App

Field Value
Authorization callback URL https://example.com/auth/github/callback
Authorization callback URL (local dev) http://localhost:8080/auth/github/callback
Google
authkit.ProviderConfig{
    Name:         "google",
    ClientID:     os.Getenv("GOOGLE_CLIENT_ID"),
    ClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"),
    // Default scopes: ["email", "profile"]
}

Where to register: Google Cloud Console → APIs & Services → Credentials → Create OAuth Client ID (type: Web application)

Field Value
Authorized redirect URIs https://example.com/auth/google/callback
Authorized redirect URIs (local dev) http://localhost:8080/auth/google/callback

Google allows multiple redirect URIs per client — add both production and localhost entries.

GitLab
authkit.ProviderConfig{
    Name:         "gitlab",
    ClientID:     os.Getenv("GITLAB_CLIENT_ID"),
    ClientSecret: os.Getenv("GITLAB_CLIENT_SECRET"),
    // Default scope: ["read_user"]
}

Where to register: GitLab → User Settings → Applications (or group/admin Applications for shared apps)

Field Value
Redirect URI https://example.com/auth/gitlab/callback
Redirect URI (local dev) http://localhost:8080/auth/gitlab/callback
Scopes to enable read_user
Multiple providers at once
Providers: []authkit.ProviderConfig{
    {Name: "bitbucket", ClientID: "...", ClientSecret: "..."},
    {Name: "github",    ClientID: "...", ClientSecret: "..."},
    {Name: "google",    ClientID: "...", ClientSecret: "..."},
    {Name: "gitlab",    ClientID: "...", ClientSecret: "..."},
},

Users can then choose their provider via the login URL:

  • GET /auth/bitbucket
  • GET /auth/github
  • GET /auth/google
  • GET /auth/gitlab
Other providers

authkit ships convenience wrappers for Bitbucket, GitHub, Google, and GitLab. For any of the 80+ other providers that goth supports (Spotify, Discord, Slack, Microsoft, Twitter, etc.), import the provider package directly and pass the pre-built value via GothProviders:

import (
    "github.com/markbates/goth/providers/discord"
    "github.com/markbates/goth/providers/spotify"
)

auth, err := authkit.New(authkit.Config{
    // Built-in wrappers still work alongside GothProviders.
    Providers: []authkit.ProviderConfig{
        {Name: "github", ClientID: "...", ClientSecret: "..."},
    },
    GothProviders: []goth.Provider{
        spotify.New(os.Getenv("SPOTIFY_ID"), os.Getenv("SPOTIFY_SECRET"),
            "https://example.com/auth/spotify/callback", "user-read-email"),
        discord.New(os.Getenv("DISCORD_ID"), os.Getenv("DISCORD_SECRET"),
            "https://example.com/auth/discord/callback", "identify", "email"),
    },
    CallbackBaseURL: "https://example.com",
    // ...
})

The callback URL pattern is always /auth/{providerName}/callback, where providerName is whatever the goth provider reports via its Name() method (e.g. "spotify", "discord"). Only the packages you import are compiled into your binary.


HTTP Routes

Method Path Handler Mode Description
GET /auth/{provider} auth.BeginAuth OAuth / Both Starts the OAuth flow
GET /auth/{provider}/callback auth.Callback OAuth / Both Completes the OAuth handshake
POST /auth/register auth.Register Password / Both Creates account with email+password
POST /auth/login auth.Login Password / Both Authenticates with email+password
POST /auth/logout auth.Logout All Clears the session
GET /auth/me auth.Me All Returns the current user as JSON
Optional routes (mount when the corresponding feature is enabled)
Method Path Handler Feature Description
POST /auth/2fa/enroll auth.Enroll2FA TOTP Provisions a TOTP secret + recovery codes
POST /auth/2fa/verify auth.Verify2FA TOTP Completes a pending 2FA login
GET /auth/csrf auth.CSRFToken CSRF Returns/issues a CSRF token as JSON
GET /authorize auth.Authorize Token layer PKCE authorization endpoint
POST /token auth.IssueToken Token layer Exchanges an auth code (+ PKCE verifier) for tokens
POST /token/refresh auth.RefreshAccessToken Token layer Rotates a refresh token for a new pair
GET /.well-known/jwks.json auth.JWKS Token layer Publishes the Ed25519 verification keys
POST /platform/login auth.PlatformLogin Platform admin Platform super-admin login (password + TOTP)
POST /platform/logout auth.PlatformLogout Platform admin Ends the platform session

/auth/me example response:

{
  "email": "alice@company.com",
  "name": "Alice",
  "avatarUrl": "https://avatars.githubusercontent.com/u/1234",
  "provider": "github",
  "role": "admin"
}

For password-auth users, provider will be "password" and avatarUrl will be empty.


Middleware

RequireAuth — enforce authentication (session or API key)
mux.Handle("GET /api/reports", auth.RequireAuth(http.HandlerFunc(reportsHandler)))

Returns 401 Unauthenticated if there is no valid credential. On success the current *authkit.User is available via authkit.UserFromCtx(r.Context()).

Accepts API keys when APIKeyValidator is configured. Works identically for OAuth, password-authenticated, and API key users.

Require(permission) — enforce authentication + permission (session or API key)
mux.Handle("POST /api/upload", auth.Require("upload")(http.HandlerFunc(handler)))

Returns 401 for missing credential, 403 Forbidden when the user lacks the permission. API keys are accepted when APIKeyValidator is configured.

RequireSessionAuth — enforce a valid session (rejects API keys)
mux.Handle("GET /auth/me", auth.RequireSessionAuth(http.HandlerFunc(meHandler)))

Same as RequireAuth but API key credentials are rejected even if APIKeyValidator is set. Use this for UI-only routes that should never be accessible from automated clients.

RequireSession(permission) — enforce session + permission (rejects API keys)
mux.Handle("DELETE /api/environments/{id}", auth.RequireSession("manage")(http.HandlerFunc(handler)))

Same as Require but API keys are always rejected. Use this for management routes that must only be operated by a logged-in human.

Reading the current user inside a handler
func reportsHandler(w http.ResponseWriter, r *http.Request) {
    u := authkit.UserFromCtx(r.Context())
    // u is always non-nil here because RequireAuth ran first.
    // Works for OAuth, password, and API key users.
    fmt.Fprintf(w, "Hello, %s (%s)", u.Name, u.Role)
}

API Key Authentication

For programmatic access (CI/CD pipelines, scripts, service-to-service calls) authkit can validate API keys alongside OAuth/password sessions. Implement the APIKeyValidator interface and pass it to Config:

type APIKeyValidator interface {
    ValidateKey(ctx context.Context, rawKey string) (*User, error)
}

Return nil, nil when the key is not found, inactive, or expired. Return nil, err only for unexpected infrastructure failures (e.g. a database connection error).

Wiring it up
auth, err := authkit.New(authkit.Config{
    // ... other fields ...
    APIKeyValidator: myKeyStore, // implements authkit.APIKeyValidator
})
How it works

When APIKeyValidator is set, the middleware checks the Authorization: Bearer <key> header (or X-API-Key: <key> as a fallback) before the session cookie on every request. On a valid key:

  1. ValidateKey returns a *User with Email, Name, Provider, and Role populated.
  2. Authkit resolves permissions from the RBAC policy based on the returned Role.
  3. The user is injected into the request context under the same key as session users — UserFromCtx works transparently for both.
Middleware variants
Middleware API keys Sessions Use for
auth.RequireAuth(next) General protected routes
auth.Require(perm)(next) Permission-gated routes open to CI/CD
auth.RequireSessionAuth(next) UI-only routes (e.g. /auth/me)
auth.RequireSession(perm)(next) Management routes that must not accept keys
mux.Handle("GET /api/reports",   auth.Require("view")(reportsHandler))   // API keys OK
mux.Handle("POST /api/projects", auth.RequireSession("manage")(createH)) // session only
mux.Handle("GET /auth/me",       auth.RequireSessionAuth(meHandler))     // session only
Example implementation

A minimal DB-backed key store:

type KeyStore struct{ db *sql.DB }

func (s *KeyStore) ValidateKey(ctx context.Context, rawKey string) (*authkit.User, error) {
    hash := sha256Hex(rawKey)
    var name, role string
    err := s.db.QueryRowContext(ctx,
        "SELECT name, role FROM api_keys WHERE key_hash = ? AND is_active = 1", hash,
    ).Scan(&name, &role)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, nil
    }
    if err != nil {
        return nil, err
    }
    return &authkit.User{
        Email:    "apikey:" + name,
        Name:     name,
        Provider: "apikey",
        Role:     role,
    }, nil
}

The returned User.Role must match a role defined in policy.yaml for permissions to be resolved. If the role is not found in the policy the user is authenticated but has no permissions.


Permissions

Permissions are fully user-defined strings — authkit does not prescribe any specific set. You define them in policy.yaml and enforce them in code.

The only built-in constant is authkit.PermAll = "*", which is a wildcard that passes every permission check.

Define permissions in policy.yaml
roles:
  admin:
    permissions: ["*"]          # wildcard — passes every check

  editor:
    permissions: ["posts:write", "posts:publish", "media:upload"]

  reader:
    permissions: ["posts:read"]
Enforce on a route
mux.Handle("POST /posts",         auth.Require("posts:write")(http.HandlerFunc(createPost)))
mux.Handle("POST /posts/publish", auth.Require("posts:publish")(http.HandlerFunc(publishPost)))
mux.Handle("POST /media",         auth.Require("media:upload")(http.HandlerFunc(uploadMedia)))
Check inline in a handler
func handler(w http.ResponseWriter, r *http.Request) {
    u := authkit.UserFromCtx(r.Context())
    if !u.Can("posts:publish") {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }
    // ...
}
Permission string format

Any alphanumeric string with . : - _ is valid. Common conventions:

Style Example
Simple "read", "write", "delete"
Namespaced "posts:read", "posts:write"
Dot-separated "reports.view", "reports.export"
Action-resource "create-project", "delete-user"

There is no hierarchy — "posts:read" does not automatically grant "posts". Each string is matched exactly.


Live Policy Reload

WatchRBAC reloads the policy on every tick. If the reload fails the old policy is kept — users are never accidentally locked out. Works with any PolicyProvider that implements PolicyReloader (both the built-in YAML provider and LayeredPolicyProvider support this).

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

// Reload every 60 seconds.
go auth.WatchRBAC(ctx, 60*time.Second)

For the YAML-only provider: edit policy.yaml and wait for the next tick. No restart needed.

For LayeredPolicyProvider: the YAML baseline is reloaded on each tick. Database overrides are always read live on each login request.

If you supply a custom PolicyProvider that manages its own cache, simply do not implement PolicyReloader and WatchRBAC will exit immediately — your provider controls its own refresh strategy.


Database-backed RBAC

By default, roles are read from a YAML file. For applications that need a management UI where operators can change user roles at runtime without touching files, authkit provides two additional mechanisms.

Option 1: Layered provider (YAML baseline + database overrides)

Roles and their initial members are defined in policy.yaml as usual. Any user whose role is changed through your UI writes to a UserRoleStore — authkit checks the store first, falling back to YAML for everyone else.

1. Implement UserRoleStore

type UserRoleStore interface {
    GetOverride(ctx context.Context, email string) (role string, permissions []string, found bool, err error)
    SetOverride(ctx context.Context, email, role string, permissions []string) error
    DeleteOverride(ctx context.Context, email string) error
}

A minimal Postgres implementation:

type RoleStore struct{ db *sql.DB }

func (s *RoleStore) GetOverride(ctx context.Context, email string) (string, []string, bool, error) {
    var role, permsJSON string
    err := s.db.QueryRowContext(ctx,
        "SELECT role, permissions FROM role_overrides WHERE email = $1", email,
    ).Scan(&role, &permsJSON)
    if errors.Is(err, sql.ErrNoRows) {
        return "", nil, false, nil
    }
    if err != nil {
        return "", nil, false, err
    }
    var perms []string
    json.Unmarshal([]byte(permsJSON), &perms)
    return role, perms, true, nil
}

func (s *RoleStore) SetOverride(ctx context.Context, email, role string, permissions []string) error {
    permsJSON, _ := json.Marshal(permissions)
    _, err := s.db.ExecContext(ctx,
        `INSERT INTO role_overrides (email, role, permissions)
         VALUES ($1, $2, $3)
         ON CONFLICT (email) DO UPDATE SET role = $2, permissions = $3`,
        email, role, string(permsJSON),
    )
    return err
}

func (s *RoleStore) DeleteOverride(ctx context.Context, email string) error {
    _, err := s.db.ExecContext(ctx, "DELETE FROM role_overrides WHERE email = $1", email)
    return err
}

Required table:

CREATE TABLE role_overrides (
    email       TEXT PRIMARY KEY,
    role        TEXT NOT NULL,
    permissions JSONB NOT NULL DEFAULT '[]'
);

2. Wire it up

roleStore := &RoleStore{db: db}

provider, err := authkit.NewLayeredProvider("policy.yaml", roleStore,
    authkit.WithLogger(myLogger), // optional: logs DB errors during role lookups
)
if err != nil {
    log.Fatal(err)
}

auth, err := authkit.New(authkit.Config{
    RBAC: authkit.RBACConfig{Provider: provider},
    // ... rest of config
})

go auth.WatchRBAC(ctx, 30*time.Second) // reloads the YAML baseline

3. Change a user's role from a management handler

Use provider.SetOverride — it validates the role name against the YAML policy and checks permission string format before writing to the store.

func setRoleHandler(w http.ResponseWriter, r *http.Request) {
    email := r.FormValue("email")
    role  := r.FormValue("role")
    perms := rolesMap[role] // your app's role→permissions lookup

    if err := provider.SetOverride(r.Context(), email, role, perms); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }
    // Change takes effect on the user's next login.
}

Session note: role changes take effect on the next login only. Existing sessions keep their current permissions until they expire (7 days by default) or the user logs out. If you need immediate enforcement for demotions, implement session invalidation at the application level.

4. Reset a user to the YAML baseline

provider.DeleteOverride(ctx, "bob@example.com")
Option 2: Fully custom PolicyProvider

If neither YAML nor the layered approach fits your needs, implement the PolicyProvider interface directly:

type PolicyProvider interface {
    RoleFor(ctx context.Context, email string) (role string, permissions []string)
    PermissionsForRole(ctx context.Context, role string) []string
}

Tenant-aware resolution: both methods receive a context.Context carrying the tenant (read it with authkit.TenantIDFromCtx(ctx)), so a DB-backed provider can scope role definitions per tenant. PermissionsForRole taking a ctx is required for API-key and live per-request resolution.

Pass your implementation via RBACConfig.Provider:

auth, err := authkit.New(authkit.Config{
    RBAC: authkit.RBACConfig{Provider: myCustomProvider},
})

Optionally implement PolicyReloader to participate in WatchRBAC:

type PolicyReloader interface {
    Reload() error
}

Multi-Tenancy

Every authenticated principal carries a TenantID (and optional BranchID), making authkit suitable for multi-tenant SaaS where data is isolated per tenant (e.g. Postgres Row-Level Security).

type User struct {
    Email     string
    Name      string
    AvatarURL string
    Provider  string
    Role      string
    TenantID  string // the hard security boundary
    BranchID  string // optional in-tenant scope (a row filter, not a permission)
    // ...
}
  • Where it comes from: your UserStore (PasswordUser.TenantID/BranchID), the OAuth callback mapping, or the APIKeyValidator populate it. Email is global, so the lookup determines the tenant.

  • How authkit uses it: before resolving permissions, authkit puts the tenant on the request context via WithTenant. Read it in your handlers and DB layer:

    func handler(w http.ResponseWriter, r *http.Request) {
        tenantID, ok := authkit.TenantIDFromCtx(r.Context())
        if !ok {
            http.Error(w, "no tenant", http.StatusForbidden) // fail closed
            return
        }
        // e.g. set the per-transaction RLS GUC to tenantID before querying.
    }
    
  • Fail-closed: TenantIDFromCtx returns ok == false for an empty/unset tenant — treat that as a hard deny.

  • A tenant-aware PolicyProvider reads the same context to resolve roles per tenant (see Database-backed RBAC).


Server-Side Sessions

By default authkit stores the session in an encrypted cookie (stateless). For instant revocation and "log out everywhere", provide a SessionStore — the cookie then carries only an opaque session ID and all identity state lives in your store (DB or Redis).

type SessionStore interface {
    Create(ctx context.Context, s *authkit.Session) error
    Get(ctx context.Context, id string) (*authkit.Session, error)
    Touch(ctx context.Context, id string, lastSeen time.Time) error
    Revoke(ctx context.Context, id string) error
    RevokeAllForUser(ctx context.Context, tenantID, email string) error
}
auth, err := authkit.New(authkit.Config{
    SessionStore:    myStore,           // implements authkit.SessionStore
    IdleTimeout:     30 * time.Minute,  // sliding; default 30m
    AbsoluteTimeout: 24 * time.Hour,    // hard cap; default 24h
    // ...
})
  • Session fixation prevention: a fresh session ID is minted on every login; any prior session is revoked.

  • Sliding idle renewal: LastSeenAt is advanced at most once per minute (throttled writes).

  • __Host- cookie prefix is used when SecureCookie is true.

  • Log out everywhere (e.g. on password reset or firing an employee):

    err := auth.RevokeUserSessions(ctx, tenantID, "bob@example.com")
    

    ctx must carry the user's tenant. No-op when no SessionStore is configured.

The token layer, platform admin, and 2FA features require a SessionStore for their session/revocation semantics.


Two-Factor Authentication (TOTP)

Enforce a second factor for sensitive roles. After the password step, users whose role is in Require2FAForRoles must complete a TOTP challenge (authenticator app) before a session is minted.

type TOTPStore interface {
    Enroll(ctx context.Context, tenantID, email, secret string, recoveryCodeHashes []string) error
    Secret(ctx context.Context, tenantID, email string) (secret string, enrolled bool, err error)
    ConsumeRecovery(ctx context.Context, tenantID, email, codeHash string) (bool, error)
}
auth, err := authkit.New(authkit.Config{
    TOTPStore:          myTOTPStore,                  // implements authkit.TOTPStore
    Require2FAForRoles: []string{"owner", "manager"}, // which roles must complete 2FA
    AppName:            "Acme",                       // issuer shown in authenticator apps
    Throttler:          myThrottler,                  // recommended — throttles 2FA attempts too
    // ...
})

mux.HandleFunc("POST /auth/2fa/enroll", auth.Enroll2FA)
mux.HandleFunc("POST /auth/2fa/verify", auth.Verify2FA)

Flow:

  1. POST /auth/login with a 2FA-required role responds {"status":"2fa_required","action":"enroll"|"verify"} and sets a short-lived (5 min) pending cookie instead of a session.
  2. If action == "enroll", call POST /auth/2fa/enroll to get an otpauthUrl (render as a QR code), the raw secret, and one-time recoveryCodes to display once.
  3. POST /auth/2fa/verify with form value code (6-digit TOTP) or recovery_code completes the login and establishes the session.
  • The host store is responsible for encrypting the secret at rest — authkit passes plaintext at the interface boundary.
  • Recovery codes are single-use; authkit stores only SHA-256 hashes and ConsumeRecovery must mark them used atomically.
  • Enroll2FA also works for voluntary enrollment from an already-authenticated session.

Mobile Token Layer (OAuth2 + PKCE)

For native/mobile clients, authkit can issue Ed25519-signed access JWTs and rotating opaque refresh tokens via the OAuth2 Authorization Code flow with PKCE.

signing, _ := authkit.NewSigningKey("key-2026-01", seed) // seed is 32 random bytes

auth, err := authkit.New(authkit.Config{
    EnableTokens:      true,
    SigningKeys:       []authkit.SigningKey{signing}, // first key signs; all verify (key rotation)
    RefreshTokenStore: myRefreshStore,                // implements authkit.RefreshTokenStore
    AccessTokenTTL:    15 * time.Minute,              // default 15m
    RefreshTokenTTL:   30 * 24 * time.Hour,           // default 30d
    TokenIssuer:       "https://api.example.com",     // JWT `iss`
    TokenClientID:     "mobile-app",                  // public client id + JWT audience
    TokenRedirectURIs: []string{"acme://callback"},   // allowed PKCE redirect URIs
    // ...
})

mux.HandleFunc("GET  /authorize",              auth.Authorize)
mux.HandleFunc("POST /token",                  auth.IssueToken)
mux.HandleFunc("POST /token/refresh",          auth.RefreshAccessToken)
mux.HandleFunc("GET  /.well-known/jwks.json",  auth.JWKS)
type RefreshTokenStore interface {
    Create(ctx context.Context, t *authkit.RefreshToken) error
    Get(ctx context.Context, rawToken string) (*authkit.RefreshToken, error)
    Rotate(ctx context.Context, rawOld string, next *authkit.RefreshToken) error
    RevokeChain(ctx context.Context, chainID string) error
}

Flow:

  1. The app opens GET /authorize?response_type=code&client_id=...&redirect_uri=...&code_challenge=...&code_challenge_method=S256 in the system browser. The user must already have a web session (the browser carries the cookie); otherwise they are bounced to login.
  2. authkit redirects back to redirect_uri?code=.... The app calls POST /token with grant_type=authorization_code, code, code_verifier, and redirect_uri to receive {access_token, refresh_token, token_type, expires_in}.
  3. The app calls protected APIs with Authorization: Bearer <access_token>. Require/RequireAuth verify the JWT against the JWKS.
  4. POST /token/refresh with refresh_token rotates the pair.

Security properties:

  • Permissions are never in the JWT — they're resolved server-side per request, so role/permission changes take effect within one access-token TTL.
  • Refresh-token rotation with reuse detection: presenting an already-used or revoked refresh token revokes the entire chain (theft response) and emits an audit revoke event.
  • Refresh tokens are opaque; the store must hash them at rest.
  • Key rotation: the first SigningKey signs; all keys verify and are published via JWKS, so a key can be retired without invalidating tokens it already signed.

Platform Super-Admin

A platform principal is a SaaS operator acting across tenants — a separate axis from tenant RBAC, with no TenantID. Platform login requires password and mandatory TOTP, and uses its own cookie (never crosses with tenant sessions).

type PlatformAdminStore interface {
    GetPlatformAdmin(ctx context.Context, email string) (*authkit.PlatformAdminRecord, error)
}

type PlatformPolicy interface {
    PermissionsForPlatformRole(role string) []string // small static capability catalog
}
auth, err := authkit.New(authkit.Config{
    SessionStore:        myStore, // required for platform sessions
    PlatformAdminStore:  myPlatformStore,
    PlatformPolicy:      myPlatformPolicy,
    EnableImpersonation: true, // gates break-glass single-tenant access
    // ...
})

mux.HandleFunc("POST /platform/login",  auth.PlatformLogin)  // form: email, password, code
mux.HandleFunc("POST /platform/logout", auth.PlatformLogout)

// Protect platform routes with a platform capability check:
mux.Handle("GET /platform/tenants",
    auth.RequirePlatformAdmin("platform:tenants.read")(http.HandlerFunc(listTenants)))
  • Read the principal inside a handler with authkit.PlatformAdminFromCtx(r.Context()).

  • RequirePlatformAdmin never sets a tenant GUC — a platform admin's DB role has no cross-tenant bypass.

  • Break-glass impersonation lets an admin act within exactly one tenant; it requires the platform:impersonate capability and is audited:

    ctx, err := auth.ImpersonationContext(r.Context(), admin, tenantID)
    // ctx is now tenant-scoped; downstream queries run under normal RLS for that one tenant.
    

Device Principals

A device principal is a headless print agent (not a human) confined to a fixed, tiny capability set defined in code — it can receive print jobs and report status, and nothing else in the API. It is a separate credential path from the human/API-key flow.

type DeviceTokenValidator interface {
    ValidateDeviceToken(ctx context.Context, rawToken string) (*authkit.DeviceRecord, error)
}
auth, err := authkit.New(authkit.Config{
    DeviceTokenValidator: myDeviceValidator,
    // ...
})

// Fixed device capabilities (the whole of what a device may ever do):
//   authkit.CapPrintJobReceive   = "print:job.receive"
//   authkit.CapPrintStatusReport = "print:status.report"
mux.Handle("GET /agent/jobs",
    auth.RequireDevice(authkit.CapPrintJobReceive)(http.HandlerFunc(jobsHandler)))
  • The device presents an opaque token via Authorization: Bearer / X-API-Key; the store hashes it at rest and looks it up pre-tenant.
  • On success, RequireDevice binds the device's tenant and branch on the context. Read the principal with authkit.DeviceFromCtx(ctx).
  • A device never resolves permissions from a policy — it can never hold "*" or any tenant capability.
  • RequireDevice panics at startup if given a non-device capability (a wiring mistake caught early). AuthenticateDevice is also exposed for non-HTTP paths (e.g. a WebSocket upgrade).

CSRF Protection

For cookie-authenticated SPAs, enable signed double-submit CSRF protection. Token-authenticated requests (Bearer / API key) carry no ambient cookie credential and are always exempt.

auth, err := authkit.New(authkit.Config{
    EnableCSRF: true,
    // ...
})

// Wrap state-changing, cookie-authenticated routes:
mux.Handle("POST /api/projects", auth.CSRF(auth.Require("projects:write")(createHandler)))

// Optional explicit token endpoint for SPAs that fetch it:
mux.HandleFunc("GET /auth/csrf", auth.CSRFToken)
  • The token is delivered in a JS-readable cookie and must be echoed back in the X-CSRF-Token header on unsafe methods (POST/PUT/PATCH/DELETE).
  • The server checks both that the header matches the cookie and that the token carries a valid HMAC (signed with SessionSecret), so a subdomain that can set cookies still can't forge a signed token.
  • Safe methods (GET/HEAD/OPTIONS/TRACE) pass through and bootstrap the cookie.

Login Throttling

Blunt brute-force and credential-stuffing attacks by plugging in a rate limiter (e.g. backed by Redis). authkit calls it around password login, 2FA verification, and platform login, keyed per account+IP.

type LoginThrottler interface {
    Allow(ctx context.Context, key string) (retryAfter time.Duration, ok bool)
    RecordFailure(ctx context.Context, key string) error
    Reset(ctx context.Context, key string) error
}
auth, err := authkit.New(authkit.Config{
    Throttler: myThrottler, // implements authkit.LoginThrottler
    // ...
})
  • When locked out, authkit responds 429 Too Many Requests with a Retry-After header.
  • A successful login calls Reset to clear the failure counter.
  • authkit uses RemoteAddr for the client IP and does not trust X-Forwarded-For — behind a proxy, set RemoteAddr from a vetted header before authkit sees the request.

Live Permission Resolution

By default a session's permissions are resolved once at login and cached in the session, so role changes take effect on next login. Enable LivePermissionResolution to re-resolve permissions from the PolicyProvider on every request, through a short TTL cache — useful for multi-tenant deployments where operators change roles at runtime.

auth, err := authkit.New(authkit.Config{
    LivePermissionResolution: true,
    PermissionCacheTTL:       30 * time.Second, // default 30s
    // ...
})
  • API-key and token (JWT) credentials always resolve live, regardless of this flag.
  • Leave it off for single-shop deployments to keep the cheaper login-time cache.

Audit Events

Wire an AuditSink to persist structured security events to your audit log.

type AuditSink interface {
    Emit(ctx context.Context, ev authkit.AuditEvent)
}

type AuditEvent struct {
    Type     string // see constants below
    TenantID string
    Actor    string // who performed the action
    Subject  string // who/what it acted on
    IP       string
    At       time.Time
    Meta     map[string]any
}
auth, err := authkit.New(authkit.Config{
    AuditSink: myAuditSink, // defaults to a no-op sink when nil
    // ...
})

Well-known event types: AuditLogin, AuditLogout, AuditRefresh, AuditRevoke, Audit2FAEnroll, Audit2FAVerify, AuditRoleChange, AuditPermissionChange, AuditImpersonate.

Don't block the request path — implementations must buffer or hand off slow I/O. Emit is best-effort and never returns an error to authkit.


Policy YAML Reference

roles:
  <role-name>:
    # List of permission strings granted to this role.
    # Use "*" for superuser access.
    permissions: ["view", "upload"]

    # Emails assigned to this role (case-insensitive).
    members:
      - user@example.com

# Optional: role assigned to authenticated users whose email is not
# listed under any role. Omit to deny access to unknown users.
default_role: viewer

Role names must be alphanumeric (with hyphens/underscores). Permission names must be alphanumeric (with dots, colons, hyphens, underscores, or *). Member emails are validated for basic format.


Configuration Reference

authkit.Config{
    // Optional: auth mode. Default: authkit.AuthModeOAuth
    // Options: authkit.AuthModeOAuth, authkit.AuthModePassword, authkit.AuthModeBoth
    Mode: authkit.AuthModeOAuth,

    // Required when OAuth is enabled: providers to register.
    Providers: []authkit.ProviderConfig{...},

    // Required when OAuth is enabled: externally-reachable base URL (no trailing slash).
    CallbackBaseURL: "https://example.com",

    // Required: secret used to sign+encrypt session cookies.
    // Must be at least 32 bytes. Generate with: openssl rand -hex 32
    SessionSecret: "...",

    // Optional: set true for production HTTPS deployments. Default: false
    SecureCookie: true,

    // Optional: redirect target after successful login. Default: "/"
    AfterLoginURL: "/dashboard",

    // Optional: redirect target after logout. Default: "/"
    AfterLogoutURL: "/",

    // Optional: RBAC policy.
    // YAML only (default):
    RBAC: authkit.RBACConfig{FilePath: "policy.yaml"},
    // Layered (YAML baseline + database overrides):
    // RBAC: authkit.RBACConfig{Provider: authkit.NewLayeredProvider("policy.yaml", store)},
    // Fully custom:
    // RBAC: authkit.RBACConfig{Provider: myProvider},

    // Required when password auth is enabled: storage backend.
    UserStore: myUserStore,

    // Optional: password validation rules. Default: min 8 characters.
    PasswordPolicy: &authkit.PasswordPolicy{MinLength: 12},

    // Optional: custom logger. Default: standard log package.
    Logger: myLogger, // implements authkit.Logger

    // Optional: enables API key authentication alongside sessions.
    // When set, Require/RequireAuth accept Bearer tokens validated by this.
    // RequireSession/RequireSessionAuth always reject API keys regardless.
    APIKeyValidator: myKeyStore, // implements authkit.APIKeyValidator

    // Optional: structured security audit events. Default: no-op sink.
    AuditSink: myAuditSink, // implements authkit.AuditSink

    // Optional: re-resolve session permissions per request (TTL-cached).
    // Default: false (resolve once at login). PermissionCacheTTL default: 30s.
    LivePermissionResolution: true,
    PermissionCacheTTL:       30 * time.Second,

    // Optional: revocable server-side sessions. When nil, encrypted cookie sessions.
    SessionStore:    myStore,          // implements authkit.SessionStore
    IdleTimeout:     30 * time.Minute, // sliding; default 30m
    AbsoluteTimeout: 24 * time.Hour,   // hard cap; default 24h

    // Optional: CSRF double-submit middleware for cookie auth.
    EnableCSRF: true,

    // Optional: per-account+IP login rate limiting.
    Throttler: myThrottler, // implements authkit.LoginThrottler

    // Optional: two-factor auth (TOTP).
    TOTPStore:          myTOTPStore, // implements authkit.TOTPStore
    Require2FAForRoles: []string{"owner", "manager"},
    AppName:            "Acme", // issuer shown in authenticator apps; default "App"

    // Optional: mobile token layer (OAuth2 + PKCE + Ed25519 JWTs).
    EnableTokens:      true,
    SigningKeys:       []authkit.SigningKey{signingKey},
    RefreshTokenStore: myRefreshStore, // implements authkit.RefreshTokenStore
    AccessTokenTTL:    15 * time.Minute,    // default 15m
    RefreshTokenTTL:   30 * 24 * time.Hour, // default 30d
    TokenIssuer:       "https://api.example.com",
    TokenClientID:     "mobile-app",
    TokenRedirectURIs: []string{"acme://callback"},

    // Optional: platform super-admin axis + break-glass impersonation.
    PlatformAdminStore:  myPlatformStore,  // implements authkit.PlatformAdminStore
    PlatformPolicy:      myPlatformPolicy, // implements authkit.PlatformPolicy
    EnableImpersonation: true,

    // Optional: device principals (confined print agents).
    DeviceTokenValidator: myDeviceValidator, // implements authkit.DeviceTokenValidator
}

Session Security

By default, sessions are stored in encrypted cookies (stateless). For revocable, server-side sessions with idle/absolute timeouts and "log out everywhere", see Server-Side Sessions.

  • Sessions are stored in encrypted, signed cookies (gorilla/sessions + securecookie).
  • Cookie flags: HttpOnly, SameSite=Lax, 7-day MaxAge.
  • Set SecureCookie: true in production to add the Secure flag (HTTPS only).
  • The SessionSecret must be at least 32 bytes of cryptographically random data:
    openssl rand -hex 32
    
  • A startup warning is logged if SecureCookie is false.

Documentation

Overview

Package authkit provides plug-and-play OAuth authentication with RBAC for Go HTTP services. It wraps markbates/goth for the OAuth dance, gorilla/sessions for encrypted cookie sessions, and a YAML-based role policy for access control.

Quick start:

auth, err := authkit.New(authkit.Config{
    Providers: []authkit.ProviderConfig{
        {Name: "github", ClientID: os.Getenv("GITHUB_CLIENT_ID"), ClientSecret: os.Getenv("GITHUB_CLIENT_SECRET")},
    },
    CallbackBaseURL: "https://example.com",
    SessionSecret:   os.Getenv("SESSION_SECRET"),
    RBAC: authkit.RBACConfig{FilePath: "policy.yaml"},
})

mux.Handle("GET /auth/{provider}",          http.HandlerFunc(auth.BeginAuth))
mux.Handle("GET /auth/{provider}/callback", http.HandlerFunc(auth.Callback))
mux.Handle("POST /auth/logout",             http.HandlerFunc(auth.Logout))
mux.Handle("GET /auth/me",                  http.HandlerFunc(auth.Me))

// Protected routes
mux.Handle("GET /api/reports", auth.RequireAuth(reportsHandler))
mux.Handle("POST /api/projects", auth.Require("projects:write")(createHandler))

Index

Constants

View Source
const (
	AuditLogin            = "login"
	AuditLogout           = "logout"
	AuditRefresh          = "refresh"
	AuditRevoke           = "revoke"
	Audit2FAEnroll        = "2fa_enroll"
	Audit2FAVerify        = "2fa_verify"
	Audit2FADisable       = "2fa_disable"
	Audit2FARecoveryRegen = "2fa_recovery_regenerate"
	AuditPasswordChange   = "password_change"
	AuditRoleChange       = "role_change"
	AuditPermissionChange = "permission_change"
	AuditImpersonate      = "impersonate"

	// Password reset: a recovery token was requested, and (later) a password was
	// actually changed via a consumed token.
	AuditPasswordResetRequest = "password_reset_request"
	AuditPasswordReset        = "password_reset"
)

Well-known audit event types. Phases wire these as the corresponding features land; the set matches §7.7.

View Source
const (
	CapPrintJobReceive   = "print:job.receive"
	CapPrintStatusReport = "print:status.report"
)

Device capabilities. A device principal is a print agent (§7.12) — a headless service on a shop PC, not a human. Its capability set is FIXED and tiny: it can receive print jobs and report their status, and nothing else in the API. These names are the whole of what a device may ever do; there is no policy lookup.

View Source
const (
	ResetKindUser     = "user"
	ResetKindPlatform = "platform"
)

Reset kinds namespace a token to one principal axis.

View Source
const (
	// PermAll grants every permission check. Use "*" in policy.yaml to assign
	// it to a role. All other permission strings are user-defined.
	PermAll = "*"
)

Variables

View Source
var (
	ErrUserExists   = errors.New("authkit: user already exists")
	ErrUserNotFound = errors.New("authkit: user not found")
)

Sentinel errors for UserStore implementations.

View Source
var (
	ErrImpersonationDisabled  = errors.New("authkit: impersonation is disabled")
	ErrImpersonationForbidden = errors.New("authkit: platform:impersonate capability required")
)

Sentinel errors for impersonation.

View Source
var ErrDeviceTokenInvalid = errors.New("authkit: invalid device token")

ErrDeviceTokenInvalid is returned by AuthenticateDevice when the presented token is missing, unknown, or revoked.

Functions

func CheckPassword

func CheckPassword(hashedPassword, password string) bool

CheckPassword compares a plaintext password against a bcrypt hash.

func HashPassword

func HashPassword(password string) (string, error)

HashPassword hashes a plaintext password using bcrypt. Exported so consumers can use it in admin/seed tooling.

func IsDeviceCapability added in v1.0.3

func IsDeviceCapability(perm string) bool

IsDeviceCapability reports whether perm is one of the fixed device capabilities. Used to reject a programming error where a non-print capability is required on a device route.

func TenantIDFromCtx added in v1.0.2

func TenantIDFromCtx(ctx context.Context) (id string, ok bool)

TenantIDFromCtx returns the tenant ID stored in ctx. ok is false when no tenant has been set (or it is empty), which callers MUST treat as fail-closed.

func WithDevice added in v1.0.3

func WithDevice(ctx context.Context, d *Device) context.Context

WithDevice returns a copy of ctx carrying the device principal. RequireDevice sets this; the WS hub (§10) sets it on the connection context after a successful upgrade authentication.

func WithLogger added in v0.0.4

func WithLogger(l Logger) func(*LayeredPolicyProvider)

WithLogger configures a Logger for LayeredPolicyProvider. When set, DB errors during role lookups are logged so operators can detect store outages.

Example:

provider, err := authkit.NewLayeredProvider("policy.yaml", store,
    authkit.WithLogger(myLogger),
)

func WithTenant added in v1.0.2

func WithTenant(ctx context.Context, tenantID string) context.Context

WithTenant returns a copy of ctx carrying the given tenant ID. authkit sets this from the authenticated principal before resolving permissions, so a tenant-aware PolicyProvider (and the host's per-transaction RLS GUC) can scope to the right tenant. It is the single owner of the tenant context key shared across the auth library and the host application.

Types

type APIKeyValidator added in v0.0.3

type APIKeyValidator interface {
	ValidateKey(ctx context.Context, rawKey string) (*User, error)
}

APIKeyValidator validates a raw API key string and returns the associated user. Implementations look up the key hash in a store and return a *User with Email, Name, Provider, and Role populated. Authkit then resolves the user's permissions from the RBAC policy based on the returned Role.

Return nil, nil when the key is not found, expired, or inactive. Return nil, err only for unexpected infrastructure failures.

type AuditEvent added in v1.0.2

type AuditEvent struct {
	Type     string
	TenantID string
	Actor    string
	Subject  string
	IP       string
	At       time.Time
	Meta     map[string]any
}

AuditEvent is a single auditable security event emitted by authkit. The host application wires an AuditSink to persist these to its audit log (§7.7).

Type is one of the well-known constants below. TenantID is empty for platform principals and for pre-tenant events (e.g. a failed login before the user is resolved). Actor is who performed the action (email or admin id); Subject is who/what it acted on (often the same as Actor for self-service events). Meta carries event-specific detail (provider, session id, role names, etc.).

type AuditSink added in v1.0.2

type AuditSink interface {
	Emit(ctx context.Context, ev AuditEvent)
}

AuditSink receives audit events. Implementations MUST NOT block the request path on slow I/O — buffer or hand off as needed. Emit is best-effort from authkit's perspective; it never returns an error to the caller.

type Auth

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

Auth is the central object. Create one with New() and attach its methods as HTTP handlers and middleware.

func New

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

New validates the config, registers the OAuth providers with goth, loads the RBAC policy, and returns a ready-to-use Auth instance.

func (*Auth) AuthenticateDevice added in v1.0.3

func (a *Auth) AuthenticateDevice(ctx context.Context, rawToken string) (*Device, error)

AuthenticateDevice validates a raw device token and builds the principal. It is the credential path shared by RequireDevice (HTTP) and the WS-upgrade authenticator (§10), so both bind tenant+branch identically. It does NOT touch ctx — the caller binds (RequireDevice via WithDevice + WithTenant; the hub on the connection context).

func (*Auth) Authorize added in v1.0.3

func (a *Auth) Authorize(w http.ResponseWriter, r *http.Request)

Authorize is the PKCE authorization endpoint. It requires an authenticated session, validates the client + redirect + challenge, mints an auth code, and redirects back to the app. Mount on: GET /authorize

func (*Auth) BeginAuth

func (a *Auth) BeginAuth(w http.ResponseWriter, r *http.Request)

BeginAuth starts the OAuth flow for the provider named in the URL path. Mount this on: GET /auth/{provider}

The provider name is extracted from the Go 1.22+ path value {provider}.

func (*Auth) CSRF added in v1.0.3

func (a *Auth) CSRF(next http.Handler) http.Handler

CSRF is middleware enforcing the double-submit check on unsafe methods. On safe methods it ensures a valid token cookie is present (bootstrapping the SPA). It is a pass-through when EnableCSRF is false or the request is token-authenticated.

func (*Auth) CSRFToken added in v1.0.3

func (a *Auth) CSRFToken(w http.ResponseWriter, r *http.Request)

CSRFToken issues (or returns the existing) CSRF token and writes it as JSON, for SPAs that fetch it explicitly. Mount on: GET /auth/csrf

func (*Auth) Callback

func (a *Auth) Callback(w http.ResponseWriter, r *http.Request)

Callback completes the OAuth handshake, resolves the user's role from the RBAC policy, stores the user in the session, then redirects to AfterLoginURL. Mount this on: GET /auth/{provider}/callback

func (*Auth) ChangePassword added in v1.0.3

func (a *Auth) ChangePassword(w http.ResponseWriter, r *http.Request)

ChangePassword lets a signed-in user rotate their own password. It verifies the current password, sets the new one, then revokes every session and mints a fresh one for THIS device — so other devices are logged out (a credential change must not leave old sessions live) while the user stays signed in here. Mount on: POST /auth/password/change. Form: current_password, new_password.

func (*Auth) ConfirmTwoFactor added in v1.0.3

func (a *Auth) ConfirmTwoFactor(w http.ResponseWriter, r *http.Request)

ConfirmTwoFactor activates a pending TOTP secret for a user enrolling voluntarily from their account page (as opposed to the login-time flow, which uses Verify2FA + the pending cookie). It validates a code against the freshly provisioned secret and confirms it. Mount on: POST /auth/2fa/confirm. Form: code (6-digit TOTP) or recovery_code.

func (*Auth) DisableTwoFactor added in v1.0.3

func (a *Auth) DisableTwoFactor(w http.ResponseWriter, r *http.Request)

DisableTwoFactor turns off the current user's 2FA. Refused when the user's role mandates 2FA (they would be forced to re-enroll at next login anyway). Mount on: POST /auth/2fa/disable.

func (*Auth) Enroll2FA added in v1.0.3

func (a *Auth) Enroll2FA(w http.ResponseWriter, r *http.Request)

Enroll2FA provisions a new TOTP secret + recovery codes for the user currently in the 2FA-pending state (or an authenticated session, for voluntary enrollment), and returns the otpauth URL + recovery codes to show once. Mount on: POST /auth/2fa/enroll

func (*Auth) ForgotPassword added in v1.0.3

func (a *Auth) ForgotPassword(w http.ResponseWriter, r *http.Request)

ForgotPassword starts self-service recovery for a tenant user. It ALWAYS responds 200 with the same body whether or not the email exists, leaking nothing about which emails are registered. Mount on: POST /auth/password/forgot. Expects form: email.

func (*Auth) ImpersonationContext added in v1.0.3

func (a *Auth) ImpersonationContext(ctx context.Context, admin *PlatformAdmin, tenantID string) (context.Context, error)

ImpersonationContext returns a single-tenant-scoped context for a platform admin to act within exactly one tenant (break-glass). It requires the `platform:impersonate` capability and is audited. Downstream tenant-scoped DB access then runs under normal RLS for that one tenant — the admin is confined, never able to read across tenants.

func (*Auth) IssueResetToken added in v1.0.3

func (a *Auth) IssueResetToken(ctx context.Context, email, name, kind string) (string, error)

IssueResetToken mints, stores, and delivers a reset token for (email, kind). It is the shared core of the self-service forgot-password handlers and any admin-initiated reset (an owner resetting a staff member). It returns the raw token so an authenticated caller can surface the reset link directly; the public handlers discard it. A delivery error is returned (the caller decides); a store error is returned without attempting delivery.

func (*Auth) IssueToken added in v1.0.3

func (a *Auth) IssueToken(w http.ResponseWriter, r *http.Request)

IssueToken exchanges an authorization code (+ PKCE verifier) for an access + refresh token pair. Mount on: POST /token Expects form values: grant_type=authorization_code, code, code_verifier, redirect_uri.

func (*Auth) JWKS added in v1.0.3

func (a *Auth) JWKS(w http.ResponseWriter, _ *http.Request)

JWKS serves the public verification keys. Mount on: GET /.well-known/jwks.json

func (*Auth) Login

func (a *Auth) Login(w http.ResponseWriter, r *http.Request)

Login authenticates a user with email and password. Mount this on: POST /auth/login

Expects form values: email and password.

func (*Auth) Logout

func (a *Auth) Logout(w http.ResponseWriter, r *http.Request)

Logout clears the session and redirects to AfterLogoutURL. Mount this on: POST /auth/logout

func (*Auth) LogoutEverywhere added in v1.0.3

func (a *Auth) LogoutEverywhere(w http.ResponseWriter, r *http.Request)

LogoutEverywhere revokes every session for the current user, including this one, and clears the cookie ("log out all devices"). The client should treat the response as a logout and route to login. Mount on: POST /auth/logout/all.

func (*Auth) Me

func (a *Auth) Me(w http.ResponseWriter, r *http.Request)

Me returns the currently authenticated user as JSON. Returns 401 if the request has no valid session. Mount this on: GET /auth/me

func (*Auth) PlatformEnroll2FA added in v1.0.3

func (a *Auth) PlatformEnroll2FA(w http.ResponseWriter, r *http.Request)

PlatformEnroll2FA provisions a PENDING TOTP secret + recovery codes for a platform admin who passed the password step but has not enrolled 2FA yet, and returns the otpauth URL + recovery codes to show once. The admin confirms by calling PlatformVerify2FA with a code. Mount on: POST /platform/2fa/enroll.

It refuses once 2FA is confirmed: a stolen password alone must never be able to re-enroll (and thus reset) a platform admin's authenticator. Re-enrollment of a confirmed admin only happens after another admin resets their 2FA.

func (*Auth) PlatformForgotPassword added in v1.0.3

func (a *Auth) PlatformForgotPassword(w http.ResponseWriter, r *http.Request)

PlatformForgotPassword starts self-service recovery for a platform admin. Same no-enumeration contract as ForgotPassword. Mount on: POST /platform/password/forgot. Expects form: email.

func (*Auth) PlatformLogin added in v1.0.3

func (a *Auth) PlatformLogin(w http.ResponseWriter, r *http.Request)

PlatformLogin is step ONE of platform login: it verifies the email + password and, only on success, starts the mandatory TOTP challenge (no role exemption). It does NOT mint a session — the client must then call PlatformVerify2FA with the code. Mount on a separate route/subdomain: POST /platform/login. Expects form values: email, password.

func (*Auth) PlatformLogout added in v1.0.3

func (a *Auth) PlatformLogout(w http.ResponseWriter, r *http.Request)

PlatformLogout revokes the platform session and clears the cookie.

func (*Auth) PlatformMe added in v1.0.3

func (a *Auth) PlatformMe(w http.ResponseWriter, r *http.Request)

PlatformMe returns the currently signed-in platform admin (from the platform session cookie), or 401 when there is none. It is the platform counterpart to Me, letting an SPA confirm the platform session and show who is logged in. Mount on: GET /platform/me

func (*Auth) PlatformResetPassword added in v1.0.3

func (a *Auth) PlatformResetPassword(w http.ResponseWriter, r *http.Request)

PlatformResetPassword completes a platform admin's recovery: consume the token, set the new password, revoke platform sessions. 2FA enrollment is untouched — the admin still passes TOTP at next login. Mount on: POST /platform/password/reset. Expects form: token, password.

func (*Auth) PlatformVerify2FA added in v1.0.3

func (a *Auth) PlatformVerify2FA(w http.ResponseWriter, r *http.Request)

PlatformVerify2FA is step TWO: it validates the TOTP code for the admin in the platform-pending state (password already verified) and, on success, establishes the platform session. Mount on: POST /platform/2fa/verify. Expects form: code.

func (*Auth) RefreshAccessToken added in v1.0.3

func (a *Auth) RefreshAccessToken(w http.ResponseWriter, r *http.Request)

RefreshAccessToken rotates a refresh token: it issues a new access + refresh pair and invalidates the presented token. Reuse of a spent/revoked token revokes the entire chain. Mount on: POST /token/refresh Expects form value: refresh_token.

func (*Auth) RegenerateRecoveryCodes added in v1.0.3

func (a *Auth) RegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request)

RegenerateRecoveryCodes issues a fresh set of recovery codes for a user whose 2FA is already confirmed, invalidating the old set, and returns the new codes to show once. Mount on: POST /auth/2fa/recovery/regenerate.

func (*Auth) Register

func (a *Auth) Register(w http.ResponseWriter, r *http.Request)

Register creates a new user account with email and password, then logs them in automatically. Mount this on: POST /auth/register

Expects form values: email, password, and optionally name.

func (*Auth) Require

func (a *Auth) Require(permission string) func(http.Handler) http.Handler

Require is middleware that enforces both a valid credential (API key or OAuth session) AND that the authenticated user holds the given permission. Returns 401 when there is no credential, 403 when the user lacks the permission.

func (*Auth) RequireAuth

func (a *Auth) RequireAuth(next http.Handler) http.Handler

RequireAuth is middleware that enforces a valid credential — either an API key (via APIKeyValidator, if configured) or an OAuth session cookie. Responds 401 when neither is present or valid. On success it injects the User into the request context.

func (*Auth) RequireDevice added in v1.0.3

func (a *Auth) RequireDevice(perm string) func(http.Handler) http.Handler

RequireDevice authenticates a device principal (opaque token via Authorization: Bearer / X-API-Key) and checks it holds the given print capability. It is a SEPARATE credential path from Require/RequireAuth — a device token never authenticates a human route, and a human credential never authenticates a device route, so a device "can do nothing else in the API" (§7.12).

On success it sets both the device principal and the device's tenant on ctx (via WithTenant), so tenant-scoped DB access runs under the right RLS GUC. It panics if perm is not a fixed device capability — a wiring mistake caught at startup, not a runtime 403.

func (*Auth) RequirePlatformAdmin added in v1.0.3

func (a *Auth) RequirePlatformAdmin(perm string) func(http.Handler) http.Handler

RequirePlatformAdmin authenticates a platform principal and checks a platform capability. It NEVER sets the tenant GUC (§6.6). 401 without a platform session, 403 without the capability.

func (*Auth) RequireSession added in v0.0.3

func (a *Auth) RequireSession(permission string) func(http.Handler) http.Handler

RequireSession is like Require but rejects API key credentials. Use this for permission-gated management routes that must use OAuth sessions.

func (*Auth) RequireSessionAuth added in v0.0.3

func (a *Auth) RequireSessionAuth(next http.Handler) http.Handler

RequireSessionAuth is like RequireAuth but rejects API key credentials. Use this for routes that must only be accessed via an OAuth session (e.g. /auth/me, UI-only management actions).

func (*Auth) ResetPassword added in v1.0.3

func (a *Auth) ResetPassword(w http.ResponseWriter, r *http.Request)

ResetPassword completes self-service recovery: consume a valid reset token, set the new password, and revoke all of the user's sessions. Mount on: POST /auth/password/reset. Expects form: token, password.

func (*Auth) RevokeUserSessions added in v1.0.3

func (a *Auth) RevokeUserSessions(ctx context.Context, tenantID, email string) error

RevokeUserSessions revokes every server-side session for a user — the "log out everywhere" / fired-employee path. No-op when no SessionStore is configured. ctx must carry the user's tenant.

func (*Auth) TwoFactorStatus added in v1.0.3

func (a *Auth) TwoFactorStatus(w http.ResponseWriter, r *http.Request)

TwoFactorStatus reports whether the current user has confirmed 2FA and whether their role mandates it (§6.5#4). The SPA uses this to show enroll-vs-manage and to mark 2FA as required. Mount on: GET /auth/2fa/status.

func (*Auth) Verify2FA added in v1.0.3

func (a *Auth) Verify2FA(w http.ResponseWriter, r *http.Request)

Verify2FA completes a pending login by validating a TOTP code or a recovery code, then establishes the session. Mount on: POST /auth/2fa/verify Expects form values: code (6-digit TOTP) OR recovery_code.

func (*Auth) WatchRBAC

func (a *Auth) WatchRBAC(ctx context.Context, interval time.Duration)

WatchRBAC starts a background goroutine that reloads the RBAC policy file every interval. It stops when ctx is cancelled. This allows operators to update the policy without restarting the service.

Example:

go auth.WatchRBAC(ctx, 30*time.Second)

type AuthMode

type AuthMode string

AuthMode controls which authentication methods are enabled.

const (
	// AuthModeOAuth enables only OAuth providers (default).
	AuthModeOAuth AuthMode = "oauth"

	// AuthModePassword enables only email/password authentication.
	AuthModePassword AuthMode = "password"

	// AuthModeBoth enables both OAuth and email/password authentication.
	AuthModeBoth AuthMode = "both"
)

type Config

type Config struct {
	// Mode controls which authentication methods are enabled.
	// Defaults to AuthModeOAuth for backward compatibility.
	Mode AuthMode

	// Providers is the list of OAuth providers to enable using the built-in
	// convenience wrappers (github, google, gitlab).
	// Required when Mode is AuthModeOAuth or AuthModeBoth, unless GothProviders
	// is supplied instead.
	Providers []ProviderConfig

	// GothProviders is a list of pre-constructed goth.Provider values.
	// Use this to enable any of the 80+ providers that goth supports beyond the
	// three built-in convenience wrappers. Import the provider package you need
	// from github.com/markbates/goth/providers/*, construct the provider, and
	// pass it here. It is merged with any providers built from Providers.
	//
	// Example — add Spotify and Discord:
	//
	//	import (
	//	    "github.com/markbates/goth/providers/spotify"
	//	    "github.com/markbates/goth/providers/discord"
	//	)
	//
	//	GothProviders: []goth.Provider{
	//	    spotify.New(clientID, secret, callbackURL, "user-read-email"),
	//	    discord.New(clientID, secret, callbackURL, "identify", "email"),
	//	},
	GothProviders []goth.Provider

	// CallbackBaseURL is the externally-reachable base URL of the service
	// (e.g. "https://example.com"). The OAuth callback URLs are derived as
	// {CallbackBaseURL}/auth/{provider}/callback.
	// Required when Mode is AuthModeOAuth or AuthModeBoth.
	CallbackBaseURL string

	// SessionSecret is used to sign and encrypt session cookies.
	// Must be at least 32 bytes of random data.
	SessionSecret string

	// SecureCookie controls the Secure flag on session cookies.
	// Set to true in production (HTTPS only). Defaults to false.
	SecureCookie bool

	// AfterLoginURL is the URL the user is redirected to after a successful login.
	// Defaults to "/".
	AfterLoginURL string

	// AfterLogoutURL is the URL the user is redirected to after logout.
	// Defaults to "/".
	AfterLogoutURL string

	// RBAC configures the role policy. If FilePath is empty, all authenticated
	// users receive an empty role with no permissions.
	RBAC RBACConfig

	// UserStore provides user persistence for password-based authentication.
	// Required when Mode is AuthModePassword or AuthModeBoth.
	UserStore UserStore

	// PasswordPolicy configures password validation rules.
	// If nil, defaults are used (minimum 8 characters).
	PasswordPolicy *PasswordPolicy

	// Logger is used for diagnostic output. If nil, logs are written to the
	// standard library log package.
	Logger Logger

	// APIKeyValidator enables API key authentication alongside OAuth sessions.
	// When set, Require and RequireAuth middleware check the Authorization:
	// Bearer (or X-API-Key) header first. RequireSession and RequireSessionAuth
	// skip API key auth entirely (session-only routes).
	// If nil, API key auth is disabled and only sessions are accepted.
	APIKeyValidator APIKeyValidator

	// AuditSink receives security audit events (login, logout, refresh, revoke,
	// 2fa_*, role_change, permission_change, impersonate). If nil, a
	// NopAuditSink is installed so authkit can emit unconditionally.
	AuditSink AuditSink

	// LivePermissionResolution makes Require/RequireAuth re-resolve a session
	// user's permissions from the PolicyProvider on every request (through a
	// short TTL cache), so role and permission changes take effect within the
	// cache window rather than only on next login. Multi-tenant deploys want
	// this on; single-shop deploys can leave it off (cheaper login-time cache).
	// API-key credentials always resolve live regardless of this flag.
	LivePermissionResolution bool

	// PermissionCacheTTL bounds how stale a live-resolved permission set may be.
	// Defaults to 30s when LivePermissionResolution is enabled.
	PermissionCacheTTL time.Duration

	// SessionStore enables revocable, server-side sessions. When set, the cookie
	// carries only an opaque session ID and all identity state lives in the
	// store, allowing instant revocation and "log out everywhere". When nil,
	// authkit falls back to the legacy encrypted-cookie session.
	SessionStore SessionStore

	// IdleTimeout expires a session after inactivity (sliding). Defaults to 30m.
	// Only used with SessionStore.
	IdleTimeout time.Duration

	// AbsoluteTimeout caps a session's total lifetime regardless of activity.
	// Defaults to 24h. Only used with SessionStore.
	AbsoluteTimeout time.Duration

	// EnableCSRF turns on the CSRF middleware (signed double-submit) for
	// cookie-authenticated, state-changing requests. Token-authenticated
	// requests are always exempt.
	EnableCSRF bool

	// Throttler rate-limits password login attempts (per account+IP). When nil,
	// no throttling is applied.
	Throttler LoginThrottler

	// TOTPStore enables two-step auth (TOTP). When set, a user whose role is in
	// Require2FAForRoles must complete a TOTP challenge after the password step.
	// When nil, 2FA is disabled.
	TOTPStore TOTPStore

	// Require2FAForRoles lists the roles that must complete 2FA (e.g. owner,
	// manager). Only consulted when TOTPStore is set.
	Require2FAForRoles []string

	// AppName is the issuer shown in authenticator apps (the TOTP provisioning
	// URL). Defaults to "App".
	AppName string

	// ── Trusted devices ("remember this device", skip 2FA) ────────────────
	// TrustedDeviceStore enables a "trust this device" option at the 2FA step: a
	// remembered device skips the TOTP prompt (the password is still required) for
	// TrustedDeviceTTL. The token is opaque + server-side, so it is revocable
	// (logout-everywhere, password change/reset, and disabling 2FA all drop it).
	// When nil, every 2FA login prompts for TOTP. NOT used for platform admins.
	TrustedDeviceStore TrustedDeviceStore

	// TrustedDeviceTTL bounds how long a trusted device skips 2FA. Defaults to 30d.
	TrustedDeviceTTL time.Duration

	// ── Mobile token layer (§7.6) ─────────────────────────────────────────
	// EnableTokens turns on the OAuth2/PKCE token endpoints and the bearer-JWT
	// verifier. Requires SigningKeys and RefreshTokenStore.
	EnableTokens bool

	// SigningKeys is the Ed25519 rotation ring; the first key signs new access
	// tokens, all keys verify (and are published via JWKS).
	SigningKeys []SigningKey

	// AccessTokenTTL is the access-JWT lifetime (default 15m); RefreshTokenTTL
	// the refresh lifetime (default 30d).
	AccessTokenTTL  time.Duration
	RefreshTokenTTL time.Duration

	// RefreshTokenStore persists opaque refresh tokens (rotation + reuse
	// detection).
	RefreshTokenStore RefreshTokenStore

	// TokenIssuer is the JWT `iss` claim; TokenClientID the public mobile
	// client id (also the JWT audience); TokenRedirectURIs the allowed PKCE
	// redirect URIs.
	TokenIssuer       string
	TokenClientID     string
	TokenRedirectURIs []string

	// ── Platform / super-admin (§6.6, §7.11) ──────────────────────────────
	// PlatformAdminStore + PlatformPolicy enable the platform principal axis
	// (separate from tenant users). EnableImpersonation gates break-glass
	// single-tenant access.
	PlatformAdminStore  PlatformAdminStore
	PlatformPolicy      PlatformPolicy
	EnableImpersonation bool

	// ── Password reset (§6, "Forget Password") ────────────────────────────
	// PasswordResetStore persists single-use, hashed reset tokens; ResetDelivery
	// sends the raw token out-of-band (email/SMS). Both are required to enable the
	// ForgotPassword/ResetPassword (and platform) handlers. PasswordResetTTL bounds
	// a token's validity (default 30m).
	PasswordResetStore PasswordResetStore
	ResetDelivery      ResetDelivery
	PasswordResetTTL   time.Duration

	// ── Device principals / print agents (§7.12) ──────────────────────────
	// DeviceTokenValidator enables the device principal axis (print agents).
	// When set, RequireDevice and AuthenticateDevice validate opaque device
	// tokens against it. A device principal is confined to the fixed print:*
	// capabilities and is a separate credential path from the human/API-key
	// flow, so a device can do nothing else in the API. When nil, device auth
	// is disabled.
	DeviceTokenValidator DeviceTokenValidator
}

Config holds all configuration needed to create an Auth instance.

type Device added in v1.0.3

type Device struct {
	AgentID  string
	Name     string
	TenantID string
	BranchID string
}

Device is a device principal — a print agent bound to exactly one tenant and branch. It carries no human identity and resolves no permissions from a policy provider: its reach is the fixed deviceCaps set. The branch binds job routing (§10): the hub delivers a job only to agents whose tenant+branch match.

func DeviceFromCtx added in v1.0.3

func DeviceFromCtx(ctx context.Context) *Device

DeviceFromCtx returns the device principal on ctx, or nil.

func (*Device) Can added in v1.0.3

func (d *Device) Can(perm string) bool

Can reports whether the device holds a capability. Only the fixed print capabilities ever pass — a device can never hold "*" or any tenant capability.

type DeviceRecord added in v1.0.3

type DeviceRecord struct {
	AgentID  string
	Name     string
	TenantID string
	BranchID string
}

DeviceRecord is what DeviceTokenValidator returns for a valid token. The store looks the token up by hash (pre-tenant, via a SECURITY DEFINER function) and returns only the binding the principal needs — never the token itself.

type DeviceTokenValidator added in v1.0.3

type DeviceTokenValidator interface {
	ValidateDeviceToken(ctx context.Context, rawToken string) (*DeviceRecord, error)
}

DeviceTokenValidator validates an opaque device token and returns the bound agent. Implementations hash the token at rest and look it up before any tenant is known (a device authenticates with only the token). Return nil, nil when the token is unknown, revoked, or inactive; nil, err only on infrastructure failure.

type LayeredPolicyProvider added in v0.0.4

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

LayeredPolicyProvider combines a YAML baseline with per-user database overrides. The YAML file defines roles and their initial members. A management UI can call SetOverride to change individual users without editing the file.

Lookup order per request:

  1. Database override for this email (UI-managed, takes precedence)
  2. YAML baseline (file-managed, fallback)

Implements PolicyProvider and PolicyReloader.

func NewLayeredProvider added in v0.0.4

func NewLayeredProvider(filePath string, store UserRoleStore, opts ...func(*LayeredPolicyProvider)) (*LayeredPolicyProvider, error)

NewLayeredProvider creates a LayeredPolicyProvider that reads the initial policy from filePath and looks up per-user overrides from store.

Example:

provider, err := authkit.NewLayeredProvider("policy.yaml", myDBStore,
    authkit.WithLogger(myLogger),
)
auth, err := authkit.New(authkit.Config{
    RBAC: authkit.RBACConfig{Provider: provider},
    ...
})
go auth.WatchRBAC(ctx, 30*time.Second) // reloads YAML baseline

func (*LayeredPolicyProvider) DeleteOverride added in v0.0.4

func (l *LayeredPolicyProvider) DeleteOverride(ctx context.Context, email string) error

DeleteOverride removes the role override for email, reverting that user to the YAML baseline on their next login.

func (*LayeredPolicyProvider) PermissionsForRole added in v0.0.4

func (l *LayeredPolicyProvider) PermissionsForRole(_ context.Context, role string) []string

PermissionsForRole implements PolicyProvider. Resolves permissions for a named role from the YAML baseline — role definitions are always file-managed. ctx is unused: the layered provider's role definitions are not per-tenant.

func (*LayeredPolicyProvider) Reload added in v0.0.4

func (l *LayeredPolicyProvider) Reload() error

Reload implements PolicyReloader. Re-reads the YAML baseline without affecting any database overrides. Called automatically by WatchRBAC.

func (*LayeredPolicyProvider) RoleFor added in v0.0.4

func (l *LayeredPolicyProvider) RoleFor(ctx context.Context, email string) (string, []string)

RoleFor implements PolicyProvider. Checks the DB override first, then falls back to the YAML baseline. If the DB returns an error the fallback is used and the error is logged (if a Logger was configured). This prevents a store outage from locking users out, but means a user whose DB override was a demotion will temporarily regain their YAML role. Monitor store errors.

func (*LayeredPolicyProvider) SetOverride added in v0.0.4

func (l *LayeredPolicyProvider) SetOverride(ctx context.Context, email, role string, permissions []string) error

SetOverride validates and stores a per-user role override. Returns an error if the email format is invalid, the role name is not defined in the current YAML policy, or any permission string is invalid.

NOTE: role changes take effect on the user's next login — existing sessions retain their current permissions until they expire or the user logs out and back in. There is no built-in session invalidation.

func (*LayeredPolicyProvider) Store added in v0.0.4

Store returns the raw UserRoleStore for operations not covered by SetOverride/DeleteOverride (e.g. listing all overrides in a management UI).

type Logger

type Logger interface {
	// Info logs informational messages (e.g. startup warnings).
	Info(msg string, args ...any)

	// Error logs error messages (e.g. failed OAuth callbacks, session errors).
	Error(msg string, args ...any)
}

Logger is the interface authkit uses for diagnostic output. Provide your own implementation to route authkit logs into your application's logging system (e.g. slog, zap, zerolog). If not set in Config, a default logger that writes to the standard library log package is used.

type LoginThrottler added in v1.0.3

type LoginThrottler interface {
	// Allow reports whether an attempt for key may proceed. When locked out, ok
	// is false and retryAfter is the remaining lockout duration.
	Allow(ctx context.Context, key string) (retryAfter time.Duration, ok bool)
	// RecordFailure registers a failed attempt (advancing backoff/lockout).
	RecordFailure(ctx context.Context, key string) error
	// Reset clears the failure state for key after a successful login.
	Reset(ctx context.Context, key string) error
}

LoginThrottler rate-limits authentication attempts to blunt credential stuffing and brute force. The host implements it (e.g. backed by Redis); authkit calls it around the password login flow. Keys are per account+IP.

type NopAuditSink added in v1.0.2

type NopAuditSink struct{}

NopAuditSink discards every event. It is the default when Config.AuditSink is nil, so callers can emit unconditionally without nil checks.

func (NopAuditSink) Emit added in v1.0.2

Emit implements AuditSink and does nothing.

type PasswordPolicy

type PasswordPolicy struct {
	// MinLength is the minimum password length. Defaults to 8.
	MinLength int
}

PasswordPolicy configures password validation constraints.

type PasswordResetStore added in v1.0.3

type PasswordResetStore interface {
	// CreateResetToken stores a hashed token bound to (email, kind) with expiry.
	CreateResetToken(ctx context.Context, tokenHash, email, kind string, expiresAt time.Time) error
	// ConsumeResetToken atomically marks the token used (only if unused and
	// unexpired) and returns the email it binds. ok is false when the token is
	// unknown, already used, expired, or of the wrong kind.
	ConsumeResetToken(ctx context.Context, tokenHash, kind string) (email string, ok bool, err error)
}

PasswordResetStore persists single-use password-reset tokens. The raw token is delivered out-of-band (email today, SMS later); only its hash is stored. Consume MUST be atomic and single-use.

type PasswordUser

type PasswordUser struct {
	Email          string
	Name           string
	HashedPassword string

	// TenantID binds this credential to one tenant; BranchID is an optional
	// in-tenant scope. The store populates them (email is global, so the lookup
	// determines the tenant). They are copied onto the authenticated User.
	TenantID string
	BranchID string
}

PasswordUser is the record returned by UserStore.GetUserByEmail.

type PlatformAdmin added in v1.0.3

type PlatformAdmin struct {
	Email string
	Name  string
	Role  string
	// contains filtered or unexported fields
}

PlatformAdmin is a platform principal — a super-admin operating the SaaS across tenants. It is a different axis from tenant RBAC and has NO TenantID (§6.6). Its reach comes from application logic + explicit single-tenant impersonation, never from the DB role (which has no BYPASSRLS).

func PlatformAdminFromCtx added in v1.0.3

func PlatformAdminFromCtx(ctx context.Context) *PlatformAdmin

PlatformAdminFromCtx returns the platform principal on ctx, or nil.

func (*PlatformAdmin) Can added in v1.0.3

func (p *PlatformAdmin) Can(perm string) bool

Can reports whether the admin holds a platform capability ("*" passes all).

type PlatformAdminRecord added in v1.0.3

type PlatformAdminRecord struct {
	Email          string
	Name           string
	HashedPassword string
	Role           string
	TOTPSecret     string
	TOTPConfirmed  bool
}

PlatformAdminRecord is what PlatformAdminStore returns for login. TOTPSecret is the decrypted TOTP secret (the store handles encryption at rest), and is empty for an admin who has not enrolled yet; TOTPConfirmed reports whether that secret has been activated by a first successful verification. 2FA is mandatory, but a console-created admin enrolls on first login (pending→confirmed), so the secret is not always present immediately (only after bootstrap, or post-enroll).

type PlatformAdminStore added in v1.0.3

type PlatformAdminStore interface {
	GetPlatformAdmin(ctx context.Context, email string) (*PlatformAdminRecord, error)

	// UpdatePassword sets the bcrypt-hashed password for the platform admin with
	// this email. Called by the platform password-reset flow; it does NOT touch
	// the TOTP secret (2FA stays mandatory). Runs on the pool — platform_admins
	// has no RLS.
	UpdatePassword(ctx context.Context, email, hashedPassword string) error

	// EnrollPlatformTOTP stores (or replaces) a PENDING secret + recovery hashes
	// for an admin who has not confirmed 2FA yet (mirrors the tenant TOTPStore).
	EnrollPlatformTOTP(ctx context.Context, email, secret string, recoveryCodeHashes []string) error
	// ConfirmPlatformTOTP activates a pending secret on first successful verify.
	// Idempotent: a no-op once confirmed.
	ConfirmPlatformTOTP(ctx context.Context, email string) error
	// ConsumePlatformRecovery atomically marks a recovery code used (single-use)
	// and reports whether it matched an unused code.
	ConsumePlatformRecovery(ctx context.Context, email, codeHash string) (bool, error)
}

PlatformAdminStore looks up platform admins (separate from UserStore) and backs their TOTP enrollment. Admin lifecycle (create/list/remove) lives in the host app's own store methods; this interface is only what authkit's auth flow needs.

type PlatformPolicy added in v1.0.3

type PlatformPolicy interface {
	PermissionsForPlatformRole(role string) []string
}

PlatformPolicy maps a platform role to its capabilities (a small, static catalog independent of the per-tenant PolicyProvider).

type Policy

type Policy struct {
	// Roles maps role names to their definition.
	Roles map[string]RolePolicy `yaml:"roles"`

	// DefaultRole is assigned to authenticated users whose email is not listed
	// under any role. Leave empty to deny access to unlisted users.
	DefaultRole string `yaml:"default_role"`
}

Policy is the top-level structure of an rbac.yaml file.

type PolicyProvider added in v0.0.4

type PolicyProvider interface {
	// RoleFor returns the role name and permission list for the given email.
	// Called on every login and API key auth. Return empty strings/nil when
	// the user has no assigned role.
	RoleFor(ctx context.Context, email string) (role string, permissions []string)

	// PermissionsForRole returns the permissions for a named role. The ctx
	// carries the tenant (via TenantIDFromCtx) so role definitions can be
	// per-tenant — a DB-backed provider scopes its lookup to that tenant.
	// Used to resolve permissions for API key users and for per-request live
	// resolution (LivePermissionResolution).
	PermissionsForRole(ctx context.Context, role string) []string
}

PolicyProvider resolves a user's role and permissions at login time. Implement this interface to back RBAC with any storage system. The built-in implementations are the YAML file provider (default) and LayeredPolicyProvider (YAML baseline + database overrides).

type PolicyReloader added in v0.0.4

type PolicyReloader interface {
	Reload() error
}

PolicyReloader is an optional interface that PolicyProvider implementations can satisfy to support live policy reloading via WatchRBAC.

type ProviderConfig

type ProviderConfig struct {
	// Name is the provider identifier for the built-in wrappers: "bitbucket", "github", "google", or "gitlab".
	// For any other provider, use Config.GothProviders instead.
	Name string

	// ClientID and ClientSecret are the OAuth application credentials.
	ClientID     string
	ClientSecret string

	// Scopes overrides the default scopes for the provider.
	// Leave nil to use the sensible defaults (email + profile).
	Scopes []string
}

ProviderConfig holds the OAuth credentials for a single provider.

type RBACConfig

type RBACConfig struct {
	// FilePath is the path to the rbac.yaml policy file.
	// Used when Provider is nil.
	FilePath string

	// Provider supplies a custom PolicyProvider implementation (e.g. a
	// database-backed or layered provider). When set, FilePath is ignored.
	Provider PolicyProvider
}

RBACConfig tells the Auth instance how to load the role policy.

type RefreshToken added in v1.0.3

type RefreshToken struct {
	ID        string
	UserEmail string
	TenantID  string
	ChainID   string
	ParentID  string
	IssuedAt  time.Time
	ExpiresAt time.Time
	UsedAt    *time.Time
	RevokedAt *time.Time
}

RefreshToken is one opaque refresh token in a rotation chain. A successful refresh marks the presented token used and issues a child (same ChainID, ParentID = the used token). Presenting an already-used or revoked token is treated as theft and revokes the whole chain.

ID is the raw token at the authkit boundary; the store MUST hash it at rest and hash lookups, so the database never holds a usable token.

type RefreshTokenStore added in v1.0.3

type RefreshTokenStore interface {
	// Create stores a new refresh token.
	Create(ctx context.Context, t *RefreshToken) error
	// Get returns the token for the raw value, or nil if not found.
	Get(ctx context.Context, rawToken string) (*RefreshToken, error)
	// Rotate atomically marks rawOld used and stores next (its child). It MUST
	// fail (and change nothing) if rawOld is already used or revoked.
	Rotate(ctx context.Context, rawOld string, next *RefreshToken) error
	// RevokeChain revokes every token sharing chainID (reuse response).
	RevokeChain(ctx context.Context, chainID string) error
}

RefreshTokenStore persists refresh tokens with rotation lineage and reuse detection. Implementations hash ID at rest.

type ResetDelivery added in v1.0.3

type ResetDelivery interface {
	SendPasswordReset(ctx context.Context, req ResetRequest) error
}

ResetDelivery delivers a password-reset token to the principal. The host owns the channel (email now, SMS later) — authkit is channel-agnostic. Sending is best-effort from the request's perspective: the "forgot" endpoint logs a delivery error but still returns 200, so it leaks neither which emails exist nor which channel succeeded.

type ResetRequest added in v1.0.3

type ResetRequest struct {
	Email string
	Name  string
	Kind  string
	Token string
	TTL   time.Duration
}

ResetRequest is handed to ResetDelivery to deliver a reset token. authkit stores only the hash; Token here is the raw secret that goes in the link/code. Kind selects the template/channel and which reset page the link points to.

type ResetToken added in v1.0.3

type ResetToken struct {
	TokenHash string
	Email     string
	Kind      string
	ExpiresAt time.Time
	UsedAt    *time.Time
}

ResetToken is a single-use, hashed password-reset token record. UsedAt is nil until the token is consumed.

type RolePolicy

type RolePolicy struct {
	// Permissions is the list of allowed permissions for this role.
	// Use "*" to grant all permissions (admin).
	Permissions []string `yaml:"permissions"`

	// Members is the list of email addresses assigned to this role.
	Members []string `yaml:"members"`
}

RolePolicy defines the permissions and member emails for a single role.

type Session added in v1.0.3

type Session struct {
	ID          string
	TenantID    string
	BranchID    string
	Email       string
	Name        string
	Provider    string
	Role        string
	Permissions []string

	// Platform marks a super-admin (platform principal) session — no tenant.
	// Tenant and platform sessions use different cookies, so they never cross.
	Platform bool

	// CreatedAt anchors the absolute timeout; LastSeenAt anchors the idle
	// timeout (advanced by Touch on a sliding basis).
	CreatedAt  time.Time
	LastSeenAt time.Time
}

Session is a server-side session record. The cookie holds only the opaque ID; all identity state lives in the SessionStore, enabling instant revocation and "log out everywhere" — things a stateless cookie cannot do.

type SessionStore added in v1.0.3

type SessionStore interface {
	// Create persists a new session. Returns an error only on infrastructure failure.
	Create(ctx context.Context, s *Session) error
	// Get returns the session for id, or nil when it does not exist.
	Get(ctx context.Context, id string) (*Session, error)
	// Touch advances LastSeenAt for sliding idle renewal.
	Touch(ctx context.Context, id string, lastSeen time.Time) error
	// Revoke deletes a single session (logout / fixation rotation).
	Revoke(ctx context.Context, id string) error
	// RevokeAllForUser deletes every session for a user ("log out everywhere").
	RevokeAllForUser(ctx context.Context, tenantID, email string) error
}

SessionStore is the revocable, server-side session backend (DB or Redis). The host application implements it; authkit generates the opaque IDs and enforces the idle/absolute timeouts.

Get is called before the tenant is known (it resolves the session by its unguessable ID), so implementations MUST be able to read by ID without a tenant scope. Create/Touch/Revoke/RevokeAllForUser are always called with the session's tenant already on the context.

type SigningKey added in v1.0.3

type SigningKey struct {
	KID     string
	Private ed25519.PrivateKey
}

SigningKey is one Ed25519 key in the rotation ring. The current key (the first in Config.SigningKeys) signs new access tokens; all keys verify, so a key can be retired without invalidating tokens it already signed.

func NewSigningKey added in v1.0.3

func NewSigningKey(kid string, seed []byte) (SigningKey, error)

NewSigningKey builds a SigningKey from a 32-byte Ed25519 seed.

type TOTPManager added in v1.0.3

type TOTPManager interface {
	// Disable removes the user's TOTP secret and all recovery codes (turning 2FA
	// off). Idempotent: a no-op when none is stored.
	Disable(ctx context.Context, tenantID, email string) error
	// ReplaceRecoveryCodes deletes the user's existing recovery codes and stores
	// the given hashed set, without touching the confirmed TOTP secret.
	ReplaceRecoveryCodes(ctx context.Context, tenantID, email string, recoveryCodeHashes []string) error
}

TOTPManager is an OPTIONAL extension of TOTPStore for self-service 2FA management (disable, regenerate recovery codes). authkit type-asserts the configured TOTPStore to this — a store that does not implement it simply makes those endpoints return 501, leaving the base enroll/verify flow intact. Kept separate from TOTPStore so adding management never breaks existing implementers (e.g. the platform-admin store, whose 2FA is mandatory).

type TOTPStore added in v1.0.3

type TOTPStore interface {
	// Enroll stores (or replaces) the user's TOTP secret and the hashed recovery
	// codes as PENDING — provisioned but NOT yet confirmed. The user is not
	// considered to have working 2FA until they prove possession of the
	// authenticator with a valid code (see Confirm). This two-phase model is what
	// lets a user who abandons enrollment (got the QR, never added it) be sent
	// back to enroll on the next login instead of being locked at the verify step.
	Enroll(ctx context.Context, tenantID, email, secret string, recoveryCodeHashes []string) error
	// Confirm marks a pending secret confirmed (activated). Called once, on the
	// user's first successful verification. MUST be idempotent: a no-op when the
	// secret is already confirmed or absent.
	Confirm(ctx context.Context, tenantID, email string) error
	// Secret returns the user's TOTP secret (empty when none is stored) and whether
	// it has been CONFIRMED. A non-empty secret with confirmed=false is a pending
	// enrollment: it can be validated (to confirm it) but does not by itself mean
	// the user has set up 2FA.
	Secret(ctx context.Context, tenantID, email string) (secret string, confirmed bool, err error)
	// ConsumeRecovery atomically marks a recovery code used (single-use) and
	// reports whether it matched an unused code.
	ConsumeRecovery(ctx context.Context, tenantID, email, codeHash string) (bool, error)
}

TOTPStore persists a user's TOTP secret and recovery codes. The host implementation is responsible for encrypting the secret at rest; authkit passes/receives the plaintext secret at this boundary. All methods are called with the user's tenant already on the context (TOTP access is tenant-scoped).

type TrustedDeviceStore added in v1.0.3

type TrustedDeviceStore interface {
	// Trust records a trusted device for (tenant, email) and returns the opaque
	// cookie token. ttl bounds its lifetime.
	Trust(ctx context.Context, tenantID, email string, ttl time.Duration) (token string, err error)
	// IsTrusted reports whether token is a live trusted device for (tenant, email).
	IsTrusted(ctx context.Context, tenantID, email, token string) (bool, error)
	// RevokeAllForUser drops every trusted device for a user.
	RevokeAllForUser(ctx context.Context, tenantID, email string) error
}

TrustedDeviceStore persists "remember this device" tokens so a user whose role requires 2FA can skip the TOTP step on a device they previously trusted. The token is opaque and server-side, so it is revocable: "log out everywhere", a password change/reset, and disabling 2FA all drop a user's trusted devices. Password is still required on every login; only the second factor is skipped. NOT used for platform admins (their 2FA is mandatory). All calls carry the user's tenant on the context.

type User

type User struct {
	Email     string `json:"email"`
	Name      string `json:"name"`
	AvatarURL string `json:"avatarUrl"`
	Provider  string `json:"provider"`
	Role      string `json:"role"`

	// TenantID binds the principal to one tenant (the hard security boundary).
	// Populated in every credential path: UserStore (password), the OAuth
	// callback mapping, and APIKeyValidator. Used to set the per-transaction
	// RLS GUC and to key tenant-scoped permission resolution.
	TenantID string `json:"tenantId"`

	// BranchID is an optional authorization scope within a tenant (not an RLS
	// boundary). Carried into queries as a row filter, never as a permission.
	BranchID string `json:"branchId,omitempty"`
	// contains filtered or unexported fields
}

User represents an authenticated user resolved from an OAuth session. It is injected into every request context after successful authentication.

func UserFromCtx

func UserFromCtx(ctx context.Context) *User

UserFromCtx returns the authenticated User stored in ctx, or nil if the request has not passed through RequireAuth middleware.

func (*User) Can

func (u *User) Can(permission string) bool

Can reports whether the user holds the given permission. A user with the "*" (PermAll) permission passes every check.

type UserRoleStore added in v0.0.4

type UserRoleStore interface {
	// GetOverride returns the role and permissions for email if a UI-managed
	// override exists. Return found=false when no override has been set for
	// this user — authkit will fall back to the YAML baseline.
	// The email argument is always lower-cased and trimmed before being passed.
	GetOverride(ctx context.Context, email string) (role string, permissions []string, found bool, err error)

	// SetOverride creates or replaces the role override for a user.
	// Prefer calling LayeredPolicyProvider.SetOverride instead — it validates
	// the role name and permission strings before writing to the store.
	SetOverride(ctx context.Context, email, role string, permissions []string) error

	// DeleteOverride removes the override for email, reverting that user to
	// whatever the YAML baseline assigns them.
	DeleteOverride(ctx context.Context, email string) error
}

UserRoleStore persists per-user role overrides to a database. Implement this interface against your preferred database (Postgres, SQLite, etc.) and pass it to NewLayeredProvider.

Only users whose roles have been changed via your UI need a row in the store. Everyone else falls through to the YAML baseline automatically.

type UserStore

type UserStore interface {
	// CreateUser persists a new user with the given email and bcrypt-hashed
	// password. Implementations MUST return ErrUserExists if the email is
	// already taken.
	CreateUser(ctx context.Context, email, name, hashedPassword string) error

	// GetUserByEmail retrieves a user by email. Returns ErrUserNotFound if
	// no user matches.
	GetUserByEmail(ctx context.Context, email string) (*PasswordUser, error)

	// UpdatePassword sets the bcrypt-hashed password for the user with this
	// (global) email. Called by the password-reset flow after a valid token is
	// consumed. ctx carries the user's tenant, so a tenant-scoped store can run
	// the update under RLS.
	UpdatePassword(ctx context.Context, email, hashedPassword string) error
}

UserStore is the interface consumers implement to provide user persistence for password-based authentication. authkit is storage-agnostic — the consumer chooses the backing store (PostgreSQL, SQLite, in-memory, etc.).

Jump to

Keyboard shortcuts

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