think
16px
820px

Design: Authentication for ahu-ocr-tidyup

Date: 2026-07-15 · Author: Efran Nathanael (with Claude) · Status: design, pre-implementation.

Companion docs: identity recon → docs/research/2026-07-15-rebuild-public-auth-recon.md; push-back connector (the consumer of this) → tidyup/2026-07-13-prod-integration-readiness.md + backend/src/services/push/.


1. Why now, and what already exists

We're adding real authentication. Two forces converged: per-account ownership (Submission.ownerId) and audit actor-attribution have been stubbed-but-dormant since Phase 1, and the push-back connector is blocked on not knowing the origin user (services/push/payload-builders.tsbuildOriginIdentity() throws today).

Crucially, the foundation is already laid — this is wiring, not a greenfield build:

Seam File State
Verifier interface (swap by iss) security/token-verifier.ts built; header literally reserves the "SabhSsoVerifier (RS256/JWKS) drops in later selected by iss" slot
Session minting signLocalToken() (same file) works (HS256, dependency-free)
Auth middleware (never rejects) security/authenticate.ts built; attaches principal, shadow-safe
Route policies + enforcement modes security/route-policies.ts (off/shadow/on) built
Owner stamping + list scoping security/owner.ts built; dormant until SECURITY_ENFORCE=on + real tokens
Role vocabulary notaris \| verifikator \| verifikator_pt \| verifikator_apostille \| admin (+ FE perseroan/umum) in place

So the work is: a real KeycloakVerifier, an SSO-exchange endpoint, a claims→persona map, profile resolution, provenance columns, an FE landing route, and finally flipping SECURITY_ENFORCE=on.

2. Hard constraints (from the recon — these shape everything)

  1. One IdP covers PP + Apostille. Both are OIDC clients of Keycloak sso-dev.kemenkum.go.id, realm public, client djahu, verified by JWKS/RS256. SABH is different (internal, HS256 sk_epicenter handoff). Our iss-dispatched verifier handles all three with one seam.
  2. Their token carries almost no identity — only djahu_userId, preferred_username, resource_access.djahu.roles, module_code, sid, exp/iat. No NIK/name/email/address. Identity is read from the shared m_users DB, not the token.
  3. The userSession cookie will not reach us — it's the portal's, scoped to their parent domain. On *.val.id it does not travel. We need our own client (auth-code flow) or an explicit token hand-off, and our own session afterward (their token dies in minutes; our verification runs for days).
  4. Do not mirror their authorization — no FE route gating, RolesGuard dead/landmine in both, resource_access.djahu is client-keyed (ours lands elsewhere), no aud validation. Map their claims to our role model at the boundary and enforce with route-policies.ts.
  5. We verify identity harder than they do. Zero Dukcapil/KYC upstream; NIK is user-typed and unvalidated. Our OCR reads NIK off a real KTP — an OCR-vs-m_users mismatch is a signal to surface, not to reconcile.

3. Target architecture

sequenceDiagram autonumber participant U as User (browser) participant O as Origin (PP / Apostille / SABH) participant KC as Keycloak realm `public` (or SABH signer) participant FE as Our SPA (/sso) participant BE as Our API (/api/auth/sso) participant DB as m_users (read-only) U->>O: logged in at the portal O->>FE: button → redirect to /sso?token=&return=&ctx=<...> FE->>BE: POST /api/auth/sso { handoffToken, returnUrl, ctx } BE->>KC: verify (JWKS/RS256 by iss; SABH → HS256) BE->>DB: resolve profile by djahu_userId (nik,name,email) — lazy/cached BE->>BE: map claims → our persona; upsert OriginUser; mint OUR session (signLocalToken) BE-->>FE: { sessionToken, persona, returnUrl } FE->>FE: setAuthToken + setRole; navigate(next) Note over U,BE: user uploads/verifies for days on OUR session BE->>O: push-back carries originUserRef = djahu_userId (see services/push)

The exchange is the linchpin: verify their short-lived handoff once, mint our own session, and record where the user came from so the completed submission can be pushed back and the user returned.

4. Component design

