# Bilko Self-Serve Trial — CIAM Architecture and Auth Pattern (MC #103232)

# Bilko Self-Serve Trial — CIAM Architecture &amp; Auth Pattern

**MC:** #103232 | **Status:** LIVE — Proveo 11/11 PASS | **Last updated:** 2026-06-09 | **Securion verdict:** LAUNCH WITH CONDITIONS

---

## 1. Overview

A prospect navigates to `app.bilko.cloud` (or `bilko-demo.alai.no`), clicks **"Sign in or create a free account with your email"**, and completes a Microsoft CIAM Email-OTP sign-up. On first login, the backend JIT-provisions an empty Bilko organisation with a **7-day trial** directly on the real production database (`bilko-demo-db`). There is no separate demo build, no invite-only flow, and no org Microsoft account required — any personal email address works.

The deployment target is the standard `bilko-main-deploy` semver-tag trigger. Stage and demo share the same Kotlin/Ktor binary and the same database instance (multi-tenant via RLS). The CIAM tenant (`bilkociam`) is a dedicated Microsoft Entra External ID tenant, completely separate from the Bilko staff Entra tenant.

### 1.1 Flow diagram

```

Prospect → app.bilko.cloud/login
         → "Sign in with Microsoft" (MSAL redirect)
         → bilkociam.ciamlogin.com [BilkoSignUpSignIn user flow]
         → Email OTP verification (8-digit code, ~6s delivery)
         → Consent pages (2 pages on first login only)
         → Redirect to app.bilko.cloud/auth/callback
         → MSAL: LOGIN_SUCCESS fires, payload.idToken available
         → POST /auth/entra/session { idToken }   [B1 exchange fix]
         → bilko-api: JWKS RS256 verify → OID lookup → JIT provision
         → Response: Bilko HMAC JWT + org { trialEndsAt }
         → setAuthFromRegistration()              [B1.2 session fix]
         → checkAuth() in-memory JWT fast-path    [B1.3 session fix]
         → /dashboard — empty org, trial active ("Probno: 6 dana preostalo")
```

---

## 2. CIAM Tenant Configuration

<table id="bkmrk-propertyvalue-tenant"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Tenant name</td><td>bilkociam</td></tr> <tr><td>Tenant ID</td><td>20bb17de-9be5-4143-a7e5-8c1ddae6a064</td></tr> <tr><td>Tenant type</td><td>CIAM (Entra External ID)</td></tr> <tr><td>SPA app name</td><td>Bilko Web (SPA)</td></tr> <tr><td>SPA client ID</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a</td></tr> <tr><td>API resource app ID</td><td>fe39e0f5-513e-40af-93f0-c3ee624df56c</td></tr> <tr><td>Authority URL</td><td>https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0</td></tr> <tr><td>OIDC issuer</td><td>same as authority URL (confirmed via discovery endpoint)</td></tr> </tbody></table>

### 2.1 User flow: BilkoSignUpSignIn

<table id="bkmrk-propertyvalue-flow-i"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Flow ID</td><td>aa86084b-01dc-453f-9e10-679dfefdd824</td></tr> <tr><td>Type</td><td>externalUsersSelfServiceSignUpEventsFlow</td></tr> <tr><td>Display name</td><td>BilkoSignUpSignIn</td></tr> <tr><td>Identity provider</td><td>EmailOtpSignup-OAUTH (Email One Time Passcode)</td></tr> <tr><td>isSignUpAllowed</td><td>true</td></tr> <tr><td>userTypeToCreate</td><td>member (not guest)</td></tr> <tr><td>Attributes collected</td><td>email (auto-filled by OTP verification)</td></tr> <tr><td>Linked app</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA)</td></tr> </tbody></table>

**Authority URL note:** Unlike Azure AD B2C, Entra External ID CIAM does *not* require a user flow policy name suffix in the authority URL. The BilkoSignUpSignIn flow is applied automatically at the tenant level when the SPA app is linked to it. The deployed authority URL requires no changes.

### 2.2 Registered SPA redirect URIs

- https://app.bilko.cloud/auth/callback and https://app.bilko.cloud
- https://app.bilko.company/auth/callback and https://app.bilko.company
- https://app.bilko.io/auth/callback and https://app.bilko.io
- https://bilko-demo.alai.no/auth/callback and https://bilko-demo.alai.no
- https://bilko-web-stage-dh4m46blja-lz.a.run.app/auth/callback and .a.run.app
- http://localhost:3000/auth/callback and http://localhost:3000

