Skip to content

Latest commit

 

History

History
830 lines (656 loc) · 27.2 KB

File metadata and controls

830 lines (656 loc) · 27.2 KB

SimpleAuth — Build Spec

Single-instance identity server for your app. Single Go binary. Replaces Keycloak. Every user is a GUID. LDAP/AD is just another identity provider.


Overview

Single Go binary with embedded admin UI. Runs as a Docker container. No external dependencies except an optional AD/LDAP server.

  • Users are GUIDs — username, email, display name are just attributes
  • LDAP/AD is a provider — maps LDAP usernames to GUIDs, authenticates via bind
  • Identity mappings — any provider (LDAP, future SAML/OAuth) maps external IDs to user GUIDs
  • Issues signed JWTs with GUID as sub, plus roles, permissions, and AD groups
  • Exposes JWKS endpoint so any app can validate tokens
  • Impersonation — admins can generate tokens as any user (audited)
  • Embedded admin UI — manage everything from the browser, no separate frontend
  • BoltDB storage (single file, no database service needed)
  • Recommended: run behind nginx for TLS termination

Tech Stack

  • Go (stdlib net/http for HTTP)
  • Go embed package for bundling the admin UI
  • BoltDB via go.etcd.io/bbolt (pure Go, embedded key-value store)
  • github.com/go-ldap/ldap/v3 — LDAP authentication
  • github.com/golang-jwt/jwt/v5 — JWT signing/verification
  • github.com/jcmturner/gokrb5/v8 — Kerberos/SPNEGO
  • golang.org/x/crypto/bcrypt — local password hashing
  • Frontend: Preact + Tailwind CSS (compiled to static files, embedded in binary)

Core Concepts

Users (GUIDs)

Every user in SimpleAuth is identified by a UUID. That's it. The GUID never changes regardless of username changes, email changes, or provider changes.

Identity Providers

LDAP/AD is just one type of provider. A provider:

  • Maps an external identifier to a user GUID
  • May or may not support authentication (LDAP does)

Identity Mappings

The link between external identifiers and user GUIDs:

  • (provider, external_id) -> user_guid
  • LDAP provider: (ldap, "kalahmad") -> abc-123

All collapse to the same GUID.

Roles & Permissions

Roles and permissions are global to the SimpleAuth instance. A user has one set of roles and one set of permissions. There is a special built-in role SimpleAuthAdmin — users with this role get full admin access to the SimpleAuth API (equivalent to using the ADMIN_KEY).

Default roles can be configured and are automatically assigned to new users on first login.


JWT Claims

{
  "sub": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
  "name": "Khalefa Ahmad",
  "email": "kalahmad@corp.local",
  "roles": ["admin", "moderator"],
  "permissions": ["can_delete_messages", "can_ban_users"],
  "groups": ["Domain Users", "IT Department"],
  "iss": "simpleauth",
  "iat": 1741500000,
  "exp": 1741528800
}

Impersonated tokens include extra claims:

{
  "sub": "a1b2c3d4-...",
  "impersonated": true,
  "impersonated_by": "d4c3b2a1-...",
  "...": "..."
}
Field Source
sub User GUID (UUID)
name LDAP displayName attr or local user display name
email LDAP mail attr or local user email
roles Global roles for this user
permissions Global permissions for this user
groups Pulled from AD memberOf attribute during LDAP auth
impersonated true if token was issued via impersonation
impersonated_by GUID of the admin who impersonated

Signed with RS256. RSA-2048 key pair auto-generated on first start, saved to data directory.


API Endpoints

Authentication

Method Path Description
POST /api/auth/login Login (username + password)
POST /api/auth/refresh Refresh access token
GET /api/auth/userinfo Get user info from current token
GET /api/auth/negotiate Kerberos/SPNEGO login (Windows SSO)
POST /api/auth/impersonate Impersonate a user (admin only)
GET /login Hosted login page (redirect-based flow)
GET /.well-known/jwks.json Public keys for token validation
GET /health Health check

POST /api/auth/login

Request:

{
  "username": "khalefa",
  "password": "secret"
}

Response:

{
  "access_token": "eyJ...",
  "refresh_token": "eyJ...",
  "expires_in": 28800,
  "token_type": "Bearer"
}

Auth flow:

  1. Treat username as LDAP username (default field: sAMAccountName)
  2. If LDAP configured, try LDAP bind with username + password
  3. If LDAP bind succeeds, pull display name, email, groups from AD
  4. If LDAP bind fails or not configured, try local user password (bcrypt)
  5. On success, load roles/permissions, issue JWT with GUID as sub
  6. On failure, 401

