think
16px
820px

Recon: how PP & Apostille Rebuild authenticate PUBLIC users

Date: 2026-07-15 · Why: input to building auth in ahu-ocr-tidyup. Efran's premise — "PP and Apostille are public-facing, unlike SABH which is internal-only" — is confirmed, and the auth model that follows from it is unusual enough to change our design.

Evidence: code-level survey of ahu-apostille-api + module-apostille and ahu-perseroan-perorangan-api + module-perseroan-perseorangan (file:line throughout).

Not available locally — the central portal ("Marina"/djahu) codebase, the Keycloak realm config, and the User-Management ("usman") API. Anything about registration, credential storage, cookie issuance, token lifetime, and identity vetting is inference from the consumer side and is marked so.


1. Headline: neither system authenticates anyone

Both are pure SSO consumers. Neither registers users, logs them in, owns a session, or verifies identity. The API validates a Keycloak RS256 signature and reads ~6 claims; the SPA checks a cookie is non-empty and otherwise bounces to the portal.

Value Evidence
IdP Keycloak https://sso-dev.kemenkum.go.id PP docker-compose.yaml:47
Realm public — literally the citizen realm PP docker-compose.yaml:48
Client djahu (= Ditjen AHU) jwt.strategy.ts (resource_access.djahu.roles)
Validation JWKS + RS256 + issuer. No audience check in either. PP jwt.strategy.ts:18-26; AP jwt.strategy.ts:11-18
Credentials Keycloak. m_users.password is nullable and never read by either API. AP prisma/auth/schema.prisma:20
Registration Portal only. Neither repo has a signup route, page, or API. both: exhaustive grep, 0 hits

Both repos carry a complete but dead local-auth implementation (Login.jsx, OauthCallback.jsx, forget/reset password, login sagas) — unrouted, and in Apostille's case importing a symbol (apiPostOauthSSO) that doesn't exist, proving it never ran. Ignore all of it; it is not the model.

Entry path is uniform: no userSession cookie → hard redirect to ${VITE_APP_PORTAL_HOST}/login (PP routes/route.jsx:15-29, AP routes/route.jsx:13-19).

2. The token tells you almost nothing

The complete claim set both APIs read (PP jwt.strategy.ts:29-39, AP jwt.strategy.ts:22-32):

Claim Meaning
djahu_userId The only durable human identifier — numeric PK into the shared m_users.
preferred_username Mapped to email by both — but it's the username. m_users has both username (unique, NOT NULL) and email (unique, nullable). Do not treat it as an email address.
resource_access.djahu.roles Role names (string[]).
module_code Module entitlements (string[] despite the singular name). '20' = PP, '23' = Apostille/Legalisasi.
sid Keycloak session id.
exp / iat Both APIs map these swapped (initial: exp, expired: iat). Don't copy.

No NIK. No name. No email. No phone. No NPWP. No address. Nothing that identifies the human beyond a row id.

So both systems read identity from the shared user DB, not the token. The sharpest example — Apostille's PNBP billing (verifikasi.service.ts:1714-1760):

const user = await this.authRepository.findById(id_user);   // m_users row
if (user.nik) { npwp = '999999999999999'; nik = user.nik; }
else if (user.no_kitas_paspor) { /* WNA: fake NIK */ }
const body = { npwp, nik, namaPemohon: user.fullname, emailPemohon: user.email, ... };

The bearer on that SIMPADHU call is pure transport auth — it carries no billing identity. (This independently confirms §3 of our ingest plan.) PP does the same for fullname/email via its userman client.

m_users supports WNA (foreign nationals) via no_kitas_paspor — Apostille gates billing on nik OR no_kitas_paspor. That's a public-facing model, not a staff directory.

3. "Public user" is an absence, never an assertion

There is no positive citizen claim. Neither backend can say "this token is an ordinary applicant" — it concludes it by fall-through:

  • PP — the only real check, in 2 of ~20 repo files (notif.repository.ts:12-18, lapkeu.repository.ts:300-307): hasAdminAccess = roles.some(r => r === 'admin' || r === 'pp-admin'); if not → scope queries to id_user = user.user_id. Everywhere else makes no staff/public distinction at all.
  • Apostille — staff gates are hand-rolled inline ~30× (verifikasi.service.ts:110-126): module_codes.includes('23') + a literal allow-list (la-apl-verifikator, la-lgl-kasi, kanwil, …). Public = not in the list.

POST /permohonan — the applicant's core action — has no role check whatsoever (permohonan.controller.ts:52-64). Any signature-valid realm token can file an application.

Role vocabularies are a mess worth knowing about: PP's role_ptp_enum (Individu, ptp-admin, …) is only a notification-audience tag, never read for authz — and it maps to "ptp-admin" while the token check compares 'pp-admin' (different strings). Apostille's frontend invents individu/individu-wna markers the backend has no concept of. In Apostille, JWT roles are title-shaped (la-apl-verifikator) while m_users_roles.r_roles_code holds short codes (10.02) — token roles ≠ DB role codes; don't join them naively.

4. Identity assurance: there is none

