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 +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +129 -6
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +22 -5
- data/lib/standard_singpass/engine.rb +6 -0
- data/lib/standard_singpass/myinfo/client.rb +38 -24
- data/lib/standard_singpass/myinfo/configuration.rb +21 -1
- 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: dbda49a3c31fd6844db0d58c66c0bf1c8654882d37105c58dd1537a2e2cb13c5
|
|
4
|
+
data.tar.gz: 5b4ebd78e72bab8b36a2d8d5c298cc8f99e277c018b23a5b42ff7dfb4030292e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
28
|
-
# identity, contact, income, employment, housing,
|
|
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:
|
|
36
|
-
#
|
|
37
|
-
c.minimum_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
|
|
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
|
|
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.
|
|
42
|
-
|
|
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
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
@@ -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,
|
|
296
|
-
# validator
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
|
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.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
|