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.



143
144
145
146
147
148
149
150
# File 'lib/standard_singpass/myinfo/configuration.rb', line 143

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

Instance Attribute Details

#authorize_urlObject

Returns the value of attribute authorize_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def authorize_url
  @authorize_url
end

#client_idObject

Returns the value of attribute client_id.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def client_id
  @client_id
end

#encryption_keysObject

Returns the value of attribute encryption_keys.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def encryption_keys
  @encryption_keys
end

#environmentObject

Returns the value of attribute environment.



164
165
166
# File 'lib/standard_singpass/myinfo/configuration.rb', line 164

def environment
  @environment
end

#issuerObject

Returns the value of attribute issuer.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def issuer
  @issuer
end

#jwks_urlObject

Returns the value of attribute jwks_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def jwks_url
  @jwks_url
end

#minimum_acrObject

Returns the value of attribute minimum_acr.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def minimum_acr
  @minimum_acr
end

#mock_modeObject

Returns the value of attribute mock_mode.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def mock_mode
  @mock_mode
end

#network_wrapperObject

Returns the value of attribute network_wrapper.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def network_wrapper
  @network_wrapper
end

#par_urlObject

Returns the value of attribute par_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def par_url
  @par_url
end

#personas_pathObject

Returns the value of attribute personas_path.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def personas_path
  @personas_path
end

#production_env_detectorObject

Optional callable deciding whether the current deploy is real production, for the purpose of MockModeGuard. Takes no args, returns a boolean. When nil (the default) the gem falls back to Rails.env.production?, so existing consumers are unchanged.

Hosts that distinguish a physical deploy environment from RAILS_ENV — staging and preview boxes routinely run RAILS_ENV=production — can supply -> { AppEnv.production? } so mock mode stays legal on those while still refusing to boot on real production.



141
142
143
# File 'lib/standard_singpass/myinfo/configuration.rb', line 141

def production_env_detector
  @production_env_detector
end

#redirect_urlObject

Returns the value of attribute redirect_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def redirect_url
  @redirect_url
end

#scopeObject

Returns the value of attribute scope.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def scope
  @scope
end

#signing_keyObject

Returns the value of attribute signing_key.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def signing_key
  @signing_key
end

#signing_kidObject

Returns the value of attribute signing_kid.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def signing_kid
  @signing_kid
end

#token_urlObject

Returns the value of attribute token_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def token_url
  @token_url
end

#userinfo_jwks_urlObject

Returns the value of attribute userinfo_jwks_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

def userinfo_jwks_url
  @userinfo_jwks_url
end

#userinfo_urlObject

Returns the value of attribute userinfo_url.



126
127
128
# File 'lib/standard_singpass/myinfo/configuration.rb', line 126

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.



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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/standard_singpass/myinfo/configuration.rb', line 170

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