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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +87 -1
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +11 -1
- data/lib/standard_singpass/engine.rb +6 -0
- data/lib/standard_singpass/myinfo/client.rb +24 -11
- data/lib/standard_singpass/myinfo/configuration.rb +16 -0
- data/lib/standard_singpass/myinfo/error.rb +55 -25
- data/lib/standard_singpass/myinfo/failure_classifier.rb +64 -0
- data/lib/standard_singpass/myinfo/mock_mode_guard.rb +112 -0
- data/lib/standard_singpass/myinfo/noa.rb +76 -0
- data/lib/standard_singpass/myinfo/security.rb +18 -4
- data/lib/standard_singpass/myinfo.rb +3 -0
- data/lib/standard_singpass/version.rb +1 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1ecc46cf176551f25806d49f04c64ba204801c137b46c7c5970fd6a0f97d650a
|
|
4
|
+
data.tar.gz: f5d5c709779c0082d75c4909d549ccf451daa5406900ac7e05f83e23d2e69994
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
14
|
-
# reaching
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
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.
|
|
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
|