The project is in a healthy, maintained state
StandardSingpass packages the FAPI 2.0 OAuth client, DPoP/PKCE primitives, native ECDH-ES JWE decryption, and person-data parser needed to integrate with Singpass MyInfo. Designed as a reusable Rails engine; the host owns persistence, orchestration, and UI.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

~> 0.22
~> 0.5
~> 0.16

Runtime

>= 2.0, < 3
>= 2.7, < 4
>= 8.1, < 9
 Project Readme

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:install

This 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 = -> { ... }
end

StandardSingpass.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_jwks

Error 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 (nil for 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.
end

This 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, or nil when none arrived
  • error — class name of the raised error, or nil on success (the error is re-raised as usual)
  • transport — true when Singpass was never reached
  • attempts — (userinfo only) 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] })
end

Development 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 green

lefthook 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 with StandardSingpass::Myinfo::TestPersonas.fetch(key), then PersonDataParser.call(persona). Bundled keys: default, self_employed, tamil_name, permanent_resident, sparse_data, fin_holder, work_permit_holder, error_paths, duplicate_check. Point c.personas_path at 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/testing is not loaded by require "standard_singpass". The old StandardSingpass::Myinfo::EcdhJwe.encrypt forwarder 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.