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,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
|