myinfo 0.5.4 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c7aeaabc21bdd0de155e96b9306ba1c86015f70eae7f0e1aeec8ef0c8ce0e553
4
- data.tar.gz: 26abbbb4749b9ae61c4d38ec1e4e6ab7b28dec410602b7ec448024bc0ceb726d
3
+ metadata.gz: 12a9996ed3c5052b49d43a086da72399138768ecc3e6928be0cbe152d1cf8bab
4
+ data.tar.gz: aeb4fbf81d51722a3e3994c392e3c74a89122f87169d3f317b366916bc47a97c
5
5
  SHA512:
6
- metadata.gz: 25015c46d6e06e6d9ec2405448926e9d0482c1ee9fe5d0aadea0467afe1a830518abc376beb46757c89234322df9077ea983025330744ed071e1f28dbbab7c48
7
- data.tar.gz: 9edf346e8b4da8d49cc95e6db9efc477b690b0b9bbbd6d680cf8a537c5ca38081c1b7dd2eac169b8dfd2050fc0bdf0a5028b8159c1e9850364f0dfcdd8017bfd
6
+ metadata.gz: e00d202ce7b880932543b9e4c1e9cf4ce3d6ab6cd5e0862dd7bf279512f9b91e81032da4ed777ab735dd36b5b0f790bed626c36268d366642c36a15fc2e7b227
7
+ data.tar.gz: 921b719e0b480948f4159a5ebd1645f8062966b89c1db55e536931ab0b174dc60ba997e41cc94fa6feaea1c1061d770184f64f9ddd32a273f57ff6a925b8c21f
data/README.md CHANGED
@@ -2,10 +2,65 @@
2
2
 
