ibm_cloud_sdk_core 0.2.0 → 0.3.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 +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
|