4.1 KeycloakVerifier (RS256 / JWKS), dispatched by iss

New security/keycloak-verifier.ts implementing the existing TokenVerifier interface — so call sites never change (the interface's stated purpose).

  • Fetch + cache JWKS from ${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/certs (cache by kid, refetch on miss, rate-limit like theirs). No new dependency if we verify RS256 via node:crypto createVerify against the JWKS-derived public key; a tiny JWKS fetch/cache helper is all that's needed.
  • Verify: signature, iss == our configured issuer, exp, and aud/azp = our client id (the check both rebuilds omit — we add it).
  • Map claims → TokenClaims, but do not hardcode resource_access.djahu: read resource_access[OUR_CLIENT].roles (config-driven client id), guarded (missing → empty roles, never a throw).
  • Never throws (shadow contract): returns TokenClaims | null.

Dispatcher. authenticate.ts currently holds one verifier. Add a small CompositeVerifier that peeks the unverified iss and routes: our-Keycloak-issuer → KeycloakVerifier; SABH-issuer → a SabhHs256Verifier (the sk_epicenter handoff, later); our-own-issuer → LocalJwtVerifier (our session tokens). Peeking iss before verifying is safe because each branch fully verifies; an unknown iss → null → anonymous.

DECIDE: own Keycloak client + full auth-code flow (we redirect to Keycloak, exchange code) vs. accept the portal's handoff token in the redirect and verify it. Auth-code is cleaner and gives us aud; handoff is faster to ship but puts a bearer in a URL (their own /auth/refresh?refresh_token= already does this). Recommendation: auth-code with our own client, handoff as an interim only if the client isn't provisioned in time. Both need AHU-REQ-02 resolved.

4.2 POST /api/auth/sso — the exchange (new, in routes/auth.ts)

The only new inbound trust boundary. Steps:
1. Verify the handoff token via the composite verifier. Invalid → 401 (this endpoint does reject, unlike the shadow middleware).
2. Extract djahu_userId (→ originUserRef), iss (→ originSystem), roles/module_code.
3. Resolve profile (§4.4) — best-effort; absence is not fatal.
4. Map to our persona (§4.3).
5. Upsert an OriginUser (§4.5) keyed by (originSystem, originUserRef) → our stable local userId.
6. Mint our session via signLocalToken({ sub: <our userId>, role: <persona>, name }, SECURITY_JWT_SECRET, { expiresInSeconds }) with our own iss.
7. Return { sessionToken, persona, returnUrl }. The FE stores it and navigates.

Sits beside the existing dev POST /api/auth/login (the header already says "In prod, SABH-SSO replaces it").

4.3 Claims → persona mapping (security/persona-map.ts, new)

Their vocabulary is inconsistent and client-keyed; we translate once, at the boundary, into our roles and never depend on theirs again.

Origin signal Our persona
Apostille staff roles (la-apl-verifikator, la-lgl-verifikator, la-*-kasi) verifikator_apostille
PP/PT staff (admin/pp-admin, verifikator-family) verifikator_pt (or admin)
Applicant — individu/individu-wna, or absence of any staff role perseroan (self-service applicant)
SABH notaris (from the SABH handoff claims) notaris
unmatched umum (least privilege)

Public status is inferred by absence (there's no positive claim) — but we make that decision here, once, and record the concrete persona, rather than re-inferring it per request the way they do.

4.4 Profile resolution (services/origin-profile.ts, new)

Identity beyond the id must be fetched. Two options, both proven by their own code:
- (A) Read m_users directly by djahu_userId (AUTH_DATABASE_URL) — matches our established pattern (we already run 4 read-only external-DB clients: sabh-db, pp-registry-db, apostille-spesimen-db, simpadhu-db) and avoids a portal round-trip. Fields: nik, no_kitas_paspor, fullname, email, no_telp, tgl_lahir, alamat.
- (B) Call the portal GET /auth/data + /users/profile — what their SPA does; needs the portal base URL and S2S sanction.

Recommendation: (A), dormant/fail-soft (unset AUTH_DATABASE_URL ⇒ profile null, session still mints from token claims), cache per originUserRef. Store the resolved NIK/name on the submission provenance so push-back and audit have it without re-fetching.

Carry-forward: treat m_users.nik as unverified and m_users.valid as unknown-semantics until the portal team confirms (AHU-REQ-02). Surface OCR-vs-m_users NIK mismatch; never auto-reconcile.

4.5 Provenance columns (Prisma migration — the bit that unblocks push-back)

Add to Submission (additive, nullable, dormant-safe):

originSystem        String?   // 'PP_REBUILD' | 'APOSTILLE_REBUILD' | 'SABH'
originUserRef       String?   // djahu_userId (PP/Apostille) / SABH user id — THE subject for push-back
originTransactionRef String?  // their permohonan/transaksi id when the user came from an existing record
returnUrl           String?   // where to send the user back on completion

Plus a small OriginUser table ((originSystem, originUserRef) unique → our id, cached profile) so our principal.sub is a stable local id, not a foreign one. Stamp originSystem/originUserRef/originTransactionRef/returnUrl at flow-create from the SSO session + redirect context. This makes buildOriginIdentity() return real data and turns the connector on.

4.6 FE /sso landing route (frontend/src/routes.tsx + a page)

Public route: read ?token, ?return, ?ctxPOST /api/auth/sso → on success setAuthToken(sessionToken) (lib/auth-token.ts) + setRole(persona) (lib/role.ts, key userRole.v2) → navigate(next). Mechanically identical to what LoginPage.handleSubmit already does; the RoleSwitcher stays as a dev-only affordance. On failure → an error page, not a bounce to their portal (we're not the portal).

4.7 Our session model

  • Our own HS256 session token (via signLocalToken), iss = ours, carrying sub (local id), role (persona), name. Verified by LocalJwtVerifier on every request — already wired.
  • Lifetime: long enough for a verification sitting (hours), refreshed by re-exchange while the origin session is alive, or by a modest sliding window. (Their tokens are ~minutes; we deliberately decouple.) DECIDE exact TTL + refresh policy once Keycloak lifetime is known.
  • Stored the way the current dev login stores it (localStorage authToken); revisit HttpOnly-cookie hardening at enforcement flip.

4.8 Enforcement flip

SECURITY_ENFORCE: offshadow (now) → on. Prerequisites before on: tokens actually flowing (this design), route-policies.ts coverage reviewed, CORS pinned to the origins, owner-scoping (owner.ts) validated, and the spoofable rate-limit key (behind an untrusted proxy) fixed. Ship everything above in shadow first (attaches principals, changes no behavior), verify audit actor-attribution populates, then flip.

5. Phasing

Phase Work Unblocks
A Provenance columns + OriginUser + stamp at flow-create push-back connector (buildOriginIdentity)
B KeycloakVerifier + iss dispatch + aud check verifying real portal tokens
C POST /api/auth/sso + persona map + FE /sso end-to-end SSO-in
D Profile resolution from m_users NIK/name for audit + push-back + OCR cross-check
E SABH HS256 handoff verifier the third origin
F SECURITY_ENFORCE=on + hardening real enforcement

A+B+C are the foundation slice. A alone unblocks the connector even before SSO is live (we can stamp provenance from the dev login meanwhile).

6. Open decisions

  1. Auth-code flow vs. handoff token (§4.1) — recommend auth-code with our own client.
  2. Profile via DB vs. portal API (§4.4) — recommend direct m_users read.
  3. Session TTL + refresh (§4.7) — pending Keycloak lifetime.
  4. Hosting domain — under the portal domain (cookie shares, simpler) vs. *.val.id (must do auth-code). Feeds AHU-REQ-02.
  5. m_users.valid + NIK assurance semantics — need the portal team's answer before we key anything off them.

7. Do NOT copy from the rebuilds (recon §8)

No FE route gating; RolesGuard reading an unmapped claim (landmine); unguarded resource_access.djahu deref; missing aud; refresh tokens in query strings; non-HttpOnly session cookies; bearer tokens logged to stdout; and PP's unauthenticated live-DJP-NPWP endpoint. We translate their identity, not their (non-)authorization.