### 2.3 Adding identity providers or attributes

To add social identity providers (Google, Apple) or additional signup attributes (e.g. display name, company name): Microsoft Entra admin centre → External Identities → User flows → BilkoSignUpSignIn → Identity providers / Attributes. No code changes or redeploys are required for attribute-only changes. Adding a social provider requires app registration on the provider side and linking in the CIAM tenant.

---

## 3. Auth Flow — The Hard-Won Pattern

This section documents three bugs that were discovered and fixed during Proveo E2E validation (MC #103232 WS-V). The fixes are canonical — do not revert them.

### B1 — Token exchange (commit 660f410, tag v0.2.45)

**Problem:** MSAL's `LOGIN_SUCCESS` event fires with an Entra `access_token` (RS256, Microsoft-issued). The original code set this directly as the API Bearer header. The Bilko API validates HMAC256 JWTs only — all calls returned 401.

**Fix:** After MSAL fires, pass `payload.idToken` (not `payload.accessToken`) to a `POST /auth/entra/session { idToken }` call. The backend verifies the CIAM RS256 idToken via JWKS, looks up or JIT-provisions the user, and returns a Bilko HMAC JWT.

```

// apps/web/lib/msal/msal-provider.tsx — corrected token selection
const idToken = payload.idToken ?? payload.accessToken
if (idToken) { handleEntraLogin(idToken) }

// apps/web/lib/msal/use-entra-auth.ts — exchange call
const sessionResult = await api.auth.entraSession(idToken)
const bilkoJwt = sessionResult?.tokens?.accessToken
setAccessToken(bilkoJwt)
```

### B1.2 — Session persistence via setAuthFromRegistration (commit e1e31c5, tag v0.2.46)

**Problem:** After the B1 exchange, `checkAuth()` was called to hydrate the store. `checkAuth()` internally calls `POST /auth/refresh` using the httpOnly refresh-token cookie. The CIAM exchange path does not set a cookie — so `/auth/refresh` returned 401, which cleared the Bilko JWT and redirected back to `/login`.

**Fix:** Replace the `checkAuth()` call in `handleEntraLogin` with `setAuthFromRegistration()`, which hydrates the Zustand auth store directly from the `/auth/entra/session` response body. No cookie round-trip needed.

```

// apps/web/lib/msal/use-entra-auth.ts — hydrate from session response
const { setAuthFromRegistration } = useAuthStore.getState()
setAuthFromRegistration({
  user: sessionResult.user,
  organization: sessionResult.organization,
  tokens: { accessToken: bilkoJwt },
})
// Navigate to /dashboard — Bilko JWT is in-memory Bearer
```

### B1.3 — checkAuth in-memory JWT fast-path (commit 30a8c85, tag v0.2.47)

**Problem:** Even with B1.2, `setAuthFromRegistration()` in `handleEntraLogin` correctly set `isAuthenticated=true`. However, `AuthProvider` mounts on every protected route and calls `checkAuth()`. That call hit `/auth/refresh` (cookie path) → 401 → store reset to unauthenticated → redirect to `/login` on every page navigation.

**Fix:** Added an in-memory JWT fast-path at the top of `checkAuth()` in `auth-store.ts`. If a Bilko JWT is already in memory (set via the CIAM exchange), `checkAuth()` uses `GET /auth/me` with that Bearer token instead of falling through to the cookie-refresh path.

```

// apps/web/lib/stores/auth-store.ts — in-memory fast-path
checkAuth: async () => {
  const inMemoryToken = getAccessToken()
  if (inMemoryToken) {
    try {
      const me = await api.auth.me()
      set({ isAuthenticated: true, isLoading: false,
            user: { ...me, name: me.fullName },
            organization: me?.organization ?? null })
      return true
    } catch {
      set({ isAuthenticated: false, isLoading: false, user: null, organization: null })
      return false
    }
  }
  // Original cookie-refresh fallback (unchanged — non-CIAM sessions)
  ...
}
```

### ENTRA\_EXTERNAL\_ID\_AUDIENCE — critical build var (fixed in v0.2.47 trigger update)

**Problem:** The `bilko-main-deploy` Cloud Build trigger had `_ENTRA_EXTERNAL_ID_AUDIENCE` set to `fe39e0f5` (the API resource app ID). This is wrong — the CIAM idToken audience is the *SPA client ID* (`c2902239`), because MSAL requests id\_tokens scoped to the requesting app. Every new deploy reverted the Cloud Run env to the wrong value, requiring a manual patch.

**Fix:** The trigger substitution was updated:

```

# infrastructure/gcp/cloudbuild.yaml — correct value
_ENTRA_EXTERNAL_ID_AUDIENCE: c2902239-ea63-41bd-8619-6cf096d7d45a   # SPA client ID
# NOT: fe39e0f5-513e-40af-93f0-c3ee624df56c  (that is the API resource app — wrong for idToken aud)
```

This is now stable in the trigger — it will not revert on future deploys.

---

## 4. Backend JIT Provisioning

### 4.1 Database migrations (Flyway V66–V69)

<table id="bkmrk-migrationpurpose-v66"> <thead><tr><th>Migration</th><th>Purpose</th></tr></thead> <tbody> <tr><td>V66\_\_entra\_rls\_and\_password\_nullable.sql</td><td>Makes `password_hash` nullable (Entra-only users have no password). Adds RLS policy on `entra_external_identities` (FORCE + fail-closed). Adds CHECK constraint: issuer must not end with trailing slash.</td></tr> <tr><td>V67\_\_rbac\_permissions\_catalog.sql</td><td>RBAC permissions catalog seeding.</td></tr> <tr><td>V68\_\_rbac\_user\_provisioning.sql</td><td>SECURITY DEFINER function `bilko_auth.provision_user_with_org()`: creates org (7-day trial, `trial_starts_at`, `trial_ends_at = now() + 7 days`), creates user (`role='viewer'`, `password_hash=NULL`), inserts `entra_external_identities` row (issuer, OID, user\_id). Default: `country='BA'`, `currency='BAM'`.</td></tr> <tr><td>V69\_\_fix\_provision\_rls.sql</td><td>RLS fix: calls `set_config('app.current_org_id', v_org_id, true)` before the users INSERT so that RLS policies on the users table pass during JIT provisioning.</td></tr> </tbody></table>

### 4.2 JIT provisioning call flow

```

POST /auth/entra/session { idToken }
  → EntraExternalIdService.verifyIdToken()         [RS256, JWKS, issuer+audience+exp]
  → AuthUserRepository.findByEntraIdentity(issuer, oid)  → null (new user)
  → AuthUserRepository.findByEmail(email)                → null (no existing Bilko account)
  → UserProvisioningService.provisionNewUserForEntra()
      → bilko_auth.provision_user_with_org()  [SECURITY DEFINER, SERIALIZABLE]
          → INSERT organizations (trial 7 days)
          → INSERT users (role=viewer, password=null)
          → INSERT entra_external_identities (issuer, oid)
  → jwtService.signAccessToken(userId, email, role='viewer', orgId)
  → Response: { user, organization { trialEndsAt }, tokens { accessToken, refreshToken } }
```

**Idempotency:** Re-login with the same OID returns the existing user and org; trial end date is not reset. The `entra_external_identities` table has a UNIQUE constraint on `(issuer, subject)`.

### 4.3 trialEndsAt in /auth/me

The `GET /auth/me` response includes `organization.trialEndsAt` (ISO 8601). The frontend auth store exposes this on the `Organization` interface. The trial expiry is enforced server-side by `TrialGatePlugin` which queries the DB on every gated request — the JWT does not embed expiry.

### 4.4 RLS isolation

All JIT-provisioned tenants are isolated via PostgreSQL Row Level Security. The `app.current_org_id` session variable is set by `OrgScopePlugin` from the `BilkoPrincipal` (JWT-derived, not from any HTTP header). `orgTransaction()` uses `SET LOCAL` scoped to the transaction — connection pool does not carry state between requests. Cross-tenant isolation is verified by `RlsOrgIsolationV46IntegrationTest`.

---

## 5. Deploy

<table id="bkmrk-propertyvalue-deploy"> <thead><tr><th>Property</th><th>Value</th></tr></thead> <tbody> <tr><td>Deploy trigger</td><td>bilko-main-deploy (europe-north1, project tribal-sign-487920-k0)</td></tr> <tr><td>Trigger type</td><td>semver tag on main: `git tag vX.Y.Z && git push origin vX.Y.Z`</td></tr> <tr><td>Config</td><td>infrastructure/gcp/cloudbuild.yaml</td></tr> <tr><td>Current live tag</td><td>v0.2.47 (commit 30a8c85)</td></tr> <tr><td>Web revision</td><td>bilko-web-demo-00080-tq5</td></tr> <tr><td>API revision</td><td>bilko-api-demo-00155-524</td></tr> <tr><td>CIAM env vars in trigger</td><td>NEXT\_PUBLIC\_ENTRA\_CLIENT\_ID, NEXT\_PUBLIC\_ENTRA\_AUTHORITY, NEXT\_PUBLIC\_ENTRA\_SCOPE, ENTRA\_EXTERNAL\_ID\_ISSUER, ENTRA\_EXTERNAL\_ID\_AUDIENCE (= c2902239), ENTRA\_EXTERNAL\_ID\_JWKS\_URL</td></tr> </tbody></table>

**ZAKON PI2:** Do not run `cloudbuild.yaml` manually. Use `git tag + git push origin` only. The stage pipeline (`bilko-stage-auto-deploy`) fires on every push to main and is unrelated to the demo deploy.

---

## 6. Known Follow-Ups

<table id="bkmrk-idprioritydescriptio"> <thead><tr><th>ID</th><th>Priority</th><th>Description</th></tr></thead> <tbody> <tr> <td>H1</td> <td>HIGH — must-fix before scale launch</td> <td>**Abuse gate (MC #103245):** JIT provisioning has no server-side rate gate on tenant creation. An attacker with many email inboxes can script CIAM sign-ups (each requires a real OTP but automation services exist). Fix: add a platform-level provision rate gate in `UserProvisioningService.provisionNewUserForEntra()` (max N JIT orgs per hour) + CIAM tenant configuration to block disposable email domains.</td> </tr> <tr> <td>B3</td> <td>MEDIUM</td> <td>**Migadu email OTP blocking:** Migadu (one.com), used for `@alai.no`, blocks Microsoft Azure CIAM OTP emails. Prospects with Gmail or Outlook receive OTP in ~6 seconds. Alai staff using `@alai.no` addresses cannot sign up. Fix: whitelist `accountprotection.microsoft.com` sender in Migadu SPF settings, or configure a custom CIAM email sender domain.</td> </tr> <tr> <td>UX-1</td> <td>LOW</td> <td>**Org display name:** JIT-provisioned orgs are named "unknown's Organization" (no display name collected at signup). The user flow only collects email. Fix: add displayName to the BilkoSignUpSignIn attribute collection (Azure config, no code change), or collect it on first post-login screen.</td> </tr> <tr> <td>UX-2</td> <td>LOW</td> <td>**Default country/currency:** JIT-provisioned org defaults to `country='BA'`, `currency='BAM'`. Prospects outside Bosnia must update via Settings. A country selection step at signup would improve the onboarding experience (follow-on, not a blocker).</td> </tr> <tr> <td>M1</td> <td>MEDIUM</td> <td>**INGRESS\_TRAFFIC\_ALL (MC #99924):** Direct `*.run.app` access bypasses GCLB, which degrades IP-based rate limiting to per-GFE-region keying. Pre-existing risk, not introduced by CIAM. Fix: lock ingress to internal-only when load balancer is provisioned.</td> </tr> <tr> <td>M3</td> <td>MEDIUM</td> <td>**No alert on rapid tenant creation:** Add a GCP Cloud Monitoring alert triggering when more than N organisations are JIT-provisioned per hour.</td> </tr> </tbody></table>

---

## 7. Validation Evidence

### Proveo — 11/11 PASS (v0.2.47, 2026-06-09T02:55Z)

Real Gmail sign-up (alembasic@gmail.com) end-to-end on `bilko-demo.alai.no`:

<table id="bkmrk-stepresultdetails-1p"> <thead><tr><th>Step</th><th>Result</th><th>Details</th></tr></thead> <tbody> <tr><td>1</td><td>PASS</td><td>Self-serve copy present; "Contact your administrator" absent</td></tr> <tr><td>2</td><td>PASS</td><td>"Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect</td></tr> <tr><td>3</td><td>PASS</td><td>Email entered on CIAM; OTP sent immediately</td></tr> <tr><td>4</td><td>PASS</td><td>Returning user — OTP sent directly (no create-account needed)</td></tr> <tr><td>5</td><td>PASS</td><td>8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds</td></tr> <tr><td>6</td><td>PASS</td><td>Redirect back to bilko-demo.alai.no/dashboard</td></tr> <tr><td>7</td><td>PASS</td><td>POST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed</td></tr> <tr><td>8</td><td>PASS</td><td>/dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15</td></tr> <tr><td>9</td><td>PASS</td><td>/invoices via SPA nav — empty org (0 invoices), session alive</td></tr> <tr><td>10</td><td>PASS</td><td>/invoices/new — invoice form visible, trial tenant usable</td></tr> <tr><td>11</td><td>PASS</td><td>Regression clear — admin wall absent, self-serve copy confirmed</td></tr> </tbody></table>

**Zero /auth/refresh calls** during SPA navigation after B1.3 fix (confirmed by network capture count=0). Cross-tenant RLS: org 4e96b6ff shows 0 invoices and 0 BAM balances (no data from other tenants).

### Securion — LAUNCH WITH CONDITIONS

- CRITICAL: None found.
- HIGH: H1 (JIT provisioning rate gate) — must fix before scale launch (MC #103245).
- PASS areas: RS256 JWKS verification, issuer/audience pinning, OID as identity anchor, alg:none bypass blocked, org\_id derived from DB (not Entra token), RLS fail-closed, FORCE RLS on all 9 tenant tables, role=viewer hardcoded (no self-escalation), trial re-signup blocked, refresh token rotation (jti-based single-use), legacy auth endpoints retired (HTTP 410).

---

## 8. DEPLOY-MAP Reference

The CIAM substitutions live in `infrastructure/gcp/cloudbuild.yaml` under the `bilko-main-deploy` trigger. The Cloudflare Turnstile entries in DEPLOY-MAP.md cover the marketing landing forms and are unrelated to the CIAM auth flow. No DEPLOY-MAP.md changes are required for the CIAM self-serve trial feature — the trigger substitutions are already updated.

Do not add CIAM secrets to the DEPLOY-MAP secrets table — these are build-time substitutions injected directly from the trigger, not GCP Secret Manager secrets.

---

## 9. Environment Variables Reference

<table id="bkmrk-variableservicecorre"> <thead><tr><th>Variable</th><th>Service</th><th>Correct value note</th></tr></thead> <tbody> <tr><td>NEXT\_PUBLIC\_ENTRA\_CLIENT\_ID</td><td>bilko-web-demo (build-time)</td><td>c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app)</td></tr> <tr><td>NEXT\_PUBLIC\_ENTRA\_AUTHORITY</td><td>bilko-web-demo (build-time)</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/v2.0 — no user flow suffix needed</td></tr> <tr><td>NEXT\_PUBLIC\_ENTRA\_SCOPE</td><td>bilko-web-demo (build-time)</td><td>api://fe39e0f5.../access\_as\_user</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_ISSUER</td><td>bilko-api-demo</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/v2.0</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_AUDIENCE</td><td>bilko-api-demo</td><td>**c2902239-ea63-41bd-8619-6cf096d7d45a** (SPA client ID — NOT the API resource ID)</td></tr> <tr><td>ENTRA\_EXTERNAL\_ID\_JWKS\_URL</td><td>bilko-api-demo</td><td>https://\[tenant-id\].ciamlogin.com/\[tenant-id\]/discovery/v2.0/keys</td></tr> </tbody></table>

**Critical:** `ENTRA_EXTERNAL_ID_AUDIENCE` must be the SPA client ID (`c2902239`), not the API resource app ID. MSAL requests id\_tokens with the SPA client as audience. If set to the API app ID, the backend rejects every CIAM idToken with audience mismatch.

---

*Page created by Skillforge (MC #103232 WS-D, 2026-06-09). Source evidence: /tmp/evidence-103232/. Validation: Proveo 11/11 PASS + Securion LAUNCH WITH CONDITIONS.*