aspera-cli 4.16.0 → 4.17.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|