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 toid_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 tom_users.nikin the pendirian flow. m_users.nikis@unique— that enforces no duplicates, not truth. Whatever the portal wrote at registration is trusted forever.- The
validcolumn is dead in both APIs — never read, never written (and Apostille'supdateUseris 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 (
isAtLeast18YearsOldon the DTO'stanggal_lahir, never againstm_users.tgl_lahir) — andperubahan/pembubaranskip 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
setCookieis 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.statuschecked instead oferror.response.status; Apostille's primary/auth/dataclient 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.
- Be an OIDC client in realm
public(authorization-code flow). Do not plan on inheriting theuserSessioncookie: it's scoped to the portal's parent domain, so if we live on*.val.idthe 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). - Verify offline — JWKS + RS256 + issuer, exactly as they do, and add the
aud/azpcheck they omit. This slots into our existingsecurity/token-verifier.tsseam (multi-issuer byiss, already designed for it). djahu_userIdis ouroriginUserRef. This directly unblocks the push-back connector's #1 blocker (services/push/payload-builders.ts—buildOriginIdentity()currently throws). It's also exactly what PP's ownership checks compare (id_user === Number(userId)), so we and they would agree on identity.- Resolve profile (NIK/name/email) the way they do — read
m_usersbyid, 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. - Do not hardcode
resource_access.djahu.roles.resource_accessis keyed by client id — with our own client our roles land atresource_access.<our-client>.roles. Their hardcoded.djahuderef is also unguarded (TypeError → 500 on a token lacking it). - Do not mirror their authorization model. It is not a security boundary: no route gating in either SPA,
RolesGuarddead in both (Apostille's readsrealm_accessthe 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.ts→signLocalToken) and enforce with our existingroute-policies.ts. - 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)
- Our own Keycloak client in realm
public— and confirmation that the custom protocol mappersdjahu_userIdandmodule_codeare attached to OUR client's tokens. These are non-standard; withoutdjahu_userIdwe have no user id at all and the whole integration fails. - PROD realm + URL — we only ever see
sso-dev.kemenkum.go.id. Realm name is env-only; not in any repo. - Token lifetime + refresh policy (unknowable from the repos).
- Hosting/domain: can we sit under the portal's parent domain (cookie shares), or must we run a full auth-code flow?
- Profile access: read access to
m_users(AUTH_DATABASE_URL/ userman), or is/auth/data+/users/profilesanctioned for server-to-server? - What does
m_users.validmean, 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'sfullname,nik,alamat,tanggal_lahir,tempat_lahir. - PP logs raw bearer tokens (
pmPerseroan.repository.ts:74,134) to stdout at info level. - Apostille
RolesGuardreadsuser.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;
userSessionnot HttpOnly.