Government of Andhra Pradesh / MobileSigner eFile platform
Introduction

Introduction

Architecture

Process topology, request paths, trust boundaries and the WebAuthn ceremony sequence.

Two processes and one browser. There is no message broker, no cache tier and no service mesh — the design intent is that a district IT cell can run it.

Topology

Hover a node to isolate its connections. Click a node to open its reference page.

graph TB
  subgraph BROWSER["Browser — desktop or mobile"]
    App["Next.js app<br/><small>8 routes</small>"]
    Sdk["@mobilesigner/web-sdk"]
    Ceremony["window.WebAuthn<br/><small>webauthn.js from Quarkus</small>"]
    Creds["navigator.credentials"]
    Key(["FIDO2 security key<br/><small>private key never leaves</small>"])
  end

  subgraph NEXT["Next.js server — port 3000"]
    Rewrites["standalone server.js<br/><small>proxy rewrites only</small>"]
  end

  subgraph QUARKUS["Quarkus backend ×2 — port 8090"]
    WebAuthnExt["/q/webauthn/*<br/><small>WebAuthn extension</small>"]
    Api["/api/*<br/><small>7 JAX-RS resources</small>"]
    Panache["Panache entities"]
  end

  Db[("PostgreSQL 17<br/><small>StatefulSet + PersistentVolumeClaim</small>")]

  App --> Sdk
  Sdk --> Ceremony
  Ceremony --> Creds
  Creds --> Key
  App -- "/backend/*" --> Rewrites
  Ceremony -- "/q/webauthn/*" --> Rewrites
  Rewrites -- "proxy" --> WebAuthnExt
  Rewrites -- "strips /backend, adds /api" --> Api
  WebAuthnExt --> Panache
  Api --> Panache
  Panache --> Db

  click App "/workstation" "Workstation UI reference"
  click Sdk "/web-sdk" "Web SDK reference"
  click Api "/backend-api" "Backend API reference"
  click Panache "/data-model" "Data model reference"
  click Db "/data-model" "Data model reference"

  classDef edge fill:#e8f0f9,stroke:#1d4e89,color:#18221d
  classDef store fill:#fdf3dd,stroke:#7a5200,color:#18221d
  class Rewrites edge
  class Db,Key store

Request paths

Everything the browser calls is same-origin. There is no CORS configuration anywhere in the backend, and none is needed, because the Next.js server proxies both API families.

From next.config.mjs:

const backend = process.env.BACKEND_URL ?? 'http://localhost:8090';

async rewrites() {
  return [
    { source: '/q/webauthn/:path*', destination: `${backend}/q/webauthn/:path*` },
    { source: '/backend/:path*',    destination: `${backend}/api/:path*` }
  ];
}

The two rules behave differently, and the difference matters:

