standard_singpass 0.2.0 → 0.3.1

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: dbda49a3c31fd6844db0d58c66c0bf1c8654882d37105c58dd1537a2e2cb13c5
4
+ data.tar.gz: 5b4ebd78e72bab8b36a2d8d5c298cc8f99e277c018b23a5b42ff7dfb4030292e
5
5
  SHA512:
6
- metadata.gz: e3f7ca50aaf67da5e1b82f33455662da9743a9f4cffedd533a878c409394681b7f1e1d16c197362cccce9031744509c71e6674195b1012ee7f7867595fa4cd48
7
- data.tar.gz: bba22e4c5ede26f234acfea6289ee469fa646612bf198eabcaacc397e3270ce276fd5d3ea18adf9c87b549a0d88ab3b905baa15b7692de1666eaebae0ba58d8d
6
+ metadata.gz: 47d70dda7f2948b55b59afac864885452e776aefe9e8806a16e62f31b9ca02bc6a8772e5eff7849b25ca3a8805487d45ea3c63a77412a713ba3c7b11f6e7f3e1
7
+ data.tar.gz: cd7b80ae5bea3214b3d1bb146801c673fe2934b8b245865ef7bd61dee8952a0d2f5dd670f24c08e925fb083fb6e8debe1901ad2ce9fcee57b8fe9174cff503af
data/CHANGELOG.md CHANGED
@@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.1] - 2026-09-24
11
+
12
+ ### Fixed
13
+
14
+ - **`minimum_acr` is no longer sent to Singpass as `acr_values` on the Pushed Authorization Request.** Singpass rejects that parameter for MyInfo — `PAR failed (HTTP 400) invalid_request — "The acr_values parameter can only be used for specific use cases."` — so any host that set `minimum_acr` (as the README and the install generator recommended, via `MYINFO_MIN_ACR`) failed every MyInfo login at the first step. This took down production onboarding for a consuming app for roughly four days before it was traced. `minimum_acr` is now purely a client-side check: the id_token `acr` claim is still validated against it (`AuthenticationError` when below the floor or missing; `ConfigurationError` for an unrecognised URN), and the PAR body never carries `acr_values`.
15
+
16
+ **Behaviour change / upgrade note:** hosts with `minimum_acr` set go from "every PAR fails with HTTP 400" to "PAR succeeds and the returned id_token `acr` is checked locally". Hosts that left it unset see no change. If you had pinned `minimum_acr = nil` as a workaround, you can keep it — MyInfo's assurance level is governed by Singpass server-side and the floor is optional. If you wired it from `MYINFO_MIN_ACR`, consider removing that env wiring (see Documentation below).
17
+
18
+ ### Documentation
19
+
20
+ - **Install generator no longer wires `c.minimum_acr = ENV["MYINFO_MIN_ACR"]`** or documents `MYINFO_MIN_ACR`. The option is left commented out with a warning that it is a client-side check only and that a wrong value fails every login closed.
21
+ - **README:** Installation now mentions `bin/rails g standard_singpass:install`; the `MYINFO_MIN_ACR` wiring is removed from the configuration example; a new "Assurance level (`minimum_acr`)" section explains what the option does and does not do; and the default-scope comment now gives the real size of `DEFAULT_SCOPE` (42 scopes — `openid` plus 41 attributes — not "a 36-attribute set").
22
+ - **Consumer list in `CLAUDE.md` narrowed to the one app that actually consumes this gem.** It said "the rarebit-one workspace's web apps", which reads as all five and would send a rollout at four apps with no Singpass integration. The single consumer is `fundbright-web`, in the sibling `~/Workspace/fundbright/` workspace. The list is now bulleted so the new advisory `check-gem-family-drift.sh` can diff it against the canonical matrix in the workspace's `rollout-gem/SKILL.md` — prose that a human reads as one thing and a script as another is how this drifted in the first place.
23
+
24
+ ## [0.3.0] - 2026-07-30
25
+
26
+ ### Added
27
+
28
+ - **`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".
29
+ - **`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.
30
+ - **`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.
31
+ - **`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? }`.
32
+
33
+ ### Changed
34
+
35
+ - **`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.
36
+ - **`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.
37
+
38
+ - **`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.
39
+
40
+ **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.
41
+
42
+ Reporting goes through `Rails.error` rather than any specific error tracker, so whatever the host has subscribed (Sentry included) picks it up.
43
+
44
+ ### Notes
45
+
46
+ - 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.
47
+
10
48
  ## [0.2.0] - 2026-07-22
