standard_singpass 0.3.0 → 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 +57 -0
- data/README.md +114 -7
- data/fixtures/myinfo-personas.json +59 -0
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +27 -8
- data/lib/standard_singpass/engine.rb +13 -3
- data/lib/standard_singpass/error.rb +9 -0
- data/lib/standard_singpass/myinfo/client.rb +122 -63
- data/lib/standard_singpass/myinfo/configuration.rb +127 -93
- 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,63 @@ 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
|
+
|
|
53
|
+
## [0.3.1] - 2026-09-24
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`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`.
|
|
58
|
+
|
|
59
|
+
**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).
|
|
60
|
+
|
|
61
|
+
### Documentation
|
|
62
|
+
|
|
63
|
+
- **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.
|
|
64
|
+
- **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").
|
|
65
|
+
- **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.
|
|
66
|
+
|
|
10
67
|
## [0.3.0] - 2026-07-30
|
|
11
68
|
|
|
12
69
|
### 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,19 @@ 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
|
-
#
|
|
28
|
-
#
|
|
29
|
-
#
|
|
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"
|
|
30
41
|
|
|
31
42
|
# Required: full private JWKS JSON containing both sig (ES256) and enc
|
|
32
43
|
# (ECDH-ES+A256KW) keys with the private scalar `d`.
|
|
33
44
|
c.private_jwks_json = ENV["MYINFO_PRIVATE_JWKS"]
|
|
34
45
|
|
|
35
|
-
# Optional:
|
|
36
|
-
#
|
|
37
|
-
c.minimum_acr =
|
|
46
|
+
# Optional: client-side floor on the id_token `acr` claim. Never sent to
|
|
47
|
+
# Singpass — see "Assurance level (`minimum_acr`)" below before setting it.
|
|
48
|
+
# c.minimum_acr = "urn:singpass:authentication:loa:2"
|
|
38
49
|
|
|
39
50
|
# Optional: wrap outbound HTTP calls with a circuit breaker / retry layer.
|
|
40
51
|
# Defaults to identity (no wrapper).
|
|
@@ -54,6 +65,37 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
54
65
|
end
|
|
55
66
|
```
|
|
56
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
|
+
|
|
72
|
+
## Assurance level (`minimum_acr`)
|
|
73
|
+
|
|
74
|
+
`config.minimum_acr` is an optional, **client-side** check. When set to a
|
|
75
|
+
Singpass LOA URN (`urn:singpass:authentication:loa:2` or `...:loa:3`), the
|
|
76
|
+
client rejects any id_token whose `acr` claim is below that level — or missing
|
|
77
|
+
— with `StandardSingpass::Myinfo::AuthenticationError`. A value that is not a
|
|
78
|
+
recognised LOA URN raises `ConfigurationError`. Unset or blank disables the
|
|
79
|
+
check.
|
|
80
|
+
|
|
81
|
+
It is **never sent to Singpass.** Singpass rejects `acr_values` on a MyInfo
|
|
82
|
+
Pushed Authorization Request:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
PAR failed (HTTP 400) invalid_request —
|
|
86
|
+
"The acr_values parameter can only be used for specific use cases."
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Versions up to 0.3.0 forwarded a configured `minimum_acr` as `acr_values`, so
|
|
90
|
+
setting it broke every MyInfo login. From 0.3.1 the PAR request never carries
|
|
91
|
+
`acr_values`. MyInfo's assurance level is governed by Singpass server-side, so
|
|
92
|
+
most hosts should leave `minimum_acr` unset; if you do set it, set it in code
|
|
93
|
+
rather than from a loosely-managed environment variable, because a wrong value
|
|
94
|
+
fails every login closed.
|
|
95
|
+
|
|
96
|
+
The `acr` Singpass actually returned is surfaced as `result[:id_token_acr]`
|
|
97
|
+
from `get_person_data` for audit, whether or not a floor is configured.
|
|
98
|
+
|
|
57
99
|
## Mock mode
|
|
58
100
|
|
|
59
101
|
`config.mock_mode` makes the host serve fixture persona data
|
|
@@ -163,7 +205,7 @@ render json: StandardSingpass::Myinfo.public_jwks
|
|
|
163
205
|
|
|
164
206
|
## Error classes
|
|
165
207
|
|
|
166
|
-
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`:
|
|
167
209
|
|
|
168
210
|
- `AuthenticationError` — ID token or token exchange rejected
|
|
169
211
|
- `ApiError` — endpoint reachable but returned a non-2xx response
|
|
@@ -173,6 +215,8 @@ All errors descend from `StandardSingpass::Myinfo::Error`:
|
|
|
173
215
|
- `RateLimitError` — Singpass returned HTTP 429
|
|
174
216
|
- `ConfigurationError` — gem is misconfigured (e.g. invalid ACR URN)
|
|
175
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
|
+
|
|
176
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.
|
|
177
221
|
|
|
178
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:
|
|
@@ -197,6 +241,69 @@ This covers every leg — PAR, token exchange, userinfo, and the JWKS fetches th
|
|
|
197
241
|
|
|
198
242
|
**Never classify by matching the message text** — it is not a stable interface. That is exactly what `transport?` and `status` exist to replace.
|
|
199
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
|
+
|
|
200
307
|
## License
|
|
201
308
|
|
|
202
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,11 +13,9 @@
|
|
|
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
|
-
# 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
20
|
# unlocks any mock-callback routes the host
|
|
23
21
|
# registers. The gem REFUSES TO BOOT if this is set
|
|
@@ -33,15 +31,36 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
33
31
|
c.client_id = ENV["MYINFO_CLIENT_ID"]
|
|
34
32
|
c.redirect_url = ENV["MYINFO_REDIRECT_URL"]
|
|
35
33
|
|
|
36
|
-
|
|
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)
|
|
37
47
|
|
|
38
48
|
# Endpoint overrides — only set if you're running against a non-standard
|
|
39
49
|
# Singpass sandbox or mocked service.
|
|
40
50
|
c.userinfo_url = ENV["MYINFO_USERINFO_URL"] if ENV["MYINFO_USERINFO_URL"].present?
|
|
41
51
|
c.userinfo_jwks_url = ENV["MYINFO_USERINFO_JWKS_URL"] if ENV["MYINFO_USERINFO_JWKS_URL"].present?
|
|
42
52
|
|
|
43
|
-
c.
|
|
44
|
-
|
|
53
|
+
c.mock_mode = ENV["MYINFO_MOCK_MODE"].present?
|
|
54
|
+
|
|
55
|
+
# Optional client-side floor on the id_token `acr` claim. The gem rejects an
|
|
56
|
+
# id_token whose assurance level is below it (AuthenticationError); it is
|
|
57
|
+
# NEVER sent to Singpass — `acr_values` on a MyInfo PAR is rejected with
|
|
58
|
+
# HTTP 400 invalid_request. MyInfo's assurance level is governed by Singpass
|
|
59
|
+
# server-side, so most hosts should leave this unset.
|
|
60
|
+
#
|
|
61
|
+
# WARNING: do not wire this to an environment variable without a test that
|
|
62
|
+
# exercises the real flow — a stray value fails every login closed.
|
|
63
|
+
# c.minimum_acr = "urn:singpass:authentication:loa:2"
|
|
45
64
|
|
|
46
65
|
# How the gem decides whether this deploy is real production, for the
|
|
47
66
|
# mock-mode boot guard. Defaults to Rails.env.production?. Staging and
|
|
@@ -61,7 +80,7 @@ StandardSingpass::Myinfo.configure do |c|
|
|
|
61
80
|
# c.network_wrapper = ->(&block) { StandardCircuit.run(:myinfo, &block) }
|
|
62
81
|
|
|
63
82
|
# Required: full JWKS JSON containing both sig (ES256) and enc
|
|
64
|
-
# (ECDH-ES+A256KW) keys with the private scalar `d`.
|
|
65
|
-
#
|
|
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.
|
|
66
85
|
c.private_jwks_json = ENV["MYINFO_PRIVATE_JWKS"]
|
|
67
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
|