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.
@@ -60,32 +60,35 @@ module StandardSingpass
60
60
  )
61
61
  }
62
62
 
63
- # Ask Singpass to enforce a minimum assurance level upstream. The same
64
- # config attribute also drives downstream validation of the returned
65
- # id_token (validate_id_token_acr) — defense in depth. When unset, we
66
- # skip both the request parameter and the validator entirely; useful
67
- # for sandbox personas that may return non-conformant acr values.
68
- # Concrete URN per Singpass: `urn:singpass:authentication:loa:N` (N
69
- # is 2 or 3; Singpass never issues below LOA 2). `.to_s.strip` mirrors
70
- # the validator so whitespace-only values are treated as unset and any
71
- # value sent over the wire is trimmed.
72
- min_acr = @minimum_acr.to_s.strip
73
- body[:acr_values] = min_acr unless min_acr.empty?
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)
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
- 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.
@@ -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, both this
303
- # validator and the upstream PAR `acr_values` parameter are skipped.
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
- sig { params(block: T.proc.returns(T::Hash[String, T.untyped])).returns(T::Hash[String, T.untyped]) }
409
- 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)
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
- 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)
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 (defaults to DEFAULT_SCOPE)
17
- # c.minimum_acr - Required Authentication Context Class Reference URN
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 — keep aligned with the Singpass developer-portal
33
- # approval list. Entries on separate lines so diffs against the portal's
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
- # Identity — uinfin, name, alias names, dob, sex, race,
85
- # nationality, residentialstatus → KYC, contracts
86
- # Pass (FIN-only) — passtype, passstatus, passexpirydate,
87
- # employmentsector → tenure-vs-pass-expiry, eligibility
88
- # Address — regadd, hdbtype, housingtype → KYC + income proxy
89
- # Contact — mobileno, email → OTP, mailers
90
- # Family — marital → soft underwriting signal
91
- # Income — noa, noa-basic, noahistory, noahistory-basic,
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
- # `cpfbalances.oa`, `hdbownership.*`, and `vehicles.effectiveownership`
103
- # use FAPI 2.0 sub-attribute scope notation — sharper data minimisation
104
- # than parent-keyword grants.
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
- # Accepts the raw JWKS JSON string and populates signing_key, signing_kid,
167
- # and encryption_keys. Logs and reports issues via Rails.logger / Rails.error
168
- # rather than raising — a malformed JWKS silently degrades the Singpass
169
- # widget at runtime, but the host should boot regardless.
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 mock_mode || (defined?(Rails) && Rails.env.test?)
177
- Rails.logger.warn("StandardSingpass::Myinfo: private_jwks_json is not set — Singpass flow will fail at first request")
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
- Rails.logger.warn("StandardSingpass::Myinfo: multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
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 !mock_mode && !(defined?(Rails) && Rails.env.test?)
196
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"sig\"")
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? && !mock_mode && !(defined?(Rails) && Rails.env.test?)
224
+ if @encryption_keys.empty? && !quiet?
210
225
  if enc_jwks.empty?
211
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"enc\"")
226
+ log(:error, "private_jwks_json contains no key with \"use\":\"enc\"")
212
227
  else
213
- Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
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
- Rails.logger.error("StandardSingpass::Myinfo: failed to parse private_jwks_json: #{e.class}: #{e.message}")
224
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "parse_private_jwks" }) if defined?(Rails.error)
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
- private
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
- Rails.logger.error("StandardSingpass::Myinfo: #{role} JWK #{kid.inspect} is public-only (missing \"d\") — re-export with include_private: true")
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
- Rails.logger.error("StandardSingpass::Myinfo: failed to convert #{role} JWK #{kid.inspect}: #{e.class} — #{e.message}")
244
- Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "jwk_to_private_pem", role:, kid: }) if defined?(Rails.error)
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