ibm_cloud_sdk_core 0.2.0 → 0.3.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 +13 -0
- data/lib/ibm_cloud_sdk_core/base_service.rb +34 -8
- data/lib/ibm_cloud_sdk_core/iam_token_manager.rb +7 -103
- data/lib/ibm_cloud_sdk_core/icp4d_token_manager.rb +30 -0
- data/lib/ibm_cloud_sdk_core/jwt_token_manager.rb +85 -0
- data/lib/ibm_cloud_sdk_core/version.rb +1 -1
- data/test/unit/test_base_service.rb +54 -4
- data/test/unit/test_iam_token_manager.rb +25 -88
- data/test/unit/test_icp4d_token_manager.rb +60 -0
- data/test/unit/test_jwt_token_manager.rb +104 -0
- metadata +20 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b18d557fd57fed55fa59dc6be40502ed3cd5cb5dd38927b1e0086191056412a1
|
4
|
+
data.tar.gz: 5d4a983f01f4d9eeaa10dc6970683048ef7b3a0996f9f6de43c1832f04618a4f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 95eb85a507326b86317e5a3209e3086ce1e27f6a849d0d19148b83847da61d71227f12f66268f540b55d0c90c55e052739dc29eb8a86e331a9b783c43062e4ee
|
7
|
+
data.tar.gz: ca617c999050ed7e2dca0583e1d62ae018b898ec46feb021875c916433d390a351f1448fbb9d310baa57dc2dfda95ad889c205663ffe4619913a771305d46735
|
data/README.md
CHANGED
@@ -21,6 +21,19 @@ Inside of your Ruby program do:
|
|
21
21
|
require "ibm_cloud_sdk_core"
|
22
22
|
```
|
23
23
|
|
24
|
+
## Authentication Types
|
25
|
+
There are several flavors of authentication supported in this package. To specify the intended authentication pattern to use, the user can pass in the parameter `authentication_type`. This parameter is optional, but it may become required in a future major release. The options for this parameter are `basic`, `iam`, and `icp4d`.
|
26
|
+
|
27
|
+
### basic
|
28
|
+
This indicates Basic Auth is to be used. Users will pass in a `username` and `password` and the SDK will generate a Basic Auth header to send with requests to the service.
|
29
|
+
|
30
|
+
### iam
|
31
|
+
This indicates that IAM token authentication is to be used. Users can pass in an `iam_apikey` or an `iam_access_token`. If an API key is used, the SDK will manage the token for the user. In either case, the SDK will generate a Bearer Auth header to send with requests to the service.
|
32
|
+
|
33
|
+
### icp4d
|
34
|
+
This indicates that the service is an instance of ICP4D, which has its own version of token authentication. Users can pass in a `username` and `password`, or an `icp4d_access_token`. If a username and password is given, the SDK will manage the token for the user.
|
35
|
+
A `icp4d_url` is **required** for this type. In order to use an SDK-managed token with ICP4D authentication, this option **must** be passed in.
|
36
|
+
|
24
37
|
## Issues
|
25
38
|
|
26
39
|
If you encounter an issue with this project, you are welcome to submit a [bug report](https://github.com/IBM/ruby-sdk-core/issues).
|
@@ -7,6 +7,7 @@ require("json")
|
|
7
7
|
require_relative("./detailed_response.rb")
|
8
8
|
require_relative("./api_exception.rb")
|
9
9
|
require_relative("./iam_token_manager.rb")
|
10
|
+
require_relative("./icp4d_token_manager.rb")
|
10
11
|
require_relative("./version.rb")
|
11
12
|
|
12
13
|
DEFAULT_CREDENTIALS_FILE_NAME = "ibm-credentials.env"
|
@@ -22,9 +23,12 @@ module IBMCloudSdkCore
|
|
22
23
|
def initialize(vars)
|
23
24
|
defaults = {
|
24
25
|
vcap_services_name: nil,
|
26
|
+
use_vcap_services: true,
|
27
|
+
authentication_type: nil,
|
25
28
|
username: nil,
|
26
29
|
password: nil,
|
27
|
-
|
30
|
+
icp4d_access_token: nil,
|
31
|
+
icp4d_url: nil,
|
28
32
|
iam_apikey: nil,
|
29
33
|
iam_access_token: nil,
|
30
34
|
iam_url: nil,
|
@@ -34,22 +38,29 @@ module IBMCloudSdkCore
|
|
34
38
|
}
|
35
39
|
vars = defaults.merge(vars)
|
36
40
|
@url = vars[:url]
|
37
|
-
@username =
|
38
|
-
@password =
|
39
|
-
@
|
41
|
+
@username = vars[:username]
|
42
|
+
@password = vars[:password]
|
43
|
+
@icp_prefix = vars[:password]&.start_with?("icp-") || vars[:iam_apikey]&.start_with?("icp-") ? true : false
|
44
|
+
@icp4d_access_token = vars[:icp4d_access_token]
|
45
|
+
@icp4d_url = vars[:icp4d_url]
|
46
|
+
@iam_url = vars[:iam_url]
|
47
|
+
@iam_apikey = vars[:iam_apikey]
|
40
48
|
@token_manager = nil
|
49
|
+
@authentication_type = vars[:authentication_type].downcase unless vars[:authentication_type].nil?
|
41
50
|
@temp_headers = nil
|
42
|
-
@icp_prefix = vars[:password]&.start_with?("icp-") || vars[:iam_apikey]&.start_with?("icp-") ? true : false
|
43
51
|
@disable_ssl_verification = false
|
44
52
|
@display_name = vars[:display_name]
|
45
53
|
|
46
|
-
if (!vars[:iam_access_token].nil? || !vars[:iam_apikey].nil?) && !@icp_prefix
|
47
|
-
|
54
|
+
if @authentication_type == "iam" || ((!vars[:iam_access_token].nil? || !vars[:iam_apikey].nil?) && !@icp_prefix)
|
55
|
+
iam_token_manager(iam_apikey: vars[:iam_apikey], iam_access_token: vars[:iam_access_token],
|
48
56
|
iam_url: vars[:iam_url], iam_client_id: vars[:iam_client_id],
|
49
57
|
iam_client_secret: vars[:iam_client_secret])
|
50
58
|
elsif !vars[:iam_apikey].nil? && @icp_prefix
|
51
59
|
@username = "apikey"
|
52
60
|
@password = vars[:iam_apikey]
|
61
|
+
elsif @authentication_type == "icp4d" || !vars[:icp4d_access_token].nil?
|
62
|
+
icp4d_token_manager(icp4d_access_token: vars[:icp4d_access_token], icp4d_url: vars[:icp4d_url],
|
63
|
+
username: vars[:username], password: vars[:password])
|
53
64
|
elsif !vars[:username].nil? && !vars[:password].nil?
|
54
65
|
if vars[:username] == "apikey" && !@icp_prefix
|
55
66
|
iam_apikey(iam_apikey: vars[:password])
|
@@ -73,6 +84,8 @@ module IBMCloudSdkCore
|
|
73
84
|
@password = @vcap_service_credentials["password"] if @vcap_service_credentials.key?("password")
|
74
85
|
@iam_apikey = @vcap_service_credentials["iam_apikey"] if @vcap_service_credentials.key?("iam_apikey")
|
75
86
|
@iam_access_token = @vcap_service_credentials["iam_access_token"] if @vcap_service_credentials.key?("iam_access_token")
|
87
|
+
@icp4d_access_token = @vcap_service_credentials["icp4d_access_token"] if @vcap_service_credentials.key?("icp4d_access_token")
|
88
|
+
@icp4d_url = @vcap_service_credentials["icp4d_url"] if @vcap_service_credentials.key?("icp4d_url")
|
76
89
|
@iam_url = @vcap_service_credentials["iam_url"] if @vcap_service_credentials.key?("iam_url")
|
77
90
|
@icp_prefix = @password&.start_with?("icp-") || @iam_apikey&.start_with?("icp-") ? true : false
|
78
91
|
end
|
@@ -82,6 +95,9 @@ module IBMCloudSdkCore
|
|
82
95
|
raise ArgumentError.new('The password shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your password') if check_bad_first_or_last_char(@password)
|
83
96
|
raise ArgumentError.new('The url shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your url') if check_bad_first_or_last_char(@url)
|
84
97
|
raise ArgumentError.new('The apikey shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your apikey') if check_bad_first_or_last_char(@iam_apikey)
|
98
|
+
raise ArgumentError.new('The iam access token shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your iam access token') if check_bad_first_or_last_char(@iam_access_token)
|
99
|
+
raise ArgumentError.new('The icp4d access token shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your icp4d access token') if check_bad_first_or_last_char(@icp4d_access_token)
|
100
|
+
raise ArgumentError.new('The icp4d url shouldn\'t start or end with curly brackets or quotes. Be sure to remove any {} and \" characters surrounding your icp4d url') if check_bad_first_or_last_char(@icp4d_url)
|
85
101
|
|
86
102
|
@conn = HTTP::Client.new(
|
87
103
|
headers: {}
|
@@ -248,7 +264,7 @@ module IBMCloudSdkCore
|
|
248
264
|
return str.start_with?("{", "\"") || str.end_with?("}", "\"") unless str.nil?
|
249
265
|
end
|
250
266
|
|
251
|
-
def
|
267
|
+
def iam_token_manager(iam_apikey: nil, iam_access_token: nil, iam_url: nil,
|
252
268
|
iam_client_id: nil, iam_client_secret: nil)
|
253
269
|
@iam_apikey = iam_apikey
|
254
270
|
@iam_access_token = iam_access_token
|
@@ -260,6 +276,16 @@ module IBMCloudSdkCore
|
|
260
276
|
iam_url: iam_url, iam_client_id: iam_client_id, iam_client_secret: iam_client_secret)
|
261
277
|
end
|
262
278
|
|
279
|
+
def icp4d_token_manager(icp4d_access_token: nil, icp4d_url: nil, username: nil, password: nil)
|
280
|
+
if !@token_manager.nil?
|
281
|
+
@token_manager.access_token(icp4d_access_token)
|
282
|
+
else
|
283
|
+
raise ArgumentError.new("The icp4d_url is mandatory for ICP4D.") if icp4d_url.nil? && icp4d_access_token.nil?
|
284
|
+
|
285
|
+
@token_manager = ICP4DTokenManager.new(url: icp4d_url, access_token: icp4d_access_token, username: username, password: password)
|
286
|
+
end
|
287
|
+
end
|
288
|
+
|
263
289
|
def add_timeout(timeout)
|
264
290
|
if timeout.key?(:per_operation)
|
265
291
|
raise TypeError("per_operation in timeout must be a Hash") unless timeout[:per_operation].instance_of?(Hash)
|
@@ -4,10 +4,11 @@ require("http")
|
|
4
4
|
require("json")
|
5
5
|
require("rbconfig")
|
6
6
|
require_relative("./version.rb")
|
7
|
+
require_relative("./jwt_token_manager")
|
7
8
|
|
8
9
|
module IBMCloudSdkCore
|
9
10
|
# Class to manage IAM Token Authentication
|
10
|
-
class IAMTokenManager
|
11
|
+
class IAMTokenManager < JWTTokenManager
|
11
12
|
DEFAULT_IAM_URL = "https://iam.cloud.ibm.com/identity/token"
|
12
13
|
CONTENT_TYPE = "application/x-www-form-urlencoded"
|
13
14
|
ACCEPT = "application/json"
|
@@ -16,7 +17,6 @@ module IBMCloudSdkCore
|
|
16
17
|
DEFAULT_CLIENT_SECRET = "bx"
|
17
18
|
REQUEST_TOKEN_GRANT_TYPE = "urn:ibm:params:oauth:grant-type:apikey"
|
18
19
|
REQUEST_TOKEN_RESPONSE_TYPE = "cloud_iam"
|
19
|
-
REFRESH_TOKEN_GRANT_TYPE = "refresh_token"
|
20
20
|
|
21
21
|
attr_accessor :token_info, :user_access_token
|
22
22
|
def initialize(iam_apikey: nil, iam_access_token: nil, iam_url: nil,
|
@@ -24,13 +24,8 @@ module IBMCloudSdkCore
|
|
24
24
|
@iam_apikey = iam_apikey
|
25
25
|
@user_access_token = iam_access_token
|
26
26
|
@iam_url = iam_url.nil? ? DEFAULT_IAM_URL : iam_url
|
27
|
-
|
28
|
-
|
29
|
-
"refresh_token" => nil,
|
30
|
-
"token_type" => nil,
|
31
|
-
"expires_in" => nil,
|
32
|
-
"expiration" => nil
|
33
|
-
}
|
27
|
+
super(url: iam_url, access_token: iam_access_token)
|
28
|
+
|
34
29
|
# Both the client id and secret should be provided or neither should be provided.
|
35
30
|
if !iam_client_id.nil? && !iam_client_secret.nil?
|
36
31
|
@iam_client_id = iam_client_id
|
@@ -43,48 +38,6 @@ module IBMCloudSdkCore
|
|
43
38
|
end
|
44
39
|
end
|
45
40
|
|
46
|
-
def request(method:, url:, headers: nil, params: nil, data: nil)
|
47
|
-
response = nil
|
48
|
-
if headers.key?("Content-Type") && headers["Content-Type"] == CONTENT_TYPE
|
49
|
-
response = HTTP.basic_auth(user: @iam_client_id, pass: @iam_client_secret).request(
|
50
|
-
method,
|
51
|
-
url,
|
52
|
-
body: HTTP::URI.form_encode(data),
|
53
|
-
headers: headers,
|
54
|
-
params: params
|
55
|
-
)
|
56
|
-
end
|
57
|
-
return JSON.parse(response.body.to_s) if (200..299).cover?(response.code)
|
58
|
-
|
59
|
-
require_relative("./api_exception.rb")
|
60
|
-
raise ApiException.new(response: response)
|
61
|
-
end
|
62
|
-
|
63
|
-
# The source of the token is determined by the following logic:
|
64
|
-
# 1. If user provides their own managed access token, assume it is valid and send it
|
65
|
-
# 2. If this class is managing tokens and does not yet have one, make a request for one
|
66
|
-
# 3. If this class is managing tokens and the token has expired refresh it. In case the refresh token is expired, get a new one
|
67
|
-
# If this class is managing tokens and has a valid token stored, send it
|
68
|
-
def token
|
69
|
-
return @user_access_token unless @user_access_token.nil? || (@user_access_token.respond_to?(:empty?) && @user_access_token.empty?)
|
70
|
-
|
71
|
-
if @token_info.all? { |_k, v| v.nil? }
|
72
|
-
token_info = request_token
|
73
|
-
save_token_info(
|
74
|
-
token_info: token_info
|
75
|
-
)
|
76
|
-
return @token_info["access_token"]
|
77
|
-
elsif token_expired?
|
78
|
-
token_info = refresh_token_expired? ? request_token : refresh_token
|
79
|
-
save_token_info(
|
80
|
-
token_info: token_info
|
81
|
-
)
|
82
|
-
return @token_info["access_token"]
|
83
|
-
else
|
84
|
-
@token_info["access_token"]
|
85
|
-
end
|
86
|
-
end
|
87
|
-
|
88
41
|
private
|
89
42
|
|
90
43
|
# Request an IAM token using an API key
|
@@ -102,60 +55,11 @@ module IBMCloudSdkCore
|
|
102
55
|
method: "POST",
|
103
56
|
url: @iam_url,
|
104
57
|
headers: headers,
|
105
|
-
data: data
|
106
|
-
|
107
|
-
|
108
|
-
end
|
109
|
-
|
110
|
-
# Refresh an IAM token using a refresh token
|
111
|
-
def refresh_token
|
112
|
-
headers = {
|
113
|
-
"Content-Type" => CONTENT_TYPE,
|
114
|
-
"accept" => ACCEPT
|
115
|
-
}
|
116
|
-
data = {
|
117
|
-
"grant_type" => REFRESH_TOKEN_GRANT_TYPE,
|
118
|
-
"refresh_token" => @token_info["refresh_token"]
|
119
|
-
}
|
120
|
-
response = request(
|
121
|
-
method: "POST",
|
122
|
-
url: @iam_url,
|
123
|
-
headers: headers,
|
124
|
-
data: data
|
58
|
+
data: HTTP::URI.form_encode(data),
|
59
|
+
username: @iam_client_id,
|
60
|
+
password: @iam_client_secret
|
125
61
|
)
|
126
62
|
response
|
127
63
|
end
|
128
|
-
|
129
|
-
# Check if currently stored token is expired.
|
130
|
-
# Using a buffer to prevent the edge case of the
|
131
|
-
# token expiring before the request could be made.
|
132
|
-
# The buffer will be a fraction of the total TTL. Using 80%.
|
133
|
-
def token_expired?
|
134
|
-
return true if @token_info["expiration"].nil? || @token_info["expires_in"].nil?
|
135
|
-
|
136
|
-
fraction_of_ttl = 0.8
|
137
|
-
time_to_live = @token_info["expires_in"].nil? ? 0 : @token_info["expires_in"]
|
138
|
-
expire_time = @token_info["expiration"].nil? ? 0 : @token_info["expiration"]
|
139
|
-
refresh_time = expire_time - (time_to_live * (1.0 - fraction_of_ttl))
|
140
|
-
current_time = Time.now.to_i
|
141
|
-
refresh_time < current_time
|
142
|
-
end
|
143
|
-
|
144
|
-
# Used as a fail-safe to prevent the condition of a refresh token expiring,
|
145
|
-
# which could happen after around 30 days. This function will return true
|
146
|
-
# if it has been at least 7 days and 1 hour since the last token was set
|
147
|
-
def refresh_token_expired?
|
148
|
-
return true if @token_info["expiration"].nil?
|
149
|
-
|
150
|
-
seven_days = 7 * 24 * 3600
|
151
|
-
current_time = Time.now.to_i
|
152
|
-
new_token_time = @token_info["expiration"] + seven_days
|
153
|
-
new_token_time < current_time
|
154
|
-
end
|
155
|
-
|
156
|
-
# Save the response from the IAM service request to the object's state
|
157
|
-
def save_token_info(token_info:)
|
158
|
-
@token_info = token_info
|
159
|
-
end
|
160
64
|
end
|
161
65
|
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require("http")
|
4
|
+
require("json")
|
5
|
+
require("rbconfig")
|
6
|
+
require_relative("./version.rb")
|
7
|
+
require_relative("./jwt_token_manager")
|
8
|
+
|
9
|
+
module IBMCloudSdkCore
|
10
|
+
# Class to manage ICP4D Token Authentication
|
11
|
+
class ICP4DTokenManager < JWTTokenManager
|
12
|
+
def initialize(url: nil, username: nil, password: nil, access_token: nil)
|
13
|
+
raise ArgumentError.new("The url is mandatory for ICP4D.") if url.nil? && access_token.nil?
|
14
|
+
|
15
|
+
url += "/v1/preauth/validateAuth"
|
16
|
+
@username = username
|
17
|
+
@password = password
|
18
|
+
super(url: url, user_access_token: access_token)
|
19
|
+
end
|
20
|
+
|
21
|
+
def request_token
|
22
|
+
request(
|
23
|
+
method: "GET",
|
24
|
+
url: @url,
|
25
|
+
username: @username,
|
26
|
+
password: @password
|
27
|
+
)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,85 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require("http")
|
4
|
+
require("json")
|
5
|
+
require("jwt")
|
6
|
+
require("rbconfig")
|
7
|
+
require_relative("./version.rb")
|
8
|
+
|
9
|
+
TOKEN_NAME = "access_token"
|
10
|
+
|
11
|
+
module IBMCloudSdkCore
|
12
|
+
# Class to manage JWT Token Authentication
|
13
|
+
class JWTTokenManager
|
14
|
+
def initialize(vars)
|
15
|
+
defaults = {
|
16
|
+
token_info: nil,
|
17
|
+
url: nil,
|
18
|
+
access_token: nil
|
19
|
+
}
|
20
|
+
vars = defaults.merge(vars)
|
21
|
+
|
22
|
+
@url = vars[:url]
|
23
|
+
@token_info = vars[:token_info]
|
24
|
+
@user_access_token = vars[:access_token]
|
25
|
+
@time_to_live = nil
|
26
|
+
@expire_time = nil
|
27
|
+
@disable_ssl_verification = false
|
28
|
+
end
|
29
|
+
|
30
|
+
def token
|
31
|
+
if !@user_access_token.nil?
|
32
|
+
@user_access_token
|
33
|
+
elsif @token_info.nil? || token_expired?
|
34
|
+
token_info = request_token
|
35
|
+
save_token_info(token_info: token_info)
|
36
|
+
@token_info[TOKEN_NAME]
|
37
|
+
elsif !@token_info.nil?
|
38
|
+
@token_info[TOKEN_NAME]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def access_token(access_token)
|
43
|
+
@user_access_token = access_token
|
44
|
+
end
|
45
|
+
|
46
|
+
private
|
47
|
+
|
48
|
+
# Check if currently stored token is expired.
|
49
|
+
# Using a buffer to prevent the edge case of the
|
50
|
+
# token expiring before the request could be made.
|
51
|
+
# The buffer will be a fraction of the total TTL. Using 80%.
|
52
|
+
def token_expired?
|
53
|
+
return true if @time_to_live.nil? || @expire_time.nil?
|
54
|
+
|
55
|
+
fraction_of_ttl = 0.8
|
56
|
+
refresh_time = @expire_time - (@time_to_live * (1.0 - fraction_of_ttl))
|
57
|
+
current_time = Time.now.to_i
|
58
|
+
refresh_time < current_time
|
59
|
+
end
|
60
|
+
|
61
|
+
def save_token_info(token_info: nil)
|
62
|
+
access_token = token_info[TOKEN_NAME]
|
63
|
+
decoded_response = JWT.decode access_token, nil, false, {}
|
64
|
+
exp = decoded_response[0]["exp"]
|
65
|
+
iat = decoded_response[0]["iat"]
|
66
|
+
@time_to_live = exp - iat
|
67
|
+
@expire_time = exp
|
68
|
+
@token_info = token_info
|
69
|
+
end
|
70
|
+
|
71
|
+
def request(method:, url:, headers: nil, params: nil, data: nil, username: nil, password: nil)
|
72
|
+
response = HTTP.basic_auth(user: username, pass: password).request(
|
73
|
+
method,
|
74
|
+
url,
|
75
|
+
body: data,
|
76
|
+
headers: headers,
|
77
|
+
params: params
|
78
|
+
)
|
79
|
+
return JSON.parse(response.body.to_s) if (200..299).cover?(response.code)
|
80
|
+
|
81
|
+
require_relative("./api_exception.rb")
|
82
|
+
raise ApiException.new(response: response)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
@@ -1,6 +1,7 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require("json")
|
4
|
+
require("jwt")
|
4
5
|
require_relative("./../test_helper.rb")
|
5
6
|
require("webmock/minitest")
|
6
7
|
|
@@ -209,8 +210,21 @@ class BaseServiceTest < Minitest::Test
|
|
209
210
|
"created" => "2016-07-11T16:39:01.774Z",
|
210
211
|
"updated" => "2015-12-07T18:53:59.153Z"
|
211
212
|
}
|
213
|
+
access_token_layout = {
|
214
|
+
"username" => "dummy",
|
215
|
+
"role" => "Admin",
|
216
|
+
"permissions" => %w[administrator manage_catalog],
|
217
|
+
"sub" => "admin",
|
218
|
+
"iss" => "sss",
|
219
|
+
"aud" => "sss",
|
220
|
+
"uid" => "sss",
|
221
|
+
"iat" => 3600,
|
222
|
+
"exp" => Time.now.to_i
|
223
|
+
}
|
224
|
+
|
225
|
+
access_token = JWT.encode(access_token_layout, "secret", "HS256", "kid": "230498151c214b788dd97f22b85410a5")
|
212
226
|
token_response = {
|
213
|
-
"access_token" =>
|
227
|
+
"access_token" => access_token,
|
214
228
|
"token_type" => "Bearer",
|
215
229
|
"expires_in" => 3600,
|
216
230
|
"expiration" => 1_524_167_011,
|
@@ -239,7 +253,7 @@ class BaseServiceTest < Minitest::Test
|
|
239
253
|
stub_request(:get, "https://we.the.best/music")
|
240
254
|
.with(
|
241
255
|
headers: {
|
242
|
-
"Authorization" => "Bearer
|
256
|
+
"Authorization" => "Bearer " + access_token,
|
243
257
|
"Host" => "we.the.best"
|
244
258
|
}
|
245
259
|
).to_return(status: 200, body: response.to_json, headers: headers)
|
@@ -252,14 +266,38 @@ class BaseServiceTest < Minitest::Test
|
|
252
266
|
assert_equal(response, service_response.result)
|
253
267
|
end
|
254
268
|
|
269
|
+
def test_for_icp4d
|
270
|
+
service = IBMCloudSdkCore::BaseService.new(
|
271
|
+
username: "hello",
|
272
|
+
password: "world",
|
273
|
+
icp4d_url: "service_url",
|
274
|
+
authentication_type: "icp4d"
|
275
|
+
)
|
276
|
+
refute_nil(service.token_manager)
|
277
|
+
end
|
278
|
+
|
255
279
|
def test_dummy_request_username_apikey_cred_file
|
256
280
|
response = {
|
257
281
|
"text" => "I want financial advice today.",
|
258
282
|
"created" => "2016-07-11T16:39:01.774Z",
|
259
283
|
"updated" => "2015-12-07T18:53:59.153Z"
|
260
284
|
}
|
285
|
+
|
286
|
+
access_token_layout = {
|
287
|
+
"username" => "dummy",
|
288
|
+
"role" => "Admin",
|
289
|
+
"permissions" => %w[administrator manage_catalog],
|
290
|
+
"sub" => "admin",
|
291
|
+
"iss" => "sss",
|
292
|
+
"aud" => "sss",
|
293
|
+
"uid" => "sss",
|
294
|
+
"iat" => 3600,
|
295
|
+
"exp" => Time.now.to_i
|
296
|
+
}
|
297
|
+
|
298
|
+
access_token = JWT.encode(access_token_layout, "secret", "HS256", "kid": "230498151c214b788dd97f22b85410a5")
|
261
299
|
token_response = {
|
262
|
-
"access_token" =>
|
300
|
+
"access_token" => access_token,
|
263
301
|
"token_type" => "Bearer",
|
264
302
|
"expires_in" => 3600,
|
265
303
|
"expiration" => 1_524_167_011,
|
@@ -288,7 +326,7 @@ class BaseServiceTest < Minitest::Test
|
|
288
326
|
stub_request(:get, "https://we.the.best/music")
|
289
327
|
.with(
|
290
328
|
headers: {
|
291
|
-
"Authorization" => "Bearer
|
329
|
+
"Authorization" => "Bearer " + access_token,
|
292
330
|
"Host" => "we.the.best"
|
293
331
|
}
|
294
332
|
).to_return(status: 200, body: response.to_json, headers: headers)
|
@@ -301,4 +339,16 @@ class BaseServiceTest < Minitest::Test
|
|
301
339
|
service_response = service.request(method: "GET", url: "/music", headers: {})
|
302
340
|
assert_equal(response, service_response.result)
|
303
341
|
end
|
342
|
+
|
343
|
+
def test_icp4d_access_token
|
344
|
+
service = IBMCloudSdkCore::BaseService.new(
|
345
|
+
authentication_type: "icp4d",
|
346
|
+
icp4d_url: "https://the.sixth.one",
|
347
|
+
icp4d_access_token: "token"
|
348
|
+
)
|
349
|
+
assert_equal(service.instance_variable_get(:@icp4d_access_token), "token")
|
350
|
+
service.send :icp4d_token_manager, icp4d_access_token: "new_token", icp4d_url: "the.url"
|
351
|
+
token_manager = service.instance_variable_get(:@token_manager)
|
352
|
+
assert_equal(token_manager.instance_variable_get(:@user_access_token), "new_token")
|
353
|
+
end
|
304
354
|
end
|
@@ -36,7 +36,7 @@ class IAMTokenManagerTest < Minitest::Test
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def test_request_token_fails
|
39
|
-
iam_url = "https://iam.
|
39
|
+
iam_url = "https://iam.cloud.ibm.com/identity/token"
|
40
40
|
token_manager = IBMCloudSdkCore::IAMTokenManager.new(
|
41
41
|
iam_apikey: "iam_apikey",
|
42
42
|
iam_access_token: "iam_access_token",
|
@@ -46,14 +46,14 @@ class IAMTokenManagerTest < Minitest::Test
|
|
46
46
|
"code" => "500",
|
47
47
|
"error" => "Oh no"
|
48
48
|
}
|
49
|
-
stub_request(:post, "https://iam.
|
49
|
+
stub_request(:post, "https://iam.cloud.ibm.com/identity/token")
|
50
50
|
.with(
|
51
51
|
body: { "apikey" => "iam_apikey", "grant_type" => "urn:ibm:params:oauth:grant-type:apikey", "response_type" => "cloud_iam" },
|
52
52
|
headers: {
|
53
53
|
"Accept" => "application/json",
|
54
54
|
"Authorization" => "Basic Yng6Yng=",
|
55
55
|
"Content-Type" => "application/x-www-form-urlencoded",
|
56
|
-
"Host" => "iam.
|
56
|
+
"Host" => "iam.cloud.ibm.com"
|
57
57
|
}
|
58
58
|
).to_return(status: 500, body: response.to_json, headers: {})
|
59
59
|
assert_raises do
|
@@ -87,69 +87,19 @@ class IAMTokenManagerTest < Minitest::Test
|
|
87
87
|
end
|
88
88
|
end
|
89
89
|
|
90
|
-
def test_refresh_token
|
91
|
-
iam_url = "https://iam.cloud.ibm.com/identity/token"
|
92
|
-
response = {
|
93
|
-
"access_token" => "oAeisG8yqPY7sFR_x66Z15",
|
94
|
-
"token_type" => "Bearer",
|
95
|
-
"expires_in" => 3600,
|
96
|
-
"expiration" => 1_524_167_011,
|
97
|
-
"refresh_token" => "jy4gl91BQ"
|
98
|
-
}
|
99
|
-
token_manager = IBMCloudSdkCore::IAMTokenManager.new(
|
100
|
-
iam_apikey: "iam_apikey",
|
101
|
-
iam_access_token: "iam_access_token",
|
102
|
-
iam_url: iam_url
|
103
|
-
)
|
104
|
-
stub_request(:post, "https://iam.cloud.ibm.com/identity/token")
|
105
|
-
.with(
|
106
|
-
body: { "grant_type" => "refresh_token", "refresh_token" => "" },
|
107
|
-
headers: {
|
108
|
-
"Accept" => "application/json",
|
109
|
-
"Authorization" => "Basic Yng6Yng=",
|
110
|
-
"Content-Type" => "application/x-www-form-urlencoded",
|
111
|
-
"Host" => "iam.cloud.ibm.com"
|
112
|
-
}
|
113
|
-
).to_return(status: 200, body: response.to_json, headers: {})
|
114
|
-
token_response = token_manager.send(:refresh_token)
|
115
|
-
assert_equal(response, token_response)
|
116
|
-
end
|
117
|
-
|
118
90
|
def test_is_token_expired
|
119
91
|
token_manager = IBMCloudSdkCore::IAMTokenManager.new(
|
120
92
|
iam_apikey: "iam_apikey",
|
121
93
|
iam_access_token: "iam_access_token",
|
122
94
|
iam_url: "iam_url"
|
123
95
|
)
|
124
|
-
token_manager.token_info = {
|
125
|
-
"access_token" => "oAeisG8yqPY7sFR_x66Z15",
|
126
|
-
"token_type" => "Bearer",
|
127
|
-
"expires_in" => 3600,
|
128
|
-
"expiration" => Time.now.to_i + 6000,
|
129
|
-
"refresh_token" => "jy4gl91BQ"
|
130
|
-
}
|
131
96
|
|
132
|
-
refute(token_manager.send(:token_expired?))
|
133
|
-
token_manager.token_info["expiration"] = Time.now.to_i - 3600
|
134
97
|
assert(token_manager.send(:token_expired?))
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
token_manager
|
139
|
-
|
140
|
-
iam_access_token: "iam_access_token",
|
141
|
-
iam_url: "iam_url"
|
142
|
-
)
|
143
|
-
token_manager.token_info = {
|
144
|
-
"access_token" => "oAeisG8yqPY7sFR_x66Z15",
|
145
|
-
"token_type" => "Bearer",
|
146
|
-
"expires_in" => 3600,
|
147
|
-
"expiration" => Time.now.to_i,
|
148
|
-
"refresh_token" => "jy4gl91BQ"
|
149
|
-
}
|
150
|
-
|
151
|
-
refute(token_manager.send(:refresh_token_expired?))
|
152
|
-
token_manager.token_info["expiration"] = Time.now.to_i - (8 * 24 * 3600)
|
98
|
+
token_manager.instance_variable_set(:@time_to_live, 3600)
|
99
|
+
token_manager.instance_variable_set(:@expire_time, Time.now.to_i + 6000)
|
100
|
+
refute(token_manager.send(:token_expired?))
|
101
|
+
token_manager.instance_variable_set(:@time_to_live, 3600)
|
102
|
+
token_manager.instance_variable_set(:@expire_time, Time.now.to_i - 3600)
|
153
103
|
assert(token_manager.send(:token_expired?))
|
154
104
|
end
|
155
105
|
|
@@ -164,33 +114,31 @@ class IAMTokenManagerTest < Minitest::Test
|
|
164
114
|
token = token_manager.token
|
165
115
|
assert_equal(token_manager.user_access_token, token)
|
166
116
|
|
117
|
+
access_token_layout = {
|
118
|
+
"username" => "dummy",
|
119
|
+
"role" => "Admin",
|
120
|
+
"permissions" => %w[administrator manage_catalog],
|
121
|
+
"sub" => "admin",
|
122
|
+
"iss" => "sss",
|
123
|
+
"aud" => "sss",
|
124
|
+
"uid" => "sss",
|
125
|
+
"iat" => 3600,
|
126
|
+
"exp" => Time.now.to_i
|
127
|
+
}
|
128
|
+
|
129
|
+
access_token = JWT.encode(access_token_layout, "secret", "HS256", "kid": "230498151c214b788dd97f22b85410a5")
|
130
|
+
|
167
131
|
response = {
|
168
|
-
"access_token" =>
|
132
|
+
"access_token" => access_token,
|
169
133
|
"token_type" => "Bearer",
|
170
134
|
"expires_in" => 3600,
|
171
135
|
"expiration" => 1_524_167_011,
|
172
136
|
"refresh_token" => "jy4gl91BQ"
|
173
137
|
}
|
174
|
-
stub_request(:post, "https://iam.cloud.ibm.com/identity/token")
|
175
|
-
.with(
|
176
|
-
body: { "apikey" => "iam_apikey", "grant_type" => "urn:ibm:params:oauth:grant-type:apikey", "response_type" => "cloud_iam" },
|
177
|
-
headers: {
|
178
|
-
"Accept" => "application/json",
|
179
|
-
"Authorization" => "Basic Yng6Yng=",
|
180
|
-
"Content-Type" => "application/x-www-form-urlencoded",
|
181
|
-
"Host" => "iam.cloud.ibm.com"
|
182
|
-
}
|
183
|
-
).to_return(status: 200, body: response.to_json, headers: {})
|
184
|
-
token_manager.user_access_token = ""
|
185
|
-
token = token_manager.token
|
186
|
-
assert_equal("hellohello", token)
|
187
|
-
|
188
|
-
token_manager.token_info["expiration"] = Time.now.to_i - (20 * 24 * 3600)
|
189
|
-
token = token_manager.token
|
190
|
-
assert_equal("hellohello", token)
|
191
138
|
|
192
139
|
stub_request(:post, "https://iam.cloud.ibm.com/identity/token")
|
193
140
|
.with(
|
141
|
+
body: { "apikey" => "iam_apikey", "grant_type" => "urn:ibm:params:oauth:grant-type:apikey", "response_type" => "cloud_iam" },
|
194
142
|
headers: {
|
195
143
|
"Accept" => "application/json",
|
196
144
|
"Authorization" => "Basic Yng6Yng=",
|
@@ -198,19 +146,8 @@ class IAMTokenManagerTest < Minitest::Test
|
|
198
146
|
"Host" => "iam.cloud.ibm.com"
|
199
147
|
}
|
200
148
|
).to_return(status: 200, body: response.to_json, headers: {})
|
201
|
-
token_manager.token_info["expiration"] = Time.now.to_i - 4000
|
202
149
|
token = token_manager.token
|
203
|
-
assert_equal(
|
204
|
-
|
205
|
-
token_manager.token_info = {
|
206
|
-
"access_token" => "dummy",
|
207
|
-
"token_type" => "Bearer",
|
208
|
-
"expires_in" => 3600,
|
209
|
-
"expiration" => Time.now.to_i + 3600,
|
210
|
-
"refresh_token" => "jy4gl91BQ"
|
211
|
-
}
|
212
|
-
token = token_manager.token
|
213
|
-
assert_equal("dummy", token)
|
150
|
+
assert_equal(token_manager.user_access_token, token)
|
214
151
|
end
|
215
152
|
|
216
153
|
def test_client_id_only
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative("./../test_helper.rb")
|
4
|
+
require("webmock/minitest")
|
5
|
+
|
6
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
7
|
+
|
8
|
+
# Unit tests for the ICP4D Token Manager
|
9
|
+
class ICP4DTokenManagerTest < Minitest::Test
|
10
|
+
def test_request_token
|
11
|
+
response = {
|
12
|
+
"access_token" => "oAeisG8yqPY7sFR_x66Z15",
|
13
|
+
"token_type" => "Bearer",
|
14
|
+
"expires_in" => 3600,
|
15
|
+
"expiration" => 1_524_167_011,
|
16
|
+
"refresh_token" => "jy4gl91BQ"
|
17
|
+
}
|
18
|
+
|
19
|
+
token_manager = IBMCloudSdkCore::ICP4DTokenManager.new(
|
20
|
+
url: "https://the.sixth.one",
|
21
|
+
username: "you",
|
22
|
+
password: "me"
|
23
|
+
)
|
24
|
+
stub_request(:get, "https://the.sixth.one/v1/preauth/validateAuth")
|
25
|
+
.with(
|
26
|
+
headers: {
|
27
|
+
"Authorization" => "Basic eW91Om1l",
|
28
|
+
"Host" => "the.sixth.one"
|
29
|
+
}
|
30
|
+
).to_return(status: 200, body: response.to_json, headers: {})
|
31
|
+
token_response = token_manager.send(:request_token)
|
32
|
+
assert_equal(response, token_response)
|
33
|
+
token_manager.access_token("token")
|
34
|
+
assert_equal(token_manager.instance_variable_get(:@user_access_token), "token")
|
35
|
+
end
|
36
|
+
|
37
|
+
def test_request_token_fails
|
38
|
+
token_manager = IBMCloudSdkCore::ICP4DTokenManager.new(
|
39
|
+
url: "https://the.sixth.one",
|
40
|
+
username: "you",
|
41
|
+
password: "me"
|
42
|
+
)
|
43
|
+
response = {
|
44
|
+
"code" => "500",
|
45
|
+
"error" => "Oh no"
|
46
|
+
}
|
47
|
+
stub_request(:get, "https://the.sixth.one/v1/preauth/validateAuth")
|
48
|
+
.with(
|
49
|
+
headers: {
|
50
|
+
"Authorization" => "Basic eW91Om1l",
|
51
|
+
"Host" => "the.sixth.one"
|
52
|
+
}
|
53
|
+
).to_return(status: 500, body: response.to_json, headers: {})
|
54
|
+
begin
|
55
|
+
token_manager.send(:request_token)
|
56
|
+
rescue IBMCloudSdkCore::ApiException => e
|
57
|
+
assert(e.to_s.instance_of?(String))
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,104 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require_relative("./../test_helper.rb")
|
4
|
+
require("jwt")
|
5
|
+
require("webmock/minitest")
|
6
|
+
|
7
|
+
WebMock.disable_net_connect!(allow_localhost: true)
|
8
|
+
|
9
|
+
# Unit tests for the JWT Token Manager
|
10
|
+
class JWTTokenManagerTest < Minitest::Test
|
11
|
+
def test_request_token
|
12
|
+
response = {
|
13
|
+
"access_token" => "oAeisG8yqPY7sFR_x66Z15",
|
14
|
+
"token_type" => "Bearer",
|
15
|
+
"expires_in" => 3600,
|
16
|
+
"expiration" => 1_524_167_011,
|
17
|
+
"refresh_token" => "jy4gl91BQ"
|
18
|
+
}
|
19
|
+
|
20
|
+
token_manager = IBMCloudSdkCore::JWTTokenManager.new(
|
21
|
+
icp4d_url: "https://the.sixth.one",
|
22
|
+
username: "you",
|
23
|
+
password: "me"
|
24
|
+
)
|
25
|
+
stub_request(:get, "https://the.sixth.one")
|
26
|
+
.with(
|
27
|
+
headers: {
|
28
|
+
"Authorization" => "Basic Og==",
|
29
|
+
"Host" => "the.sixth.one"
|
30
|
+
}
|
31
|
+
).to_return(status: 200, body: response.to_json, headers: {})
|
32
|
+
token_response = token_manager.send(:request, method: "get", url: "https://the.sixth.one")
|
33
|
+
assert_equal(response, token_response)
|
34
|
+
token_manager.access_token("token")
|
35
|
+
assert_equal(token_manager.instance_variable_get(:@user_access_token), "token")
|
36
|
+
end
|
37
|
+
|
38
|
+
def test_request_token_fails
|
39
|
+
token_manager = IBMCloudSdkCore::JWTTokenManager.new(
|
40
|
+
icp4d_url: "https://the.sixth.one",
|
41
|
+
username: "you",
|
42
|
+
password: "me"
|
43
|
+
)
|
44
|
+
response = {
|
45
|
+
"code" => "500",
|
46
|
+
"error" => "Oh no"
|
47
|
+
}
|
48
|
+
stub_request(:get, "https://the.sixth.one/")
|
49
|
+
.with(
|
50
|
+
headers: {
|
51
|
+
"Authorization" => "Basic Og==",
|
52
|
+
"Host" => "the.sixth.one"
|
53
|
+
}
|
54
|
+
).to_return(status: 500, body: response.to_json, headers: {})
|
55
|
+
begin
|
56
|
+
token_manager.send(:request, method: "get", url: "https://the.sixth.one")
|
57
|
+
rescue IBMCloudSdkCore::ApiException => e
|
58
|
+
assert(e.to_s.instance_of?(String))
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def test_request_token_exists
|
63
|
+
token_manager = IBMCloudSdkCore::JWTTokenManager.new(
|
64
|
+
icp4d_url: "https://the.sixth.one",
|
65
|
+
username: "you",
|
66
|
+
password: "me",
|
67
|
+
access_token: "token"
|
68
|
+
)
|
69
|
+
token_response = token_manager.send(:token)
|
70
|
+
assert_equal("token", token_response)
|
71
|
+
end
|
72
|
+
|
73
|
+
def test_request_token_not_expired
|
74
|
+
access_token_layout = {
|
75
|
+
"username" => "dummy",
|
76
|
+
"role" => "Admin",
|
77
|
+
"permissions" => %w[administrator manage_catalog],
|
78
|
+
"sub" => "admin",
|
79
|
+
"iss" => "sss",
|
80
|
+
"aud" => "sss",
|
81
|
+
"uid" => "sss",
|
82
|
+
"iat" => Time.now.to_i,
|
83
|
+
"exp" => Time.now.to_i + (6 * 3600)
|
84
|
+
}
|
85
|
+
access_token = JWT.encode(access_token_layout, "secret", "HS256", "kid": "230498151c214b788dd97f22b85410a5")
|
86
|
+
|
87
|
+
token = {
|
88
|
+
"access_token" => access_token,
|
89
|
+
"token_type" => "Bearer",
|
90
|
+
"expires_in" => 3600,
|
91
|
+
"expiration" => Time.now.to_i + (6 * 3600),
|
92
|
+
"refresh_token" => "jy4gl91BQ"
|
93
|
+
}
|
94
|
+
|
95
|
+
token_manager = IBMCloudSdkCore::JWTTokenManager.new(
|
96
|
+
icp4d_url: "https://the.sixth.one",
|
97
|
+
username: "you",
|
98
|
+
password: "me"
|
99
|
+
)
|
100
|
+
token_manager.send(:save_token_info, token_info: token)
|
101
|
+
token_response = token_manager.send(:token)
|
102
|
+
assert_equal(access_token, token_response)
|
103
|
+
end
|
104
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: ibm_cloud_sdk_core
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.3.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mamoon Raja
|
8
8
|
autorequire:
|
9
9
|
bindir: exe
|
10
10
|
cert_chain: []
|
11
|
-
date: 2019-
|
11
|
+
date: 2019-06-07 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: concurrent-ruby
|
@@ -38,6 +38,20 @@ dependencies:
|
|
38
38
|
- - "~>"
|
39
39
|
- !ruby/object:Gem::Version
|
40
40
|
version: 4.1.0
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: jwt
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: 2.2.1
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: 2.2.1
|
41
55
|
- !ruby/object:Gem::Dependency
|
42
56
|
name: bundler
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
@@ -218,6 +232,8 @@ files:
|
|
218
232
|
- lib/ibm_cloud_sdk_core/base_service.rb
|
219
233
|
- lib/ibm_cloud_sdk_core/detailed_response.rb
|
220
234
|
- lib/ibm_cloud_sdk_core/iam_token_manager.rb
|
235
|
+
- lib/ibm_cloud_sdk_core/icp4d_token_manager.rb
|
236
|
+
- lib/ibm_cloud_sdk_core/jwt_token_manager.rb
|
221
237
|
- lib/ibm_cloud_sdk_core/version.rb
|
222
238
|
- rakefile
|
223
239
|
- test/test_helper.rb
|
@@ -225,6 +241,8 @@ files:
|
|
225
241
|
- test/unit/test_configure_http_proxy.rb
|
226
242
|
- test/unit/test_detailed_response.rb
|
227
243
|
- test/unit/test_iam_token_manager.rb
|
244
|
+
- test/unit/test_icp4d_token_manager.rb
|
245
|
+
- test/unit/test_jwt_token_manager.rb
|
228
246
|
homepage: https://www.github.com/IBM
|
229
247
|
licenses:
|
230
248
|
- Apache-2.0
|