Skip to content

Commit

Permalink
config token actions
Browse files Browse the repository at this point in the history
  • Loading branch information
laurent-martin committed Feb 24, 2025
1 parent 0c7de13 commit a2cb2a8
Show file tree
Hide file tree
Showing 10 changed files with 1,923 additions and 1,720 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

* New Features:
* `faspex5`: New command: `admin contact reset_password`
* `config`: New command: `tokens` with `list`, `show`, `flush` (replace `flush_tokens`)
* Issues Fixed:
* `config`: Soft links in transfer SDK archive are correctly extracted
* `aoc`: Fix `packages delete` not working.
Expand Down
197 changes: 114 additions & 83 deletions README.md

Large diffs are not rendered by default.

3,040 changes: 1,562 additions & 1,478 deletions docs/Manual.html

Large diffs are not rendered by default.

Binary file modified docs/Manual.pdf
Binary file not shown.
186 changes: 108 additions & 78 deletions docs/README.erb.md

Large diffs are not rendered by default.

17 changes: 13 additions & 4 deletions lib/aspera/cli/plugins/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -857,7 +857,7 @@ def execute_preset(action: nil, name: nil)
remote_certificate
gem
plugins
flush_tokens
tokens
echo
wizard
detect
Expand Down Expand Up @@ -913,9 +913,18 @@ def execute_action
end
when :echo # display the content of a value given on command line
return Formatter.auto_type(options.get_next_argument('value', validation: nil))
when :flush_tokens
deleted_files = OAuth::Factory.instance.flush_tokens
return {type: :value_list, data: deleted_files, name: 'file'}
when :tokens
require 'aspera/api/node'
case options.get_next_command(%i{flush list show})
when :flush
return {type: :value_list, data: OAuth::Factory.instance.flush_tokens, name: 'file'}
when :list
return {type: :object_list, data: OAuth::Factory.instance.persisted_tokens}
when :show
data = OAuth::Factory.instance.get_token_info(instance_identifier)
raise Cli::Error, 'No such identifier' if data.nil?
return {type: :single_object, data: data}
end
when :plugins
case options.get_next_command(%i[list create])
when :list
Expand Down
73 changes: 31 additions & 42 deletions lib/aspera/oauth/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def initialize(
scope: nil,
use_query: false,
path_token: 'token',
token_field: 'access_token',
token_field: Factory::TOKEN_FIELD,
cache_ids: nil,
**rest_params
)
Expand Down Expand Up @@ -87,48 +87,37 @@ def optional_scope_client_id(add_secret: false)
# @param cache set to false to disable cache
# @param refresh set to true to force refresh or re-generation (if previous failed)
def token(cache: true, refresh: false)
# get token_data from cache (or nil), token_data is what is returned by /token
token_data = Factory.instance.persist_mgr.get(@token_cache_id) if cache
token_data = JSON.parse(token_data) unless token_data.nil?
# Optional optimization: check if node token is expired based on decoded content then force refresh if close enough
# might help in case the transfer agent cannot refresh himself
# `direct` agent is equipped with refresh code
if !refresh && !token_data.nil?
decoded_token = OAuth::Factory.instance.decode_token(token_data[@token_field])
Log.log.debug{Log.dump('decoded_token', decoded_token)} unless decoded_token.nil?
if decoded_token.is_a?(Hash)
expires_at_sec =
if decoded_token['expires_at'].is_a?(String) then DateTime.parse(decoded_token['expires_at']).to_time
elsif decoded_token['exp'].is_a?(Integer) then Time.at(decoded_token['exp'])
# get token info from cache (or nil), decoded with date and expiration status
token_info = Factory.instance.get_token_info(@token_cache_id) if cache
token_data = nil
unless token_info.nil?
token_data = token_info[:data]
# Optional optimization:
# check if node token is expired based on decoded content then force refresh if close enough
# might help in case the transfer agent cannot refresh himself
# `direct` agent is equipped with refresh code
# an API was already called, but failed, we need to regenerate or refresh
if refresh || token_info[:expired]
if token_data.key?('refresh_token') && token_data['refresh_token'].eql?('not_supported')
# save possible refresh token, before deleting the cache
refresh_token = token_data['refresh_token']
end
# delete cache
Factory.instance.persist_mgr.delete(@token_cache_id)
token_data = nil
# lets try the existing refresh token
if !refresh_token.nil?
Log.log.info{"refresh=[#{refresh_token}]".bg_green}
# NOTE: AoC admin token has no refresh, and lives by default 1800secs
resp = create_token_call(optional_scope_client_id.merge(grant_type: 'refresh_token', refresh_token: refresh_token))
if resp[:http].code.start_with?('2')
# save only if success
json_data = resp[:http].body
token_data = JSON.parse(json_data)
Factory.instance.persist_mgr.put(@token_cache_id, json_data)
else
Log.log.debug{"refresh failed: #{resp[:http].body}".bg_red}
end
# force refresh if we see a token too close from expiration
refresh = true if expires_at_sec.is_a?(Time) && (expires_at_sec - Time.now) < OAuth::Factory.instance.parameters[:token_expiration_guard_sec]
Log.log.debug{"Expiration: #{expires_at_sec} / #{refresh}"}
end
end

