hitch-rails 0.2.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 (91) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +103 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +460 -0
  5. data/SECURITY.md +118 -0
  6. data/app/controllers/concerns/hitch/cors_support.rb +97 -0
  7. data/app/controllers/concerns/hitch/host_validation.rb +51 -0
  8. data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
  9. data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
  10. data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
  11. data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
  12. data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
  13. data/app/controllers/concerns/hitch/request_admission.rb +46 -0
  14. data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
  15. data/app/controllers/hitch/application_controller.rb +59 -0
  16. data/app/controllers/hitch/authorizations_controller.rb +152 -0
  17. data/app/controllers/hitch/metadata_controller.rb +114 -0
  18. data/app/controllers/hitch/preflights_controller.rb +14 -0
  19. data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
  20. data/app/controllers/hitch/registrations_controller.rb +135 -0
  21. data/app/controllers/hitch/revocations_controller.rb +31 -0
  22. data/app/controllers/hitch/tokens_controller.rb +89 -0
  23. data/app/models/hitch/access_token.rb +267 -0
  24. data/app/models/hitch/application_record.rb +7 -0
  25. data/app/models/hitch/authorization_request.rb +252 -0
  26. data/app/models/hitch/client/credentials.rb +30 -0
  27. data/app/models/hitch/client.rb +237 -0
  28. data/app/models/hitch/client_authentication.rb +80 -0
  29. data/app/models/hitch/client_id_metadata/cache.rb +69 -0
  30. data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
  31. data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
  32. data/app/models/hitch/client_id_metadata.rb +316 -0
  33. data/app/models/hitch/client_redirect_uri.rb +14 -0
  34. data/app/models/hitch/mcp/context.rb +91 -0
  35. data/app/models/hitch/mcp/forbidden.rb +10 -0
  36. data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
  37. data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
  38. data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
  39. data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
  40. data/app/models/hitch/mcp/internal/header_field.rb +31 -0
  41. data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
  42. data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
  43. data/app/models/hitch/mcp/internal/json_values.rb +182 -0
  44. data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
  45. data/app/models/hitch/mcp/internal/media_type.rb +61 -0
  46. data/app/models/hitch/mcp/internal/observation.rb +333 -0
  47. data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
  48. data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
  49. data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
  50. data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
  51. data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
  52. data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
  53. data/app/models/hitch/mcp/internal/server_info.rb +49 -0
  54. data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
  55. data/app/models/hitch/mcp/internal.rb +11 -0
  56. data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
  57. data/app/models/hitch/mcp/registry.rb +70 -0
  58. data/app/models/hitch/mcp/result.rb +63 -0
  59. data/app/models/hitch/mcp/tool.rb +148 -0
  60. data/app/models/hitch/oauth_request_parameters.rb +74 -0
  61. data/app/views/hitch/authorizations/new.html.erb +57 -0
  62. data/config/routes.rb +37 -0
  63. data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
  64. data/docs/operator/doctor.md +82 -0
  65. data/docs/operator/rate_limiting.md +98 -0
  66. data/docs/public_api/0.2.0.md +322 -0
  67. data/docs/removing.md +43 -0
  68. data/lib/generators/hitch/generator_guards.rb +36 -0
  69. data/lib/generators/hitch/install/install_generator.rb +168 -0
  70. data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
  71. data/lib/generators/hitch/install/templates/initializer.rb +40 -0
  72. data/lib/generators/hitch/install/templates/registry.rb +6 -0
  73. data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
  74. data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
  75. data/lib/generators/hitch/tool_generator.rb +153 -0
  76. data/lib/hitch/configuration.rb +386 -0
  77. data/lib/hitch/doctor.rb +647 -0
  78. data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
  79. data/lib/hitch/engine.rb +154 -0
  80. data/lib/hitch/mcp/configuration.rb +190 -0
  81. data/lib/hitch/mcp/protocol.rb +36 -0
  82. data/lib/hitch/mcp/test_helper.rb +203 -0
  83. data/lib/hitch/pkce.rb +18 -0
  84. data/lib/hitch/rack_form_guard.rb +109 -0
  85. data/lib/hitch/rate_limit_store.rb +47 -0
  86. data/lib/hitch/resource_uri.rb +71 -0
  87. data/lib/hitch/version.rb +5 -0
  88. data/lib/hitch-rails.rb +6 -0
  89. data/lib/hitch.rb +51 -0
  90. data/lib/tasks/hitch.rake +197 -0
  91. metadata +230 -0
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require "uri"
5
+
6
+ module Hitch
7
+ # Rack::MethodOverride parses every form POST before Rails dispatches a
8
+ # controller. Hitch's OAuth endpoints and final MCP endpoint own stricter raw
9
+ # body parsers, so pre-populate Rack's form cache only when Rails resolves the
10
+ # request to one of those actions. Resolving ownership matters: Rails accepts
11
+ # format/trailing-slash variants, host routes may shadow the engine, and the
12
+ # MCP controller/route remain host-owned.
13
+ class RackFormGuard
14
+ ENDPOINTS = {
15
+ "hitch/authorizations" => "create",
16
+ "hitch/registrations" => "create",
17
+ "hitch/revocations" => "create",
18
+ "hitch/tokens" => "create"
19
+ }.freeze
20
+ OAUTH_CANDIDATE_PATH = %r{/(?:oauth)/(?:authorize|register|revoke|token)(?:\.[^/]*)?/?\z}
21
+
22
+ def initialize(app, routes: -> { Rails.application.routes })
23
+ @app = app
24
+ @routes = routes
25
+ end
26
+
27
+ def call(environment)
28
+ route_type = hitch_strict_body_route(environment)
29
+ if route_type
30
+ environment[Rack::RACK_REQUEST_FORM_HASH] = {}
31
+ environment[Rack::RACK_REQUEST_FORM_PAIRS] = []
32
+ # The MCP route is permanently POST/OPTIONS. A generic Rack method
33
+ # override must neither parse its body nor turn authenticated POST
34
+ # traffic into an unauthenticated OPTIONS request.
35
+ environment.delete("HTTP_X_HTTP_METHOD_OVERRIDE") if route_type == :mcp
36
+ end
37
+
38
+ @app.call(environment)
39
+ end
40
+
41
+ private
42
+
43
+ def hitch_strict_body_route(environment)
44
+ return false unless environment[Rack::REQUEST_METHOD] == "POST"
45
+
46
+ # Match RouteSet#call exactly before narrowing the ownership probe. Rails
47
+ # collapses repeated slashes but deliberately does not resolve dot
48
+ # segments; applying the candidate filter to raw PATH_INFO would miss
49
+ # routes Rails later dispatches.
50
+ normalized_path = ActionDispatch::Journey::Router::Utils.normalize_path(environment[Rack::PATH_INFO])
51
+ return false unless hitch_candidate_path?(normalized_path)
52
+
53
+ # Route recognition must not share the credential-bearing input or
54
+ # path-parameter cache with the request Rails will actually dispatch.
55
+ # Host route constraints still receive the real method/host/headers, but
56
+ # cannot become an earlier body reader through this ownership probe.
57
+ probe_environment = environment.dup
58
+ probe_environment.delete("action_dispatch.request.path_parameters")
59
+ probe_environment["rack.input"] = StringIO.new
60
+ probe_environment[Rack::RACK_REQUEST_FORM_HASH] = {}
61
+ probe_environment[Rack::RACK_REQUEST_FORM_PAIRS] = []
62
+ probe_environment["CONTENT_LENGTH"] = "0"
63
+ probe_environment[Rack::PATH_INFO] = normalized_path
64
+
65
+ request = ActionDispatch::Request.new(probe_environment)
66
+ parameters = route_set.recognize_path_with_request(
67
+ request,
68
+ normalized_path,
69
+ {},
70
+ raise_on_missing: false
71
+ )
72
+ return false unless parameters
73
+ return :oauth if hitch_oauth_endpoint?(parameters)
74
+ return :mcp if hitch_mcp_endpoint?(parameters)
75
+
76
+ false
77
+ rescue ActionController::RoutingError
78
+ false
79
+ end
80
+
81
+ def route_set
82
+ @routes.respond_to?(:call) ? @routes.call : @routes
83
+ end
84
+
85
+ def hitch_oauth_endpoint?(parameters)
86
+ ENDPOINTS[parameters[:controller]] == parameters[:action]
87
+ end
88
+
89
+ def hitch_mcp_endpoint?(parameters)
90
+ return false unless parameters[:action] == "handle"
91
+
92
+ controller_name = parameters[:controller].to_s
93
+ return false if controller_name.empty?
94
+
95
+ controller = "#{controller_name.camelize}Controller".safe_constantize
96
+ controller && controller.ancestors.include?(Hitch::MCP::Endpoint)
97
+ end
98
+
99
+ def hitch_candidate_path?(path)
100
+ return true if OAUTH_CANDIDATE_PATH.match?(path)
101
+
102
+ resource_path = URI.parse(Hitch.configuration.resource_uri.to_s).path
103
+ resource_path = "/" if resource_path.empty?
104
+ %r{\A#{Regexp.escape(resource_path)}(?:\.[^/]*)?/?\z}.match?(path)
105
+ rescue URI::InvalidURIError
106
+ false
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ # Both Hitch rate limits — unauthenticated client registration and
5
+ # authenticated MCP requests — count through the host application's own
6
+ # ActiveSupport::Cache store, the way ActionController::RateLimiting does.
7
+ # Neither adds a service to the deployment, and neither asks the host to
8
+ # implement an interface.
9
+ module RateLimitStore
10
+ class << self
11
+ def resolve(configured)
12
+ configured || ActionController::Base.cache_store
13
+ end
14
+
15
+ def validate!(store, setting:)
16
+ return store if store.nil? || store.respond_to?(:increment)
17
+
18
+ raise ArgumentError,
19
+ "#{setting} must be an ActiveSupport::Cache store responding to increment"
20
+ end
21
+
22
+ # Production boot check, shared by both limiters: the resolved store
23
+ # must be able to count one caller's requests across processes. Unlike
24
+ # validate!, nil is refused here — there is no later to resolve at.
25
+ def assert_shared!(store, setting:)
26
+ unless store.respond_to?(:increment)
27
+ raise ArgumentError,
28
+ "#{setting} must be an ActiveSupport::Cache store responding to increment"
29
+ end
30
+ return true unless unshared?(store)
31
+
32
+ raise ArgumentError,
33
+ "#{setting} resolved to #{store.class.name}, which cannot count one caller's " \
34
+ "requests across the processes serving them. Configure a shared " \
35
+ "config.cache_store (Solid Cache, Redis, Memcached) or set #{setting} explicitly."
36
+ end
37
+
38
+ # MemoryStore is per process, NullStore retains nothing, and FileStore
39
+ # reads and writes without a lock.
40
+ def unshared?(store)
41
+ store.is_a?(ActiveSupport::Cache::MemoryStore) ||
42
+ store.is_a?(ActiveSupport::Cache::NullStore) ||
43
+ store.is_a?(ActiveSupport::Cache::FileStore)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Hitch
6
+ class ResourceUri
7
+ class Invalid < ArgumentError; end
8
+
9
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
10
+
11
+ def self.canonicalize!(value, allow_loopback_http: false)
12
+ uri = URI.parse(value.to_s)
13
+ scheme = uri.scheme.to_s.downcase
14
+ host = uri.hostname.to_s.downcase
15
+
16
+ raise Invalid, "resource must be an absolute HTTP URI" unless uri.absolute? && host.present?
17
+ unless uri.userinfo.nil? && !userinfo_component_present?(value)
18
+ raise Invalid, "resource must not include userinfo"
19
+ end
20
+ raise Invalid, "resource must not include a fragment" unless uri.fragment.nil?
21
+ unless scheme == "https" || (scheme == "http" && allow_loopback_http && LOOPBACK_HOSTS.include?(host))
22
+ raise Invalid, "resource must use HTTPS"
23
+ end
24
+
25
+ canonical = uri.dup
26
+ canonical.scheme = scheme
27
+ canonical.hostname = host
28
+ canonical.port = nil if canonical.port == canonical.default_port
29
+ canonical.to_s
30
+ rescue URI::InvalidURIError
31
+ raise Invalid, "resource must be an absolute HTTP URI"
32
+ end
33
+
34
+ # The `host[:port]` authority, with the port omitted when it is the
35
+ # scheme's default. URI already brackets IPv6 literals in #host and
36
+ # already knows each scheme's default port, so neither rule is restated
37
+ # here or at any call site.
38
+ def self.authority(uri)
39
+ uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
40
+ end
41
+
42
+ # The origin (RFC 6454): scheme and authority, no path, query, or
43
+ # fragment. The authorization server's issuer identifier is the origin of
44
+ # the canonical resource_uri (RFC 8414 §2), and it MUST be byte-identical
45
+ # in the discovery document, the `iss` authorization response parameter
46
+ # (RFC 9207), and every bearer challenge. One derivation is what makes
47
+ # that true by construction rather than by convention.
48
+ def self.origin(uri)
49
+ "#{uri.scheme}://#{authority(uri)}"
50
+ end
51
+
52
+ # The path-aware protected-resource metadata URL (RFC 9728 §3.1): the
53
+ # well-known segment sits between the origin and the resource's own path.
54
+ # Derives its own origin so a caller cannot pair this path with a
55
+ # different issuer.
56
+ def self.protected_resource_metadata_url(uri)
57
+ path = uri.path.to_s
58
+ suffix = path.empty? || path == "/" ? "" : path
59
+ query = uri.query ? "?#{uri.query}" : ""
60
+
61
+ "#{origin(uri)}/.well-known/oauth-protected-resource#{suffix}#{query}"
62
+ end
63
+
64
+ # URI.parse can miss a userinfo component that a raw scan of the
65
+ # authority still finds; callers use both checks together.
66
+ def self.userinfo_component_present?(value)
67
+ authority = value.to_s.match(/\A[a-z][a-z0-9+.-]*:\/\/([^\/?#]*)/i)&.captures&.first
68
+ authority&.include?("@") || false
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hitch
4
+ VERSION = "0.2.0"
5
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Shim so Bundler.require finds the gem under its dasherized name.
4
+ # All real code lives in lib/hitch.rb (underscore matches the
5
+ # module namespace Hitch).
6
+ require "hitch"
data/lib/hitch.rb ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hitch/version"
4
+ require "hitch/rack_form_guard"
5
+ require "hitch/rate_limit_store"
6
+ require "hitch/engine"
7
+ require "hitch/resource_uri"
8
+ require "hitch/pkce"
9
+ require "hitch/mcp/protocol"
10
+ require "hitch/mcp/configuration"
11
+ require "hitch/configuration"
12
+ require "hitch/dynamic_registration_rate_limit"
13
+
14
+ # hitch-rails turns a Rails app into an authorization server implemented
15
+ # against the MCP 2026-07-28 authorization profile — OAuth 2.1 + PKCE (S256),
16
+ # Resource Indicators with audience binding (RFC 8707), discovery metadata
17
+ # (RFC 8414 + RFC 9728), token revocation (RFC 7009), and CORS for
18
+ # browser-based MCP clients — plus an authenticated host-mounted MCP endpoint
19
+ # with a deny-default tool Registry behind a private Ruby SDK boundary, rate
20
+ # limiting counted through the host application's own cache store, sanitized
21
+ # observation events, and a read-only operator doctor.
22
+ #
23
+ # Spec reference: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
24
+ #
25
+ # Usage:
26
+ # # config/initializers/hitch.rb
27
+ # Hitch.configure do |config|
28
+ # config.resource_uri = "https://example.com/mcp" # for RFC 8707
29
+ # config.allowed_hosts = []
30
+ # config.allowed_origins = []
31
+ # end
32
+ module Hitch
33
+ class << self
34
+ # @yield [Configuration] the gem's configuration
35
+ # @return [Configuration] the (potentially modified) configuration
36
+ def configure
37
+ yield(configuration) if block_given?
38
+ configuration
39
+ end
40
+
41
+ # @return [Configuration]
42
+ def configuration
43
+ @configuration ||= Configuration.new
44
+ end
45
+
46
+ # Reset configuration (useful in tests).
47
+ def reset_configuration!
48
+ @configuration = nil
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hitch/doctor"
4
+
5
+ module Hitch
6
+ # Argument parsing for hitch:tokens:issue. Kept out of the task body so a
7
+ # bad PRINCIPAL aborts with a sentence instead of a NameError.
8
+ module TokenIssueTask
9
+ module_function
10
+
11
+ # rpartition, not split: a namespaced principal (Accounts::User:5) has
12
+ # colons in the model half, and only the last one separates the id.
13
+ def principal!(value)
14
+ model_name, _, id = value.to_s.rpartition(":")
15
+ abort "PRINCIPAL is required, as Model:id (for example User:1)" if model_name.blank? || id.blank?
16
+
17
+ model = model_name.safe_constantize
18
+ unless model.is_a?(Class) && model < ActiveRecord::Base
19
+ abort "PRINCIPAL model #{model_name} is not an Active Record model"
20
+ end
21
+ abort "PRINCIPAL model #{model_name} is abstract; name the model that stores the record" if
22
+ model.abstract_class?
23
+
24
+ # Numeric keys silently absorb junk: Rails casts "12 34" to 12, so a
25
+ # typo would issue a token for somebody else. UUID, ULID and string
26
+ # keys are matched exactly by the adapter, or raise, so they are left
27
+ # alone — including the upcased and undashed UUID forms that resolve
28
+ # correctly.
29
+ numeric_key = %i[integer decimal float].include?(
30
+ model.type_for_attribute(model.primary_key).type
31
+ )
32
+ if numeric_key && !id.match?(/\A\d+\z/)
33
+ abort "PRINCIPAL id #{id.inspect} is not a whole number"
34
+ end
35
+
36
+ model.find(id)
37
+ rescue ActiveRecord::RecordNotFound
38
+ abort "No #{model_name} with id #{id}"
39
+ end
40
+
41
+ # Long enough that a cron agent is not reissued monthly, short enough
42
+ # that an unnoticed leak expires.
43
+ def days!(value)
44
+ return 90 if value.blank?
45
+
46
+ days = Integer(value, 10, exception: false)
47
+ abort "EXPIRES_IN_DAYS must be a positive whole number of days" unless days&.positive?
48
+ # The ceiling is the model's, so there is one answer to how long a
49
+ # token may live rather than two that can drift.
50
+ max = Hitch::AccessToken::MAX_LIFETIME_SECONDS / 86_400
51
+ abort "EXPIRES_IN_DAYS must not exceed #{max}" if days > max
52
+
53
+ days
54
+ end
55
+ end
56
+
57
+ module ClientCredentialTask
58
+ module_function
59
+
60
+ # The block returns the exact bytes to disclose. A client registration
61
+ # hands over two values and labels them; a token is one value, and what
62
+ # lands in the file has to be usable as `Authorization: Bearer $(cat …)`
63
+ # without anyone having to know a file format.
64
+ def disclose(stdin: $stdin, tty_path: "/dev/tty")
65
+ with_output(stdin: stdin, tty_path: tty_path) do |output|
66
+ Hitch::ApplicationRecord.transaction do
67
+ disclosed = yield
68
+ # A secret is what gets written; anything else is a caller mistake
69
+ # worth failing on rather than serialising.
70
+ raise TypeError, "disclose must be given the exact bytes to write" unless
71
+ disclosed.is_a?(String)
72
+
73
+ output.write(disclosed.end_with?("\n") ? disclosed : "#{disclosed}\n")
74
+ output.flush
75
+ end
76
+ end
77
+ end
78
+
79
+ def with_output(stdin:, tty_path:)
80
+ output_path = ENV["OUTPUT_FILE"].presence
81
+ if output_path
82
+ with_exclusive_file(output_path) { |output| yield output }
83
+ elsif stdin.tty?
84
+ File.open(tty_path, File::WRONLY) { |output| yield output }
85
+ else
86
+ abort "Set OUTPUT_FILE when standard input is not an interactive terminal"
87
+ end
88
+ end
89
+
90
+ def with_exclusive_file(path)
91
+ output = File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600)
92
+ begin
93
+ output.chmod(0o600)
94
+ yield output
95
+ rescue Exception # rubocop:disable Lint/RescueException -- remove a partially written credential file on every task abort
96
+ output.close
97
+ File.unlink(path) if File.exist?(path)
98
+ raise
99
+ ensure
100
+ output.close unless output.closed?
101
+ end
102
+ rescue Errno::EEXIST
103
+ abort "Refusing to overwrite existing OUTPUT_FILE"
104
+ end
105
+ end
106
+ end
107
+
108
+ namespace :hitch do
109
+ desc "Diagnose Hitch configuration, routes, schema, registry, and admission store"
110
+ task doctor: :environment do
111
+ format = ENV.fetch("HITCH_DOCTOR_FORMAT", "human")
112
+ report = Hitch::Doctor.call
113
+ puts Hitch::Doctor.render(report, format:)
114
+ exit(1) if report.failure?
115
+ rescue ArgumentError => error
116
+ abort error.message
117
+ end
118
+
119
+ namespace :clients do
120
+ desc "Create a confidential OAuth client and disclose its one-time secret safely"
121
+ task create_confidential: :environment do
122
+ client_id = ENV["CLIENT_ID"].presence || abort("CLIENT_ID is required")
123
+ redirect_uri = ENV["REDIRECT_URI"].presence || abort("REDIRECT_URI is required")
124
+
125
+ Hitch::ClientCredentialTask.disclose do
126
+ credentials = Hitch::Client.register_confidential!(
127
+ client_id: client_id,
128
+ client_name: ENV["NAME"],
129
+ redirect_uris: [ redirect_uri ]
130
+ )
131
+ "client_id=#{credentials.client.client_id}\nclient_secret=#{credentials.client_secret}\n"
132
+ end
133
+ end
134
+
135
+ desc "Rotate a confidential OAuth client's secret and disclose it safely"
136
+ task rotate_secret: :environment do
137
+ client_id = ENV["CLIENT_ID"].presence || abort("CLIENT_ID is required")
138
+
139
+ Hitch::ClientCredentialTask.disclose do
140
+ client = Hitch::Client.find_by(client_id: client_id)
141
+ abort "Confidential client not found" unless client&.confidential_client?
142
+
143
+ credentials = client.rotate_secret!
144
+ "client_id=#{credentials.client.client_id}\nclient_secret=#{credentials.client_secret}\n"
145
+ end
146
+ end
147
+ end
148
+
149
+ namespace :tokens do
150
+ desc "Issue a long-lived access token for a headless agent " \
151
+ "(usage: bin/rails hitch:tokens:issue PRINCIPAL=User:1)"
152
+ task issue: :environment do
153
+ principal = Hitch::TokenIssueTask.principal!(ENV["PRINCIPAL"])
154
+ scopes = ENV["SCOPES"].to_s.split
155
+ days = Hitch::TokenIssueTask.days!(ENV["EXPIRES_IN_DAYS"])
156
+
157
+ client_id = ENV["CLIENT_ID"].presence || "hitch-cli"
158
+ Hitch::ClientCredentialTask.disclose do
159
+ Hitch::AccessToken.issue!(
160
+ principal: principal,
161
+ client_id: client_id,
162
+ client_name: ENV["NAME"],
163
+ scopes: scopes,
164
+ expires_in: days * 86_400
165
+ )
166
+ end
167
+
168
+ # The secret went to the file or the terminal; this goes to stderr, so a
169
+ # silent success is not mistaken for a no-op. It never names where the
170
+ # token went, because on a terminal there is no file.
171
+ warn "Issued an access token for #{principal.class.name}:#{principal.id} " \
172
+ "(client_id #{client_id}, #{days} days)."
173
+ end
174
+ end
175
+
176
+ namespace :cimd do
177
+ desc "Fetch a Client ID Metadata Document to verify this host's egress " \
178
+ "(usage: bin/rails 'hitch:cimd:check[https://client.example/client.json]')"
179
+ task :check, [ :client_id ] => :environment do |_task, args|
180
+ client_id = args[:client_id]
181
+ abort "Usage: bin/rails 'hitch:cimd:check[https://client.example/client.json]'" if client_id.blank?
182
+
183
+ # Reports; never changes what discovery advertises. Whether this
184
+ # host can reach one document today is a different question from
185
+ # whether it supports CIMD, and only the second belongs in the
186
+ # discovery document.
187
+ result = Hitch::ClientIdMetadata.diagnose(client_id)
188
+
189
+ puts "client_id: #{client_id}"
190
+ puts "outcome: #{result.outcome}"
191
+ puts "detail: #{result.detail}"
192
+ puts
193
+ puts(result.ok? ? "Egress to this document works." : "This host could not resolve that document.")
194
+ exit(1) unless result.ok?
195
+ end
196
+ end
197
+ end