standard_singpass 0.1.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.
@@ -0,0 +1,429 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ class Client
6
+ extend T::Sig
7
+
8
+ CLIENT_ASSERTION_TYPE = T.let("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", String)
9
+
10
+ REQUIRED_CONFIG = T.let(%i[client_id redirect_url scope token_url authorize_url
11
+ par_url signing_key signing_kid jwks_url issuer
12
+ userinfo_url userinfo_jwks_url].freeze, T::Array[Symbol])
13
+
14
+ # Allow up to 30 seconds of clock skew for token expiry checks (RFC 7519 §4.1.4)
15
+ CLOCK_SKEW_LEEWAY = T.let(30, Integer)
16
+
17
+ # Maximum age for iat (issued-at) claim: reject tokens issued more than 5 minutes ago
18
+ IAT_MAX_AGE = T.let(300, Integer)
19
+
20
+ sig { params(config: T::Hash[Symbol, T.untyped]).void }
21
+ def initialize(config = {})
22
+ c = StandardSingpass::Myinfo.configuration
23
+ @client_id = T.let(config[:client_id] || c.client_id, T.nilable(String))
24
+ @redirect_url = T.let(config[:redirect_url] || c.redirect_url, T.nilable(String))
25
+ @scope = T.let(config[:scope] || c.scope, T.nilable(String))
26
+ @token_url = T.let(config[:token_url] || c.token_url, T.nilable(String))
27
+ @userinfo_url = T.let(config[:userinfo_url] || c.userinfo_url, T.nilable(String))
28
+ @authorize_url = T.let(config[:authorize_url] || c.authorize_url, T.nilable(String))
29
+ @par_url = T.let(config[:par_url] || c.par_url, T.nilable(String))
30
+ @signing_key = T.let(config[:signing_key] || c.signing_key, T.nilable(String))
31
+ @signing_kid = T.let(config[:signing_kid] || c.signing_kid, T.nilable(String))
32
+ @encryption_keys = T.let(config[:encryption_keys] || c.encryption_keys || [], T::Array[T::Hash[Symbol, T.untyped]])
33
+ @jwks_url = T.let(config[:jwks_url] || c.jwks_url, T.nilable(String))
34
+ @issuer = T.let(config[:issuer] || c.issuer, T.nilable(String))
35
+ @userinfo_jwks_url = T.let(config[:userinfo_jwks_url] || c.userinfo_jwks_url, T.nilable(String))
36
+ @minimum_acr = T.let(config[:minimum_acr] || c.minimum_acr, T.nilable(String))
37
+ @network_wrapper = T.let(config[:network_wrapper] || c.network_wrapper, T.untyped)
38
+ @http_connection = T.let(nil, T.nilable(Faraday::Connection))
39
+
40
+ validate_config!
41
+ end
42
+
43
+ sig { params(code_challenge: String, state: String, nonce: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[Symbol, T.untyped]) }
44
+ def push_authorization_request(code_challenge:, state:, nonce:, dpop_key_pair:)
45
+ body = {
46
+ response_type: "code",
47
+ client_id: @client_id,
48
+ redirect_uri: @redirect_url,
49
+ scope: @scope,
50
+ code_challenge:,
51
+ code_challenge_method: "S256",
52
+ state:,
53
+ nonce:,
54
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
55
+ client_assertion: Security.build_client_assertion(
56
+ client_id: T.must(@client_id),
57
+ audience: T.must(@issuer),
58
+ signing_key: T.must(@signing_key),
59
+ signing_kid: T.must(@signing_kid)
60
+ )
61
+ }
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)
84
+ end
85
+ handle_par_response(response)
86
+ end
87
+ rescue Faraday::Error => e
88
+ raise PARError, "PAR endpoint unreachable: #{e.class}"
89
+ end
90
+
91
+ sig { params(request_uri: String).returns(String) }
92
+ def build_authorize_redirect(request_uri:)
93
+ params = {
94
+ client_id: @client_id,
95
+ request_uri:
96
+ }
97
+
98
+ "#{@authorize_url}?#{URI.encode_www_form(params)}"
99
+ end
100
+
101
+ sig { params(auth_code: String, code_verifier: String, dpop_key_pair: OpenSSL::PKey::EC, nonce: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
102
+ def get_person_data(auth_code:, code_verifier:, dpop_key_pair:, nonce: nil)
103
+ token_data = exchange_token(auth_code:, code_verifier:, dpop_key_pair:)
104
+ id_token_payload = validate_id_token(token_data[:id_token], nonce:)
105
+
106
+ person_data = fetch_userinfo(access_token: token_data[:access_token], dpop_key_pair:)
107
+
108
+ # `acr` is the Authentication Context Class Reference — Singpass FAPI 2.0
109
+ # uses it to communicate which assurance level the user authenticated at
110
+ # (e.g. password+OTP vs. biometrics). Surface it so the callback can
111
+ # persist it for audit. Optional per OIDC core; nil when not present.
112
+ { person_data:, id_token_acr: id_token_payload["acr"] }
113
+ end
114
+
115
+ private
116
+
117
+ sig { params(response: Faraday::Response).returns(T::Hash[Symbol, T.untyped]) }
118
+ def handle_par_response(response)
119
+ case response.status
120
+ when 200, 201
121
+ data = JSON.parse(response.body)
122
+ { request_uri: data.fetch("request_uri"), expires_in: data.fetch("expires_in") }
123
+ when 401, 403
124
+ raise PARError, "PAR rejected (HTTP #{response.status}): #{body_excerpt(response)}"
125
+ when 429
126
+ raise RateLimitError, "PAR endpoint rate limit exceeded"
127
+ else
128
+ raise PARError, "PAR failed (HTTP #{response.status}): #{body_excerpt(response)}"
129
+ end
130
+ rescue KeyError
131
+ raise PARError, "PAR response missing required fields"
132
+ rescue JSON::ParserError
133
+ raise PARError, "Invalid PAR response format"
134
+ end
135
+
136
+ sig { params(auth_code: String, code_verifier: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[Symbol, T.untyped]) }
137
+ def exchange_token(auth_code:, code_verifier:, dpop_key_pair:)
138
+ body = {
139
+ grant_type: "authorization_code",
140
+ code: auth_code,
141
+ redirect_uri: @redirect_url,
142
+ code_verifier:,
143
+ client_id: @client_id,
144
+ client_assertion_type: CLIENT_ASSERTION_TYPE,
145
+ client_assertion: Security.build_client_assertion(
146
+ client_id: T.must(@client_id),
147
+ audience: T.must(@issuer),
148
+ signing_key: T.must(@signing_key),
149
+ signing_kid: T.must(@signing_kid),
150
+ code: auth_code
151
+ )
152
+ }
153
+
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)
163
+ end
164
+ handle_token_response(response)
165
+ end
166
+ rescue Faraday::Error => e
167
+ raise ApiError, "MyInfo token endpoint unreachable: #{e.class}"
168
+ end
169
+
170
+ sig { params(access_token: String, dpop_key_pair: OpenSSL::PKey::EC).returns(T::Hash[String, T.untyped]) }
171
+ def fetch_userinfo(access_token:, dpop_key_pair:)
172
+ with_network_wrapper do
173
+ response = http_connection.get(@userinfo_url) do |req|
174
+ req.headers["Authorization"] = "DPoP #{access_token}"
175
+ req.headers["DPoP"] = Security.build_dpop_proof(
176
+ http_method: "GET",
177
+ url: T.must(@userinfo_url),
178
+ key_pair: dpop_key_pair,
179
+ access_token:
180
+ )
181
+ end
182
+ handle_person_response(response, jwks_url: @userinfo_jwks_url)
183
+ end
184
+ rescue Faraday::Error => e
185
+ raise ApiError, "MyInfo userinfo endpoint unreachable: #{e.class}"
186
+ end
187
+
188
+ sig { params(id_token: T.nilable(String), nonce: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
189
+ def validate_id_token(id_token, nonce: nil)
190
+ raise AuthenticationError, "ID token missing from token response" unless id_token
191
+
192
+ payload = decrypt_and_decode_id_token(id_token)
193
+
194
+ validate_id_token_issuer(payload)
195
+ validate_id_token_audience(payload)
196
+ validate_id_token_expiry(payload)
197
+ validate_id_token_iat(payload)
198
+ validate_id_token_sub(payload)
199
+ validate_id_token_acr(payload)
200
+ validate_id_token_nonce(payload, nonce) if nonce.present?
201
+
202
+ payload
203
+ end
204
+
205
+ # FAPI 2.0 always returns id_tokens as 5-segment JWE (encrypted to our enc
206
+ # key, signed by Singpass). The previous 3-segment fallback was a v3/v4
207
+ # sandbox compatibility path; FAPI 2.0 has no sandbox/production
208
+ # distinction here.
209
+ sig { params(id_token: String).returns(T::Hash[String, T.untyped]) }
210
+ def decrypt_and_decode_id_token(id_token)
211
+ raise AuthenticationError, "ID token must be a 5-segment JWE" unless id_token.split(".", -1).length == 5
212
+
213
+ decrypted = Security.decrypt_jwe(id_token, private_keys: @encryption_keys)
214
+ Security.validate_jws(decrypted, jwks_url: T.must(@jwks_url))
215
+ rescue Security::DecryptionError
216
+ raise AuthenticationError, "ID token decryption failed"
217
+ rescue Security::ValidationError
218
+ raise AuthenticationError, "ID token signature verification failed"
219
+ rescue JWT::DecodeError
220
+ raise AuthenticationError, "Failed to decode ID token"
221
+ end
222
+
223
+ sig { params(payload: T::Hash[String, T.untyped]).void }
224
+ def validate_id_token_issuer(payload)
225
+ token_iss = payload["iss"]
226
+ raise AuthenticationError, "ID token iss claim is missing" unless token_iss.present?
227
+
228
+ unless ActiveSupport::SecurityUtils.secure_compare(token_iss.to_s, @issuer.to_s)
229
+ raise AuthenticationError, "ID token issuer does not match expected issuer"
230
+ end
231
+ end
232
+
233
+ sig { params(payload: T::Hash[String, T.untyped]).void }
234
+ def validate_id_token_audience(payload)
235
+ token_aud = payload["aud"]
236
+ raise AuthenticationError, "ID token aud claim is missing" unless token_aud.present?
237
+
238
+ aud_values = Array(token_aud)
239
+ unless aud_values.any? { |a| ActiveSupport::SecurityUtils.secure_compare(a.to_s, @client_id.to_s) }
240
+ raise AuthenticationError, "ID token audience does not match client_id"
241
+ end
242
+ end
243
+
244
+ sig { params(payload: T::Hash[String, T.untyped]).void }
245
+ def validate_id_token_expiry(payload)
246
+ token_exp = payload["exp"]
247
+ raise AuthenticationError, "ID token exp claim is missing" unless token_exp.present?
248
+
249
+ if token_exp.to_i + CLOCK_SKEW_LEEWAY < Time.now.to_i
250
+ raise AuthenticationError, "ID token has expired"
251
+ end
252
+ end
253
+
254
+ sig { params(payload: T::Hash[String, T.untyped]).void }
255
+ def validate_id_token_iat(payload)
256
+ token_iat = payload["iat"]
257
+ raise AuthenticationError, "ID token iat claim is missing" unless token_iat.present?
258
+
259
+ if token_iat.to_i > Time.now.to_i + CLOCK_SKEW_LEEWAY
260
+ raise AuthenticationError, "ID token iat is in the future"
261
+ end
262
+
263
+ if token_iat.to_i + IAT_MAX_AGE < Time.now.to_i
264
+ raise AuthenticationError, "ID token iat is too old"
265
+ end
266
+ end
267
+
268
+ sig { params(payload: T::Hash[String, T.untyped], nonce: String).void }
269
+ def validate_id_token_nonce(payload, nonce)
270
+ token_nonce = payload["nonce"]
271
+ raise AuthenticationError, "ID token nonce claim is missing" unless token_nonce.present?
272
+
273
+ unless ActiveSupport::SecurityUtils.secure_compare(token_nonce.to_s, nonce.to_s)
274
+ raise AuthenticationError, "ID token nonce does not match session nonce"
275
+ end
276
+ end
277
+
278
+ sig { params(payload: T::Hash[String, T.untyped]).void }
279
+ def validate_id_token_sub(payload)
280
+ token_sub = payload["sub"]
281
+ raise AuthenticationError, "ID token sub claim is missing" unless token_sub.present?
282
+ end
283
+
284
+ # Enforce a minimum Authentication Context Class Reference (`acr`) on the
285
+ # id_token. The floor is configured via `minimum_acr` so staging and
286
+ # production can diverge — staging may tolerate looser values returned by
287
+ # MyInfo sandbox personas. When the attr is unset or blank, both this
288
+ # validator and the upstream PAR `acr_values` parameter are skipped.
289
+ #
290
+ # Singpass's `acr` URN format is `urn:singpass:authentication:loa:N`
291
+ # where N is 2 or 3 (no LOA 1 path — Singpass's IdP is 2FA by design).
292
+ sig { params(payload: T::Hash[String, T.untyped]).void }
293
+ def validate_id_token_acr(payload)
294
+ required_acr = @minimum_acr.to_s.strip
295
+ return if required_acr.empty?
296
+
297
+ required_level = parse_acr_level(required_acr)
298
+ if required_level.nil?
299
+ raise ConfigurationError,
300
+ "minimum_acr=#{required_acr.inspect} is not a recognised Singpass LOA URN (expected: urn:singpass:authentication:loa:N)"
301
+ end
302
+
303
+ actual_acr = payload["acr"]
304
+ actual_level = parse_acr_level(T.cast(actual_acr, T.nilable(String)))
305
+
306
+ if actual_level.nil? || actual_level < required_level
307
+ raise AuthenticationError, "id_token acr=#{actual_acr.inspect} is below required minimum (#{required_acr})"
308
+ end
309
+ end
310
+
311
+ # Parses the trailing integer N out of `urn:singpass:authentication:loa:N`.
312
+ # Returns nil for blank input or anything that doesn't match — for actual
313
+ # id_token acr claims, callers treat nil as "below the floor" so
314
+ # unparseable values fail closed. For the configured floor, callers
315
+ # distinguish nil as a misconfiguration (ConfigurationError) rather than
316
+ # an assurance-level failure.
317
+ sig { params(acr_string: T.nilable(String)).returns(T.nilable(Integer)) }
318
+ def parse_acr_level(acr_string)
319
+ return nil if acr_string.to_s.empty?
320
+ match = acr_string.to_s.match(/loa:(\d+)\z/)
321
+ match && match[1].to_i
322
+ end
323
+
324
+ sig { params(response: Faraday::Response).returns(T::Hash[Symbol, T.untyped]) }
325
+ def handle_token_response(response)
326
+ case response.status
327
+ when 200
328
+ data = JSON.parse(response.body)
329
+ { access_token: data.fetch("access_token"), id_token: data["id_token"] }
330
+ when 401, 403
331
+ raise AuthenticationError, "Token exchange rejected (HTTP #{response.status}): #{body_excerpt(response)}"
332
+ when 429
333
+ raise RateLimitError, "Token endpoint rate limit exceeded"
334
+ else
335
+ raise ApiError, "Token exchange failed (HTTP #{response.status}): #{body_excerpt(response)}"
336
+ end
337
+ rescue KeyError
338
+ raise AuthenticationError, "Token response missing access_token"
339
+ rescue JSON::ParserError
340
+ raise AuthenticationError, "Invalid token response format"
341
+ end
342
+
343
+ sig { params(response: Faraday::Response, jwks_url: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
344
+ def handle_person_response(response, jwks_url:)
345
+ case response.status
346
+ when 200
347
+ decrypt_and_validate_person(response.body, jwks_url:)
348
+ when 401, 403
349
+ raise AuthenticationError, "Person data request forbidden (HTTP #{response.status}): #{body_excerpt(response)}"
350
+ when 429
351
+ raise RateLimitError, "Person endpoint rate limit exceeded"
352
+ else
353
+ raise ApiError, "Person data fetch failed (HTTP #{response.status}): #{body_excerpt(response)}"
354
+ end
355
+ end
356
+
357
+ # Standardized FAPI/OAuth error fields that are safe to surface in error
358
+ # messages — non-PII, useful for debugging, and explicitly named by the
359
+ # OAuth 2.0 / FAPI 2.0 / Singpass specs. Any other field in the response
360
+ # body is dropped: Singpass error payloads can carry NRIC / email / other
361
+ # PII alongside the OAuth fields, and we'd rather lose diagnostic detail
362
+ # than leak PII into log streams.
363
+ SAFE_ERROR_FIELDS = T.let(%w[error error_description trace_id id state].freeze, T::Array[String])
364
+
365
+ # Single-line excerpt of a Faraday response body for inclusion in error
366
+ # messages. Parses JSON when possible and emits only the SAFE_ERROR_FIELDS
367
+ # values; falls back to a fixed-marker for non-JSON or unrecognised shape.
368
+ # Never returns arbitrary body content — that would risk leaking PII.
369
+ sig { params(response: Faraday::Response).returns(String) }
370
+ def body_excerpt(response)
371
+ body = response.body.to_s
372
+ return "(empty body)" if body.empty?
373
+
374
+ parsed = begin
375
+ JSON.parse(body)
376
+ rescue JSON::ParserError
377
+ nil
378
+ end
379
+
380
+ if parsed.is_a?(Hash)
381
+ parts = SAFE_ERROR_FIELDS.filter_map do |field|
382
+ value = parsed[field]
383
+ next if value.nil? || value.to_s.empty?
384
+ "#{field}=#{value.inspect}"
385
+ end
386
+ return parts.empty? ? "(non-standard error body — no error/error_description fields)" : parts.join(" ")
387
+ end
388
+
389
+ "(non-JSON body, #{body.bytesize} bytes)"
390
+ end
391
+
392
+ sig { params(jwe_body: String, jwks_url: T.nilable(String)).returns(T::Hash[String, T.untyped]) }
393
+ def decrypt_and_validate_person(jwe_body, jwks_url:)
394
+ decrypted = Security.decrypt_jwe(jwe_body, private_keys: @encryption_keys)
395
+ Security.validate_jws(decrypted, jwks_url: T.must(jwks_url))
396
+ rescue Security::DecryptionError => e
397
+ raise DecryptionError, e.message
398
+ rescue Security::ValidationError => e
399
+ raise SignatureError, e.message
400
+ end
401
+
402
+ sig { void }
403
+ def validate_config!
404
+ missing = REQUIRED_CONFIG.select { |key| instance_variable_get(:"@#{key}").blank? }
405
+ raise ArgumentError, "Missing MyInfo config: #{missing.join(', ')}" if missing.any?
406
+ end
407
+
408
+ sig { returns(Faraday::Connection) }
409
+ def http_connection
410
+ @http_connection ||= Faraday.new do |f|
411
+ f.options.open_timeout = 10
412
+ f.options.timeout = 15
413
+ end
414
+ end
415
+
416
+ # Wraps the given block in the configured network_wrapper. Default is
417
+ # the identity wrapper (no resilience). Hosts typically set this to a
418
+ # circuit-breaker lambda. Scoped narrowly to the Faraday network call
419
+ # so that downstream JWE/JWS processing errors (DecryptionError,
420
+ # SignatureError) propagate untouched — they indicate key/cert
421
+ # misconfiguration, not an upstream outage, and should not trip a
422
+ # circuit breaker.
423
+ sig { params(block: T.proc.returns(T.untyped)).returns(T.untyped) }
424
+ def with_network_wrapper(&block)
425
+ @network_wrapper.call(&block)
426
+ end
427
+ end
428
+ end
429
+ end
@@ -0,0 +1,233 @@
1
+ # MyInfo (Singpass) configuration.
2
+ #
3
+ # The host application is responsible for reading environment variables and
4
+ # passing them in via `StandardSingpass::Myinfo.configure`. The gem itself
5
+ # does not consult ENV — keeping env wiring at the host boundary makes the
6
+ # gem portable and trivially testable.
7
+ #
8
+ # Required attributes:
9
+ # c.environment - :production | :staging (drives endpoint URLs)
10
+ # c.client_id - App ID from Singpass Developer Portal
11
+ # c.redirect_url - OAuth callback URL (e.g. https://example.com/singpass/callback)
12
+ # c.private_jwks_json - Full JWKS JSON string with private signing + encryption keys
13
+ # (keys are identified by "use": "sig" and "use": "enc")
14
+ #
15
+ # Optional attributes:
16
+ # c.scope - Space-separated scopes (defaults to DEFAULT_SCOPE)
17
+ # c.minimum_acr - Required Authentication Context Class Reference URN
18
+ # c.network_wrapper - Lambda wrapping outbound Faraday calls (e.g. circuit breaker)
19
+ # c.mock_mode - When true, suppresses missing-key warnings
20
+ # c.personas_path - Pathname to JSON file of test personas
21
+ # c.authorize_url, c.par_url, c.token_url, c.userinfo_url,
22
+ # c.jwks_url, c.userinfo_jwks_url, c.issuer
23
+ # - Override individual endpoints (rarely needed)
24
+
25
+ module StandardSingpass
26
+ module Myinfo
27
+ class Configuration
28
+ # Default MyInfo scope — keep aligned with the Singpass developer-portal
29
+ # approval list. Entries on separate lines so diffs against the portal's
30
+ # ordered dump are reviewable line-by-line. Joined into a single
31
+ # space-delimited string before sending to PAR.
32
+ DEFAULT_SCOPE = %w[
33
+ openid
34
+ aliasname
35
+ cpfbalances.oa
36
+ cpfcontributions
37
+ cpfemployers
38
+ cpfhousingwithdrawal
39
+ dob
40
+ email
41
+ employment
42
+ employmentsector
43
+ hanyupinyinaliasname
44
+ hanyupinyinname
45
+ hdbownership.address
46
+ hdbownership.balanceloanrepayment
47
+ hdbownership.hdbtype
48
+ hdbownership.loangranted
49
+ hdbownership.monthlyloaninstalment
50
+ hdbownership.noofowners
51
+ hdbownership.outstandinginstalment
52
+ hdbownership.outstandingloanbalance
53
+ hdbtype
54
+ housingtype
55
+ marital
56
+ marriedname
57
+ mobileno
58
+ name
59
+ nationality
60
+ noa
61
+ noa-basic
62
+ noahistory
63
+ noahistory-basic
64
+ occupation
65
+ ownerprivate
66
+ passexpirydate
67
+ passstatus
68
+ passtype
69
+ race
70
+ regadd
71
+ residentialstatus
72
+ sex
73
+ uinfin
74
+ vehicles.effectiveownership
75
+ ].join(" ").freeze
76
+
77
+ # The categories and their lender-underwriting purpose (PDPA §18,
78
+ # Purpose Limitation):
79
+ #
80
+ # Identity — uinfin, name, alias names, dob, sex, race,
81
+ # nationality, residentialstatus → KYC, contracts
82
+ # Pass (FIN-only) — passtype, passstatus, passexpirydate,
83
+ # employmentsector → tenure-vs-pass-expiry, eligibility
84
+ # Address — regadd, hdbtype, housingtype → KYC + income proxy
85
+ # Contact — mobileno, email → OTP, mailers
86
+ # Family — marital → soft underwriting signal
87
+ # Income — noa, noa-basic, noahistory, noahistory-basic,
88
+ # cpfcontributions → MAS TDSR input
89
+ # Employment — employment, occupation, cpfemployers
90
+ # → continuity + employer stability
91
+ # Assets — cpfbalances.oa (only OA — MA/SA/RA are ring-fenced
92
+ # and not lender-relevant), ownerprivate
93
+ # Liabilities — cpfhousingwithdrawal, hdbownership.* (8 sub-fields)
94
+ # → TDSR housing component
95
+ # Vehicle — vehicles.effectiveownership (asset/liability hint;
96
+ # full vehicle details deliberately not requested)
97
+ #
98
+ # `cpfbalances.oa`, `hdbownership.*`, and `vehicles.effectiveownership`
99
+ # use FAPI 2.0 sub-attribute scope notation — sharper data minimisation
100
+ # than parent-keyword grants.
101
+
102
+ PRODUCTION_ENDPOINTS = {
103
+ authorize_url: "https://id.singpass.gov.sg/fapi/auth",
104
+ par_url: "https://id.singpass.gov.sg/fapi/par",
105
+ token_url: "https://id.singpass.gov.sg/fapi/token",
106
+ jwks_url: "https://id.singpass.gov.sg/.well-known/keys",
107
+ issuer: "https://id.singpass.gov.sg/fapi",
108
+ userinfo_url: "https://id.singpass.gov.sg/fapi/userinfo",
109
+ userinfo_jwks_url: "https://id.singpass.gov.sg/.well-known/keys"
110
+ }.freeze
111
+
112
+ STAGING_ENDPOINTS = {
113
+ authorize_url: "https://stg-id.singpass.gov.sg/fapi/auth",
114
+ par_url: "https://stg-id.singpass.gov.sg/fapi/par",
115
+ token_url: "https://stg-id.singpass.gov.sg/fapi/token",
116
+ jwks_url: "https://stg-id.singpass.gov.sg/.well-known/keys",
117
+ issuer: "https://stg-id.singpass.gov.sg/fapi",
118
+ userinfo_url: "https://stg-id.singpass.gov.sg/fapi/userinfo",
119
+ userinfo_jwks_url: "https://stg-id.singpass.gov.sg/.well-known/keys"
120
+ }.freeze
121
+
122
+ attr_accessor :authorize_url, :par_url, :token_url, :userinfo_url,
123
+ :jwks_url, :userinfo_jwks_url, :issuer,
124
+ :client_id, :redirect_url, :scope,
125
+ :signing_key, :signing_kid, :encryption_keys,
126
+ :minimum_acr, :network_wrapper, :mock_mode, :personas_path
127
+
128
+ def initialize
129
+ self.environment = :staging
130
+ @scope = DEFAULT_SCOPE
131
+ @encryption_keys = []
132
+ @network_wrapper = ->(&block) { block.call }
133
+ @mock_mode = false
134
+ end
135
+
136
+ def environment=(env)
137
+ @environment = env
138
+ endpoints = env == :production ? PRODUCTION_ENDPOINTS : STAGING_ENDPOINTS
139
+ @authorize_url = endpoints[:authorize_url]
140
+ @par_url = endpoints[:par_url]
141
+ @token_url = endpoints[:token_url]
142
+ @jwks_url = endpoints[:jwks_url]
143
+ @issuer = endpoints[:issuer]
144
+ @userinfo_url = endpoints[:userinfo_url]
145
+ @userinfo_jwks_url = endpoints[:userinfo_jwks_url]
146
+ end
147
+
148
+ attr_reader :environment
149
+
150
+ # Accepts the raw JWKS JSON string and populates signing_key, signing_kid,
151
+ # and encryption_keys. Logs and reports issues via Rails.logger / Rails.error
152
+ # rather than raising — a malformed JWKS silently degrades the Singpass
153
+ # widget at runtime, but the host should boot regardless.
154
+ def private_jwks_json=(jwks_json)
155
+ @encryption_keys = []
156
+ @signing_key = nil
157
+ @signing_kid = nil
158
+
159
+ if jwks_json.nil? || jwks_json.to_s.strip.empty?
160
+ return if mock_mode || (defined?(Rails) && Rails.env.test?)
161
+ Rails.logger.warn("StandardSingpass::Myinfo: private_jwks_json is not set — Singpass flow will fail at first request")
162
+ return
163
+ end
164
+
165
+ jwks = JSON.parse(jwks_json)
166
+ raise TypeError, "private_jwks_json must be a JSON object with a \"keys\" array, got #{jwks.class}" unless jwks.is_a?(Hash)
167
+ keys = jwks["keys"] || []
168
+
169
+ sig_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "sig" }
170
+ Rails.logger.warn("StandardSingpass::Myinfo: multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
171
+ sig_jwk = sig_jwks.first
172
+ if sig_jwk
173
+ @signing_kid = sig_jwk["kid"]
174
+ @signing_key = jwk_to_private_pem(sig_jwk, role: "signing")
175
+ # Keep paired: a nil signing_key with a populated signing_kid is
176
+ # confusing in console triage (which is the scenario this method is
177
+ # trying to help with).
178
+ @signing_kid = nil unless @signing_key
179
+ elsif !mock_mode && !(defined?(Rails) && Rails.env.test?)
180
+ Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"sig\"")
181
+ end
182
+
183
+ enc_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "enc" }
184
+ @encryption_keys = enc_jwks.filter_map do |enc_jwk|
185
+ pem = jwk_to_private_pem(enc_jwk, role: "encryption")
186
+ next unless pem
187
+ { kid: enc_jwk["kid"], key: pem }
188
+ end
189
+ # Distinguish the two empty-state cases: missing entirely (operator
190
+ # forgot to include enc keys) vs all-rejected (every enc key was
191
+ # public-only or otherwise unloadable). The latter is the trap the
192
+ # rest of this method is built to catch.
193
+ if @encryption_keys.empty? && !mock_mode && !(defined?(Rails) && Rails.env.test?)
194
+ if enc_jwks.empty?
195
+ Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"enc\"")
196
+ else
197
+ Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
198
+ end
199
+ end
200
+ rescue JSON::ParserError, TypeError => e
201
+ # JSON::ParserError: not valid JSON. TypeError: valid JSON but wrong
202
+ # shape (e.g. an array, a string) — caught explicitly so an operator
203
+ # who pastes the wrong file doesn't see a bare TypeError escape.
204
+ # Reported because a malformed private JWKS silently degrades the
205
+ # Singpass widget — the request that finally fails will report, but by
206
+ # then customers have hit the broken page.
207
+ Rails.logger.error("StandardSingpass::Myinfo: failed to parse private_jwks_json: #{e.class}: #{e.message}")
208
+ Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "parse_private_jwks" }) if defined?(Rails.error)
209
+ @encryption_keys = []
210
+ end
211
+
212
+ private
213
+
214
+ # Converts a JWK to a *private* PEM. Refuses public-only JWKs — the
215
+ # private scalar (`d` for EC) must be present, otherwise signing /
216
+ # decryption will fail at runtime deep inside a request flow with an
217
+ # opaque OpenSSL::PKey::PKeyError. Logs the kid so operators can
218
+ # correlate against the JWKS they pasted into the env var.
219
+ def jwk_to_private_pem(jwk_hash, role:)
220
+ kid = jwk_hash["kid"]
221
+ if jwk_hash["d"].blank?
222
+ Rails.logger.error("StandardSingpass::Myinfo: #{role} JWK #{kid.inspect} is public-only (missing \"d\") — re-export with include_private: true")
223
+ return nil
224
+ end
225
+ JWT::JWK.new(jwk_hash).keypair.to_pem
226
+ rescue => e
227
+ Rails.logger.error("StandardSingpass::Myinfo: failed to convert #{role} JWK #{kid.inspect}: #{e.class} — #{e.message}")
228
+ Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "jwk_to_private_pem", role:, kid: }) if defined?(Rails.error)
229
+ nil
230
+ end
231
+ end
232
+ end
233
+ end