POST /api/auth/impersonate

Request (requires admin key or user with can_impersonate permission):

{
  "target_guid": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
}

Response: same as login, but JWT includes impersonated: true and impersonated_by claims. Shorter TTL (1h default).

GET /login (Hosted Login Page)

Redirect-based login flow for apps that don't want to build their own login form.

Redirect to SimpleAuth:

https://auth.corp.local/login?redirect_uri=https://chat.corp.local/callback

Flow:

  1. App redirects user to SimpleAuth's /login page with redirect_uri
  2. SimpleAuth shows the login form
  3. User enters credentials
  4. SimpleAuth authenticates (same flow as /api/auth/login)
  5. On success, redirects back to the app:
    https://chat.corp.local/callback#access_token=eyJ...&refresh_token=eyJ...&expires_in=28800&token_type=Bearer
    
  6. On failure, shows error on the login page, user can retry

Security:

  • redirect_uri must match one of the instance's configured allowed redirect URIs (AUTH_REDIRECT_URIS)
  • If redirect_uri doesn't match, reject with error
  • Tokens passed in URL fragment (#) not query params (?) so they don't hit server logs

This means apps have two choices for login:

  1. API-based — build your own login form, call POST /api/auth/login from your backend
  2. Redirect-based — redirect to SimpleAuth's hosted login page, get tokens back via redirect

GET /api/auth/negotiate

Kerberos/SPNEGO flow (transparent Windows SSO):

  1. Browser sends Authorization: Negotiate <base64 token>
  2. Server validates Kerberos ticket using keytab
  3. Extracts username from ticket
  4. Resolves to user GUID (auto-creates if new)
  5. Looks up user in LDAP for groups/display name
  6. Issues JWT with GUID as sub

If no Negotiate header, responds with 401 + WWW-Authenticate: Negotiate

Requires:

  • AUTH_KRB5_KEYTAB env var pointing to a keytab file
  • AUTH_KRB5_REALM env var (e.g., CORP.LOCAL)
  • Service registered as SPN in AD

Admin API

All admin endpoints require either:

  • Authorization: Bearer <ADMIN_KEY> header (the bootstrap admin key from env), or
  • A valid JWT for a user with the SimpleAuthAdmin role

LDAP Providers

Multiple LDAP/AD directories can be configured. Each gets a unique provider ID (e.g., corp, partner). Identity mappings reference them as ldap:corp, ldap:partner, etc.

Method Path Description
GET /api/admin/ldap List all LDAP providers
GET /api/admin/ldap/:provider_id Get single LDAP provider config
POST /api/admin/ldap Add a new LDAP provider
PUT /api/admin/ldap/:provider_id Update LDAP provider config
DELETE /api/admin/ldap/:provider_id Remove LDAP provider
POST /api/admin/ldap/:provider_id/test Test LDAP provider connection
GET /api/admin/ldap/export Export all LDAP provider configs as JSON
POST /api/admin/ldap/import Import LDAP provider configs from JSON
POST /api/admin/ldap/auto-discover Auto-configure LDAP from domain + service account
POST /api/admin/ldap/setup-kerberos One-time Kerberos/SPNEGO setup using privileged creds

POST /api/admin/ldap body:

{
  "provider_id": "corp",
  "name": "Corporate AD",
  "url": "ldap://dc1.corp.local:389",
  "base_dn": "DC=corp,DC=local",
  "bind_dn": "CN=svc-auth,OU=Service Accounts,DC=corp,DC=local",
  "bind_password": "service-password",
  "user_filter": "(sAMAccountName={{username}})",
  "use_tls": false,
  "skip_tls_verify": false,
  "display_name_attr": "displayName",
  "email_attr": "mail",
  "groups_attr": "memberOf"
}

GET /api/admin/ldap/export response:

{
  "ldap_providers": [
    {
      "provider_id": "corp",
      "name": "Corporate AD",
      "url": "ldap://dc1.corp.local:389",
      "base_dn": "DC=corp,DC=local",
      "bind_dn": "CN=svc-auth,OU=Service Accounts,DC=corp,DC=local",
      "bind_password": "service-password",
      "user_filter": "(sAMAccountName={{username}})",
      "use_tls": false,
      "skip_tls_verify": false,
      "display_name_attr": "displayName",
      "email_attr": "mail",
      "groups_attr": "memberOf",
      "priority": 0
    }
  ]
}

POST /api/admin/ldap/import — accepts the same JSON format. Upserts providers (creates new, updates existing by provider_id).

Note: Export/import covers LDAP connection config only. Kerberos keytab is a file managed at the instance level (AUTH_KRB5_KEYTAB env var) — Kerberos/SPNEGO won't work on a new instance until the keytab file is also deployed there separately.

POST /api/admin/ldap/auto-discover

Auto-configure an LDAP provider from just a domain name and service account credentials. No need to manually figure out DCs, base DN, or ports.

Request:

{
  "domain": "corp.local",
  "bind_dn": "CN=svc-auth,OU=Service Accounts,DC=corp,DC=local",
  "bind_password": "service-password",
  "provider_id": "corp"
}

provider_id is optional — defaults to the domain name with dots replaced by dashes (e.g., corp-local).

Auto-discovery flow:

  1. DNS SRV lookup for _ldap._tcp.corp.local -> get DC hostnames + ports
  2. Connect to the first reachable DC
  3. Query RootDSE -> extract defaultNamingContext (base DN), supported controls, forest info
  4. Set sensible defaults: user_filter=(sAMAccountName={{username}}), display_name_attr=displayName, email_attr=mail, groups_attr=memberOf
  5. Test bind with provided credentials to verify connectivity
  6. Return the fully populated LDAP provider config (not yet saved)

Response:

{
  "provider_id": "corp",
  "name": "corp.local (auto-discovered)",
  "url": "ldap://dc1.corp.local:389",
  "base_dn": "DC=corp,DC=local",
  "bind_dn": "CN=svc-auth,OU=Service Accounts,DC=corp,DC=local",
  "bind_password": "service-password",
  "user_filter": "(sAMAccountName={{username}})",
  "use_tls": false,
  "display_name_attr": "displayName",
  "email_attr": "mail",
  "groups_attr": "memberOf",
  "discovered_dcs": ["dc1.corp.local:389", "dc2.corp.local:389"],
  "saved": false
}

The response includes "saved": false — the caller can review and then POST /api/admin/ldap to save it, or pass "save": true in the request to auto-save.

POST /api/admin/ldap/setup-kerberos

One-time Kerberos/SPNEGO setup. Uses privileged AD credentials to create the service principal and generate a keytab — then wipes the privileged credentials from memory.

Request:

{
  "domain": "corp.local",
  "domain_admin_dn": "CN=Administrator,CN=Users,DC=corp,DC=local",
  "domain_admin_password": "admin-password",
  "service_hostname": "auth.corp.local"
}

Setup flow:

  1. Connect to AD using the privileged domain admin credentials
  2. Create (or find existing) service account for SimpleAuth
  3. Register SPN HTTP/auth.corp.local on the service account
  4. Generate keytab for the SPN
  5. Save keytab to {DATA_DIR}/krb5.keytab
  6. Set AUTH_KRB5_KEYTAB and AUTH_KRB5_REALM internally (runtime config)
  7. Wipe domain admin credentials from memory — they are never stored
  8. Test: attempt a Kerberos handshake to verify the keytab works

Response:

{
  "status": "ok",
  "realm": "CORP.LOCAL",
  "spn": "HTTP/auth.corp.local",
  "keytab_path": "/data/krb5.keytab",
  "message": "Kerberos configured. Domain admin credentials have been wiped."
}

Security notes:

  • Domain admin credentials exist only for the duration of this API call
  • They are never written to disk, never logged, never stored in the database
  • After setup, only the keytab file is retained — it contains only the service account key, not admin credentials
  • This endpoint should only be called once during initial setup
  • If the keytab needs to be regenerated, call this endpoint again

Auth flow with multiple LDAP providers:

  1. Login request comes in -> resolve to user GUID
  2. Find all ldap:* mappings for that GUID
  3. Try authenticating against each mapped LDAP provider until one succeeds
  4. If no GUID yet (new user) -> try each LDAP provider in order until one succeeds, then auto-create user + mapping

User Management

Method Path Description
GET /api/admin/users List all users
GET /api/admin/users/:guid Get single user
POST /api/admin/users Create local user (auto-generates GUID)
PUT /api/admin/users/:guid Update user (display name, email)
DELETE /api/admin/users/:guid Delete user
PUT /api/admin/users/:guid/password Set local password
PUT /api/admin/users/:guid/disabled Enable/disable user

POST /api/admin/users body:

{
  "username": "admin",
  "password": "changeme",
  "display_name": "Admin User",
  "email": "admin@company.com"
}

Response includes the generated GUID.

Identity Mappings

Method Path Description
GET /api/admin/users/:guid/mappings List all mappings for a user
PUT /api/admin/users/:guid/mappings Add/update a mapping
DELETE /api/admin/users/:guid/mappings/:provider/:external_id Remove a mapping
GET /api/admin/mappings/resolve Resolve an external ID to a GUID

PUT /api/admin/users/:guid/mappings body:

{
  "provider": "ldap:corp",
  "external_id": "kalahmad"
}

GET /api/admin/mappings/resolve?provider=ldap:corp&external_id=kalahmad -> returns GUID.

Roles & Permissions

Roles and permissions are global to the instance. A user has one set of roles and one set of permissions across the entire SimpleAuth instance.

Method Path Description
GET /api/admin/users/:guid/roles Get user's roles
PUT /api/admin/users/:guid/roles Set user's roles
GET /api/admin/users/:guid/permissions Get user's permissions
PUT /api/admin/users/:guid/permissions Set user's permissions
GET /api/admin/defaults/roles Get default roles for new users
PUT /api/admin/defaults/roles Set default roles for new users
GET /api/admin/role-permissions Get role-to-permissions mapping
PUT /api/admin/role-permissions Set role-to-permissions mapping

PUT /api/admin/users/:guid/roles body:

["admin", "moderator"]

PUT /api/admin/users/:guid/permissions body:

["can_delete_messages", "can_ban_users"]

Default roles are automatically assigned to users on first login.

User Merge

Method Path Description
POST /api/admin/users/merge Merge two users into a new one
POST /api/admin/users/:guid/unmerge Undo a merge (restore original user)

POST /api/admin/users/merge body:

{
  "source_guids": ["guid-A", "guid-B"],
  "display_name": "Khalefa Ahmad",
  "email": "kalahmad@corp.local"
}

Response:

{
  "merged_guid": "guid-C",
  "sources": ["guid-A", "guid-B"]
}

Merge flow:

  1. Create new user guid-C with provided display name/email
  2. Move all identity mappings from guid-A and guid-B -> guid-C
  3. Merge roles and permissions (union of both) -> guid-C
  4. Mark guid-A and guid-B as merged_into = guid-C
  5. All future JWTs use guid-C as sub
  6. Any API call or login that resolves to guid-A or guid-B transparently follows merged_into -> returns guid-C's data

Un-merge flow (POST /api/admin/users/:guid/unmerge):

  1. Restore original user record (clears merged_into)
  2. Move back identity mappings that originally belonged to this user
  3. The user becomes active again with its original GUID

Backup & Restore

Method Path Description
GET /api/admin/backup Download a consistent snapshot of the entire DB
POST /api/admin/restore Upload a DB snapshot to replace current data

GET /api/admin/backup — streams the BoltDB file as a binary download. Uses BoltDB's built-in consistent read snapshot, so it works while the server is running with zero downtime. Returns Content-Disposition: attachment; filename="auth-backup-2026-03-09.db".

POST /api/admin/restore — accepts a BoltDB file upload. Validates the file, swaps the current DB, and reloads. This is a destructive operation — the current data is replaced entirely.

For simple backup, you can also just copy {DATA_DIR}/auth.db while the server is stopped.


Storage

BoltDB database at {DATA_DIR}/auth.db. Single file, memory-mapped, pure Go.

Optimized for orgs up to 10K users. All lookups are key-based — no SQL, no query engine, just fast reads from a B+ tree.

Buckets

config                          # Key-value config
  key -> value (string)
  e.g. "default_roles" -> '["user"]'

ldap_providers                  # LDAP provider configs
  {provider_id} -> JSON {
    provider_id, name, url, base_dn, bind_dn, bind_password,
    user_filter, use_tls, skip_tls_verify,
    display_name_attr, email_attr, groups_attr,
    priority, created_at
  }

users                           # Users by GUID
  {guid} -> JSON {
    guid, password_hash, display_name, email,
    disabled, merged_into, created_at
  }

identity_mappings               # External ID -> user GUID
  {provider}:{external_id} -> {guid}
  e.g. "ldap:corp:kalahmad" -> "a1b2c3d4-..."

user_roles                      # Global roles
  {guid} -> JSON ["admin", "moderator"]

user_permissions                # Global permissions
  {guid} -> JSON ["can_delete_messages", "can_ban_users"]

refresh_tokens                  # Refresh token tracking (rotation + family revocation)
  {token_id} -> JSON {
    token_id, family_id, user_guid,
    used, expires_at, created_at
  }

audit_log                       # Append-only security event log
  {timestamp}:{event_id} -> JSON {
    id, timestamp, event, actor, ip, data
  }

Reverse Indexes

For lookups that go the "wrong direction" (e.g., find all mappings for a GUID):

idx_mappings_by_guid            # GUID -> list of mappings
  {guid} -> JSON [{"provider":"ldap:corp","external_id":"kalahmad"}, ...]

These are maintained on write — when a mapping is created, both identity_mappings and idx_mappings_by_guid are updated in the same BoltDB transaction (atomic).

RSA Keys

RSA-2048 key pair stored at:

  • {DATA_DIR}/private.pem
  • {DATA_DIR}/public.pem

Auto-generated on first start if not present.


Admin UI (Embedded)

Single-page app embedded in the Go binary via go:embed. Served at / (root).

Pages

  • Dashboard — overview: user count, recent logins
  • Users — list/search users, view GUID, edit profile, manage roles/permissions, view all identity mappings
  • LDAP Providers — add/configure multiple AD/LDAP directories, test connectivity, set priority
  • Identity Mappings — browse/search all mappings across providers
  • Impersonation — select a user, generate impersonated token
  • Login Page — clean login form that apps can redirect to (supports redirect_uri query param)

Tech

  • Preact + Tailwind CSS
  • Built to static files at build time
  • Embedded into Go binary via //go:embed ui/dist/*
  • Served by the same HTTP server on the same port
  • API calls go to /api/*, UI serves from /

Environment Variables

Variable Default Description
AUTH_PORT 9090 Listen port
AUTH_DATA_DIR ./data Directory for BoltDB file and RSA keys
AUTH_ADMIN_KEY (required) Bootstrap admin API key for admin endpoints
AUTH_CLIENT_ID (optional) OIDC client ID for this instance
AUTH_CLIENT_SECRET (optional) OIDC client secret for this instance
AUTH_REDIRECT_URIS (optional) Comma-separated list of allowed redirect URIs
AUTH_JWT_ISSUER simpleauth JWT iss claim
AUTH_JWT_ACCESS_TTL 8h Access token lifetime
AUTH_JWT_REFRESH_TTL 720h Refresh token lifetime (30 days)
AUTH_IMPERSONATE_TTL 1h Impersonated token lifetime
AUTH_KRB5_KEYTAB (optional) Path to Kerberos keytab file
AUTH_KRB5_REALM (optional) Kerberos realm (e.g., CORP.LOCAL)
AUTH_TLS_CERT (optional) Path to TLS certificate (if not using nginx)
AUTH_TLS_KEY (optional) Path to TLS private key (if not using nginx)
AUTH_AUDIT_RETENTION 90d How long to keep audit log entries

Project Structure

simpleauth/
  main.go
  go.mod
  Dockerfile
  .env.example
  build.md
  ui/                          # Frontend source
    src/
      index.html
      app.jsx
      pages/
        Dashboard.jsx
        Users.jsx
        LdapProviders.jsx
        Mappings.jsx
        Impersonate.jsx
        Login.jsx
      components/
        Layout.jsx
        Table.jsx
        Modal.jsx
    tailwind.config.js
    package.json
    dist/                      # Built static files (embedded)
  internal/
    config/config.go           # Bootstrap config from env vars
    store/store.go             # BoltDB store (all buckets, CRUD)
    auth/
      jwt.go                   # RSA key management, JWT sign/verify, JWKS
      ldap.go                  # LDAP bind + attribute fetch
      local.go                 # bcrypt password verification
      spnego.go                # Kerberos/SPNEGO (optional)
    handler/
      auth.go                  # Login, refresh, userinfo, negotiate, impersonate
      admin.go                 # User/mapping/role/permission CRUD
      admin_ldap.go            # LDAP provider CRUD endpoints
      middleware.go            # Admin key + SimpleAuthAdmin role validation
      ui.go                    # Serve embedded UI files

Docker

FROM node:22-alpine AS ui-build
WORKDIR /ui
COPY ui/package.json ui/package-lock.json ./
RUN npm ci
COPY ui/ .
RUN npm run build

FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
COPY --from=ui-build /ui/dist ./ui/dist
RUN go build -o /simpleauth .

FROM alpine:3.21
RUN apk add --no-cache ca-certificates
COPY --from=build /simpleauth /simpleauth
EXPOSE 9090
VOLUME /data
ENV AUTH_DATA_DIR=/data
ENTRYPOINT ["/simpleauth"]

Deployment Recommendation

Run behind nginx for TLS termination:

server {
    listen 443 ssl;
    server_name auth.corp.local;

    ssl_certificate     /etc/ssl/certs/auth.pem;
    ssl_certificate_key /etc/ssl/private/auth.key;

    location / {
        proxy_pass http://127.0.0.1:9090;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

SimpleAuth can also serve TLS directly via AUTH_TLS_CERT and AUTH_TLS_KEY for simpler setups.


Integration with Consuming Apps

Apps validate JWTs by fetching the public key from /.well-known/jwks.json. Users are always identified by GUID (sub claim).

Go middleware:

// Fetch JWKS once at startup
jwks := auth.FetchJWKS("http://simpleauth:9090/.well-known/jwks.json")

// Middleware: validate JWT on every request
e.Use(auth.JWTMiddleware(jwks))

// In handlers: user GUID from JWT
userGUID := auth.GetUserGUID(c)  // "a1b2c3d4-5678-..."

// Check roles/permissions
if !auth.HasRole(c, "admin") {
    return c.JSON(403, "forbidden")
}

Frontend integration:

// Login
const res = await fetch("/api/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ username: "khalefa", password: "secret" })
});
const { access_token, refresh_token } = await res.json();
localStorage.setItem("token", access_token);

// API calls — user is identified by GUID in the token
fetch("/api/v1/conversations", {
  headers: { Authorization: `Bearer ${access_token}` }
});

Bootstrap Flow

  1. Start SimpleAuth with AUTH_ADMIN_KEY=your-secret
  2. Server auto-generates RSA key pair, creates empty BoltDB
  3. Open the admin UI at http://localhost:9090
  4. Configure LDAP connection, test connectivity
  5. Create initial admin user, assign SimpleAuthAdmin role
  6. Set default roles for new users
  7. Users can now login via /api/auth/login with their usernames
  8. LDAP users are auto-created in BoltDB (with new GUID) on first login with default roles

Token Lifecycle

  1. User authenticates -> receives access_token (8h) + refresh_token (30d)
  2. Frontend sends access_token on every API request
  3. When access token expires, frontend calls POST /api/auth/refresh with refresh token
  4. Server issues new access token and a new refresh token (rotation)
  5. Old refresh token is invalidated — each refresh token is one-time use
  6. If a previously-used refresh token is resubmitted -> revoke the entire token family (likely stolen)
  7. If refresh token is expired -> user must login again

Refresh tokens are stored in BoltDB keyed by token ID, grouped into families (all tokens from the same login session). This enables detecting replay attacks.


Rate Limiting

Simple per-IP throttling on login endpoints. In-memory, no external dependencies.

  • POST /api/auth/login — max 10 attempts per IP per minute
  • GET /api/auth/negotiate — max 20 attempts per IP per minute
  • After limit exceeded -> 429 Too Many Requests with Retry-After header

Implementation: in-memory sliding window counter per IP. Automatically cleans up stale entries. Resets on server restart (intentional — keeps it simple).

Not a replacement for nginx rate limiting or fail2ban, but provides basic protection out of the box.


Audit Log

All security-relevant events are logged to a dedicated BoltDB bucket. Append-only, keyed by timestamp.

Logged Events

Event Logged Data
login_success user GUID, provider used, IP
login_failed username attempted, reason, IP
token_refresh user GUID
impersonation admin GUID, target GUID, IP
user_created new GUID, created_by
user_merged source GUIDs, merged GUID, merged_by
user_unmerged restored GUID, unmerged_by
role_changed user GUID, old roles, new roles, changed_by
permission_changed user GUID, old perms, new perms, changed_by
ldap_provider_added provider_id, added_by
kerberos_setup realm, SPN, setup_by

Storage

Each entry:

{
  "id": "evt-uuid",
  "timestamp": "2026-03-09T14:30:00Z",
  "event": "login_success",
  "actor": "a1b2c3d4-...",
  "ip": "10.0.1.50",
  "data": { "provider": "ldap:corp" }
}

Admin API

Method Path Description
GET /api/admin/audit Query audit log (paginated, filterable)

Query params: ?event=login_failed&from=2026-03-01&to=2026-03-09&user=guid&limit=100&offset=0

Retention

Configurable via AUTH_AUDIT_RETENTION env var (default 90d). A background goroutine prunes old entries daily.