Releases: rarebit-one/standard_singpass
Release list
v0.3.0
Added
StandardSingpass::Myinfo::FailureClassifier.upstream_unavailable?(error)— one answer to the question every host asks in its rescue: is Singpass unavailable, or is our request wrong? It decides what the user is told, and the two answers are not interchangeable — an unavailable upstream clears itself within minutes ("try again shortly"), while "contact support and quote this reference" is a dead end for a transient blip. True for 502/503/504, HTTP 429, and transport failures on any leg; false for authentication, decryption, signature, and configuration errors.FailureClassifier::UPSTREAM_UNAVAILABLE_STATUSESis the documented status list, andClient::RETRYABLE_USERINFO_STATUSESis now an alias of it rather than a second copy — the statuses worth one more automatic attempt are exactly the statuses that mean "their side, transient".StandardSingpass::Myinfo::Error#transport?— true when the request never reached Singpass at all (DNS, connection refused, TLS, read timeout). Set on the PAR, token, and userinfo transport-failure paths. Previously the only way to recover this was to match"unreachable"in the message, which is the same trapApiError#statuswas added in 0.2.0 to close: the message is not a stable interface.StandardSingpass::Myinfo::Noa.history(parsed)/.basic_history(parsed)— normalise Notice-of-Assessment data into a single list regardless of which NOA scope the Singpass app was approved for. The width is a property of the app registration, not the request:noayields the latest year only,noahistorythe last two, andPersonDataParsermirrors that as:noa(one record) or:noa_history(an array). The records share a shape, so every consumer ended up writing the same "array, or wrap the single record" branch. Prefers the 2-year history when both are present (it is a superset), returnsnilwhen neither scope yielded data, and reads both Symbol and String keys so a hash round-tripped through JSON or a database column works unchanged.config.production_env_detector— an optional callable deciding whether the current deploy is real production, for the mock-mode guard below. Takes no arguments, returns a boolean;nil(the default) falls back toRails.env.production?. MirrorsStandardId's option of the same name. Staging and preview deploys routinely runRAILS_ENV=production, so hosts that distinguish a physical deploy environment fromRAILS_ENVcan supply-> { AppEnv.production? }.
Changed
-
Security::ValidationErrorandSecurity::DecryptionErrornow descend fromStandardSingpass::Myinfo::Error(previously bareStandardError), and JWKS-fetch failures carry the JWKS response's status — ortransport?when the endpoint was unreachable. Verifying a signature requires fetching Singpass's JWKS, so a JWKS host that is down previously surfaced as a statuslessSignatureError(orAuthenticationErroron the ID-token leg), indistinguishable from a genuinely bad signature and classified as our own bug. The client now propagates both attributes across those re-raises. -
statusmoved fromApiErrorup to the baseErrorclass, and is now set on every leg of the flow. 0.2.0 put it onApiError, which covers the token and userinfo legs — but PAR raisesPARError, so a 502 from the PAR endpoint carried no status and any classification keyed onApiErrorreported a Singpass-side outage as our own bug.statusandtransport?now both live onError, are populated on the PAR and authentication paths as well, andFailureClassifierkeys on the base class.ApiError#statusis unchanged for existing callers;Error#initializetakes both keywords optionally, soraise SomeError, "msg"still works. -
config.mock_modeis now guarded at boot (StandardSingpass::Myinfo::MockModeGuard). Mock mode serves fixture persona data instead of running the real FAPI 2.0 flow — right for development, CI, and staging rehearsals, catastrophic in production, where real users would submit fabricated identity data that is indistinguishable from verified MyInfo data downstream. A mis-set environment variable is all it takes, and nothing about the running app looks wrong afterwards, so it is checked at boot rather than per request. Three outcomes: a production deploy raisesConfigurationErrorand refuses to boot; a production-like deploy (RAILS_ENV=productionbut not production perproduction_env_detector) logs and reports viaRails.error, then boots; anything else is silent. Wired from the engine'safter_initializewith no opt-out — a guard you can forget to invoke is a guard that isn't there.Upgrade note: a host that sets
mock_modeon a deploy runningRAILS_ENV=productionwill now either fail to boot or emit a reported error. If that deploy is a staging or preview box, setconfig.production_env_detectorso the gem can tell the difference. Hosts that leavemock_modeat its default (false) are unaffected.Reporting goes through
Rails.errorrather than any specific error tracker, so whatever the host has subscribed (Sentry included) picks it up.
Notes
- All three additions are promotions of knowledge that had accumulated in a consumer app. Each is MyInfo protocol or scope-grant knowledge that any Singpass client needs — which statuses Singpass uses for upstream-agency outages, how the two NOA scope widths differ in shape, and that the gem's own mock mode must never reach real users — rather than host policy. Host policy (what copy to show a user, where to read an environment variable from) stays in the host: the gem still never reads
ENVitself, andproduction_env_detectoris the seam for the one environment opinion it does hold.
v0.2.0
Added
StandardSingpass::Myinfo::ApiError#status— the HTTP status of the Myinfo response that produced the error (nilfor transport failures and for errors a host raises itself). Hosts need this to separate "Myinfo or one of its upstream agencies is unavailable" (502/503/504 — tell the user to retry shortly) from "we sent something wrong" (4xx — a bug worth surfacing to support). Previously the only way to recover the status was to parse it back out of the message, which is not a stable interface. Set on both the userinfo and token-exchange failure paths.- Bounded retry with jittered exponential backoff on the userinfo fetch for transient upstream statuses (502/503/504), 3 attempts total. Singpass returns
502 upstream_dependency_errorwhenever a Myinfo upstream (CPF Board, IRAS, MOM, …) is unavailable — including throughout their published maintenance windows — and a single transient one previously failed the whole flow. Because the authorization code is already spent by that point, the user's only recovery was a full Singpass re-login, so a momentary blip cost a complete re-authentication.
Notes
- The retry is scoped deliberately:
- Userinfo only. The token exchange is never retried — it consumes a single-use authorization code, and replaying it earns an
invalid_grant. Userinfo is an idempotent GET against an access token already in hand. - Status-based failures only, never timeouts. A 5xx returns in well under a second; a timed-out attempt has already spent the request budget, and retrying it risks turning a slow page into a gateway error.
- Inside
network_wrapper, not outside. A host's wrapper is typically a circuit breaker; keeping retries inside means one user attempt counts as one failure against the breaker rather than three, so a single unlucky user can't trip a shared circuit alone.
- Userinfo only. The token exchange is never retried — it consumes a single-use authorization code, and replaying it earns an
- The DPoP proof is rebuilt on every attempt — RFC 9449 proofs carry a one-shot
jti, so a replayed header would be rejected.
v0.1.0
Added
StandardSingpass::Myinfo::Client— FAPI 2.0 OAuth client with PKCE, DPoP (RFC 9449), andprivate_key_jwtclient assertion. Performs PAR, token exchange, ID-token validation (iss/aud/exp/iat/sub/acr/nonce), and userinfo fetch.StandardSingpass::Myinfo::Security— PKCE/DPoP primitives, JWE decryption dispatch, JWS validation with JWKS caching and one-shot key-rotation retry.StandardSingpass::Myinfo::EcdhJwe— native ECDH-ES+A128KW / +A256KW JWE implementation covering A128GCM, A256GCM, A128CBC-HS256, A256CBC-HS512. Exists because thejwtgem does not support ECDH-ES key agreement.StandardSingpass::Myinfo::PersonDataParser— extracts 40+ structured fields (identity, contact, address, pass info, income, employment, assets, housing, vehicles) from FAPI 2.0 v5 userinfo responses, unwrapping theperson_infoenvelope.StandardSingpass::Myinfo::JwksGenerator— generates and validates the private JWKS document used for signing + ECDH-ES decryption keys. Includes astandard_singpass:myinfo:generate_jwksrake task.StandardSingpass::Myinfo::TestPersonas— loader for the bundled persona fixture set used by mock-callback flows and RSpec helpers; host can override viaconfig.personas_path.StandardSingpass::Myinfo::Configuration— block-style config (StandardSingpass::Myinfo.configure { |c| ... }). Host passesclient_id,redirect_url,private_jwks_json, optionalminimum_acr, optionalnetwork_wrapper(e.g. circuit breaker), andenvironment(:production/:staging— picks Singpass endpoint URLs).rails generate standard_singpass:installscaffoldsconfig/initializers/standard_singpass.rbwith the full configuration surface commented out. Idempotent;--forceoverwrites.- Full-flow integration spec at
spec/standard_singpass/myinfo/full_flow_spec.rbwalking PAR → token exchange → userinfo → JWE decrypt → JWS validate → parse end to end. Covers happy path, nonce mismatch, and theminimum_acrenforcement branch (LOA-3 floor + LOA-2 token →AuthenticationError). spec/standard_singpass/myinfo/configuration_spec.rbcovering the globalconfigure/public_jwkspaths and the private-JWKS parser (happy path, malformed JSON, public-only-key rejection).- Sorbet wired end to end:
bin/tapiocashim, generated RBIs for runtime deps undersorbet/rbi/gems/, hand-edited shims forOpenSSL::PKey::EC::Point#to_octet_stringandFaraday.get,.github/workflows/typecheck.ymlrunningbundle exec srb tcon every PR, andbundle exec srb tcappended to the weekly-maintenance test commands so dependency-update PRs also catch type-sig breakage. AGENTS.md— quick-reference doc for AI agents and human contributors. Public surface, error taxonomy, key workflows.- Bundled persona fixture at
fixtures/myinfo-personas.json. - Productivity workflows:
.github/workflows/{claude.yml,claude-code-review.yml,weekly-maintenance.yml}+.github/dependabot.yml(matches peerstandard_*gem setup).
Notes
- No Rails routes, models, or migrations — gem is library-only by design. The host owns persistence (e.g. a MyInfo record model), orchestration (callback handlers), forms, and UI.
lib/standard_singpass/engine.rbdefers itsRails::Enginedefinition behindif defined?(::Rails::Engine)so the gem loads cleanly undertapioca gemsand other no-Rails contexts. The hostGemfileshould also listgem "rails"ahead ofgemspecsoBundler.requireloads Rails first.- Coverage sits at 95.26% line / 84.29% branch with a 90% line / 75% branch floor enforced via SimpleCov.