Skip to content
Registry StackDocsDevelopment (unreleased)

Authenticate citizens with eSignet over Base Registry Engine (BReg)

View as Markdown

If you run a population registry on Base Registry Engine (BReg) and an eSignet deployment, the eSignet BReg provider lets citizens sign in to public services against your registry. eSignet keeps the login, the consent screen, and the signed OpenID Connect (OIDC) tokens. Your registry answers one governed lookup and releases only the fields each citizen approves. On the registry side that takes one selector profile, one access profile, one runtime entry, and one client at your issuer, for the provider version your deployment runs.

Relying parties talk only to eSignet, which runs the OIDC endpoints and the consent screen and signs the ID Token and UserInfo response. The provider, a Go module compiled into the eSignet host, verifies the citizen’s challenge, looks the citizen up in your registry, and hands eSignet the claims to sign.

sequenceDiagram
    participant C as Citizen
    participant E as eSignet
    participant P as BReg provider
    participant I as OAuth issuer
    participant B as BReg

    C->>E: Identifier and challenge response
    E->>P: Verify challenge
    P->>I: private_key_jwt client_credentials
    I-->>P: Short-lived access token
    P->>B: Lookup account-check fields
    B-->>P: uin, status
    C->>E: Approve requested claims
    E->>P: Fetch approved claims
    P->>B: Lookup approved fields
    B-->>P: Domain data
    P-->>E: Claims and pairwise subject
    E-->>C: Signed ID Token and UserInfo

The citizen’s tokens never reach your registry. The provider authenticates as a private_key_jwt client to the OAuth issuer your registry already trusts. It asks for a fixed resource and scope set and caches each access token, for five minutes by default. BReg trusts one issuer only, so the provider’s client must be registered there. An ID Token is addressed to the relying party, not to your registry, and BReg refuses it.

On every call BReg verifies the token’s signature, issuer, type, lifetime, and audience, and that its azp or client_id is in allowedClients. What a token must carry lists each requirement. The token must then satisfy the profile the provider names in accessProfile, because naming a profile grants nothing. Three claims decide that:

  • The principal claim, here registry_principal, recorded in the audit journal as the caller.
  • The required scope, here registry:esignet:lookup, which you grant to the provider’s client alone.
  • The row claim, here registry_eligible_status, which lists the statuses the provider may resolve.

A token that misses any of these gets a refusal, never a narrower answer. The provider adds its own safeguards:

  1. It verifies the challenge before it calls the issuer or your registry.
  2. Its first lookup asks only for account_check_fields, usually the identifier and a status.
  3. eSignet asks for consent on every sign-in. The second lookup asks only for approved claims the provider maps and provisions, and an empty set makes no call.
  4. The OIDC sub claim is a keyed hash of the relying party, the client, and the identifier. Each relying party sees a different subject, which does not reveal the identifier.

The examples use a person Record type on the population route with a uin identifier field.

Add a selector profile to the Record type and an access profile that grants only that lookup:

population/registry.yaml
recordTypes:
- id: person
route: population
# fields: uin, status, given-name, family-name, birthdate
selectorProfiles:
- id: by-uin
fields: [uin]
accessProfiles:
- id: esignet-source
principalClaim: registry_principal
requiredScopes: ["registry:esignet:lookup"]
permissions:
records:
- recordType: person
operations: [lookup]
readableFields: [uin, status, given-name, family-name, birthdate]
lookups:
- selector: by-uin
valueOrigin: request
rowBoundaries:
- field: status
claim: registry_eligible_status
operator: in

lookups ties the grant to by-uin, and valueOrigin: request lets the provider send the identifier it verified. The profile grants no get, list, or write operation, because the provider never needs one. The row boundary resolves only Records whose status the row claim lists, so an inactive Record answers like a missing one.

Read the compiled grant:

Terminal window
bregctl explain access population

The esignet-source block reads as follows; the rest of the output is omitted:

profile esignet-source
actor kind not declared
requester clients any
principal claim registry_principal
task grant none
operations ["lookup"]
required scopes (all) ["registry:esignet:lookup"]
allowed purposes (any) unrestricted
readable fields ["birthdate","family-name","given-name","status","uin"]
writable fields none
filterable fields none
sortable fields none
row restrictions (all) [{"claim":"registry_eligible_status","field":"status","operator":"in"}]
lookups [{"claimMapping":{},"selector":"by-uin","valueOrigin":"request"}]

