standard_singpass 0.3.1 → 0.4.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 +43 -0
- data/README.md +75 -5
- data/fixtures/myinfo-personas.json +59 -0
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +16 -4
- data/lib/standard_singpass/engine.rb +13 -3
- data/lib/standard_singpass/error.rb +9 -0
- data/lib/standard_singpass/myinfo/client.rb +107 -49
- data/lib/standard_singpass/myinfo/configuration.rb +122 -92
- data/lib/standard_singpass/myinfo/ecdh_jwe.rb +19 -121
- data/lib/standard_singpass/myinfo/error.rb +5 -2
- data/lib/standard_singpass/myinfo/security.rb +29 -20
- data/lib/standard_singpass/myinfo/test_personas.rb +7 -2
- data/lib/standard_singpass/myinfo.rb +10 -4
- data/lib/standard_singpass/testing.rb +174 -0
- data/lib/standard_singpass/version.rb +1 -1
- data/lib/standard_singpass.rb +25 -0
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b337f846e33125912e93e3819ad6ab9858dea86f6f2976f821650cd41eda5f2d
|
|
4
|
+
data.tar.gz: d655d89457c6a8120223dec9c266945ef03601dc79d82268e9ea5149fe7de75b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0ccefce8b78a9ad7ef3213738a6266b544241c18e99ce645aa0de0bb1f6067f5bda79f793ee048757c4763cd1f990779649bc86c3d506849c5e3d7cfc19a0290
|
|
7
|
+
data.tar.gz: 6fb74b23e6fdf75e2191d7eecc76a45969aac95f6d5dd8383572b563299c15b1013dd2199c2816189248713452f0043c861c74cf9e14d33504357226a3b1f1e7
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,49 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Upgrading from 0.3.x
|
|
13
|
+
|
|
14
|
+
1. **Set `c.scope` explicitly** if you relied on `DEFAULT_SCOPE` — it is now only `openid uinfin name`.
|
|
15
|
+
2. `rescue Security::DecryptionError` / `Security::ValidationError` keeps working (they alias the public classes) but should move to `DecryptionError` / `SignatureError`.
|
|
16
|
+
3. Specs calling `Myinfo::EcdhJwe.encrypt` should `require "standard_singpass/testing"` and call `StandardSingpass::Testing::EcdhJwe.encrypt`.
|
|
17
|
+
4. The "set `private_jwks_json` last" ordering rule no longer applies.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **Breaking: `Configuration::DEFAULT_SCOPE` is now the minimal `"openid uinfin name"`.** It used to be one consuming app's 42-entry underwriting scope — including `noa`, `noa-basic` and `noahistory-basic`, which Singpass review flagged as redundant collection — so any host that never set `c.scope` silently requested all of it. Which attributes to collect is a host decision (PDPA Purpose Limitation) that must match the host's own developer-portal approval list, and the gem holds no domain policy. A minimal default was chosen over a "scope is required" boot error because `openid uinfin name` is a subset of every MyInfo approval: a host that forgets to set it gets a working flow that collects the least data possible, rather than over-collecting or failing to boot in development and test. The install generator now sets `c.scope` explicitly.
|
|
22
|
+
|
|
23
|
+
**Upgrade note:** if you relied on the old default, set `c.scope` to your approved list before upgrading — otherwise `PersonDataParser` fields outside `uinfin`/`name` come back `nil`. The 0.3.1 list is in [`configuration.rb` at v0.3.1](https://github.com/rarebit-one/standard_singpass/blob/v0.3.1/lib/standard_singpass/myinfo/configuration.rb) for reference (drop the NOA entries your approval does not cover). Hosts that already set `c.scope` are unaffected.
|
|
24
|
+
- **Behaviour change: `ECDH-ES+A128KW` is no longer accepted.** `EcdhJwe::SUPPORTED_ALGS` is now `["ECDH-ES+A256KW"]` — the only alg the gem publishes on its encryption JWKs (`Myinfo.public_jwks`) and the one FAPI 2.0 Singpass uses. An A128KW JWE now raises `DecryptionError` ("Unsupported JWE alg") instead of decrypting. Nothing legitimate sends it to us, so accepting it was pure attack surface; hosts should see no difference.
|
|
25
|
+
- **`Security` raises the public errors directly; `Security::DecryptionError` / `Security::ValidationError` are deprecated aliases.** JWE failures now raise `StandardSingpass::Myinfo::DecryptionError` and JWS / JWKS-fetch failures raise `StandardSingpass::Myinfo::SignatureError` (still carrying the JWKS response's `status`, or `transport?` when the JWKS host was unreachable). The old constants resolve to those same classes — `Security::ValidationError` *is* `SignatureError` — so an existing `rescue Security::ValidationError` keeps catching; referencing them emits Ruby's constant-deprecation warning (when `Warning[:deprecated]` is on) and they will be removed in a future minor. The client no longer needs to translate them on the userinfo leg; on the ID-token leg they are still wrapped in `AuthenticationError` exactly as before.
|
|
26
|
+
- **New gem-wide root `StandardSingpass::Error`**, which `StandardSingpass::Myinfo::Error` now inherits from (previously `StandardError`). Existing `rescue StandardSingpass::Myinfo::Error` is unaffected.
|
|
27
|
+
- **`private_jwks_json=` is parsed lazily.** The setter now only stores the JSON; it is parsed into `signing_key` / `signing_kid` / `encryption_keys` on first read of any of them, or by `Configuration#resolve_private_jwks!`, which the engine calls from `after_initialize` alongside `MockModeGuard` so missing / malformed-key warnings still surface at boot. Previously the setter parsed eagerly and read `mock_mode` / `Rails.env` at assignment time, so the configure block was order-dependent (hence the old "set `private_jwks_json` last" advice — no longer needed). Assigning `signing_key=` / `signing_kid=` / `encryption_keys=` directly still works and wins over an earlier `private_jwks_json=`. `Rails.logger` / `Rails.error` are now guarded (nil logger, or no Rails at all) in the parser and in `Myinfo.public_jwks`.
|
|
28
|
+
- **`EcdhJwe.encrypt` moved to `StandardSingpass::Testing::EcdhJwe.encrypt`** (`require "standard_singpass/testing"`, not loaded by default). Encryption is only ever needed to build test fixtures — Singpass encrypts, the gem decrypts — so it no longer ships in the runtime load path. `StandardSingpass::Myinfo::EcdhJwe.encrypt` remains as a deprecated forwarder (same signature) that warns through the new `StandardSingpass.deprecator` — registered with `Rails.application.deprecators[:standard_singpass]` — and will be removed in a future minor.
|
|
29
|
+
- **Dependency upper bounds:** `rails >= 8.0, < 9`, `faraday >= 2.0, < 3`, `jwt >= 2.7, < 4`. Each next major is a real API break for code the gem depends on (`JWT::JWK`, Faraday connection options, Rails engine hooks) and should be adopted deliberately rather than resolved into silently. The consuming app's current versions (Rails 8.1, Faraday 2.14, jwt 3.3) are all inside the ranges.
|
|
30
|
+
- **Gem packaging:** `spec.files` no longer globs `app/`, `config/`, or `db/` (the gem has none), and the engine drops `isolate_namespace` — it has no routes, models, controllers, or views, so namespace isolation did nothing. Hosts never mounted it, so nothing changes for them.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- **`ActiveSupport::Notifications` instrumentation.** `standard_singpass.par`, `standard_singpass.token`, and `standard_singpass.userinfo` wrap each leg of the flow with a payload of `duration` (ms), `status`, `error` (class name only), `transport`, and — for userinfo — `attempts`; `standard_singpass.retry` fires on each automatic userinfo retry with `leg`, `attempt`, `status`, `error`, and `delay`. Payloads are PII-free by construction: no tokens, codes, bodies, URLs, or error messages (the error is captured and re-raised outside the instrument block, so ActiveSupport's message-bearing `:exception` keys are never added). See README → Instrumentation.
|
|
35
|
+
- **`StandardSingpass.configure` / `StandardSingpass.config`** — top-level aliases for `StandardSingpass::Myinfo.configure` / `.configuration`, matching the sibling `standard_*` gems' `Gem.configure` / `Gem.config` convention. `StandardSingpass::Myinfo.config` is also available. The existing `Myinfo.configure` / `Myinfo.configuration` entry points are unchanged.
|
|
36
|
+
- **Two more bundled test personas** in `fixtures/myinfo-personas.json`, upstreamed from the consuming app's own fixture set: `work_permit_holder` (FIN holder on a Work Permit / `RPass`) and `duplicate_check` (a stable NRIC for exercising duplicate-application paths). The existing personas are unchanged.
|
|
37
|
+
|
|
38
|
+
### Deprecated
|
|
39
|
+
|
|
40
|
+
- **`TestPersonas.reload!`** — unused by any consumer; now warns via `StandardSingpass.deprecator` and will be removed in a future minor.
|
|
41
|
+
- **`Security::DecryptionError` / `Security::ValidationError`** — aliases of `DecryptionError` / `SignatureError`; see Changed.
|
|
42
|
+
- **`StandardSingpass::Myinfo::EcdhJwe.encrypt`** — use `StandardSingpass::Testing::EcdhJwe.encrypt`; see Changed.
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- **An unsupported JWE `enc` now raises `StandardSingpass::Myinfo::DecryptionError`.** `EcdhJwe::InvalidAlgorithm` (a bare `StandardError`) used to escape `Security.decrypt_jwe`, bypassing every `rescue StandardSingpass::Myinfo::Error` in a host.
|
|
47
|
+
|
|
48
|
+
### Documentation
|
|
49
|
+
|
|
50
|
+
- **README:** new "Instrumentation" and "Development and testing" sections (running the suite, mock-mode personas, the test-only JWE encryptor); the configuration example sets `c.scope` explicitly and notes the top-level `StandardSingpass.configure` alias and lazy JWKS parsing; the error section documents `StandardSingpass::Error` and the deprecated `Security` aliases.
|
|
51
|
+
- **Install generator** sets `c.scope` to an explicit list and drops the "set `private_jwks_json` last" advice.
|
|
52
|
+
|
|
10
53
|
## [0.3.1] - 2026-09-24
|
|
11
54
|
|
|
12
55
|
### Fixed
|
data/README.md
CHANGED
|
@@ -33,10 +33,11 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
33
33
|
c.client_id = ENV["MYINFO_CLIENT_ID"]
|
|
34
34
|
c.redirect_url = ENV["MYINFO_REDIRECT_URL"]
|
|
35
35
|
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
#
|
|
36
|
+
# MyInfo scope: set it explicitly to the attributes approved for your app
|
|
37
|
+
# on the Singpass developer portal. The gem's default is only the minimal
|
|
38
|
+
# identity set ("openid uinfin name"); which attributes you collect is a
|
|
39
|
+
# host decision (PDPA Purpose Limitation), not the gem's.
|
|
40
|
+
c.scope = "openid uinfin name email mobileno regadd"
|
|
40
41
|
|
|
41
42
|
# Required: full private JWKS JSON containing both sig (ES256) and enc
|
|
42
43
|
# (ECDH-ES+A256KW) keys with the private scalar `d`.
|
|
@@ -64,6 +65,10 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
64
65
|
end
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
`StandardSingpass.configure` / `StandardSingpass.config` are equivalent top-level aliases, matching the other `standard_*` gems.
|
|
69
|
+
|
|
70
|
+
`private_jwks_json` is parsed lazily — on first use, or at the end of boot (the engine resolves it from `after_initialize`, next to the mock-mode guard) — so assignments inside the block can come in any order.
|
|
71
|
+
|
|
67
72
|
## Assurance level (`minimum_acr`)
|
|
68
73
|
|
|
69
74
|
`config.minimum_acr` is an optional, **client-side** check. When set to a
|
|
@@ -200,7 +205,7 @@ render json: StandardSingpass::Myinfo.public_jwks
|
|
|
200
205
|
|
|
201
206
|
## Error classes
|
|
202
207
|
|
|
203
|
-
All errors descend from `StandardSingpass::Myinfo::Error`:
|
|
208
|
+
All errors descend from `StandardSingpass::Myinfo::Error`, which in turn descends from the gem-wide `StandardSingpass::Error`:
|
|
204
209
|
|
|
205
210
|
- `AuthenticationError` — ID token or token exchange rejected
|
|
206
211
|
- `ApiError` — endpoint reachable but returned a non-2xx response
|
|
@@ -210,6 +215,8 @@ All errors descend from `StandardSingpass::Myinfo::Error`:
|
|
|
210
215
|
- `RateLimitError` — Singpass returned HTTP 429
|
|
211
216
|
- `ConfigurationError` — gem is misconfigured (e.g. invalid ACR URN)
|
|
212
217
|
|
|
218
|
+
`Security` raises `DecryptionError` / `SignatureError` directly. The pre-0.4.0 `Security::DecryptionError` / `Security::ValidationError` constants are deprecated aliases of those two classes.
|
|
219
|
+
|
|
213
220
|
`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
221
|
|
|
215
222
|
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:
|
|
@@ -234,6 +241,69 @@ This covers every leg — PAR, token exchange, userinfo, and the JWKS fetches th
|
|
|
234
241
|
|
|
235
242
|
**Never classify by matching the message text** — it is not a stable interface. That is exactly what `transport?` and `status` exist to replace.
|
|
236
243
|
|
|
244
|
+
## Instrumentation
|
|
245
|
+
|
|
246
|
+
The client emits [`ActiveSupport::Notifications`](https://api.rubyonrails.org/classes/ActiveSupport/Notifications.html) events, one per leg of the flow:
|
|
247
|
+
|
|
248
|
+
| Event | When |
|
|
249
|
+
| --- | --- |
|
|
250
|
+
| `standard_singpass.par` | Pushed Authorization Request |
|
|
251
|
+
| `standard_singpass.token` | Token exchange |
|
|
252
|
+
| `standard_singpass.userinfo` | Userinfo fetch, decrypt, and verify (spans all retry attempts) |
|
|
253
|
+
| `standard_singpass.retry` | Each automatic userinfo retry, just before the backoff sleep |
|
|
254
|
+
|
|
255
|
+
`par` / `token` / `userinfo` payloads:
|
|
256
|
+
|
|
257
|
+
- `duration` — wall time in milliseconds (Float)
|
|
258
|
+
- `status` — HTTP status of the (last) Singpass response, or `nil` when none arrived
|
|
259
|
+
- `error` — class name of the raised error, or `nil` on success (the error is re-raised as usual)
|
|
260
|
+
- `transport` — `true` when Singpass was never reached
|
|
261
|
+
- `attempts` — (`userinfo` only) attempts made, including retries
|
|
262
|
+
|
|
263
|
+
`retry` payloads: `leg` (`:userinfo`), `attempt` (the attempt that failed), `status`, `error`, `delay` (seconds about to be slept).
|
|
264
|
+
|
|
265
|
+
Payloads are deliberately PII-free: no tokens, authorization codes, response bodies, URLs, or error messages — only the class name. ActiveSupport's own `:exception` / `:exception_object` keys (which would carry the message) are not added.
|
|
266
|
+
|
|
267
|
+
```ruby
|
|
268
|
+
ActiveSupport::Notifications.subscribe(/\Astandard_singpass\./) do |event|
|
|
269
|
+
StatsD.distribution("singpass.#{event.name.delete_prefix("standard_singpass.")}",
|
|
270
|
+
event.payload[:duration] || 0,
|
|
271
|
+
tags: { status: event.payload[:status], error: event.payload[:error] })
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Development and testing
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
bundle install
|
|
279
|
+
bundle exec rspec # full suite (boots spec/dummy, WebMock blocks outbound HTTP)
|
|
280
|
+
bin/rubocop # style
|
|
281
|
+
bundle exec srb tc # Sorbet typecheck — keep it green
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
`lefthook install` wires the pre-push checks (rubocop, brakeman, rspec, signed-commit verification).
|
|
285
|
+
|
|
286
|
+
### Testing a host app against the gem
|
|
287
|
+
|
|
288
|
+
- **Mock mode + personas.** Set `c.mock_mode = true` (never on production — see [Mock mode](#mock-mode)) and drive your mock callback with `StandardSingpass::Myinfo::TestPersonas.fetch(key)`, then `PersonDataParser.call(persona)`. Bundled keys: `default`, `self_employed`, `tamil_name`, `permanent_resident`, `sparse_data`, `fin_holder`, `work_permit_holder`, `error_paths`, `duplicate_check`. Point `c.personas_path` at your own JSON file to maintain your own set.
|
|
289
|
+
- **End-to-end crypto.** To exercise the real decrypt + verify path, build the encrypted payloads Singpass would send with the test-only encryptor:
|
|
290
|
+
|
|
291
|
+
```ruby
|
|
292
|
+
# spec/rails_helper.rb
|
|
293
|
+
require "standard_singpass/testing"
|
|
294
|
+
|
|
295
|
+
jwe = StandardSingpass::Testing::EcdhJwe.encrypt(
|
|
296
|
+
signed_jws,
|
|
297
|
+
public_key: enc_key, # OpenSSL::PKey::EC matching your "enc" JWK
|
|
298
|
+
alg: "ECDH-ES+A256KW",
|
|
299
|
+
enc: "A256GCM",
|
|
300
|
+
kid: "your-enc-kid"
|
|
301
|
+
)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
`standard_singpass/testing` is not loaded by `require "standard_singpass"`. The old `StandardSingpass::Myinfo::EcdhJwe.encrypt` still works but is deprecated.
|
|
305
|
+
- **Specs that mutate configuration** should call `StandardSingpass::Myinfo.reset_configuration!` afterwards.
|
|
306
|
+
|
|
237
307
|
## License
|
|
238
308
|
|
|
239
309
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
|
@@ -220,6 +220,38 @@
|
|
|
220
220
|
}
|
|
221
221
|
},
|
|
222
222
|
|
|
223
|
+
"work_permit_holder": {
|
|
224
|
+
"name": { "value": "MD RAHMAN HASAN" },
|
|
225
|
+
"uinfin": { "value": "G6543210T" },
|
|
226
|
+
"dob": { "value": "1992-03-15" },
|
|
227
|
+
"sex": { "code": "M", "desc": "MALE" },
|
|
228
|
+
"race": { "code": "OT", "desc": "OTHERS" },
|
|
229
|
+
"nationality": { "code": "BD", "desc": "BANGLADESHI" },
|
|
230
|
+
"residentialstatus": { "code": "F", "desc": "FOREIGNER" },
|
|
231
|
+
"passtype": { "code": "RPass", "desc": "Work Permit" },
|
|
232
|
+
"passstatus": { "code": "LIVE", "desc": "LIVE" },
|
|
233
|
+
"passexpirydate": { "value": "2027-02-28" },
|
|
234
|
+
"employmentsector": { "code": "CONSTRUCTION", "desc": "CONSTRUCTION" },
|
|
235
|
+
"employment": { "value": "BUILDRIGHT CONSTRUCTION PTE LTD" },
|
|
236
|
+
"occupation": { "code": "9312", "desc": "CONSTRUCTION LABOURER" },
|
|
237
|
+
"marital": { "code": "1", "desc": "SINGLE" },
|
|
238
|
+
"email": { "value": "rahman.hasan@example.com" },
|
|
239
|
+
"mobileno": {
|
|
240
|
+
"prefix": { "value": "+" },
|
|
241
|
+
"areacode": { "value": "65" },
|
|
242
|
+
"nbr": { "value": "98771234" }
|
|
243
|
+
},
|
|
244
|
+
"regadd": {
|
|
245
|
+
"type": "SG",
|
|
246
|
+
"block": { "value": "12" },
|
|
247
|
+
"street": { "value": "TUAS SOUTH AVENUE 3" },
|
|
248
|
+
"floor": { "value": "01" },
|
|
249
|
+
"unit": { "value": "05" },
|
|
250
|
+
"postal": { "value": "637601" },
|
|
251
|
+
"country": { "code": "SG" }
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
|
|
223
255
|
"error_paths": {
|
|
224
256
|
"name": { "value": "ONG WEI MING" },
|
|
225
257
|
"uinfin": { "value": "S8345678F" },
|
|
@@ -246,5 +278,32 @@
|
|
|
246
278
|
"country": { "code": "SG" }
|
|
247
279
|
},
|
|
248
280
|
"employment": { "value": "EMPLOYED" }
|
|
281
|
+
},
|
|
282
|
+
"duplicate_check": {
|
|
283
|
+
"name": { "value": "DUPLICATE CHECK TESTER" },
|
|
284
|
+
"uinfin": { "value": "S7777777J" },
|
|
285
|
+
"dob": { "value": "1990-01-01" },
|
|
286
|
+
"sex": { "code": "M", "desc": "MALE" },
|
|
287
|
+
"race": { "code": "CN", "desc": "CHINESE" },
|
|
288
|
+
"nationality": { "code": "SG", "desc": "SINGAPORE CITIZEN" },
|
|
289
|
+
"residentialstatus": { "code": "C", "desc": "CITIZEN" },
|
|
290
|
+
"marital": { "code": "1", "desc": "SINGLE" },
|
|
291
|
+
"edulevel": { "code": "7", "desc": "BACHELOR'S OR EQUIVALENT" },
|
|
292
|
+
"email": { "value": "dup-check@example.com" },
|
|
293
|
+
"mobileno": {
|
|
294
|
+
"prefix": { "value": "+" },
|
|
295
|
+
"areacode": { "value": "65" },
|
|
296
|
+
"nbr": { "value": "97777777" }
|
|
297
|
+
},
|
|
298
|
+
"regadd": {
|
|
299
|
+
"type": "SG",
|
|
300
|
+
"block": { "value": "1" },
|
|
301
|
+
"street": { "value": "DUPLICATE LANE" },
|
|
302
|
+
"floor": { "value": "01" },
|
|
303
|
+
"unit": { "value": "01" },
|
|
304
|
+
"postal": { "value": "100001" },
|
|
305
|
+
"country": { "code": "SG" }
|
|
306
|
+
},
|
|
307
|
+
"employment": { "value": "EMPLOYED" }
|
|
249
308
|
}
|
|
250
309
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
#
|
|
14
14
|
# Optional environment variables:
|
|
15
15
|
#
|
|
16
|
-
# MYINFO_SCOPE Space-separated scopes (
|
|
16
|
+
# MYINFO_SCOPE Space-separated scopes (overrides the list below)
|
|
17
17
|
# MYINFO_USERINFO_URL Userinfo endpoint override (rarely needed)
|
|
18
18
|
# MYINFO_USERINFO_JWKS_URL Userinfo JWKS endpoint override (rarely needed)
|
|
19
19
|
# MYINFO_MOCK_MODE When set, suppresses missing-key warnings and
|
|
@@ -31,7 +31,19 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
31
31
|
c.client_id = ENV["MYINFO_CLIENT_ID"]
|
|
32
32
|
c.redirect_url = ENV["MYINFO_REDIRECT_URL"]
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
# MyInfo scope — set this explicitly. It must match (or be a subset of) the
|
|
35
|
+
# attributes approved for your app on the Singpass developer portal, and
|
|
36
|
+
# which attributes you collect is your decision to justify under PDPA
|
|
37
|
+
# (Purpose Limitation). The gem's own default is only the minimal identity
|
|
38
|
+
# set ("openid uinfin name"). One scope per line keeps diffs against the
|
|
39
|
+
# portal's approval list reviewable.
|
|
40
|
+
app_scope = %w[
|
|
41
|
+
openid
|
|
42
|
+
uinfin
|
|
43
|
+
name
|
|
44
|
+
].join(" ").freeze
|
|
45
|
+
|
|
46
|
+
c.scope = ENV.fetch("MYINFO_SCOPE", app_scope)
|
|
35
47
|
|
|
36
48
|
# Endpoint overrides — only set if you're running against a non-standard
|
|
37
49
|
# Singpass sandbox or mocked service.
|
|
@@ -68,7 +80,7 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
68
80
|
# c.network_wrapper = ->(&block) { StandardCircuit.run(:myinfo, &block) }
|
|
69
81
|
|
|
70
82
|
# Required: full JWKS JSON containing both sig (ES256) and enc
|
|
71
|
-
# (ECDH-ES+A256KW) keys with the private scalar `d`.
|
|
72
|
-
#
|
|
83
|
+
# (ECDH-ES+A256KW) keys with the private scalar `d`. Parsed lazily (at the
|
|
84
|
+
# end of boot, or on first use), so it can be assigned in any order.
|
|
73
85
|
c.private_jwks_json = ENV["MYINFO_PRIVATE_JWKS"]
|
|
74
86
|
end
|
|
@@ -6,17 +6,27 @@
|
|
|
6
6
|
# only meaningful inside a Rails host anyway.
|
|
7
7
|
if defined?(::Rails::Engine)
|
|
8
8
|
module StandardSingpass
|
|
9
|
+
# Library-only engine: no routes, models, controllers, or views, so no
|
|
10
|
+
# `isolate_namespace` (dropped in 0.4.0 — it only matters for engines
|
|
11
|
+
# that ship app/ code or mountable routes). It exists for the rake tasks
|
|
12
|
+
# and the boot-time hooks below.
|
|
9
13
|
class Engine < ::Rails::Engine
|
|
10
|
-
|
|
14
|
+
initializer "standard_singpass.deprecator" do |app|
|
|
15
|
+
app.deprecators[:standard_singpass] = StandardSingpass.deprecator if app.respond_to?(:deprecators)
|
|
16
|
+
end
|
|
11
17
|
|
|
12
18
|
rake_tasks do
|
|
13
19
|
load File.expand_path("../tasks/standard_singpass.rake", __dir__)
|
|
14
20
|
end
|
|
15
21
|
|
|
16
|
-
# Runs after the host's own initializers
|
|
17
|
-
#
|
|
22
|
+
# Runs after the host's own initializers (and its `to_prepare`
|
|
23
|
+
# blocks), so the whole configure block has run by the time either is
|
|
24
|
+
# read. The guard is inert unless mock mode is on; resolving the
|
|
25
|
+
# private JWKS here surfaces missing/malformed-key warnings at boot
|
|
26
|
+
# rather than on the first Singpass request.
|
|
18
27
|
config.after_initialize do
|
|
19
28
|
StandardSingpass::Myinfo::MockModeGuard.check!
|
|
29
|
+
StandardSingpass::Myinfo.configuration.resolve_private_jwks!
|
|
20
30
|
end
|
|
21
31
|
end
|
|
22
32
|
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
|
|
3
|
+
module StandardSingpass
|
|
4
|
+
# Root of every error the gem raises, across products. Hosts that want one
|
|
5
|
+
# `rescue` for "anything standard_singpass raised" rescue this;
|
|
6
|
+
# `StandardSingpass::Myinfo::Error` (which carries `status` / `transport?`)
|
|
7
|
+
# descends from it.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
end
|
|
@@ -72,20 +72,23 @@ module StandardSingpass
|
|
|
72
72
|
# (see validate_id_token_acr); MyInfo's assurance level itself is
|
|
73
73
|
# governed by Singpass server-side.
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
75
|
+
instrument(:par) do |payload|
|
|
76
|
+
with_network_wrapper do
|
|
77
|
+
response = http_connection.post(@par_url) do |req|
|
|
78
|
+
req.headers["DPoP"] = Security.build_dpop_proof(
|
|
79
|
+
http_method: "POST",
|
|
80
|
+
url: T.must(@par_url),
|
|
81
|
+
key_pair: dpop_key_pair
|
|
82
|
+
)
|
|
83
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
84
|
+
req.body = URI.encode_www_form(body)
|
|
85
|
+
end
|
|
86
|
+
payload[:status] = response.status
|
|
87
|
+
handle_par_response(response)
|
|
84
88
|
end
|
|
85
|
-
|
|
89
|
+
rescue Faraday::Error => e
|
|
90
|
+
raise PARError.new("PAR endpoint unreachable: #{e.class}", transport: true)
|
|
86
91
|
end
|
|
87
|
-
rescue Faraday::Error => e
|
|
88
|
-
raise PARError.new("PAR endpoint unreachable: #{e.class}", transport: true)
|
|
89
92
|
end
|
|
90
93
|
|
|
91
94
|
sig { params(request_uri: String).returns(String) }
|
|
@@ -151,20 +154,23 @@ module StandardSingpass
|
|
|
151
154
|
)
|
|
152
155
|
}
|
|
153
156
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
157
|
+
instrument(:token) do |payload|
|
|
158
|
+
with_network_wrapper do
|
|
159
|
+
response = http_connection.post(@token_url) do |req|
|
|
160
|
+
req.headers["DPoP"] = Security.build_dpop_proof(
|
|
161
|
+
http_method: "POST",
|
|
162
|
+
url: T.must(@token_url),
|
|
163
|
+
key_pair: dpop_key_pair
|
|
164
|
+
)
|
|
165
|
+
req.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
166
|
+
req.body = URI.encode_www_form(body)
|
|
167
|
+
end
|
|
168
|
+
payload[:status] = response.status
|
|
169
|
+
handle_token_response(response)
|
|
163
170
|
end
|
|
164
|
-
|
|
171
|
+
rescue Faraday::Error => e
|
|
172
|
+
raise ApiError.new("MyInfo token endpoint unreachable: #{e.class}", transport: true)
|
|
165
173
|
end
|
|
166
|
-
rescue Faraday::Error => e
|
|
167
|
-
raise ApiError.new("MyInfo token endpoint unreachable: #{e.class}", transport: true)
|
|
168
174
|
end
|
|
169
175
|
|
|
170
176
|
sig { params(access_token: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[String, T.untyped]) }
|
|
@@ -173,24 +179,27 @@ module StandardSingpass
|
|
|
173
179
|
# is typically a circuit breaker; keeping retries inside means one user
|
|
174
180
|
# attempt counts as one failure against the breaker instead of three,
|
|
175
181
|
# so a single unlucky borrower can't trip a shared circuit on their own.
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
182
|
+
instrument(:userinfo) do |payload|
|
|
183
|
+
with_network_wrapper do
|
|
184
|
+
with_userinfo_retries(payload) do
|
|
185
|
+
# The DPoP proof carries a per-request jti/iat, so it is rebuilt on
|
|
186
|
+
# every attempt — replaying one would be rejected as a duplicate.
|
|
187
|
+
response = http_connection.get(@userinfo_url) do |req|
|
|
188
|
+
req.headers["Authorization"] = "DPoP #{access_token}"
|
|
189
|
+
req.headers["DPoP"] = Security.build_dpop_proof(
|
|
190
|
+
http_method: "GET",
|
|
191
|
+
url: T.must(@userinfo_url),
|
|
192
|
+
key_pair: dpop_key_pair,
|
|
193
|
+
access_token:
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
payload[:status] = response.status
|
|
197
|
+
handle_person_response(response, jwks_url: @userinfo_jwks_url)
|
|
188
198
|
end
|
|
189
|
-
handle_person_response(response, jwks_url: @userinfo_jwks_url)
|
|
190
199
|
end
|
|
200
|
+
rescue Faraday::Error => e
|
|
201
|
+
raise ApiError.new("MyInfo userinfo endpoint unreachable: #{e.class}", transport: true)
|
|
191
202
|
end
|
|
192
|
-
rescue Faraday::Error => e
|
|
193
|
-
raise ApiError.new("MyInfo userinfo endpoint unreachable: #{e.class}", transport: true)
|
|
194
203
|
end
|
|
195
204
|
|
|
196
205
|
sig { params(id_token: T.nilable(String), nonce: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
|
|
@@ -220,9 +229,9 @@ module StandardSingpass
|
|
|
220
229
|
|
|
221
230
|
decrypted = Security.decrypt_jwe(id_token, private_keys: @encryption_keys)
|
|
222
231
|
Security.validate_jws(decrypted, jwks_url: T.must(@jwks_url))
|
|
223
|
-
rescue
|
|
232
|
+
rescue DecryptionError
|
|
224
233
|
raise AuthenticationError, "ID token decryption failed"
|
|
225
|
-
rescue
|
|
234
|
+
rescue SignatureError => e
|
|
226
235
|
# Carry status/transport across the re-raise: a JWKS endpoint that is
|
|
227
236
|
# down produces the same exception here as a genuinely bad signature,
|
|
228
237
|
# and only these attributes tell them apart.
|
|
@@ -406,17 +415,26 @@ module StandardSingpass
|
|
|
406
415
|
# in well under a second, whereas a timed-out attempt has already spent
|
|
407
416
|
# the request budget and retrying it risks turning a slow page into a
|
|
408
417
|
# gateway error. A transport failure therefore propagates on the first hit.
|
|
409
|
-
|
|
410
|
-
|
|
418
|
+
#
|
|
419
|
+
# Each retry emits a `standard_singpass.retry` notification; the
|
|
420
|
+
# enclosing `standard_singpass.userinfo` payload records `attempts`.
|
|
421
|
+
sig { params(payload: T::Hash[Symbol, T.untyped], block: T.proc.returns(T::Hash[String, T.untyped])).returns(T::Hash[String, T.untyped]) }
|
|
422
|
+
def with_userinfo_retries(payload, &block)
|
|
411
423
|
attempt = 0
|
|
412
424
|
begin
|
|
413
425
|
attempt += 1
|
|
426
|
+
payload[:attempts] = attempt
|
|
414
427
|
block.call
|
|
415
428
|
rescue ApiError => e
|
|
416
429
|
raise unless RETRYABLE_USERINFO_STATUSES.include?(e.status)
|
|
417
430
|
raise if attempt >= USERINFO_MAX_ATTEMPTS
|
|
418
431
|
|
|
419
|
-
|
|
432
|
+
delay = userinfo_retry_delay(attempt)
|
|
433
|
+
ActiveSupport::Notifications.instrument(
|
|
434
|
+
"standard_singpass.retry",
|
|
435
|
+
leg: :userinfo, attempt:, status: e.status, error: e.class.name, delay:
|
|
436
|
+
)
|
|
437
|
+
sleep(delay)
|
|
420
438
|
retry
|
|
421
439
|
end
|
|
422
440
|
end
|
|
@@ -463,15 +481,13 @@ module StandardSingpass
|
|
|
463
481
|
"(non-JSON body, #{body.bytesize} bytes)"
|
|
464
482
|
end
|
|
465
483
|
|
|
484
|
+
# Security raises the public DecryptionError / SignatureError directly
|
|
485
|
+
# (SignatureError carrying the JWKS response's status / transport?), so
|
|
486
|
+
# there is nothing to translate on the userinfo leg.
|
|
466
487
|
sig { params(jwe_body: String, jwks_url: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
|
|
467
488
|
def decrypt_and_validate_person(jwe_body, jwks_url:)
|
|
468
489
|
decrypted = Security.decrypt_jwe(jwe_body, private_keys: @encryption_keys)
|
|
469
490
|
Security.validate_jws(decrypted, jwks_url: T.must(jwks_url))
|
|
470
|
-
rescue Security::DecryptionError => e
|
|
471
|
-
raise DecryptionError, e.message
|
|
472
|
-
rescue Security::ValidationError => e
|
|
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?)
|
|
475
491
|
end
|
|
476
492
|
|
|
477
493
|
sig { void }
|
|
@@ -488,6 +504,48 @@ module StandardSingpass
|
|
|
488
504
|
end
|
|
489
505
|
end
|
|
490
506
|
|
|
507
|
+
# Emits `standard_singpass.<event>` via ActiveSupport::Notifications
|
|
508
|
+
# around one leg of the flow. The payload is deliberately PII-free:
|
|
509
|
+
#
|
|
510
|
+
# :duration — wall time in milliseconds (Float)
|
|
511
|
+
# :status — HTTP status of the (last) Singpass response, or nil
|
|
512
|
+
# :error — class name of the error raised, or nil on success
|
|
513
|
+
# :transport — true when Singpass was never reached
|
|
514
|
+
# :attempts — (userinfo only) attempts made, including retries
|
|
515
|
+
#
|
|
516
|
+
# Never tokens, codes, bodies, URLs, or error messages. The error is
|
|
517
|
+
# captured inside the instrument block and re-raised outside it so
|
|
518
|
+
# ActiveSupport does not add its own `:exception` entry (which would
|
|
519
|
+
# carry the message) to the payload.
|
|
520
|
+
sig do
|
|
521
|
+
params(
|
|
522
|
+
event: Symbol,
|
|
523
|
+
block: T.proc.params(payload: T::Hash[Symbol, T.untyped]).returns(T.untyped)
|
|
524
|
+
).returns(T.untyped)
|
|
525
|
+
end
|
|
526
|
+
def instrument(event, &block)
|
|
527
|
+
payload = T.let({ status: nil, error: nil, transport: false }, T::Hash[Symbol, T.untyped])
|
|
528
|
+
error = T.let(nil, T.nilable(Exception))
|
|
529
|
+
result = ActiveSupport::Notifications.instrument("standard_singpass.#{event}", payload) do
|
|
530
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
531
|
+
begin
|
|
532
|
+
block.call(payload)
|
|
533
|
+
rescue => e
|
|
534
|
+
error = e
|
|
535
|
+
payload[:error] = e.class.name
|
|
536
|
+
if e.is_a?(Error)
|
|
537
|
+
payload[:status] ||= e.status
|
|
538
|
+
payload[:transport] = e.transport?
|
|
539
|
+
end
|
|
540
|
+
nil
|
|
541
|
+
ensure
|
|
542
|
+
payload[:duration] = (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0
|
|
543
|
+
end
|
|
544
|
+
end
|
|
545
|
+
raise error if error
|
|
546
|
+
result
|
|
547
|
+
end
|
|
548
|
+
|
|
491
549
|
# Wraps the given block in the configured network_wrapper. Default is
|
|
492
550
|
# the identity wrapper (no resilience). Hosts typically set this to a
|
|
493
551
|
# circuit-breaker lambda. Scoped narrowly to the Faraday network call
|