11
49
 
12
50
  ### Added
data/README.md CHANGED
@@ -12,6 +12,15 @@ Add to your Gemfile:
12
12
  gem "standard_singpass"
13
13
  ```
14
14
 
15
+ Then generate the initializer:
16
+
17
+ ```bash
18
+ bin/rails g standard_singpass:install
19
+ ```
20
+
21
+ This writes `config/initializers/standard_singpass.rb`, mapping the `MYINFO_*`
22
+ environment variables onto the gem's configuration (see below).
23
+
15
24
  ## Configuration
16
25
 
17
26
  ```ruby
@@ -24,17 +33,18 @@ StandardSingpass::Myinfo.configure do |c|
24
33
  c.client_id = ENV["MYINFO_CLIENT_ID"]
25
34
  c.redirect_url = ENV["MYINFO_REDIRECT_URL"]
26
35
 
27
- # Optional: override default scope (defaults to a 36-attribute set covering
28
- # identity, contact, income, employment, housing, assets, vehicles).
36
+ # Optional: override default scope (defaults to 42 scopes `openid` plus 41
37
+ # MyInfo attributes covering identity, contact, income, employment, housing,
38
+ # assets, vehicles).
29
39
  # c.scope = "openid name email ..."
30
40
 
31
41
  # Required: full private JWKS JSON containing both sig (ES256) and enc
32
42
  # (ECDH-ES+A256KW) keys with the private scalar `d`.
33
43
  c.private_jwks_json = ENV["MYINFO_PRIVATE_JWKS"]
34
44
 
35
- # Optional: enforce minimum Authentication Context Class Reference. Set to
36
- # e.g. "urn:singpass:authentication:loa:3" to require high-assurance.
37
- c.minimum_acr = ENV["MYINFO_MIN_ACR"]
45
+ # Optional: client-side floor on the id_token `acr` claim. Never sent to
46
+ # Singpass — see "Assurance level (`minimum_acr`)" below before setting it.
47
+ # c.minimum_acr = "urn:singpass:authentication:loa:2"
38
48
 
39
49
  # Optional: wrap outbound HTTP calls with a circuit breaker / retry layer.
40
50
  # Defaults to identity (no wrapper).
@@ -43,9 +53,79 @@ StandardSingpass::Myinfo.configure do |c|
43
53
  # Optional: path to a JSON file of test personas (for mock callback flows).
44
54
  # Defaults to the gem's bundled fixtures/myinfo-personas.json.
45
55
  # c.personas_path = Rails.root.join("e2e/fixtures/myinfo-personas.json")
56
+
57
+ # Optional: serve fixture persona data instead of running the real FAPI 2.0
58
+ # flow. Refused outright on a production deploy — see "Mock mode" below.
59
+ c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
60
+
61
+ # Optional: how the gem decides whether this deploy is real production, for
62
+ # the mock-mode guard. Defaults to Rails.env.production?.
63
+ # c.production_env_detector = -> { AppEnv.production? }
46
64
  end