# an API was already called, but failed, we need to regenerate or refresh
if refresh
if token_data.is_a?(Hash) && token_data.key?('refresh_token') && !token_data['refresh_token'].eql?('not_supported')
# save possible refresh token, before deleting the cache
refresh_token = token_data['refresh_token']
end
# delete cache
Factory.instance.persist_mgr.delete(@token_cache_id)
token_data = nil
# lets try the existing refresh token
if !refresh_token.nil?
Log.log.info{"refresh=[#{refresh_token}]".bg_green}
# try to refresh
# note: AoC admin token has no refresh, and lives by default 1800secs
resp = create_token_call(optional_scope_client_id.merge(grant_type: 'refresh_token', refresh_token: refresh_token))
if resp[:http].code.start_with?('2')
# save only if success
json_data = resp[:http].body
token_data = JSON.parse(json_data)
Factory.instance.persist_mgr.put(@token_cache_id, json_data)
else
Log.log.debug{"refresh failed: #{resp[:http].body}".bg_red}
end
end
end
Expand Down
43 changes: 41 additions & 2 deletions lib/aspera/oauth/factory.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class Factory
PERSIST_CATEGORY_TOKEN = 'token'
# prefix for bearer token when in header
BEARER_PREFIX = 'Bearer '
TOKEN_FIELD = 'access_token'

private_constant :PERSIST_CATEGORY_TOKEN, :BEARER_PREFIX

Expand Down Expand Up @@ -87,7 +88,45 @@ def get(_x); nil; end; def delete(_x); nil; end; def put(_x, _y); nil; end; def

# delete all existing tokens
def flush_tokens
persist_mgr.garbage_collect(PERSIST_CATEGORY_TOKEN, nil)
persist_mgr.garbage_collect(PERSIST_CATEGORY_TOKEN)
end

def persisted_tokens
data = persist_mgr.current_items(PERSIST_CATEGORY_TOKEN)
data.each.map do |k, v|
info = {id: k}
info.merge!(JSON.parse(v)) rescue nil
d = decode_token(info.delete(TOKEN_FIELD))
info.merge(d) if d
info
end
end

# get token information from cache
# @param id [String] identifier of token
# @return [Hash] token internal information , including Date object for `expiration_date`
def get_token_info(id)
token_raw_string = persist_mgr.get(id)
return nil if token_raw_string.nil?
token_data = JSON.parse(token_raw_string)
Aspera.assert_type(token_data, Hash)
decoded_token = decode_token(token_data[TOKEN_FIELD])
info = { data: token_data }
Log.log.debug{Log.dump('decoded_token', decoded_token)}
if decoded_token.is_a?(Hash)
info[:decoded] = decoded_token
# TODO: move date decoding to token decoder ?
expiration_date =
if decoded_token['expires_at'].is_a?(String) then DateTime.parse(decoded_token['expires_at']).to_time
elsif decoded_token['exp'].is_a?(Integer) then Time.at(decoded_token['exp'])
end
unless expiration_date.nil?
info[:expiration] = expiration_date
info[:ttl_sec] = expiration_date - Time.now
info[:expired] = info[:ttl_sec] < @parameters[:token_expiration_guard_sec]
end
end
return info
end

# register a bearer token decoder, mainly to inspect expiry date
Expand Down Expand Up @@ -125,6 +164,6 @@ def create(**parameters)
end
end
# JSON Web Signature (JWS) compact serialization: https://datatracker.ietf.org/doc/html/rfc7515
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
Factory.instance.register_decoder(lambda { |token| parts = token.split('.'); Aspera.assert(parts.length.eql?(3)){'not JWS token'}; JSON.parse(Base64.decode64(parts[1]))}) # rubocop:disable Style/Semicolon, Layout/LineLength
end
end
22 changes: 20 additions & 2 deletions lib/aspera/persistency_folder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ def initialize(folder)
Log.log.debug{"persistency folder: #{@folder}"}
end

# @return String or nil string on existing persist, else nil
# Get value of persisted item
# @return [String,nil] Value of persisted id
def get(object_id)
Log.log.debug{"persistency get: #{object_id}"}
if @cache.key?(object_id)
Expand All @@ -34,6 +35,10 @@ def get(object_id)
return @cache[object_id]
end

# Set value of persisted item
# @param object_id [String] Identifier of persisted item
# @param value [String] Value of persisted item
# @return [nil]
def put(object_id, value)
Aspera.assert_type(value, String)
persist_filepath = id_to_filepath(object_id)
Expand All @@ -42,17 +47,21 @@ def put(object_id, value)
File.write(persist_filepath, value)
Environment.restrict_file_access(persist_filepath)
@cache[object_id] = value
nil
end

# Delete persisted item
# @param object_id [String] Identifier of persisted item
def delete(object_id)
persist_filepath = id_to_filepath(object_id)
Log.log.debug{"persistency deleting: #{persist_filepath}"}
FileUtils.rm_f(persist_filepath)
@cache.delete(object_id)
end

# Delete persisted items
def garbage_collect(persist_category, max_age_seconds=nil)
garbage_files = Dir[File.join(@folder, persist_category + '*' + FILE_SUFFIX)]
garbage_files = current_files(persist_category)
if !max_age_seconds.nil?
current_time = Time.now
garbage_files.select! { |filepath| (current_time - File.stat(filepath).mtime).to_i > max_age_seconds}
Expand All @@ -61,9 +70,18 @@ def garbage_collect(persist_category, max_age_seconds=nil)
File.delete(filepath)
Log.log.debug{"persistency deleted expired: #{filepath}"}
end
@cache.clear
return garbage_files
end

def current_files(persist_category)
Dir[File.join(@folder, persist_category + '*' + FILE_SUFFIX)]
end

def current_items(persist_category)
current_files(persist_category).each_with_object({}) {|i, h| h[File.basename(i, FILE_SUFFIX)] = File.read(i)}
end

private

# @param object_id String or Array
Expand Down
Loading

0 comments on commit a2cb2a8

Please sign in to comment.