Skip to content

Auth Platform Backend

Central authentication service for the Primuse platform: identity, sessions, JWT access and refresh tokens, JWKS, password reset, and internal admin APIs. It implements the unified auth RFC (see docs/) with an Auth-owned PostgreSQL schema (users, sessions, audit) and optional read-only integration with a separate Finance database for company resolution.

Module: github.com/Primuse-Pte-Ltd/Backend-Kisum-Auth
Default listen address: 0.0.0.0:3097


  1. What this service does
  2. What it explicitly does not do
  3. Architecture at a glance
  4. Technology stack
  5. Repository layout
  6. Data ownership (Auth DB vs Finance)
  7. HTTP API surface
  8. Authentication and authorization models
  9. Configuration reference
  10. How to run locally
  11. Docker and Compose
  12. Nginx reverse proxy and TLS (auth.kisum.io)
  13. Database migrations
  14. sqlc and generated code
  15. Development workflow
  16. Testing
  17. Observability and health
  18. Security notes
  19. Troubleshooting
  20. Further reading

AreaBehavior
IdentityStores internal users in Auth PostgreSQL (users): email, Argon2id password hash, global_role, token_version, profile fields, legacy ID slots.
SessionsCreates and rotates sessions with hashed refresh tokens; supports revoke, revoke-all, Redis-backed revocation markers and session cache.
TokensIssues RS256 access JWTs with RFC-aligned claims; verifies tokens and enforces session validity and token_version against Postgres (and Redis hints).
DiscoveryExposes JWKS at /.well-known/jwks.json for verifiers.
Password resetRequest and confirm flows with stored reset tokens; invalidates sessions on success per policy.
Rate limitingLogin rate limit via Redis (configurable fail-open vs fail-closed).
Internal opsMachine routes (session revoke, user context, company resolve) protected by X-Internal-API-Key when configured. User administration (create / update / deactivate users) requires a valid Bearer access token and an appropriate platform globalRole (see README_AUTH_API.md §5.1).
MigrationsOn startup, applies SQL migrations from MIGRATIONS_PATH (default migrations/) using golang-migrate.
Optional Finance DBIf FINANCE_DATABASE_URL is set, resolves company identifiers for GET /internal/companies/resolve; otherwise that route returns a clear “not configured” error.

Startup order: load config → migrate up → connect Postgres → connect Redis → connect optional Finance pool → build JWT issuer/verifier → serve HTTP → graceful shutdown on SIGINT/SIGTERM.


  • It is not the system of record for company or business-unit master data, vendors, or Finance business rules. Company/BU membership rows (user ↔ tenant/BU role links) are stored in Auth; Finance remains authoritative for master records and domain authorization beyond those links (RFC §17).
  • Vendor login as a full product flow may be deferred or return not implemented depending on deployment; internal internal users are the primary Auth-DB-backed flow.
  • It does not issue HMAC “API secrets” for JWT: signing uses RSA PEM keys (JWT_SIGNING_KEY_PEM).
  • Environment variables such as legacy JWT_SECRET (symmetric) are not read by this codebase; configure PEM-based JWT settings documented below.

