Class: StandardSingpass::Myinfo::Configuration

Inherits:
Object
  • Object
show all
Defined in:
lib/standard_singpass/myinfo/configuration.rb

Constant Summary collapse

DEFAULT_SCOPE =

Default MyInfo scope — keep aligned with the Singpass developer-portal approval list. Entries on separate lines so diffs against the portal’s ordered dump are reviewable line-by-line. Joined into a single space-delimited string before sending to PAR.

%w[
  openid
  aliasname
  cpfbalances.oa
  cpfcontributions
  cpfemployers
  cpfhousingwithdrawal
  dob
  email
  employment
  employmentsector
  hanyupinyinaliasname
  hanyupinyinname
  hdbownership.address
  hdbownership.balanceloanrepayment
  hdbownership.hdbtype
  hdbownership.loangranted
  hdbownership.monthlyloaninstalment
  hdbownership.noofowners
  hdbownership.outstandinginstalment
  hdbownership.outstandingloanbalance
  hdbtype
  housingtype
  marital
  marriedname
  mobileno
  name
  nationality
  noa
  noa-basic
  noahistory
  noahistory-basic
  occupation
  ownerprivate
  passexpirydate
  passstatus
  passtype
  race
  regadd
  residentialstatus
  sex
  uinfin
  vehicles.effectiveownership
].join(" ").freeze
PRODUCTION_ENDPOINTS =

The categories and their lender-underwriting purpose (PDPA §18, Purpose Limitation):

Identity        — uinfin, name, alias names, dob, sex, race,
                  nationality, residentialstatus → KYC, contracts
Pass (FIN-only) — passtype, passstatus, passexpirydate,
                  employmentsector → tenure-vs-pass-expiry, eligibility
Address         — regadd, hdbtype, housingtype → KYC + income proxy
Contact         — mobileno, email → OTP, mailers
Family          — marital → soft underwriting signal
Income          — noa, noa-basic, noahistory, noahistory-basic,
                  cpfcontributions → MAS TDSR input
Employment      — employment, occupation, cpfemployers
                  → continuity + employer stability
Assets          — cpfbalances.oa (only OA — MA/SA/RA are ring-fenced
                  and not lender-relevant), ownerprivate
Liabilities     — cpfhousingwithdrawal, hdbownership.* (8 sub-fields)
                  → TDSR housing component
Vehicle         — vehicles.effectiveownership (asset/liability hint;
                  full vehicle details deliberately not requested)

