standard_singpass 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbda49a3c31fd6844db0d58c66c0bf1c8654882d37105c58dd1537a2e2cb13c5
4
- data.tar.gz: 5b4ebd78e72bab8b36a2d8d5c298cc8f99e277c018b23a5b42ff7dfb4030292e
3
+ metadata.gz: 6a27b57ad2ae40ae92c93b8328f2fe0849d22bc95ecc1812bb1f6899f2eb36e1
4
+ data.tar.gz: 3d8c189d90e1cd51db83b73f7bee5fe78d4f856b90721707a12befd8af8c37e6
5
5
  SHA512:
6
- metadata.gz: 47d70dda7f2948b55b59afac864885452e776aefe9e8806a16e62f31b9ca02bc6a8772e5eff7849b25ca3a8805487d45ea3c63a77412a713ba3c7b11f6e7f3e1
7
- data.tar.gz: cd7b80ae5bea3214b3d1bb146801c673fe2934b8b245865ef7bd61dee8952a0d2f5dd670f24c08e925fb083fb6e8debe1901ad2ce9fcee57b8fe9174cff503af
6
+ metadata.gz: eeca8fd0904977618487859b6aa207f4cd3394aa016943f1f6e177ee71404f9e95bdef1b68fb2a68b47d9463f5779871f5b0d8257f834fd403d954c81537816b
7
+ data.tar.gz: a321882ef3e389555bb2427262f863961c38dd30e2a265959cbc383bcad05de9f63b243b248115f40ca5adc5fd267f3f6a2a860a36a6d8e8cbe92c5bd6369b1f
data/CHANGELOG.md CHANGED
@@ -7,6 +7,68 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-24
11
+
12
+ **Breaking.** Removes everything 0.4.0 deprecated. No other behaviour changes.
13
+
14
+ ### Removed
15
+
16
+ - **`StandardSingpass::Myinfo::Security::DecryptionError` / `Security::ValidationError`.** These were aliases of `StandardSingpass::Myinfo::DecryptionError` / `SignatureError`, which `Security` has raised directly since 0.4.0. Referencing the old names now raises `NameError`. Rescue `DecryptionError` / `SignatureError`, or the `StandardSingpass::Myinfo::Error` base.
17
+ - **`StandardSingpass::Myinfo::EcdhJwe.encrypt`**, the forwarder. Use `require "standard_singpass/testing"` and `StandardSingpass::Testing::EcdhJwe.encrypt` (same signature). `EcdhJwe.decrypt` is unchanged.
18
+ - **`StandardSingpass::Myinfo::TestPersonas.reload!`**. Personas are read once per process. Restart, or point `personas_path` at a different file.
19
+
20
+ ### Changed
21
+
22
+ - `StandardSingpass.deprecator`'s horizon is now `0.6`.
23
+
24
+ ### Upgrade notes
25
+
26
+ - **No code change required in the one consumer, fundbright-web.** Grepped `origin/main` on 2026-09-24: no reference to `Security::DecryptionError`, `Security::ValidationError`, `EcdhJwe.encrypt` or `TestPersonas.reload!`.
27
+ - Regenerate the gem RBI when bumping (`bin/tapioca gem standard_singpass`). `sorbet/rbi/gems/standard_singpass@0.4.0.rbi` still declares the removed constants and methods.
28
+
29
+ ## [0.4.0] - 2026-09-24
30
+
31
+ ### Upgrading from 0.3.x
32
+
33
+ 1. **Set `c.scope` explicitly** if you relied on `DEFAULT_SCOPE` — it is now only `openid uinfin name`.
34
+ 2. `rescue Security::DecryptionError` / `Security::ValidationError` keeps working (they alias the public classes) but should move to `DecryptionError` / `SignatureError`.
35
+ 3. Specs calling `Myinfo::EcdhJwe.encrypt` should `require "standard_singpass/testing"` and call `StandardSingpass::Testing::EcdhJwe.encrypt`.
36
+ 4. The "set `private_jwks_json` last" ordering rule no longer applies.
37
+
38
+ ### Changed
39
+
40
+ - **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.
41
+
42
+ **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.
43
+ - **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.
44
+ - **`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.
45
+ - **New gem-wide root `StandardSingpass::Error`**, which `StandardSingpass::Myinfo::Error` now inherits from (previously `StandardError`). Existing `rescue StandardSingpass::Myinfo::Error` is unaffected.
46
+ - **`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`.
47
+ - **`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.
48
+ - **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.
49
+ - **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.
50
+
51
+ ### Added
52
+
53
+ - **`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.
54
+ - **`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.
55
+ - **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.
56
+
57
+ ### Deprecated
58
+
59
+ - **`TestPersonas.reload!`** — unused by any consumer; now warns via `StandardSingpass.deprecator` and will be removed in a future minor.
60
+ - **`Security::DecryptionError` / `Security::ValidationError`** — aliases of `DecryptionError` / `SignatureError`; see Changed.
61
+ - **`StandardSingpass::Myinfo::EcdhJwe.encrypt`** — use `StandardSingpass::Testing::EcdhJwe.encrypt`; see Changed.
62
+
63
+ ### Fixed
64
+
65
+ - **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.
66
+
67
+ ### Documentation
68
+
69
+ - **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.
70
+ - **Install generator** sets `c.scope` to an explicit list and drops the "set `private_jwks_json` last" advice.
71
+
10
72
  ## [0.3.1] - 2026-09-24
