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
Table of contents
Section titled “Table of contents”- What this service does
- What it explicitly does not do
- Architecture at a glance
- Technology stack
- Repository layout
- Data ownership (Auth DB vs Finance)
- HTTP API surface
- Authentication and authorization models
- Configuration reference
- How to run locally
- Docker and Compose
- Nginx reverse proxy and TLS (auth.kisum.io)
- Database migrations
- sqlc and generated code
- Development workflow
- Testing
- Observability and health
- Security notes
- Troubleshooting
- Further reading
1. What this service does
Section titled “1. What this service does”| Area | Behavior |
|---|---|
| Identity | Stores internal users in Auth PostgreSQL (users): email, Argon2id password hash, global_role, token_version, profile fields, legacy ID slots. |
| Sessions | Creates and rotates sessions with hashed refresh tokens; supports revoke, revoke-all, Redis-backed revocation markers and session cache. |
| Tokens | Issues RS256 access JWTs with RFC-aligned claims; verifies tokens and enforces session validity and token_version against Postgres (and Redis hints). |
| Discovery | Exposes JWKS at /.well-known/jwks.json for verifiers. |
| Password reset | Request and confirm flows with stored reset tokens; invalidates sessions on success per policy. |
| Rate limiting | Login rate limit via Redis (configurable fail-open vs fail-closed). |
| Internal ops | Machine 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). |
| Migrations | On startup, applies SQL migrations from MIGRATIONS_PATH (default migrations/) using golang-migrate. |
| Optional Finance DB | If 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.
2. What it explicitly does not do
Section titled “2. What it explicitly does not do”- 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.
3. Architecture at a glance
Section titled “3. Architecture at a glance”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.
4. Technology stack
Section titled “4. Technology stack”| Layer | Choice |
|---|---|
| Language | Go 1.25+ (go.mod) |
| Router | chi v5 |
| Database | PostgreSQL (via pgx pool) |
| Queries | sqlc → internal/repository/postgres/db |
| Migrations | golang-migrate (embedded driver, file source) |
| Cache / limits | Redis (go-redis v9) |
| JWT | golang-jwt/jwt v5, RS256, JWKS |
| Passwords | Argon2id (golang.org/x/crypto) |
| Config | Environment variables; optional .env via joho/godotenv |
5. Repository layout
Section titled “5. Repository layout”| Path | Purpose |
|---|---|
cmd/api/main.go | Process 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. |
Dockerfile | Multi-stage production-oriented image. |
docker-compose.yml | API container only; use .env for external DB/Redis. |
docker-compose.local-deps.yml | Optional bundled Postgres + Redis for local full-stack. |
6. Data ownership (Auth DB vs Finance)
Section titled “6. Data ownership (Auth DB vs Finance)”- Auth DB (this service’s
DATABASE_URL):users,sessions,auth_audit_logs,password_reset_tokens,company_memberships,business_unit_memberships, etc. Seemigrations/. - 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 whichcompany_id/business_unit_idUUIDs) 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).
7. HTTP API surface
Section titled “7. HTTP API surface”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).
Global
Section titled “Global”| Method | Path | Description |
|---|---|---|
GET | /health | Liveness: process is up. |
GET | /ready | Readiness: Postgres (Auth), optional Finance (if configured), Redis ping. |
GET | /.well-known/jwks.json | JWKS document for JWT verification. |
Public auth — /auth
Section titled “Public auth — /auth”All bodies are Content-Type: application/json unless noted.
| Method | Path | Auth | Description |
|---|---|---|---|
POST | /auth/register | None | Public 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/login | None | Internal user login (email/password); vendor flows may be limited per deployment. Fails with pending_approval / registration_rejected / account_inactive when applicable. |
POST | /auth/refresh | None | Refresh token rotation + new access token. |
POST | /auth/logout | None | Logout (requires refresh token in body). |
POST | /auth/logout-all | Bearer access token | Revoke all sessions for the user in the token. No body. |
GET | /auth/me | Bearer access token | Current user from token + DB, including companyMemberships and businessUnitMemberships arrays (see OpenAPI). No body. |
POST | /auth/password-reset/request | None | Start password reset. |
POST | /auth/password-reset/confirm | None | Complete 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.
accountType | Behavior |
|---|---|
"" (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 value | 400 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).
Internal — /internal
Section titled “Internal — /internal”A) User administration (RFC §5.16) — requires:
- Header:
Authorization: Bearer <access_token> - Token must represent an active user; RBAC (platform
globalRoleand/orcompany_memberships) is enforced per route in the service. SeeREADME_AUTH_API.md§5.1. Step-by-step runbook (create user → company membership → login): §5.2.
| Method | Path | Description |
|---|---|---|
GET | /internal/users | List users (filters/pagination). Platform staff (PLATFORM_ADMIN, PLATFORM_MODERATOR, PLATFORM_SUPERADMIN). |
POST | /internal/users | Create 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}/users | List users with membership in that company (includes nested memberships per user for that company). |
POST | /internal/companies/{companyId}/memberships | Upsert tenant membership (userId, role). |
GET | /internal/companies/{companyId}/business-units/{businessUnitId}/users | Users with an active BU membership in that company and BU. |
POST | /internal/companies/{companyId}/business-units/{businessUnitId}/memberships | Upsert 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:
| Method | Path | Description |
|---|---|---|
POST | /internal/sessions/{id}/revoke | Revoke a single session. No body (session id is in the path). |
POST | /internal/users/{id}/revoke-all | Revoke 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}/context | User 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”- Public endpoints (
/health, parts of/auth): no principal. - Bearer JWT (
Authorization: Bearer): access token verified with RSA public key; claims validated; session andtoken_versionchecked against Postgres (Redis used for acceleration/revocation markers). - Internal API key: single shared secret in
INTERNAL_API_KEYfor automation — only for the routes in §7B, not for/internal/usersCRUD. - Internal user management: Bearer token; server checks platform roles (
PLATFORM_ADMIN,PLATFORM_MODERATOR,PLATFORM_SUPERADMIN) and company / BU memberships (company_memberships,business_unit_memberships) perREADME_AUTH_API.md§5.1.
9. Configuration reference
Section titled “9. Configuration reference”All variables are read from the process environment. A .env file in the working directory is loaded automatically if present (godotenv).
| Variable | Required | Default | Meaning |
|---|---|---|---|
HOST | No | 0.0.0.0 | Bind address. |
PORT | No | 3097 | Listen port. |
DATABASE_URL | Yes* | Local dev default | Auth PostgreSQL connection string. |
REDIS_URL | Yes* | redis://localhost:6379/0 | Redis URL for cache/rate limits/revocation. |
FINANCE_DATABASE_URL | No | empty | Optional Finance Postgres for company resolve. |
JWT_SIGNING_KEY_PEM | Yes** | empty | RSA private key PEM — required here to sign access tokens (RS256). Never share this value; treat it like a root secret. |
JWT_PUBLIC_KEY_PEM | No | derived automatically | RSA 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_KID | No | default | Key id for JWKS. |
JWT_ISSUER | No | auth.primuse.dev | JWT iss. |
JWT_AUDIENCE | No | primuse-apps | JWT aud. |
ACCESS_TOKEN_TTL | No | 24h | Access token lifetime (Go time.ParseDuration). |
REFRESH_TOKEN_TTL | No | 720h | Refresh/session horizon for new sessions. |
APP_ENV | No | empty | If development and JWT_SIGNING_KEY_PEM empty, generates ephemeral RSA keys (dev only). |
INTERNAL_API_KEY | No | empty | Enables §7B internal routes when set. |
PUBLIC_REGISTRATION_ENABLED | No | false | If true, enables POST /auth/register (self-serve; users stay PENDING until platform staff approve via PATCH). |
MIGRATIONS_PATH | No | migrations | Directory of golang-migrate files (in container often /app/migrations). |
ARGON2_TIME | No | 3 | Argon2id time parameter. |
ARGON2_MEMORY | No | 65536 | Memory (KB). |
ARGON2_THREADS | No | 4 | Threads. |
ARGON2_KEYLEN | No | 32 | Key length. |
RATE_LIMIT_FAIL_CLOSED_LOGIN | No | true | If true, login rate limit fails closed when Redis is unavailable. |
EXPOSE_PASSWORD_RESET_TOKEN | No | false | Dev-only: return reset token in API (dangerous in prod). |
EMAIL_ENABLED | No | true | If false, no outbound email (SES calls skipped). |
FRONTEND_URL | No | https://app.kisum.io | Web app base URL for password-reset links and email buttons. |
SES_AWS_REGION | No† | empty | AWS region for SES (e.g. ap-southeast-1). |
SES_AWS_ACCESS_KEY_ID | No† | empty | IAM access key with ses:SendEmail (or scoped policy). |
SES_AWS_SECRET_ACCESS_KEY | No† | empty | Secret for the access key. |
EMAIL_FROM | No† | empty | Sender address verified in SES (e.g. no-reply@kisum.io). |
EMAIL_APP_NAME | No | Kisum Auth | Display name in email subjects and body. |
EMAIL_LOGO_URL | No | empty | Optional 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.
10. How to run locally
Section titled “10. How to run locally”Prerequisites
Section titled “Prerequisites”- 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).
-
Clone the repository and
cdinto it. -
Create a database for Auth (e.g.
auth) and a user with rights to create theschema_migrationstable and apply DDL (see Troubleshooting if you hitpermission denied for schema public). -
Environment — create
.envin the repo root (never commit it). Load order: the app reads process environment; a.envfile 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=developmentand omitJWT_SIGNING_KEY_PEM(ephemeral key each restart).Option B — stable signing (prod or long-lived local): set
APP_ENV=production(or anything other thandevelopment). Put the private key inJWT_SIGNING_KEY_PEMonly — the file fromopenssl genrsa(starts withBEGIN PRIVATE KEYorBEGIN RSA PRIVATE KEY). Do not put the public key here.APP_ENV=productionDATABASE_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_PEMto the private key (signing). LeaveJWT_PUBLIC_KEY_PEMunset 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. -
Install modules (if needed):
Terminal window go mod download -
Run the API from the module root (so
.envandmigrations/resolve):Terminal window go run ./cmd/api -
Smoke checks:
Terminal window curl -sS http://localhost:3097/healthcurl -sS http://localhost:3097/readycurl -sS http://localhost:3097/.well-known/jwks.json | head
First platform admin user
Section titled “First platform admin user”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 asinternal/auth(see tests forHashPassword), 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.
11. Docker and Compose
Section titled “11. Docker and Compose”Build image
Section titled “Build image”docker build -t kisum-auth:local .Optional build arg (default ./cmd/api):
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/migrationsand setsMIGRATIONS_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):
docker run --rm -p 3097:3097 --env-file .env kisum-auth:localCompose
Section titled “Compose”- Production-style (external DB + Redis): put
JWT_SIGNING_KEY_PEM(multiline quoted PEM),DATABASE_URL, andREDIS_URLin.envnext todocker-compose.yml, thendocker 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.envwithout external URLs (or defaults) so the API uses the bundled services, or keep your.envand the API will still followDATABASE_URL/REDIS_URLfrom 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.
12.1 Prerequisites
Section titled “12.1 Prerequisites”-
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.iopointing 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_AUDIENCEso tokens and JWKS match what consumers expect forhttps://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.
-
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 -
Store API credentials outside version control, with strict permissions:
Terminal window mkdir -p ~/.secrets/certbotchmod 700 ~/.secrets/certbotCreate
~/.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.iodns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKENTerminal window chmod 600 ~/.secrets/certbot/cloudflare.ini -
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.comFor 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-interactiveCertificates and keys are usually stored under
/etc/letsencrypt/live/auth.kisum.io/. -
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-runAdd 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:
sudo certbot --nginx -d auth.kisum.ioCertbot will adjust Nginx snippets for SSL. This README still assumes you align proxy_pass with the Docker app on 3097 as below.
12.4 Nginx site configuration
Section titled “12.4 Nginx site configuration”Place a file such as /etc/nginx/sites-available/09-auth.kisum.io.conf, then enable it:
sudo ln -sf /etc/nginx/sites-available/09-auth.kisum.io.conf /etc/nginx/sites-enabled/sudo nginx -t && sudo systemctl reload nginxExample: 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(notlocalhost) if your system resolveslocalhostto IPv6 first and the app listens on IPv4 only. X-Forwarded-Protoshould behttpsso the app and any upstream logic see the external scheme (JWT issuer URLs in docs often use the public HTTPS host).mapforConnectionavoids duplicatingupgradelogic; you can instead keepproxy_set_header Connection 'upgrade';for simple setups.- Tune timeouts and
client_max_body_sizeto your API usage.
12.5 Verify
Section titled “12.5 Verify”curl -sS https://auth.kisum.io/healthcurl -sS https://auth.kisum.io/readycurl -sS https://auth.kisum.io/.well-known/jwks.json | headEnsure 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.
13. Database migrations
Section titled “13. Database migrations”- Tool:
golang-migratefile 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:
migrate -path migrations -database "$DATABASE_URL" upThe 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).
# Preview counts without writinggo run ./cmd/finance-migrate -dry-run
# Import (set URLs; optional MIGRATE_* overrides)FINANCE_DATABASE_URL="postgres://..." DATABASE_URL="postgres://..." go run ./cmd/finance-migrateFinance 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).
14. sqlc and generated code
Section titled “14. sqlc and generated code”- Config:
sqlc.yaml— queries inquery/, schema frommigrations/*.up.sql, output packageinternal/repository/postgres/db.
Regenerate after editing SQL in query/:
go run github.com/sqlc-dev/sqlc/cmd/sqlc@v1.29.0 generateCommit generated files so Docker builds do not require sqlc.
15. Development workflow
Section titled “15. Development workflow”- Create a branch.
- Change code; for SQL changes update
query/*.sqland run sqlc generate. - Add migrations if schema changes: new
NNNNNN_name.up.sql/.down.sql. - Run
go fmt ./...,go vet ./...,go test ./.... - Run the server locally and exercise
/health,/ready, auth flows, and internal routes appropriate to your role/keys.
16. Testing
Section titled “16. Testing”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.
17. Observability and health
Section titled “17. Observability and health”- Logging: Structured logs via
log/slog(seeinternal/observability/logger.go). /health: Process alive./ready: Dependency checks — Auth DB, Redis, and Finance DB if configured.
18. Security notes
Section titled “18. Security notes”- Secrets: Never commit
.env; rotate any key that was pasted into tickets or chat. - JWT: Production must use strong RSA keys and stable
JWT_KIDrotation policies as per your RFC/ops runbooks. - Internal API key: Treat
INTERNAL_API_KEYlike 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.
19. Troubleshooting
Section titled “19. Troubleshooting”| Symptom | Likely cause |
|---|---|
JWT_SIGNING_KEY_PEM is required | Set PEM or APP_ENV=development for local ephemeral keys. |
connection refused to Postgres/Redis | Wrong 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 resolve | FINANCE_DATABASE_URL unset; expected unless you use Finance integration. |
| Internal routes 401/403 | Wrong 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 user | Email already exists. |
20. Further reading
Section titled “20. Further reading”| Document | Content |
|---|---|
README_AUTH_API.md | Integration guide for FE/BE teams: all endpoints, envelopes, error codes, JWT/JWKS, checklists. |
docs/openapi.yaml | OpenAPI 3.0 specification (paths, bodies, security schemes, schemas). Import into Swagger UI / Postman / codegen. |
docs/RFC.md / docs/README_RFC.md | Architecture, token contract, §17 ownership, §5.16 admin rules. |
docs/SYSTEM.md | System-level design notes. |
docs/Backend_Implementation.md | Workstreams and ticket-style breakdown from RFC to delivery. |
docs/DOCS.md | Broader platform documentation index (if maintained). |
License
Section titled “License”See LICENSE in the repository root.