47
65
  ```
48
66
 
67
+ ## Assurance level (`minimum_acr`)
68
+
69
+ `config.minimum_acr` is an optional, **client-side** check. When set to a
70
+ Singpass LOA URN (`urn:singpass:authentication:loa:2` or `...:loa:3`), the
71
+ client rejects any id_token whose `acr` claim is below that level — or missing
72
+ — with `StandardSingpass::Myinfo::AuthenticationError`. A value that is not a
73
+ recognised LOA URN raises `ConfigurationError`. Unset or blank disables the
74
+ check.
75
+
76
+ It is **never sent to Singpass.** Singpass rejects `acr_values` on a MyInfo
77
+ Pushed Authorization Request:
78
+
79
+ ```
80
+ PAR failed (HTTP 400) invalid_request —
81
+ "The acr_values parameter can only be used for specific use cases."
82
+ ```
83
+
84
+ Versions up to 0.3.0 forwarded a configured `minimum_acr` as `acr_values`, so
85
+ setting it broke every MyInfo login. From 0.3.1 the PAR request never carries
86
+ `acr_values`. MyInfo's assurance level is governed by Singpass server-side, so
87
+ most hosts should leave `minimum_acr` unset; if you do set it, set it in code
88
+ rather than from a loosely-managed environment variable, because a wrong value
89
+ fails every login closed.
90
+
91
+ The `acr` Singpass actually returned is surfaced as `result[:id_token_acr]`
92
+ from `get_person_data` for audit, whether or not a floor is configured.
93
+
94
+ ## Mock mode
95
+
96
+ `config.mock_mode` makes the host serve fixture persona data
97
+ (`StandardSingpass::Myinfo::TestPersonas`) instead of running the real
98
+ Singpass flow — right for development, CI, and staging rehearsals, and
99
+ catastrophic in production, where real users would submit fabricated identity
100
+ data indistinguishable from verified MyInfo data downstream.
101
+
102
+ **The gem enforces that.** An `after_initialize` hook runs
103
+ `StandardSingpass::Myinfo::MockModeGuard.check!` on every boot. There is no
104
+ opt-out — a guard you can forget to wire is a guard that isn't there.
105
+
106
+ | Deploy | Mock mode on | Behaviour |
107
+ |---|---|---|
108
+ | Production | yes | **Raises `ConfigurationError` — the app refuses to boot.** |
109
+ | `RAILS_ENV=production` but not production | yes | Logs an error and reports via `Rails.error`; boots. |
110
+ | Anything else | yes | Silent. |
111
+ | Any | no | Silent. |
112
+
113
+ By default "production" means `Rails.env.production?`. Staging and preview
114
+ deploys routinely run `RAILS_ENV=production` while being entirely legitimate
115
+ places to enable mock mode, so hosts that distinguish a physical deploy
116
+ environment from `RAILS_ENV` should say so:
117
+
118
+ ```ruby
119
+ # Mock mode stays legal on a physically-staging box, still refused on real prod.
120
+ c.production_env_detector = -> { AppEnv.production? }
121
+ ```
122
+
123
+ `production_env_detector` takes no arguments and returns a boolean; when `nil`
124
+ (the default) the gem falls back to `Rails.env.production?`, so existing
125
+ consumers are unaffected. The production-like tier reports through
126
+ `Rails.error` rather than a specific error tracker, so whatever the host has
127
+ subscribed (Sentry included) picks it up.
128
+
49
129
  ## Initiating the flow
50
130
 
51
131
  ```ruby
@@ -84,6 +164,27 @@ acr = result[:id_token_acr]
84
164
  # host-side persistence / projection layer.
