StandardSingpass
Singpass MyInfo (FAPI 2.0) client for Rails applications. Packages the OAuth flow, DPoP/PKCE primitives, native ECDH-ES JWE decryption, JWS validation, and person-data parser needed to integrate with Singpass MyInfo.
The gem is intentionally library-only — it does not own routes, models, migrations, or UI. The host application owns persistence, orchestration, forms, and presentation.
Installation
Add to your Gemfile:
gem "standard_singpass"Then generate the initializer:
bin/rails g standard_singpass:installThis writes config/initializers/standard_singpass.rb, mapping the MYINFO_*
environment variables onto the gem's configuration (see below).
Configuration
# config/initializers/standard_singpass.rb
StandardSingpass::Myinfo.configure do |c|
# Endpoint set. Drives production vs. staging Singpass URLs.
c.environment = Rails.env.production? ? :production : :staging
# Client credentials.
c.client_id = ENV["MYINFO_CLIENT_ID"]
c.redirect_url = ENV["MYINFO_REDIRECT_URL"]
# MyInfo scope: set it explicitly to the attributes approved for your app
# on the Singpass developer portal. The gem's default is only the minimal
# identity set ("openid uinfin name"); which attributes you collect is a
# host decision (PDPA Purpose Limitation), not the gem's.
c.scope = "openid uinfin name email mobileno regadd"
# Required: full private JWKS JSON containing both sig (ES256) and enc
# (ECDH-ES+A256KW) keys with the private scalar `d`.
c.private_jwks_json = ENV["MYINFO_PRIVATE_JWKS"]
# Optional: client-side floor on the id_token `acr` claim. Never sent to
# Singpass — see "Assurance level (`minimum_acr`)" below before setting it.
# c.minimum_acr = "urn:singpass:authentication:loa:2"
# Optional: wrap outbound HTTP calls with a circuit breaker / retry layer.
# Defaults to identity (no wrapper).
# c.network_wrapper = ->(&block) { StandardCircuit.run(:myinfo, &block) }
# Optional: path to a JSON file of test personas (for mock callback flows).
# Defaults to the gem's bundled fixtures/myinfo-personas.json.
# c.personas_path = Rails.root.join("e2e/fixtures/myinfo-personas.json")
# Optional: serve fixture persona data instead of running the real FAPI 2.0
# flow. Refused outright on a production deploy — see "Mock mode" below.
c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
# Optional: how the gem decides whether this deploy is real production, for
# the mock-mode guard. Defaults to APP_ENVIRONMENT (failing closed), then
# Rails.env.production? — see "Mock mode" below.
# c.production_env_detector = -> { ... }
endStandardSingpass.configure / StandardSingpass.config are equivalent top-level aliases, matching the other standard_* gems.
private_jwks_json is parsed lazily — on first use, or at the end of boot (the engine resolves it from after_initialize, next to the mock-mode guard) — so assignments inside the block can come in any order.
Assurance level (minimum_acr)
config.minimum_acr is an optional, client-side check. When set to a
Singpass LOA URN (urn:singpass:authentication:loa:2 or ...:loa:3), the
client rejects any id_token whose acr claim is below that level — or missing
— with StandardSingpass::Myinfo::AuthenticationError. A value that is not a
recognised LOA URN raises ConfigurationError. Unset or blank disables the
check.
It is never sent to Singpass. Singpass rejects acr_values on a MyInfo
Pushed Authorization Request:
PAR failed (HTTP 400) invalid_request —
"The acr_values parameter can only be used for specific use cases."
Versions up to 0.3.0 forwarded a configured minimum_acr as acr_values, so
setting it broke every MyInfo login. From 0.3.1 the PAR request never carries
acr_values. MyInfo's assurance level is governed by Singpass server-side, so
most hosts should leave minimum_acr unset; if you do set it, set it in code
rather than from a loosely-managed environment variable, because a wrong value
fails every login closed.
The acr Singpass actually returned is surfaced as result[:id_token_acr]
from get_person_data for audit, whether or not a floor is configured.
Mock mode
config.mock_mode makes the host serve fixture persona data
(StandardSingpass::Myinfo::TestPersonas) instead of running the real
Singpass flow — right for development, CI, and staging rehearsals, and
catastrophic in production, where real users would submit fabricated identity
data indistinguishable from verified MyInfo data downstream.
The gem enforces that. An after_initialize hook runs
StandardSingpass::Myinfo::MockModeGuard.check! on every boot. There is no
opt-out — a guard you can forget to wire is a guard that isn't there.
| Deploy | Mock mode on | Behaviour |
|---|---|---|
| Production | yes | Raises ConfigurationError — the app refuses to boot. |
RAILS_ENV=production but not production |
yes | Logs an error and reports via Rails.error; boots. |
| Anything else | yes | Silent. |
| Any | no | Silent. |
"Production" follows the estate's APP_ENVIRONMENT convention (the same rule
standard_id, standard_audit and standard_health use): when APP_ENVIRONMENT is
set, only staging, preview, development and test are non-production and
any other value (a typo included) is production, so a mis-set variable fails
closed; when it is unset, Rails.env.production? decides. Staging and preview
deploys routinely run RAILS_ENV=production, so with APP_ENVIRONMENT=staging
they land in the production-like tier, where mock mode boots and is reported,
with no extra wiring.
config.production_env_detector (a callable taking no arguments, returning a
boolean) overrides that, for a host with a different discriminator. The
production-like tier reports through Rails.error rather than a specific error
tracker, so whatever the host has subscribed (Sentry included) picks it up.
Initiating the flow
pkce = StandardSingpass::Myinfo::Security.generate_pkce_pair
dpop_key = StandardSingpass::Myinfo::Security.generate_ephemeral_key_pair
state = SecureRandom.hex(16)
nonce = SecureRandom.hex(16)
client = StandardSingpass::Myinfo::Client.new
par = client.push_authorization_request(
code_challenge: pkce[:code_challenge],
state: state,
nonce: nonce,
dpop_key_pair: dpop_key
)
# Persist pkce[:code_verifier], state, nonce, and dpop_key in the user session.
redirect_to client.build_authorize_redirect(request_uri: par[:request_uri])Handling the callback
result = client.get_person_data(
auth_code: params[:code],
code_verifier: session[:myinfo_code_verifier],
dpop_key_pair: session[:myinfo_dpop_key],
nonce: session[:myinfo_nonce]
)
parsed = StandardSingpass::Myinfo::PersonDataParser.call(result[:person_data])
acr = result[:id_token_acr]
# `parsed` is a 40+ key hash: nric, name, email, mobile_number,
# registered_address, cpf_balances, noa, hdb_ownership, etc. Pass it to your
# host-side persistence / projection layer.Notice-of-Assessment scopes
Singpass approves NOA access at one of two widths, and the width is a property
of the app registration, not of the request: noa yields the latest year
only, noahistory the last two. PersonDataParser mirrors that faithfully,
emitting :noa (one record) or :noa_history (an array) — so consumers that
just want "the NOA records" would each write the same branch.
StandardSingpass::Myinfo::Noa is that branch:
# An array of NOA records regardless of which scope was granted, or nil when
# neither yielded data. Prefers the 2-year history when both are present.
StandardSingpass::Myinfo::Noa.history(parsed)
# Same, for the narrower `noa-basic` / `noahistory-basic` scope pair.
StandardSingpass::Myinfo::Noa.basic_history(parsed)Keys are read as both Symbols and Strings, so a hash round-tripped through JSON or a database column works unchanged.
Generating and serving JWKS
The host application is responsible for serving the public JWKS at
/.well-known/jwks.json (or another endpoint Singpass is configured to fetch).
# Generate a fresh private JWKS (run locally, never in CI):
bin/rails standard_singpass:myinfo:generate_jwks > private-jwks.json
# Serve the public JWKS from a controller:
render json: StandardSingpass::Myinfo.public_jwksError classes
All errors descend from StandardSingpass::Myinfo::Error, which in turn descends from the gem-wide StandardSingpass::Error:
-
AuthenticationError— ID token or token exchange rejected -
ApiError— endpoint reachable but returned a non-2xx response -
PARError— pushed authorization request failed -
DecryptionError— JWE decryption failed -
SignatureError— JWS verification failed -
RateLimitError— Singpass returned HTTP 429 -
ConfigurationError— gem is misconfigured (e.g. invalid ACR URN)
Security raises DecryptionError / SignatureError directly. The pre-0.4.0 Security::DecryptionError / Security::ValidationError aliases were removed in 0.5.0; rescue DecryptionError / SignatureError (or StandardSingpass::Myinfo::Error).
DecryptionError and SignatureError usually indicate a key/cert misconfiguration rather than an upstream outage. Usually, not always: verifying a signature requires fetching Singpass's JWKS, so a JWKS host that is down surfaces as a SignatureError (or an AuthenticationError on the ID-token leg) too. Those carry the JWKS response's status / transport?, so ask FailureClassifier rather than deciding by class — including when choosing what to feed a circuit breaker.
Every error carries two facts, on the base class rather than on any one subclass — a 502 is a 502 whichever leg of the flow returned it:
-
status— the HTTP status of the offending response (nilfor transport failures and for errors a host raises itself) -
transport?— true when the request never reached Singpass at all (DNS, connection refused, TLS, read timeout)
Classifying a failure
What the host tells the user turns on one question: is Singpass unavailable, or is our request wrong? An unavailable upstream clears itself within minutes, so "try again shortly" is the honest instruction; "contact support and quote this reference" is a dead end. Get the answer from the gem rather than re-deriving it:
if StandardSingpass::Myinfo::FailureClassifier.upstream_unavailable?(error)
# 502/503/504, HTTP 429, or a transport failure on any leg.
# Tell the user to retry shortly.
else
# Our request, our keys, or our config. Surface it to support.
endThis covers every leg — PAR, token exchange, userinfo, and the JWKS fetches that back signature verification. 502 is Singpass's documented signal that a MyInfo upstream agency (CPF Board, IRAS, MOM, …) is unavailable — returned both for genuine blips and throughout their published maintenance windows; 503/504 are the generic equivalents. The list is FailureClassifier::UPSTREAM_UNAVAILABLE_STATUSES, and the client's automatic userinfo retry uses that same constant.
Never classify by matching the message text — it is not a stable interface. That is exactly what transport? and status exist to replace.
Instrumentation
The client emits ActiveSupport::Notifications events, one per leg of the flow:
| Event | When |
|---|---|
standard_singpass.par |
Pushed Authorization Request |
standard_singpass.token |
Token exchange |
standard_singpass.userinfo |
Userinfo fetch, decrypt, and verify (spans all retry attempts) |
standard_singpass.retry |
Each automatic userinfo retry, just before the backoff sleep |
par / token / userinfo payloads:
-
duration— wall time in milliseconds (Float) -
status— HTTP status of the (last) Singpass response, ornilwhen none arrived -
error— class name of the raised error, ornilon success (the error is re-raised as usual) -
transport—truewhen Singpass was never reached -
attempts— (userinfoonly) attempts made, including retries
retry payloads: leg (:userinfo), attempt (the attempt that failed), status, error, delay (seconds about to be slept).
Payloads are deliberately PII-free: no tokens, authorization codes, response bodies, URLs, or error messages — only the class name. ActiveSupport's own :exception / :exception_object keys (which would carry the message) are not added.
ActiveSupport::Notifications.subscribe(/\Astandard_singpass\./) do |event|
StatsD.distribution("singpass.#{event.name.delete_prefix("standard_singpass.")}",
event.payload[:duration] || 0,
tags: { status: event.payload[:status], error: event.payload[:error] })
endDevelopment and testing
bundle install
bundle exec rspec # full suite (boots spec/dummy, WebMock blocks outbound HTTP)
bin/rubocop # style
bundle exec srb tc # Sorbet typecheck — keep it greenlefthook install wires the pre-push checks (rubocop, brakeman, rspec, signed-commit verification).
Testing a host app against the gem
-
Mock mode + personas. Set
c.mock_mode = true(never on production — see Mock mode) and drive your mock callback withStandardSingpass::Myinfo::TestPersonas.fetch(key), thenPersonDataParser.call(persona). Bundled keys:default,self_employed,tamil_name,permanent_resident,sparse_data,fin_holder,work_permit_holder,error_paths,duplicate_check. Pointc.personas_pathat your own JSON file to maintain your own set. -
End-to-end crypto. To exercise the real decrypt + verify path, build the encrypted payloads Singpass would send with the test-only encryptor:
# spec/rails_helper.rb require "standard_singpass/testing" jwe = StandardSingpass::Testing::EcdhJwe.encrypt( signed_jws, public_key: enc_key, # OpenSSL::PKey::EC matching your "enc" JWK alg: "ECDH-ES+A256KW", enc: "A256GCM", kid: "your-enc-kid" )
standard_singpass/testingis not loaded byrequire "standard_singpass". The oldStandardSingpass::Myinfo::EcdhJwe.encryptforwarder was removed in 0.5.0. -
Specs that mutate configuration should call
StandardSingpass::Myinfo.reset_configuration!afterwards.
License
The gem is available as open source under the terms of the MIT License.