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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +160 -4
  4. data/CONTRIBUTING.md +4 -2
  5. data/README.md +1 -1
  6. data/bin/asession +1 -1
  7. data/docs/README.md +1086 -331
  8. data/docs/test-mcp-with-ai.md +5 -3
  9. data/lib/aspera/agent/direct.rb +15 -4
  10. data/lib/aspera/agent/factory.rb +2 -1
  11. data/lib/aspera/agent/node.rb +1 -1
  12. data/lib/aspera/api/cos_node.rb +1 -1
  13. data/lib/aspera/api/faspex.rb +1 -1
  14. data/lib/aspera/api/node.rb +6 -2
  15. data/lib/aspera/ascp/installation.rb +47 -11
  16. data/lib/aspera/ascp/management.rb +89 -43
  17. data/lib/aspera/cli/ascp_actions.rb +5 -19
  18. data/lib/aspera/cli/bootstrapper.rb +1 -2
  19. data/lib/aspera/cli/command_registry.rb +43 -3
  20. data/lib/aspera/cli/command_spec.rb +3 -0
  21. data/lib/aspera/cli/completion/ascli.bash +39 -5
  22. data/lib/aspera/cli/completion/ascli.fish +16 -8
  23. data/lib/aspera/cli/completion/ascli.ps1 +41 -0
  24. data/lib/aspera/cli/completion/ascli.zsh +20 -4
  25. data/lib/aspera/cli/completion_actions.rb +360 -0
  26. data/lib/aspera/cli/error.rb +42 -0
  27. data/lib/aspera/cli/extended_value.rb +30 -5
  28. data/lib/aspera/cli/formatter.rb +118 -56
  29. data/lib/aspera/cli/hints.rb +0 -1
  30. data/lib/aspera/cli/http.rb +6 -5
  31. data/lib/aspera/cli/info.rb +10 -4
  32. data/lib/aspera/cli/mailer.rb +1 -1
  33. data/lib/aspera/cli/mcp/http_servlet.rb +156 -0
  34. data/lib/aspera/cli/mcp/secrets.rb +143 -0
  35. data/lib/aspera/cli/mcp/tool.rb +231 -0
  36. data/lib/aspera/cli/option_value.rb +9 -1
  37. data/lib/aspera/cli/options.schema.yaml +390 -39
  38. data/lib/aspera/cli/parser.rb +14 -5
  39. data/lib/aspera/cli/plugins/aoc.rb +8 -11
  40. data/lib/aspera/cli/plugins/base.rb +36 -15
  41. data/lib/aspera/cli/plugins/config.rb +15 -44
  42. data/lib/aspera/cli/plugins/console.rb +3 -1
  43. data/lib/aspera/cli/plugins/factory.rb +1 -1
  44. data/lib/aspera/cli/plugins/faspex5.rb +9 -19
  45. data/lib/aspera/cli/plugins/faspio.rb +36 -5
  46. data/lib/aspera/cli/plugins/httpgw.rb +1 -0
  47. data/lib/aspera/cli/plugins/mcp.rb +177 -118
  48. data/lib/aspera/cli/plugins/node.rb +14 -8
  49. data/lib/aspera/cli/plugins/orchestrator.rb +351 -38
  50. data/lib/aspera/cli/plugins/server.rb +3 -1
  51. data/lib/aspera/cli/plugins/shares.rb +205 -35
  52. data/lib/aspera/cli/preset_actions.rb +15 -7
  53. data/lib/aspera/cli/preset_manager.rb +24 -2
  54. data/lib/aspera/cli/prompt.rb +3 -1
  55. data/lib/aspera/cli/result.rb +17 -4
  56. data/lib/aspera/cli/runner.rb +133 -43
  57. data/lib/aspera/cli/security.rb +134 -0
  58. data/lib/aspera/cli/table_fitter.rb +116 -0
  59. data/lib/aspera/cli/terminal_formatter.rb +23 -34
  60. data/lib/aspera/cli/transfer_agent.rb +4 -1
  61. data/lib/aspera/cli/vault_manager.rb +6 -5
  62. data/lib/aspera/cli/version.rb +1 -1
  63. data/lib/aspera/cli/web_secret.html.erb +61 -0
  64. data/lib/aspera/cli/web_secret.rb +220 -0
  65. data/lib/aspera/cli/wizard.rb +19 -9
  66. data/lib/aspera/command_line_builder.rb +26 -0
  67. data/lib/aspera/data_repository.rb +38 -24
  68. data/lib/aspera/duration.rb +42 -0
  69. data/lib/aspera/environment.rb +88 -24
  70. data/lib/aspera/exec_spec.rb +6 -5
  71. data/lib/aspera/faspex_gw.rb +98 -32
  72. data/lib/aspera/faspex_postproc.rb +187 -44
  73. data/lib/aspera/formatter_interface.rb +0 -1
  74. data/lib/aspera/id_generator.rb +2 -1
  75. data/lib/aspera/json_rpc/client.rb +51 -32
  76. data/lib/aspera/json_rpc/version.rb +1 -0
  77. data/lib/aspera/keychain/base.rb +5 -0
  78. data/lib/aspera/keychain/encrypted_hash.rb +6 -2
  79. data/lib/aspera/keychain/factory.rb +2 -3
  80. data/lib/aspera/keychain/hashicorp_vault.rb +7 -4
  81. data/lib/aspera/keychain/macos_security.rb +119 -13
  82. data/lib/aspera/keychain/one_password_api.rb +8 -3
  83. data/lib/aspera/keychain/one_password_base.rb +14 -9
  84. data/lib/aspera/keychain/one_password_cli.rb +4 -2
  85. data/lib/aspera/log.rb +3 -6
  86. data/lib/aspera/markdown.rb +152 -115
  87. data/lib/aspera/mime.rb +6 -0
  88. data/lib/aspera/nagios.rb +3 -1
  89. data/lib/aspera/node_emulator.rb +98 -101
  90. data/lib/aspera/oauth/{base.rb → client/base.rb} +5 -4
  91. data/lib/aspera/oauth/{boot.rb → client/boot.rb} +3 -2
  92. data/lib/aspera/oauth/{factory.rb → client/factory.rb} +7 -0
  93. data/lib/aspera/oauth/{generic.rb → client/generic.rb} +1 -1
  94. data/lib/aspera/oauth/{json_credentials.rb → client/json_credentials.rb} +1 -1
  95. data/lib/aspera/oauth/{jwt.rb → client/jwt.rb} +1 -1
  96. data/lib/aspera/oauth/{url_json.rb → client/url_json.rb} +1 -1
  97. data/lib/aspera/oauth/{web.rb → client/web.rb} +2 -2
  98. data/lib/aspera/oauth/server/authorize.html.erb +34 -0
  99. data/lib/aspera/oauth/server/pkce.rb +426 -0
  100. data/lib/aspera/orchestrator_workflow_checker.rb +561 -0
  101. data/lib/aspera/preview/terminal.rb +3 -2
  102. data/lib/aspera/rainbow.rb +27 -1
  103. data/lib/aspera/rest/aspera_errors.rb +51 -59
  104. data/lib/aspera/rest/client.rb +21 -14
  105. data/lib/aspera/rest/error_analyzer.rb +9 -0
  106. data/lib/aspera/rest/list.rb +6 -6
  107. data/lib/aspera/rest/util.rb +7 -1
  108. data/lib/aspera/rest.rb +0 -1
  109. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +0 -2
  110. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1-enhanced.yaml +4001 -0
  111. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0-enhanced.yaml +1097 -0
  112. data/lib/aspera/schema/IBM Aspera on Cloud Automation API-1.0.5-enhanced.yaml +35 -35
  113. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +819 -7
  114. data/lib/aspera/schema/documentation.rb +50 -78
  115. data/lib/aspera/schema/reader.rb +39 -11
  116. data/lib/aspera/schema/registry.rb +8 -2
  117. data/lib/aspera/secret_hider.rb +94 -15
  118. data/lib/aspera/secret_strength.rb +114 -0
  119. data/lib/aspera/servlet_helpers.rb +111 -0
  120. data/lib/aspera/sync/args.schema.yaml +3 -1
  121. data/lib/aspera/sync/conf.schema.yaml +16 -6
  122. data/lib/aspera/sync/operations.rb +21 -1
  123. data/lib/aspera/temp_file_manager.rb +79 -20
  124. data/lib/aspera/transfer/parameters.rb +5 -0
  125. data/lib/aspera/transfer/spec.rb +52 -12
  126. data/lib/aspera/transfer/spec.schema.yaml +8 -5
  127. data/lib/aspera/web_auth/index.html.erb +16 -0
  128. data/lib/aspera/web_auth/server.rb +18 -10
  129. data/lib/aspera/web_page.html.erb +262 -0
  130. data/lib/aspera/web_page.rb +61 -0
  131. data/lib/aspera/web_server_simple.rb +76 -10
  132. data.tar.gz.sig +0 -0
  133. metadata +47 -32
  134. metadata.gz.sig +0 -0
  135. data/lib/aspera/cli/mcp_tool.rb +0 -189
  136. data/lib/aspera/oauth.rb +0 -9
  137. data/lib/aspera/schema/IBM Aspera Orchestrator API-v1.yaml +0 -1784
  138. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +0 -249
  139. 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
- # TODO: :username and :scope shall be done in class, using cache_ids
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 — re-authenticate in browser')
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
 
5
5
  module Aspera
6
6
  module OAuth
@@ -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 'digest'
5
5
 
6
6
  module Aspera
@@ -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/assert'
6
6
  require 'securerandom'
@@ -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
 
5
5
  module Aspera
6
6
  module OAuth
@@ -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(%w[127.0.0.1 ::1 localhost].include?(uri.host)) { 'redirect_uri host must be localhost or loopback address' }
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