myinfo 0.5.4 → 0.6.0

Sign up to get free protection for your applications and to get access to all the features.
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: []