Bilko Self-Serve Trial — CIAM Architecture and Auth Pattern (MC #103232)
Bilko Self-Serve Trial — CIAM Architecture & Auth Pattern
MC: #103232 | Status: LIVE — Proveo 11/11 PASS | Last updated: 2026-06-09 | Securion verdict: LAUNCH WITH CONDITIONS
1. Overview
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
| Property | Value |
|---|---|
| Tenant name | bilkociam |
| Tenant ID | 20bb17de-9be5-4143-a7e5-8c1ddae6a064 |
| Tenant type | CIAM (Entra External ID) |
| SPA app name | Bilko Web (SPA) |
| SPA client ID | c2902239-ea63-41bd-8619-6cf096d7d45a |
| API resource app ID | fe39e0f5-513e-40af-93f0-c3ee624df56c |
| Authority URL | https://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0 |
| OIDC issuer | same as authority URL (confirmed via discovery endpoint) |
2.1 User flow: BilkoSignUpSignIn
| Property | Value |
|---|---|
| Flow ID | aa86084b-01dc-453f-9e10-679dfefdd824 |
| Type | externalUsersSelfServiceSignUpEventsFlow |
| Display name | BilkoSignUpSignIn |
| Identity provider | EmailOtpSignup-OAUTH (Email One Time Passcode) |
| isSignUpAllowed | true |
| userTypeToCreate | member (not guest) |
| Attributes collected | email (auto-filled by OTP verification) |
| Linked app | c2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA) |
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
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)
| Migration | Purpose |
|---|---|
| V66__entra_rls_and_password_nullable.sql | 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. |
| V67__rbac_permissions_catalog.sql | RBAC permissions catalog seeding. |
| V68__rbac_user_provisioning.sql | 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'. |
| V69__fix_provision_rls.sql | 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. |
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
| Property | Value |
|---|---|
| Deploy trigger | bilko-main-deploy (europe-north1, project tribal-sign-487920-k0) |
| Trigger type | semver tag on main: git tag vX.Y.Z && git push origin vX.Y.Z |
| Config | infrastructure/gcp/cloudbuild.yaml |
| Current live tag | v0.2.47 (commit 30a8c85) |
| Web revision | bilko-web-demo-00080-tq5 |
| API revision | bilko-api-demo-00155-524 |
| CIAM env vars in trigger | 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 |
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
| ID | Priority | Description |
|---|---|---|
| H1 | HIGH — must-fix before scale launch | 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. |
| B3 | MEDIUM | 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. |
| UX-1 | LOW | 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. |
| UX-2 | LOW | 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). |
| M1 | MEDIUM | 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. |
| M3 | MEDIUM | No alert on rapid tenant creation: Add a GCP Cloud Monitoring alert triggering when more than N organisations are JIT-provisioned per hour. |
7. Validation Evidence
Proveo — 11/11 PASS (v0.2.47, 2026-06-09T02:55Z)
Real Gmail sign-up ([email protected]) end-to-end on bilko-demo.alai.no:
| Step | Result | Details |
|---|---|---|
| 1 | PASS | Self-serve copy present; "Contact your administrator" absent |
| 2 | PASS | "Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect |
| 3 | PASS | Email entered on CIAM; OTP sent immediately |
| 4 | PASS | Returning user — OTP sent directly (no create-account needed) |
| 5 | PASS | 8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds |
| 6 | PASS | Redirect back to bilko-demo.alai.no/dashboard |
| 7 | PASS | POST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed |
| 8 | PASS | /dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15 |
| 9 | PASS | /invoices via SPA nav — empty org (0 invoices), session alive |
| 10 | PASS | /invoices/new — invoice form visible, trial tenant usable |
| 11 | PASS | Regression clear — admin wall absent, self-serve copy confirmed |
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
| Variable | Service | Correct value note |
|---|---|---|
| NEXT_PUBLIC_ENTRA_CLIENT_ID | bilko-web-demo (build-time) | c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app) |
| NEXT_PUBLIC_ENTRA_AUTHORITY | bilko-web-demo (build-time) | https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 — no user flow suffix needed |
| NEXT_PUBLIC_ENTRA_SCOPE | bilko-web-demo (build-time) | api://fe39e0f5.../access_as_user |
| ENTRA_EXTERNAL_ID_ISSUER | bilko-api-demo | https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 |
| ENTRA_EXTERNAL_ID_AUDIENCE | bilko-api-demo | c2902239-ea63-41bd-8619-6cf096d7d45a (SPA client ID — NOT the API resource ID) |
| ENTRA_EXTERNAL_ID_JWKS_URL | bilko-api-demo | https://[tenant-id].ciamlogin.com/[tenant-id]/discovery/v2.0/keys |
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.
No comments to display
No comments to display