11
73
 
12
74
  ### 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
- # Optional: override default scope (defaults to 42 scopes — `openid` plus 41
37
- # MyInfo attributes covering identity, contact, income, employment, housing,
38
- # assets, vehicles).
39
- # c.scope = "openid name email ..."
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` aliases were removed in 0.5.0; rescue `DecryptionError` / `SignatureError` (or `StandardSingpass::Myinfo::Error`).
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` forwarder was removed in 0.5.0.
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 (defaults to DEFAULT_SCOPE)
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
- c.scope = ENV.fetch("MYINFO_SCOPE", StandardSingpass::Myinfo::Configuration::DEFAULT_SCOPE)
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`. Set last so any
72
- # missing-key warnings the gem logs include the rest of the context.
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
- isolate_namespace StandardSingpass
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, so `config.mock_mode` has
17
- # been set by the time it is read. Inert unless mock mode is on.
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
- with_network_wrapper do
76
- response = http_connection.post(@par_url) do |req|
77
- req.headers["DPoP"] = Security.build_dpop_proof(
78
- http_method: "POST",
79
- url: T.must(@par_url),
80
- key_pair: dpop_key_pair
81
- )
82
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
83
- req.body = URI.encode_www_form(body)
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
- handle_par_response(response)
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
- with_network_wrapper do
155
- response = http_connection.post(@token_url) do |req|
156
- req.headers["DPoP"] = Security.build_dpop_proof(
157
- http_method: "POST",
158
- url: T.must(@token_url),
159
- key_pair: dpop_key_pair
160
- )
161
- req.headers["Content-Type"] = "application/x-www-form-urlencoded"
162
- req.body = URI.encode_www_form(body)
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
- handle_token_response(response)
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
- with_network_wrapper do
177
- with_userinfo_retries do
178
- # The DPoP proof carries a per-request jti/iat, so it is rebuilt on
179
- # every attempt — replaying one would be rejected as a duplicate.
180
- response = http_connection.get(@userinfo_url) do |req|
181
- req.headers["Authorization"] = "DPoP #{access_token}"
182
- req.headers["DPoP"] = Security.build_dpop_proof(
183
- http_method: "GET",
184
- url: T.must(@userinfo_url),
185
- key_pair: dpop_key_pair,
186
- access_token:
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 Security::DecryptionError
232
+ rescue DecryptionError
224
233
  raise AuthenticationError, "ID token decryption failed"
225
- rescue Security::ValidationError => e
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
- sig { params(block: T.proc.returns(T::Hash[String, T.untyped])).returns(T::Hash[String, T.untyped]) }
410
- def with_userinfo_retries(&block)
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
- sleep(userinfo_retry_delay(attempt))
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