Skip to content

Codevertex SSO Integration Guide

Codevertex uses a single centralised SSO (Single Sign-On) service for all authentication. Every frontend delegates login/register entirely to the SSO — no service handles passwords or sessions independently.

Component Domain Role
auth-api (SSO server) sso.codevertexafrica.com Issues JWT tokens, manages sessions
auth-ui (login/register UI) accounts.codevertexafrica.com User-facing login/register forms
All other frontends *.codevertexafrica.com Consume SSO tokens

How it works today

Auth-api issues a JWT carrying roles and permissions (canonical codes, e.g. catalog:view, catalog:manage) at the top level of the token — not duplicated under user. The authorize URL supports tenant=<slug>, and token exchange prefers that tenant when the user is a member of it. GET /api/v1/auth/me is cached in Redis by user ID (TTL matches token expiry, or 24h) to keep repeated profile lookups cheap; frontends should cache it client-side with a similar TTL (TanStack Query, 5 min–24h).

Every Go backend uses JIT (just-in-time) tenant sync and user provisioning: on first login, global JWT roles (superuser, admin, staff) map automatically to that service's own local role (e.g. superuser → finance_admin in treasury, inventory_admin in inventory, pos_admin in POS), and service-level /auth/me merges the JWT claims with service-level RBAC roles/permissions from the local database.

Subscription enforcement is mutations-only, live across ordering-backend, logistics-api, treasury-api, inventory-api, pos-api, and projects-api — reads always pass through; only mutating requests require an active subscription. Core services (auth-api, subscriptions-api, notifications-api) don't enforce it directly; notifications instead rate-limits email by plan. A subscription 403 (code: subscription_inactive, upgrade: true) is distinguished from an auth 403 on the frontend, so an expired subscription shows an upgrade banner rather than bouncing the user back to the login screen — every frontend implements SubscriptionBanner, SubscriptionGate, and useSubscription() for this.


OIDC Authorization Code + PKCE Flow

Every frontend uses the same flow:

1. User clicks "Login" or "Sign Up" on any frontend
         ↓
2. Frontend generates PKCE: code_verifier + code_challenge (SHA-256)
         ↓
3. Frontend redirects to:
   https://sso.codevertexafrica.com/api/v1/authorize
     ?response_type=code
     &client_id=<service-client-id>
     &redirect_uri=<callback-url>
     &scope=openid profile email offline_access
     &state=<csrf-token>
     &code_challenge=<pkce-challenge>
     &code_challenge_method=S256
     &tenant=<slug>            ← optional, pre-selects tenant
         ↓
4. auth-api detects user is not authenticated, redirects to:
   https://accounts.codevertexafrica.com/login
     ?return_to=<full-authorize-url>
     &tenant=<slug>
   (auth-api does **not** pass client_id/redirect_uri as separate params; they are only inside return_to.)
         ↓
5. auth-ui handles login/register (email+password, Google, GitHub, Microsoft)
         ↓
6. auth-ui redirects back to return_to (the full authorize URL). This **must** be a **full page redirect** (window.location.href), not a client-side router.push, so the browser sends the session cookie to sso and the redirect loop is avoided. auth-ui validates return_to with isValidReturnUrl (allows same-origin relative paths and absolute URLs starting with NEXT_PUBLIC_API_URL / SSO issuer).
         ↓
7. auth-api now has authenticated user → generates auth code
   Redirects to: <redirect_uri>?code=<auth-code>&state=<csrf-token>
         ↓
8. Frontend callback page:
   a. Verify state matches stored value (CSRF check)
   b. POST /api/v1/token with code + code_verifier + client_id + redirect_uri
   c. Receive: access_token (JWT), id_token, refresh_token
         ↓