If lookups names a selector the Record type does not declare, bregctl refuses the project with breg.access-profile.lookup-selector-unknown. Without the row boundary it warns breg.access.profile-unrestricted-rows, because every Record on the route could then resolve.

In the runtime file, list the provider’s client in allowedClients. Its audience and principal claim must match what the issuer will put in the token:

population/runtime.yaml
authentication:
oidc:
issuer: https://issuer.example.com
audience: urn:breg:population
allowedClients: [esignet-source, registry-admin]
authorityClaims:
principal: registry_principal

Other keys stay as your deployment sets them. Check the project and the runtime file together:

Terminal window
bregctl check population --runtime-config population/runtime.yaml

It prints Authoring check passed; registry revision sha256:<digest>. first and exits 0. Then deploy the changed project as Check, test, package describes.

At your OAuth issuer, register the esignet-source client to authenticate with private_key_jwt. Give it an ES256 or RS256 key whose private half the provider reads from a file. Grant it the registry:esignet:lookup scope and nothing that writes. Configure its access tokens to carry:

  • aud equal to urn:breg:population, the value the provider requests as its resource.
  • registry_principal, naming the provider as the caller.
  • registry_eligible_status, an array of the statuses that may sign in, such as ["active"].

Decode one token issued to this client and confirm each claim before you go on. A token without the row claim fails every lookup as if no citizen existed.

Select the provider with MOSIP_ESIGNET_AUTHN_PROVIDER=breg and name its one YAML file in REGISTRY_ESIGNET_CONFIG_FILE. These keys connect it to the grant:

provider.yaml
subject_id_type: uin
psut_secret_file: /run/secrets/registry-psut
breg:
base_url: https://registry.example.com
route: population
selector: by-uin
selector_field: uin
access_profile: esignet-source
provisioned_fields: [uin, status, givenName, familyName, birthdate]
account_check_fields: [uin, status]
token_client:
token_endpoint: https://issuer.example.com/oauth2/token
assertion_audience: https://issuer.example.com/oauth2/token
client_id: esignet-source
private_key_file: /run/secrets/registry-client.jwk
key_id: esignet-source-2026-09
resource: urn:breg:population
scopes: ["registry:esignet:lookup"]
claim_map:
sub: $psut
given_name: givenName
family_name: familyName
birthdate: birthdate

route, selector, and access_profile name what the project declares; client_id, resource, and scopes match the issuer and the runtime file. selector_field, provisioned_fields, and account_check_fields take API names, which default to the camelCase form of each field ID, so given-name becomes givenName. account_check_fields must include selector_field. Every claim a citizen may release needs a claim_map entry, and sub must be $psut. Protect and rotate the two secret files on your own schedule.

Sign in with a synthetic identifier your registry holds. The consent screen follows the challenge, or eSignet reports one of the codes below.

BReg answers each refusal with a problem document, and the provider reads its code. The provider collapses those codes, so a relying party never learns whether an identifier exists:

BReg answersWhenProvider reports
401 authentication.refusedThe token’s signature, issuer, type, lifetime, or audience fails, its client is not in allowedClients, or it lacks the principal claim.registry_auth_unavailable, after dropping its cached token
404 resource.not_foundThe token lacks the profile’s scope, principal claim, or row claim; the profile does not exist or grant lookup; or $select names a field it cannot read.registry_auth_subject_denied
404 lookup.unresolvedNo single Record matches inside the row boundary, or the profile has no lookups entry for the selector.registry_auth_subject_denied
400 request.invalidThe values keys in the request body are not the selector’s API names.registry_auth_unavailable

A profile that grants lookup with no lookups entry passes bregctl check, yet every lookup answers lookup.unresolved. Because both 404 codes look alike, a grant mistake reads as an unknown citizen. When every sign-in is denied, compare a decoded token with the bregctl explain access output before you suspect the data. The audit journal records each refused lookup with its principal and selected profile, but not which check failed.

Timeouts, redirects, and oversized responses also report registry_auth_unavailable. The provider’s other codes never involve your registry. registry_auth_challenge_failed reports a failed challenge. registry_auth_context_invalid and registry_auth_context_expired report a five-minute sign-in context that does not match or has lapsed.