‘cpfbalances.oa`, `hdbownership.*`, and `vehicles.effectiveownership` use FAPI 2.0 sub-attribute scope notation — sharper data minimisation than parent-keyword grants.

{
  authorize_url:     "https://id.singpass.gov.sg/fapi/auth",
  par_url:           "https://id.singpass.gov.sg/fapi/par",
  token_url:         "https://id.singpass.gov.sg/fapi/token",
  jwks_url:          "https://id.singpass.gov.sg/.well-known/keys",
  issuer:            "https://id.singpass.gov.sg/fapi",
  userinfo_url:      "https://id.singpass.gov.sg/fapi/userinfo",
  userinfo_jwks_url: "https://id.singpass.gov.sg/.well-known/keys"
}.freeze
STAGING_ENDPOINTS =
{
  authorize_url:     "https://stg-id.singpass.gov.sg/fapi/auth",
  par_url:           "https://stg-id.singpass.gov.sg/fapi/par",
  token_url:         "https://stg-id.singpass.gov.sg/fapi/token",
  jwks_url:          "https://stg-id.singpass.gov.sg/.well-known/keys",
  issuer:            "https://stg-id.singpass.gov.sg/fapi",
  userinfo_url:      "https://stg-id.singpass.gov.sg/fapi/userinfo",
  userinfo_jwks_url: "https://stg-id.singpass.gov.sg/.well-known/keys"
}.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeConfiguration

Returns a new instance of Configuration.



128
129
130
131
132
133
134
# File 'lib/standard_singpass/myinfo/configuration.rb', line 128

def initialize
  self.environment = :staging
  @scope = DEFAULT_SCOPE
  @encryption_keys = []
  @network_wrapper = ->(&block) { block.call }
  @mock_mode = false
end

Instance Attribute Details

#authorize_urlObject

Returns the value of attribute authorize_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def authorize_url
  @authorize_url
end

#client_idObject

Returns the value of attribute client_id.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def client_id
  @client_id
end

#encryption_keysObject

Returns the value of attribute encryption_keys.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def encryption_keys
  @encryption_keys
end

#environmentObject

Returns the value of attribute environment.



148
149
150
# File 'lib/standard_singpass/myinfo/configuration.rb', line 148

def environment
  @environment
end

#issuerObject

Returns the value of attribute issuer.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def issuer
  @issuer
end

#jwks_urlObject

Returns the value of attribute jwks_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def jwks_url
  @jwks_url
end

#minimum_acrObject

Returns the value of attribute minimum_acr.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def minimum_acr
  @minimum_acr
end

#mock_modeObject

Returns the value of attribute mock_mode.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def mock_mode
  @mock_mode
end

#network_wrapperObject

Returns the value of attribute network_wrapper.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def network_wrapper
  @network_wrapper
end

#par_urlObject

Returns the value of attribute par_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def par_url
  @par_url
end

#personas_pathObject

Returns the value of attribute personas_path.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def personas_path
  @personas_path
end

#redirect_urlObject

Returns the value of attribute redirect_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def redirect_url
  @redirect_url
end

#scopeObject

Returns the value of attribute scope.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def scope
  @scope
end

#signing_keyObject

Returns the value of attribute signing_key.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def signing_key
  @signing_key
end

#signing_kidObject

Returns the value of attribute signing_kid.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def signing_kid
  @signing_kid
end

#token_urlObject

Returns the value of attribute token_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def token_url
  @token_url
end

#userinfo_jwks_urlObject

Returns the value of attribute userinfo_jwks_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def userinfo_jwks_url
  @userinfo_jwks_url
end

#userinfo_urlObject

Returns the value of attribute userinfo_url.



122
123
124
# File 'lib/standard_singpass/myinfo/configuration.rb', line 122

def userinfo_url
  @userinfo_url
end

Instance Method Details

#private_jwks_json=(jwks_json) ⇒ Object

Accepts the raw JWKS JSON string and populates signing_key, signing_kid, and encryption_keys. Logs and reports issues via Rails.logger / Rails.error rather than raising — a malformed JWKS silently degrades the Singpass widget at runtime, but the host should boot regardless.



154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/standard_singpass/myinfo/configuration.rb', line 154

def private_jwks_json=(jwks_json)
  @encryption_keys = []
  @signing_key = nil
  @signing_kid = nil

  if jwks_json.nil? || jwks_json.to_s.strip.empty?
    return if mock_mode || (defined?(Rails) && Rails.env.test?)
    Rails.logger.warn("StandardSingpass::Myinfo: private_jwks_json is not set — Singpass flow will fail at first request")
    return
  end

  jwks = JSON.parse(jwks_json)
  raise TypeError, "private_jwks_json must be a JSON object with a \"keys\" array, got #{jwks.class}" unless jwks.is_a?(Hash)
  keys = jwks["keys"] || []

  sig_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "sig" }
  Rails.logger.warn("StandardSingpass::Myinfo: multiple sig keys in private_jwks_json — using first") if sig_jwks.size > 1
  sig_jwk = sig_jwks.first
  if sig_jwk
    @signing_kid = sig_jwk["kid"]
    @signing_key = jwk_to_private_pem(sig_jwk, role: "signing")
    # Keep paired: a nil signing_key with a populated signing_kid is
    # confusing in console triage (which is the scenario this method is
    # trying to help with).
    @signing_kid = nil unless @signing_key
  elsif !mock_mode && !(defined?(Rails) && Rails.env.test?)
    Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"sig\"")
  end

  enc_jwks = keys.select { |k| k.is_a?(Hash) && k["use"] == "enc" }
  @encryption_keys = enc_jwks.filter_map do |enc_jwk|
    pem = jwk_to_private_pem(enc_jwk, role: "encryption")
    next unless pem
    { kid: enc_jwk["kid"], key: pem }
  end
  # Distinguish the two empty-state cases: missing entirely (operator
  # forgot to include enc keys) vs all-rejected (every enc key was
  # public-only or otherwise unloadable). The latter is the trap the
  # rest of this method is built to catch.
  if @encryption_keys.empty? && !mock_mode && !(defined?(Rails) && Rails.env.test?)
    if enc_jwks.empty?
      Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json contains no key with \"use\":\"enc\"")
    else
      Rails.logger.error("StandardSingpass::Myinfo: private_jwks_json has \"use\":\"enc\" keys but none are usable (all public-only or invalid)")
    end
  end
rescue JSON::ParserError, TypeError => e
  # JSON::ParserError: not valid JSON. TypeError: valid JSON but wrong
  # shape (e.g. an array, a string) — caught explicitly so an operator
  # who pastes the wrong file doesn't see a bare TypeError escape.
  # Reported because a malformed private JWKS silently degrades the
  # Singpass widget — the request that finally fails will report, but by
  # then customers have hit the broken page.
  Rails.logger.error("StandardSingpass::Myinfo: failed to parse private_jwks_json: #{e.class}: #{e.message}")
  Rails.error.report(e, handled: true, context: { component: "StandardSingpass::Myinfo::Configuration", reason: "parse_private_jwks" }) if defined?(Rails.error)
  @encryption_keys = []
end