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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +33 -0
- data/MIT-LICENSE +20 -0
- data/README.md +116 -0
- data/Rakefile +8 -0
- data/fixtures/myinfo-personas.json +250 -0
- data/lib/generators/standard_singpass/install/install_generator.rb +45 -0
- data/lib/generators/standard_singpass/install/templates/initializer.rb.erb +57 -0
- data/lib/standard_singpass/engine.rb +17 -0
- data/lib/standard_singpass/myinfo/client.rb +429 -0
- data/lib/standard_singpass/myinfo/configuration.rb +233 -0
- data/lib/standard_singpass/myinfo/ecdh_jwe.rb +350 -0
- data/lib/standard_singpass/myinfo/error.rb +14 -0
- data/lib/standard_singpass/myinfo/jwks_generator.rb +116 -0
- data/lib/standard_singpass/myinfo/person_data_parser.rb +356 -0
- data/lib/standard_singpass/myinfo/security.rb +186 -0
- data/lib/standard_singpass/myinfo/test_personas.rb +47 -0
- data/lib/standard_singpass/myinfo.rb +78 -0
- data/lib/standard_singpass/version.rb +3 -0
- data/lib/standard_singpass.rb +6 -0
- data/lib/tasks/standard_singpass.rake +71 -0
- metadata +179 -0
|
@@ -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
|