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
|
@@ -60,32 +60,35 @@ module StandardSingpass
|
|
|
60
60
|
)
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
63
|
+
# `acr_values` is deliberately NEVER sent, even when `minimum_acr` is
|
|
64
|
+
# configured. Singpass rejects it on a MyInfo PAR:
|
|
65
|
+
#
|
|
66
|
+
# PAR failed (HTTP 400) invalid_request —
|
|
67
|
+
# "The acr_values parameter can only be used for specific use cases."
|
|
68
|
+
#
|
|
69
|
+
# Until 0.3.1 a non-empty `minimum_acr` was forwarded here, which broke
|
|
70
|
+
# every MyInfo onboarding for any host that set it. `minimum_acr` is
|
|
71
|
+
# now purely a client-side check of the returned id_token `acr` claim
|
|
72
|
+
# (see validate_id_token_acr); MyInfo's assurance level itself is
|
|
73
|
+
# governed by Singpass server-side.
|
|
74
|
+
|
|
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.
|
|
@@ -299,8 +308,9 @@ module StandardSingpass
|
|
|
299
308
|
# Enforce a minimum Authentication Context Class Reference (`acr`) on the
|
|
300
309
|
# id_token. The floor is configured via `minimum_acr` so staging and
|
|
301
310
|
# production can diverge — staging may tolerate looser values returned by
|
|
302
|
-
# MyInfo sandbox personas. When the attr is unset or blank,
|
|
303
|
-
# validator
|
|
311
|
+
# MyInfo sandbox personas. When the attr is unset or blank, this
|
|
312
|
+
# validator is skipped. This is the ONLY effect of `minimum_acr` — it is
|
|
313
|
+
# never sent to Singpass as `acr_values` (see push_authorization_request).
|
|
304
314
|
#
|
|
305
315
|
# Singpass's `acr` URN format is `urn:singpass:authentication:loa:N`
|
|
306
316
|
# where N is 2 or 3 (no LOA 1 path — Singpass's IdP is 2FA by design).
|
|
@@ -405,17 +415,26 @@ module StandardSingpass
|
|
|
405
415
|
# in well under a second, whereas a timed-out attempt has already spent
|
|
406
416
|
# the request budget and retrying it risks turning a slow page into a
|
|
407
417
|
# gateway error. A transport failure therefore propagates on the first hit.
|
|
408
|
-
|
|
409
|
-
|
|
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)
|
|
410
423
|
attempt = 0
|
|
411
424
|
begin
|
|
412
425
|
attempt += 1
|
|
426
|
+
payload[:attempts] = attempt
|
|
413
427
|
block.call
|
|
414
428
|
rescue ApiError => e
|
|
415
429
|
raise unless RETRYABLE_USERINFO_STATUSES.include?(e.status)
|
|
416
430
|
raise if attempt >= USERINFO_MAX_ATTEMPTS
|
|
417
431
|
|
|
418
|
-
|
|
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)
|
|
419
438
|
retry
|
|
420
439
|
end
|
|
421
440
|
end
|
|
@@ -462,15 +481,13 @@ module StandardSingpass
|
|
|
462
481
|
"(non-JSON body, #{body.bytesize} bytes)"
|
|
463
482
|
end
|
|
464
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.
|
|
465
487
|
sig { params(jwe_body: String, jwks_url: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
|
|
466
488
|
def decrypt_and_validate_person(jwe_body, jwks_url:)
|
|
467
489
|
decrypted = Security.decrypt_jwe(jwe_body, private_keys: @encryption_keys)
|
|
468
490
|
Security.validate_jws(decrypted, jwks_url: T.must(jwks_url))
|
|
469
|
-
rescue Security::DecryptionError => e
|
|
470
|
-
raise DecryptionError, e.message
|
|
471
|
-
rescue Security::ValidationError => e
|
|
472
|
-
# As above — a JWKS-host outage must not be reported as a bad signature.
|
|
473
|
-
raise SignatureError.new(e.message, status: e.status, transport: e.transport?)
|
|
474
491
|
end
|
|
475
492
|
|
|
476
493
|
sig { void }
|
|
@@ -487,6 +504,48 @@ module StandardSingpass
|
|
|
487
504
|
end
|
|
488
505
|
end
|
|
489
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
|
+
|
|
490
549
|
# Wraps the given block in the configured network_wrapper. Default is
|
|
491
550
|
# the identity wrapper (no resilience). Hosts typically set this to a
|
|
492
551
|
# circuit-breaker lambda. Scoped narrowly to the Faraday network call
|
|
@@ -10,11 +10,18 @@
|
|
|
10
10
|
# c.client_id - App ID from Singpass Developer Portal
|
|
11
11
|
# c.redirect_url - OAuth callback URL (e.g. https://example.com/singpass/callback)
|
|
12
12
|
# c.private_jwks_json - Full JWKS JSON string with private signing + encryption keys
|
|
13
|
-
# (keys are identified by "use": "sig" and "use": "enc")
|
|
13
|
+
# (keys are identified by "use": "sig" and "use": "enc").
|
|
14
|
+
# Parsed lazily, so it can be assigned in any order.
|
|
14
15
|
#
|
|
15
16
|
# Optional attributes:
|
|
16
|
-
# c.scope - Space-separated scopes
|
|
17
|
-
#
|
|
17
|
+
# c.scope - Space-separated scopes. Defaults to the minimal
|
|
18
|
+
# DEFAULT_SCOPE ("openid uinfin name"); every
|
|
19
|
+
# real host should set its own approved list.
|
|
20
|
+
# c.minimum_acr - Minimum Authentication Context Class Reference URN
|
|
21
|
+
# (e.g. urn:singpass:authentication:loa:3) checked
|
|
22
|
+
# client-side against the id_token `acr` claim.
|
|
23
|
+
# Never sent to Singpass: `acr_values` on a MyInfo
|
|
24
|
+
# PAR is rejected with HTTP 400 invalid_request.
|
|
18
25
|
# c.network_wrapper - Lambda wrapping outbound Faraday calls (e.g. circuit breaker)
|
|
19
26
|
# c.mock_mode - When true, suppresses missing-key warnings
|
|
20
27
|
# (and is refused outright on a production deploy
|
|
@@ -29,79 +36,25 @@
|
|
|
29
36
|
module StandardSingpass
|
|
30
37
|
module Myinfo
|
|
31
38
|
class Configuration
|
|
32
|
-
# Default MyInfo scope
|
|
33
|
-
#
|
|
34
|
-
# ordered dump are reviewable line-by-line. Joined into a single
|
|
35
|
-
# space-delimited string before sending to PAR.
|
|
36
|
-
DEFAULT_SCOPE = %w[
|
|
37
|
-
openid
|
|
38
|
-
aliasname
|
|
39
|
-
cpfbalances.oa
|
|
40
|
-
cpfcontributions
|
|
41
|
-
cpfemployers
|
|
42
|
-
cpfhousingwithdrawal
|
|
43
|
-
dob
|
|
44
|
-
email
|
|
45
|
-
employment
|
|
46
|
-
employmentsector
|
|
47
|
-
hanyupinyinaliasname
|
|
48
|
-
hanyupinyinname
|
|
49
|
-
hdbownership.address
|
|
50
|
-
hdbownership.balanceloanrepayment
|
|
51
|
-
hdbownership.hdbtype
|
|
52
|
-
hdbownership.loangranted
|
|
53
|
-
hdbownership.monthlyloaninstalment
|
|
54
|
-
hdbownership.noofowners
|
|
55
|
-
hdbownership.outstandinginstalment
|
|
56
|
-
hdbownership.outstandingloanbalance
|
|
57
|
-
hdbtype
|
|
58
|
-
housingtype
|
|
59
|
-
marital
|
|
60
|
-
marriedname
|
|
61
|
-
mobileno
|
|
62
|
-
name
|
|
63
|
-
nationality
|
|
64
|
-
noa
|
|
65
|
-
noa-basic
|
|
66
|
-
noahistory
|
|
67
|
-
noahistory-basic
|
|
68
|
-
occupation
|
|
69
|
-
ownerprivate
|
|
70
|
-
passexpirydate
|
|
71
|
-
passstatus
|
|
72
|
-
passtype
|
|
73
|
-
race
|
|
74
|
-
regadd
|
|
75
|
-
residentialstatus
|
|
76
|
-
sex
|
|
77
|
-
uinfin
|
|
78
|
-
vehicles.effectiveownership
|
|
79
|
-
].join(" ").freeze
|
|
80
|
-
|
|
81
|
-
# The categories and their lender-underwriting purpose (PDPA §18,
|
|
82
|
-
# Purpose Limitation):
|
|
39
|
+
# Default MyInfo scope: the minimal identity set (`openid` plus the
|
|
40
|
+
# NRIC/FIN and name). Deliberately tiny.
|
|
83
41
|
#
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
# cpfcontributions → MAS TDSR input
|
|
93
|
-
# Employment — employment, occupation, cpfemployers
|
|
94
|
-
# → continuity + employer stability
|
|
95
|
-
# Assets — cpfbalances.oa (only OA — MA/SA/RA are ring-fenced
|
|
96
|
-
# and not lender-relevant), ownerprivate
|
|
97
|
-
# Liabilities — cpfhousingwithdrawal, hdbownership.* (8 sub-fields)
|
|
98
|
-
# → TDSR housing component
|
|
99
|
-
# Vehicle — vehicles.effectiveownership (asset/liability hint;
|
|
100
|
-
# full vehicle details deliberately not requested)
|
|
42
|
+
# Which person attributes to request is a business decision (PDPA §18,
|
|
43
|
+
# Purpose Limitation) that must match the host's Singpass
|
|
44
|
+
# developer-portal approval list, so it belongs to the host, not this
|
|
45
|
+
# gem. Until 0.4.0 this constant was one consuming app's 42-entry
|
|
46
|
+
# underwriting scope — including `noa`, `noa-basic` and
|
|
47
|
+
# `noahistory-basic`, which Singpass review later flagged as redundant
|
|
48
|
+
# collection — and any host that relied on the default silently
|
|
49
|
+
# requested all of it.
|
|
101
50
|
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
51
|
+
# A minimal default rather than a boot-time "scope is required" error:
|
|
52
|
+
# `openid uinfin name` is a subset of every MyInfo approval, so a host
|
|
53
|
+
# that forgets to set `scope` gets a working flow that collects the
|
|
54
|
+
# least data possible, instead of either over-collecting (the old
|
|
55
|
+
# default) or failing to boot in development and test. Hosts set
|
|
56
|
+
# `c.scope` explicitly; the install generator does.
|
|
57
|
+
DEFAULT_SCOPE = "openid uinfin name"
|
|
105
58
|
|
|
106
59
|
PRODUCTION_ENDPOINTS = {
|
|
107
60
|
authorize_url: "https://id.singpass.gov.sg/fapi/auth",
|
|
@@ -126,7 +79,6 @@ module StandardSingpass
|
|
|
126
79
|
attr_accessor :authorize_url, :par_url, :token_url, :userinfo_url,
|
|
127
80
|
:jwks_url, :userinfo_jwks_url, :issuer,
|
|
128
81
|
:client_id, :redirect_url, :scope,
|
|
129
|
-
:signing_key, :signing_kid, :encryption_keys,
|
|
130
82
|
:minimum_acr, :network_wrapper, :mock_mode, :personas_path
|
|
131
83
|
|
|
132
84
|
# Optional callable deciding whether the current deploy is real
|
|
@@ -144,6 +96,10 @@ module StandardSingpass
|
|
|
144
96
|
self.environment = :staging
|
|
145
97
|
@scope = DEFAULT_SCOPE
|
|
146
98
|
@encryption_keys = []
|
|
99
|
+
@signing_key = nil
|
|
100
|
+
@signing_kid = nil
|
|
101
|
+
@private_jwks_json = nil
|
|
102
|
+
@private_jwks_pending = false
|
|
147
103
|
@network_wrapper = ->(&block) { block.call }
|
|
148
104
|
@mock_mode = false
|
|
149
105
|
@production_env_detector = nil
|
|
@@ -163,18 +119,77 @@ module StandardSingpass
|
|
|
163
119
|
|
|
164
120
|
attr_reader :environment
|
|
165
121
|
|
|
166
|
-
#
|
|
167
|
-
# and encryption_keys
|
|
168
|
-
#
|
|
169
|
-
#
|
|
122
|
+
# Stores the raw private JWKS JSON string. Parsing into signing_key,
|
|
123
|
+
# signing_kid, and encryption_keys is deferred until one of those is
|
|
124
|
+
# first read (or `resolve_private_jwks!` is called — the engine does so
|
|
125
|
+
# from `after_initialize`, alongside MockModeGuard, so problems surface
|
|
126
|
+
# at boot). Until 0.4.0 this setter parsed eagerly, so its warnings
|
|
127
|
+
# depended on `mock_mode` having been assigned *before* it ("set
|
|
128
|
+
# last"); deferring makes the configure block order-independent.
|
|
129
|
+
#
|
|
130
|
+
# Issues are logged and reported via Rails.logger / Rails.error rather
|
|
131
|
+
# than raised — a malformed JWKS silently degrades the Singpass widget
|
|
132
|
+
# at runtime, but the host should boot regardless.
|
|
170
133
|
def private_jwks_json=(jwks_json)
|
|
134
|
+
@private_jwks_json = jwks_json
|
|
135
|
+
@private_jwks_pending = true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
attr_reader :private_jwks_json
|
|
139
|
+
|
|
140
|
+
def signing_key
|
|
141
|
+
resolve_private_jwks!
|
|
142
|
+
@signing_key
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def signing_kid
|
|
146
|
+
resolve_private_jwks!
|
|
147
|
+
@signing_kid
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def encryption_keys
|
|
151
|
+
resolve_private_jwks!
|
|
152
|
+
@encryption_keys
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Direct assignment still works (and wins over an earlier
|
|
156
|
+
# `private_jwks_json=`): any pending JWKS is resolved first so it can't
|
|
157
|
+
# later overwrite the explicit value.
|
|
158
|
+
def signing_key=(value)
|
|
159
|
+
resolve_private_jwks!
|
|
160
|
+
@signing_key = value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def signing_kid=(value)
|
|
164
|
+
resolve_private_jwks!
|
|
165
|
+
@signing_kid = value
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def encryption_keys=(value)
|
|
169
|
+
resolve_private_jwks!
|
|
170
|
+
@encryption_keys = value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Parses the pending private JWKS, if any. Idempotent: a no-op until
|
|
174
|
+
# `private_jwks_json=` is assigned again. Returns self.
|
|
175
|
+
def resolve_private_jwks!
|
|
176
|
+
return self unless @private_jwks_pending
|
|
177
|
+
|
|
178
|
+
@private_jwks_pending = false
|
|
179
|
+
parse_private_jwks(@private_jwks_json)
|
|
180
|
+
self
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
private
|
|
184
|
+
|
|
185
|
+
def parse_private_jwks(jwks_json)
|
|
171
186
|
@encryption_keys = []
|
|
172
187
|
@signing_key = nil
|
|
173
188
|
@signing_kid = nil
|
|
174
189
|
|
|
175
190
|
if jwks_json.nil? || jwks_json.to_s.strip.empty?
|
|
176
|
-
return if
|
|
177
|
-
|
|
191
|
+
return if quiet?
|
|
192
|
+
log(:warn, "private_jwks_json is not set — Singpass flow will fail at first request")
|
|
178
193
|
return
|
|
179
194
|
end
|
|
180
195
|
|
|
@@ -183,7 +198,7 @@ module StandardSingpass
|
|
|
183
198
|
keys = jwks["keys"] || []
|
|
184
199
|
|
|
185
200
|
sig_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "sig" }
|
|
186
|
-
|
|
201
|
+
log(:warn, "multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
|
|
187
202
|
sig_jwk = sig_jwks.first
|
|
188
203
|
if sig_jwk
|
|
189
204
|
@signing_kid = sig_jwk["kid"]
|
|
@@ -192,8 +207,8 @@ module StandardSingpass
|
|
|
192
207
|
# confusing in console triage (which is the scenario this method is
|
|
193
208
|
# trying to help with).
|
|
194
209
|
@signing_kid = nil unless @signing_key
|
|
195
|
-
elsif !
|
|
196
|
-
|
|
210
|
+
elsif !quiet?
|
|
211
|
+
log(:error, "private_jwks_json contains no key with \"use\":\"sig\"")
|
|
197
212
|
end
|
|
198
213
|
|
|
199
214
|
enc_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "enc" }
|
|
@@ -206,11 +221,11 @@ module StandardSingpass
|
|
|
206
221
|
# forgot to include enc keys) vs all-rejected (every enc key was
|
|
207
222
|
# public-only or otherwise unloadable). The latter is the trap the
|
|
208
223
|
# rest of this method is built to catch.
|
|
209
|
-
if @encryption_keys.empty? && !
|
|
224
|
+
if @encryption_keys.empty? && !quiet?
|
|
210
225
|
if enc_jwks.empty?
|
|
211
|
-
|
|
226
|
+
log(:error, "private_jwks_json contains no key with \"use\":\"enc\"")
|
|
212
227
|
else
|
|
213
|
-
|
|
228
|
+
log(:error, "private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
|
|
214
229
|
end
|
|
215
230
|
end
|
|
216
231
|
rescue JSON::ParserError, TypeError => e
|
|
@@ -220,12 +235,31 @@ module StandardSingpass
|
|
|
220
235
|
# Reported because a malformed private JWKS silently degrades the
|
|
221
236
|
# Singpass widget — the request that finally fails will report, but by
|
|
222
237
|
# then customers have hit the broken page.
|
|
223
|
-
|
|
224
|
-
|
|
238
|
+
log(:error, "failed to parse private_jwks_json: #{e.class}: #{e.message}")
|
|
239
|
+
report(e, reason: "parse_private_jwks")
|
|
225
240
|
@encryption_keys = []
|
|
226
241
|
end
|
|
227
242
|
|
|
228
|
-
|
|
243
|
+
# Missing-key warnings are noise in mock mode and in the test env.
|
|
244
|
+
# Read at parse time — i.e. after the whole configure block has run —
|
|
245
|
+
# so the order of assignments inside the block no longer matters.
|
|
246
|
+
def quiet?
|
|
247
|
+
mock_mode || (defined?(::Rails) && ::Rails.respond_to?(:env) && ::Rails.env.test?)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Rails.logger / Rails.error are optional: the gem can be loaded (and a
|
|
251
|
+
# Configuration built) outside a booted Rails app — tooling, plain Ruby
|
|
252
|
+
# scripts, or before the logger is assigned.
|
|
253
|
+
def log(level, message)
|
|
254
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:logger)
|
|
255
|
+
::Rails.logger&.public_send(level, "StandardSingpass::Myinfo: #{message}")
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def report(error, reason:, **context)
|
|
259
|
+
return unless defined?(::Rails) && ::Rails.respond_to?(:error)
|
|
260
|
+
::Rails.error.report(error, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason:, **context })
|
|
261
|
+
end
|
|
262
|
+
|
|
229
263
|
|
|
230
264
|
# Converts a JWK to a *private* PEM. Refuses public-only JWKs — the
|
|
231
265
|
# private scalar (`d` for EC) must be present, otherwise signing /
|
|
@@ -235,13 +269,13 @@ module StandardSingpass
|
|
|
235
269
|
def jwk_to_private_pem(jwk_hash, role:)
|
|
236
270
|
kid = jwk_hash["kid"]
|
|
237
271
|
if jwk_hash["d"].blank?
|
|
238
|
-
|
|
272
|
+
log(:error, "#{role} JWK #{kid.inspect} is public-only (missing \"d\") — re-export with include_private: true")
|
|
239
273
|
return nil
|
|
240
274
|
end
|
|
241
275
|
JWT::JWK.new(jwk_hash).keypair.to_pem
|
|
242
276
|
rescue => e
|
|
243
|
-
|
|
244
|
-
|
|
277
|
+
log(:error, "failed to convert #{role} JWK #{kid.inspect}: #{e.class} — #{e.message}")
|
|
278
|
+
report(e, reason: "jwk_to_private_pem", role:, kid:)
|
|
245
279
|
nil
|
|
246
280
|
end
|
|
247
281
|
end
|