flowchart LR
  subgraph clients [Clients]
    FE[Web / mobile apps]
    GW[API gateways]
    SVC[Backend services]
  end

  subgraph auth [Auth service]
    HTTP[Chi HTTP router]
    SVC_LAYER[Service layer]
    REPO_PG[Postgres repos sqlc]
    REPO_R[Redis repos]
    REPO_F[Optional Finance DB]
  end

  PG[(Auth PostgreSQL)]
  R[(Redis)]
  FPG[(Finance PostgreSQL optional)]

  FE --> HTTP
  GW --> HTTP
  SVC --> HTTP
  HTTP --> SVC_LAYER
  SVC_LAYER --> REPO_PG
  SVC_LAYER --> REPO_R
  SVC_LAYER --> REPO_F
  REPO_PG --> PG
  REPO_R --> R
  REPO_F --> FPG
  • HTTP: Public /auth/*, internal /internal/*, GET /health, GET /ready, GET /.well-known/jwks.json.
  • Auth PostgreSQL: Users, sessions, password reset tokens, auth audit logs.
  • Redis: Session cache, revocation flags, rate limits, optional company-resolution cache.
  • Finance PostgreSQL (optional): Read-only helpers (e.g. company UUID resolution); not required for core login/refresh.

LayerChoice
LanguageGo 1.25+ (go.mod)
Routerchi v5
DatabasePostgreSQL (via pgx pool)
Queriessqlcinternal/repository/postgres/db
Migrationsgolang-migrate (embedded driver, file source)
Cache / limitsRedis (go-redis v9)
JWTgolang-jwt/jwt v5, RS256, JWKS
PasswordsArgon2id (golang.org/x/crypto)
ConfigEnvironment variables; optional .env via joho/godotenv

PathPurpose
cmd/api/main.goProcess entry: config, migrate, pools, router, graceful shutdown.
internal/config/Environment-based configuration.
internal/http/Chi router, middleware (bearer, internal key, rate limit, principal).
internal/http/handlers/HTTP handlers (auth, health, internal, …).
internal/service/Use cases: login, refresh, logout, password reset, admin users, audit helpers.
internal/auth/JWT issue/verify, JWKS, password hashing, claim helpers.
internal/repository/postgres/Store + sqlc Queries wrapper.
internal/repository/postgres/db/Generated sqlc code (do not hand-edit).
internal/repository/redis/Redis session/revoke/rate-limit helpers.
internal/repository/finance/Optional Finance DB access.
internal/dbmigrate/Up() wrapper around golang-migrate.
internal/observability/Structured logging.
migrations/SQL up/down migrations.
query/sqlc SQL query files.
docs/RFC, system docs, implementation breakdowns.
DockerfileMulti-stage production-oriented image.
docker-compose.ymlAPI container only; use .env for external DB/Redis.
docker-compose.local-deps.ymlOptional bundled Postgres + Redis for local full-stack.

  • Auth DB (this service’s DATABASE_URL): users, sessions, auth_audit_logs, password_reset_tokens, company_memberships, business_unit_memberships, etc. See migrations/.
  • Finance DB (FINANCE_DATABASE_URL, optional): company and business-unit master data remain authoritative there. Auth stores membership links (which user has which role for which company_id / business_unit_id UUIDs) without cross-database foreign keys; IDs are aligned with Finance by contract.

For the full ownership model, see docs/RFC.md or docs/README_RFC.md (§17).


Base URL in examples: http://localhost:3097. Responses are JSON with a standard envelope: success payloads under data, errors under error with code and message. Client integrators (frontend / other backends): see README_AUTH_API.md for endpoints, errors, JWT, and checklists. Machine-readable contract: docs/openapi.yaml (OpenAPI 3).

MethodPathDescription
GET/healthLiveness: process is up.
GET/readyReadiness: Postgres (Auth), optional Finance (if configured), Redis ping.
GET/.well-known/jwks.jsonJWKS document for JWT verification.

All bodies are Content-Type: application/json unless noted.

MethodPathAuthDescription
POST/auth/registerNonePublic self-serve sign-up (requires PUBLIC_REGISTRATION_ENABLED=true). Creates user with approvalStatus: PENDING, isActive: false; no tokens until platform staff approve (see below).
POST/auth/loginNoneInternal user login (email/password); vendor flows may be limited per deployment. Fails with pending_approval / registration_rejected / account_inactive when applicable.
POST/auth/refreshNoneRefresh token rotation + new access token.
POST/auth/logoutNoneLogout (requires refresh token in body).
POST/auth/logout-allBearer access tokenRevoke all sessions for the user in the token. No body.
GET/auth/meBearer access tokenCurrent user from token + DB, including companyMemberships and businessUnitMemberships arrays (see OpenAPI). No body.
POST/auth/password-reset/requestNoneStart password reset.
POST/auth/password-reset/confirmNoneComplete password reset.

Request body examples

// POST /auth/register (optional phoneNumber, profilePictureUrl, authProvider)
{
"email": "user@example.com",
"password": "at-least-8-chars",
"fullName": "Display Name",
"phoneNumber": "",
"profilePictureUrl": "",
"authProvider": "password"
}

authProvider defaults to password if omitted. Same enum as internal user create (google, microsoft, sso, other reserved for future OAuth).

Approval flow: after register, platform staff call PATCH /internal/users/{id} with approvalStatus: "APPROVED" and isActive: true so the user can log in. To reject, use approvalStatus: "REJECTED" (login remains blocked).

// POST /auth/login
{ "email": "user@example.com", "password": "string", "accountType": "" }

accountType on login (vs globalRole): accountType selects which login pipeline runs — mainly where the identity is resolved — not whether the user is an admin.

accountTypeBehavior
"" (omit or empty), "internal", "auto"Email/password against Auth DB users (users table). Use this for normal app users and for platform staff alike.
"vendor"Reserved for a vendor / Finance-backed flow; not implemented in this service (returns 501).
Any other value400 validation error.

Authorization (NONE / PLATFORM_SUPERADMIN / PLATFORM_ADMIN / PLATFORM_MODERATOR, etc.) is not chosen in the login body. It comes from the user row in Postgres (global_role) and appears in the JWT as globalRole after a successful login. There is no accountType: "admin" — admins are still "internal" (or empty) logins whose account has the appropriate global_role.

// POST /auth/refresh
{ "refreshToken": "opaque-refresh-token" }
// POST /auth/logout
{ "refreshToken": "opaque-refresh-token" }
// POST /auth/password-reset/request
{ "email": "user@example.com", "accountType": "" }
// POST /auth/password-reset/confirm
{ "token": "reset-token-from-email-or-dev-response", "newPassword": "min-length-per-policy" }

Login may be wrapped with Redis rate limiting when Redis is available (currently 30 requests per minute per IP in cmd/api/main.go, subject to change).

A) User administration (RFC §5.16) — requires:

  • Header: Authorization: Bearer <access_token>
  • Token must represent an active user; RBAC (platform globalRole and/or company_memberships) is enforced per route in the service. See README_AUTH_API.md §5.1. Step-by-step runbook (create user → company membership → login): §5.2.
MethodPathDescription
GET/internal/usersList users (filters/pagination). Platform staff (PLATFORM_ADMIN, PLATFORM_MODERATOR, PLATFORM_SUPERADMIN).
POST/internal/usersCreate user (201). PLATFORM_ADMIN or PLATFORM_SUPERADMIN only. Admin-created users default to approvalStatus: APPROVED.
PATCH/internal/users/{id}Partial update; omitted JSON fields stay unchanged. Platform moderator: approvalStatus only; company roles: see docs.
DELETE/internal/users/{id}Soft-delete: is_active = false, revoke sessions, bump token_version. No body.
GET/internal/companies/{companyId}/usersList users with membership in that company (includes nested memberships per user for that company).
POST/internal/companies/{companyId}/membershipsUpsert tenant membership (userId, role).
GET/internal/companies/{companyId}/business-units/{businessUnitId}/usersUsers with an active BU membership in that company and BU.
POST/internal/companies/{companyId}/business-units/{businessUnitId}/membershipsUpsert BU membership (userId, role, optional isActive).

Request body examples (Content-Type: application/json)

globalRole values: NONE, PLATFORM_SUPERADMIN, PLATFORM_ADMIN, PLATFORM_MODERATOR. authProvider values: password, google, microsoft, sso, other. approvalStatus values: PENDING, APPROVED, REJECTED. Omitted globalRole / authProvider on create default to NONE / password. Omitted isActive defaults to true.

// POST /internal/users
{
"email": "new.user@example.com",
"password": "at-least-8-chars",
"fullName": "Display Name",
"globalRole": "NONE",
"isActive": true,
"phoneNumber": null,
"profilePictureUrl": null,
"authProvider": "password"
}
// PATCH /internal/users/{id} — only include fields to change
{
"email": "renamed@example.com",
"password": "new-secret-if-changing",
"fullName": "New Name",
"globalRole": "PLATFORM_ADMIN",
"isActive": true,
"approvalStatus": "APPROVED",
"phoneNumber": "+1234567890",
"profilePictureUrl": "https://example.com/avatar.png",
"authProvider": "password"
}

B) Machine / support routes — require header X-Internal-API-Key: <INTERNAL_API_KEY> and are registered only if INTERNAL_API_KEY is non-empty in the environment:

MethodPathDescription
POST/internal/sessions/{id}/revokeRevoke a single session. No body (session id is in the path).
POST/internal/users/{id}/revoke-allRevoke all sessions for user; bump token_version. No body.
GET/internal/companies/resolve?raw=...Resolve company UUID from raw (requires Finance DB + optional Redis cache). Query string only.
GET/internal/users/{id}/contextUser context for support — same companyMemberships / businessUnitMemberships shape as /auth/me. No body.

If INTERNAL_API_KEY is unset, the API-key group is not mounted; user administration via Bearer (platform or company RBAC) still works so operators can bootstrap or administer without a static internal key.


8. Authentication and authorization models

Section titled “8. Authentication and authorization models”
  1. Public endpoints (/health, parts of /auth): no principal.
  2. Bearer JWT (Authorization: Bearer): access token verified with RSA public key; claims validated; session and token_version checked against Postgres (Redis used for acceleration/revocation markers).
  3. Internal API key: single shared secret in INTERNAL_API_KEY for automation — only for the routes in §7B, not for /internal/users CRUD.
  4. Internal user management: Bearer token; server checks platform roles (PLATFORM_ADMIN, PLATFORM_MODERATOR, PLATFORM_SUPERADMIN) and company / BU memberships (company_memberships, business_unit_memberships) per README_AUTH_API.md §5.1.

All variables are read from the process environment. A .env file in the working directory is loaded automatically if present (godotenv).

VariableRequiredDefaultMeaning
HOSTNo0.0.0.0Bind address.
PORTNo3097Listen port.
DATABASE_URLYes*Local dev defaultAuth PostgreSQL connection string.
REDIS_URLYes*redis://localhost:6379/0Redis URL for cache/rate limits/revocation.
FINANCE_DATABASE_URLNoemptyOptional Finance Postgres for company resolve.
JWT_SIGNING_KEY_PEMYes**emptyRSA private key PEM — required here to sign access tokens (RS256). Never share this value; treat it like a root secret.
JWT_PUBLIC_KEY_PEMNoderived automaticallyRSA public key PEM — optional for this service. If unset, the public key is derived from the private key for verification and JWKS. Set this only in unusual setups where verify must use different material than signing. Other apps that only verify JWTs need the public key (or they fetch JWKS from this service) — they must never receive the private key.
JWT_KIDNodefaultKey id for JWKS.
JWT_ISSUERNoauth.primuse.devJWT iss.
JWT_AUDIENCENoprimuse-appsJWT aud.
ACCESS_TOKEN_TTLNo24hAccess token lifetime (Go time.ParseDuration).
REFRESH_TOKEN_TTLNo720hRefresh/session horizon for new sessions.
APP_ENVNoemptyIf development and JWT_SIGNING_KEY_PEM empty, generates ephemeral RSA keys (dev only).
INTERNAL_API_KEYNoemptyEnables §7B internal routes when set.
PUBLIC_REGISTRATION_ENABLEDNofalseIf true, enables POST /auth/register (self-serve; users stay PENDING until platform staff approve via PATCH).
MIGRATIONS_PATHNomigrationsDirectory of golang-migrate files (in container often /app/migrations).
ARGON2_TIMENo3Argon2id time parameter.
ARGON2_MEMORYNo65536Memory (KB).
ARGON2_THREADSNo4Threads.
ARGON2_KEYLENNo32Key length.
RATE_LIMIT_FAIL_CLOSED_LOGINNotrueIf true, login rate limit fails closed when Redis is unavailable.
EXPOSE_PASSWORD_RESET_TOKENNofalseDev-only: return reset token in API (dangerous in prod).
EMAIL_ENABLEDNotrueIf false, no outbound email (SES calls skipped).
FRONTEND_URLNohttps://app.kisum.ioWeb app base URL for password-reset links and email buttons.
SES_AWS_REGIONNo†emptyAWS region for SES (e.g. ap-southeast-1).
SES_AWS_ACCESS_KEY_IDNo†emptyIAM access key with ses:SendEmail (or scoped policy).
SES_AWS_SECRET_ACCESS_KEYNo†emptySecret for the access key.
EMAIL_FROMNo†emptySender address verified in SES (e.g. no-reply@kisum.io).
EMAIL_APP_NAMENoKisum AuthDisplay name in email subjects and body.
EMAIL_LOGO_URLNoemptyOptional absolute URL for header image; if empty, {FRONTEND_URL}/logo_white.png is used.

*In production you always set explicit URLs and secrets.
**Or rely on APP_ENV=development for ephemeral keys only in non-production environments.

†Together, SES_* and EMAIL_FROM enable transactional email (registration pending, admin welcome, approval/rejection/deactivation, password reset link, password-changed notices) via AWS SES. If any are missing, the service logs a startup notice and skips email (API behavior unchanged). Failures to send are logged as warnings; HTTP handlers still return success when the underlying DB action succeeded. See README_AUTH_API.md §7.1.


  • Go 1.25 or newer (matches go.mod).
  • PostgreSQL 14+ (local or Docker).
  • Redis 6+ (local or Docker).
  • Optional: sqlc only if you change queries (go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 generate).
  1. Clone the repository and cd into it.

  2. Create a database for Auth (e.g. auth) and a user with rights to create the schema_migrations table and apply DDL (see Troubleshooting if you hit permission denied for schema public).

  3. Environment — create .env in the repo root (never commit it). Load order: the app reads process environment; a .env file is optional and is not a shell script (no $(cat ...) — that is ignored and breaks JWT parsing).

    Who creates the JWT key? You do, once: openssl genrsa -out jwt_private.pem 2048 (or 4096). The Docker image and Compose file do not generate keys; they only pass through whatever you put in env.

    Option A — quick local: APP_ENV=development and omit JWT_SIGNING_KEY_PEM (ephemeral key each restart).

    Option B — stable signing (prod or long-lived local): set APP_ENV=production (or anything other than development). Put the private key in JWT_SIGNING_KEY_PEM only — the file from openssl genrsa (starts with BEGIN PRIVATE KEY or BEGIN RSA PRIVATE KEY). Do not put the public key here.

    APP_ENV=production
    DATABASE_URL=postgres://...
    REDIS_URL=redis://...
    JWT_SIGNING_KEY_PEM="-----BEGIN PRIVATE KEY-----
    ...all lines from your generated private key file...
    -----END PRIVATE KEY-----"

    Private vs public: For this service, set JWT_SIGNING_KEY_PEM to the private key (signing). Leave JWT_PUBLIC_KEY_PEM unset unless you have an unusual split-key setup; the app derives the public key for verify/JWKS. For downstream APIs that only validate tokens, use the public key (or fetch JWKS from this host) — never the private key.

  4. Install modules (if needed):

    Terminal window
    go mod download
  5. Run the API from the module root (so .env and migrations/ resolve):

    Terminal window
    go run ./cmd/api
  6. Smoke checks:

    Terminal window
    curl -sS http://localhost:3097/health
    curl -sS http://localhost:3097/ready
    curl -sS http://localhost:3097/.well-known/jwks.json | head

User CRUD requires a Bearer token whose globalRole is allowed for the operation (see README_AUTH_API.md §5.1). Bootstrap strategies:

  • Insert a user row in Postgres with global_role = 'PLATFORM_SUPERADMIN' or 'PLATFORM_ADMIN' and a password hash produced with the same Argon2 parameters as internal/auth (see tests for HashPassword), then log in via /auth/login; or
  • Use a one-off script or migration seed (team policy) — production seeding should be governed by your security process.

Terminal window
docker build -t kisum-auth:local .

Optional build arg (default ./cmd/api):

Terminal window
docker build --build-arg MAIN_PATH=./cmd/api -t kisum-auth:local .

The image:

  • Builds a static Linux binary (CGO_ENABLED=0).
  • Copies migrations/ to /app/migrations and sets MIGRATIONS_PATH=/app/migrations.
  • Runs as non-root user app (uid 65532).
  • Exposes 3097 and includes a HEALTHCHECK against /health.

Run with the same .env as local (Compose and docker run inject env into the container; the image never creates keys):

Terminal window
docker run --rm -p 3097:3097 --env-file .env kisum-auth:local
  • Production-style (external DB + Redis): put JWT_SIGNING_KEY_PEM (multiline quoted PEM), DATABASE_URL, and REDIS_URL in .env next to docker-compose.yml, then docker compose up --build. This starts only the API container; it does not run Postgres or Redis on the host.
  • No PEM for local only: docker compose -f docker-compose.yml -f docker-compose.dev.yml up --build (ephemeral JWT; dev-only).
  • Bundled Postgres + Redis (local full stack): docker compose -f docker-compose.yml -f docker-compose.local-deps.yml up --build. Use a .env without external URLs (or defaults) so the API uses the bundled services, or keep your .env and the API will still follow DATABASE_URL / REDIS_URL from it.

./run-Docker_Composer.sh uses the API-only compose file. Pass local to add bundled Postgres and Redis (./run-Docker_Composer.sh local), and dev for the dev overlay (./run-Docker_Composer.sh dev or ./run-Docker_Composer.sh local dev).


12. Nginx reverse proxy and TLS (auth.kisum.io)

Section titled “12. Nginx reverse proxy and TLS (auth.kisum.io)”

This section describes a typical production-style setup: the auth API listens on port 3097 on the host (from docker run -p 3097:3097 or Compose), Nginx terminates HTTPS on the public host for auth.kisum.io, and Let’s Encrypt provides certificates. Adapt paths, users, and DNS to your environment.

  • A VPS or server with Nginx installed and ports 80 (ACME / HTTP-01 or redirects) and 443 (HTTPS) reachable from the internet (or reachable to Let’s Encrypt for the challenge type you use).

  • DNS: an A (and optionally AAAA) record for auth.kisum.io pointing to that server’s public IP.

  • Docker (or binary) already publishing the API on the host, for example:

    Terminal window
    docker compose up -d
    # API available at http://127.0.0.1:3097 on the same host as Nginx
  • JWT / env: In production, set stable RSA keys and correct JWT_ISSUER / JWT_AUDIENCE so tokens and JWKS match what consumers expect for https://auth.kisum.io.

12.2 Let’s Encrypt with Cloudflare DNS (DNS-01)

Section titled “12.2 Let’s Encrypt with Cloudflare DNS (DNS-01)”

Useful when you cannot expose port 80 on the origin, or you prefer DNS-01 validation. You need a Cloudflare API token (or legacy global key + email, depending on plugin version) with permission to edit DNS for kisum.io.

  1. Install Certbot and the Cloudflare DNS plugin (package names vary by OS), for example on Debian/Ubuntu:

    Terminal window
    sudo apt update && sudo apt install -y certbot python3-certbot-dns-cloudflare
  2. Store API credentials outside version control, with strict permissions:

    Terminal window
    mkdir -p ~/.secrets/certbot
    chmod 700 ~/.secrets/certbot

    Create ~/.secrets/certbot/cloudflare.ini (example for API token; check Certbot Cloudflare docs for the exact format your plugin expects):

    # Restrictive token: Zone → DNS → Edit for zone kisum.io
    dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN
    Terminal window
    chmod 600 ~/.secrets/certbot/cloudflare.ini
  3. Obtain / renew a certificate for auth.kisum.io:

    Terminal window
    sudo certbot certonly \
    --dns-cloudflare \
    --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \
    --dns-cloudflare-propagation-seconds 60 \
    -d "auth.kisum.io" \
    --agree-tos \
    --non-interactive \
    -m your-admin@example.com

    For a one-off forced reissue (use sparingly; watch Let’s Encrypt rate limits):

    Terminal window
    sudo certbot certonly \
    --dns-cloudflare \
    --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \
    --force-renewal \
    -d "auth.kisum.io" \
    --agree-tos \
    --non-interactive

    Certificates and keys are usually stored under /etc/letsencrypt/live/auth.kisum.io/.

  4. Auto-renewal: Certbot installs a systemd timer or cron job. After renewal, Nginx must reload to pick up new files. Common pattern:

    Terminal window
    sudo certbot renew --dry-run

    Add a deploy hook if needed, e.g. sudo systemctl reload nginx (see Certbot documentation for --deploy-hook).

12.3 Alternative: HTTP-01 (Nginx plugin on the same host)

Section titled “12.3 Alternative: HTTP-01 (Nginx plugin on the same host)”

If port 80 on this server can answer for auth.kisum.io, you can use:

Terminal window
sudo certbot --nginx -d auth.kisum.io

Certbot will adjust Nginx snippets for SSL. This README still assumes you align proxy_pass with the Docker app on 3097 as below.

Place a file such as /etc/nginx/sites-available/09-auth.kisum.io.conf, then enable it:

Terminal window
sudo ln -sf /etc/nginx/sites-available/09-auth.kisum.io.conf /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Example: HTTPS only, proxy to the auth container bound to localhost:3097 (same host as Nginx):

# Optional: redirect HTTP → HTTPS (listen on 80 in a separate server block or include)
# server {
# listen 80;
# server_name auth.kisum.io;
# return 301 https://$host$request_uri;
# }
server {
listen 443 ssl http2;
server_name auth.kisum.io;
ssl_certificate /etc/letsencrypt/live/auth.kisum.io/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/auth.kisum.io/privkey.pem;
# include /etc/letsencrypt/options-ssl-nginx.conf; # if provided by certbot
# ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
location / {
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Server $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;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass_request_headers on;
proxy_redirect off;
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_busy_buffers_size 256k;
proxy_temp_file_write_size 256k;
client_max_body_size 100M;
client_body_buffer_size 128k;
proxy_read_timeout 300s;
proxy_connect_timeout 300s;
proxy_send_timeout 300s;
# Docker / local auth API on this host
proxy_pass http://127.0.0.1:3097;
}
}

Notes:

  • Use 127.0.0.1:3097 (not localhost) if your system resolves localhost to IPv6 first and the app listens on IPv4 only.
  • X-Forwarded-Proto should be https so the app and any upstream logic see the external scheme (JWT issuer URLs in docs often use the public HTTPS host).
  • map for Connection avoids duplicating upgrade logic; you can instead keep proxy_set_header Connection 'upgrade'; for simple setups.
  • Tune timeouts and client_max_body_size to your API usage.
Terminal window
curl -sS https://auth.kisum.io/health
curl -sS https://auth.kisum.io/ready
curl -sS https://auth.kisum.io/.well-known/jwks.json | head

Ensure firewall rules allow 443 (and 80 if you use redirects or HTTP-01). Restrict 3097 so it is not exposed publicly if Nginx is the only intended entry point.


  • Tool: golang-migrate file source, Postgres driver.
  • Files: migrations/<version>_<name>.up.sql / .down.sql.
  • When: Migrations run automatically on process start via dbmigrate.Up(DATABASE_URL, MIGRATIONS_PATH).

Manual apply (optional), from repo root:

Terminal window
migrate -path migrations -database "$DATABASE_URL" up

The database user must be able to create the schema_migrations table in public (or your chosen schema). Managed Postgres often requires explicit GRANT on public for application roles.

Finance → Auth import (users + memberships)

Section titled “Finance → Auth import (users + memberships)”

Use the finance-migrate command to copy users and merged company / BU membership rows from a Prisma Finance PostgreSQL database into Auth (run against staging first; Finance DB user should be read-only).

Terminal window
# Preview counts without writing
go run ./cmd/finance-migrate -dry-run
# Import (set URLs; optional MIGRATE_* overrides)
FINANCE_DATABASE_URL="postgres://..." DATABASE_URL="postgres://..." go run ./cmd/finance-migrate

Finance table/column names match Prisma defaults ("User", "CompanyMembership", "BusinessUnitMembership" with camelCase columns). Users without a password receive an Argon2 placeholder hash (they must use password reset). If Auth already has a different users.id for the same email (e.g. test data), that row is deleted first so the Finance UUID can own the email (sessions/memberships for the old id cascade away).


  • Config: sqlc.yaml — queries in query/, schema from migrations/*.up.sql, output package internal/repository/postgres/db.

Regenerate after editing SQL in query/:

Terminal window
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 generate

Commit generated files so Docker builds do not require sqlc.


  1. Create a branch.
  2. Change code; for SQL changes update query/*.sql and run sqlc generate.
  3. Add migrations if schema changes: new NNNNNN_name.up.sql / .down.sql.
  4. Run go fmt ./..., go vet ./..., go test ./....
  5. Run the server locally and exercise /health, /ready, auth flows, and internal routes appropriate to your role/keys.

Terminal window
go test ./...

Unit tests cover: internal/auth, internal/config, internal/errors, internal/httputil, internal/http (router), internal/http/handlers (health/ready), internal/http/middleware (bearer, internal API key, global role), internal/observability.

Packages without tests are mostly thin glue (cmd/api), generated sqlc (internal/repository/postgres/db), or layers that need Postgres/Redis (dbmigrate, service, repository/*) — add integration tests (e.g. Compose-backed CI) when you want end-to-end coverage.


  • Logging: Structured logs via log/slog (see internal/observability/logger.go).
  • /health: Process alive.
  • /ready: Dependency checks — Auth DB, Redis, and Finance DB if configured.

  • Secrets: Never commit .env; rotate any key that was pasted into tickets or chat.
  • JWT: Production must use strong RSA keys and stable JWT_KID rotation policies as per your RFC/ops runbooks.
  • Internal API key: Treat INTERNAL_API_KEY like a root credential for automation routes; scope network access (VPC, allowlists).
  • User admin: Prefer Bearer tokens for platform staff over sharing the internal key broadly; audit events are written for admin actions.
  • TLS: Terminate TLS at a reverse proxy or gateway in production; the Go server listens HTTP by default.

SymptomLikely cause
JWT_SIGNING_KEY_PEM is requiredSet PEM or APP_ENV=development for local ephemeral keys.
connection refused to Postgres/RedisWrong DATABASE_URL / REDIS_URL or service not running.
permission denied for schema public (migrate)DB role cannot create tables in public; grant USAGE,CREATE or run migrations as owner.
finance database not configured on company resolveFINANCE_DATABASE_URL unset; expected unless you use Finance integration.
Internal routes 401/403Wrong auth mode: user CRUD needs Bearer with sufficient platform globalRole; machine routes need X-Internal-API-Key and INTERNAL_API_KEY set on server.
409 on create userEmail already exists.

DocumentContent
README_AUTH_API.mdIntegration guide for FE/BE teams: all endpoints, envelopes, error codes, JWT/JWKS, checklists.
docs/openapi.yamlOpenAPI 3.0 specification (paths, bodies, security schemes, schemas). Import into Swagger UI / Postman / codegen.
docs/RFC.md / docs/README_RFC.mdArchitecture, token contract, §17 ownership, §5.16 admin rules.
docs/SYSTEM.mdSystem-level design notes.
docs/Backend_Implementation.mdWorkstreams and ticket-style breakdown from RFC to delivery.
docs/DOCS.mdBroader platform documentation index (if maintained).

See LICENSE in the repository root.