85
165
  ```
86
166
 
167
+ ### Notice-of-Assessment scopes
168
+
169
+ Singpass approves NOA access at one of two widths, and the width is a property
170
+ of the *app registration*, not of the request: `noa` yields the latest year
171
+ only, `noahistory` the last two. `PersonDataParser` mirrors that faithfully,
172
+ emitting `:noa` (one record) or `:noa_history` (an array) — so consumers that
173
+ just want "the NOA records" would each write the same branch.
174
+ `StandardSingpass::Myinfo::Noa` is that branch:
175
+
176
+ ```ruby
177
+ # An array of NOA records regardless of which scope was granted, or nil when
178
+ # neither yielded data. Prefers the 2-year history when both are present.
179
+ StandardSingpass::Myinfo::Noa.history(parsed)
180
+
181
+ # Same, for the narrower `noa-basic` / `noahistory-basic` scope pair.
182
+ StandardSingpass::Myinfo::Noa.basic_history(parsed)
183
+ ```
184
+
185
+ Keys are read as both Symbols and Strings, so a hash round-tripped through
186
+ JSON or a database column works unchanged.
187
+
87
188
  ## Generating and serving JWKS
88
189
 
89
190
  The host application is responsible for serving the public JWKS at
@@ -109,7 +210,29 @@ All errors descend from `StandardSingpass::Myinfo::Error`:
109
210
  - `RateLimitError` — Singpass returned HTTP 429
110
211
  - `ConfigurationError` — gem is misconfigured (e.g. invalid ACR URN)
111
212
 
112
- `DecryptionError` and `SignatureError` indicate a key/cert misconfiguration, not an upstream outage exclude them from circuit-breaker tracking if you use one.
213
+ `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.
214
+
215
+ 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:
216
+
217
+ - `status` — the HTTP status of the offending response (`nil` for transport failures and for errors a host raises itself)
218
+ - `transport?` — true when the request never reached Singpass at all (DNS, connection refused, TLS, read timeout)
219
+
220
+ ### Classifying a failure
221
+
222
+ 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:
223
+
224
+ ```ruby
225
+ if StandardSingpass::Myinfo::FailureClassifier.upstream_unavailable?(error)
226
+ # 502/503/504, HTTP 429, or a transport failure on any leg.
227
+ # Tell the user to retry shortly.
228
+ else
229
+ # Our request, our keys, or our config. Surface it to support.
230
+ end
231
+ ```
232
+
233
+ 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.
234
+
235
+ **Never classify by matching the message text** — it is not a stable interface. That is exactly what `transport?` and `status` exist to replace.
113
236
 
114
237
  ## License
115
238
 
@@ -16,10 +16,10 @@
16
16
  # MYINFO_SCOPE Space-separated scopes (defaults to DEFAULT_SCOPE)
17
17
  # MYINFO_USERINFO_URL Userinfo endpoint override (rarely needed)
18
18
  # MYINFO_USERINFO_JWKS_URL Userinfo JWKS endpoint override (rarely needed)
19
- # MYINFO_MIN_ACR Required Authentication Context Class Reference
20
- # URN, e.g. urn:singpass:authentication:loa:3
21
19
  # MYINFO_MOCK_MODE When set, suppresses missing-key warnings and
22
- # unlocks any mock-callback routes the host registers
20
+ # unlocks any mock-callback routes the host
21
+ # registers. The gem REFUSES TO BOOT if this is set
22
+ # on a production deploy — see MockModeGuard.
23
23
 
24
24
  StandardSingpass::Myinfo.configure do |c|
25
25
  # Endpoint set. :production → live Singpass FAPI; :staging → sandbox.
@@ -38,8 +38,25 @@ StandardSingpass::Myinfo.configure do |c|
38
38
  c.userinfo_url = ENV["MYINFO_USERINFO_URL"] if ENV["MYINFO_USERINFO_URL"].present?
39
39
  c.userinfo_jwks_url = ENV["MYINFO_USERINFO_JWKS_URL"] if ENV["MYINFO_USERINFO_JWKS_URL"].present?
40
40
 
41
- c.minimum_acr = ENV["MYINFO_MIN_ACR"]
42
- c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
41
+ c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
42
+
43
+ # Optional client-side floor on the id_token `acr` claim. The gem rejects an
44
+ # id_token whose assurance level is below it (AuthenticationError); it is
45
+ # NEVER sent to Singpass — `acr_values` on a MyInfo PAR is rejected with
46
+ # HTTP 400 invalid_request. MyInfo's assurance level is governed by Singpass
47
+ # server-side, so most hosts should leave this unset.
48
+ #
49
+ # WARNING: do not wire this to an environment variable without a test that
50
+ # exercises the real flow — a stray value fails every login closed.
51
+ # c.minimum_acr = "urn:singpass:authentication:loa:2"
52
+
53
+ # How the gem decides whether this deploy is real production, for the
54
+ # mock-mode boot guard. Defaults to Rails.env.production?. Staging and
55
+ # preview deploys routinely run RAILS_ENV=production while being legitimate
56
+ # places to run mock mode — if that describes you, point this at your own
57
+ # environment discriminator so mock mode stays legal there and still
58
+ # refuses to boot on real production.
59
+ # c.production_env_detector = -> { AppEnv.production? }
43
60
 
