Web RP demo — Developer guide¶
The eID platform's browser RP demo — an example Relying Party written in
Next.js (App Router, TypeScript). It demonstrates, in the browser, the flow of
signing in via QR / РД push with the mobile eID Mongolia app, and applying a
qualified electronic signature to a PDF. Source: web/ (a faithful port of the
Java smartid-demo WebDemo.java + index.html — the same screens, the same
flow, the same Mongolian labels).
This is a real RP. Unlike the macOS/iOS clients, the web app itself holds the RP secret: the
RP_API_SECRETfor the Go RP-API (/v3/*) is used only in server-side route handlers (web/src/lib/rpclient.ts). The browser never sees this secret — every call is proxied through the web app's public/api/*routes. For detailed RP integration see RP integration, and for the desktop client that uses the same/api/*see macOS desktop.
1. What it demonstrates¶
- Authentication — QR code (
/api/start) or push by РД/civil ID (/api/login-notify). Confirmed with PIN1 on the phone app. A server-side route cryptographically verifies the signature over the rpChallenge, then extracts the name + civil ID +documentNumberfrom the cert subject and returns them (the browser does not parse the cert). - PDF signature — select a PDF, compute its SHA-256 digest locally on the
client, and sign it with PIN2 (signing key, non-repudiation) on the phone app.
The backend returns the stamped (PAdES / PKCS#7 + verification page) PDF
(
/api/sign-pdf-download). - Organization representation — after a natural person signs in, the choice of
"which organization to continue as" is fetched in real time from the registry
(
/api/representations, an analog of Estonia's äriregister).
The "virtual phone" simulation does not work. The Java demo performed real threshold ECDSA via the
VirtualPhone/PhoneCryptoJVM SDK. That crypto lives only on the JVM side, so/api/simulate-phonereturns501(dev) /404(prod) (web/src/app/api/simulate-phone/route.ts). The real flow: confirm the QR/push with the eID Mongolia app.
2. Architecture¶
Browser (page.tsx, demo/page.tsx)
│ fetch /api/* (same-origin, no secret)
▼
Next.js route handler (web/src/app/api/**, server-side)
│ rpclient.ts: Authorization: Bearer <RP_API_SECRET>
▼
Go RP-API (/v3/*, RP_API_BASE)
The Java demo connected to the RP-API from within a lightweight HTTP server. In
this port, each backend helper endpoint becomes a Next.js route handler that
calls the Go RP-API (/v3/...) from the server side (no CORS, same logic as Java).
The wire contract is 1:1 with the Go DTOs (server/internal/dto/*.go): responses
contain sessionID, vc.value, result.endResult, result.documentNumber,
cert.value (base64 DER), cert.certificateLevel.
Where the secret and RP identifiers live¶
| Value | Where | Description |
|---|---|---|
RP_API_SECRET |
env, server-side only | Sent by rpclient.ts authHeaders() as Authorization: Bearer. Not NEXT_PUBLIC_, so it is not exposed in the client bundle. If not set, no header is added (dev / RP-auth disabled). |
RP_UUID / RP_NAME |
constants in rpclient.ts |
Not env — compile-time constants (RP_UUID = "2d87bd3a-…", RP_NAME = "Demo Bank"). Pre-registered in relying_parties, stable across restarts. |
rpChallenge |
server-side, random | 64 random bytes (base64) per request. Stored in challengeStore keyed by sessionId, used to verify the AUTH signature over ACSP_V2 at /status. |
pollToken(PII gate). SincesessionIdis exposed in the QR, on its own it does not grant the right to read the name/registration/signature. Eachstart/login-notify/sign-pdf-startresponse returns apollToken, and/api/status,/api/sign-pdf-downloadrequire this token (web/src/lib/pollTokenStore.ts).
3. /api/* routes¶
Public demo RP flow (page.tsx, demo/page.tsx, macOS client)¶
| Route | Method | What it does | Go RP-API endpoint |
|---|---|---|---|
/api/start |
POST | Starts an anonymous QR session → {sessionId, qr, deviceLinkBase, vc, pollToken} (qr = sessionId) |
POST /v3/authentication/device-link/anonymous |
/api/login-notify |
POST | {register, callbackUrl} — push by РД/civil ID → {sessionId, vc, pollToken}. Rate limit 60s/3 per target |
POST /v3/authentication/notification/etsi/{etsi} |
/api/status |
GET | ?sessionId=&pollToken= long-poll (server holds ~1s). On COMPLETE/OK, verifies the AUTH signature over the rpChallenge and extracts name/idNumber/documentNumber from the cert |
GET /v3/session/{id}?timeoutMs=1000 |
/api/sign-start |
POST | {etsi, doc} — text-document signature (PIN2) push → {sessionId, vc, doc, pollToken}. Rate limit 60s/3 |
POST /v3/signature/notification/etsi/{etsi} |
/api/sign-pdf-start |
POST | {etsi, digestB64, fileName, onBehalfOf?} — session to sign the PDF's SHA-256 digest with PIN2 → {sessionId, vc, pollToken}. The digest must be 32 bytes; rate limit 60s/3 |
POST /v3/signature/notification/etsi/{etsi} |
/api/sign-pdf-download |
POST | multipart file + sessionId + pollToken → stamped PDF bytes (application/pdf attachment) |
POST /v3/signature/stamp/{sessionId} |
/api/representations |
POST | {personId} — ACTIVE organizations the person can represent → {personEtsi, representations}. Rate limit 60s/10 |
GET /v3/organization/representations/etsi/{personEtsi} |
/api/simulate-phone |
POST | Stubbed out — 501 (dev) / 404 (prod). Requires the real threshold ECDSA JVM SDK |
— |
/api/health |
GET | {status:"ok", service:"eidmongolia-web"} |
— |
/demo/live proxy flow (web/src/app/api/demo/*)¶
A thin proxy for the public website's live demo page — it returns a different
shape with snake_case responses, but still goes through rpclient.ts (RP-API /v3).
| Route | Method | What it does |
|---|---|---|
/api/demo/auth/init |
POST | Anonymous device-link auth → {session_id, device_link_url, control_code, poll_token, expires_at} (device_link_url = raw sessionId) |
/api/demo/auth/poll |
GET | ?id=&poll_token= — session poll, verifies the AUTH signature and returns identity |
/api/demo/sign/init |
POST | multipart file + x-eid-token header (the documentNumber from authentication) — computes the PDF's SHA-256 and opens a PIN2 ceremony → {session_id, document_hash, verification_code} |
/api/demo/sign/poll |
GET | ?id= + x-eid-token — on COMPLETE/OK, a detached ECDSA signature_hex; the page itself embeds it into the PDF with pdf-lib (there is no /download here) |
4. Running¶
cd web
npm install
npm run dev # http://localhost:3000
npm run build # check the production build
npm run lint
npm run dev starts web on :3000 and the admin console on :3001 (CLAUDE.md).
Environment variables¶
| Variable | Default | Description |
|---|---|---|
RP_API_BASE |
— | Base URL of the Go RP-API (server-only). The route handler appends /v3 itself. If not set in production, rpclient.ts throws an error |
NEXT_PUBLIC_API_BASE |
— | Legacy fallback (compatibility). Read if RP_API_BASE is not set |
RP_API_SECRET |
— | RP shared secret. Match it with the Go SMARTID_RP_API_SECRET. If empty, no Bearer is added (dev) |
NODE_ENV |
— | When production, simulate-phone is 404 and RP_API_BASE is mandatory |
Dev fallback. If both
RP_API_BASE/NEXT_PUBLIC_API_BASEare empty, it falls back tohttp://localhost:8080/v3only in dev (rpclient.tsapiBase()). So for local testing it is enough to start the Go server on:8080.
Full local stack (Go API + web):
cd server && SMARTID_RP_API_SECRET= go run ./cmd/smartid # Go API :8080 (RP-auth disabled)
cd web && npm run dev # web :3000
The phone app must also point at the same Go server (on the simulator,
SMARTID_REQUIRE_ATTESTATION=false).
5. rpclient.ts — how it talks to the RP-API¶
web/src/lib/rpclient.ts is a copy of the Java GeregeSmartIdRpClient + WebDemo.
It runs only server-side (from route handlers).
- Base URL —
apiBase()=RP_API_BASE(orNEXT_PUBLIC_API_BASE) +/v3, with any trailing/stripped. - Auth —
authHeaders()returnsAuthorization: Bearer <secret>ifRP_API_SECRETis set; otherwise an empty header (dev). Everypost()/get()attaches this header. - Request body —
relyingPartyUUID,relyingPartyName,certificateLevel(QUALIFIED),signatureProtocol(ACSP_V2),interactions, and when neededrpChallenge/digest+hashType/initialCallbackUrl/onBehalfOf. - Timeout — 15s for ordinary calls, 140s for long-poll (
AbortSignal.timeout). - PDF stamp —
stampSignedPdf()sends the original PDF toPOST /v3/signature/stamp/{sessionId}asapplication/pdfon a finished SIGN session, and receives the stamped PDF back.
web/src/lib/x509subject.ts is a minimal ASN.1 DER parser that extracts the name +
registration (serialNumber) from the cert subject (a replacement for Java's
BouncyCastle).
6. Links¶
- General RP integration guide: RP integration
- Desktop client that uses the same
/api/*: macOS desktop - Citizen identifier numbers: IDENTIFIERS.md
Source (GitHub)¶
- Web demo:
https://github.com/gerege-systems/eid-platform-mn/tree/main/web - RP-API client:
https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/lib/rpclient.ts - Route handlers:
https://github.com/gerege-systems/eid-platform-mn/tree/main/web/src/app/api /api/start:https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/start/route.ts/api/status:https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/status/route.ts/api/sign-pdf-download:https://github.com/gerege-systems/eid-platform-mn/blob/main/web/src/app/api/sign-pdf-download/route.ts
Next.js 16. This repo uses Next.js 16 (breaking changes). Before writing Next.js code, follow the instructions in
web/AGENTS.mdand consultnode_modules/next/dist/docs/rather than training data.