graph LR
  B1["Browser<br/>/backend/files"] --> R1{{"rewrite"}}
  R1 -->|"prefix replaced"| S1["Backend<br/>/api/files"]
  B2["Browser<br/>/q/webauthn/login"] --> R2{{"rewrite"}}
  R2 -->|"path preserved"| S2["Backend<br/>/q/webauthn/login"]

  classDef browser fill:#e8f0f9,stroke:#1d4e89,color:#18221d
  classDef server fill:#e4f4ec,stroke:#0d6b4f,color:#18221d
  class B1,B2 browser
  class S1,S2 server
  • /backend/files in the browser reaches /api/files on the backend. The path prefix changes across the proxy. src/lib/api.ts sets const BASE = '/backend' and the SDK defaults apiBase to '/backend' for the same reason.
  • /q/webauthn/* is not rewritten to /api. It passes through with its path intact, because that is where the Quarkus WebAuthn extension mounts its ceremony endpoints and serves webauthn.js.

Same-origin also means the encrypted Quarkus session cookie is sent on every fetch without any cross-site cookie negotiation. quarkus.webauthn.cookie-same-site=strict is therefore safe to keep strict.

How BACKEND_URL is resolved

Next.js resolves rewrite destinations when it builds, not when it serves. BACKEND_URL is read in next.config.mjs, which executes during next build, and the value is baked into .next/routes-manifest.json.

The Dockerfile supplies it as a build argument for exactly this reason:

ARG BACKEND_URL=http://backend:8090
ENV BACKEND_URL=${BACKEND_URL}
RUN npm run build

The image therefore ships with the in-cluster address already compiled in. To point a build at a different backend, pass --build-arg BACKEND_URL=…; setting the variable on a running container has no effect.

The WebAuthn ceremony

layout.tsx loads the ceremony client before hydration:

<Script src="/q/webauthn/webauthn.js" strategy="beforeInteractive" />

That script defines window.WebAuthn. The SDK never touches navigator.credentials itself — it delegates through MobileSignerWebSdk.frameworkClient(), which constructs new window.WebAuthn() and fails fast with a typed error if the script has not loaded:

private frameworkClient(): FrameworkWebAuthnClient {
  if (typeof window === 'undefined' || typeof PublicKeyCredential === 'undefined') {
    throw new MobileSignerError('UNSUPPORTED_BROWSER', 'This browser does not support WebAuthn.');
  }
  if (!window.WebAuthn) {
    throw new MobileSignerError('SDK_NOT_LOADED', 'The Quarkus WebAuthn ceremony client did not load.');
  }
  return new window.WebAuthn();
}

The three flows below share that delegation. Switch tabs to compare them.

### Registration
sequenceDiagram
  autonumber
  participant U as Officer
  participant S as web-sdk
  participant W as window.WebAuthn
  participant Q as Quarkus
  participant K as FIDO2 key

  U->>S: registerCredential({username})
  Note over S: emits registration:started
  S->>W: register()
  W->>Q: POST /q/webauthn/register-options-challenge
  Q-->>W: challenge, rp.id, user handle
  W->>K: navigator.credentials.create()
  K-->>K: user presence + verification
  K-->>W: attestation object
  W->>Q: POST /q/webauthn/register
  Note over Q: store() removes any prior<br/>credential for this username,<br/>then persists credentialId,<br/>publicKey, algorithm,<br/>counter, aaguid
  Q-->>W: Set-Cookie: encrypted session
  S->>Q: GET /api/session
  Q-->>S: {authenticated: true, username}
  Note over S: emits registration:completed,<br/>then session:loaded

### Authentication
sequenceDiagram
  autonumber
  participant U as Officer
  participant S as web-sdk
  participant W as window.WebAuthn
  participant Q as Quarkus
  participant K as FIDO2 key

  U->>S: authenticate({username})
  Note over S: emits authentication:started
  S->>W: login()
  W->>Q: POST /q/webauthn/login-options-challenge
  Q-->>W: challenge, allowCredentials
  W->>K: navigator.credentials.get()
  K-->>K: user presence + verification
  K-->>W: signed assertion
  W->>Q: POST /q/webauthn/login
  Note over Q: verifies signature against<br/>stored public key, then<br/>update(credentialId, counter)
  Q-->>W: Set-Cookie: encrypted session
  S->>Q: GET /api/session
  Q-->>S: {authenticated: true, username}
  Note over S: emits authentication:completed,<br/>then session:loaded

### Signing a file
sequenceDiagram
  autonumber
  participant U as Officer
  participant V as FileDetailView
  participant C as crypto.subtle
  participant Q as Quarkus
  participant D as Database

  U->>V: clicks "Approve & Sign"
  V->>C: sha256(file.body)
  C-->>V: 64 hex characters
  V->>Q: POST /backend/files/{id}/action<br/>{action, note, documentHash}
  Note over Q: proxied to<br/>POST /api/files/{id}/action
  Q->>Q: identity anonymous? → 401
  Q->>Q: action not a known verb? → 400
  Q->>Q: hash not 64 hex? → 400
  rect rgb(228, 244, 236)
    Note over Q,D: one transaction
    Q->>D: append FileNote (GREEN,<br/>signatureHash, signedAt)
    Q->>D: GovFile.status = APPROVED
    Q->>D: persist Approval
    Q->>D: record FILE_APPROVED
  end
  Q-->>V: full refreshed FileDetail
  Note over V: replaces state from<br/>server truth, so badge and<br/>noting cannot drift

Counter regression is what a cloned-authenticator detection check looks for, and the Quarkus extension performs it against the stored counter on every assertion.

The action endpoint returns the complete refreshed FileDetail rather than an acknowledgement. The workstation replaces its state with that response, which is why the status badge and the new noting-sheet entry appear together and cannot drift apart.

Trust boundaries

Boundary Enforcement
Authenticator → browser CTAP2; the private key never crosses
Browser → Next.js server none; the Next server is a pass-through proxy and holds no secrets
Next.js → Quarkus plain HTTP inside the cluster
Quarkus request → identity SecurityIdentity from the encrypted WebAuthn session cookie
Write endpoints @RolesAllowed("approver") on file decisions, administrator on device revocation
Read endpoints @RolesAllowed("officer") — nothing but /api/session is readable anonymously
Audit ledger UPDATE, DELETE and TRUNCATE refused by database triggers; rows hash-chained
Private key never leaves the FIDO2 authenticator; the backend stores only the public key

Roles are real. UserAccount carries a role column constrained to officer, approver, auditor or administrator, and a role implies the lesser capabilities so an endpoint can require the narrowest one it needs. A noting records the signatory's own designation rather than a hardcoded constant.

One constraint shapes how roles are resolved: Quarkus builds the security identity inside the HTTP authentication mechanism, which runs on the IO thread, and WebAuthnUserProvider.getRoles is a synchronous call on that path. Reading the account from the database there throws BlockingOperationNotAllowedException, because Hibernate refuses to block an event loop. So RoleDirectory holds the mapping in memory — warmed at startup, updated whenever an identity is established, and failing safe to read-only access on a miss while it refreshes in the background. Security model covers what this does and does not establish.

Persistence

PostgreSQL 17 in every deployed environment, reached over the network and configured entirely from the environment:

quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.url=${DB_URL:jdbc:postgresql://localhost:5432/mobilesigner}
quarkus.datasource.username=${DB_USERNAME:mobilesigner}
quarkus.datasource.password=${DB_PASSWORD:mobilesigner}

Dev and test override this to H2, so ./gradlew quarkusDev needs no database running:

%dev.quarkus.datasource.db-kind=h2
%dev.quarkus.datasource.jdbc.url=jdbc:h2:file:./data/mobilesigner;AUTO_SERVER=TRUE
%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:test;DB_CLOSE_DELAY=-1

quarkus.datasource.db-kind is a build-time property, so the value baked into a container image is whichever profile was active during ./gradlew build — the default, production one. Both JDBC drivers are on the classpath to make the dev override possible.

Why this is not H2 in the cluster

An embedded file database is a poor fit for the deployment target, for three separate reasons that are worth keeping apart:

Concern With a file database With PostgreSQL
Durability the file lives in the container filesystem, so every restart loses all credentials, approvals and audit rows and re-seeds a PersistentVolumeClaim outlives the pods entirely
Horizontal scale H2 file mode admits one writer, capping the backend at one replica any number of replicas share one database
Rolling updates a new pod cannot open the file while the old one holds it replicas start and stop independently

The important nuance is that only persistence was ever the blocker. The application tier itself is stateless: the WebAuthn challenge and the officer's session both travel in encrypted cookies, so nothing is held in a pod's memory between requests. Swapping the datasource is therefore all that was required to scale out, and the backend now runs replicas: 2.

That statelessness has one hard requirement: every replica must encrypt cookies with the same key, or a request balanced onto another pod cannot be decrypted. SESSION_ENCRYPTION_KEY is supplied from a Kubernetes Secret for exactly this reason.

The seeding race

With more than one replica, every pod runs DemoDataSeeder on startup and they can all observe an empty table before any of them commits. The unique constraint on gov_file.file_number is what prevents a duplicated working set — the losing transaction fails, and that failure is expected:

void onStart(@Observes StartupEvent ev) {
    try {
        QuarkusTransaction.requiringNew().run(this::seed);
    } catch (RuntimeException e) {
        LOG.infof("Seed data not written — another instance seeded this database first (%s)",
                e.getClass().getSimpleName());
    }
}

The transaction is started programmatically rather than with @Transactional on the observer, because an annotated observer would give the class no opportunity to catch the rollback and the pod would fail to start.

Schema is still generated by Hibernate with schema-management.strategy=update. That is additive only — it never drops or narrows a column — so a non-additive change needs a migration tool such as Flyway before this carries data that matters. Seed data describes what gets loaded.