Skip to main content

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

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

PropertyValue
Tenant namebilkociam
Tenant ID20bb17de-9be5-4143-a7e5-8c1ddae6a064
Tenant typeCIAM (Entra External ID)
SPA app nameBilko Web (SPA)
SPA client IDc2902239-ea63-41bd-8619-6cf096d7d45a
API resource app IDfe39e0f5-513e-40af-93f0-c3ee624df56c
Authority URLhttps://20bb17de-9be5-4143-a7e5-8c1ddae6a064.ciamlogin.com/20bb17de-9be5-4143-a7e5-8c1ddae6a064/v2.0
OIDC issuersame as authority URL (confirmed via discovery endpoint)

2.1 User flow: BilkoSignUpSignIn

PropertyValue
Flow IDaa86084b-01dc-453f-9e10-679dfefdd824
TypeexternalUsersSelfServiceSignUpEventsFlow
Display nameBilkoSignUpSignIn
Identity providerEmailOtpSignup-OAUTH (Email One Time Passcode)
isSignUpAllowedtrue
userTypeToCreatemember (not guest)
Attributes collectedemail (auto-filled by OTP verification)
Linked appc2902239-ea63-41bd-8619-6cf096d7d45a (Bilko Web SPA)

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)

MigrationPurpose
V66__entra_rls_and_password_nullable.sqlMakes 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.sqlRBAC permissions catalog seeding.
V68__rbac_user_provisioning.sqlSECURITY 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.sqlRLS 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

PropertyValue
Deploy triggerbilko-main-deploy (europe-north1, project tribal-sign-487920-k0)
Trigger typesemver tag on main: git tag vX.Y.Z && git push origin vX.Y.Z
Configinfrastructure/gcp/cloudbuild.yaml
Current live tagv0.2.47 (commit 30a8c85)
Web revisionbilko-web-demo-00080-tq5
API revisionbilko-api-demo-00155-524
CIAM env vars in triggerNEXT_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

IDPriorityDescription
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:

StepResultDetails
1PASSSelf-serve copy present; "Contact your administrator" absent
2PASS"Sign in with Microsoft" → ciamlogin.com (tenant 20bb17de) redirect
3PASSEmail entered on CIAM; OTP sent immediately
4PASSReturning user — OTP sent directly (no create-account needed)
5PASS8-digit OTP (17717965) received via Gmail UID:75644 in 7 seconds
6PASSRedirect back to bilko-demo.alai.no/dashboard
7PASSPOST /auth/entra/session → 200, Bilko HMAC JWT, org 4e96b6ff confirmed
8PASS/dashboard with trial UI ("Probno: 6 dana preostalo"), /auth/me → 200 + trialEndsAt 2026-06-15
9PASS/invoices via SPA nav — empty org (0 invoices), session alive
10PASS/invoices/new — invoice form visible, trial tenant usable
11PASSRegression 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

VariableServiceCorrect value note
NEXT_PUBLIC_ENTRA_CLIENT_IDbilko-web-demo (build-time)c2902239-ea63-41bd-8619-6cf096d7d45a (SPA app)
NEXT_PUBLIC_ENTRA_AUTHORITYbilko-web-demo (build-time)https://[tenant-id].ciamlogin.com/[tenant-id]/v2.0 — no user flow suffix needed
NEXT_PUBLIC_ENTRA_SCOPEbilko-web-demo (build-time)api://fe39e0f5.../access_as_user
ENTRA_EXTERNAL_ID_ISSUERbilko-api-demohttps://[tenant-id].ciamlogin.com/[tenant-id]/v2.0
ENTRA_EXTERNAL_ID_AUDIENCEbilko-api-democ2902239-ea63-41bd-8619-6cf096d7d45a (SPA client ID — NOT the API resource ID)
ENTRA_EXTERNAL_ID_JWKS_URLbilko-api-demohttps://[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.