Grepped both repos for dukcapil, kyc, ektp, email_verified, activation, NIK checksum/Luhn → zero hits.

  • PP's NIK is user-typed and unvalidated: @IsOptional() @IsString() nik?: string (create_pendirian.dto.ts:276-279) — no length, no regex, despite "16 digit" in the description. nik: "x" is accepted; stored verbatim. It is never compared to m_users.nik in the pendirian flow.
  • m_users.nik is @unique — that enforces no duplicates, not truth. Whatever the portal wrote at registration is trusted forever.
  • The valid column is dead in both APIs — never read, never written (and Apostille's updateUser is never called). Its semantics live in the portal and are unknowable from here. Do not key anything off it.
  • DJP CTAS does not prove identity — it registers an NPWP for the company using AHU's own machine credentials. PP even substitutes the NIK into the NPWP field when NPWP is absent (pendirian.repository.ts:699).
  • Age ≥18 is enforced but self-declared (isAtLeast18YearsOld on the DTO's tanggal_lahir, never against m_users.tgl_lahir) — and perubahan/pembubaran skip it entirely.

Consequence for us: our OCR extracts NIK from an actual KTP. That is more identity verification than the portal has ever done. A mismatch between an OCR'd NIK and m_users.nik is a signal to surface, not something to silently reconcile.

5. Session mechanics — the portal owns everything

  • userSession = the raw Keycloak access token; refreshSession = the refresh token. Plain cookies, not HttpOnly (the SPAs read them with JS).
  • The portal sets them on a shared parent domain [INFERRED — it's the only config under which a SPA on another host can read them]. Both SPAs' own setCookie is attribute-less (Cookie.set(name, value)) → host-only, which is also the source of a real double-cookie bug after refresh in both repos.
  • Refresh is reactive only (on HTTP 401), via GET /auth/refresh?refresh_token=<rt>the refresh token travels in the query string (proxy/access-log exposure).
  • Token lifetime is NOT knowable from these repos. The "~15 min" is a hardcoded, dead client-side constant (authService.js:120-137, never called) reading a localStorage key nothing writes. The real value is a Keycloak realm setting.
  • Expiry handling is broken in several places (forced-logout commented out; error.status checked instead of error.response.status; Apostille's primary /auth/data client has no refresh at all). We cannot rely on their SPA to recover a session.

6. What this means for OUR auth

The single best outcome: one Keycloak client in realm public gives us SSO for BOTH PP and Apostille users — same IdP, same realm, same claim set. That's the whole point of them sharing djahu.

  1. Be an OIDC client in realm public (authorization-code flow). Do not plan on inheriting the userSession cookie: it's scoped to the portal's parent domain, so if we live on *.val.id the cookie will not travel. Either we get hosted under the portal domain, or we do a proper auth-code flow, or we accept an explicit token hand-off in the redirect (which is what their own /auth/refresh?refresh_token= already does badly — a bearer in a URL).
  2. Verify offline — JWKS + RS256 + issuer, exactly as they do, and add the aud/azp check they omit. This slots into our existing security/token-verifier.ts seam (multi-issuer by iss, already designed for it).
  3. djahu_userId is our originUserRef. This directly unblocks the push-back connector's #1 blocker (services/push/payload-builders.tsbuildOriginIdentity() currently throws). It's also exactly what PP's ownership checks compare (id_user === Number(userId)), so we and they would agree on identity.
  4. Resolve profile (NIK/name/email) the way they do — read m_users by id, or call the portal /auth/data + /users/profile. Direct DB read matches our established pattern (4 read-only external DB clients already) and avoids a portal round-trip per request. Their backends do the same, guaranteeing we agree.
  5. Do not hardcode resource_access.djahu.roles. resource_access is keyed by client id — with our own client our roles land at resource_access.<our-client>.roles. Their hardcoded .djahu deref is also unguarded (TypeError → 500 on a token lacking it).
  6. Do not mirror their authorization model. It is not a security boundary: no route gating in either SPA, RolesGuard dead in both (Apostille's reads realm_access the strategy never maps — a landmine: the first @Roles() anyone adds denies 100% of requests), and PP has an unauthenticated endpoint that fires a live DJP NPWP registration. Map their claims to our own role model at the exchange point (routes/auth.tssignLocalToken) and enforce with our existing route-policies.ts.
  7. Public status must be inferred by absence of staff roles — there is no positive claim. Better: decide our own persona at the exchange and stop depending on their vocabulary.

7. What we must ask the portal / SSO team (feeds ClickUp AHU-REQ-02)

  1. Our own Keycloak client in realm public — and confirmation that the custom protocol mappers djahu_userId and module_code are attached to OUR client's tokens. These are non-standard; without djahu_userId we have no user id at all and the whole integration fails.
  2. PROD realm + URL — we only ever see sso-dev.kemenkum.go.id. Realm name is env-only; not in any repo.
  3. Token lifetime + refresh policy (unknowable from the repos).
  4. Hosting/domain: can we sit under the portal's parent domain (cookie shares), or must we run a full auth-code flow?
  5. Profile access: read access to m_users (AUTH_DATABASE_URL / userman), or is /auth/data + /users/profile sanctioned for server-to-server?
  6. What does m_users.valid mean, and is there ANY identity assurance at registration (Dukcapil?) — because there is none downstream.

8. Security findings to relay (their systems, found in passing)

  • PP @Public() POST /pendirian/generate-npwp-by-ptp/:id_ptp (pendirian.controller.ts:342) — no guard at all, triggers a live DJP NPWP registration, unauthenticated and unrate-limited.
  • PP GET /perubahan/cek_email — a PII oracle: any logged-in citizen supplies an arbitrary email and receives that person's fullname, nik, alamat, tanggal_lahir, tempat_lahir.
  • PP logs raw bearer tokens (pmPerseroan.repository.ts:74,134) to stdout at info level.
  • Apostille RolesGuard reads user.realm_access.roles, a shape the strategy never produces — adding any @Roles() would deny everyone.
  • Neither API validates aud; both accept any realm-issued token.
  • Refresh tokens in query strings; userSession not HttpOnly.