aspera-cli 4.27.5 → 4.28.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 +160 -4
- data/CONTRIBUTING.md +4 -2
- data/README.md +1 -1
- data/bin/asession +1 -1
- data/docs/README.md +1086 -331
- data/docs/test-mcp-with-ai.md +5 -3
- data/lib/aspera/agent/direct.rb +15 -4
- data/lib/aspera/agent/factory.rb +2 -1
- data/lib/aspera/agent/node.rb +1 -1
- data/lib/aspera/api/cos_node.rb +1 -1
- data/lib/aspera/api/faspex.rb +1 -1
- data/lib/aspera/api/node.rb +6 -2
- data/lib/aspera/ascp/installation.rb +47 -11
- data/lib/aspera/ascp/management.rb +89 -43
- data/lib/aspera/cli/ascp_actions.rb +5 -19
- data/lib/aspera/cli/bootstrapper.rb +1 -2
- data/lib/aspera/cli/command_registry.rb +43 -3
- data/lib/aspera/cli/command_spec.rb +3 -0
- data/lib/aspera/cli/completion/ascli.bash +39 -5
- data/lib/aspera/cli/completion/ascli.fish +16 -8
- data/lib/aspera/cli/completion/ascli.ps1 +41 -0
- data/lib/aspera/cli/completion/ascli.zsh +20 -4
- data/lib/aspera/cli/completion_actions.rb +360 -0
- data/lib/aspera/cli/error.rb +42 -0
- data/lib/aspera/cli/extended_value.rb +30 -5
- data/lib/aspera/cli/formatter.rb +118 -56
- data/lib/aspera/cli/hints.rb +0 -1
- data/lib/aspera/cli/http.rb +6 -5
- data/lib/aspera/cli/info.rb +10 -4
- data/lib/aspera/cli/mailer.rb +1 -1
- data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
- data/lib/aspera/cli/mcp/secrets.rb +143 -0
- data/lib/aspera/cli/mcp/tool.rb +231 -0
- data/lib/aspera/cli/option_value.rb +9 -1
- data/lib/aspera/cli/options.schema.yaml +390 -39
- data/lib/aspera/cli/parser.rb +14 -5
- data/lib/aspera/cli/plugins/aoc.rb +8 -11
- data/lib/aspera/cli/plugins/base.rb +36 -15
- data/lib/aspera/cli/plugins/config.rb +15 -44
- data/lib/aspera/cli/plugins/console.rb +3 -1
- data/lib/aspera/cli/plugins/factory.rb +1 -1
- data/lib/aspera/cli/plugins/faspex5.rb +9 -19
- data/lib/aspera/cli/plugins/faspio.rb +36 -5
- data/lib/aspera/cli/plugins/httpgw.rb +1 -0
- data/lib/aspera/cli/plugins/mcp.rb +177 -118
- data/lib/aspera/cli/plugins/node.rb +14 -8
- data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
- data/lib/aspera/cli/plugins/server.rb +3 -1
- data/lib/aspera/cli/plugins/shares.rb +205 -35
- data/lib/aspera/cli/preset_actions.rb +15 -7
- data/lib/aspera/cli/preset_manager.rb +24 -2
- data/lib/aspera/cli/prompt.rb +3 -1
- data/lib/aspera/cli/result.rb +17 -4
- data/lib/aspera/cli/runner.rb +133 -43
- data/lib/aspera/cli/security.rb +134 -0
- data/lib/aspera/cli/table_fitter.rb +116 -0
- data/lib/aspera/cli/terminal_formatter.rb +23 -34
- data/lib/aspera/cli/transfer_agent.rb +4 -1
- data/lib/aspera/cli/vault_manager.rb +6 -5
- data/lib/aspera/cli/version.rb +1 -1
- data/lib/aspera/cli/web_secret.html.erb +61 -0
- data/lib/aspera/cli/web_secret.rb +220 -0
- data/lib/aspera/cli/wizard.rb +19 -9
- data/lib/aspera/command_line_builder.rb +26 -0
- data/lib/aspera/data_repository.rb +38 -24
- data/lib/aspera/duration.rb +42 -0
- data/lib/aspera/environment.rb +88 -24
- data/lib/aspera/exec_spec.rb +6 -5
- data/lib/aspera/faspex_gw.rb +98 -32
- data/lib/aspera/faspex_postproc.rb +187 -44
- data/lib/aspera/formatter_interface.rb +0 -1
- data/lib/aspera/id_generator.rb +2 -1
- data/lib/aspera/json_rpc/client.rb +51 -32
- data/lib/aspera/json_rpc/version.rb +1 -0
- data/lib/aspera/keychain/base.rb +5 -0
- data/lib/aspera/keychain/encrypted_hash.rb +6 -2
- data/lib/aspera/keychain/factory.rb +2 -3
- data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
- data/lib/aspera/keychain/macos_security.rb +119 -13
- data/lib/aspera/keychain/one_password_api.rb +8 -3
- data/lib/aspera/keychain/one_password_base.rb +14 -9
- data/lib/aspera/keychain/one_password_cli.rb +4 -2
- data/lib/aspera/log.rb +3 -6
- data/lib/aspera/markdown.rb +152 -115
- data/lib/aspera/mime.rb +6 -0
- data/lib/aspera/nagios.rb +3 -1
- data/lib/aspera/node_emulator.rb +98 -101
- data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
- data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
- data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
- data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
- data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
- data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
- data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
- data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
- data/lib/aspera/oauth/server/authorize.html.erb +34 -0
- data/lib/aspera/oauth/server/pkce.rb +426 -0
- data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
- data/lib/aspera/preview/terminal.rb +3 -2
- data/lib/aspera/rainbow.rb +27 -1
- data/lib/aspera/rest/aspera_errors.rb +51 -59
- data/lib/aspera/rest/client.rb +21 -14
- data/lib/aspera/rest/error_analyzer.rb +9 -0
- data/lib/aspera/rest/list.rb +6 -6
- data/lib/aspera/rest/util.rb +7 -1
- data/lib/aspera/rest.rb +0 -1
- data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
- data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
- data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
- data/lib/aspera/schema/documentation.rb +50 -78
- data/lib/aspera/schema/reader.rb +39 -11
- data/lib/aspera/schema/registry.rb +8 -2
- data/lib/aspera/secret_hider.rb +94 -15
- data/lib/aspera/secret_strength.rb +114 -0
- data/lib/aspera/servlet_helpers.rb +111 -0
- data/lib/aspera/sync/args.schema.yaml +3 -1
- data/lib/aspera/sync/conf.schema.yaml +16 -6
- data/lib/aspera/sync/operations.rb +21 -1
- data/lib/aspera/temp_file_manager.rb +79 -20
- data/lib/aspera/transfer/parameters.rb +5 -0
- data/lib/aspera/transfer/spec.rb +52 -12
- data/lib/aspera/transfer/spec.schema.yaml +8 -5
- data/lib/aspera/web_auth/index.html.erb +16 -0
- data/lib/aspera/web_auth/server.rb +18 -10
- data/lib/aspera/web_page.html.erb +262 -0
- data/lib/aspera/web_page.rb +61 -0
- data/lib/aspera/web_server_simple.rb +76 -10
- data.tar.gz.sig +0 -0
- metadata +47 -32
- metadata.gz.sig +0 -0
- data/lib/aspera/cli/mcp_tool.rb +0 -189
- data/lib/aspera/oauth.rb +0 -9
- data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
- data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
- data/lib/aspera/web_auth/index.html +0 -147
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/oauth/factory'
|
|
3
|
+
require 'aspera/oauth/client/factory'
|
|
4
4
|
require 'aspera/log'
|
|
5
5
|
require 'aspera/assert'
|
|
6
|
+
require 'aspera/secret_hider'
|
|
6
7
|
require 'aspera/mime'
|
|
7
8
|
require 'date'
|
|
8
9
|
require 'aspera/rainbow'
|
|
@@ -39,8 +40,7 @@ module Aspera
|
|
|
39
40
|
@path_token = path_token
|
|
40
41
|
@token_field = token_field
|
|
41
42
|
@use_query = use_query
|
|
42
|
-
|
|
43
|
-
@token_cache_id = Factory.cache_id(@api.base_url, self.class, cache_ids, rest_params[:username], @params[:scope])
|
|
43
|
+
@token_cache_id = Factory.cache_id(@api.base_url, self.class, *cache_ids, @params[:scope])
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
# The OAuth API Object
|
|
@@ -133,7 +133,8 @@ module Aspera
|
|
|
133
133
|
Factory.instance.get_token_info(@token_cache_id, token_field: @token_field)
|
|
134
134
|
end
|
|
135
135
|
Aspera.assert(token_data.key?(@token_field)) { "API error: No such field in answer: #{@token_field}" } unless token_data.nil?
|
|
136
|
-
# Ok we shall have a token here
|
|
136
|
+
# Ok we shall have a token here: hidden wherever it appears (logs, results)
|
|
137
|
+
SecretHider.instance.add_secret_values(token_data[@token_field], token_data['refresh_token'])
|
|
137
138
|
return token_data[@token_field]
|
|
138
139
|
end
|
|
139
140
|
end
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/oauth/base'
|
|
3
|
+
require 'aspera/oauth/client/base'
|
|
4
4
|
require 'aspera/assert'
|
|
5
|
+
require 'aspera/environment'
|
|
5
6
|
require 'json'
|
|
6
7
|
|
|
7
8
|
module Aspera
|
|
@@ -35,7 +36,7 @@ module Aspera
|
|
|
35
36
|
|
|
36
37
|
# Should never be reached: if cache and refresh are both exhausted, re-authenticate via browser
|
|
37
38
|
def create_token
|
|
38
|
-
Aspera.report_error(AssertError, 'Boot: token expired and no refresh available
|
|
39
|
+
Aspera.report_error(AssertError, 'Boot: token expired and no refresh available. Re-authenticate in browser')
|
|
39
40
|
end
|
|
40
41
|
end
|
|
41
42
|
Factory.instance.register_token_creator(Boot)
|
|
@@ -193,6 +193,13 @@ module Aspera
|
|
|
193
193
|
def create(**parameters)
|
|
194
194
|
Aspera.assert_type(parameters, Hash)
|
|
195
195
|
id = parameters[:grant_method]
|
|
196
|
+
unless @token_type_classes.key?(id)
|
|
197
|
+
begin
|
|
198
|
+
require "aspera/oauth/client/#{id}"
|
|
199
|
+
rescue LoadError
|
|
200
|
+
# ignored, handled by assert
|
|
201
|
+
end
|
|
202
|
+
end
|
|
196
203
|
Aspera.assert(@token_type_classes.key?(id)) { "token grant method unknown: '#{id}'" }
|
|
197
204
|
create_parameters = parameters.reject { |k, _v| k.eql?(:grant_method) }
|
|
198
205
|
@token_type_classes[id].new(**create_parameters)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require 'aspera/oauth/base'
|
|
3
|
+
require 'aspera/oauth/client/base'
|
|
4
4
|
require 'aspera/environment'
|
|
5
5
|
require 'aspera/web_auth/server'
|
|
6
6
|
require 'aspera/assert'
|
|
@@ -24,7 +24,7 @@ module Aspera
|
|
|
24
24
|
uri = URI.parse(@redirect_uri)
|
|
25
25
|
Aspera.assert_values(uri.scheme, %w[http https]) { 'redirect_uri scheme must be http or https' }
|
|
26
26
|
Aspera.assert(!uri.port.nil?, 'redirect_uri must have a port')
|
|
27
|
-
Aspera.assert(
|
|
27
|
+
Aspera.assert(WebServerSimple.loopback?(uri.hostname)) { "redirect_uri host must be loopback (localhost, 127.0.0.0/8, ::1): #{uri.host}" }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def create_token
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<%# Content of the consent page, in layout of Aspera::WebPage %>
|
|
2
|
+
<div class="icon-circle">
|
|
3
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<rect x="6" y="13" width="20" height="15" rx="3"/>
|
|
5
|
+
<path d="M10 13V9a6 6 0 0 1 12 0v4"/>
|
|
6
|
+
<circle cx="16" cy="20.5" r="2" fill="currentColor"/>
|
|
7
|
+
</svg>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<h1>Authorization request</h1>
|
|
11
|
+
<p>The client <span class="client"><%= h client_id %></span> is requesting access to <%= description %>.</p>
|
|
12
|
+
<p>The authorization is sent to <span class="client"><%= h redirect_origin %></span>.</p>
|
|
13
|
+
<p>Do you want to allow it?</p>
|
|
14
|
+
<% if error %>
|
|
15
|
+
<p class="error"><%= h error %></p>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<form method="POST" action="<%= h action_url %>">
|
|
19
|
+
<input type="hidden" name="client_id" value="<%= h client_id %>">
|
|
20
|
+
<input type="hidden" name="redirect_uri" value="<%= h redirect_uri %>">
|
|
21
|
+
<input type="hidden" name="state" value="<%= h state %>">
|
|
22
|
+
<input type="hidden" name="code_challenge" value="<%= h code_challenge %>">
|
|
23
|
+
<input type="hidden" name="code_challenge_method" value="<%= h code_challenge_method %>">
|
|
24
|
+
<% if secret_required %>
|
|
25
|
+
<label class="secret">Server access code
|
|
26
|
+
<input type="password" name="consent_secret" autocomplete="current-password" required autofocus>
|
|
27
|
+
</label>
|
|
28
|
+
<% end %>
|
|
29
|
+
<div class="actions">
|
|
30
|
+
<button class="allow" type="submit" name="decision" value="allow">Allow</button>
|
|
31
|
+
<button class="deny" type="submit" name="decision" value="deny" formnovalidate>Deny</button>
|
|
32
|
+
</div>
|
|
33
|
+
</form>
|
|
34
|
+
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# cspell:ignore pkce urlsafe
|
|
4
|
+
|
|
5
|
+
require 'aspera/log'
|
|
6
|
+
require 'aspera/assert'
|
|
7
|
+
require 'aspera/web_page'
|
|
8
|
+
require 'aspera/servlet_helpers'
|
|
9
|
+
require 'json'
|
|
10
|
+
require 'securerandom'
|
|
11
|
+
require 'uri'
|
|
12
|
+
|
|
13
|
+
module Aspera
|
|
14
|
+
module OAuth
|
|
15
|
+
module Server
|
|
16
|
+
# Minimal OAuth 2.0 authorization server supporting:
|
|
17
|
+
# - Dynamic Client Registration (RFC 7591)
|
|
18
|
+
# - Authorization Code + PKCE (S256) flow (RFC 7636)
|
|
19
|
+
# - Refresh Token rotation
|
|
20
|
+
# - RFC 8414 authorization server metadata discovery
|
|
21
|
+
#
|
|
22
|
+
# Designed to be embedded inside a WEBrick servlet. The caller is
|
|
23
|
+
# responsible for:
|
|
24
|
+
# 1. Creating an instance (once, before the server starts).
|
|
25
|
+
# 2. Routing the five OAuth paths to the handle_* methods.
|
|
26
|
+
# 3. Calling access_token_valid?(bearer_token) to gate every other request.
|
|
27
|
+
#
|
|
28
|
+
# All in-memory stores are protected by an internal Mutex.
|
|
29
|
+
#
|
|
30
|
+
# @example Minimal integration inside a WEBrick servlet
|
|
31
|
+
# @oauth = OAuth::Server::Pkce.new(base_url: 'http://localhost:3000')
|
|
32
|
+
# # inside do_GET / do_POST:
|
|
33
|
+
# case req.path
|
|
34
|
+
# when '/.well-known/oauth-authorization-server' then @oauth.handle_discovery(req, res)
|
|
35
|
+
# when '/oauth/register' then @oauth.handle_register(req, res)
|
|
36
|
+
# when '/oauth/authorize' then @oauth.handle_authorize(req, res)
|
|
37
|
+
# when '/oauth/token' then @oauth.handle_token(req, res)
|
|
38
|
+
# else
|
|
39
|
+
# unless @oauth.access_token_valid?(authorization(req, 'Bearer'))
|
|
40
|
+
# # respond 401 …
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
class Pkce
|
|
44
|
+
include ServletHelpers
|
|
45
|
+
|
|
46
|
+
ACCESS_TOKEN_TTL = 3600 # 1 hour
|
|
47
|
+
REFRESH_TOKEN_TTL = 2_592_000 # 30 days
|
|
48
|
+
CODE_TTL = 120 # 2 minutes
|
|
49
|
+
# Limits of dynamic client registration, which needs no authentication
|
|
50
|
+
MAX_CLIENTS = 100 # beyond, the least recently used client is removed
|
|
51
|
+
MAX_REDIRECT_URIS = 10
|
|
52
|
+
MAX_REDIRECT_URI_SIZE = 2048
|
|
53
|
+
# Path to the ERB consent page template (relative to this file).
|
|
54
|
+
HTML_TEMPLATE_PATH = File.join(__dir__, 'authorize.html.erb')
|
|
55
|
+
|
|
56
|
+
private_constant :ACCESS_TOKEN_TTL, :REFRESH_TOKEN_TTL, :CODE_TTL, :MAX_CLIENTS, :MAX_REDIRECT_URIS, :MAX_REDIRECT_URI_SIZE, :HTML_TEMPLATE_PATH
|
|
57
|
+
|
|
58
|
+
# Default TTL values, exposed for callers that want to display or override them.
|
|
59
|
+
class << self
|
|
60
|
+
def default_access_token_ttl = ACCESS_TOKEN_TTL
|
|
61
|
+
def default_refresh_token_ttl = REFRESH_TOKEN_TTL
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @param base_url [String] Full base URL, e.g. "http://localhost:3000"
|
|
65
|
+
# @param access_token_ttl [Integer] Lifetime (seconds) of issued access tokens
|
|
66
|
+
# @param refresh_token_ttl [Integer] Lifetime (seconds) of issued refresh tokens
|
|
67
|
+
# @param title [String] Page <title> and branding (default: "Authorization request")
|
|
68
|
+
# @param description [String] Description shown in the consent text (default: "this server"), trusted HTML (not escaped)
|
|
69
|
+
# @param consent_secret [String, nil] If set, the user must enter this secret on the consent page to allow access.
|
|
70
|
+
# Without it, anyone who can reach the server can approve its own request.
|
|
71
|
+
# @param authorize_html_cb [#call] Optional callable(params, base_url) → HTML String
|
|
72
|
+
# for the consent page. If nil the built-in HTML template is used.
|
|
73
|
+
# With `consent_secret`, the form must post it in field `consent_secret`.
|
|
74
|
+
def initialize(base_url:, access_token_ttl: ACCESS_TOKEN_TTL, refresh_token_ttl: REFRESH_TOKEN_TTL,
|
|
75
|
+
title: 'Authorization request', description: 'this server',
|
|
76
|
+
consent_secret: nil, authorize_html_cb: nil)
|
|
77
|
+
require 'openssl'
|
|
78
|
+
Aspera.assert(consent_secret.nil? || !consent_secret.to_s.empty?) { 'consent_secret must not be empty' }
|
|
79
|
+
@base_url = base_url
|
|
80
|
+
@access_token_ttl = access_token_ttl
|
|
81
|
+
@refresh_token_ttl = refresh_token_ttl
|
|
82
|
+
@title = title
|
|
83
|
+
@description = description
|
|
84
|
+
@consent_secret = consent_secret&.to_s
|
|
85
|
+
@authorize_html_cb = authorize_html_cb
|
|
86
|
+
@store_mutex = Mutex.new
|
|
87
|
+
# dynamically registered clients: client_id => redirect_uris, least recently used first
|
|
88
|
+
@clients = {}
|
|
89
|
+
# pending authorization codes: code => {client_id:, redirect_uri:, code_challenge:, expires_at:}
|
|
90
|
+
@pending_codes = {}
|
|
91
|
+
# valid access tokens: token => expires_at (Time)
|
|
92
|
+
@access_tokens = {}
|
|
93
|
+
# refresh tokens: token => {access_token:, expires_at:}
|
|
94
|
+
@refresh_tokens = {}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# -------------------------------------------------------------------------
|
|
98
|
+
# Authorization check
|
|
99
|
+
# -------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
# @param access_token [String, nil] Bearer token of the request, see ServletHelpers#authorization
|
|
102
|
+
# @return [Boolean] true if it is an access token issued by this server, not expired
|
|
103
|
+
def access_token_valid?(access_token)
|
|
104
|
+
return false if access_token.nil?
|
|
105
|
+
@store_mutex.synchronize do
|
|
106
|
+
expire_at = @access_tokens[access_token]
|
|
107
|
+
!expire_at.nil? && expire_at > Time.now
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# -------------------------------------------------------------------------
|
|
112
|
+
# RFC 8414 — authorization server metadata
|
|
113
|
+
# -------------------------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
# Metadata hash (RFC 8414).
|
|
116
|
+
# @return [Hash]
|
|
117
|
+
def metadata
|
|
118
|
+
{
|
|
119
|
+
issuer: @base_url,
|
|
120
|
+
authorization_endpoint: "#{@base_url}/oauth/authorize",
|
|
121
|
+
token_endpoint: "#{@base_url}/oauth/token",
|
|
122
|
+
registration_endpoint: "#{@base_url}/oauth/register",
|
|
123
|
+
response_types_supported: ['code'],
|
|
124
|
+
grant_types_supported: %w[authorization_code refresh_token],
|
|
125
|
+
code_challenge_methods_supported: ['S256'],
|
|
126
|
+
token_endpoint_auth_methods_supported: ['none']
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# GET /.well-known/oauth-authorization-server
|
|
131
|
+
def handle_discovery(_req, res)
|
|
132
|
+
json_response(res, 200, metadata)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# -------------------------------------------------------------------------
|
|
136
|
+
# Dynamic Client Registration — RFC 7591
|
|
137
|
+
# -------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
# POST /oauth/register
|
|
140
|
+
def handle_register(req, res)
|
|
141
|
+
body = req.body.to_s.empty? ? {} : (JSON.parse(req.body.to_s) rescue {})
|
|
142
|
+
body = {} unless body.is_a?(Hash)
|
|
143
|
+
redirect_uris = body['redirect_uris']
|
|
144
|
+
error = redirect_uris_error(redirect_uris)
|
|
145
|
+
unless error.nil?
|
|
146
|
+
json_error(res, 400, 'invalid_redirect_uri', error)
|
|
147
|
+
return
|
|
148
|
+
end
|
|
149
|
+
client_id = SecureRandom.urlsafe_base64(16)
|
|
150
|
+
@store_mutex.synchronize do
|
|
151
|
+
while @clients.size >= MAX_CLIENTS
|
|
152
|
+
removed_id, = @clients.shift
|
|
153
|
+
Log.log.info { "OAuth client removed, limit of #{MAX_CLIENTS} clients reached: client_id=#{removed_id}" }
|
|
154
|
+
end
|
|
155
|
+
@clients[client_id] = redirect_uris
|
|
156
|
+
end
|
|
157
|
+
Log.log.info { "OAuth dynamic registration: client_id=#{client_id} name=#{body['client_name'].to_s[0, 64].inspect}" }
|
|
158
|
+
registration = {
|
|
159
|
+
client_id: client_id,
|
|
160
|
+
client_id_issued_at: Time.now.to_i,
|
|
161
|
+
grant_types: %w[authorization_code refresh_token],
|
|
162
|
+
response_types: ['code'],
|
|
163
|
+
token_endpoint_auth_method: 'none',
|
|
164
|
+
redirect_uris: redirect_uris
|
|
165
|
+
}
|
|
166
|
+
json_response(res, 201, registration, headers: {'Cache-Control' => 'no-store'})
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# -------------------------------------------------------------------------
|
|
170
|
+
# Authorization endpoint — RFC 6749 §4.1
|
|
171
|
+
# -------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
# GET or POST /oauth/authorize — dispatch based on request method.
|
|
174
|
+
def handle_authorize(req, res)
|
|
175
|
+
if req.request_method == 'GET'
|
|
176
|
+
handle_authorize_get(req, res)
|
|
177
|
+
elsif req.request_method == 'POST'
|
|
178
|
+
handle_authorize_post(req, res)
|
|
179
|
+
else
|
|
180
|
+
json_error(res, 405, 'method_not_allowed')
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# -------------------------------------------------------------------------
|
|
185
|
+
# Token endpoint — RFC 6749 §4.1 / §6
|
|
186
|
+
# -------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
# POST /oauth/token
|
|
189
|
+
def handle_token(req, res)
|
|
190
|
+
require 'webrick'
|
|
191
|
+
params = WEBrick::HTTPUtils.parse_query(req.body.to_s)
|
|
192
|
+
grant_type = params['grant_type']
|
|
193
|
+
case grant_type
|
|
194
|
+
when 'authorization_code'
|
|
195
|
+
code = params['code']
|
|
196
|
+
code_verifier = params['code_verifier']
|
|
197
|
+
stored = @store_mutex.synchronize { @pending_codes.delete(code) }
|
|
198
|
+
if stored.nil? || stored[:expires_at] < Time.now
|
|
199
|
+
json_error(res, 400, 'invalid_grant', 'Authorization code expired or unknown')
|
|
200
|
+
return
|
|
201
|
+
end
|
|
202
|
+
# RFC 6749 §4.1.3: the code must have been issued to this client (client_id is optional with PKCE)
|
|
203
|
+
unless params['client_id'].nil? || params['client_id'] == stored[:client_id]
|
|
204
|
+
json_error(res, 400, 'invalid_grant', 'client_id mismatch')
|
|
205
|
+
return
|
|
206
|
+
end
|
|
207
|
+
# RFC 6749 §4.1.3: redirect_uri must match the one used in the authorization request
|
|
208
|
+
unless params['redirect_uri'] == stored[:redirect_uri]
|
|
209
|
+
json_error(res, 400, 'invalid_grant', 'redirect_uri mismatch')
|
|
210
|
+
return
|
|
211
|
+
end
|
|
212
|
+
unless pkce_valid?(code_verifier.to_s, stored[:code_challenge])
|
|
213
|
+
json_error(res, 400, 'invalid_grant', 'PKCE verification failed')
|
|
214
|
+
return
|
|
215
|
+
end
|
|
216
|
+
token_response(res)
|
|
217
|
+
when 'refresh_token'
|
|
218
|
+
rt_value = params['refresh_token']
|
|
219
|
+
stored = @store_mutex.synchronize do
|
|
220
|
+
# Single use: removed even if expired
|
|
221
|
+
entry = @refresh_tokens.delete(rt_value)
|
|
222
|
+
next nil if entry.nil? || entry[:expires_at] < Time.now
|
|
223
|
+
# Rotate: revoke old access token and issue fresh pair
|
|
224
|
+
@access_tokens.delete(entry[:access_token])
|
|
225
|
+
entry
|
|
226
|
+
end
|
|
227
|
+
if stored.nil?
|
|
228
|
+
json_error(res, 400, 'invalid_grant', 'Refresh token expired or unknown')
|
|
229
|
+
return
|
|
230
|
+
end
|
|
231
|
+
token_response(res)
|
|
232
|
+
else
|
|
233
|
+
json_error(res, 400, 'unsupported_grant_type', "Unsupported grant_type: #{grant_type}")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# -------------------------------------------------------------------------
|
|
238
|
+
# WWW-Authenticate header value for 401 responses
|
|
239
|
+
# -------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
# @return [String] value for the WWW-Authenticate header
|
|
242
|
+
def www_authenticate_header
|
|
243
|
+
"Bearer realm=\"#{@base_url}\", " \
|
|
244
|
+
"resource_metadata=\"#{@base_url}/.well-known/oauth-authorization-server\""
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
# -------------------------------------------------------------------------
|
|
250
|
+
# Internal helpers
|
|
251
|
+
# -------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
# @param redirect_uris [Object] Value of `redirect_uris` in the registration request
|
|
254
|
+
# @return [String, nil] Reason why `redirect_uris` is refused, `nil` if valid
|
|
255
|
+
def redirect_uris_error(redirect_uris)
|
|
256
|
+
return 'redirect_uris must be a non-empty array of strings' unless redirect_uris.is_a?(Array) && !redirect_uris.empty? && redirect_uris.all?(String)
|
|
257
|
+
return "redirect_uris must have at most #{MAX_REDIRECT_URIS} values" if redirect_uris.length > MAX_REDIRECT_URIS
|
|
258
|
+
redirect_uris.each do |redirect_uri|
|
|
259
|
+
return "redirect_uri must have at most #{MAX_REDIRECT_URI_SIZE} characters" if redirect_uri.length > MAX_REDIRECT_URI_SIZE
|
|
260
|
+
# RFC 6749 §3.1.2: absolute URI, without fragment
|
|
261
|
+
uri = URI.parse(redirect_uri) rescue nil
|
|
262
|
+
return "redirect_uri must be an absolute URI without fragment: #{redirect_uri}" if uri.nil? || !uri.absolute? || !uri.fragment.nil?
|
|
263
|
+
end
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# The client is also marked as the most recently used one
|
|
268
|
+
# @return [Boolean] true if client is registered and redirect_uri is one of its registered URIs (exact match)
|
|
269
|
+
def client_redirect_valid?(client_id, redirect_uri)
|
|
270
|
+
@store_mutex.synchronize do
|
|
271
|
+
redirect_uris = @clients.delete(client_id)
|
|
272
|
+
next false if redirect_uris.nil?
|
|
273
|
+
@clients[client_id] = redirect_uris
|
|
274
|
+
redirect_uris.include?(redirect_uri)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# @param redirect_uri [String] Redirect URI of the authorization request
|
|
279
|
+
# @return [String] Where the code is sent: origin of `redirect_uri`, or `redirect_uri` if it has no host
|
|
280
|
+
def redirect_origin(redirect_uri)
|
|
281
|
+
URI.parse(redirect_uri).host.to_s.empty? ? redirect_uri : ServletHelpers.origin(redirect_uri)
|
|
282
|
+
rescue URI::InvalidURIError
|
|
283
|
+
redirect_uri
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Build redirect URL to client, preserving any query already present in redirect_uri.
|
|
287
|
+
def client_redirect_url(redirect_uri, **params)
|
|
288
|
+
query = params.reject { |_, v| v.to_s.empty? }.map { |k, v| "#{k}=#{WEBrick::HTTPUtils.escape_form(v.to_s)}" }.join('&')
|
|
289
|
+
"#{redirect_uri}#{redirect_uri.include?('?') ? '&' : '?'}#{query}"
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def purge_expired
|
|
293
|
+
now = Time.now
|
|
294
|
+
@pending_codes.delete_if { |_, v| v[:expires_at] < now }
|
|
295
|
+
@access_tokens.delete_if { |_, exp| exp < now }
|
|
296
|
+
@refresh_tokens.delete_if { |_, v| v[:expires_at] < now }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def issue_tokens
|
|
300
|
+
access = SecureRandom.urlsafe_base64(32)
|
|
301
|
+
refresh = SecureRandom.urlsafe_base64(48)
|
|
302
|
+
now = Time.now
|
|
303
|
+
@store_mutex.synchronize do
|
|
304
|
+
purge_expired
|
|
305
|
+
@access_tokens[access] = now + @access_token_ttl
|
|
306
|
+
@refresh_tokens[refresh] = {access_token: access, expires_at: now + @refresh_token_ttl}
|
|
307
|
+
end
|
|
308
|
+
{
|
|
309
|
+
access_token: access,
|
|
310
|
+
token_type: 'Bearer',
|
|
311
|
+
expires_in: @access_token_ttl,
|
|
312
|
+
refresh_token: refresh
|
|
313
|
+
}
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def token_response(res)
|
|
317
|
+
json_response(res, 200, issue_tokens, headers: {'Cache-Control' => 'no-store'})
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
# Verify PKCE: SHA-256(code_verifier) == code_challenge (base64url, no padding).
|
|
321
|
+
def pkce_valid?(verifier, challenge)
|
|
322
|
+
return false unless challenge.is_a?(String) && !challenge.empty?
|
|
323
|
+
require 'base64'
|
|
324
|
+
digest = OpenSSL::Digest::SHA256.digest(verifier)
|
|
325
|
+
computed = Base64.urlsafe_encode64(digest, padding: false)
|
|
326
|
+
OpenSSL.secure_compare(computed, challenge)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def handle_authorize_get(req, res)
|
|
330
|
+
require 'webrick'
|
|
331
|
+
params = req.query
|
|
332
|
+
client_id = params['client_id']
|
|
333
|
+
unless client_redirect_valid?(client_id, params['redirect_uri'])
|
|
334
|
+
json_error(res, 400, 'unauthorized_client', "Unknown client_id or unregistered redirect_uri: #{client_id}")
|
|
335
|
+
return
|
|
336
|
+
end
|
|
337
|
+
unless pkce_requested?(params)
|
|
338
|
+
json_error(res, 400, 'invalid_request', 'PKCE with S256 is required')
|
|
339
|
+
return
|
|
340
|
+
end
|
|
341
|
+
consent_page(res, params)
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# @return [Boolean] true if the request carries a S256 PKCE challenge
|
|
345
|
+
def pkce_requested?(params)
|
|
346
|
+
!params['code_challenge'].to_s.empty? && params['code_challenge_method'] == 'S256'
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Respond with the consent page
|
|
350
|
+
# @param params [Hash] Parameters of the authorization request
|
|
351
|
+
# @param status [Integer] HTTP status
|
|
352
|
+
# @param error [String, nil] Error message displayed on the page
|
|
353
|
+
def consent_page(res, params, status: 200, error: nil)
|
|
354
|
+
res.status = status
|
|
355
|
+
WebPage.secure_headers(res)
|
|
356
|
+
res.body = if @authorize_html_cb
|
|
357
|
+
@authorize_html_cb.call(params, @base_url)
|
|
358
|
+
else
|
|
359
|
+
authorize_html_from_template(params, error: error)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def handle_authorize_post(req, res)
|
|
364
|
+
require 'webrick'
|
|
365
|
+
params = WEBrick::HTTPUtils.parse_query(req.body.to_s)
|
|
366
|
+
client_id = params['client_id']
|
|
367
|
+
redirect_uri = params['redirect_uri']
|
|
368
|
+
state = params['state']
|
|
369
|
+
decision = params['decision']
|
|
370
|
+
# Validate client and redirect_uri before any redirect (prevents open redirect)
|
|
371
|
+
unless client_redirect_valid?(client_id, redirect_uri)
|
|
372
|
+
json_error(res, 400, 'unauthorized_client', "Unknown client_id or unregistered redirect_uri: #{client_id}")
|
|
373
|
+
return
|
|
374
|
+
end
|
|
375
|
+
if decision != 'allow'
|
|
376
|
+
res.status = 302
|
|
377
|
+
res['Location'] = client_redirect_url(redirect_uri, error: 'access_denied', state: state)
|
|
378
|
+
return
|
|
379
|
+
end
|
|
380
|
+
unless pkce_requested?(params)
|
|
381
|
+
res.status = 302
|
|
382
|
+
res['Location'] = client_redirect_url(redirect_uri, error: 'invalid_request', error_description: 'PKCE with S256 is required', state: state)
|
|
383
|
+
return
|
|
384
|
+
end
|
|
385
|
+
# The consent secret proves that the user approving the request is allowed to grant access
|
|
386
|
+
if @consent_secret && !OpenSSL.secure_compare(params['consent_secret'].to_s, @consent_secret)
|
|
387
|
+
Log.log.warn { "OAuth consent refused: wrong access code for client_id=#{client_id}" }
|
|
388
|
+
consent_page(res, params, status: 401, error: 'Invalid access code')
|
|
389
|
+
return
|
|
390
|
+
end
|
|
391
|
+
code = SecureRandom.urlsafe_base64(24)
|
|
392
|
+
@store_mutex.synchronize do
|
|
393
|
+
purge_expired
|
|
394
|
+
@pending_codes[code] = {
|
|
395
|
+
client_id: client_id,
|
|
396
|
+
redirect_uri: redirect_uri,
|
|
397
|
+
code_challenge: params['code_challenge'],
|
|
398
|
+
expires_at: Time.now + CODE_TTL
|
|
399
|
+
}
|
|
400
|
+
end
|
|
401
|
+
res.status = 302
|
|
402
|
+
res['Location'] = client_redirect_url(redirect_uri, code: code, state: state)
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Render the consent page from the ERB template.
|
|
406
|
+
# @param error [String, nil] Error message displayed on the page
|
|
407
|
+
def authorize_html_from_template(params, error: nil)
|
|
408
|
+
WebPage.render(
|
|
409
|
+
HTML_TEMPLATE_PATH,
|
|
410
|
+
title: @title,
|
|
411
|
+
description: @description,
|
|
412
|
+
action_url: "#{@base_url}/oauth/authorize",
|
|
413
|
+
client_id: params['client_id'].to_s,
|
|
414
|
+
redirect_uri: params['redirect_uri'].to_s,
|
|
415
|
+
redirect_origin: redirect_origin(params['redirect_uri'].to_s),
|
|
416
|
+
state: params['state'].to_s,
|
|
417
|
+
code_challenge: params['code_challenge'].to_s,
|
|
418
|
+
code_challenge_method: params['code_challenge_method'].to_s,
|
|
419
|
+
secret_required: !@consent_secret.nil?,
|
|
420
|
+
error: error
|
|
421
|
+
)
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|