3
3
  ![tests](https://github.com/GovTechSG/myinfo/workflows/tests/badge.svg?branch=main)
4
4
 
5
+ # MyInfo V4
6
+
7
+ [MyInfo Documentation (Public)](https://public.cloud.myinfo.gov.sg/myinfo/api/myinfo-kyc-v4.0.html)
8
+
9
+ Note: Currently this gem only supports `public_facing = true` for MyInfo V4 API.
10
+
11
+ ## Basic Setup (Public)
12
+
13
+ 1. `bundle add myinfo`
14
+ 2. Create a `config/initializers/myinfo.rb` and add the required configuration based on your environment.
15
+ ```ruby
16
+ MyInfo.configure do |config|
17
+ config.app_id = ''
18
+ config.client_id = ''
19
+ config.client_secret = ''
20
+ config.base_url = 'test.api.myinfo.gov.sg'
21
+ config.redirect_uri = 'https://localhost:3001/callback'
22
+ config.public_facing = true
23
+ config.sandbox = false # optional, false by default
24
+ config.private_encryption_key = ''
25
+ config.private_signing_key = ''
26
+ config.authorise_jwks_base_url = "test.authorise.singpass.gov.sg"
27
+ # setup proxy here if needed
28
+ config.proxy = { address: 'proxy_address', port: 'proxy_port' } # optional, nil by default
29
+ config.gateway_url = 'https://test_gateway_url' # optional, nil by default
30
+ config.gateway_key = '44d953c796cccebcec9bdc826852857ab412fbe2' # optional, nil by default
31
+ end
32
+ ```
33
+
34
+ 3. Generate the code verifier and code challenge and manage a session to their frontend to link the code_verifier and code_challenge.
35
+ ```ruby
36
+ code_verifier, code_challenge = MyInfo::V4::Session.call
37
+ ```
38
+
39
+ 4. On callback url triggered, obtain a `MyInfo::V4::Token`. This token can only be used once.
40
+
41
+ ```ruby
42
+ key_pairs = SecurityHelper.generate_session_key_pair
43
+ response = MyInfo::V4::Token.call(
44
+ key_pairs: key_pairs,
45
+ auth_code: params[:code],
46
+ code_verifier: session[:code_verifier]
47
+ )
48
+
49
+ access_token = response.data
50
+ ```
51
+
52
+ 5. Obtain the `access_token` from the `response` and query for `MyInfo::V4::Person`:
53
+ ```ruby
54
+ response = MyInfo::V4::Person.call(key_pairs: key_pairs, access_token: access_token)
55
+ ```
56
+
57
+
58
+ # MyInfo V3
5
59
 
6
60
  [MyInfo Documentation (Public)](https://public.cloud.myinfo.gov.sg/myinfo/api/myinfo-kyc-v3.1.0.html)
7
61
 
8
62
  [MyInfo Documentation (Government)](https://public.cloud.myinfo.gov.sg/myinfo/tuo/myinfo-tuo-specs.html)
63
+
9
64
  ## Basic Setup (Public)
10
65
 
11
66
  1. `bundle add myinfo`
@@ -22,8 +77,8 @@
22
77
  config.public_cert = File.read(Rails.root.join('public_cert_location'))
23
78
  config.sandbox = false # optional, false by default
24
79
  config.proxy = { address: 'proxy_address', port: 'proxy_port' } # optional, nil by default
25
- config.gateway_url = 'https://test_gateway_url' #optional, nil by default
26
- config.gateway_key = '44d953c796cccebcec9bdc826852857ab412fbe2' #optional, nil by default
80
+ config.gateway_url = 'https://test_gateway_url' # optional, nil by default
81
+ config.gateway_key = '44d953c796cccebcec9bdc826852857ab412fbe2' # optional, nil by default
27
82
  end
28
83
  ```
29
84
 
data/lib/myinfo/errors.rb CHANGED
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MyInfo
4
- class MissingConfigurationError < StandardError; end
4
+ class MyInfoError < StandardError; end
5
5
 
6
- class UnavailableError < StandardError; end
6
+ class MissingConfigurationError < MyInfoError; end
7
+
8
+ class UnavailableError < MyInfoError; end
7
9
  end
@@ -5,10 +5,10 @@ module MyInfo
5
5
  module Attributes
6
6
  DEFAULT_VALUES = %i[name sex race dob residentialstatus email mobileno regadd].freeze
7
7
 
8
- def self.parse(attributes)
8
+ def self.parse(attributes, separator = ',')
9
9
  attributes ||= DEFAULT_VALUES
10
10
 
11
- attributes.is_a?(String) ? attributes : attributes.join(',')
11
+ attributes.is_a?(String) ? attributes : attributes.join(separator)
12
12
  end
13
13
  end
14
14
  end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ # This class is used to decrypt the JWE token received from MyInfo.
4
+ # Codes are actually extracted from JOSE gem
5
+ class JweDecryptor
6
+ DEFAULT_IV = OpenSSL::BN.new(0xA6A6A6A6A6A6A6A6).to_s(2).freeze
7
+
8
+ def initialize(key:, jwe:)
9
+ @jwe = jwe
10
+ @private_encryption_key = key
11
+ end
12
+
13
+ def decrypt
14
+ jwe_parts = @jwe.split('.')
15
+
16
+ raise ArgumentError, 'bad jwe' if jwe_parts.size != 5
17
+
18
+ protected, encoded_encrypted_key, encoded_iv, encoded_ciphertext, encoded_tag = jwe_parts
19
+
20
+ header = JSON.parse(Base64.urlsafe_decode64(protected), { symbolize_names: true })
21
+ encrypted_key = Base64.urlsafe_decode64(encoded_encrypted_key)
22
+ iv = Base64.urlsafe_decode64(encoded_iv)
23
+ ciphertext = Base64.urlsafe_decode64(encoded_ciphertext)
24
+ tag = Base64.urlsafe_decode64(encoded_tag)
25
+
26
+ key = compute_public_key(header)
27
+ cek = decrypt_key(key, encrypted_key)
28
+ plain_text = decrypt_ciphertext(cek, iv, ciphertext, tag, protected)
29
+
30
+ JWT.decode(plain_text, nil, false).first
31
+ end
32
+
33
+ private
34
+
35
+ def compute_public_key(header)
36
+ crv = 'prime256v1'
37
+ x = Base64.urlsafe_decode64(header[:epk][:x])
38
+ y = Base64.urlsafe_decode64(header[:epk][:y])
39
+
40
+ point = OpenSSL::PKey::EC::Point.new(
41
+ OpenSSL::PKey::EC::Group.new(crv),
42
+ OpenSSL::BN.new([0x04, x, y].pack('Ca*a*'), 2)
43
+ )
44
+
45
+ sequence = OpenSSL::ASN1::Sequence([
46
+ OpenSSL::ASN1::Sequence([
47
+ OpenSSL::ASN1::ObjectId('id-ecPublicKey'),
48
+ OpenSSL::ASN1::ObjectId(crv)
49
+ ]),
50
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
51
+ ])
52
+ ec = OpenSSL::PKey::EC.new(sequence.to_der)
53
+
54
+ @private_encryption_key.dh_compute_key(ec.public_key)
55
+ end
56
+
57
+ # this method decrypts the key which is encypted using ECDH-ES+A256KW algorithm, 256 bits
58
+ def decrypt_key(key, encrypted_key)
59
+ algorithm_id = 'ECDH-ES+A256KW'
60
+ hash = OpenSSL::Digest::SHA256
61
+ key_data_len = 256
62
+ supp_pub_info = [key_data_len].pack('N')
63
+
64
+ other_info = [
65
+ algorithm_id.bytesize, algorithm_id,
66
+ ''.bytesize, '',
67
+ ''.bytesize, '',
68
+ supp_pub_info,
69
+ ''
70
+ ].pack('Na*Na*Na*a*a*')
71
+ hash_len = hash.digest('').bytesize * 8
72
+ reps = (key_data_len / hash_len.to_f).ceil
73
+ if reps == 1
74
+ concatenation = [0, 0, 0, 1, key, other_info].pack('C4a*a*')
75
+ derived_key = [hash.digest(concatenation).unpack1('B*')[0...key_data_len]].pack('B*')
76
+ elsif reps > 0xFFFFFFFF
77
+ raise ArgumentError, "too many reps"
78
+ else
79
+ derived_key = derive_key(hash, 1, reps, key_data_len, [key, other_info].join, '')
80
+ end
81
+
82
+ unwrap(encrypted_key, derived_key)
83
+ end
84
+
85
+ def decrypt_ciphertext(cek, iv, ciphertext, tag, protected)
86
+ cipher = OpenSSL::Cipher.new('aes-256-gcm')
87
+ cipher.decrypt
88
+ cipher.key = cek
89
+ cipher.iv = iv
90
+ cipher.padding = 0
91
+ cipher.auth_data = protected
92
+ cipher.auth_tag = tag
93
+ cipher.update(ciphertext) + cipher.final
94
+ end
95
+
96
+ def unwrap(cipher_text, kek, iv = nil)
97
+ iv ||= DEFAULT_IV
98
+ bits = kek.bytesize * 8
99
+ unless (cipher_text.bytesize % 8).zero? && ((bits == 128) || (bits == 192) || (bits == 256))
100
+ raise ArgumentError, 'bad cipher_text, kek, or iv'
101
+ end
102
+
103
+ block_count = cipher_text.bytesize.div(8) - 1
104
+ buffer = do_unwrap(cipher_text, 5, block_count, kek, bits)
105
+ buffer_s = StringIO.new(buffer)
106
+ raise ArgumentError, 'iv does not match' unless buffer_s.read(iv.bytesize) == iv
107
+
108
+ buffer_s.read
109
+ end
110
+
111
+ def do_unwrap(buffer, j, block_count, kek, bits)
112
+ if j.negative?
113
+ buffer
114
+ else
115
+ do_unwrap(do_unwrap_step(buffer, j, block_count, block_count, kek, bits), j - 1, block_count, kek, bits)
116
+ end
117
+ end
118
+
119
+ def do_unwrap_step(buffer, j, i, block_count, kek, bits)
120
+ return buffer if i < 1
121
+
122
+ buffer_s = StringIO.new(buffer)
123
+ a0, = buffer_s.read(8).unpack('Q>')
124
+ head_size = (i - 1) * 8
125
+ head = buffer_s.read(head_size)
126
+ b0 = buffer_s.read(8)
127
+ tail = buffer_s.read
128
+ round = (block_count * j) + i
129
+ a1 = a0 ^ round
130
+ data = [a1, b0].pack('Q>a*')
131
+ a2, b1 = aes_ecb_decrypt(bits, kek, data).unpack('Q>a*')
132
+ do_unwrap_step([a2, head, b1, tail].pack('Q>a*a*a*'), j, i - 1, block_count, kek, bits)
133
+ end
134
+
135
+ def aes_ecb_decrypt(bits, key, cipher_text)
136
+ cipher = OpenSSL::Cipher::AES.new(bits, :ECB)
137
+ cipher.decrypt
138
+ cipher.key = key
139
+ cipher.padding = 0
140
+ cipher.update(cipher_text) + cipher.final
141
+ end
142
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwe'
4
+ require 'jwt'
5
+
6
+ JWT.configuration.jwk.kid_generator = JWT::JWK::Thumbprint
7
+
8
+ # Helper class for security related codes
9
+ class SecurityHelper
10
+ class << self
11
+ def generate_session_key_pair
12
+ ec = OpenSSL::PKey::EC.generate('prime256v1')
13
+
14
+ group = ec.public_key.group
15
+ point = ec.public_key
16
+ asn1 = OpenSSL::ASN1::Sequence(
17
+ [
18
+ OpenSSL::ASN1::Sequence([
19
+ OpenSSL::ASN1::ObjectId('id-ecPublicKey'),
20
+ OpenSSL::ASN1::ObjectId(group.curve_name)
21
+ ]),
22
+ OpenSSL::ASN1::BitString(point.to_octet_string(:uncompressed))
23
+ ]
24
+ )
25
+ public_key = OpenSSL::PKey::EC.new(asn1.to_der)
26
+
27
+ { private_key: ec.to_pem, public_key: public_key.to_pem }
28
+ end
29
+
30
+ def generate_dpop(url, access_token, http_method, key_pairs)
31
+ now = Time.now.to_i
32
+ payload = {
33
+ htu: url,
34
+ htm: http_method,
35
+ jti: SecureRandom.alphanumeric(40),
36
+ iat: now,
37
+ exp: now + 120
38
+ }
39
+
40
+ if access_token.present?
41
+ payload[:ath] = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token), padding: false)
42
+ end
43
+
44
+ private_key = OpenSSL::PKey.read(key_pairs[:private_key])
45
+ jwk = JWT::JWK.new(OpenSSL::PKey.read(key_pairs[:public_key]), { use: 'sig', alg: 'ES256' })
46
+
47
+ JWT.encode(payload, private_key, 'ES256', { typ: 'dpop+jwt', jwk: jwk.export })
48
+ end
49
+
50
+ def generate_client_assertion(client_id, url, thumbprint, private_signing_key)
51
+ now = Time.now.to_i
52
+ payload = {
53
+ sub: client_id,
54
+ jti: SecureRandom.alphanumeric(40),
55
+ aud: url,
56
+ iss: client_id,
57
+ iat: now,
58
+ exp: now + 300,
59
+ cnf: {
60
+ jkt: thumbprint
61
+ }
62
+ }
63
+
64
+ headers = {
65
+ typ: 'JWT',
66
+ alg: 'ES256'
67
+ }
68
+
69
+ JWT.encode(payload, private_signing_key, 'ES256', headers)
70
+ end
71
+
72
+ def thumbprint(key)
73
+ jwk = JWT::JWK.new(OpenSSL::PKey.read(key), { use: 'sig', alg: 'ES256' })
74
+ jwk_hash = jwk.export
75
+ jwk_hash[:kid]
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module MyInfo
6
+ module V4
7
+ # Base API class
8
+ class Api
9
+ extend Callable
10
+
11
+ attr_reader :key_pairs, :thumbprint
12
+
13
+ def initialize(key_pairs:)
14
+ @key_pairs = key_pairs
15
+ @thumbprint = SecurityHelper.thumbprint(key_pairs[:public_key])
16
+ end
17
+
18
+ def endpoint
19
+ raise NotImplementedError, 'abstract'
20
+ end
21
+
22
+ def params(_args)
23
+ raise NotImplementedError, 'abstract'
24
+ end
25
+
26
+ def slug
27
+ ''
28
+ end
29
+
30
+ def call
31
+ yield
32
+ rescue StandardError => e
33
+ Response.new(success: false, data: e)
34
+ end
35
+
36
+ def http_method
37
+ 'GET'
38
+ end
39
+
40
+ def support_gzip?
41
+ false
42
+ end
43
+
44
+ def header(access_token: nil)
45
+ {
46
+ 'Content-Type' => 'application/json',
47
+ 'Accept' => 'application/json',
48
+ 'Cache-Control' => 'no-cache'
49
+ }.tap do |values|
50
+ values['x-api-key'] = config.gateway_key if config.gateway_key.present?
51
+
52
+ unless config.sandbox?
53
+ values['Authorization'] = auth_header(access_token: access_token) if access_token.present?
54
+ values['dpop'] = SecurityHelper.generate_dpop(endpoint, access_token, http_method, key_pairs)
55
+ end
56
+
57
+ if support_gzip?
58
+ values['Accept-Encoding'] = 'gzip'
59
+ values['Content-Encoding'] = 'gzip'
60
+ end
61
+ end
62
+ end
63
+
64
+ def api_path
65
+ path = config.gateway_path.present? ? "#{config.gateway_path}/" : ''
66
+ "#{path}#{slug}"
67
+ end
68
+
69
+ def parse_response(response)
70
+ if response.code == '200'
71
+ yield
72
+ elsif errors.include?(response.code)
73
+ json = JSON.parse(response.body)
74
+
75
+ Response.new(success: false, data: "#{json['code']} - #{json['message']}")
76
+ else
77
+ Response.new(success: false, data: "#{response.code} - #{response.body}")
78
+ end
79
+ end
80
+
81
+ protected
82
+
83
+ def http
84
+ url = config.gateway_host || config.base_url
85
+ @http ||= if config.proxy.blank?
86
+ Net::HTTP.new(url, 443)
87
+ else
88
+ Net::HTTP.new(url, 443, config.proxy[:address], config.proxy[:port])
89
+ end
90
+
91
+ @http.use_ssl = true
92
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
93
+
94
+ @http
95
+ end
96
+
97
+ def jwks_http
98
+ url = config.gateway_host || config.authorise_jwks_base_url
99
+ @jwks_http_client = if config.proxy.blank?
100
+ Net::HTTP.new(url, 443)
101
+ else
102
+ Net::HTTP.new(url, 443, config.proxy[:address], config.proxy[:port])
103
+ end
104
+
105
+ @jwks_http_client.use_ssl = true
106
+ @jwks_http_client.verify_mode = OpenSSL::SSL::VERIFY_PEER
107
+
108
+ @jwks_http_client
109
+ end
110
+
111
+ def config
112
+ MyInfo.configuration
113
+ end
114
+
115
+ def errors
116
+ %w[400 401]
117
+ end
118
+
119
+ private
120
+
121
+ def private_encryption_key
122
+ raise MissingConfigurationError, :private_encryption_key if config.private_encryption_key.blank?
123
+
124
+ OpenSSL::PKey::EC.new(config.private_encryption_key)
125
+ end
126
+
127
+ def private_signing_key
128
+ raise MissingConfigurationError, :private_signing_key if config.private_signing_key.blank?
129
+
130
+ OpenSSL::PKey::EC.new(config.private_signing_key)
131
+ end
132
+
133
+ def to_query(headers)
134
+ headers.sort_by { |k, v| [k.to_s, v] }
135
+ .map { |arr| arr.join('=') }
136
+ .join('&')
137
+ end
138
+
139
+ def auth_header(access_token: nil)
140
+ "DPoP #{access_token}"
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyInfo
4
+ module V4
5
+ # https://public.cloud.myinfo.gov.sg/myinfo/api/myinfo-kyc-v4.0.html#operation/getauthorize
6
+ class AuthoriseUrl
7
+ extend Callable
8
+
9
+ attr_accessor :attributes, :code_challenge, :purpose, :nric_fin
10
+
11
+ def initialize(purpose:, code_challenge:, nric_fin: nil, attributes: nil)
12
+ @attributes = Attributes.parse(attributes)
13
+ @code_challenge = code_challenge
14
+ @purpose = purpose
15
+ @nric_fin = nric_fin
16
+ end
17
+
18
+ def call
19
+ query_string = {
20
+ purpose_id: purpose,
21
+ response_type: 'code',
22
+ scope: attributes,
23
+ code_challenge: code_challenge,
24
+ code_challenge_method: 'S256',
25
+ redirect_uri: config.redirect_uri,
26
+ client_id: config.client_id
27
+ }.compact.to_param
28
+
29
+ endpoint(query_string)
30
+ end
31
+
32
+ def endpoint(query_string)
33
+ if config.public?
34
+ "#{config.base_url_with_protocol}/#{slug}?#{query_string}"
35
+ else
36
+ # TODO: update url for gov version
37
+ "#{config.base_url_with_protocol}/#{slug}/#{nric_fin}?#{query_string}"
38
+ end
39
+ end
40
+
41
+ def slug
42
+ slug_prefix = config.public? ? 'com' : 'gov'
43
+
44
+ "#{slug_prefix}/v4/authorize"
45
+ end
46
+
47
+ def config
48
+ MyInfo.configuration
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jwt'
4
+
5
+ module MyInfo
6
+ module V4
7
+ # Calls the Person API
8
+ class Person < Api
9
+ attr_reader :access_token, :decoded_token, :attributes, :user_identifier
10
+
11
+ def initialize(key_pairs:, access_token:, attributes: nil)
12
+ super(key_pairs: key_pairs)
13
+ @access_token = access_token
14
+ @decoded_token = verify_jws(access_token).first
15
+ @user_identifier = @decoded_token['sub']
16
+
17
+ @attributes = Attributes.parse(attributes, ' ')
18
+ end
19
+
20
+ def call
21
+ super do
22
+ # call person API using, uinfin, access token, ephemeral key_pairs
23
+ headers = header(access_token: access_token).merge({ 'Content-Type' => 'application/x-www-form-urlencoded' })
24
+ endpoint_url = "/#{api_path}?#{params.to_query}"
25
+
26
+ response = http.request_get(endpoint_url, headers)
27
+ parse_response(response)
28
+ end
29
+ end
30
+
31
+ def slug
32
+ slug_prefix = config.public? ? 'com' : 'gov'
33
+
34
+ "#{slug_prefix}/v4/person/#{user_identifier}"
35
+ end
36
+
37
+ def endpoint
38
+ "#{config.base_url_with_protocol}/#{slug}"
39
+ end
40
+
41
+ def support_gzip?
42
+ true
43
+ end
44
+
45
+ def params
46
+ {
47
+ scope: attributes,
48
+ subentity_id: config.subentity_id
49
+ }.compact
50
+ end
51
+
52
+ def errors
53
+ %w[401 403 404]
54
+ end
55
+
56
+ def parse_response(response)
57
+ super do
58
+ json = JweDecryptor.new(key: private_encryption_key, jwe: response.body).decrypt
59
+ Response.new(success: true, data: json)
60
+ end
61
+ end
62
+
63
+ private
64
+
65
+ def verify_jws(access_token)
66
+ response = jwks_http.request_get('/.well-known/keys.json')
67
+
68
+ jwks_hash = JSON.parse(response.body)
69
+ jwks = JWT::JWK::Set.new(jwks_hash)
70
+ jwks.filter! { |key| key[:use] == 'sig' }
71
+ algorithms = jwks.map { |key| key[:alg] }.compact.uniq
72
+
73
+ JWT.decode(access_token, nil, true, algorithms: algorithms, jwks: jwks)
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyInfo
4
+ module V4
5
+ # Simple response wrapper
6
+ class Response
7
+ attr_reader :data
8
+
9
+ def initialize(success:, data:)
10
+ @success = success
11
+
12
+ if data.is_a?(StandardError)
13
+ @data = data.message
14
+ @exception = true
15
+ else
16
+ @data = data
17
+ @exception = false
18
+ end
19
+ end
20
+
21
+ def exception?
22
+ @exception
23
+ end
24
+
25
+ def success?
26
+ @success
27
+ end
28
+
29
+ def to_s
30
+ data
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module MyInfo
6
+ module V4
7
+ # Class to generate code_verifier and code_challenge to client application
8
+ class Session
9
+ extend Callable
10
+
11
+ def call
12
+ code_verifier = SecureRandom.hex(32)
13
+
14
+ sha256_encoded_code_verifier = Digest::SHA256.digest code_verifier
15
+ code_challenge = Base64.urlsafe_encode64(sha256_encoded_code_verifier, padding: false)
16
+
17
+ [code_verifier, code_challenge]
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MyInfo
4
+ module V4
5
+ # Called after authorise to obtain a token for API calls
6
+ class Token < Api
7
+ attr_accessor :auth_code, :code_verifier
8
+
9
+ def initialize(key_pairs:, auth_code:, code_verifier:)
10
+ super(key_pairs: key_pairs)
11
+ @auth_code = auth_code
12
+ @code_verifier = code_verifier
13
+ end
14
+
15
+ def call
16
+ super do
17
+ headers = header.merge({ 'Content-Type' => 'application/x-www-form-urlencoded' })
18
+ response = http.request_post("/#{api_path}", params.to_param, headers)
19
+
20
+ parse_response(response)
21
+ end
22
+ end
23
+
24
+ def http_method
25
+ 'POST'
26
+ end
27
+
28
+ def endpoint
29
+ "#{config.base_url_with_protocol}/#{slug}"
30
+ end
31
+
32
+ def slug
33
+ slug_prefix = config.public? ? 'com' : 'gov'
34
+
35
+ "#{slug_prefix}/v4/token"
36
+ end
37
+
38
+ def params
39
+ {
40
+ code: auth_code,
41
+ grant_type: 'authorization_code',
42
+ client_id: config.client_id,
43
+ redirect_uri: config.redirect_uri,
44
+ client_assertion: SecurityHelper.generate_client_assertion(
45
+ config.client_id,
46
+ endpoint,
47
+ thumbprint,
48
+ private_signing_key
49
+ ),
50
+ client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
51
+ code_verifier: code_verifier
52
+ }.compact
53
+ end
54
+
55
+ def parse_response(response)
56
+ super do
57
+ json = JSON.parse(response.body)
58
+ access_token = json['access_token']
59
+
60
+ Response.new(success: true, data: access_token)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module MyInfo
4
4
  module Version
5
- WRAPPER_VERSION = '0.5.4'
5
+ WRAPPER_VERSION = '0.6.0'
6
6
  end
7
7
  end
data/lib/myinfo.rb CHANGED
@@ -8,6 +8,8 @@ require_relative 'myinfo/errors'
8
8
 
9
9
  require_relative 'myinfo/helpers/callable'
10
10
  require_relative 'myinfo/helpers/attributes'
11
+ require_relative 'myinfo/helpers/jwe_decryptor'
12
+ require_relative 'myinfo/helpers/security_helper'
11
13
 
12
14
  require_relative 'myinfo/v3/response'
13
15
  require_relative 'myinfo/v3/api'
@@ -16,6 +18,13 @@ require_relative 'myinfo/v3/person'
16
18
  require_relative 'myinfo/v3/person_basic'
17
19
  require_relative 'myinfo/v3/authorise_url'
18
20
 
21
+ require_relative 'myinfo/v4/response'
22
+ require_relative 'myinfo/v4/api'
23
+ require_relative 'myinfo/v4/session'
24
+ require_relative 'myinfo/v4/authorise_url'
25
+ require_relative 'myinfo/v4/token'
26
+ require_relative 'myinfo/v4/person'
27
+
19
28
  # Base MyInfo class
20
29
  module MyInfo
21
30
  class << self
@@ -29,8 +38,20 @@ module MyInfo
29
38
 
30
39
  # Configuration to set various properties needed to use MyInfo
31
40
  class Configuration
32
- attr_accessor :singpass_eservice_id, :app_id, :client_id, :proxy, :private_key, :public_cert, :client_secret,
33
- :redirect_uri, :gateway_url, :gateway_key
41
+ attr_accessor :singpass_eservice_id,
42
+ :app_id,
43
+ :client_id,
44
+ :proxy,
45
+ :private_key,
46
+ :public_cert,
47
+ :client_secret,
48
+ :redirect_uri,
49
+ :gateway_url,
50
+ :gateway_key,
51
+ :authorise_jwks_base_url, # added for v4
52
+ :subentity_id, # added for v4, UEN of SaaS partner's client that will be receiving the person data.
53
+ :private_encryption_key, # added for V4
54
+ :private_signing_key # added for V4
34
55
 
35
56
  attr_reader :base_url
36
57
  attr_writer :public_facing, :sandbox
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: myinfo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.4
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lim Yao Jie
8
8
  - Eileen Kang
9
- autorequire:
9
+ autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2022-11-22 00:00:00.000000000 Z
12
+ date: 2023-12-26 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: jwe
@@ -31,28 +31,28 @@ dependencies:
31
31
  requirements:
32
32
  - - "~>"
33
33
  - !ruby/object:Gem::Version
34
- version: '2.2'
34
+ version: '2.7'
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - "~>"
40
40
  - !ruby/object:Gem::Version
41
- version: '2.2'
41
+ version: '2.7'
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: rails
44
44
  requirement: !ruby/object:Gem::Requirement
45
45
  requirements:
46
46
  - - "~>"
47
47
  - !ruby/object:Gem::Version
48
- version: '6'
48
+ version: '7'
49
49
  type: :development
50
50
  prerelease: false
51
51
  version_requirements: !ruby/object:Gem::Requirement
52
52
  requirements:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
- version: '6'
55
+ version: '7'
56
56
  - !ruby/object:Gem::Dependency
57
57
  name: rake
58
58
  requirement: !ruby/object:Gem::Requirement
@@ -101,14 +101,28 @@ dependencies:
101
101
  requirements:
102
102
  - - "~>"
103
103
  - !ruby/object:Gem::Version
104
- version: '1.8'
104
+ version: '1.59'
105
105
  type: :development
106
106
  prerelease: false
107
107
  version_requirements: !ruby/object:Gem::Requirement
108
108
  requirements:
109
109
  - - "~>"
110
110
  - !ruby/object:Gem::Version
111
- version: '1.8'
111
+ version: '1.59'
112
+ - !ruby/object:Gem::Dependency
113
+ name: rubocop-rspec
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - "~>"
117
+ - !ruby/object:Gem::Version
118
+ version: '2.25'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - "~>"
124
+ - !ruby/object:Gem::Version
125
+ version: '2.25'
112
126
  - !ruby/object:Gem::Dependency
113
127
  name: simplecov
114
128
  requirement: !ruby/object:Gem::Requirement
@@ -148,19 +162,27 @@ files:
148
162
  - lib/myinfo/errors.rb
149
163
  - lib/myinfo/helpers/attributes.rb
150
164
  - lib/myinfo/helpers/callable.rb
165
+ - lib/myinfo/helpers/jwe_decryptor.rb
166
+ - lib/myinfo/helpers/security_helper.rb
151
167
  - lib/myinfo/v3/api.rb
152
168
  - lib/myinfo/v3/authorise_url.rb
153
169
  - lib/myinfo/v3/person.rb
154
170
  - lib/myinfo/v3/person_basic.rb
155
171
  - lib/myinfo/v3/response.rb
156
172
  - lib/myinfo/v3/token.rb
173
+ - lib/myinfo/v4/api.rb
174
+ - lib/myinfo/v4/authorise_url.rb
175
+ - lib/myinfo/v4/person.rb
176
+ - lib/myinfo/v4/response.rb
177
+ - lib/myinfo/v4/session.rb
178
+ - lib/myinfo/v4/token.rb
157
179
  - lib/myinfo/version.rb
158
180
  homepage: https://github.com/GovTechSG/myinfo-rails
159
181
  licenses:
160
182
  - MIT
161
183
  metadata:
162
184
  rubygems_mfa_required: 'true'
163
- post_install_message:
185
+ post_install_message:
164
186
  rdoc_options: []
165
187
  require_paths:
166
188
  - lib
@@ -175,8 +197,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
175
197
  - !ruby/object:Gem::Version
176
198
  version: '0'
177
199
  requirements: []
178
- rubygems_version: 3.0.3.1
179
- signing_key:
200
+ rubygems_version: 3.4.6
201
+ signing_key:
180
202
  specification_version: 4
181
203
  summary: Rails wrapper for MyInfo API
182
204
  test_files: []