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,356 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ class PersonDataParser
6
+ extend T::Sig
7
+
8
+ # Extracts structured fields from the raw MyInfo person data response.
9
+ # Returns a hash suitable for storing in the host application's MyInfo
10
+ # record (typically encrypted at rest).
11
+ sig { params(person_data: T.nilable(T::Hash[String, T.untyped])).returns(T::Hash[Symbol, T.untyped]) }
12
+ def self.call(person_data)
13
+ new(person_data).parse
14
+ end
15
+
16
+ # Singpass FAPI 2.0 / v5 userinfo responses wrap the attribute set in
17
+ # `person_info`:
18
+ # { "sub" => "<uuid>", "person_info" => { "uinfin" => { "value" => "..." }, ... } }
19
+ # Per the official migration guide:
20
+ # https://docs.developer.singpass.gov.sg/docs/technical-specifications/migration-guides/login-myinfo-v5-apps
21
+ # We unwrap once here so every consumer can read attributes at the top
22
+ # level. Tests and mock-mode pass flat data — that also works because we
23
+ # only unwrap when the wrapper key is present.
24
+ sig { params(person_data: T.nilable(T::Hash[String, T.untyped])).void }
25
+ def initialize(person_data)
26
+ data = person_data || {}
27
+ @data = T.let(data.key?("person_info") ? (data["person_info"] || {}) : data, T::Hash[String, T.untyped])
28
+ end
29
+
30
+ sig { returns(T::Hash[Symbol, T.untyped]) }
31
+ def parse
32
+ {
33
+ # Identity
34
+ nric: extract_value("uinfin"),
35
+ name: extract_value("name"),
36
+ alias_name: extract_value("aliasname"),
37
+ hanyu_pinyin_name: extract_value("hanyupinyinname"),
38
+ hanyu_pinyin_alias_name: extract_value("hanyupinyinaliasname"),
39
+ married_name: extract_value("marriedname"),
40
+ sex: extract_code("sex"),
41
+ race: extract_code("race"),
42
+ nationality: extract_code("nationality"),
43
+ date_of_birth: extract_value("dob"),
44
+ residential_status: extract_code("residentialstatus"),
45
+ marital_status: extract_code("marital"),
46
+
47
+ # Contact
48
+ email: extract_value("email"),
49
+ mobile_number:,
50
+
51
+ # Address
52
+ registered_address:,
53
+ hdb_type: extract_label("hdbtype"),
54
+ housing_type: extract_label("housingtype"),
55
+
56
+ # Pass info — FIN-only; absent for SC/PR.
57
+ pass_type: extract_label("passtype"),
58
+ pass_status: extract_label("passstatus"),
59
+ pass_expiry_date: extract_value("passexpirydate"),
60
+ employment_sector: extract_label("employmentsector"),
61
+
62
+ # Employment. Singpass `employment` returns the employer's company
63
+ # name for FIN holders, but a status label ("EMPLOYED" / "SELF-
64
+ # EMPLOYED") for SC/PR. Stored as `:employment` rather than
65
+ # `:employer_name` so consumers don't render "Employer: EMPLOYED"
66
+ # for citizen borrowers.
67
+ employment: extract_value("employment"),
68
+ # SSOC 4-digit code → desc (e.g. "5223" → "SALES SUPERVISOR"). Codes
69
+ # alone are meaningless to humans reviewing what's been shared.
70
+ occupation: extract_label("occupation"),
71
+ cpf_employers:,
72
+
73
+ # Income — MAS TDSR inputs
74
+ noa_basic:,
75
+ noa_history_basic:,
76
+ noa: noa_detailed,
77
+ noa_history: noa_history_detailed,
78
+ cpf_contributions:,
79
+
80
+ # Assets / liabilities
81
+ cpf_balances:,
82
+ cpf_housing_withdrawal:,
83
+ owner_private: extract_value("ownerprivate"),
84
+ hdb_ownership:,
85
+ vehicles:
86
+ }.compact
87
+ end
88
+
89
+ private
90
+
91
+ sig { params(field: String).returns(T.nilable(String)) }
92
+ def extract_value(field)
93
+ target = @data[field]
94
+ return nil unless target.is_a?(Hash)
95
+ val = target["value"]
96
+ return nil if val.nil?
97
+ return nil if val.is_a?(String) && val.empty?
98
+ # Singpass returns some Y/N attributes (observed on `ownerprivate`
99
+ # in FAPI 2.0 v5) as JSON booleans rather than "Y"/"N" strings.
100
+ # Normalise here so downstream consumers see one shape regardless
101
+ # of upstream type. Boolean false → "N", true → "Y".
102
+ return val ? "Y" : "N" if val == true || val == false
103
+ return val if val.is_a?(String)
104
+ # Numeric or other unexpected types — stringify rather than crash.
105
+ val.to_s
106
+ end
107
+
108
+ sig { params(field: String).returns(T.nilable(String)) }
109
+ def extract_code(field)
110
+ target = @data[field]
111
+ return nil unless target.is_a?(Hash)
112
+ target["code"].presence
113
+ end
114
+
115
+ # Singpass MyInfo v5 returns enum fields as `{ code: "112", desc: "4-ROOM FLAT", ... }`.
116
+ # `extract_code` keeps the raw enum for business logic (e.g. residential
117
+ # status eligibility check). For display-only enums (HDB type, housing
118
+ # type, occupation, pass info, employment sector) the desc is what the
119
+ # human reviewing what's been shared needs to see — falling back to code
120
+ # when desc is absent rather than render an empty cell.
121
+ sig { params(field: String).returns(T.nilable(String)) }
122
+ def extract_label(field)
123
+ label_from(@data[field])
124
+ end
125
+
126
+ # Hash-direct variant of `extract_label` for nested code/desc blocks
127
+ # (e.g. `hdbownership[].hdbtype`) where the parent already navigated
128
+ # to the leaf hash.
129
+ sig { params(hash: T.untyped).returns(T.nilable(String)) }
130
+ def label_from(hash)
131
+ return nil unless hash.is_a?(Hash)
132
+ hash["desc"].presence || hash["code"].presence
133
+ end
134
+
135
+ # Singpass MyInfo (FAPI 2.0) returns `mobileno` as three nested objects —
136
+ # `prefix` ("+"), `areacode` ("65"), `nbr` ("91234567") — each wrapped in
137
+ # `{ "value" => ... }`.
138
+ sig { returns(T.nilable(String)) }
139
+ def mobile_number
140
+ mobileno = @data["mobileno"]
141
+ return nil unless mobileno.is_a?(Hash)
142
+
143
+ prefix = mobileno.dig("prefix", "value")
144
+ areacode = mobileno.dig("areacode", "value")
145
+ number = mobileno.dig("nbr", "value")
146
+ return nil if number.blank? || prefix.blank?
147
+
148
+ "#{prefix}#{areacode}#{number}"
149
+ end
150
+
151
+ sig { returns(T.nilable(T::Hash[Symbol, String])) }
152
+ def registered_address
153
+ addr = @data["regadd"]
154
+ return nil unless addr
155
+
156
+ {
157
+ block: addr.dig("block", "value"),
158
+ building: addr.dig("building", "value"),
159
+ floor: addr.dig("floor", "value"),
160
+ unit: addr.dig("unit", "value"),
161
+ street: addr.dig("street", "value"),
162
+ postal: addr.dig("postal", "value"),
163
+ country: addr.dig("country", "code")
164
+ }.compact
165
+ end
166
+
167
+ # CPF Ordinary Account balance only. FAPI 2.0 sub-attribute scope
168
+ # assumption: the `cpfbalances.oa` scope returns the response in the
169
+ # nested form `{ "cpfbalances": { "oa": { "value": "..." } } }` rather
170
+ # than a flattened key. Same assumption for `hdbownership.*` and
171
+ # `vehicles.effectiveownership` below.
172
+ sig { returns(T.nilable(T::Hash[Symbol, String])) }
173
+ def cpf_balances
174
+ oa = @data.dig("cpfbalances", "oa", "value")
175
+ return nil if oa.blank?
176
+ { ordinary_account: oa }
177
+ end
178
+
179
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, String]])) }
180
+ def cpf_contributions
181
+ list = array_at("cpfcontributions", "history")
182
+ return nil unless list
183
+
184
+ list.filter_map do |entry|
185
+ next unless entry.is_a?(Hash)
186
+ record = {
187
+ employer: entry.dig("employer", "value"),
188
+ month: entry.dig("month", "value"),
189
+ amount: entry.dig("amount", "value"),
190
+ date: entry.dig("date", "value")
191
+ }.compact
192
+ record.presence
193
+ end.presence
194
+ end
195
+
196
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, String]])) }
197
+ def cpf_employers
198
+ list = array_at("cpfemployers", "history")
199
+ return nil unless list
200
+
201
+ list.filter_map do |entry|
202
+ next unless entry.is_a?(Hash)
203
+ record = {
204
+ name: entry.dig("employer", "value"),
205
+ month: entry.dig("month", "value")
206
+ }.compact
207
+ record.presence
208
+ end.presence
209
+ end
210
+
211
+ sig { returns(T.nilable(T::Hash[Symbol, String])) }
212
+ def cpf_housing_withdrawal
213
+ block = @data["cpfhousingwithdrawal"]
214
+ return nil unless block.is_a?(Hash)
215
+
216
+ record = {
217
+ principal: block.dig("totalprincipalamount", "value"),
218
+ monthly_instalment: block.dig("totalmonthlyinstalmentamount", "value"),
219
+ accrued_interest: block.dig("totalaccruedinterestamount", "value")
220
+ }.compact
221
+ record.presence
222
+ end
223
+
224
+ sig { returns(T.nilable(T::Hash[Symbol, String])) }
225
+ def noa_basic
226
+ noa_record(@data["noa-basic"])
227
+ end
228
+
229
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, String]])) }
230
+ def noa_history_basic
231
+ list = array_at("noahistory-basic", "noas")
232
+ return nil unless list
233
+
234
+ list.filter_map { |entry| noa_record(entry) }.presence
235
+ end
236
+
237
+ sig { returns(T.nilable(T::Hash[Symbol, String])) }
238
+ def noa_detailed
239
+ block = @data["noa"]
240
+ return nil unless block.is_a?(Hash)
241
+
242
+ record = {
243
+ year_of_assessment: block.dig("yearofassessment", "value"),
244
+ amount: block.dig("amount", "value"),
245
+ employment: block.dig("employment", "value"),
246
+ trade: block.dig("trade", "value"),
247
+ rent: block.dig("rent", "value"),
248
+ interest: block.dig("interest", "value"),
249
+ tax_category: block.dig("taxclearance", "value")
250
+ }.compact
251
+ record.presence
252
+ end
253
+
254
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, String]])) }
255
+ def noa_history_detailed
256
+ list = array_at("noahistory", "noas")
257
+ return nil unless list
258
+
259
+ list.filter_map do |entry|
260
+ next unless entry.is_a?(Hash)
261
+ record = {
262
+ year_of_assessment: entry.dig("yearofassessment", "value"),
263
+ amount: entry.dig("amount", "value"),
264
+ employment: entry.dig("employment", "value"),
265
+ trade: entry.dig("trade", "value"),
266
+ rent: entry.dig("rent", "value"),
267
+ interest: entry.dig("interest", "value"),
268
+ tax_category: entry.dig("taxclearance", "value")
269
+ }.compact
270
+ record.presence
271
+ end.presence
272
+ end
273
+
274
+ # HDB ownership records — one entry per flat owned. The 8 sub-fields
275
+ # drive TDSR housing-loan calculations: monthly instalment is the most
276
+ # important (direct repayment-capacity reduction); outstanding balance
277
+ # gives leverage ratio context.
278
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, T.untyped]])) }
279
+ def hdb_ownership
280
+ list = array_at("hdbownership")
281
+ return nil unless list
282
+
283
+ list.filter_map do |entry|
284
+ next unless entry.is_a?(Hash)
285
+ record = {
286
+ no_of_owners: entry.dig("noofowners", "value"),
287
+ address: hdb_address(entry["address"]),
288
+ hdb_type: label_from(entry["hdbtype"]),
289
+ loan_granted: entry.dig("loangranted", "value"),
290
+ balance_loan_repayment: entry.dig("balanceloanrepayment", "value"),
291
+ outstanding_loan_balance: entry.dig("outstandingloanbalance", "value"),
292
+ monthly_loan_instalment: entry.dig("monthlyloaninstalment", "value"),
293
+ outstanding_instalment: entry.dig("outstandinginstalment", "value")
294
+ }.compact
295
+ record.presence
296
+ end.presence
297
+ end
298
+
299
+ sig { params(addr: T.untyped).returns(T.nilable(T::Hash[Symbol, String])) }
300
+ def hdb_address(addr)
301
+ return nil unless addr.is_a?(Hash)
302
+
303
+ record = {
304
+ block: addr.dig("block", "value"),
305
+ building: addr.dig("building", "value"),
306
+ floor: addr.dig("floor", "value"),
307
+ unit: addr.dig("unit", "value"),
308
+ street: addr.dig("street", "value"),
309
+ postal: addr.dig("postal", "value"),
310
+ country: addr.dig("country", "code")
311
+ }.compact
312
+ record.presence
313
+ end
314
+
315
+ sig { returns(T.nilable(T::Array[T::Hash[Symbol, String]])) }
316
+ def vehicles
317
+ list = array_at("vehicles")
318
+ return nil unless list
319
+
320
+ list.filter_map do |entry|
321
+ next unless entry.is_a?(Hash)
322
+ date = entry.dig("effectiveownership", "value")
323
+ next if date.blank?
324
+ { effective_ownership_date: date }
325
+ end.presence
326
+ end
327
+
328
+ sig { params(entry: T.untyped).returns(T.nilable(T::Hash[Symbol, String])) }
329
+ def noa_record(entry)
330
+ return nil unless entry.is_a?(Hash)
331
+ record = {
332
+ year_of_assessment: entry.dig("yearofassessment", "value"),
333
+ amount: entry.dig("amount", "value")
334
+ }.compact
335
+ record.presence
336
+ end
337
+
338
+ # Singpass returns array-shaped attributes either as a direct array
339
+ # (e.g. `vehicles: [...]`) or wrapped in a sub-key like
340
+ # `cpfcontributions: { history: [...] }` / `noahistory: { noas: [...] }`.
341
+ sig { params(field: String, sub: T.nilable(String)).returns(T.nilable(T::Array[T.untyped])) }
342
+ def array_at(field, sub = nil)
343
+ block = @data[field]
344
+
345
+ direct =
346
+ case block
347
+ when Array then block
348
+ when Hash then sub ? block[sub] : nil
349
+ end
350
+
351
+ return direct if direct.is_a?(Array)
352
+ nil
353
+ end
354
+ end
355
+ end
356
+ end
@@ -0,0 +1,186 @@
1
+ # typed: strict
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ class Security
6
+ extend T::Sig
7
+
8
+ class DecryptionError < StandardError; end
9
+ class ValidationError < StandardError; end
10
+
11
+ JWKS_CACHE_TTL = T.let(1.hour, ActiveSupport::Duration)
12
+
13
+ # Generates a PKCE code verifier and code challenge pair (S256).
14
+ sig { returns({ code_verifier: String, code_challenge: String }) }
15
+ def self.generate_pkce_pair
16
+ code_verifier = SecureRandom.urlsafe_base64(48)
17
+ code_challenge = Base64.urlsafe_encode64(
18
+ Digest::SHA256.digest(code_verifier), padding: false
19
+ )
20
+ { code_verifier:, code_challenge: }
21
+ end
22
+
23
+ # Generates an ephemeral EC key pair (ES256 / prime256v1) for DPoP.
24
+ sig { returns(OpenSSL::PKey::EC) }
25
+ def self.generate_ephemeral_key_pair
26
+ OpenSSL::PKey::EC.generate("prime256v1")
27
+ end
28
+
29
+ # Builds a DPoP proof JWT per RFC 9449.
30
+ sig { params(http_method: String, url: String, key_pair: OpenSSL::PKey::EC, access_token: T.nilable(String)).returns(String) }
31
+ def self.build_dpop_proof(http_method:, url:, key_pair:, access_token: nil)
32
+ jwk = JWT::JWK.new(key_pair)
33
+
34
+ header = {
35
+ typ: "dpop+jwt",
36
+ alg: "ES256",
37
+ jwk: jwk.export(include_private: false)
38
+ }
39
+
40
+ htu = URI(url).tap { |u| u.query = nil; u.fragment = nil }.to_s
41
+
42
+ claims = {
43
+ htm: http_method.upcase,
44
+ htu:,
45
+ iat: Time.current.to_i,
46
+ exp: (Time.current + 2.minutes).to_i,
47
+ jti: SecureRandom.uuid
48
+ }
49
+
50
+ if access_token
51
+ ath = Base64.urlsafe_encode64(
52
+ Digest::SHA256.digest(access_token), padding: false
53
+ )
54
+ claims[:ath] = ath
55
+ end
56
+
57
+ JWT.encode(claims, key_pair, "ES256", header)
58
+ end
59
+
60
+ # Builds a private_key_jwt client assertion for the token endpoint.
61
+ sig { params(client_id: String, audience: String, signing_key: T.any(String, OpenSSL::PKey::PKey), signing_kid: String, code: T.nilable(String)).returns(String) }
62
+ def self.build_client_assertion(client_id:, audience:, signing_key:, signing_kid:, code: nil)
63
+ key = signing_key.is_a?(OpenSSL::PKey::PKey) ? signing_key : OpenSSL::PKey.read(signing_key)
64
+
65
+ header = {
66
+ alg: "ES256",
67
+ kid: signing_kid,
68
+ typ: "JWT"
69
+ }
70
+
71
+ claims = {
72
+ iss: client_id,
73
+ sub: client_id,
74
+ aud: audience,
75
+ iat: Time.current.to_i,
76
+ exp: (Time.current + 2.minutes).to_i,
77
+ jti: SecureRandom.uuid
78
+ }
79
+ claims[:code] = code if code.present?
80
+
81
+ JWT.encode(claims, key, "ES256", header)
82
+ end
83
+
84
+ # Decrypts a JWE string using the matching private key (by kid). FAPI 2.0
85
+ # mandates EC P-256 with ECDH-ES+A256KW; RSA-OAEP (the v4 path) is no
86
+ # longer supported by Singpass and we no longer accept it on our side.
87
+ sig { params(jwe_string: String, private_keys: T::Array[T::Hash[Symbol, T.untyped]]).returns(String) }
88
+ def self.decrypt_jwe(jwe_string, private_keys:)
89
+ header_kid = extract_jwe_kid(jwe_string)
90
+ raise DecryptionError, "JWE header missing kid field" unless header_kid
91
+
92
+ matching_key = private_keys.find { |k| k[:kid] == header_kid }
93
+ raise DecryptionError, "No matching decryption key found" unless matching_key
94
+
95
+ alg = extract_jwe_alg(jwe_string)
96
+ unless EcdhJwe::SUPPORTED_ALGS.include?(alg)
97
+ raise DecryptionError, "Unsupported JWE alg #{alg.inspect}; FAPI 2.0 requires #{EcdhJwe::SUPPORTED_ALGS.join('/')}"
98
+ end
99
+
100
+ EcdhJwe.decrypt(jwe_string, private_key: resolve_key(matching_key[:key]))
101
+ rescue EcdhJwe::DecryptionFailed => e
102
+ raise DecryptionError, "JWE decryption failed: #{e.message}"
103
+ rescue ArgumentError => e
104
+ raise DecryptionError, "Malformed JWE: #{e.message}"
105
+ end
106
+
107
+ # Validates a JWS string against keys from a JWKS endpoint.
108
+ # Performs signature verification only — callers must validate claims (aud, iss, nbf, etc.).
109
+ sig { params(jws_string: String, jwks_url: String).returns(T::Hash[String, T.untyped]) }
110
+ def self.validate_jws(jws_string, jwks_url:)
111
+ jwks_data = fetch_jwks(jwks_url)
112
+ begin
113
+ decode_with_jwks(jws_string, jwks_data)
114
+ rescue JWT::VerificationError
115
+ # Retry once with a fresh JWKS fetch in case of key rotation
116
+ jwks_data = fetch_jwks(jwks_url, force_refresh: true)
117
+ begin
118
+ decode_with_jwks(jws_string, jwks_data)
119
+ rescue JWT::DecodeError => e
120
+ raise ValidationError, "JWS validation failed: #{e.message}"
121
+ end
122
+ rescue JWT::DecodeError => e
123
+ raise ValidationError, "JWS validation failed: #{e.message}"
124
+ end
125
+ end
126
+
127
+ sig { params(jws_string: String, jwks_data: T::Hash[String, T.untyped]).returns(T::Hash[String, T.untyped]) }
128
+ private_class_method def self.decode_with_jwks(jws_string, jwks_data)
129
+ jwks = JWT::JWK::Set.new(jwks_data)
130
+ decoded = JWT.decode(jws_string, nil, true, algorithms: ALLOWED_ALGORITHMS, jwks:)
131
+ decoded.first
132
+ end
133
+
134
+ sig { params(jwe_string: String).returns(T::Hash[String, T.untyped]) }
135
+ def self.extract_jwe_header(jwe_string)
136
+ header_segment = jwe_string.split(".").first
137
+ return {} unless header_segment.present?
138
+ padded = header_segment + "=" * ((4 - header_segment.length % 4) % 4)
139
+ JSON.parse(Base64.urlsafe_decode64(padded))
140
+ end
141
+ private_class_method :extract_jwe_header
142
+
143
+ sig { params(jwe_string: String).returns(T.nilable(String)) }
144
+ def self.extract_jwe_kid(jwe_string)
145
+ extract_jwe_header(jwe_string)["kid"]
146
+ end
147
+ private_class_method :extract_jwe_kid
148
+
149
+ sig { params(jwe_string: String).returns(T.nilable(String)) }
150
+ def self.extract_jwe_alg(jwe_string)
151
+ extract_jwe_header(jwe_string)["alg"]
152
+ end
153
+ private_class_method :extract_jwe_alg
154
+
155
+ sig { params(key: T.untyped).returns(T.untyped) }
156
+ def self.resolve_key(key)
157
+ case key
158
+ when OpenSSL::PKey::PKey then key
159
+ when String then OpenSSL::PKey.read(key)
160
+ else raise DecryptionError, "Unsupported key type: #{key.class}"
161
+ end
162
+ end
163
+ private_class_method :resolve_key
164
+
165
+ sig { params(url: String, force_refresh: T::Boolean).returns(T::Hash[String, T.untyped]) }
166
+ def self.fetch_jwks(url, force_refresh: false)
167
+ cache_key = "standard_singpass:myinfo:jwks:#{Digest::SHA256.hexdigest(url)}"
168
+
169
+ Rails.cache.delete(cache_key) if force_refresh
170
+
171
+ Rails.cache.fetch(cache_key, expires_in: JWKS_CACHE_TTL) do
172
+ response = Faraday.get(url) { |req| req.options.timeout = 5; req.options.open_timeout = 3 }
173
+ raise ValidationError, "Failed to fetch JWKS: HTTP #{response.status}" unless response.success?
174
+
175
+ JSON.parse(response.body)
176
+ end
177
+ rescue Faraday::Error, JSON::ParserError => e
178
+ raise ValidationError, "Failed to fetch JWKS: #{e.message}"
179
+ end
180
+ private_class_method :fetch_jwks
181
+
182
+ # FAPI 2.0 mandates ES256 for all JWS signatures.
183
+ ALLOWED_ALGORITHMS = T.let(%w[ES256].freeze, T::Array[String])
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,47 @@
1
+ # typed: false
2
+
3
+ module StandardSingpass
4
+ module Myinfo
5
+ # Loads the test-persona fixture set used by Singpass mock-callback flows
6
+ # (E2E only) and RSpec helpers. The fixture file is the single source of
7
+ # truth so Ruby and Playwright sides can't drift.
8
+ #
9
+ # The host application can override the fixture path via
10
+ # `StandardSingpass::Myinfo.configuration.personas_path = Pathname.new(...)`.
11
+ # Without an override, the gem's bundled `fixtures/myinfo-personas.json`
12
+ # is used.
13
+ module TestPersonas
14
+ DEFAULT_KEY = "default"
15
+ GEM_FIXTURE_PATH = Pathname.new(File.expand_path("../../../fixtures/myinfo-personas.json", __dir__)).freeze
16
+
17
+ class UnknownPersona < KeyError; end
18
+
19
+ def self.fetch(key)
20
+ key = key.to_s.presence || DEFAULT_KEY
21
+ data.fetch(key) do
22
+ raise UnknownPersona, "Unknown MyInfo test persona: #{key.inspect}. Known: #{data.keys.inspect}"
23
+ end
24
+ end
25
+
26
+ def self.keys
27
+ data.keys
28
+ end
29
+
30
+ def self.data
31
+ @data ||= JSON.parse(fixture_path.read).freeze
32
+ end
33
+
34
+ # Test-only — call from a spec `before(:suite)` if you want to pick up
35
+ # mid-run edits to the fixture file.
36
+ def self.reload!
37
+ @data = nil
38
+ data
39
+ end
40
+
41
+ def self.fixture_path
42
+ configured = StandardSingpass::Myinfo.configuration.personas_path
43
+ configured ? Pathname.new(configured) : GEM_FIXTURE_PATH
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,78 @@
1
+ require "sorbet-runtime"
2
+ require "active_support/all"
3
+ require "faraday"
4
+ require "jwt"
5
+ require "openssl"
6
+ require "json"
7
+ require "base64"
8
+ require "digest"
9
+ require "securerandom"
10
+ require "aes_key_wrap"
11
+
12
+ require "standard_singpass/myinfo/error"
13
+ require "standard_singpass/myinfo/configuration"
14
+ require "standard_singpass/myinfo/ecdh_jwe"
15
+ require "standard_singpass/myinfo/security"
16
+ require "standard_singpass/myinfo/client"
17
+ require "standard_singpass/myinfo/person_data_parser"
18
+ require "standard_singpass/myinfo/jwks_generator"
19
+ require "standard_singpass/myinfo/test_personas"
20
+
21
+ module StandardSingpass
22
+ module Myinfo
23
+ class << self
24
+ def configure
25
+ yield(configuration) if block_given?
26
+ @public_jwks = nil
27
+ end
28
+
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ def reset_configuration!
34
+ @configuration = Configuration.new
35
+ @public_jwks = nil
36
+ end
37
+
38
+ def public_jwks
39
+ @public_jwks ||= build_public_jwks
40
+ end
41
+
42
+ private
43
+
44
+ def build_public_jwks
45
+ keys = []
46
+ c = configuration
47
+
48
+ if c.signing_key.present? && c.signing_kid.present?
49
+ begin
50
+ key = OpenSSL::PKey.read(c.signing_key)
51
+ jwk = JWT::JWK.new(key, kid: c.signing_kid)
52
+ exported = jwk.export(include_private: false)
53
+ exported[:use] = "sig"
54
+ exported[:alg] = "ES256"
55
+ keys << exported
56
+ rescue OpenSSL::PKey::PKeyError => e
57
+ Rails.logger.error("StandardSingpass::Myinfo: failed to load signing key: #{e.message}")
58
+ Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo", reason: "build_public_jwks_signing", kid: c.signing_kid })
59
+ end
60
+ end
61
+
62
+ Array(c.encryption_keys).each do |enc_key_config|
63
+ key = OpenSSL::PKey.read(enc_key_config[:key])
64
+ jwk = JWT::JWK.new(key, kid: enc_key_config[:kid])
65
+ exported = jwk.export(include_private: false)
66
+ exported[:use] = "enc"
67
+ exported[:alg] = "ECDH-ES+A256KW"
68
+ keys << exported
69
+ rescue OpenSSL::PKey::PKeyError => e
70
+ Rails.logger.error("StandardSingpass::Myinfo: failed to load encryption key #{enc_key_config[:kid]}: #{e.message}")
71
+ Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo", reason: "build_public_jwks_encryption", kid: enc_key_config[:kid] })
72
+ end
73
+
74
+ { keys: }
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,3 @@
1
+ module StandardSingpass
2
+ VERSION = "0.1.0"
3
+ end
@@ -0,0 +1,6 @@
1
+ require "standard_singpass/version"
2
+ require "standard_singpass/engine"
3
+ require "standard_singpass/myinfo"
4
+
5
+ module StandardSingpass
6
+ end