myinfo 0.5.3 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- 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/v3/api.rb +6 -1
- data/lib/myinfo/v3/person.rb +1 -1
- data/lib/myinfo/v3/person_basic.rb +1 -1
- data/lib/myinfo/v3/token.rb +1 -1
- 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 +30 -5
- 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
|
![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
|
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
|
data/lib/myinfo/v3/api.rb
CHANGED
@@ -51,6 +51,11 @@ module MyInfo
|
|
51
51
|
end
|
52
52
|
end
|
53
53
|
|
54
|
+
def api_path
|
55
|
+
path = config.gateway_path.present? ? "#{config.gateway_path}/" : ''
|
56
|
+
"#{path}#{slug}"
|
57
|
+
end
|
58
|
+
|
54
59
|
def parse_response(response)
|
55
60
|
if response.code == '200'
|
56
61
|
yield
|
@@ -80,7 +85,7 @@ module MyInfo
|
|
80
85
|
end
|
81
86
|
|
82
87
|
def http
|
83
|
-
url = config.
|
88
|
+
url = config.gateway_host || config.base_url
|
84
89
|
@http ||= if config.proxy.blank?
|
85
90
|
Net::HTTP.new(url, 443)
|
86
91
|
else
|
data/lib/myinfo/v3/person.rb
CHANGED
@@ -16,7 +16,7 @@ module MyInfo
|
|
16
16
|
def call
|
17
17
|
super do
|
18
18
|
headers = header(params: params, access_token: access_token)
|
19
|
-
endpoint_url = "/#{
|
19
|
+
endpoint_url = "/#{api_path}?#{params.to_query}"
|
20
20
|
|
21
21
|
response = http.request_get(endpoint_url, headers)
|
22
22
|
parse_response(response)
|
data/lib/myinfo/v3/token.rb
CHANGED
@@ -14,7 +14,7 @@ module MyInfo
|
|
14
14
|
def call
|
15
15
|
super do
|
16
16
|
headers = header(params: params).merge({ 'Content-Type' => 'application/x-www-form-urlencoded' })
|
17
|
-
response = http.request_post("/#{
|
17
|
+
response = http.request_post("/#{api_path}", params.to_param, headers)
|
18
18
|
|
19
19
|
parse_response(response)
|
20
20
|
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,10 +38,22 @@ 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
|
-
attr_reader :base_url
|
56
|
+
attr_reader :base_url
|
36
57
|
attr_writer :public_facing, :sandbox
|
37
58
|
|
38
59
|
def initialize
|
@@ -49,8 +70,12 @@ module MyInfo
|
|
49
70
|
"https://#{base_url}"
|
50
71
|
end
|
51
72
|
|
52
|
-
def
|
53
|
-
|
73
|
+
def gateway_host
|
74
|
+
gateway_url&.sub('https://', '')&.split('/')&.first
|
75
|
+
end
|
76
|
+
|
77
|
+
def gateway_path
|
78
|
+
gateway_url.present? ? gateway_url.sub('https://', '').split('/')[1..].join('/') : ''
|
54
79
|
end
|
55
80
|
|
56
81
|
def public?
|
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: []
|