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 +4 -4
- data/README.md +57 -2
- data/lib/myinfo/errors.rb +4 -2
- data/lib/myinfo/helpers/attributes.rb +2 -2
- data/lib/myinfo/helpers/jwe_decryptor.rb +142 -0
- data/lib/myinfo/helpers/security_helper.rb +78 -0
- data/lib/myinfo/v4/api.rb +144 -0
- data/lib/myinfo/v4/authorise_url.rb +52 -0
- data/lib/myinfo/v4/person.rb +77 -0
- data/lib/myinfo/v4/response.rb +34 -0
- data/lib/myinfo/v4/session.rb +21 -0
- data/lib/myinfo/v4/token.rb +65 -0
- data/lib/myinfo/version.rb +1 -1
- data/lib/myinfo.rb +23 -2
- metadata +34 -12
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 12a9996ed3c5052b49d43a086da72399138768ecc3e6928be0cbe152d1cf8bab
|
4
|
+
data.tar.gz: aeb4fbf81d51722a3e3994c392e3c74a89122f87169d3f317b366916bc47a97c
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e00d202ce7b880932543b9e4c1e9cf4ce3d6ab6cd5e0862dd7bf279512f9b91e81032da4ed777ab735dd36b5b0f790bed626c36268d366642c36a15fc2e7b227
|
7
|
+
data.tar.gz: 921b719e0b480948f4159a5ebd1645f8062966b89c1db55e536931ab0b174dc60ba997e41cc94fa6feaea1c1061d770184f64f9ddd32a273f57ff6a925b8c21f
|
data/README.md
CHANGED
@@ -2,10 +2,65 @@
|
|
2
2
|
|
3
3
|

|
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
|
4
|
+
class MyInfoError < StandardError; end
|
5
5
|
|
6
|
-
class
|
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
|
data/lib/myinfo/version.rb
CHANGED
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,
|
33
|
-
:
|
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.
|
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:
|
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.
|
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.
|
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: '
|
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: '
|
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.
|
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.
|
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.
|
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: []
|