44
61
  # Path to a JSON file of test personas the host can drive mock callbacks
45
62
  # against. Defaults to the gem's bundled fixture set. Override here if you
@@ -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
@@ -60,17 +60,17 @@ module StandardSingpass
60
60
  )
61
61
  }
62
62
 
63
- # Ask Singpass to enforce a minimum assurance level upstream. The same
64
- # config attribute also drives downstream validation of the returned
65
- # id_token (validate_id_token_acr) — defense in depth. When unset, we
66
- # skip both the request parameter and the validator entirely; useful
67
- # for sandbox personas that may return non-conformant acr values.
68
- # Concrete URN per Singpass: `urn:singpass:authentication:loa:N` (N
69
- # is 2 or 3; Singpass never issues below LOA 2). `.to_s.strip` mirrors
70
- # the validator so whitespace-only values are treated as unset and any
71
- # value sent over the wire is trimmed.
72
- min_acr = @minimum_acr.to_s.strip
73
- body[:acr_values] = min_acr unless min_acr.empty?
63
+ # `acr_values` is deliberately NEVER sent, even when `minimum_acr` is
64
+ # configured. Singpass rejects it on a MyInfo PAR:
65
+ #
66
+ # PAR failed (HTTP 400) invalid_request
67
+ # "The acr_values parameter can only be used for specific use cases."
68
+ #
69
+ # Until 0.3.1 a non-empty `minimum_acr` was forwarded here, which broke
70
+ # every MyInfo onboarding for any host that set it. `minimum_acr` is
71
+ # now purely a client-side check of the returned id_token `acr` claim
72
+ # (see validate_id_token_acr); MyInfo's assurance level itself is
73
+ # governed by Singpass server-side.
74
74
 
75
75
  with_network_wrapper do
76
76
  response = http_connection.post(@par_url) do |req|
@@ -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
@@ -292,8 +299,9 @@ module StandardSingpass
292
299
  # Enforce a minimum Authentication Context Class Reference (`acr`) on the
293
300
  # id_token. The floor is configured via `minimum_acr` so staging and
294
301
  # production can diverge — staging may tolerate looser values returned by
295
- # MyInfo sandbox personas. When the attr is unset or blank, both this
296
- # validator and the upstream PAR `acr_values` parameter are skipped.
302
+ # MyInfo sandbox personas. When the attr is unset or blank, this
303
+ # validator is skipped. This is the ONLY effect of `minimum_acr` it is
304
+ # never sent to Singpass as `acr_values` (see push_authorization_request).
297
305
  #
298
306
  # Singpass's `acr` URN format is `urn:singpass:authentication:loa:N`
299
307
  # where N is 2 or 3 (no LOA 1 path — Singpass's IdP is 2FA by design).
@@ -336,7 +344,7 @@ module StandardSingpass
336
344
  data = JSON.parse(response.body)
337
345
  { access_token: data.fetch("access_token"), id_token: data["id_token"] }
338
346
  when 401, 403
