Skip to content

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_SECRET for 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 + documentNumber from 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/PhoneCrypto JVM SDK. That crypto lives only on the JVM side, so /api/simulate-phone returns 501 (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). Since sessionId is exposed in the QR, on its own it does not grant the right to read the name/registration/signature. Each start / login-notify / sign-pdf-start response returns a pollToken, and /api/status, /api/sign-pdf-download require 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_BASE are empty, it falls back to http://localhost:8080/v3 only in dev (rpclient.ts apiBase()). 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 URLapiBase() = RP_API_BASE (or NEXT_PUBLIC_API_BASE) + /v3, with any trailing / stripped.
  • AuthauthHeaders() returns Authorization: Bearer <secret> if RP_API_SECRET is set; otherwise an empty header (dev). Every post()/get() attaches this header.
  • Request bodyrelyingPartyUUID, relyingPartyName, certificateLevel (QUALIFIED), signatureProtocol (ACSP_V2), interactions, and when needed rpChallenge / digest + hashType / initialCallbackUrl / onBehalfOf.
  • Timeout — 15s for ordinary calls, 140s for long-poll (AbortSignal.timeout).
  • PDF stampstampSignedPdf() sends the original PDF to POST /v3/signature/stamp/{sessionId} as application/pdf on 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).

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.md and consult node_modules/next/dist/docs/ rather than training data.