9. Bridge/sync screen:
   Poll service's own /me endpoint until user data is available
   (NATS events sync user from auth-service to each service's DB)
         ↓
10. SSO outlet selection (multi-outlet tenants only):
   If token response includes `requiresOutletSelection: true` (SSO-level flow):
   a. Store `ssoExchangeToken` in sessionStorage
   b. Navigate to `/{orgSlug}/auth/select-outlet`
   c. POST /api/v1/auth/select-outlet with { ssoExchangeToken, outletId }
   d. Receive final JWT with outlet claims (outlet_id, outlet_code, outlet_use_case, is_hq_user)
   Single-outlet tenants skip this step — outlet claims are embedded immediately.
         ↓
11. Service-level outlet preselection (ALL service frontends — pos-ui, inventory-ui, ordering-frontend, logistics-ui, treasury-ui, etc.):
   After receiving the final JWT with outlet claims:
   a. If `outlet_id` is non-empty AND `is_hq_user = false` (single-outlet staff):
      → Auto-preselect outlet from JWT without showing UI selector
      → Store outlet as `{service}-selected-outlet-id` in localStorage
      → Call `apiClient.setOutletID(outlet_id)` so X-Outlet-ID header is sent on every request
   b. If user IS HQ (`is_hq_user = true`) OR `outlet_id` is empty:
      → Check localStorage for a previously selected outlet
      → If stored outlet found: restore it via `apiClient.setOutletID(storedId)`, proceed
      → If no stored outlet: redirect to `/{orgSlug}/auth/select-outlet` (service-level selector page)
        The selector fetches from auth-api `GET /api/v1/tenants/{slug}/outlets` (no auth required)
        Last-used outlet is moved to the top of the list (TruLoad pattern)
        Single outlet → auto-selects, no UI shown
   c. On page reload: rehydrate outlet from localStorage → `apiClient.setOutletID(storedId)`
         ↓
12. Redirect to destination based on user role/scenario
   UI reads `outlet_use_case` from JWT to adapt sidebar and feature visibility.

Critical: Correct OIDC Endpoint Paths

CORRECT:

POST https://sso.codevertexafrica.com/api/v1/token
GET  https://sso.codevertexafrica.com/api/v1/authorize
GET  https://sso.codevertexafrica.com/api/v1/auth/logout?post_logout_redirect_uri=<url>  (clears session cookie, redirects to allowlisted URL or accounts)
POST https://sso.codevertexafrica.com/api/v1/auth/logout  (requires Bearer token; revokes session, returns JSON)
GET  https://sso.codevertexafrica.com/.well-known/openid-configuration

WRONG (will return 404):

/api/v1/auth/oidc/authorize   ← used by old frontends — DO NOT USE
/api/v1/auth/login            ← not an OIDC path
/api/v1/auth/oidc/token       ← wrong path

The correct paths are defined in auth-api/internal/httpapi/router.go.


Uniform SSO integration standard (all services)

To keep the ecosystem maintainable, every frontend and backend that integrates with SSO must follow the same pattern. Divergent flows (e.g. ordering vs treasury) cause 401s, broken UX, and hard-to-debug issues.

Frontend (all UIs: ordering-frontend, treasury-ui, pos-ui, subscriptions-ui, cafe-website, etc.)

Step Requirement
1. Login entry Redirect to SSO authorize URL with PKCE (code_challenge, code_verifier, state). Pass tenant only when the user is already in a tenant context (e.g. path /{orgSlug}/menu → pass orgSlug). When the user lands on auth-ui directly (no ?tenant=), auth-ui sends no tenant; auth-api resolves tenant from the user's primary org.
2. Callback Exchange code + code_verifier at POST /api/v1/token. Store access_token, refresh_token, expires_at. Attach token getter to the API client so every request sends Authorization: Bearer <token>.
3. Tenant context After first successful profile response, store tenant_id and tenant_slug (from SSO or service /me) in localStorage. Send X-Tenant-ID (must be the tenant UUID from auth-api, not a slug or custom string like tenant-acme-retail) and X-Tenant-Slug on every request to tenant-scoped backends. Backends expect X-Tenant-ID to be a valid UUID.
3b. Outlet context After the service-level outlet preselection step (11 above), send X-Outlet-ID (outlet UUID) on every API request via apiClient.setOutletID(outletId). Platform owners omit X-Tenant-ID/X-Tenant-Slug but still send X-Outlet-ID when an outlet is selected. Backends treat X-Outlet-ID as optional — requests without it pass through and return all data for the tenant. See Outlet/Branch Context section below.
4. Profile Two-step enrichment: (a) Call SSO GET /api/v1/auth/me → get user identity + global roles + auth-service permissions. (b) Call the service's own /auth/me (e.g. GET /api/v1/{tenant}/pos/auth/me for pos-ui) → get service-level role + service permissions (pos.. for pos-ui). Auth-api issues only auth-service permissions in the JWT; service permissions come exclusively from the service's local RBAC. Merge: use SSO identity (id, email, name, tenant) + service permissions for RBAC. If the service /auth/me returns 404 (user not yet JIT-provisioned), fall back to role inference from global JWT roles.
5. 401 handling Register a global 401 handler (e.g. axios interceptor) that (a) attempts token refresh first via refreshAccessToken() mutex (calls POST /api/v1/auth/refresh with refresh_token + client_id), (b) retries the original request with the new token, (c) only fires the logout callback if refresh fails or the retry still returns 401. The 401 callback must check status !== 'syncing' && status !== 'loading' and skip if within 15 seconds of successful auth (lastAuthenticatedAt grace period). When firing: clear TanStack query cache (queryClient.clear()), reset auth store, redirect to SSO logout. Skip 401 for /auth/me (JIT sync delay). Reference implementation: cafe-website src/lib/api/client.ts + src/lib/auth/token-refresh.ts + src/components/providers/Providers.tsx.
6. Tenant branding Fetch from auth-api GET /api/v1/tenants/by-slug/{slug} and cache with TanStack Query staleTime: 6 * 60 * 60 * 1000 (6 hours, matching JWT TTL). Apply CSS variables (--tenant-primary, --tenant-secondary, --tenant-logo-url) from cached data. Do NOT store branding locally or provide a branding editor — redirect to auth-ui /dashboard/settings?tab=branding.
7. Profile management Common profile fields (name, email, avatar) → redirect to auth-ui /dashboard/profile. Keep only role-specific fields in the service's own profile page (e.g. rider KYC in rider-app, customer preferences in ordering-frontend).
8. Logout flow On user-initiated logout: (a) reset auth store (set lastAuthenticatedAt: null), (b) clear all localStorage keys (auth storage, tenantId, tenantSlug), (c) clear sessionStorage, (d) redirect to SSO logout GET /api/v1/auth/logout?post_logout_redirect_uri={origin} which clears the session cookie and redirects back. Reference: cafe-website src/lib/store/auth-store.ts:166-176.

Token Refresh Pattern (all frontends)

Every frontend must implement token refresh to avoid unnecessary logouts when access tokens expire:

  1. refreshTokens() in SSO API file — calls POST /api/v1/auth/refresh with { refresh_token, client_id }, returns { access_token, refresh_token?, expires_in? }.
  2. token-refresh.ts mutex module — exports refreshAccessToken() which reads refreshToken from the auth store, calls refreshTokens(), updates the store with new tokens. Uses a mutex (shared promise) so concurrent callers piggyback on the same refresh request.
  3. Axios/fetch 401 interceptor — on 401 (excluding /auth/me): dynamically imports refreshAccessToken, attempts refresh, retries the original request with the new token. Marks retried requests with _retried flag to prevent infinite loops. Only fires on401Callback after refresh fails.
  4. lastAuthenticatedAt in auth store — set to Date.now() on successful auth; cleared to null on logout. The 401 callback checks this to skip logout within 15 seconds of successful auth (token propagation grace period).

Reference implementation: cafe-website/src/lib/auth/token-refresh.ts, cafe-website/src/lib/api/client.ts, cafe-website/src/components/providers/Providers.tsx.

Backend (all Go services: ordering-backend, treasury-api, etc.)

Step Requirement
1. JWT validation Use the shared auth-client validator (e.g. shared-auth-client) with the same JWKS/issuer as auth-api. Validate Authorization: Bearer <token> on protected routes.
2. Tenant in path or header For tenant-scoped routes, require tenant in path (e.g. /api/v1/{tenant}/auth/me) or accept X-Tenant-ID (UUID). Resolve tenant from path first, then header.
3. JIT user If the JWT is valid but the user is not in the local DB, JIT-provision from token claims (and optional NATS sync). Use tenant from path/header for the provisioned membership.
4. Response shape Return session (accessToken, refreshToken, expiresAt, sessionId), user (id, email, fullName, roles, permissions, …), and tenant_id / tenant_slug so the frontend can persist them. Use camelCase JSON keys to match frontend types.

auth-ui (login/register only)

  • Email/password form: Only email and password fields. Do not ask for or default tenant. Send tenant_slug: '' when the URL has no ?tenant=. auth-api resolves the user's org from primary_tenant_id.
  • OAuth buttons: Same rule: send tenant_slug only when ?tenant= is present in the URL (e.g. when redirected from an app with tenant in the link). Otherwise send empty.

E2E tests

  • Tests must mirror real user experience: no forged query params (e.g. no ?tenant=acme-retail for tenant admin). User opens /login, enters email and password only, submits; backend resolves tenant from email.
  • After login, assert that authenticated requests succeed (e.g. no 401 when calling /auth/me or a protected endpoint with the token in headers).

OAuth Client Registration

Each frontend must be registered as an OAuth client in auth-api. The seed runs on every pod startup (auth-api/cmd/seed/main.go) and upserts these clients:

client_id Frontend Redirect URI pattern (production domain from devops-k8s values.yaml) Public
ordering-ui ordering-frontend https://ordering.codevertexafrica.com/{tenant}/auth/callback Yes
rider-app rider-app https://riderapp.codevertexafrica.com/auth/callback Yes
notifications-ui notifications-ui https://notifications.codevertexafrica.com/auth/callback and /{tenant}/auth/callback Yes
pos-ui pos-ui https://pos.codevertexafrica.com/{tenant}/auth/callback and /auth/callback Yes
inventory-ui inventory-ui https://inventory.codevertexafrica.com/{tenant}/auth/callback and /auth/callback Yes
subscriptions-ui subscriptions-ui https://pricing.codevertexafrica.com/{tenant}/auth/callback and /auth/callback Yes
treasury-ui treasury-ui https://books.codevertexafrica.com/{tenant}/auth/callback and /auth/callback Yes
logistics-ui logistics-ui https://logistics.codevertexafrica.com/auth/callback Yes
auth-ui auth-ui https://accounts.codevertexafrica.com/auth/callback, https://sso.codevertexafrica.com/auth/callback Yes
cafe-website cafe-website https://example-tenant.com/auth/callback Yes (PKCE)
marketflow-ui marketflow-ui https://marketflow.codevertexafrica.com/auth/callback and /{tenant}/auth/callback Yes

The seed uses upsert — re-running it fixes misconfigured redirect URIs automatically. For tenant-aware apps, seed includes both /{tenant}/auth/callback and /auth/callback so either pattern works.

CORS and X-Tenant-ID: auth-api (and any backend that accepts X-Tenant-ID) must include X-Tenant-ID in Access-Control-Allow-Headers (app CORS and ingress annotations). Otherwise frontends that send the tenant UUID header (e.g. after loading profile from /me) will get a CORS preflight failure. See devops-k8s-ingress-cors.md.

Production domains (from devops-k8s/apps/*/values.yaml ingress host): Only these domains are configured; CORS and OAuth use this list only (no alternate domains).

App Production host
auth-api (SSO) sso.codevertexafrica.com
auth-ui accounts.codevertexafrica.com
ordering-frontend ordering.codevertexafrica.com
cafe-website example-tenant.com
notifications-ui notifications.codevertexafrica.com
rider-app riderapp.codevertexafrica.com
subscriptions-ui pricing.codevertexafrica.com
treasury-ui books.codevertexafrica.com
pos-ui pos.codevertexafrica.com
logistics-ui logistics.codevertexafrica.com
inventory-ui inventory.codevertexafrica.com
marketflow-ui marketflow.codevertexafrica.com
marketflow-api marketflowapi.codevertexafrica.com
marketflow-ai marketflowai.codevertexafrica.com
ticketing-ui ticketing.codevertexafrica.com
projects-ui projects.codevertexafrica.com

Do not use: treasury.codevertexafrica.com or subscriptions.codevertexafrica.com for the UIs — use books.codevertexafrica.com (treasury-ui) and pricing.codevertexafrica.com (subscriptions-ui). Rider app host is riderapp.codevertexafrica.com (not rider.).


Tenant Context

All SSO requests should include tenant context so auth-api can mint the token for the correct organisation and downstream services can JIT-sync the tenant:

// In buildAuthorizeUrl():
url.searchParams.set("tenant", orgSlug ?? "acme-retail");  // default tenant: acme-retail
  • Authorize URL: tenant=<slug> is optional; when present, auth-api stores it on the authorization code and prefers that tenant when minting the access token (if the user is a member).
  • Default tenant: acme-retail is the default app tenant; frontends should pass it (or the current org slug from the path) so the token carries the correct tenant.
  • Platform vs tenant orgs: Platform organisation = codevertex (operates the platform; users may have cross-tenant access and do not consume tenant subscriptions). Tenant organisations = customer orgs (e.g. acme-retail, acme-wholesale) that have subscriptions and use the product. Organisation slug codevertex is the platform owner; users with that primary tenant have elevated access.

Login without tenant_slug: tenant resolved from user's primary tenant

Direct login from auth-ui (e.g. /login with no ?tenant= in the URL): auth-ui sends tenant_slug as empty when the URL has no tenant. auth-api does not require a tenant slug for login. When tenant_slug is missing or empty:

  1. auth-api looks up the user by email.
  2. It then resolves the tenant from the user's primary_tenant_id in the database (each user has a linked primary organisation).
  3. It verifies the user is a member of that tenant and continues the login flow.

So tenant users can log in directly from auth-ui at https://accounts.codevertexafrica.com/login without any ?tenant=... in the URL. The correct tenant is determined by the user's primary organisation in auth-api. Frontends (auth-ui) must send an empty or omitted tenant_slug when the user did not arrive via a tenant-specific link; do not default to a fixed slug (e.g. codevertex) or tenant users who belong only to another org would get "invalid credentials".

After login, the JWT claims include tenant_id and tenant_slug. All service APIs read these via: - X-Tenant-ID header (UUID) - X-Tenant-Slug header (string)

Tenant UUID: single source of truth

Tenant UUID is issued by auth-api only. All microservices that store or reference tenants MUST use this same UUID for a given tenant (no per-service UUIDs). Auth-api seeds tenants with DB-generated UUIDs (no fixed IDs). When a user logs in:

  1. JWT contains tenant_id (UUID) and tenant_slug from auth-api.
  2. Profile source: Frontends must load user/roles/permissions from auth-api (SSO) GET /api/v1/auth/me (Bearer token). Do not call the service’s own API for profile unless that service exposes a dedicated /auth/me (e.g. ordering-backend GET /api/v1/{tenant}/auth/me for its synced user). Treasury-ui, notifications-ui, subscriptions-ui, etc. call SSO for /api/v1/auth/me; treasury-api also exposes GET /api/v1/auth/me (JWT claims) as an optional fallback.
  3. Frontends should store tenant_id (e.g. in localStorage) after the first successful profile load and send it as X-Tenant-ID on subsequent requests; use tenant_slug in the URL path (e.g. /api/v1/acme-retail/...).
  4. When syncing users or tenants from events (e.g. NATS auth.user.created), downstream services must use the tenant UUID from the event (auth-api-issued), not generate a new one.

Outlet/Branch Context

Every SSO-integrated service frontend and backend supports outlet/branch-level scoping via the X-Outlet-ID header. This is optional on the backend — requests without the header return all tenant-scoped data.

JWT Outlet Claims

Auth-api embeds outlet data in the access token after the select-outlet step:

Claim Type Description
outlet_id string UUID Selected outlet. Empty for HQ/admin users.
outlet_code string Short outlet code (e.g. BUSIA, HOSP).
outlet_use_case string hospitality | retail | quick_service | pharmacy | services | logistics. Controls sidebar modules.
is_hq_user bool True for HQ users who can access all outlets. HQ users use X-Outlet-ID header for per-request drill-down.

Frontend: Outlet Selector Flow

After SSO callback (JWT received)
  ↓
outlet_id in JWT && !is_hq_user?
  → YES: auto-preselect outlet, skip selector, set apiClient.setOutletID(outlet_id)
  → NO (HQ user or empty outlet_id):
      stored outlet in localStorage?
        → YES: restore + apiClient.setOutletID(storedId)
        → NO:  redirect to /{orgSlug}/auth/select-outlet
              (fetches GET /api/v1/tenants/{slug}/outlets from auth-api)
              (auto-selects if single outlet, shows list if multiple)
              (moves last-used outlet to top of list)

Frontend: Nav Bar Filter Rules

User Type Tenant Filter Outlet Filter
Platform owner (is_platform_owner = true) Dropdown (all tenants) Dropdown (outlets of selected tenant)
Tenant HQ/admin (is_hq_user = true or role = admin/manager) Not shown Dropdown ("All Outlets" default)
Regular staff (outlet_id in JWT, is_hq_user = false) Not shown Read-only badge (their assigned outlet, no dropdown)

Key files: - Outlet filter component template: inventory-service/inventory-ui/src/components/outlet-filter.tsx - Outlet filter store template: inventory-service/inventory-ui/src/store/outlet-filter.ts - API client with X-Outlet-ID: inventory-service/inventory-ui/src/lib/api/client.ts - Select-outlet page template: pos-service/pos-ui/src/app/[orgSlug]/auth/select-outlet/page.tsx

Frontend: API Client Setup (all services)

class ApiClient {
  private outletId: string | null = null;

  private handleRequest = (config) => {
    // ... existing tenant headers ...
    if (this.outletId) {
      config.headers['X-Outlet-ID'] = this.outletId;
    }
    return config;
  };

  public setOutletID(outletId: string | null) {
    this.outletId = outletId;
  }
}

On page reload (rehydration):

useEffect(() => {
  const stored = localStorage.getItem(`{service}-selected-outlet-id`);
  if (stored) apiClient.setOutletID(stored);
}, []);

Frontend: localStorage Key Convention

Each service uses its own localStorage key to avoid conflicts: - pos-selected-outlet-id (pos-ui) - inventory-selected-outlet-id (inventory-ui) - ordering-selected-outlet-id (ordering-frontend) - logistics-selected-outlet-id (logistics-ui) - treasury-selected-outlet-id (treasury-ui) - marketflow-selected-outlet-id (marketflow-ui) - projects-selected-outlet-id (projects-ui)

Backend: Outlet Context Middleware (all Go services)

// Lightweight middleware — add to /{tenant} route group after TenantV2
func OutletContext(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if outletID := httpware.OutletHeader(r); outletID != "" {
            r = r.WithContext(httpware.WithOutletID(r.Context(), outletID))
        }
        next.ServeHTTP(w, r)  // always continues — outlet is optional
    })
}

CORS: All services must include X-Outlet-ID in AllowedHeaders:

AllowedHeaders: []string{..., "X-Outlet-ID"},

Backend: Optional Outlet Filtering

// In any list handler — outlet filter is ALWAYS optional:
outletID := httpware.GetOutletID(ctx) // empty string if not set
q := client.Order.Query().Where(order.TenantID(tenantUUID))
if outletID != "" {
    if uid, err := uuid.Parse(outletID); err == nil {
        q = q.Where(order.OutletIDEQ(uid))
    }
}
// Never return an error if outlet ID is absent — this breaks platform admin calls

Backend: Outlet List Endpoint (auth-api)

Services and frontends fetch the tenant's outlet list from auth-api (no auth required):

GET /api/v1/tenants/{slug}/outlets
Response: [{ id, code, name, use_case, is_hq, status, settings }]

Filter out status = "archived" outlets before showing in UI.

Services with Outlet Support

Service Frontend Backend Outlet FK Notes
POS Full (select-outlet + header filter) OutletContext middleware + outlet_id FK orders, staff Reference implementation
Inventory Full OutletContext middleware + warehouse outlet_id warehouses
Ordering Full OutletContext middleware + outlet_id FK orders
Logistics Full OutletContext middleware + outlet_id FK tasks
Treasury Full (TenantFilter + OutletFilter) CORS + middleware Optional
MarketFlow OutletFilter in header CORS + middleware Optional
Projects OutletFilter in header CORS + middleware Optional

Frontend Implementation Pattern

1. PKCE helpers (src/lib/auth/pkce.ts)

export function generateCodeVerifier(): string          // 32 random bytes → base64url
export async function generateCodeChallenge(v): string  // SHA-256 → base64url
export function generateState(): string                 // 16 random bytes → hex
export function storeVerifier(v: string): void          // sessionStorage
export function consumeVerifier(): string | null        // get + delete
export function storeState(s: string): void
export function consumeState(): string | null

2. Auth store (src/store/auth-store.ts or equivalent)

See login-flow-contract.md for the full contract, service table, and platform vs tenant scope.

// Start login flow (tenant optional; pass when in tenant context e.g. from path)
redirectToSSO(returnTo?: string, tenant?: string): Promise<void>

// Handle /auth/callback
handleSSOCallback(code: string, callbackUrl: string, tenantSlug?: string): Promise<void>
// ↑ Exchanges code → tokens → polls /me until user synced

// Logout
logout(): void  // Clears state + redirects to SSO logout

3. Callback page (src/app/auth/callback/page.tsx)

// 1. Get code + state from URL params
// 2. Call handleSSOCallback(code, callbackUrl)
// 3. On authenticated: check user.status
//    - "pending" or "pending_review" → /auth/pending
//    - otherwise → sessionStorage.getItem("sso_return_to") or "/"

4. Pending page (src/app/auth/pending/page.tsx)

// Poll fetchMe() every 15s
// When status is no longer "pending": redirect to /{orgSlug}/profile

Service-Specific Notes

auth-ui (login/register only): direct vs service-originated entry

Entry URL After login
Direct User opens /login (no return_to). Redirect to /dashboard or valid relative return_to via router.push.
Service-originated auth-api redirects to /login?return_to=<full_sso_authorize_url>&tenant=.... auth-api does not pass client_id/redirect_uri as separate params. auth-ui must do a full page redirect (window.location.href = return_to) so the browser sends the session cookie to sso. Then auth-api sees the cookie, issues the auth code, and redirects to the service callback.

auth-ui validates return_to with isValidReturnUrl (allows relative paths and absolute URLs starting with NEXT_PUBLIC_API_URL or fallback https://sso.codevertexafrica.com). Set NEXT_PUBLIC_API_URL to the SSO base so the sso authorize URL is accepted. See auth-ui README Environment section.

ordering-frontend

  • Client ID: ordering-ui
  • Callback: /{orgSlug}/auth/callback (tenant-aware)
  • Authorize URL: pass tenant from path (orgSlug) or default acme-retail so token is minted for the correct org
  • After sync: redirect to returnTo URL (e.g. /{orgSlug}/menu)
  • Source: src/lib/auth/api.ts, src/store/auth.ts

rider-app

  • Client ID: rider-app
  • Callback: /auth/callback (NOT tenant-aware — uses fixed path)
  • Profile: Call SSO GET /api/v1/auth/me (Bearer token) — not logistics-api. Implemented in lib/auth-api.ts (fetchMe(accessToken)); response is normalized to User (id, email, roles, tenants). Fixes "Syncing your account..." stuck screen when logistics-api /riders/me returns 404.
  • After sync: check status → pending page or dashboard
  • After admin approval: redirect to /{orgSlug}/profile for profile completion
  • Invitation flow: /join?invite_code=ABC&org=acme-retail → SSO → pending → profile
  • Source: src/lib/auth-api.ts, src/store/auth-store.ts, src/hooks/useAuth.ts

notifications-ui

  • Client ID: notifications-ui
  • Callback: /{orgSlug}/auth/callback
  • Profile: Call SSO GET /api/v1/auth/me (Bearer token).
  • Source: src/lib/auth/api.ts

treasury-ui

  • Client ID: treasury-ui
  • Callback: /[orgSlug]/auth/callback
  • Profile: Call SSO GET /api/v1/auth/me (Bearer token) — not treasury-api. Implemented in lib/auth/api.ts (fetchProfile(accessToken)) and hooks/useMe.ts. Treasury-api also exposes GET /api/v1/auth/me (JWT claims) as an optional fallback.
  • Source: src/lib/auth/api.ts, src/store/auth.ts, src/hooks/useMe.ts

pos-ui

  • Client ID: pos-ui
  • Callback: /{orgSlug}/auth/callback
  • Profile: Two-step — (1) Call SSO GET /api/v1/auth/me for identity + global roles. (2) Call pos-api GET /api/v1/{tenant}/pos/auth/me for POS-specific role + pos.. permissions. Merge: SSO identity + pos-api permissions. On SSO login, fetchPosServiceProfile() enriches the user profile stored in Zustand auth store.
  • PIN login: pos-api GET /{tenant}/pos/auth/pin/profile lists staff; POST /auth/pin validates PIN and issues a terminal JWT that already contains POS role + permissions (same RBAC as SSO flow).
  • Source: src/lib/auth/api.ts, src/store/auth.ts, src/hooks/usePermissions.ts, src/lib/rbac/permissions.ts

inventory-ui

  • Client ID: inventory-ui
  • Callback: /{orgSlug}/auth/callback
  • Profile: Call SSO GET /api/v1/auth/me (Bearer token).
  • Source: src/lib/auth/api.ts, src/store/auth.ts, src/app/[orgSlug]/auth/callback/page.tsx

cafe-website

  • Direct SSO (PKCE); no NextAuth
  • Callback: /auth/callback (see seed redirect_uris)
  • Public menu: uses lib/api/public-menu.ts (GET /menu/categories, /menu/items — no Authorization). Dashboard catalog uses lib/api/catalog.ts (auth required).
  • Source: src/lib/store/auth-store.ts, src/app/auth/callback/page.tsx

JWT Access Token Claims (Single Source of Truth for Authorization)

Auth-api must issue access tokens that contain everything microservices need to authorize requests, so that 401s are not caused by missing or inconsistent role/permission data.

Required claims in the access token:

  • sub — User ID (UUID).
  • tenant_id, tenant_slug — Tenant context.
  • roles — Array of tenant-scoped roles (e.g. superuser, admin, staff, member, rider).
  • permissions — Array of canonical permission codes derived from the SSO role–permission table (e.g. catalog:view, catalog:manage, orders:read, orders:change, riders:read).
  • outlet_id — UUID of the selected outlet (empty until outlet selection is completed).
  • outlet_code — Short code for the outlet (e.g. BUSIA, HOSP).
  • outlet_use_case — Use case of the outlet: hospitality | retail | quick_service | pharmacy | services | logistics.
  • is_hq_user — Boolean; HQ outlet users bypass outlet-scoped data filtering in downstream services.

Outlet claims (added in shared-auth-client v0.6.0) are embedded after outlet selection. Services use outlet_use_case to gate access to use-case-specific routes via RequireUseCase() middleware. Platform owners (IsPlatformOwner) and users with CanAccessAllOutlets() bypass all outlet-scoped gating.

Canonical permission codes are defined once in auth-api (seed) and used by all services. No service should define its own permission strings for cross-cutting authz; use the same codes (e.g. ordering-backend checks catalog:view / catalog:manage, and auth-api issues those same strings in the token and in GET /me).

Services validate the JWT (signature, issuer, audience, expiry) and read roles and permissions from the token to authorize. GET /me should return the same roles and permissions for UI and for services that prefer to call /me.

Service-level permissions (Layer 3): In addition to global JWT permissions, each domain service maintains its own fine-grained permission system in its database (see TRINITY-AUTHORIZATION-PATTERN.md). Service-level permission codes use the format {service}.{module}.{action} (e.g. treasury.payments.add, ordering.orders.view). The shared-auth-client library provides RequirePermission() middleware that checks claims.Permissions from the JWT, while each service's RBAC module provides local DB-backed permission checks via rbacService.HasPermission(). Both layers are enforced; superuser role bypasses all checks.


Just-in-Time (JIT) Provisioning

When a microservice receives a valid JWT (valid signature, issuer, audience, expiry) but has no local user record for sub, it must not return 401. Instead it should provision the user just-in-time:

  1. Create a minimal local user from token claims: sub, email, name, tenant_id, tenant_slug, and optionally roles/permissions from the token (or one call to auth-api GET /me).
  2. Persist the user and then retry the requested operation (or continue the request).
  3. Return 200 (or the appropriate success response), not 401.

This eliminates "user not found" 401s when NATS events are delayed or missing. NATS (auth.user.created, auth.user.login) remains the primary sync mechanism; JIT is the fallback so the first API call after login succeeds.


Service-Specific Registration

  • Central auth: Login and basic registration (email, password, profile) happen only at SSO (auth-ui). No service stores passwords or implements its own login.
  • Service-specific data: When a service needs extra data (e.g. rider: KYC docs, vehicle; cafe: preferences), the flow is:
  • User is already authenticated (has valid SSO token).
  • User lands on the service; service detects "no local profile" or "incomplete profile".
  • Service redirects to service-specific onboarding (auth-ui or service-owned page) with identity prefilled from the token (e.g. ?email=...&name=... from token or GET /me).
  • User completes service-specific fields (e.g. rider KYC, uploads); form submits to the service backend.
  • Service creates or updates local profile linked to sub, then redirects back to the service or auth-ui landing.

No duplicate "registration" for credentials—only for service-specific attributes.


Public vs Protected Endpoints

Path pattern Auth required Use case
/api/v1/{tenant}/menu/* No Public menu, categories, item detail
/api/v1/{tenant}/cafes/* No Public cafe/outlet list
/api/v1/{tenant}/config No Tenant brand/config
/api/v1/{tenant}/catalog/* Yes Staff catalog CRUD
/api/v1/{tenant}/orders/* (mutations) Yes Create/update orders, etc.

Frontends must use public paths for unauthenticated or public reads (e.g. site menu). Do not send Authorization for public menu/catalog reads. Use /menu/items and /menu/categories, not /catalog/items and /catalog/categories, for public menu display.


Auth/me and Caching

  • Backend (auth-api): GET /api/v1/auth/me is cached in Redis by user ID. TTL = token expiry (from JWT exp) or 24h. Reduces DB load for high traffic; cache is per user and expires with the token.
  • Frontend: Use TanStack Query (or equivalent) to call GET /auth/me (or the service’s /me) with a staleTime/TTL aligned to token lifetime (e.g. 5 min–24h). Check cache before refetching so the first read after login is fast; refetch on window focus or after TTL as needed.

NATS User Sync

After SSO login, each service needs to know about the user. This happens via NATS events:

auth-api publishes: auth.user.created  (on new user)
auth-api publishes: auth.user.login    (on each login)

ordering-backend subscribes → creates/updates customer profile
logistics-api subscribes    → links FleetMember to UserID (for riders)
notifications-api subscribes → creates notification preferences

Bridge screen polling (all frontends): - Call SSO GET /api/v1/auth/me (Bearer token) — not the service API (e.g. do not call booksapi for treasury-ui profile). Poll up to 10 times with 1.5s intervals if needed. - Stop when user data is returned (200 OK) - Show "Syncing your account..." spinner during wait - If sync times out (15s): show error with retry


Subscription Gating (Post-Login, Never Blocks Auth)

Principle: Subscription NEVER blocks login. Users must always be able to authenticate. Subscription is enforced AFTER login on mutations, UI elements, and action buttons.

Backend Enforcement Pattern

All Go services use mutations-only enforcement via inline middleware (same pattern as ordering-backend):

api.Use(func(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // GET/HEAD/OPTIONS always pass through
        if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
            next.ServeHTTP(w, r)
            return
        }
        claims, ok := authclient.ClaimsFromContext(r.Context())
        if !ok { next.ServeHTTP(w, r); return }
        if claims.IsSuperuser() || claims.IsPlatformOwner || claims.IsSubscriptionActive() {
            next.ServeHTTP(w, r); return
        }
        // Returns {"error":"...","code":"subscription_inactive","upgrade":true}
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusForbidden)
        _, _ = w.Write([]byte(`{"error":"Your subscription is not active.","code":"subscription_inactive","upgrade":true}`))
    })
})

Frontend 403 Discrimination

Frontends MUST distinguish subscription 403s from auth 403s using the code and upgrade fields:

// In API client error interceptor:
if (error.response?.status === 403) {
  const data = error.response?.data;
  if (data?.code === 'subscription_inactive' || data?.upgrade === true) {
    // Subscription issue — show upgrade toast/banner, do NOT redirect to login
    onSubscription403Callback(data);
  }
}

// In auth-provider 403 handling:
if (isError && statusCode === 403) {
  const data = (error as any)?.response?.data;
  if (data?.code === 'subscription_inactive' || data?.upgrade === true) return; // Skip redirect
  router.replace(`/${orgSlug}/unauthorized`); // Only redirect for permission 403
}

Frontend UI Components

Each frontend implements these subscription components: - useSubscription() hook — lazy-loads subscription info after auth, returns isActive, hasFeature(), isPastDue, isExpired, etc. - <SubscriptionBanner /> — persistent top banner for trial expiry, past due, expired states. Never blocks access. - <SubscriptionGate feature="..." /> — wraps content requiring specific features; shows upgrade prompt when gated.


useMe Hook Pattern (March 29, 2026)

Critical rule: The useMe hook in ALL frontends MUST call SSO auth-api GET /api/v1/auth/me directly — NOT the service backend's /auth/me. The backend requires JIT sync via NATS which has a delay; calling it during login causes 401 loops.

Correct Pattern (all services):

// In lib/auth/api.ts:
const SSO_BASE_URL = process.env.NEXT_PUBLIC_SSO_URL || 'https://sso.codevertexafrica.com';

export async function fetchProfile(accessToken: string): Promise<UserProfile> {
  const res = await fetch(`${SSO_BASE_URL}/api/v1/auth/me`, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) throw new Error(res.status === 401 ? 'Unauthorized' : 'SSO /me failed');
  return res.json();
}

// In hooks/useMe.ts:
export function useMe(enabled = true) {
  const accessToken = useAuthStore((s) => s.session?.accessToken);
  return useQuery({
    queryKey: ['auth', 'me'],
    queryFn: () => fetchProfile(accessToken!),
    enabled: enabled && !!accessToken,
    staleTime: 5 * 60 * 1000,
    retry: (failureCount, error) => {
      if (error?.response?.status === 401 || error?.response?.status === 403) return false;
      return failureCount < 2; // NEVER use `true` — causes infinite loops
    },
  });
}

Anti-patterns to avoid:

  • fetchProfile() calling backend /api/v1/auth/me or auth/me (JIT delay → 401 → loop)
  • retry: true or retry: () => true on non-401 errors (infinite request loops)
  • useMe returning isError that triggers SSO redirect without checking subscription 403

Debugging Common Issues

Symptom Cause Fix
404 on authorize redirect Wrong path /api/v1/auth/oidc/authorize Change to /api/v1/authorize
Invalid redirect_uri error URI not registered in auth-api Re-run auth-api seed (upsert fixes it)
User synced but 403 on APIs Missing X-Tenant-ID header Ensure headers injected in API client
401 on /me even with token Token may be expired or user not found Check token expiry; re-login
Infinite loading on landing page buttons useAuth stuck in isLoading=true on 401 Fixed in useAuth.ts: 401 resolves immediately as unauthenticated
Infinite polling on callback NATS event not delivered Check NATS connection in service logs
Blank/error after SSO login PKCE verifier missing from sessionStorage Check consumeVerifier() returns non-null
Pod not running after deploy K8s probe returning non-200 Use /healthz route (not / which redirects)
401 "user not found" with valid token User not yet in service DB Enable JIT provisioning in service; or check NATS delivery
403 on catalog/menu with valid token Permission codes in token don't match backend Use canonical permission codes in auth-api (e.g. catalog:view) and in backend checks
Duplicate roles/permissions in login response Legacy response shape Fixed: auth-api returns them only at top level, not under user
"client not found" or "invalid_redirect" for pos-ui/subs/treasury/notifications Wrong client_id or redirect_uri not in seed Ensure auth-api seed includes pos-ui and tenant-aware redirect URIs; re-run seed
404 on /auth/me or stuck on "Syncing your profile" Frontend calling service API (e.g. booksapi) for profile Call SSO GET /api/v1/auth/me with Bearer token; see treasury-ui lib/auth/api.ts and useMe.ts
"Access denied" on treasury-ui /platform page AuthProvider checking wrong role name (super_admin instead of superuser) Fixed: AuthProvider now checks isPlatformOwner and superuser role from SSO /me response
User has no local service roles after first login JIT provisioning created user but didn't assign roles Fixed: All backends now map JWT roles to service-level roles during JIT (e.g. superuser → finance_admin)
429 on notifications /messages endpoint Email rate limit exceeded for subscription plan Check max_emails_per_day in JWT SubscriptionLimits; upgrade plan or wait for daily reset
Redirect to login after SSO login (subscription) Backend returns 403 subscription_inactive on GET; frontend treats as auth error Fix: backend must use mutations-only enforcement; frontend must check data.code === 'subscription_inactive' before redirecting
403 on GET with expired subscription Service uses RequireActiveSubscription() on all routes Fix: use inline mutations-only middleware (skip GET/HEAD/OPTIONS)
Toast "subscription inactive" on every page load Subscription 403 interceptor fires on read requests Fix: backend should only enforce on mutations; if already fixed, clear browser cache
Outlet selector shown for every login (single-outlet user) Frontend not checking JWT outlet_id before redirecting to selector Fix: in auth callback, auto-preselect from JWT outlet_id when is_hq_user = false
X-Outlet-ID CORS preflight failure (403/500 from backend) Service missing X-Outlet-ID in AllowedHeaders CORS config Add "X-Outlet-ID" to CORS AllowedHeaders in the service router
Backend returns all orders/tasks regardless of selected outlet Outlet middleware not wired in router or outlet FK query not applied Ensure OutletContext middleware is applied to /{tenant} group and list handlers call httpware.GetOutletID(ctx)
Page reload loses outlet selection Outlet not rehydrated from localStorage on mount Add useEffect that reads {service}-selected-outlet-id from localStorage and calls apiClient.setOutletID()
OutletFilter dropdown empty for platform owner Outlet list fetched with wrong tenant slug (should use TenantFilter selection) Pass tenantFilterStore.selectedTenant?.slug to the outlets fetch query in OutletFilter

Security Checklist

  • [x] PKCE used on all public clients (no client secret in browser)
  • [x] state parameter stored and verified (CSRF protection)
  • [x] PKCE verifier consumed after single use
  • [x] Refresh tokens stored only in memory (not localStorage for security-sensitive apps)
  • [x] CORS restricted to known domains (no wildcards in production)
  • [x] All redirect URIs whitelisted explicitly in auth-api
  • [x] JWT verified against JWKS endpoint (shared-auth-client library)

Platform Owner Access Pattern

Codevertex is the platform owner, not a business tenant. Users linked to the codevertex organisation bypass tenant isolation.

Detection

GET /api/v1/auth/me returns is_platform_owner: true when primary_tenant.slug == "codevertex" and the user has the superuser role.

Service Enforcement

// In any service's auth middleware:
if (user.is_platform_owner || user.roles.includes('superuser')) {
  // Skip tenant isolation — allow access to ALL tenant data
  return next();
}
// Otherwise enforce tenant_id === request.tenant_id

Backend: Platform Owner Tenant Override (?tenantId=)

All Go backend services support a ?tenantId=<uuid> query parameter on tenant-scoped endpoints for platform owners. This allows cross-tenant drill-down without requiring tenant headers.

Pattern (implemented in all services — March 2026):

// In each handler's tenant resolution function:
ctx := r.Context()
if httpware.IsPlatformOwner(ctx) {
    if q := r.URL.Query().Get("tenantId"); q != "" {
        return uuid.Parse(q) // override to target tenant
    }
}
// Fallback: httpware context → JWT claims
tenantIDStr := httpware.GetTenantID(ctx)

Frontend behaviour: - Platform owner UIs (orgSlug codevertex) skip X-Tenant-ID/X-Tenant-Slug headers (apiClient setPlatformOwner(true)) - A TenantFilter dropdown in the header lets platform owners select a specific tenant - Selected tenant is passed as ?tenantId=<uuid> on API calls - When no tenant is selected ("All Tenants"), the backend returns data across all tenants (for list endpoints) or requires explicit selection (for mutations/config)

Services implementing this pattern: - treasury-api, inventory-api, logistics-api, pos-api, subscriptions-api, ordering-backend, notifications-api


Multi-Step Account Registration

All account creation is centralised in auth-ui (accounts.codevertexafrica.com/signup). All other services redirect unauthenticated users here.

Registration Steps

  1. Account — Full name, email, password
  2. Organisation — Search & join existing org (by slug) OR create new org (name, slug, size, use case)
  3. Plan — Subscription tier recommendation with 14-day trial; fetched from subscription-api

Org Roles

Action Role Assigned
Join existing org member (admin can promote later)
Create new org admin (founding user becomes org admin)

Subscription Enforcement Post-Login

After login, non-platform users without an active subscription are redirected to the subscription selection page:

login success
  → check user.is_platform_owner → skip if true
  → GET /api/v1/tenants/{tenant_id}/subscription
  → status in [ACTIVE, TRIAL] → allow app access
  → status EXPIRED or 404 → redirect to /subscribe


New Service OAuth Client Registration

When a new frontend integrates with SSO:

Option A — Seed (MVP Services)

Add to auth-api/cmd/seed/main.go OAuth clients list and redeploy. Use for all services that are part of MVP.

Option B — Admin API (Runtime)

For new services added post-MVP without redeployment:

# Requires valid superuser JWT
curl -X POST https://sso.codevertexafrica.com/api/v1/admin/clients \
  -H "Authorization: Bearer <superuser-token>" \
  -H "Content-Type: application/json" \
  -d '{
    "client_id": "my-new-service",
    "name": "My New Service",
    "redirect_uris": ["https://my-new-service.example.com/callback"],
    "scopes": ["openid", "profile", "email"],
    "public": true
  }'

Rule: seed = source of truth for MVP clients. Admin API = runtime additions. Both are valid.


Public Tenant Endpoint

Services that need to resolve tenant UUIDs (e.g. subscription-api seed) use the public endpoint — no authentication required:

GET /api/v1/tenants/by-slug/{slug}
Response: { "id": "<uuid>", "name": "...", "slug": "...", "status": "active" }

This ensures cross-service UUID consistency without hardcoding values.