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.ts → buildOriginIdentity() 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)
- One IdP covers PP + Apostille. Both are OIDC clients of Keycloak
sso-dev.kemenkum.go.id, realmpublic, clientdjahu, verified by JWKS/RS256. SABH is different (internal, HS256sk_epicenterhandoff). Ouriss-dispatched verifier handles all three with one seam. - 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 sharedm_usersDB, not the token. - The
userSessioncookie will not reach us — it's the portal's, scoped to their parent domain. On*.val.idit 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). - Do not mirror their authorization — no FE route gating,
RolesGuarddead/landmine in both,resource_access.djahuis client-keyed (ours lands elsewhere), noaudvalidation. Map their claims to our role model at the boundary and enforce withroute-policies.ts. - 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_usersmismatch is a signal to surface, not to reconcile.
3. Target architecture
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 bykid, refetch on miss, rate-limit like theirs). No new dependency if we verify RS256 vianode:cryptocreateVerifyagainst the JWKS-derived public key; a tiny JWKS fetch/cache helper is all that's needed. - Verify: signature,
iss== our configured issuer,exp, andaud/azp= our client id (the check both rebuilds omit — we add it). - Map claims →
TokenClaims, but do not hardcoderesource_access.djahu: readresource_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 needAHU-REQ-02resolved.
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.nikas unverified andm_users.validas unknown-semantics until the portal team confirms (AHU-REQ-02). Surface OCR-vs-m_usersNIK 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, ?ctx → POST /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, carryingsub(local id),role(persona),name. Verified byLocalJwtVerifieron 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: off → shadow (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
- Auth-code flow vs. handoff token (§4.1) — recommend auth-code with our own client.
- Profile via DB vs. portal API (§4.4) — recommend direct
m_usersread. - Session TTL + refresh (§4.7) — pending Keycloak lifetime.
- Hosting domain — under the portal domain (cookie shares, simpler) vs.
*.val.id(must do auth-code). FeedsAHU-REQ-02. 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.