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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +103 -0
- data/MIT-LICENSE +20 -0
- data/README.md +460 -0
- data/SECURITY.md +118 -0
- data/app/controllers/concerns/hitch/cors_support.rb +97 -0
- data/app/controllers/concerns/hitch/host_validation.rb +51 -0
- data/app/controllers/concerns/hitch/issuer_url.rb +26 -0
- data/app/controllers/concerns/hitch/mcp/endpoint.rb +355 -0
- data/app/controllers/concerns/hitch/oauth_form_admission.rb +83 -0
- data/app/controllers/concerns/hitch/oauth_parameter_validation.rb +26 -0
- data/app/controllers/concerns/hitch/registration_admission.rb +115 -0
- data/app/controllers/concerns/hitch/request_admission.rb +46 -0
- data/app/controllers/concerns/hitch/uri_validation.rb +116 -0
- data/app/controllers/hitch/application_controller.rb +59 -0
- data/app/controllers/hitch/authorizations_controller.rb +152 -0
- data/app/controllers/hitch/metadata_controller.rb +114 -0
- data/app/controllers/hitch/preflights_controller.rb +14 -0
- data/app/controllers/hitch/public_endpoint_controller.rb +36 -0
- data/app/controllers/hitch/registrations_controller.rb +135 -0
- data/app/controllers/hitch/revocations_controller.rb +31 -0
- data/app/controllers/hitch/tokens_controller.rb +89 -0
- data/app/models/hitch/access_token.rb +267 -0
- data/app/models/hitch/application_record.rb +7 -0
- data/app/models/hitch/authorization_request.rb +252 -0
- data/app/models/hitch/client/credentials.rb +30 -0
- data/app/models/hitch/client.rb +237 -0
- data/app/models/hitch/client_authentication.rb +80 -0
- data/app/models/hitch/client_id_metadata/cache.rb +69 -0
- data/app/models/hitch/client_id_metadata/fetcher.rb +277 -0
- data/app/models/hitch/client_id_metadata/throttle.rb +119 -0
- data/app/models/hitch/client_id_metadata.rb +316 -0
- data/app/models/hitch/client_redirect_uri.rb +14 -0
- data/app/models/hitch/mcp/context.rb +91 -0
- data/app/models/hitch/mcp/forbidden.rb +10 -0
- data/app/models/hitch/mcp/internal/bearer_challenge.rb +51 -0
- data/app/models/hitch/mcp/internal/cors_policy.rb +53 -0
- data/app/models/hitch/mcp/internal/endpoint_error_reporter.rb +40 -0
- data/app/models/hitch/mcp/internal/error_normalizer.rb +74 -0
- data/app/models/hitch/mcp/internal/header_field.rb +31 -0
- data/app/models/hitch/mcp/internal/hmac_identity.rb +37 -0
- data/app/models/hitch/mcp/internal/host_authority.rb +51 -0
- data/app/models/hitch/mcp/internal/json_values.rb +182 -0
- data/app/models/hitch/mcp/internal/local_diagnosis.rb +31 -0
- data/app/models/hitch/mcp/internal/media_type.rb +61 -0
- data/app/models/hitch/mcp/internal/observation.rb +333 -0
- data/app/models/hitch/mcp/internal/registry_runtime.rb +312 -0
- data/app/models/hitch/mcp/internal/result_normalizer.rb +167 -0
- data/app/models/hitch/mcp/internal/sanitized_report.rb +36 -0
- data/app/models/hitch/mcp/internal/schema_contract.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter/response_normalizer.rb +173 -0
- data/app/models/hitch/mcp/internal/sdk_adapter.rb +222 -0
- data/app/models/hitch/mcp/internal/server_info.rb +49 -0
- data/app/models/hitch/mcp/internal/verified_request.rb +229 -0
- data/app/models/hitch/mcp/internal.rb +11 -0
- data/app/models/hitch/mcp/rate_limit_key.rb +29 -0
- data/app/models/hitch/mcp/registry.rb +70 -0
- data/app/models/hitch/mcp/result.rb +63 -0
- data/app/models/hitch/mcp/tool.rb +148 -0
- data/app/models/hitch/oauth_request_parameters.rb +74 -0
- data/app/views/hitch/authorizations/new.html.erb +57 -0
- data/config/routes.rb +37 -0
- data/db/migrate/20260817000000_create_hitch_tables.rb +77 -0
- data/docs/operator/doctor.md +82 -0
- data/docs/operator/rate_limiting.md +98 -0
- data/docs/public_api/0.2.0.md +322 -0
- data/docs/removing.md +43 -0
- data/lib/generators/hitch/generator_guards.rb +36 -0
- data/lib/generators/hitch/install/install_generator.rb +168 -0
- data/lib/generators/hitch/install/templates/controller.rb.tt +11 -0
- data/lib/generators/hitch/install/templates/initializer.rb +40 -0
- data/lib/generators/hitch/install/templates/registry.rb +6 -0
- data/lib/generators/hitch/tool/templates/tool.rb.tt +54 -0
- data/lib/generators/hitch/tool/templates/tool_test.rb.tt +58 -0
- data/lib/generators/hitch/tool_generator.rb +153 -0
- data/lib/hitch/configuration.rb +386 -0
- data/lib/hitch/doctor.rb +647 -0
- data/lib/hitch/dynamic_registration_rate_limit.rb +75 -0
- data/lib/hitch/engine.rb +154 -0
- data/lib/hitch/mcp/configuration.rb +190 -0
- data/lib/hitch/mcp/protocol.rb +36 -0
- data/lib/hitch/mcp/test_helper.rb +203 -0
- data/lib/hitch/pkce.rb +18 -0
- data/lib/hitch/rack_form_guard.rb +109 -0
- data/lib/hitch/rate_limit_store.rb +47 -0
- data/lib/hitch/resource_uri.rb +71 -0
- data/lib/hitch/version.rb +5 -0
- data/lib/hitch-rails.rb +6 -0
- data/lib/hitch.rb +51 -0
- data/lib/tasks/hitch.rake +197 -0
- 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
|
data/lib/hitch-rails.rb
ADDED
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
|