339
- raise AuthenticationError, "Token exchange rejected (HTTP #{response.status}): #{body_excerpt(response)}"
347
+ raise AuthenticationError.new("Token exchange rejected (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
340
348
  when 429
341
349
  raise RateLimitError, "Token endpoint rate limit exceeded"
342
350
  else
@@ -357,7 +365,7 @@ module StandardSingpass
357
365
  when 200
358
366
  decrypt_and_validate_person(response.body, jwks_url:)
359
367
  when 401, 403
360
- raise AuthenticationError, "Person data request forbidden (HTTP #{response.status}): #{body_excerpt(response)}"
368
+ raise AuthenticationError.new("Person data request forbidden (HTTP #{response.status}): #{body_excerpt(response)}", status: response.status)
361
369
  when 429
362
370
  raise RateLimitError, "Person endpoint rate limit exceeded"
363
371
  else
@@ -372,7 +380,12 @@ module StandardSingpass
372
380
  # having a moment" — transient and worth one more try. 502 is Singpass's
373
381
  # documented upstream-dependency signal (`upstream_dependency_error`),
374
382
  # which they also return throughout CPF/IRAS/MOM maintenance windows.
375
- RETRYABLE_USERINFO_STATUSES = T.let([502, 503, 504].freeze, T::Array[Integer])
383
+ #
384
+ # Aliased from FailureClassifier rather than restated: the statuses
385
+ # worth one more automatic attempt are exactly the statuses the host is
386
+ # told to present as "their side, try again shortly", and two copies of
387
+ # that list would eventually disagree.
388
+ RETRYABLE_USERINFO_STATUSES = FailureClassifier::UPSTREAM_UNAVAILABLE_STATUSES
376
389
 
377
390
  # Total attempts, not retries — 3 means the original plus two more.
378
391
  USERINFO_MAX_ATTEMPTS = 3
@@ -457,7 +470,8 @@ module StandardSingpass
457
470
  rescue Security::DecryptionError => e
458
471
  raise DecryptionError, e.message
459
472
  rescue Security::ValidationError => e
460
- raise SignatureError, e.message
473
+ # As above — a JWKS-host outage must not be reported as a bad signature.
474
+ raise SignatureError.new(e.message, status: e.status, transport: e.transport?)
461
475
  end
462
476
 
463
477
  sig { void }
@@ -14,9 +14,17 @@
14
14
  #
15
15
  # Optional attributes:
16
16
  # c.scope - Space-separated scopes (defaults to DEFAULT_SCOPE)
17
- # c.minimum_acr - Required Authentication Context Class Reference URN
17
+ # c.minimum_acr - Minimum Authentication Context Class Reference URN
18
+ # (e.g. urn:singpass:authentication:loa:3) checked
19
+ # client-side against the id_token `acr` claim.
20
+ # Never sent to Singpass: `acr_values` on a MyInfo
21
+ # PAR is rejected with HTTP 400 invalid_request.
18
22
  # c.network_wrapper - Lambda wrapping outbound Faraday calls (e.g. circuit breaker)
19
23
  # c.mock_mode - When true, suppresses missing-key warnings
24
+ # (and is refused outright on a production deploy
25
+ # — see MockModeGuard)
26
+ # c.production_env_detector - Callable returning whether this deploy is real
27
+ # production; defaults to Rails.env.production?
20
28
  # c.personas_path - Pathname to JSON file of test personas
21
29
  # c.authorize_url, c.par_url, c.token_url, c.userinfo_url,
22
30
  # c.jwks_url, c.userinfo_jwks_url, c.issuer
@@ -125,12 +133,24 @@ module StandardSingpass
125
133
  :signing_key, :signing_kid, :encryption_keys,
126
134
  :minimum_acr, :network_wrapper, :mock_mode, :personas_path
127
135
 
136
+ # Optional callable deciding whether the current deploy is real
137
+ # production, for the purpose of MockModeGuard. Takes no args, returns a
138
+ # boolean. When nil (the default) the gem falls back to
139
+ # Rails.env.production?, so existing consumers are unchanged.
140
+ #
141
+ # Hosts that distinguish a physical deploy environment from RAILS_ENV
142
+ # — staging and preview boxes routinely run RAILS_ENV=production — can
143
+ # supply `-> { AppEnv.production? }` so mock mode stays legal on those
144
+ # while still refusing to boot on real production.
145
+ attr_accessor :production_env_detector
146
+
128
147
  def initialize
129
148
  self.environment = :staging
130
149
  @scope = DEFAULT_SCOPE
131
150
  @encryption_keys = []
132
151
  @network_wrapper = ->(&block) { block.call }
133
152
  @mock_mode = false
153
+ @production_env_detector = nil
134
154
  end
135
155
 
136
156
  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.1"
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.1
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