aspera-cli 4.16.0 → 4.17.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
- checksums.yaml.gz.sig +0 -0
- data/CHANGELOG.md +50 -19
- data/CONTRIBUTING.md +3 -1
- data/README.md +965 -793
- data/bin/asession +29 -21
- data/lib/aspera/{fasp/agent_alpha.rb → agent/alpha.rb} +26 -25
- data/lib/aspera/{fasp/agent_base.rb → agent/base.rb} +15 -12
- data/lib/aspera/{fasp/agent_connect.rb → agent/connect.rb} +13 -11
- data/lib/aspera/{fasp/agent_direct.rb → agent/direct.rb} +49 -53
- data/lib/aspera/{fasp/agent_httpgw.rb → agent/httpgw.rb} +20 -19
- data/lib/aspera/{fasp/agent_node.rb → agent/node.rb} +20 -33
- data/lib/aspera/{fasp/agent_trsdk.rb → agent/trsdk.rb} +11 -11
- data/lib/aspera/api/aoc.rb +586 -0
- data/lib/aspera/api/ats.rb +46 -0
- data/lib/aspera/api/cos_node.rb +95 -0
- data/lib/aspera/api/node.rb +344 -0
- data/lib/aspera/ascmd.rb +46 -10
- data/lib/aspera/{fasp → ascp}/installation.rb +5 -5
- data/lib/aspera/{fasp → ascp}/management.rb +3 -8
- data/lib/aspera/{fasp → ascp}/products.rb +1 -1
- data/lib/aspera/assert.rb +30 -30
- data/lib/aspera/cli/basic_auth_plugin.rb +11 -10
- data/lib/aspera/cli/extended_value.rb +1 -1
- data/lib/aspera/cli/formatter.rb +13 -13
- data/lib/aspera/cli/hints.rb +5 -5
- data/lib/aspera/cli/main.rb +35 -28
- data/lib/aspera/cli/manager.rb +25 -24
- data/lib/aspera/cli/plugin.rb +22 -15
- data/lib/aspera/cli/plugin_factory.rb +61 -0
- data/lib/aspera/cli/plugins/alee.rb +7 -7
- data/lib/aspera/cli/plugins/aoc.rb +83 -77
- data/lib/aspera/cli/plugins/ats.rb +32 -33
- data/lib/aspera/cli/plugins/bss.rb +3 -4
- data/lib/aspera/cli/plugins/config.rb +169 -186
- data/lib/aspera/cli/plugins/console.rb +8 -6
- data/lib/aspera/cli/plugins/cos.rb +19 -18
- data/lib/aspera/cli/plugins/faspex.rb +61 -54
- data/lib/aspera/cli/plugins/faspex5.rb +150 -103
- data/lib/aspera/cli/plugins/node.rb +68 -73
- data/lib/aspera/cli/plugins/orchestrator.rb +34 -44
- data/lib/aspera/cli/plugins/preview.rb +31 -31
- data/lib/aspera/cli/plugins/server.rb +31 -33
- data/lib/aspera/cli/plugins/shares.rb +13 -11
- data/lib/aspera/cli/sync_actions.rb +8 -8
- data/lib/aspera/cli/transfer_agent.rb +32 -19
- data/lib/aspera/cli/transfer_progress.rb +1 -1
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/colors.rb +5 -0
- data/lib/aspera/command_line_builder.rb +14 -14
- data/lib/aspera/coverage.rb +1 -2
- data/lib/aspera/data_repository.rb +1 -1
- data/lib/aspera/environment.rb +2 -3
- data/lib/aspera/faspex_gw.rb +5 -6
- data/lib/aspera/faspex_postproc.rb +1 -1
- data/lib/aspera/id_generator.rb +2 -2
- data/lib/aspera/json_rpc.rb +5 -5
- data/lib/aspera/keychain/encrypted_hash.rb +6 -6
- data/lib/aspera/keychain/macos_security.rb +27 -22
- data/lib/aspera/log.rb +2 -2
- data/lib/aspera/nagios.rb +3 -3
- data/lib/aspera/node_simulator.rb +5 -6
- data/lib/aspera/oauth/base.rb +143 -0
- data/lib/aspera/oauth/factory.rb +124 -0
- data/lib/aspera/oauth/generic.rb +34 -0
- data/lib/aspera/oauth/jwt.rb +51 -0
- data/lib/aspera/oauth/url_json.rb +31 -0
- data/lib/aspera/oauth/web.rb +50 -0
- data/lib/aspera/oauth.rb +5 -331
- data/lib/aspera/open_application.rb +7 -7
- data/lib/aspera/persistency_action_once.rb +4 -4
- data/lib/aspera/persistency_folder.rb +2 -2
- data/lib/aspera/preview/generator.rb +5 -5
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/preview/utils.rb +3 -3
- data/lib/aspera/proxy_auto_config.rb +4 -4
- data/lib/aspera/rest.rb +175 -144
- data/lib/aspera/rest_errors_aspera.rb +3 -3
- data/lib/aspera/resumer.rb +77 -0
- data/lib/aspera/ssh.rb +6 -1
- data/lib/aspera/{fasp → transfer}/error.rb +3 -3
- data/lib/aspera/{fasp → transfer}/error_info.rb +1 -1
- data/lib/aspera/{fasp → transfer}/faux_file.rb +1 -1
- data/lib/aspera/{fasp → transfer}/parameters.rb +58 -89
- data/lib/aspera/{fasp/transfer_spec.rb → transfer/spec.rb} +18 -16
- data/lib/aspera/{fasp/parameters.yaml → transfer/spec.yaml} +4 -99
- data/lib/aspera/{fasp → transfer}/sync.rb +32 -32
- data/lib/aspera/{fasp → transfer}/uri.rb +9 -8
- data/lib/aspera/web_server_simple.rb +11 -3
- data.tar.gz.sig +0 -0
- metadata +36 -63
- metadata.gz.sig +0 -0
- data/lib/aspera/aoc.rb +0 -601
- data/lib/aspera/ats_api.rb +0 -47
- data/lib/aspera/cos_node.rb +0 -94
- data/lib/aspera/fasp/resume_policy.rb +0 -79
- data/lib/aspera/node.rb +0 -339
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
require 'base64'
|
|
6
|
+
module Aspera
|
|
7
|
+
module OAuth
|
|
8
|
+
class Factory
|
|
9
|
+
include Singleton
|
|
10
|
+
# a prefix for persistency of tokens (simplify garbage collect)
|
|
11
|
+
PERSIST_CATEGORY_TOKEN = 'token'
|
|
12
|
+
# prefix for bearer token when in header
|
|
13
|
+
BEARER_PREFIX = 'Bearer '
|
|
14
|
+
|
|
15
|
+
private_constant :PERSIST_CATEGORY_TOKEN, :BEARER_PREFIX
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def bearer_build(token)
|
|
19
|
+
return BEARER_PREFIX + token
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def bearer_extract(token)
|
|
23
|
+
Aspera.assert(bearer?(token)){'not a bearer token, wrong prefix'}
|
|
24
|
+
return token[BEARER_PREFIX.length..-1]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bearer?(token)
|
|
28
|
+
return token.start_with?(BEARER_PREFIX)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def id(*params)
|
|
32
|
+
return [PERSIST_CATEGORY_TOKEN, *params].flatten
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def class_to_id(creator_class)
|
|
36
|
+
return creator_class.name.split('::').last.capital_to_snake.to_sym
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def initialize
|
|
43
|
+
# persistency manager
|
|
44
|
+
@persist = nil
|
|
45
|
+
# token creation methods
|
|
46
|
+
@token_type_classes = {}
|
|
47
|
+
@decoders = []
|
|
48
|
+
@globals = {
|
|
49
|
+
# remove 5 minutes to account for time offset between client and server (TODO: configurable?)
|
|
50
|
+
jwt_accepted_offset_sec: 300,
|
|
51
|
+
# one hour validity (TODO: configurable?)
|
|
52
|
+
jwt_expiry_offset_sec: 3600,
|
|
53
|
+
# tokens older than 30 minutes will be discarded from cache
|
|
54
|
+
token_cache_expiry_sec: 1800,
|
|
55
|
+
# tokens valid for less than this duration will be regenerated
|
|
56
|
+
token_expiration_guard_sec: 120
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
public
|
|
61
|
+
|
|
62
|
+
attr_reader :globals
|
|
63
|
+
|
|
64
|
+
def persist_mgr=(manager)
|
|
65
|
+
@persist = manager
|
|
66
|
+
# cleanup expired tokens
|
|
67
|
+
@persist.garbage_collect(PERSIST_CATEGORY_TOKEN, @globals[:token_cache_expiry_sec])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def persist_mgr
|
|
71
|
+
if @persist.nil?
|
|
72
|
+
# use OAuth::Factory.instance.persist_mgr=PersistencyFolder.new)
|
|
73
|
+
Log.log.debug('Not using persistency')
|
|
74
|
+
# create NULL persistency class
|
|
75
|
+
@persist = Class.new do
|
|
76
|
+
def get(_x); nil; end; def delete(_x); nil; end; def put(_x, _y); nil; end; def garbage_collect(_x, _y); nil; end # rubocop:disable Layout/EmptyLineBetweenDefs, Style/Semicolon, Layout/LineLength
|
|
77
|
+
end.new
|
|
78
|
+
end
|
|
79
|
+
return @persist
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# delete all existing tokens
|
|
83
|
+
def flush_tokens
|
|
84
|
+
persist_mgr.garbage_collect(PERSIST_CATEGORY_TOKEN, nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# register a bearer token decoder, mainly to inspect expiry date
|
|
88
|
+
def register_decoder(method)
|
|
89
|
+
@decoders.push(method)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# decode token using all registered decoders
|
|
93
|
+
def decode_token(token)
|
|
94
|
+
@decoders.each do |decoder|
|
|
95
|
+
result = decoder.call(token) rescue nil
|
|
96
|
+
return result unless result.nil?
|
|
97
|
+
end
|
|
98
|
+
return nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# register a token creation method
|
|
102
|
+
# @param id creation type from field :grant_method in constructor
|
|
103
|
+
# @param lambda_create called to create token
|
|
104
|
+
# @param id_create called to generate unique id for token, for cache
|
|
105
|
+
def register_token_creator(creator_class)
|
|
106
|
+
Aspera.assert_type(creator_class, Class)
|
|
107
|
+
id = self.class.class_to_id(creator_class)
|
|
108
|
+
Log.log.debug{"registering token creator #{id}"}
|
|
109
|
+
@token_type_classes[id] = creator_class
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# @return one of the registered creators for the given create type
|
|
113
|
+
def create(**parameters)
|
|
114
|
+
Aspera.assert_type(parameters, Hash)
|
|
115
|
+
id = parameters[:grant_method]
|
|
116
|
+
Aspera.assert(@token_type_classes.key?(id)){"token grant method unknown: '#{id}'"}
|
|
117
|
+
create_parameters = parameters.reject { |k, _v| k.eql?(:grant_method) }
|
|
118
|
+
@token_type_classes[id].new(**create_parameters)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
# JSON Web Signature (JWS) compact serialization: https://datatracker.ietf.org/doc/html/rfc7515
|
|
122
|
+
Factory.instance.register_decoder(lambda { |token| parts = token.split('.'); Aspera.assert(parts.length.eql?(3)){'not aoc token'}; JSON.parse(Base64.decode64(parts[1]))}) # rubocop:disable Style/Semicolon, Layout/LineLength
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/oauth/base'
|
|
4
|
+
|
|
5
|
+
module Aspera
|
|
6
|
+
module OAuth
|
|
7
|
+
class Generic < Base
|
|
8
|
+
def initialize(
|
|
9
|
+
grant_type:,
|
|
10
|
+
response_type: nil,
|
|
11
|
+
apikey: nil,
|
|
12
|
+
receiver_client_ids: nil,
|
|
13
|
+
**base_params
|
|
14
|
+
)
|
|
15
|
+
super(**base_params)
|
|
16
|
+
@create_params = {
|
|
17
|
+
grant_type: grant_type
|
|
18
|
+
}
|
|
19
|
+
@create_params[:response_type] = response_type if response_type
|
|
20
|
+
@create_params[:apikey] = apikey if apikey
|
|
21
|
+
@create_params[:receiver_client_ids] = receiver_client_ids if receiver_client_ids
|
|
22
|
+
@identifiers.push(
|
|
23
|
+
@create_params[:grant_type]&.split(':')&.last,
|
|
24
|
+
@create_params[:apikey],
|
|
25
|
+
@create_params[:response_type])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_token
|
|
29
|
+
return create_token_call(optional_scope_client_id.merge(@create_params))
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
Factory.instance.register_token_creator(Generic)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/oauth/base'
|
|
4
|
+
require 'aspera/assert'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
module Aspera
|
|
7
|
+
module OAuth
|
|
8
|
+
# Authentication using private key
|
|
9
|
+
class Jwt < Base
|
|
10
|
+
# @param g_o:private_key_obj [M] for type :jwt
|
|
11
|
+
# @param g_o:payload [M] for type :jwt
|
|
12
|
+
# @param g_o:headers [0] for type :jwt
|
|
13
|
+
def initialize(
|
|
14
|
+
payload:,
|
|
15
|
+
private_key_obj:,
|
|
16
|
+
headers: {},
|
|
17
|
+
**base_params
|
|
18
|
+
)
|
|
19
|
+
Aspera.assert_type(payload, Hash){'payload'}
|
|
20
|
+
Aspera.assert_type(private_key_obj, OpenSSL::PKey::RSA){'private_key_obj'}
|
|
21
|
+
Aspera.assert_type(headers, Hash){'headers'}
|
|
22
|
+
super(**base_params)
|
|
23
|
+
@private_key_obj = private_key_obj
|
|
24
|
+
@payload = payload
|
|
25
|
+
@headers = headers
|
|
26
|
+
@identifiers.push(@payload[:sub])
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def create_token
|
|
30
|
+
# https://tools.ietf.org/html/rfc7523
|
|
31
|
+
# https://tools.ietf.org/html/rfc7519
|
|
32
|
+
require 'jwt'
|
|
33
|
+
seconds_since_epoch = Time.new.to_i
|
|
34
|
+
Log.log.info{"seconds=#{seconds_since_epoch}"}
|
|
35
|
+
Aspera.assert(@payload.is_a?(Hash)){'missing JWT payload'}
|
|
36
|
+
jwt_payload = {
|
|
37
|
+
exp: seconds_since_epoch + OAuth::Factory.instance.globals[:jwt_expiry_offset_sec], # expiration time
|
|
38
|
+
nbf: seconds_since_epoch - OAuth::Factory.instance.globals[:jwt_accepted_offset_sec], # not before
|
|
39
|
+
iat: seconds_since_epoch - OAuth::Factory.instance.globals[:jwt_accepted_offset_sec] + 1, # issued at
|
|
40
|
+
jti: SecureRandom.uuid # JWT id
|
|
41
|
+
}.merge(@payload)
|
|
42
|
+
Log.log.debug{"JWT jwt_payload=[#{jwt_payload}]"}
|
|
43
|
+
Log.log.debug{"private=[#{@private_key_obj}]"}
|
|
44
|
+
assertion = JWT.encode(jwt_payload, @private_key_obj, 'RS256', @headers)
|
|
45
|
+
Log.log.debug{"assertion=[#{assertion}]"}
|
|
46
|
+
return create_token_call(optional_scope_client_id.merge(grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: assertion))
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
Factory.instance.register_token_creator(Jwt)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/oauth/base'
|
|
4
|
+
|
|
5
|
+
module Aspera
|
|
6
|
+
module OAuth
|
|
7
|
+
class UrlJson < Base
|
|
8
|
+
def initialize(
|
|
9
|
+
json:,
|
|
10
|
+
url:,
|
|
11
|
+
**generic_params
|
|
12
|
+
)
|
|
13
|
+
super(**generic_params)
|
|
14
|
+
@json_params = json
|
|
15
|
+
@url_params = url
|
|
16
|
+
@identifiers.push(@json_params[:url_token])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def create_token
|
|
20
|
+
@api.call(
|
|
21
|
+
operation: 'POST',
|
|
22
|
+
subpath: @path_token,
|
|
23
|
+
headers: {'Accept' => 'application/json'},
|
|
24
|
+
json_params: @json_params,
|
|
25
|
+
url_params: @url_params.merge(scope: @scope) # scope is here because it may change over time (node)
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
Factory.instance.register_token_creator(UrlJson)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'aspera/oauth/base'
|
|
4
|
+
require 'aspera/open_application'
|
|
5
|
+
require 'aspera/web_auth'
|
|
6
|
+
require 'aspera/assert'
|
|
7
|
+
module Aspera
|
|
8
|
+
module OAuth
|
|
9
|
+
# Authentication using Web browser
|
|
10
|
+
class Web < Base
|
|
11
|
+
# @param g_o:redirect_uri [M] for type :web
|
|
12
|
+
# @param g_o:path_authorize [D] for type :web
|
|
13
|
+
def initialize(
|
|
14
|
+
redirect_uri:,
|
|
15
|
+
path_authorize: 'authorize',
|
|
16
|
+
**base_params
|
|
17
|
+
)
|
|
18
|
+
super(**base_params)
|
|
19
|
+
@redirect_uri = redirect_uri
|
|
20
|
+
@path_authorize = path_authorize
|
|
21
|
+
uri = URI.parse(@redirect_uri)
|
|
22
|
+
Aspera.assert(%w[http https].include?(uri.scheme)){'redirect_uri scheme must be http or https'}
|
|
23
|
+
Aspera.assert(!uri.port.nil?){'redirect_uri must have a port'}
|
|
24
|
+
# TODO: we could check that host is localhost or local address
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_token
|
|
28
|
+
random_state = SecureRandom.uuid # used to check later
|
|
29
|
+
login_page_url = Rest.build_uri(
|
|
30
|
+
"#{@base_url}/#{@path_authorize}",
|
|
31
|
+
optional_scope_client_id.merge(response_type: 'code', redirect_uri: @redirect_uri, state: random_state))
|
|
32
|
+
# here, we need a human to authorize on a web page
|
|
33
|
+
Log.log.info{"login_page_url=#{login_page_url}".bg_red.gray}
|
|
34
|
+
# start a web server to receive request code
|
|
35
|
+
web_server = WebAuth.new(@redirect_uri)
|
|
36
|
+
# start browser on login page
|
|
37
|
+
OpenApplication.instance.uri(login_page_url)
|
|
38
|
+
# wait for code in request
|
|
39
|
+
received_params = web_server.received_request
|
|
40
|
+
Aspera.assert(random_state.eql?(received_params['state'])){'wrong received state'}
|
|
41
|
+
# exchange code for token
|
|
42
|
+
return create_token_call(optional_scope_client_id(add_secret: true).merge(
|
|
43
|
+
grant_type: 'authorization_code',
|
|
44
|
+
code: received_params['code'],
|
|
45
|
+
redirect_uri: @redirect_uri))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
Factory.instance.register_token_creator(Web)
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/aspera/oauth.rb
CHANGED
|
@@ -1,333 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/
|
|
4
|
-
require 'aspera/
|
|
5
|
-
require 'aspera/
|
|
6
|
-
require 'aspera/
|
|
7
|
-
require 'aspera/
|
|
8
|
-
require 'base64'
|
|
9
|
-
require 'date'
|
|
10
|
-
require 'socket'
|
|
11
|
-
require 'securerandom'
|
|
12
|
-
|
|
13
|
-
module Aspera
|
|
14
|
-
# Implement OAuth 2 for the REST client and generate a bearer token
|
|
15
|
-
# call get_authorization() to get a token.
|
|
16
|
-
# bearer tokens are kept in memory and also in a file cache for later re-use
|
|
17
|
-
# if a token is expired (api returns 4xx), call again get_authorization({refresh: true})
|
|
18
|
-
# https://tools.ietf.org/html/rfc6749
|
|
19
|
-
class Oauth
|
|
20
|
-
DEFAULT_CREATE_PARAMS = {
|
|
21
|
-
path_token: 'token', # default endpoint for /token to generate token
|
|
22
|
-
token_field: 'access_token', # field with token in result of call to path_token
|
|
23
|
-
web: {path_authorize: 'authorize'} # default endpoint for /authorize, used for code exchange
|
|
24
|
-
}.freeze
|
|
25
|
-
|
|
26
|
-
# OAuth methods supported by default
|
|
27
|
-
STD_AUTH_TYPES = %i[web jwt].freeze
|
|
28
|
-
|
|
29
|
-
@@globals = { # rubocop:disable Style/ClassVars
|
|
30
|
-
# remove 5 minutes to account for time offset between client and server (TODO: configurable?)
|
|
31
|
-
jwt_accepted_offset_sec: 300,
|
|
32
|
-
# one hour validity (TODO: configurable?)
|
|
33
|
-
jwt_expiry_offset_sec: 3600,
|
|
34
|
-
# tokens older than 30 minutes will be discarded from cache
|
|
35
|
-
token_cache_expiry_sec: 1800,
|
|
36
|
-
# tokens valid for less than this duration will be regenerated
|
|
37
|
-
token_expiration_guard_sec: 120
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
# a prefix for persistency of tokens (simplify garbage collect)
|
|
41
|
-
PERSIST_CATEGORY_TOKEN = 'token'
|
|
42
|
-
# prefix for bearer token when in header
|
|
43
|
-
BEARER_PREFIX = 'Bearer '
|
|
44
|
-
|
|
45
|
-
private_constant :PERSIST_CATEGORY_TOKEN, :BEARER_PREFIX
|
|
46
|
-
|
|
47
|
-
# persistency manager
|
|
48
|
-
@persist = nil
|
|
49
|
-
# token creation methods
|
|
50
|
-
@create_handlers = {}
|
|
51
|
-
# token unique identifiers from oauth parameters
|
|
52
|
-
@id_handlers = {}
|
|
53
|
-
|
|
54
|
-
class << self
|
|
55
|
-
def bearer_build(token)
|
|
56
|
-
return BEARER_PREFIX + token
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def bearer_extract(token)
|
|
60
|
-
assert(bearer?(token)){'not a bearer token, wrong prefix'}
|
|
61
|
-
return token[BEARER_PREFIX.length..-1]
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def bearer?(token)
|
|
65
|
-
return token.start_with?(BEARER_PREFIX)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def persist_mgr=(manager)
|
|
69
|
-
@persist = manager
|
|
70
|
-
# cleanup expired tokens
|
|
71
|
-
@persist.garbage_collect(PERSIST_CATEGORY_TOKEN, @@globals[:token_cache_expiry_sec])
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def persist_mgr
|
|
75
|
-
if @persist.nil?
|
|
76
|
-
Log.log.debug('Not using persistency') # (use Aspera::Oauth.persist_mgr=Aspera::PersistencyFolder.new)
|
|
77
|
-
# create NULL persistency class
|
|
78
|
-
@persist = Class.new do
|
|
79
|
-
def get(_x); nil; end; def delete(_x); nil; end; def put(_x, _y); nil; end; def garbage_collect(_x, _y); nil; end # rubocop:disable Layout/EmptyLineBetweenDefs, Style/Semicolon, Layout/LineLength
|
|
80
|
-
end.new
|
|
81
|
-
end
|
|
82
|
-
return @persist
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
# delete all existing tokens
|
|
86
|
-
def flush_tokens
|
|
87
|
-
persist_mgr.garbage_collect(PERSIST_CATEGORY_TOKEN, nil)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
# register a bearer token decoder, mainly to inspect expiry date
|
|
91
|
-
def register_decoder(method)
|
|
92
|
-
@decoders ||= []
|
|
93
|
-
@decoders.push(method)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
# decode token using all registered decoders
|
|
97
|
-
def decode_token(token)
|
|
98
|
-
@decoders.each do |decoder|
|
|
99
|
-
result = decoder.call(token) rescue nil
|
|
100
|
-
return result unless result.nil?
|
|
101
|
-
end
|
|
102
|
-
return nil
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# register a token creation method
|
|
106
|
-
# @param id creation type from field :grant_method in constructor
|
|
107
|
-
# @param lambda_create called to create token
|
|
108
|
-
# @param id_create called to generate unique id for token, for cache
|
|
109
|
-
def register_token_creator(id, lambda_create, id_create)
|
|
110
|
-
Log.log.debug{"registering token creator #{id}"}
|
|
111
|
-
assert_type(id, Symbol)
|
|
112
|
-
assert_type(lambda_create, Proc)
|
|
113
|
-
assert_type(id_create, Proc)
|
|
114
|
-
@create_handlers[id] = lambda_create
|
|
115
|
-
@id_handlers[id] = id_create
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# @return one of the registered creators for the given create type
|
|
119
|
-
def token_creator(id)
|
|
120
|
-
assert(@create_handlers.key?(id)){"token grant method unknown: '#{id}' (#{id.class})"}
|
|
121
|
-
@create_handlers[id]
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# list of identifiers found in creation parameters that can be used to uniquely identify the token
|
|
125
|
-
def id_creator(id)
|
|
126
|
-
assert(@id_handlers.key?(id)){"id creator type unknown: #{id}/#{id.class}"}
|
|
127
|
-
@id_handlers[id]
|
|
128
|
-
end
|
|
129
|
-
end # self
|
|
130
|
-
|
|
131
|
-
# JSON Web Signature (JWS) compact serialization: https://datatracker.ietf.org/doc/html/rfc7515
|
|
132
|
-
register_decoder lambda { |token| parts = token.split('.'); assert(parts.length.eql?(3)){'not aoc token'}; JSON.parse(Base64.decode64(parts[1]))} # rubocop:disable Style/Semicolon, Layout/LineLength
|
|
133
|
-
|
|
134
|
-
# generic token creation, parameters are provided in :generic
|
|
135
|
-
register_token_creator :generic, lambda { |oauth|
|
|
136
|
-
return oauth.create_token(oauth.specific_parameters)
|
|
137
|
-
}, lambda { |oauth|
|
|
138
|
-
return [
|
|
139
|
-
oauth.specific_parameters[:grant_type]&.split(':')&.last,
|
|
140
|
-
oauth.specific_parameters[:apikey],
|
|
141
|
-
oauth.specific_parameters[:response_type]
|
|
142
|
-
]
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
# Authentication using Web browser
|
|
146
|
-
register_token_creator :web, lambda { |oauth|
|
|
147
|
-
random_state = SecureRandom.uuid # used to check later
|
|
148
|
-
login_page_url = Rest.build_uri(
|
|
149
|
-
"#{oauth.api.params[:base_url]}/#{oauth.specific_parameters[:path_authorize]}",
|
|
150
|
-
oauth.optional_scope_client_id.merge(response_type: 'code', redirect_uri: oauth.specific_parameters[:redirect_uri], state: random_state))
|
|
151
|
-
# here, we need a human to authorize on a web page
|
|
152
|
-
Log.log.info{"login_page_url=#{login_page_url}".bg_red.gray}
|
|
153
|
-
# start a web server to receive request code
|
|
154
|
-
web_server = WebAuth.new(oauth.specific_parameters[:redirect_uri])
|
|
155
|
-
# start browser on login page
|
|
156
|
-
OpenApplication.instance.uri(login_page_url)
|
|
157
|
-
# wait for code in request
|
|
158
|
-
received_params = web_server.received_request
|
|
159
|
-
assert(random_state.eql?(received_params['state'])){'wrong received state'}
|
|
160
|
-
# exchange code for token
|
|
161
|
-
return oauth.create_token(oauth.optional_scope_client_id(add_secret: true).merge(
|
|
162
|
-
grant_type: 'authorization_code',
|
|
163
|
-
code: received_params['code'],
|
|
164
|
-
redirect_uri: oauth.specific_parameters[:redirect_uri]))
|
|
165
|
-
}, lambda { |_oauth|
|
|
166
|
-
return []
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
# Authentication using private key
|
|
170
|
-
register_token_creator :jwt, lambda { |oauth|
|
|
171
|
-
# https://tools.ietf.org/html/rfc7523
|
|
172
|
-
# https://tools.ietf.org/html/rfc7519
|
|
173
|
-
require 'jwt'
|
|
174
|
-
seconds_since_epoch = Time.new.to_i
|
|
175
|
-
Log.log.info{"seconds=#{seconds_since_epoch}"}
|
|
176
|
-
assert(oauth.specific_parameters[:payload].is_a?(Hash)){'missing JWT payload'}
|
|
177
|
-
jwt_payload = {
|
|
178
|
-
exp: seconds_since_epoch + @@globals[:jwt_expiry_offset_sec], # expiration time
|
|
179
|
-
nbf: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec], # not before
|
|
180
|
-
iat: seconds_since_epoch - @@globals[:jwt_accepted_offset_sec] + 1, # issued at (we tell a little in the past so that server always accepts)
|
|
181
|
-
jti: SecureRandom.uuid # JWT id
|
|
182
|
-
}.merge(oauth.specific_parameters[:payload])
|
|
183
|
-
Log.log.debug{"JWT jwt_payload=[#{jwt_payload}]"}
|
|
184
|
-
rsa_private = oauth.specific_parameters[:private_key_obj] # type: OpenSSL::PKey::RSA
|
|
185
|
-
Log.log.debug{"private=[#{rsa_private}]"}
|
|
186
|
-
assertion = JWT.encode(jwt_payload, rsa_private, 'RS256', oauth.specific_parameters[:headers] || {})
|
|
187
|
-
Log.log.debug{"assertion=[#{assertion}]"}
|
|
188
|
-
return oauth.create_token(oauth.optional_scope_client_id.merge(grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: assertion))
|
|
189
|
-
}, lambda { |oauth|
|
|
190
|
-
return [oauth.specific_parameters.dig(:payload, :sub)]
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
attr_reader :generic_parameters, :specific_parameters, :api
|
|
194
|
-
|
|
195
|
-
private
|
|
196
|
-
|
|
197
|
-
# [M]=mandatory [D]=has default value [0]=accept nil
|
|
198
|
-
# :base_url [M] URL of authentication API
|
|
199
|
-
# :auth
|
|
200
|
-
# :grant_method [M] :generic, :web, :jwt, [custom types]
|
|
201
|
-
# :client_id [0]
|
|
202
|
-
# :client_secret [0]
|
|
203
|
-
# :scope [0]
|
|
204
|
-
# :path_token [D] API end point to create a token
|
|
205
|
-
# :token_field [D] field in result that contains the token
|
|
206
|
-
# :jwt:private_key_obj [M] for type :jwt
|
|
207
|
-
# :jwt:payload [M] for type :jwt
|
|
208
|
-
# :jwt:headers [0] for type :jwt
|
|
209
|
-
# :web:redirect_uri [M] for type :web
|
|
210
|
-
# :web:path_authorize [D] for type :web
|
|
211
|
-
# :generic [M] for type :generic
|
|
212
|
-
def initialize(a_params)
|
|
213
|
-
Log.log.debug{"auth=#{a_params}"}
|
|
214
|
-
# set default values if not set in parameters common to all types
|
|
215
|
-
@generic_parameters = DEFAULT_CREATE_PARAMS.deep_merge(a_params)
|
|
216
|
-
# check that type is known
|
|
217
|
-
self.class.token_creator(@generic_parameters[:grant_method])
|
|
218
|
-
# specific parameters for the creation type
|
|
219
|
-
@specific_parameters = @generic_parameters[@generic_parameters[:grant_method]]
|
|
220
|
-
if @generic_parameters[:grant_method].eql?(:web) && @specific_parameters.key?(:redirect_uri)
|
|
221
|
-
uri = URI.parse(@specific_parameters[:redirect_uri])
|
|
222
|
-
assert(%w[http https].include?(uri.scheme)){'redirect_uri scheme must be http or https'}
|
|
223
|
-
assert(!uri.port.nil?){'redirect_uri must have a port'}
|
|
224
|
-
# TODO: we could check that host is localhost or local address
|
|
225
|
-
end
|
|
226
|
-
rest_params = {
|
|
227
|
-
base_url: @generic_parameters[:base_url],
|
|
228
|
-
redirect_max: 2
|
|
229
|
-
}
|
|
230
|
-
rest_params[:auth] = a_params[:auth] if a_params.key?(:auth)
|
|
231
|
-
# this is the OAuth API
|
|
232
|
-
@api = Rest.new(rest_params)
|
|
233
|
-
# if those are needed use from @api
|
|
234
|
-
@generic_parameters.delete(:base_url)
|
|
235
|
-
@generic_parameters.delete(:auth)
|
|
236
|
-
@generic_parameters.delete(@generic_parameters[:grant_method])
|
|
237
|
-
Log.log.debug{Log.dump(:generic_parameters, @generic_parameters)}
|
|
238
|
-
Log.log.debug{Log.dump(:specific_parameters, @specific_parameters)}
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
public
|
|
242
|
-
|
|
243
|
-
# helper method to create token as per RFC
|
|
244
|
-
def create_token(www_params)
|
|
245
|
-
Log.log.debug{'Generating a new token'.bg_green}
|
|
246
|
-
return @api.call({
|
|
247
|
-
operation: 'POST',
|
|
248
|
-
subpath: @generic_parameters[:path_token],
|
|
249
|
-
headers: {'Accept' => 'application/json'},
|
|
250
|
-
www_body_params: www_params})
|
|
251
|
-
end
|
|
252
|
-
|
|
253
|
-
# @return Hash with optional general parameters
|
|
254
|
-
def optional_scope_client_id(add_secret: false)
|
|
255
|
-
call_params = {}
|
|
256
|
-
call_params[:scope] = @generic_parameters[:scope] unless @generic_parameters[:scope].nil?
|
|
257
|
-
call_params[:client_id] = @generic_parameters[:client_id] unless @generic_parameters[:client_id].nil?
|
|
258
|
-
call_params[:client_secret] = @generic_parameters[:client_secret] if add_secret && !@generic_parameters[:client_id].nil?
|
|
259
|
-
return call_params
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Oauth v2 token generation
|
|
263
|
-
# @param use_refresh_token set to true to force refresh or re-generation (if previous failed)
|
|
264
|
-
def get_authorization(use_refresh_token: false, use_cache: true)
|
|
265
|
-
# generate token unique identifier for persistency (memory/disk cache)
|
|
266
|
-
token_id = IdGenerator.from_list([
|
|
267
|
-
PERSIST_CATEGORY_TOKEN,
|
|
268
|
-
@api.params[:base_url],
|
|
269
|
-
@generic_parameters[:grant_method],
|
|
270
|
-
self.class.id_creator(@generic_parameters[:grant_method]).call(self), # array, so we flatten later
|
|
271
|
-
@generic_parameters[:scope],
|
|
272
|
-
@api.params.dig(*%i[auth username])
|
|
273
|
-
].flatten)
|
|
274
|
-
|
|
275
|
-
# get token_data from cache (or nil), token_data is what is returned by /token
|
|
276
|
-
token_data = self.class.persist_mgr.get(token_id) if use_cache
|
|
277
|
-
token_data = JSON.parse(token_data) unless token_data.nil?
|
|
278
|
-
# Optional optimization: check if node token is expired based on decoded content then force refresh if close enough
|
|
279
|
-
# might help in case the transfer agent cannot refresh himself
|
|
280
|
-
# `direct` agent is equipped with refresh code
|
|
281
|
-
if !use_refresh_token && !token_data.nil?
|
|
282
|
-
decoded_token = self.class.decode_token(token_data[@generic_parameters[:token_field]])
|
|
283
|
-
Log.log.debug{Log.dump('decoded_token', decoded_token)} unless decoded_token.nil?
|
|
284
|
-
if decoded_token.is_a?(Hash)
|
|
285
|
-
expires_at_sec =
|
|
286
|
-
if decoded_token['expires_at'].is_a?(String) then DateTime.parse(decoded_token['expires_at']).to_time
|
|
287
|
-
elsif decoded_token['exp'].is_a?(Integer) then Time.at(decoded_token['exp'])
|
|
288
|
-
end
|
|
289
|
-
# force refresh if we see a token too close from expiration
|
|
290
|
-
use_refresh_token = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) < @@globals[:token_expiration_guard_sec]
|
|
291
|
-
Log.log.debug{"Expiration: #{expires_at_sec} / #{use_refresh_token}"}
|
|
292
|
-
end
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
# an API was already called, but failed, we need to regenerate or refresh
|
|
296
|
-
if use_refresh_token
|
|
297
|
-
if token_data.is_a?(Hash) && token_data.key?('refresh_token') && !token_data['refresh_token'].eql?('not_supported')
|
|
298
|
-
# save possible refresh token, before deleting the cache
|
|
299
|
-
refresh_token = token_data['refresh_token']
|
|
300
|
-
end
|
|
301
|
-
# delete cache
|
|
302
|
-
self.class.persist_mgr.delete(token_id)
|
|
303
|
-
token_data = nil
|
|
304
|
-
# lets try the existing refresh token
|
|
305
|
-
if !refresh_token.nil?
|
|
306
|
-
Log.log.info{"refresh=[#{refresh_token}]".bg_green}
|
|
307
|
-
# try to refresh
|
|
308
|
-
# note: AoC admin token has no refresh, and lives by default 1800secs
|
|
309
|
-
resp = create_token(optional_scope_client_id.merge(grant_type: 'refresh_token', refresh_token: refresh_token))
|
|
310
|
-
if resp[:http].code.start_with?('2')
|
|
311
|
-
# save only if success
|
|
312
|
-
json_data = resp[:http].body
|
|
313
|
-
token_data = JSON.parse(json_data)
|
|
314
|
-
self.class.persist_mgr.put(token_id, json_data)
|
|
315
|
-
else
|
|
316
|
-
Log.log.debug{"refresh failed: #{resp[:http].body}".bg_red}
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# no cache, nor refresh: generate a token
|
|
322
|
-
if token_data.nil?
|
|
323
|
-
resp = self.class.token_creator(@generic_parameters[:grant_method]).call(self)
|
|
324
|
-
json_data = resp[:http].body
|
|
325
|
-
token_data = JSON.parse(json_data)
|
|
326
|
-
self.class.persist_mgr.put(token_id, json_data)
|
|
327
|
-
end # if ! in_cache
|
|
328
|
-
assert(token_data.key?(@generic_parameters[:token_field])){"API error: No such field in answer: #{@generic_parameters[:token_field]}"}
|
|
329
|
-
# ok we shall have a token here
|
|
330
|
-
return self.class.bearer_build(token_data[@generic_parameters[:token_field]])
|
|
331
|
-
end
|
|
332
|
-
end # OAuth
|
|
333
|
-
end # Aspera
|
|
3
|
+
require 'aspera/oauth/factory'
|
|
4
|
+
require 'aspera/oauth/generic'
|
|
5
|
+
require 'aspera/oauth/jwt'
|
|
6
|
+
require 'aspera/oauth/web'
|
|
7
|
+
require 'aspera/oauth/url_json'
|
|
@@ -17,7 +17,7 @@ module Aspera
|
|
|
17
17
|
def user_interfaces; USER_INTERFACES; end
|
|
18
18
|
|
|
19
19
|
def default_gui_mode
|
|
20
|
-
return :graphical if [
|
|
20
|
+
return :graphical if [Environment::OS_WINDOWS, Environment::OS_X].include?(Environment.os)
|
|
21
21
|
# unix family
|
|
22
22
|
return :graphical if ENV.key?('DISPLAY') && !ENV['DISPLAY'].empty?
|
|
23
23
|
return :text
|
|
@@ -25,19 +25,19 @@ module Aspera
|
|
|
25
25
|
|
|
26
26
|
# command must be non blocking
|
|
27
27
|
def uri_graphical(uri)
|
|
28
|
-
case
|
|
29
|
-
when
|
|
30
|
-
when
|
|
31
|
-
when
|
|
28
|
+
case Environment.os
|
|
29
|
+
when Environment::OS_X then return system('open', uri.to_s)
|
|
30
|
+
when Environment::OS_WINDOWS then return system('start', 'explorer', %Q{"#{uri}"})
|
|
31
|
+
when Environment::OS_LINUX then return system('xdg-open', uri.to_s)
|
|
32
32
|
else
|
|
33
|
-
raise "no graphical open method for #{
|
|
33
|
+
raise "no graphical open method for #{Environment.os}"
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
def editor(file_path)
|
|
38
38
|
if ENV.key?('EDITOR')
|
|
39
39
|
system(ENV['EDITOR'], file_path.to_s)
|
|
40
|
-
elsif
|
|
40
|
+
elsif Environment.os.eql?(Environment::OS_WINDOWS)
|
|
41
41
|
system('notepad.exe', %Q{"#{file_path}"})
|
|
42
42
|
else
|
|
43
43
|
uri_graphical(file_path.to_s)
|
|
@@ -15,10 +15,10 @@ module Aspera
|
|
|
15
15
|
# @param :format Optional dump method (default to JSON)
|
|
16
16
|
# @param :merge Optional merge data from file to current data
|
|
17
17
|
def initialize(manager:, data:, id:, delete: nil, parse: nil, format: nil, merge: nil)
|
|
18
|
-
assert(!manager.nil?)
|
|
19
|
-
assert(!data.nil?)
|
|
20
|
-
assert_type(id, String)
|
|
21
|
-
assert(!id.empty?)
|
|
18
|
+
Aspera.assert(!manager.nil?)
|
|
19
|
+
Aspera.assert(!data.nil?)
|
|
20
|
+
Aspera.assert_type(id, String)
|
|
21
|
+
Aspera.assert(!id.empty?)
|
|
22
22
|
@manager = manager
|
|
23
23
|
@persisted_object = data
|
|
24
24
|
@object_id = id
|