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:
refreshTokens()in SSO API file — callsPOST /api/v1/auth/refreshwith{ refresh_token, client_id }, returns{ access_token, refresh_token?, expires_in? }.token-refresh.tsmutex module — exportsrefreshAccessToken()which readsrefreshTokenfrom the auth store, callsrefreshTokens(), updates the store with new tokens. Uses a mutex (shared promise) so concurrent callers piggyback on the same refresh request.- Axios/fetch 401 interceptor — on 401 (excluding
/auth/me): dynamically importsrefreshAccessToken, attempts refresh, retries the original request with the new token. Marks retried requests with_retriedflag to prevent infinite loops. Only fireson401Callbackafter refresh fails. lastAuthenticatedAtin auth store — set toDate.now()on successful auth; cleared tonullon 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 fromprimary_tenant_id. - OAuth buttons: Same rule: send
tenant_slugonly 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-retailfor 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/meor 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-retailis 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 slugcodevertexis 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:
- auth-api looks up the user by email.
- It then resolves the tenant from the user's primary_tenant_id in the database (each user has a linked primary organisation).
- 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:
- JWT contains
tenant_id(UUID) andtenant_slugfrom auth-api. - 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-backendGET /api/v1/{tenant}/auth/mefor its synced user). Treasury-ui, notifications-ui, subscriptions-ui, etc. call SSO for/api/v1/auth/me; treasury-api also exposesGET /api/v1/auth/me(JWT claims) as an optional fallback. - Frontends should store
tenant_id(e.g. in localStorage) after the first successful profile load and send it asX-Tenant-IDon subsequent requests; usetenant_slugin the URL path (e.g./api/v1/acme-retail/...). - 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
tenantfrom path (orgSlug) or defaultacme-retailso 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 inlib/auth-api.ts(fetchMe(accessToken)); response is normalized to User (id, email, roles, tenants). Fixes "Syncing your account..." stuck screen when logistics-api/riders/mereturns 404. - After sync: check status → pending page or dashboard
- After admin approval: redirect to
/{orgSlug}/profilefor 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 inlib/auth/api.ts(fetchProfile(accessToken)) andhooks/useMe.ts. Treasury-api also exposesGET /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/mefor identity + global roles. (2) Call pos-apiGET /api/v1/{tenant}/pos/auth/mefor 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/profilelists staff;POST /auth/pinvalidates 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 useslib/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:
- 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). - Persist the user and then retry the requested operation (or continue the request).
- 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/meis cached in Redis by user ID. TTL = token expiry (from JWTexp) 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 astaleTime/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/meorauth/me(JIT delay → 401 → loop)retry: trueorretry: () => trueon non-401 errors (infinite request loops)useMereturningisErrorthat 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]
stateparameter 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¶
- Account — Full name, email, password
- Organisation — Search & join existing org (by slug) OR create new org (name, slug, size, use case)
- 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.