Class: StandardSingpass::Myinfo::Client

Inherits:
Object
  • Object
show all
Extended by:
T::Sig
Defined in:
lib/standard_singpass/myinfo/client.rb

Constant Summary collapse

CLIENT_ASSERTION_TYPE =
T.let("urn:ietf:params:oauth:client-assertion-type:jwt-bearer", String)
REQUIRED_CONFIG =
T.let(%i[client_id redirect_url scope token_url authorize_url
par_url signing_key signing_kid jwks_url issuer
userinfo_url userinfo_jwks_url].freeze, T::Array[Symbol])
CLOCK_SKEW_LEEWAY =

Allow up to 30 seconds of clock skew for token expiry checks (RFC 7519 §4.1.4)

T.let(30, Integer)
IAT_MAX_AGE =

Maximum age for iat (issued-at) claim: reject tokens issued more than 5 minutes ago

T.let(300, Integer)

Instance Method Summary collapse

Constructor Details

#initialize(config = {}) ⇒ Client

Returns a new instance of Client.



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# File 'lib/standard_singpass/myinfo/client.rb', line 21

def initialize(config = {})
  c = StandardSingpass::Myinfo.configuration
  @client_id = T.let(config[:client_id] || c.client_id, T.nilable(String))
  @redirect_url = T.let(config[:redirect_url] || c.redirect_url, T.nilable(String))
  @scope = T.let(config[:scope] || c.scope, T.nilable(String))
  @token_url = T.let(config[:token_url] || c.token_url, T.nilable(String))
  @userinfo_url = T.let(config[:userinfo_url] || c.userinfo_url, T.nilable(String))
  @authorize_url = T.let(config[:authorize_url] || c.authorize_url, T.nilable(String))
  @par_url = T.let(config[:par_url] || c.par_url, T.nilable(String))
  @signing_key = T.let(config[:signing_key] || c.signing_key, T.nilable(String))
  @signing_kid = T.let(config[:signing_kid] || c.signing_kid, T.nilable(String))
  @encryption_keys = T.let(config[:encryption_keys] || c.encryption_keys || [], T::Array[T::Hash[Symbol, T.untyped]])
  @jwks_url = T.let(config[:jwks_url] || c.jwks_url, T.nilable(String))
  @issuer = T.let(config[:issuer] || c.issuer, T.nilable(String))
  @userinfo_jwks_url = T.let(config[:userinfo_jwks_url] || c.userinfo_jwks_url, T.nilable(String))
  @minimum_acr = T.let(config[:minimum_acr] || c.minimum_acr, T.nilable(String))
  @network_wrapper = T.let(config[:network_wrapper] || c.network_wrapper, T.untyped)
  @http_connection = T.let(nil, T.nilable(Faraday::Connection))

  validate_config!
end

Instance Method Details

#build_authorize_redirect(request_uri:) ⇒ Object



92
93
94
95
96
97
98
99
# File 'lib/standard_singpass/myinfo/client.rb', line 92

def build_authorize_redirect(request_uri:)
  params = {
    client_id: @client_id,
    request_uri:
  }

  "#{@authorize_url}?#{URI.encode_www_form(params)}"
end

#get_person_data(auth_code:, code_verifier:, dpop_key_pair:, nonce: nil) ⇒ Object



102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/standard_singpass/myinfo/client.rb', line 102

def get_person_data(auth_code:, code_verifier:, dpop_key_pair:, nonce: nil)
  token_data = exchange_token(auth_code:, code_verifier:, dpop_key_pair:)
  id_token_payload = validate_id_token(token_data[:id_token], nonce:)

  person_data = fetch_userinfo(access_token: token_data[:access_token], dpop_key_pair:)

  # `acr` is the Authentication Context Class Reference — Singpass FAPI 2.0
  # uses it to communicate which assurance level the user authenticated at
  # (e.g. password+OTP vs. biometrics). Surface it so the callback can
  # persist it for audit. Optional per OIDC core; nil when not present.
  { person_data:, id_token_acr: id_token_payload["acr"] }
end

#push_authorization_request(code_challenge:, state:, nonce:, dpop_key_pair:) ⇒ Object



44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/standard_singpass/myinfo/client.rb', line 44

def push_authorization_request(code_challenge:, state:, nonce:, dpop_key_pair:)
  body = {
    response_type: "code",
    client_id: @client_id,
    redirect_uri: @redirect_url,
    scope: @scope,
    code_challenge:,
    code_challenge_method: "S256",
    state:,
    nonce:,
    client_assertion_type: CLIENT_ASSERTION_TYPE,
    client_assertion: Security.build_client_assertion(
      client_id: T.must(@client_id),
      audience: T.must(@issuer),
      signing_key: T.must(@signing_key),
      signing_kid: T.must(@signing_kid)
    )
  }

  # Ask Singpass to enforce a minimum assurance level upstream. The same
  # config attribute also drives downstream validation of the returned
  # id_token (validate_id_token_acr) — defense in depth. When unset, we
  # skip both the request parameter and the validator entirely; useful
  # for sandbox personas that may return non-conformant acr values.
  # Concrete URN per Singpass: `urn:singpass:authentication:loa:N` (N
  # is 2 or 3; Singpass never issues below LOA 2). `.to_s.strip` mirrors
  # the validator so whitespace-only values are treated as unset and any
  # value sent over the wire is trimmed.
  min_acr = @minimum_acr.to_s.strip
  body[:acr_values] = min_acr unless min_acr.empty?

  with_network_wrapper do
    response = http_connection.post(@par_url) do |req|
      req.headers["DPoP"] = Security.build_dpop_proof(
        http_method: "POST",
        url: T.must(@par_url),
        key_pair: dpop_key_pair
      )
      req.headers["Content-Type"] = "application/x-www-form-urlencoded"
      req.body = URI.encode_www_form(body)
    end
    handle_par_response(response)
  end
rescue Faraday::Error => e
  raise PARError, "PAR endpoint unreachable: #{e.class}"
end