standard_singpass 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea90960df9e3752b2ec01cb271cac1c31fc8c53cfb5a757a64810d3eccecbb79
4
- data.tar.gz: 3eba17716f557250f1013b303216b2450ea691080ed7bc924b8fa1749b832305
3
+ metadata.gz: 1ecc46cf176551f25806d49f04c64ba204801c137b46c7c5970fd6a0f97d650a
4
+ data.tar.gz: f5d5c709779c0082d75c4909d549ccf451daa5406900ac7e05f83e23d2e69994
5
5
  SHA512:
6
- metadata.gz: e3f7ca50aaf67da5e1b82f33455662da9743a9f4cffedd533a878c409394681b7f1e1d16c197362cccce9031744509c71e6674195b1012ee7f7867595fa4cd48
7
- data.tar.gz: bba22e4c5ede26f234acfea6289ee469fa646612bf198eabcaacc397e3270ce276fd5d3ea18adf9c87b549a0d88ab3b905baa15b7692de1666eaebae0ba58d8d
6
+ metadata.gz: a30193c9f2d2a7633f3833e1eb186df1dd09b7fa922de35ab0c7dd2d1b7acb5f8c867e91e14e86fb82cce0f814f047ee1d402911334100d1fde4ac1b353b1f05
7
+ data.tar.gz: 886ac97c7a2ea5125cc079ef141dcb6c303d0ac02e0c48343a6e20aff20ed889d66af105305f315e5aab5f69ed1aaaa7a781467482326162511a0690780fd812
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-07-30
11
+
12
+ ### Added
13
+
14
+ - **`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_STATUSES` is the documented status list, and `Client::RETRYABLE_USERINFO_STATUSES` is 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".
15
+ - **`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 trap `ApiError#status` was added in 0.2.0 to close: the message is not a stable interface.
16
+ - **`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: `noa` yields the latest year only, `noahistory` the last two, and `PersonDataParser` mirrors 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), returns `nil` when neither scope yielded data, and reads both Symbol and String keys so a hash round-tripped through JSON or a database column works unchanged.
17
+ - **`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 to `Rails.env.production?`. Mirrors `StandardId`'s option of the same name. Staging and preview deploys routinely run `RAILS_ENV=production`, so hosts that distinguish a physical deploy environment from `RAILS_ENV` can supply `-> { AppEnv.production? }`.
18
+
19
+ ### Changed
20
+
21
+ - **`Security::ValidationError` and `Security::DecryptionError` now descend from `StandardSingpass::Myinfo::Error`** (previously bare `StandardError`), and JWKS-fetch failures carry the JWKS response's status — or `transport?` when the endpoint was unreachable. Verifying a signature requires fetching Singpass's JWKS, so a JWKS host that is down previously surfaced as a statusless `SignatureError` (or `AuthenticationError` on 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.
22
+ - **`status` moved from `ApiError` up to the base `Error` class, and is now set on every leg of the flow.** 0.2.0 put it on `ApiError`, which covers the token and userinfo legs — but PAR raises `PARError`, so a 502 from the PAR endpoint carried no status and any classification keyed on `ApiError` reported a Singpass-side outage as our own bug. `status` and `transport?` now both live on `Error`, are populated on the PAR and authentication paths as well, and `FailureClassifier` keys on the base class. `ApiError#status` is unchanged for existing callers; `Error#initialize` takes both keywords optionally, so `raise SomeError, "msg"` still works.
23
+
24
+ - **`config.mock_mode` is 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 raises `ConfigurationError` and refuses to boot**; a **production-*like* deploy** (`RAILS_ENV=production` but not production per `production_env_detector`) logs and reports via `Rails.error`, then boots; anything else is silent. Wired from the engine's `after_initialize` with no opt-out — a guard you can forget to invoke is a guard that isn't there.
25
+
26
+ **Upgrade note:** a host that sets `mock_mode` on a deploy running `RAILS_ENV=production` will now either fail to boot or emit a reported error. If that deploy is a staging or preview box, set `config.production_env_detector` so the gem can tell the difference. Hosts that leave `mock_mode` at its default (`false`) are unaffected.
27
+
28
+ Reporting goes through `Rails.error` rather than any specific error tracker, so whatever the host has subscribed (Sentry included) picks it up.
29
+
30
+ ### Notes
31
+
32
+ - 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 `ENV` itself, and `production_env_detector` is the seam for the one environment opinion it does hold.
33
+
10
34
  ## [0.2.0] - 2026-07-22
11
35
 
12
36
  ### Added
data/README.md CHANGED
@@ -43,9 +43,52 @@ StandardSingpass::Myinfo.configure do |c|
43
43
  # Optional: path to a JSON file of test personas (for mock callback flows).
44
44
  # Defaults to the gem's bundled fixtures/myinfo-personas.json.
45
45
  # c.personas_path = Rails.root.join("e2e/fixtures/myinfo-personas.json")
46
+
47
+ # Optional: serve fixture persona data instead of running the real FAPI 2.0
48
+ # flow. Refused outright on a production deploy — see "Mock mode" below.
49
+ c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
50
+
51
+ # Optional: how the gem decides whether this deploy is real production, for
52
+ # the mock-mode guard. Defaults to Rails.env.production?.
53
+ # c.production_env_detector = -> { AppEnv.production? }
46
54
  end
47
55
  ```
48
56
 
57
+ ## Mock mode
58
+
59
+ `config.mock_mode` makes the host serve fixture persona data
60
+ (`StandardSingpass::Myinfo::TestPersonas`) instead of running the real
61
+ Singpass flow — right for development, CI, and staging rehearsals, and
62
+ catastrophic in production, where real users would submit fabricated identity
63
+ data indistinguishable from verified MyInfo data downstream.
64
+
65
+ **The gem enforces that.** An `after_initialize` hook runs
66
+ `StandardSingpass::Myinfo::MockModeGuard.check!` on every boot. There is no
67
+ opt-out — a guard you can forget to wire is a guard that isn't there.
68
+
69
+ | Deploy | Mock mode on | Behaviour |
70
+ |---|---|---|
71
+ | Production | yes | **Raises `ConfigurationError` — the app refuses to boot.** |
72
+ | `RAILS_ENV=production` but not production | yes | Logs an error and reports via `Rails.error`; boots. |
73
+ | Anything else | yes | Silent. |
74
+ | Any | no | Silent. |
75
+
76
+ By default "production" means `Rails.env.production?`. Staging and preview
77
+ deploys routinely run `RAILS_ENV=production` while being entirely legitimate
78
+ places to enable mock mode, so hosts that distinguish a physical deploy
79
+ environment from `RAILS_ENV` should say so:
80
+
81
+ ```ruby
82
+ # Mock mode stays legal on a physically-staging box, still refused on real prod.
83
+ c.production_env_detector = -> { AppEnv.production? }
84
+ ```
85
+
86
+ `production_env_detector` takes no arguments and returns a boolean; when `nil`
87
+ (the default) the gem falls back to `Rails.env.production?`, so existing
88
+ consumers are unaffected. The production-like tier reports through
89
+ `Rails.error` rather than a specific error tracker, so whatever the host has
90
+ subscribed (Sentry included) picks it up.
91
+
49
92
  ## Initiating the flow
50
93
 
51
94
  ```ruby
@@ -84,6 +127,27 @@ acr = result[:id_token_acr]
84
127
  # host-side persistence / projection layer.
85
128
  ```
86
129
 
130
+ ### Notice-of-Assessment scopes
131
+
132
+ Singpass approves NOA access at one of two widths, and the width is a property
133
+ of the *app registration*, not of the request: `noa` yields the latest year
134
+ only, `noahistory` the last two. `PersonDataParser` mirrors that faithfully,
135
+ emitting `:noa` (one record) or `:noa_history` (an array) — so consumers that
136
+ just want "the NOA records" would each write the same branch.
137
+ `StandardSingpass::Myinfo::Noa` is that branch:
138
+
139
+ ```ruby
140
+ # An array of NOA records regardless of which scope was granted, or nil when
141
+ # neither yielded data. Prefers the 2-year history when both are present.
142
+ StandardSingpass::Myinfo::Noa.history(parsed)
143
+
144
+ # Same, for the narrower `noa-basic` / `noahistory-basic` scope pair.
145
+ StandardSingpass::Myinfo::Noa.basic_history(parsed)
146
+ ```
147
+
148
+ Keys are read as both Symbols and Strings, so a hash round-tripped through
149
+ JSON or a database column works unchanged.
150
+
87
151
  ## Generating and serving JWKS
88
152
 
89
153
  The host application is responsible for serving the public JWKS at
@@ -109,7 +173,29 @@ All errors descend from `StandardSingpass::Myinfo::Error`:
109
173
  - `RateLimitError` — Singpass returned HTTP 429
110
174
  - `ConfigurationError` — gem is misconfigured (e.g. invalid ACR URN)
111
175
 
112
- `DecryptionError` and `SignatureError` indicate a key/cert misconfiguration, not an upstream outage exclude them from circuit-breaker tracking if you use one.
176
+ `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.
177
+
178
+ 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:
179
+
180
+ - `status` — the HTTP status of the offending response (`nil` for transport failures and for errors a host raises itself)
181
+ - `transport?` — true when the request never reached Singpass at all (DNS, connection refused, TLS, read timeout)
182
+
183
+ ### Classifying a failure
184
+
185
+ 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:
186
+
187
+ ```ruby
188
+ if StandardSingpass::Myinfo::FailureClassifier.upstream_unavailable?(error)
189
+ # 502/503/504, HTTP 429, or a transport failure on any leg.
190
+ # Tell the user to retry shortly.
191
+ else
192
+ # Our request, our keys, or our config. Surface it to support.
193
+ end
194
+ ```
195
+
196
+ 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](https://docs.developer.singpass.gov.sg/docs/products/singpass-myinfo/scheduled-downtimes); 503/504 are the generic equivalents. The list is `FailureClassifier::UPSTREAM_UNAVAILABLE_STATUSES`, and the client's automatic userinfo retry uses that same constant.
197
+
198
+ **Never classify by matching the message text** — it is not a stable interface. That is exactly what `transport?` and `status` exist to replace.
113
199
 
114
200
  ## License
115
201
 
@@ -19,7 +19,9 @@
19
19
  # MYINFO_MIN_ACR Required Authentication Context Class Reference
20
20
  # URN, e.g. urn:singpass:authentication:loa:3
21
21
  # MYINFO_MOCK_MODE When set, suppresses missing-key warnings and
22
- # unlocks any mock-callback routes the host registers
22
+ # unlocks any mock-callback routes the host
23
+ # registers. The gem REFUSES TO BOOT if this is set
24
+ # on a production deploy — see MockModeGuard.
23
25
 
24
26
  StandardSingpass::Myinfo.configure do |c|
25
27
  # Endpoint set. :production → live Singpass FAPI; :staging → sandbox.
@@ -41,6 +43,14 @@ StandardSingpass::Myinfo.configure do |c|
41
43
  c.minimum_acr = ENV["MYINFO_MIN_ACR"]
42
44
  c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
43
45
 
46
+ # How the gem decides whether this deploy is real production, for the
47
+ # mock-mode boot guard. Defaults to Rails.env.production?. Staging and
48
+ # preview deploys routinely run RAILS_ENV=production while being legitimate
49
+ # places to run mock mode — if that describes you, point this at your own
50
+ # environment discriminator so mock mode stays legal there and still
51
+ # refuses to boot on real production.
52
+ # c.production_env_detector = -> { AppEnv.production? }
53
+
44
54
  # Path to a JSON file of test personas the host can drive mock callbacks
45
55
  # against. Defaults to the gem's bundled fixture set. Override here if you
46
56
  # maintain your own persona file.
@@ -12,6 +12,12 @@ if defined?(::Rails::Engine)
12
12
  rake_tasks do
13
13
  load File.expand_path("../tasks/standard_singpass.rake", __dir__)
14
14
  end
15
+
16
+ # Runs after the host's own initializers, so `config.mock_mode` has
17
+ # been set by the time it is read. Inert unless mock mode is on.
18
+ config.after_initialize do
19
+ StandardSingpass::Myinfo::MockModeGuard.check!
20
+ end
15
21
  end
16
22
  end
17
23
  end
@@ -85,7 +85,7 @@ module StandardSingpass
85
85
  handle_par_response(response)
86
86
  end
87
87
  rescue Faraday::Error => e
88
- raise PARError, "PAR endpoint unreachable: #{e.class}"
88
+ raise PARError.new("PAR endpoint unreachable: #{e.class}", transport: true)
89
89
  end
90
90
 
91
91
  sig { params(request_uri: String).returns(String) }
@@ -121,11 +121,11 @@ module StandardSingpass
121
121
  data = JSON.parse(response.body)
122
122
  { request_uri: data.fetch("request_uri"), expires_in: data.fetch("expires_in") }
123
123
  when 401, 403
124
- raise PARError, "PAR rejected (HTTP #{response.status}): #{body_excerpt(response)}"
124
+ raise PARError.new("PAR rejected (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
125
125
  when 429
126
126
  raise RateLimitError, "PAR endpoint rate limit exceeded"
127
127
  else
128
- raise PARError, "PAR failed (HTTP #{response.status}): #{body_excerpt(response)}"
128
+ raise PARError.new("PAR failed (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
129
129
  end
130
130
  rescue KeyError
131
131
  raise PARError, "PAR response missing required fields"
@@ -164,7 +164,7 @@ module StandardSingpass
164
164
  handle_token_response(response)
165
165
  end
166
166
  rescue Faraday::Error => e
167
- raise ApiError, "MyInfo token endpoint unreachable: #{e.class}"
167
+ raise ApiError.new("MyInfo token endpoint unreachable: #{e.class}", transport: true)
168
168
  end
169
169
 
170
170
  sig { params(access_token: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[String, T.untyped]) }
@@ -190,7 +190,7 @@ module StandardSingpass
190
190
  end
191
191
  end
192
192
  rescue Faraday::Error => e
193
- raise ApiError, "MyInfo userinfo endpoint unreachable: #{e.class}"
193
+ raise ApiError.new("MyInfo userinfo endpoint unreachable: #{e.class}", transport: true)
194
194
  end
195
195
 
196
196
  sig { params(id_token: T.nilable(String), nonce: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
@@ -222,8 +222,15 @@ module StandardSingpass
222
222
  Security.validate_jws(decrypted, jwks_url: T.must(@jwks_url))
223
223
  rescue Security::DecryptionError
224
224
  raise AuthenticationError, "ID token decryption failed"
225
- rescue Security::ValidationError
226
- raise AuthenticationError, "ID token signature verification failed"
225
+ rescue Security::ValidationError => e
226
+ # Carry status/transport across the re-raise: a JWKS endpoint that is
227
+ # down produces the same exception here as a genuinely bad signature,
228
+ # and only these attributes tell them apart.
229
+ raise AuthenticationError.new(
230
+ "ID token signature verification failed",
231
+ status: e.status,
232
+ transport: e.transport?
233
+ )
227
234
  rescue JWT::DecodeError
228
235
  raise AuthenticationError, "Failed to decode ID token"
229
236
  end
@@ -336,7 +343,7 @@ module StandardSingpass
336
343
  data = JSON.parse(response.body)
337
344
  { access_token: data.fetch("access_token"), id_token: data["id_token"] }
338
345
  when 401, 403
339
- raise AuthenticationError, "Token exchange rejected (HTTP #{response.status}): #{body_excerpt(response)}"
346
+ raise AuthenticationError.new("Token exchange rejected (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
340
347
  when 429
341
348
  raise RateLimitError, "Token endpoint rate limit exceeded"
342
349
  else
@@ -357,7 +364,7 @@ module StandardSingpass
357
364
  when 200
358
365
  decrypt_and_validate_person(response.body, jwks_url:)
359
366
  when 401, 403
360
- raise AuthenticationError, "Person data request forbidden (HTTP #{response.status}): #{body_excerpt(response)}"
367
+ raise AuthenticationError.new("Person data request forbidden (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
361
368
  when 429
362
369
  raise RateLimitError, "Person endpoint rate limit exceeded"
363
370
  else
@@ -372,7 +379,12 @@ module StandardSingpass
372
379
  # having a moment" — transient and worth one more try. 502 is Singpass's
373
380
  # documented upstream-dependency signal (`upstream_dependency_error`),
374
381
  # which they also return throughout CPF/IRAS/MOM maintenance windows.
375
- RETRYABLE_USERINFO_STATUSES = T.let([502, 503, 504].freeze, T::Array[Integer])
382
+ #
383
+ # Aliased from FailureClassifier rather than restated: the statuses
384
+ # worth one more automatic attempt are exactly the statuses the host is
385
+ # told to present as "their side, try again shortly", and two copies of
386
+ # that list would eventually disagree.
387
+ RETRYABLE_USERINFO_STATUSES = FailureClassifier::UPSTREAM_UNAVAILABLE_STATUSES
376
388
 
377
389
  # Total attempts, not retries — 3 means the original plus two more.
378
390
  USERINFO_MAX_ATTEMPTS = 3
@@ -457,7 +469,8 @@ module StandardSingpass
457
469
  rescue Security::DecryptionError => e
458
470
  raise DecryptionError, e.message
459
471
  rescue Security::ValidationError => e
460
- raise SignatureError, e.message
472
+ # As above — a JWKS-host outage must not be reported as a bad signature.
473
+ raise SignatureError.new(e.message, status: e.status, transport: e.transport?)
461
474
  end
462
475
 
463
476
  sig { void }
@@ -17,6 +17,10 @@
17
17
  # c.minimum_acr - Required Authentication Context Class Reference URN
18
18
  # c.network_wrapper - Lambda wrapping outbound Faraday calls (e.g. circuit breaker)
19
19
  # c.mock_mode - When true, suppresses missing-key warnings
20
+ # (and is refused outright on a production deploy
21
+ # — see MockModeGuard)
22
+ # c.production_env_detector - Callable returning whether this deploy is real
23
+ # production; defaults to Rails.env.production?
20
24
  # c.personas_path - Pathname to JSON file of test personas
21
25
  # c.authorize_url, c.par_url, c.token_url, c.userinfo_url,
22
26
  # c.jwks_url, c.userinfo_jwks_url, c.issuer
@@ -125,12 +129,24 @@ module StandardSingpass
125
129
  :signing_key, :signing_kid, :encryption_keys,
126
130
  :minimum_acr, :network_wrapper, :mock_mode, :personas_path
127
131
 
132
+ # Optional callable deciding whether the current deploy is real
133
+ # production, for the purpose of MockModeGuard. Takes no args, returns a
134
+ # boolean. When nil (the default) the gem falls back to
135
+ # Rails.env.production?, so existing consumers are unchanged.
136
+ #
137
+ # Hosts that distinguish a physical deploy environment from RAILS_ENV
138
+ # — staging and preview boxes routinely run RAILS_ENV=production — can
139
+ # supply `-> { AppEnv.production? }` so mock mode stays legal on those
140
+ # while still refusing to boot on real production.
141
+ attr_accessor :production_env_detector
142
+
128
143
  def initialize
129
144
  self.environment = :staging
130
145
  @scope = DEFAULT_SCOPE
131
146
  @encryption_keys = []
132
147
  @network_wrapper = ->(&block) { block.call }
133
148
  @mock_mode = false
149
+ @production_env_detector = nil
134
150
  end
135
151
 
136
152
  def environment=(env)
@@ -2,7 +2,57 @@
2
2
 
3
3
  module StandardSingpass
4
4
  module Myinfo
5
- class Error < StandardError; end
5
+ # Base class for every error the gem raises.
6
+ #
7
+ # Every error carries the two facts a host needs to decide what to tell
8
+ # the user, so neither has to be recovered by reading the message:
9
+ #
10
+ # `status` — the HTTP status of the Singpass response that produced the
11
+ # error. Nil when there was no response (a transport failure) and for
12
+ # errors a host raises itself.
13
+ #
14
+ # `transport?` — true when we never got an answer from Singpass at all
15
+ # (DNS, connection refused, TLS, read timeout — a `Faraday::Error` under
16
+ # the hood), as opposed to Singpass answering with something we can't
17
+ # use. Only the former is unambiguously *their* side being unreachable.
18
+ #
19
+ # Both live on the base class rather than on `ApiError` alone because
20
+ # every leg of the flow raises its own class — `PARError` for PAR,
21
+ # `ApiError` for token and userinfo — and a host asking "is Singpass
22
+ # available?" needs the same answer from all of them. Scoping `status` to
23
+ # `ApiError` would silently misclassify a 502 from the PAR endpoint as
24
+ # "our request was wrong".
25
+ #
26
+ # The alternative — matching "unreachable" or an HTTP code in `message` —
27
+ # is a trap: the message is not a stable interface. `FailureClassifier`
28
+ # consumes these so hosts never have to.
29
+ class Error < StandardError
30
+ extend T::Sig
31
+
32
+ sig { returns(T.nilable(Integer)) }
33
+ attr_reader :status
34
+
35
+ # Both keywords are optional so the bare `raise SomeError, "msg"` form
36
+ # used across the client (and by hosts) keeps working unchanged.
37
+ sig do
38
+ params(
39
+ message: T.nilable(String),
40
+ status: T.nilable(Integer),
41
+ transport: T::Boolean
42
+ ).void
43
+ end
44
+ def initialize(message = nil, status: nil, transport: false)
45
+ super(message)
46
+ @status = status
47
+ @transport = transport
48
+ end
49
+
50
+ sig { returns(T::Boolean) }
51
+ def transport?
52
+ @transport
53
+ end
54
+ end
55
+
6
56
  class AuthenticationError < Error; end
7
57
  class PARError < Error; end
8
58
  class DecryptionError < Error; end
@@ -10,34 +60,14 @@ module StandardSingpass
10
60
  class RateLimitError < Error; end
11
61
  class ConfigurationError < Error; end
12
62
 
13
- # Raised for a Myinfo response we can't use, and for transport failures
14
- # reaching Myinfo at all.
15
- #
16
- # `status` carries the HTTP status when the error came from an actual
17
- # response (nil for transport failures, and for errors a host application
18
- # raises itself). Hosts need it to tell "Myinfo or one of its upstream
19
- # agencies is unavailable" — 502/503/504, retry in a few minutes — apart
20
- # from "we sent something wrong", which is a bug and warrants support
21
- # contact. Parsing the status back out of `message` is the alternative, and
22
- # it is a trap: the message is not a stable interface.
63
+ # Raised for a Myinfo token- or userinfo-endpoint response we can't use,
64
+ # and for transport failures reaching those endpoints at all. `status` and
65
+ # `transport?` come from Error.
23
66
  #
24
67
  # 502 specifically is Singpass's documented signal that a Myinfo upstream
25
68
  # (CPF Board, IRAS, MOM, …) is down, including during their published
26
69
  # maintenance windows:
27
70
  # https://docs.developer.singpass.gov.sg/docs/products/singpass-myinfo/scheduled-downtimes
28
- class ApiError < Error
29
- extend T::Sig
30
-
31
- sig { returns(T.nilable(Integer)) }
32
- attr_reader :status
33
-
34
- # `status:` is keyword-only and optional so the bare `raise ApiError,
35
- # "msg"` form used across the client (and by hosts) keeps working.
36
- sig { params(message: T.nilable(String), status: T.nilable(Integer)).void }
37
- def initialize(message = nil, status: nil)
38
- super(message)
39
- @status = status
40
- end
41
- end
71
+ class ApiError < Error; end
42
72
  end
43
73
  end
@@ -0,0 +1,64 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ # Decides whether a Myinfo failure is *Singpass's* side being unavailable
6
+ # or *our* request being wrong.
7
+ #
8
+ # The distinction exists because it changes what the host tells the user.
9
+ # An unavailable upstream clears itself within minutes, so the honest
10
+ # instruction is "try again shortly"; telling someone "something on our
11
+ # side stopped the verification — contact support and quote this
12
+ # reference" is both wrong and a dead end. Conversely, presenting a
13
+ # genuine integration bug as a transient blip sends the user round a loop
14
+ # that will never succeed.
15
+ #
16
+ # This lives in the gem rather than in each host because the rule is
17
+ # entirely Myinfo protocol knowledge: which statuses Singpass uses for
18
+ # upstream-agency outages, and which of the gem's own exception classes
19
+ # represent an unreachable endpoint. Hosts typically need the same answer
20
+ # at more than one call site (a callback's result branch and a
21
+ # controller's rescue), so it is a module function rather than a private
22
+ # method on either.
23
+ module FailureClassifier
24
+ extend T::Sig
25
+
26
+ # 502 is Singpass's documented signal that a Myinfo upstream agency
27
+ # (CPF Board, IRAS, MOM, …) is unavailable — returned both for genuine
28
+ # blips and throughout their published maintenance windows. 503/504 are
29
+ # the generic unavailable/gateway-timeout equivalents.
30
+ # https://docs.developer.singpass.gov.sg/docs/products/singpass-myinfo/scheduled-downtimes
31
+ #
32
+ # `Client::RETRYABLE_USERINFO_STATUSES` is an alias of this list: the
33
+ # statuses worth one more automatic attempt are exactly the statuses
34
+ # that mean "their side, transient".
35
+ UPSTREAM_UNAVAILABLE_STATUSES = T.let([502, 503, 504].freeze, T::Array[Integer])
36
+
37
+ # True when the failure is Singpass (or one of its upstream agencies)
38
+ # being unavailable, i.e. the user should be told to retry shortly.
39
+ # False for anything that looks like our own request being wrong —
40
+ # authentication, decryption, signature, and configuration failures, and
41
+ # any non-Myinfo exception.
42
+ #
43
+ # Deliberately keyed on the base `Error` rather than `ApiError`: every
44
+ # leg of the flow raises its own class (`PARError` for PAR, `ApiError`
45
+ # for token and userinfo), and a 502 is a 502 whichever leg returned it.
46
+ # Narrowing to `ApiError` would report a PAR-leg outage as our own bug.
47
+ sig { params(error: ::StandardError).returns(T::Boolean) }
48
+ def self.upstream_unavailable?(error)
49
+ # Rate limiting counts: temporary, clears on its own, and the advice
50
+ # the host gives the user is identical.
51
+ return true if error.is_a?(RateLimitError)
52
+ return false unless error.is_a?(Error)
53
+
54
+ # A transport failure never reached Singpass at all, which is still
55
+ # their endpoint being unreachable from here.
56
+ return true if error.transport?
57
+
58
+ # `status` is nil for an error a host raised itself; that falls
59
+ # through to false rather than being guessed at.
60
+ UPSTREAM_UNAVAILABLE_STATUSES.include?(error.status)
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,112 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ # Boot-time guard against running with `config.mock_mode` enabled where it
6
+ # must never be.
7
+ #
8
+ # Mock mode short-circuits the FAPI 2.0 dance and serves data from static
9
+ # fixture personas (`TestPersonas`). That is exactly right for
10
+ # development, CI, and staging rehearsals, and catastrophic in production:
11
+ # real users would submit fabricated identity and income data, and it
12
+ # would be indistinguishable downstream from verified Myinfo data. A
13
+ # mis-set environment variable on one deploy is all it takes, and nothing
14
+ # about the running app looks wrong afterwards — which is why this is a
15
+ # boot-time check rather than a runtime warning.
16
+ #
17
+ # Three outcomes, deliberately:
18
+ #
19
+ # production deploy → raise. The app refuses to boot.
20
+ # production-*like* deploy → log + report, boot anyway.
21
+ # anything else → silent.
22
+ #
23
+ # The middle tier exists because staging and preview deploys routinely run
24
+ # `RAILS_ENV=production` while being entirely legitimate places to enable
25
+ # mock mode. Failing those closed would make the guard unusable, and
26
+ # staying silent would hide a genuinely mis-set variable, so they get a
27
+ # loud-but-non-fatal signal instead.
28
+ #
29
+ # "Production deploy" is decided by `config.production_env_detector` when
30
+ # set, falling back to `Rails.env.production?`. The gem cannot know about
31
+ # a host's own environment discriminator (`APP_ENVIRONMENT` and friends),
32
+ # and deliberately does not read ENV itself — the callable is the seam.
33
+ # This mirrors `StandardId`'s `production_env_detector`.
34
+ #
35
+ # Wired automatically from the engine's `after_initialize`, so a host gets
36
+ # the guard by configuring `mock_mode` at all. There is no opt-out: a
37
+ # guard you can forget to invoke is a guard that isn't there.
38
+ module MockModeGuard
39
+ extend T::Sig
40
+
41
+ sig { void }
42
+ def self.check!
43
+ return unless StandardSingpass::Myinfo.configuration.mock_mode
44
+
45
+ if production_deploy?
46
+ raise ConfigurationError, production_message
47
+ elsif rails_production?
48
+ report_production_like
49
+ end
50
+ end
51
+
52
+ # Whether this deploy is real production for the purpose of the guard.
53
+ # Defers to config.production_env_detector when set (hosts that
54
+ # distinguish a physical deploy environment from RAILS_ENV), else falls
55
+ # back to Rails.env.production?.
56
+ sig { returns(T::Boolean) }
57
+ def self.production_deploy?
58
+ detector = StandardSingpass::Myinfo.configuration.production_env_detector
59
+ return !!detector.call if detector
60
+
61
+ rails_production?
62
+ end
63
+
64
+ sig { returns(T::Boolean) }
65
+ def self.rails_production?
66
+ defined?(::Rails) && ::Rails.env.production?
67
+ end
68
+ private_class_method :rails_production?
69
+
70
+ sig { returns(String) }
71
+ def self.production_message
72
+ "StandardSingpass::Myinfo mock_mode is enabled on a production " \
73
+ "deploy. Mock mode bypasses the real Singpass flow and serves " \
74
+ "fixture persona data, so real users would submit fabricated " \
75
+ "identity data that is indistinguishable from verified Myinfo " \
76
+ "data downstream. The application refuses to boot. Unset the " \
77
+ "mock-mode environment variable on this deploy and redeploy. If " \
78
+ "this deploy is not actually production, set " \
79
+ "config.production_env_detector so the gem can tell."
80
+ end
81
+ private_class_method :production_message
82
+
83
+ # A production-*like* deploy (RAILS_ENV=production but not production
84
+ # per the detector) — staging, preview, a review app. Mock mode can be
85
+ # intentional here, so this boots; it is reported rather than raised.
86
+ #
87
+ # Reported through `Rails.error` rather than a specific error tracker so
88
+ # whatever the host has subscribed (Sentry included) picks it up.
89
+ sig { void }
90
+ def self.report_production_like
91
+ message = "StandardSingpass::Myinfo mock_mode is enabled on a " \
92
+ "deploy running RAILS_ENV=production. Users will go through the " \
93
+ "mock Singpass flow and submit fixture data. Unset the mock-mode " \
94
+ "environment variable unless this is intentional."
95
+
96
+ ::Rails.logger&.error("StandardSingpass::Myinfo: #{message}")
97
+
98
+ return unless defined?(::Rails.error)
99
+
100
+ ::Rails.error.report(
101
+ ConfigurationError.new(message),
102
+ handled: true,
103
+ context: {
104
+ component: "StandardSingpass::Myinfo::MockModeGuard",
105
+ reason: "mock_mode_on_production_like_deploy"
106
+ }
107
+ )
108
+ end
109
+ private_class_method :report_production_like
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,76 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ # Normalises Notice-of-Assessment data from `PersonDataParser` into a
6
+ # single list, regardless of which NOA scope the Singpass app was approved
7
+ # for.
8
+ #
9
+ # Singpass approves NOA access at one of two widths, and the width is a
10
+ # property of the *app registration*, not of the request:
11
+ #
12
+ # noa → Notice of Assessment, latest year only
13
+ # noahistory → Notice of Assessment, last 2 years
14
+ #
15
+ # `PersonDataParser` mirrors that faithfully, emitting `:noa` (one record)
16
+ # or `:noa_history` (an array, one entry per assessment year). The two
17
+ # records have an identical shape — `year_of_assessment`, `amount`,
18
+ # `employment`, `trade`, `rent`, `interest`, `tax_category` — so every
19
+ # consumer that renders NOA data ends up writing the same "array or wrap
20
+ # the single record" branch. That branch is scope-grant knowledge, not
21
+ # host policy, so it belongs here.
22
+ #
23
+ # The `-basic` scopes (`noa-basic` / `noahistory-basic`) are the same
24
+ # split at a narrower field set (`year_of_assessment` and `amount` only),
25
+ # handled by `.basic_history`.
26
+ #
27
+ # The wider grant is preferred when both keys are present: a 2-year
28
+ # history is a superset of the latest year, so returning it loses nothing.
29
+ #
30
+ # Keys are looked up as both Symbol and String because parser output is
31
+ # symbol-keyed when fresh but string-keyed once it has round-tripped
32
+ # through JSON or a database column — which is where most hosts read it
33
+ # back from.
34
+ module Noa
35
+ extend T::Sig
36
+
37
+ # Detailed NOA records, newest first as Singpass returns them. Returns
38
+ # nil when neither detailed scope yielded data (e.g. a self-reported
39
+ # application with no Myinfo record at all).
40
+ sig { params(parsed: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
41
+ def self.history(parsed)
42
+ normalize(parsed, history_key: :noa_history, single_key: :noa)
43
+ end
44
+
45
+ # Same, for the `-basic` scope pair.
46
+ sig { params(parsed: T.untyped).returns(T.nilable(T::Array[T.untyped])) }
47
+ def self.basic_history(parsed)
48
+ normalize(parsed, history_key: :noa_history_basic, single_key: :noa_basic)
49
+ end
50
+
51
+ sig do
52
+ params(
53
+ parsed: T.untyped,
54
+ history_key: Symbol,
55
+ single_key: Symbol
56
+ ).returns(T.nilable(T::Array[T.untyped]))
57
+ end
58
+ def self.normalize(parsed, history_key:, single_key:)
59
+ history = fetch(parsed, history_key)
60
+ return history if history.is_a?(Array) && !history.empty?
61
+
62
+ single = fetch(parsed, single_key)
63
+ single.is_a?(Hash) && !single.empty? ? [single] : nil
64
+ end
65
+ private_class_method :normalize
66
+
67
+ sig { params(parsed: T.untyped, key: Symbol).returns(T.untyped) }
68
+ def self.fetch(parsed, key)
69
+ return nil unless parsed.is_a?(Hash)
70
+
71
+ parsed[key].nil? ? parsed[key.to_s] : parsed[key]
72
+ end
73
+ private_class_method :fetch
74
+ end
75
+ end
76
+ end
@@ -5,8 +5,15 @@ module StandardSingpass
5
5
  class Security
6
6
  extend T::Sig
7
7
 
8
- class DecryptionError < StandardError; end
9
- class ValidationError < StandardError; end
8
+ # Both inherit the gem's base Error so they carry `status` and
9
+ # `transport?`. That matters for ValidationError in particular: fetching
10
+ # the JWKS is a network call to a Singpass host, so "the JWKS endpoint
11
+ # is down" and "this signature is genuinely bad" both surface here and
12
+ # must stay distinguishable — the first is an outage, the second is a
13
+ # key/cert problem. FailureClassifier reads those attributes; without
14
+ # them a JWKS-host outage would be reported as our own bug.
15
+ class DecryptionError < Error; end
16
+ class ValidationError < Error; end
10
17
 
11
18
  JWKS_CACHE_TTL = T.let(1.hour, ActiveSupport::Duration)
12
19
 
@@ -170,11 +177,18 @@ module StandardSingpass
170
177
 
171
178
  Rails.cache.fetch(cache_key, expires_in: JWKS_CACHE_TTL) do
172
179
  response = Faraday.get(url) { |req| req.options.timeout = 5; req.options.open_timeout = 3 }
173
- raise ValidationError, "Failed to fetch JWKS: HTTP #{response.status}" unless response.success?
180
+ unless response.success?
181
+ raise ValidationError.new("Failed to fetch JWKS: HTTP #{response.status}", status: response.status)
182
+ end
174
183
 
175
184
  JSON.parse(response.body)
176
185
  end
177
- rescue Faraday::Error, JSON::ParserError => e
186
+ rescue Faraday::Error => e
187
+ # Never reached the JWKS host — the same "their side" case a transport
188
+ # failure on any other leg represents.
189
+ raise ValidationError.new("Failed to fetch JWKS: #{e.message}", transport: true)
190
+ rescue JSON::ParserError => e
191
+ # Reached it and got something unusable. Not an availability problem.
178
192
  raise ValidationError, "Failed to fetch JWKS: #{e.message}"
179
193
  end
180
194
  private_class_method :fetch_jwks
@@ -10,13 +10,16 @@ require "securerandom"
10
10
  require "aes_key_wrap"
11
11
 
12
12
  require "standard_singpass/myinfo/error"
13
+ require "standard_singpass/myinfo/failure_classifier"
13
14
  require "standard_singpass/myinfo/configuration"
14
15
  require "standard_singpass/myinfo/ecdh_jwe"
15
16
  require "standard_singpass/myinfo/security"
16
17
  require "standard_singpass/myinfo/client"
17
18
  require "standard_singpass/myinfo/person_data_parser"
19
+ require "standard_singpass/myinfo/noa"
18
20
  require "standard_singpass/myinfo/jwks_generator"
19
21
  require "standard_singpass/myinfo/test_personas"
22
+ require "standard_singpass/myinfo/mock_mode_guard"
20
23
 
21
24
  module StandardSingpass
22
25
  module Myinfo
@@ -1,3 +1,3 @@
1
1
  module StandardSingpass
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_singpass
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -145,7 +145,10 @@ files:
145
145
  - lib/standard_singpass/myinfo/configuration.rb
146
146
  - lib/standard_singpass/myinfo/ecdh_jwe.rb
147
147
  - lib/standard_singpass/myinfo/error.rb
148
+ - lib/standard_singpass/myinfo/failure_classifier.rb
148
149
  - lib/standard_singpass/myinfo/jwks_generator.rb
150
+ - lib/standard_singpass/myinfo/mock_mode_guard.rb
151
+ - lib/standard_singpass/myinfo/noa.rb
149
152
  - lib/standard_singpass/myinfo/person_data_parser.rb
150
153
  - lib/standard_singpass/myinfo/security.rb
151
154
  - lib/standard_singpass/myinfo/test_personas.rb