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,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# OAuth + MCP discovery metadata:
|
|
5
|
+
# GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
6
|
+
# GET /.well-known/oauth-protected-resource (RFC 9728)
|
|
7
|
+
class MetadataController < Hitch::PublicEndpointController
|
|
8
|
+
include Hitch::CorsSupport
|
|
9
|
+
|
|
10
|
+
def show
|
|
11
|
+
cache_discovery_metadata
|
|
12
|
+
|
|
13
|
+
render json: {
|
|
14
|
+
issuer: issuer_url,
|
|
15
|
+
authorization_endpoint: canonical_endpoint("/oauth/authorize"),
|
|
16
|
+
token_endpoint: canonical_endpoint("/oauth/token"),
|
|
17
|
+
revocation_endpoint: canonical_endpoint("/oauth/revoke"),
|
|
18
|
+
response_types_supported: [ "code" ],
|
|
19
|
+
grant_types_supported: [ "authorization_code" ],
|
|
20
|
+
code_challenge_methods_supported: [ "S256" ],
|
|
21
|
+
scopes_supported: Hitch.configuration.supported_scopes,
|
|
22
|
+
token_endpoint_auth_methods_supported: Hitch::Client::TOKEN_ENDPOINT_AUTH_METHODS,
|
|
23
|
+
# RFC 9207 §3. Advertising this is a promise the authorization
|
|
24
|
+
# response WILL carry `iss` — a conformant client treats an
|
|
25
|
+
# advertised-but-absent `iss` as a hard failure and refuses the
|
|
26
|
+
# code exchange. It is only ever true because
|
|
27
|
+
# AuthorizationsController#build_redirect_uri appends it
|
|
28
|
+
# unconditionally; the two must never be separated.
|
|
29
|
+
authorization_response_iss_parameter_supported: issuer_is_https?
|
|
30
|
+
}.merge(dynamic_client_registration_advertisement).merge(client_id_metadata_advertisement)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def resource
|
|
34
|
+
cache_discovery_metadata
|
|
35
|
+
|
|
36
|
+
# RFC 9728 + 2026-07-28 MCP spec: PRM SHOULD include
|
|
37
|
+
# scopes_supported so resource servers can echo per-tool
|
|
38
|
+
# required scopes back in 403 challenges.
|
|
39
|
+
render json: {
|
|
40
|
+
resource: Hitch.configuration.resource_uri.presence || issuer_url,
|
|
41
|
+
authorization_servers: [ issuer_url ],
|
|
42
|
+
bearer_methods_supported: [ "header" ],
|
|
43
|
+
scopes_supported: Hitch.configuration.supported_scopes
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def dynamic_client_registration_advertisement
|
|
50
|
+
return {} unless Hitch.configuration.dynamic_client_registration_enabled
|
|
51
|
+
|
|
52
|
+
{ registration_endpoint: canonical_endpoint("/oauth/register") }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# RFC 9207 §2: the `iss` value "MUST be a URL that uses the 'https'
|
|
56
|
+
# scheme". Over plain http the value this server emits is therefore
|
|
57
|
+
# not a valid issuer identifier, and withholding the advertisement
|
|
58
|
+
# does not make it one.
|
|
59
|
+
#
|
|
60
|
+
# Be clear about what this is: over http the server is NOT conformant
|
|
61
|
+
# here, and that is deliberate development compatibility, not a
|
|
62
|
+
# design. It is also not the first deviation on that path — MCP
|
|
63
|
+
# 2026-07-28 requires every authorization server endpoint be served
|
|
64
|
+
# over HTTPS, so an http deployment is already outside the spec and
|
|
65
|
+
# the issuer scheme is the smaller of its problems.
|
|
66
|
+
#
|
|
67
|
+
# `iss` is still emitted over http rather than suppressed, so that a
|
|
68
|
+
# local development flow exercises the same path as production. The
|
|
69
|
+
# alternative — omit it below https — means the parameter silently
|
|
70
|
+
# appears for the first time on deploy, in a security control that is
|
|
71
|
+
# unpleasant to debug remotely. A client that already accepted the
|
|
72
|
+
# non-conformant http issuer from this same discovery document can
|
|
73
|
+
# compare the two and pass. A stricter one may well reject the http
|
|
74
|
+
# issuer during discovery and never reach the comparison — which is
|
|
75
|
+
# the correct behaviour, and not something emitting `iss` either
|
|
76
|
+
# causes or cures.
|
|
77
|
+
#
|
|
78
|
+
# Withholding the advertisement is what keeps this safe rather than
|
|
79
|
+
# conformant. Per the spec's validation table, an advertised-but-
|
|
80
|
+
# unusable value is what makes a conformant client hard-fail; a
|
|
81
|
+
# present-but-unadvertised one it simply compares.
|
|
82
|
+
def issuer_is_https?
|
|
83
|
+
issuer_url.to_s.start_with?("https://")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def canonical_endpoint(path)
|
|
87
|
+
"#{issuer_url}#{path}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Advertised only when the host has actually enabled CIMD. The flag
|
|
91
|
+
# is what makes a conformant client stop falling back to Dynamic
|
|
92
|
+
# Client Registration and send a document URL as its client_id (the
|
|
93
|
+
# Ruby MCP SDK branches on exactly this), so advertising it while the
|
|
94
|
+
# server would reject every such client_id turns a working DCR flow
|
|
95
|
+
# into a broken one.
|
|
96
|
+
def client_id_metadata_advertisement
|
|
97
|
+
return {} unless Hitch.configuration.client_id_metadata_enabled
|
|
98
|
+
|
|
99
|
+
{ client_id_metadata_document_supported: true }
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Issuer URLs come from the configured canonical resource origin, not the
|
|
103
|
+
# request. Keep discovery private and Vary on Host as defense in depth so
|
|
104
|
+
# an ingress alias cannot cause one virtual host's response to be reused by
|
|
105
|
+
# another application sharing a cache.
|
|
106
|
+
def cache_discovery_metadata
|
|
107
|
+
expires_in 1.hour # private — not shared-cacheable
|
|
108
|
+
|
|
109
|
+
existing = response.headers["Vary"]
|
|
110
|
+
response.headers["Vary"] =
|
|
111
|
+
existing.present? ? "#{existing}, Host" : "Host"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# OPTIONS for the engine-owned auth and discovery routes. A request earns a
|
|
5
|
+
# 204 only after its Host, Origin, target method, and requested headers pass.
|
|
6
|
+
class PreflightsController < Hitch::PublicEndpointController
|
|
7
|
+
include Hitch::CorsSupport
|
|
8
|
+
|
|
9
|
+
def show
|
|
10
|
+
methods = request.path_parameters.fetch(:target_methods).split(",")
|
|
11
|
+
hitch_preflight(allowed_methods: methods)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# Base for the gem's PUBLIC OAuth endpoints (token, register, revoke,
|
|
5
|
+
# metadata, /.well-known/*). These serve MCP clients (Claude.ai,
|
|
6
|
+
# Claude Code, ChatGPT, Cursor, etc.) that are NOT browsers and have
|
|
7
|
+
# NO host Rails session — the OAuth dance brings them to a session,
|
|
8
|
+
# it doesn't start with one.
|
|
9
|
+
#
|
|
10
|
+
# Inherits from ActionController::Base directly (not the host's
|
|
11
|
+
# ApplicationController) so the host's auth concern, browser-version
|
|
12
|
+
# guard, layout, helpers, and other before-actions don't apply.
|
|
13
|
+
# Session cookies / Rack-level middleware still flow because those
|
|
14
|
+
# live outside the controller layer.
|
|
15
|
+
#
|
|
16
|
+
# The consent screen (AuthorizationsController) is deliberately NOT
|
|
17
|
+
# in this category — it must integrate with the host's auth concern
|
|
18
|
+
# to identify the user granting consent.
|
|
19
|
+
class PublicEndpointController < ::ActionController::Base
|
|
20
|
+
include Hitch::HostValidation
|
|
21
|
+
include Hitch::IssuerUrl
|
|
22
|
+
include Hitch::OauthParameterValidation
|
|
23
|
+
|
|
24
|
+
# These endpoints serve non-browser MCP clients (CLI / desktop /
|
|
25
|
+
# server-to-server) that carry no Rails session and no CSRF token —
|
|
26
|
+
# PKCE verifier or bearer-token possession is the credential, not a
|
|
27
|
+
# session cookie. A host with CSRF protection enabled (the Rails
|
|
28
|
+
# default via load_defaults) would otherwise reject every
|
|
29
|
+
# POST /oauth/token, /oauth/register and /oauth/revoke with 422,
|
|
30
|
+
# breaking the OAuth flow entirely. Skipping is safe: there is no
|
|
31
|
+
# cookie-authenticated state here for a forged cross-site request to
|
|
32
|
+
# act on. The consent screen (AuthorizationsController) is the only
|
|
33
|
+
# session-backed POST and KEEPS forgery protection.
|
|
34
|
+
skip_forgery_protection if respond_to?(:skip_forgery_protection)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# POST /oauth/register — Dynamic Client Registration (RFC 7591).
|
|
5
|
+
# MCP clients register before starting the OAuth flow. The
|
|
6
|
+
# client_name they send ("Claude Code", "ChatGPT", etc.) is
|
|
7
|
+
# attacker-controllable — we persist it for audit fidelity but
|
|
8
|
+
# consent UIs MUST NOT trust it for display (see authorize#new).
|
|
9
|
+
class RegistrationsController < Hitch::PublicEndpointController
|
|
10
|
+
include Hitch::CorsSupport
|
|
11
|
+
include Hitch::RegistrationAdmission
|
|
12
|
+
include Hitch::UriValidation
|
|
13
|
+
|
|
14
|
+
def create
|
|
15
|
+
# RegistrationAdmission has already parsed one bounded JSON object
|
|
16
|
+
# and installed it here — the action never runs otherwise.
|
|
17
|
+
metadata = request.request_parameters
|
|
18
|
+
|
|
19
|
+
# RFC 7591 §2: the authorization server is responsible for
|
|
20
|
+
# enforcing its URI policy at registration. Without this, a
|
|
21
|
+
# client could register `javascript:alert(1)` or
|
|
22
|
+
# `http://attacker.test/cb` and try to use it at authorize.
|
|
23
|
+
normalized = normalized_registration_metadata(metadata)
|
|
24
|
+
return if performed?
|
|
25
|
+
|
|
26
|
+
candidate_uris = normalized.fetch(:redirect_uris)
|
|
27
|
+
invalid = candidate_uris.reject { |uri| valid_redirect_uri?(uri) }
|
|
28
|
+
if invalid.any?
|
|
29
|
+
return oauth_error(
|
|
30
|
+
"invalid_redirect_uri",
|
|
31
|
+
"redirect_uris must contain only https URIs or RFC 8252 loopback http URIs"
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
auth_method = optional_string_metadata(metadata, "token_endpoint_auth_method") || "none"
|
|
36
|
+
return if performed?
|
|
37
|
+
|
|
38
|
+
unless Hitch::Client::TOKEN_ENDPOINT_AUTH_METHODS.include?(auth_method)
|
|
39
|
+
return oauth_error(
|
|
40
|
+
"invalid_client_metadata",
|
|
41
|
+
"token_endpoint_auth_method must be none or client_secret_basic"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
application_type = optional_string_metadata(metadata, "application_type")
|
|
46
|
+
return if performed?
|
|
47
|
+
|
|
48
|
+
client, client_secret = register_client(
|
|
49
|
+
auth_method,
|
|
50
|
+
client_name: metadata["client_name"],
|
|
51
|
+
redirect_uris: candidate_uris,
|
|
52
|
+
application_type: application_type
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
response_body = {
|
|
56
|
+
client_id: client.client_id,
|
|
57
|
+
client_id_issued_at: client.created_at.to_i,
|
|
58
|
+
client_name: client.client_name,
|
|
59
|
+
redirect_uris: client.redirect_uris,
|
|
60
|
+
grant_types: [ "authorization_code" ],
|
|
61
|
+
response_types: [ "code" ],
|
|
62
|
+
scope: Hitch.configuration.supported_scopes.join(" "),
|
|
63
|
+
token_endpoint_auth_method: client.token_endpoint_auth_method
|
|
64
|
+
}.merge(
|
|
65
|
+
# Echo what was actually STORED, not what was sent — RFC 7591
|
|
66
|
+
# §3.2.1 makes the response the authoritative record of registered
|
|
67
|
+
# metadata, so a client that sent an unrecognized value can tell
|
|
68
|
+
# it was dropped by diffing its request against this response.
|
|
69
|
+
# (Not that it reads as an explicit rejection: §2 defaults an
|
|
70
|
+
# absent application_type to "web", so silence means the default,
|
|
71
|
+
# not "you declared nothing".)
|
|
72
|
+
#
|
|
73
|
+
# Omitted rather than sent as null when undeclared. Scoped to this
|
|
74
|
+
# one key deliberately: a blanket `.compact` would silently drop
|
|
75
|
+
# any future nullable field, and §3.2.1 makes some of them —
|
|
76
|
+
# `client_secret_expires_at` when a secret is issued — REQUIRED.
|
|
77
|
+
client.application_type ? { application_type: client.application_type } : {}
|
|
78
|
+
)
|
|
79
|
+
if client_secret
|
|
80
|
+
response_body.merge!(
|
|
81
|
+
client_secret: client_secret,
|
|
82
|
+
client_secret_issued_at: client.client_secret_issued_at.to_i,
|
|
83
|
+
client_secret_expires_at: 0
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
render json: response_body, status: :created
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def normalized_registration_metadata(metadata)
|
|
93
|
+
Hitch::Client.normalize_registration_metadata!(
|
|
94
|
+
client_name: metadata["client_name"],
|
|
95
|
+
redirect_uris: metadata["redirect_uris"]
|
|
96
|
+
)
|
|
97
|
+
rescue Hitch::Client::InvalidRegistrationMetadata
|
|
98
|
+
oauth_error(
|
|
99
|
+
"invalid_client_metadata",
|
|
100
|
+
"client_name and redirect_uris must satisfy the documented size and shape limits"
|
|
101
|
+
)
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def optional_string_metadata(metadata, key)
|
|
106
|
+
return unless metadata.key?(key)
|
|
107
|
+
return metadata[key] if metadata[key].is_a?(String)
|
|
108
|
+
|
|
109
|
+
oauth_error("invalid_client_metadata", "#{key} must be a string")
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Returns [client, client_secret]; the secret is nil for public clients.
|
|
114
|
+
# Client.register! returns the record and register_confidential! returns
|
|
115
|
+
# one-time Credentials (its documented contract), so this is where the
|
|
116
|
+
# two shapes become one.
|
|
117
|
+
def register_client(auth_method, client_name:, redirect_uris:, application_type:)
|
|
118
|
+
attributes = {
|
|
119
|
+
client_id: SecureRandom.uuid,
|
|
120
|
+
client_name: client_name,
|
|
121
|
+
redirect_uris: redirect_uris,
|
|
122
|
+
# Recorded, never enforced: gating loopback redirects on a
|
|
123
|
+
# declaration would break clients that omit this metadata.
|
|
124
|
+
application_type: application_type
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if auth_method == "client_secret_basic"
|
|
128
|
+
credentials = Hitch::Client.register_confidential!(**attributes)
|
|
129
|
+
[ credentials.client, credentials.client_secret ]
|
|
130
|
+
else
|
|
131
|
+
[ Hitch::Client.register!(**attributes), nil ]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# POST /oauth/revoke — revoke an access token (RFC 7009).
|
|
5
|
+
# Per the RFC, returns 200 regardless of whether the token exists
|
|
6
|
+
# so callers can't probe for valid tokens.
|
|
7
|
+
class RevocationsController < Hitch::PublicEndpointController
|
|
8
|
+
include Hitch::CorsSupport
|
|
9
|
+
include Hitch::OauthFormAdmission
|
|
10
|
+
|
|
11
|
+
def create
|
|
12
|
+
return head :ok unless request.media_type == Hitch::OauthRequestParameters::FORM_MEDIA_TYPE
|
|
13
|
+
|
|
14
|
+
token_value = oauth_parameters(:token, form_only: true)[:token]
|
|
15
|
+
if token_value.present?
|
|
16
|
+
access_token = Hitch::AccessToken.find_by_token(token_value)
|
|
17
|
+
access_token&.revoke!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
head :ok
|
|
21
|
+
rescue Hitch::OauthRequestParameters::Invalid
|
|
22
|
+
head :ok
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def reject_oversized_oauth_form_body!
|
|
28
|
+
head :ok
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# POST /oauth/token — exchange auth code for access token.
|
|
5
|
+
#
|
|
6
|
+
# Public endpoint (no session auth — clients calling from CLI /
|
|
7
|
+
# browser / desktop reach this without a Rails session). PKCE
|
|
8
|
+
# verifier is the credential.
|
|
9
|
+
class TokensController < Hitch::PublicEndpointController
|
|
10
|
+
include Hitch::CorsSupport
|
|
11
|
+
include Hitch::OauthFormAdmission
|
|
12
|
+
include Hitch::UriValidation
|
|
13
|
+
|
|
14
|
+
TOKEN_PARAMETER_NAMES = %i[
|
|
15
|
+
grant_type
|
|
16
|
+
code
|
|
17
|
+
client_id
|
|
18
|
+
client_secret
|
|
19
|
+
code_verifier
|
|
20
|
+
resource
|
|
21
|
+
redirect_uri
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
unless request.media_type == Hitch::OauthRequestParameters::FORM_MEDIA_TYPE
|
|
26
|
+
return oauth_error("invalid_request", "token requests must use application/x-www-form-urlencoded")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
oauth = oauth_parameters(*TOKEN_PARAMETER_NAMES, form_only: true)
|
|
30
|
+
return oauth_error("invalid_request", "grant_type must be authorization_code") unless oauth[:grant_type] == "authorization_code"
|
|
31
|
+
return oauth_error("invalid_request", "code is required") if oauth[:code].blank?
|
|
32
|
+
return oauth_error("invalid_request", "code_verifier is required") if oauth[:code_verifier].blank?
|
|
33
|
+
unless Hitch::Pkce.valid_verifier?(oauth[:code_verifier])
|
|
34
|
+
return oauth_error("invalid_grant", "Invalid or expired authorization code")
|
|
35
|
+
end
|
|
36
|
+
if oauth[:redirect_uri].present? && !valid_redirect_uri?(oauth[:redirect_uri])
|
|
37
|
+
return oauth_error("invalid_request", "redirect_uri is malformed")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
resource = require_canonical_resource(oauth[:resource])
|
|
41
|
+
return unless resource
|
|
42
|
+
|
|
43
|
+
client_id = Hitch::ClientAuthentication.resolve(
|
|
44
|
+
request: request,
|
|
45
|
+
body_client_id: oauth[:client_id],
|
|
46
|
+
body_secret_present: oauth[:client_secret].present?
|
|
47
|
+
)
|
|
48
|
+
result = Hitch::AccessToken.exchange_authorization_code!(
|
|
49
|
+
raw_code: oauth[:code],
|
|
50
|
+
code_verifier: oauth[:code_verifier],
|
|
51
|
+
client_id: client_id,
|
|
52
|
+
resource_uri: resource,
|
|
53
|
+
redirect_uri: oauth[:redirect_uri]
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return oauth_error("invalid_grant", "Invalid or expired authorization code") if result.nil?
|
|
57
|
+
|
|
58
|
+
render json: {
|
|
59
|
+
access_token: result[:raw_token],
|
|
60
|
+
token_type: "Bearer",
|
|
61
|
+
expires_in: Hitch.configuration.access_token_lifetime_seconds,
|
|
62
|
+
scope: result[:scope]
|
|
63
|
+
}
|
|
64
|
+
rescue Hitch::ClientAuthentication::Invalid => error
|
|
65
|
+
response.headers["WWW-Authenticate"] = 'Basic realm="oauth/token"' if error.http_status == :unauthorized
|
|
66
|
+
oauth_error(error.oauth_code, error.message, error.http_status)
|
|
67
|
+
rescue Hitch::AccessToken::OAuthError => e
|
|
68
|
+
oauth_error(e.oauth_code, e.description)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# RFC 6749 section 5.1 requires token responses to be non-cacheable.
|
|
74
|
+
# This hook runs in OauthFormAdmission before Rails instrumentation, so
|
|
75
|
+
# successful exchanges and early admission failures get the same policy.
|
|
76
|
+
def prepare_oauth_form_response!
|
|
77
|
+
response.headers["Cache-Control"] = "no-store"
|
|
78
|
+
response.headers["Pragma"] = "no-cache"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def reject_oversized_oauth_form_body!
|
|
82
|
+
oauth_error(
|
|
83
|
+
"invalid_request",
|
|
84
|
+
"token request body exceeds #{MAX_REQUEST_BODY_BYTES} bytes",
|
|
85
|
+
:content_too_large
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hitch
|
|
4
|
+
# OAuth 2.1 access token + authorization code record. Lifecycle:
|
|
5
|
+
#
|
|
6
|
+
# pending — code minted, awaiting POST /oauth/token exchange
|
|
7
|
+
# ↓ exchange_authorization_code! — all bindings verified, then atomically consumed
|
|
8
|
+
# active — token_digest set; usable until expires_at or revoked_at
|
|
9
|
+
# ↓ revoke! / expiry
|
|
10
|
+
# inactive
|
|
11
|
+
#
|
|
12
|
+
# Polymorphic principal: the host controller supplies the signed-in
|
|
13
|
+
# record, and each row records which model type owns the token via
|
|
14
|
+
# principal_type + principal_id (standard Rails polymorphic).
|
|
15
|
+
#
|
|
16
|
+
# RFC 8707: resource_uri is the audience this token was issued for.
|
|
17
|
+
# The MCP server validates at token use time that the request's
|
|
18
|
+
# resource matches token.resource_uri.
|
|
19
|
+
class AccessToken < ApplicationRecord
|
|
20
|
+
self.table_name = "hitch_access_tokens"
|
|
21
|
+
|
|
22
|
+
# Ten years. Past it a Postgres timestamp overflows, and a SQLite
|
|
23
|
+
# five-digit year sorts before today's — which would hand an operator a
|
|
24
|
+
# token that silently never resolves.
|
|
25
|
+
MAX_LIFETIME_SECONDS = 3650 * 86_400
|
|
26
|
+
|
|
27
|
+
class OAuthError < StandardError
|
|
28
|
+
attr_reader :oauth_code, :description
|
|
29
|
+
|
|
30
|
+
def initialize(oauth_code, description)
|
|
31
|
+
@oauth_code = oauth_code
|
|
32
|
+
@description = description
|
|
33
|
+
super(description)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
belongs_to :principal, polymorphic: true
|
|
38
|
+
|
|
39
|
+
# Raw authorization code is returned to the client via the OAuth
|
|
40
|
+
# redirect once at issuance; the DB only ever holds the SHA256
|
|
41
|
+
# digest. This attr_accessor lets create_authorization! surface the
|
|
42
|
+
# raw code to the controller without persisting it.
|
|
43
|
+
attr_accessor :raw_authorization_code
|
|
44
|
+
|
|
45
|
+
scope :pending, -> { where(token_digest: nil).where("code_expires_at > ?", Time.current) }
|
|
46
|
+
scope :active, -> { where.not(token_digest: nil).where(revoked_at: nil).where("expires_at > ?", Time.current) }
|
|
47
|
+
|
|
48
|
+
validates :code_challenge, presence: true
|
|
49
|
+
validates :code_challenge, format: { with: Hitch::Pkce::S256_CHALLENGE }
|
|
50
|
+
validates :code_challenge_method, inclusion: { in: %w[S256] }
|
|
51
|
+
|
|
52
|
+
def expired?
|
|
53
|
+
expires_at.present? && expires_at < Time.current
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def revoked?
|
|
57
|
+
revoked_at.present?
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def accessible?
|
|
61
|
+
token_digest.present? && !expired? && !revoked?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Space-delimited scope check per OAuth 2.1 §3.3. Hosts call this to
|
|
65
|
+
# gate operations behind a specific scope the client requested at
|
|
66
|
+
# consent — e.g. `token.scope?("write")` before mutating ops.
|
|
67
|
+
def scope?(scope)
|
|
68
|
+
return false if scopes.blank? || scope.blank?
|
|
69
|
+
|
|
70
|
+
scopes.split(/\s+/).include?(scope.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.create_authorization!(principal:, client_id:, client_name:, code_challenge:, code_challenge_method:, scopes: "mcp", redirect_uri: nil, resource_uri: nil)
|
|
74
|
+
raw_code = SecureRandom.urlsafe_base64(32)
|
|
75
|
+
record = create!(
|
|
76
|
+
principal: principal,
|
|
77
|
+
client_id: client_id,
|
|
78
|
+
client_name: client_name,
|
|
79
|
+
redirect_uri: redirect_uri,
|
|
80
|
+
resource_uri: resource_uri,
|
|
81
|
+
authorization_code_digest: Digest::SHA256.hexdigest(raw_code),
|
|
82
|
+
code_challenge: code_challenge,
|
|
83
|
+
code_challenge_method: code_challenge_method,
|
|
84
|
+
code_expires_at: Hitch.configuration.authorization_code_lifetime_seconds.seconds.from_now,
|
|
85
|
+
scopes: scopes
|
|
86
|
+
)
|
|
87
|
+
record.raw_authorization_code = raw_code
|
|
88
|
+
record
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Mints a usable access token outside the browser flow, for a headless
|
|
92
|
+
# agent or a cron job that cannot complete a consent redirect. The
|
|
93
|
+
# operator at a console with database access is both the resource owner
|
|
94
|
+
# and the client, so there is no third party for a consent screen to
|
|
95
|
+
# protect anyone from.
|
|
96
|
+
#
|
|
97
|
+
# Runs the real authorization-code exchange rather than writing a row
|
|
98
|
+
# directly: the PKCE pair is generated and spent here, so the row that
|
|
99
|
+
# lands is indistinguishable from a browser-issued one and the same code
|
|
100
|
+
# path is proven by every other test in the suite. The token is returned
|
|
101
|
+
# once and only its digest is stored, exactly as in the OAuth flow.
|
|
102
|
+
def self.issue!(principal:, client_id:, client_name: nil, scopes: nil, expires_in: nil)
|
|
103
|
+
unless client_id.is_a?(String) && !client_id.empty?
|
|
104
|
+
# The endpoint refuses a token with a blank client_id, so issuing one
|
|
105
|
+
# would hand back a credential that can never work.
|
|
106
|
+
raise ArgumentError, "client_id must be a nonempty String"
|
|
107
|
+
end
|
|
108
|
+
# uniq like the browser flow's `asked & supported`, which cannot repeat
|
|
109
|
+
# a scope past the persisted scope-set boundary.
|
|
110
|
+
granted = Array(scopes).map(&:to_s).uniq.presence || [ Hitch.configuration.supported_scopes.first ]
|
|
111
|
+
unsupported = granted - Hitch.configuration.supported_scopes
|
|
112
|
+
unless unsupported.empty?
|
|
113
|
+
raise ArgumentError,
|
|
114
|
+
"scopes are not present in Hitch.configuration.supported_scopes: #{unsupported.join(', ')}"
|
|
115
|
+
end
|
|
116
|
+
# Seconds, as a number or a Duration. A String is a caller mistake, not
|
|
117
|
+
# something to guess at: the rake task parses ENV before it gets here,
|
|
118
|
+
# and Integer("0700") would quietly mean 448.
|
|
119
|
+
numeric = expires_in.is_a?(Numeric) || expires_in.is_a?(ActiveSupport::Duration)
|
|
120
|
+
seconds = expires_in.to_i if numeric && expires_in.to_f.finite?
|
|
121
|
+
unless expires_in.nil? || (seconds&.positive? && seconds <= MAX_LIFETIME_SECONDS)
|
|
122
|
+
raise ArgumentError,
|
|
123
|
+
"expires_in must be a positive number of seconds, at most #{MAX_LIFETIME_SECONDS}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
resource_uri = Hitch.configuration.resource_uri
|
|
127
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
128
|
+
# requires_new, not a plain transaction: a bare `transaction` JOINS a
|
|
129
|
+
# caller's open one, so a host calling issue! inside its own
|
|
130
|
+
# transaction and rescuing kept the very row this exists to roll back.
|
|
131
|
+
transaction(requires_new: true) do
|
|
132
|
+
record = create_authorization!(
|
|
133
|
+
principal: principal,
|
|
134
|
+
client_id: client_id,
|
|
135
|
+
client_name: client_name.presence || client_id,
|
|
136
|
+
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
|
|
137
|
+
code_challenge_method: "S256",
|
|
138
|
+
resource_uri: resource_uri,
|
|
139
|
+
scopes: granted.join(" ")
|
|
140
|
+
)
|
|
141
|
+
result = exchange_authorization_code!(
|
|
142
|
+
raw_code: record.raw_authorization_code,
|
|
143
|
+
code_verifier: verifier,
|
|
144
|
+
client_id: client_id,
|
|
145
|
+
resource_uri: resource_uri
|
|
146
|
+
)
|
|
147
|
+
raise "Hitch could not issue an access token" if result.nil?
|
|
148
|
+
|
|
149
|
+
# The exchange dates every token by the configured lifetime, which is
|
|
150
|
+
# sized for a browser session. A headless agent asks for its own.
|
|
151
|
+
record.reload.update!(expires_at: seconds.seconds.from_now) if seconds
|
|
152
|
+
|
|
153
|
+
result.fetch(:raw_token)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def self.exchange_authorization_code!(raw_code:, code_verifier:, client_id:, resource_uri:, redirect_uri: nil)
|
|
158
|
+
unless Hitch::Pkce.valid_verifier?(code_verifier)
|
|
159
|
+
raise OAuthError.new("invalid_grant", "PKCE verifier is malformed")
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
code_digest = Digest::SHA256.hexdigest(raw_code.to_s)
|
|
163
|
+
record = pending.find_by(authorization_code_digest: code_digest)
|
|
164
|
+
return nil unless record
|
|
165
|
+
|
|
166
|
+
unless record.client_id == client_id
|
|
167
|
+
raise OAuthError.new("invalid_grant", "Authorization code was not issued to this client")
|
|
168
|
+
end
|
|
169
|
+
# RFC 6749 §4.1.3: a redirect_uri sent to the token endpoint MUST be
|
|
170
|
+
# identical to the one the code was issued to. Omitting it is legal
|
|
171
|
+
# (OAuth 2.1 drops the parameter; PKCE carries the binding).
|
|
172
|
+
if redirect_uri.present? && record.redirect_uri != redirect_uri
|
|
173
|
+
raise OAuthError.new("invalid_grant", "redirect_uri does not match the authorization request")
|
|
174
|
+
end
|
|
175
|
+
unless record.resource_uri == resource_uri
|
|
176
|
+
raise OAuthError.new("invalid_target", "resource does not match the authorized resource")
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
record.send(:verify_pkce!, code_verifier)
|
|
180
|
+
raw_token = SecureRandom.urlsafe_base64(32)
|
|
181
|
+
now = Time.current
|
|
182
|
+
updated = where(
|
|
183
|
+
id: record.id,
|
|
184
|
+
authorization_code_digest: code_digest,
|
|
185
|
+
token_digest: nil
|
|
186
|
+
).where("code_expires_at > ?", now).update_all(
|
|
187
|
+
token_digest: Digest::SHA256.hexdigest(raw_token),
|
|
188
|
+
authorization_code_digest: nil,
|
|
189
|
+
code_expires_at: nil,
|
|
190
|
+
expires_at: now + Hitch.configuration.access_token_lifetime_seconds.seconds,
|
|
191
|
+
updated_at: now
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return nil unless updated == 1
|
|
195
|
+
|
|
196
|
+
{ raw_token: raw_token, scope: record.scopes }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def revoke!
|
|
200
|
+
update!(revoked_at: Time.current)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def self.find_by_token(raw_token)
|
|
204
|
+
return nil if raw_token.blank?
|
|
205
|
+
|
|
206
|
+
active.find_by(token_digest: Digest::SHA256.hexdigest(raw_token))
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Operational cleanup. Three classes of rows accumulate that nothing
|
|
210
|
+
# ever reads again:
|
|
211
|
+
#
|
|
212
|
+
# 1) Pending auth codes whose code_expires_at < now — orphaned by
|
|
213
|
+
# OAuth flows the client abandoned (closed the browser, etc.).
|
|
214
|
+
# No token was issued; the row is unreachable.
|
|
215
|
+
# 2) Revoked tokens older than `revoked_retention_days`. The
|
|
216
|
+
# record is kept for a window so audit logs/billing/etc. can
|
|
217
|
+
# look up the principal_id; beyond that, drop.
|
|
218
|
+
# 3) Expired tokens (expires_at < now) older than
|
|
219
|
+
# `revoked_retention_days` — same audit-window argument.
|
|
220
|
+
#
|
|
221
|
+
# Returns the number of rows deleted. Idempotent.
|
|
222
|
+
#
|
|
223
|
+
# Hosts schedule this via whatever background job framework they
|
|
224
|
+
# use (Solid Queue / GoodJob / Sidekiq / cron+rake — gem-agnostic).
|
|
225
|
+
# Example:
|
|
226
|
+
#
|
|
227
|
+
# class CleanupMCPTokensJob < ApplicationJob
|
|
228
|
+
# def perform
|
|
229
|
+
# Hitch::AccessToken.cleanup_expired!
|
|
230
|
+
# end
|
|
231
|
+
# end
|
|
232
|
+
def self.cleanup_expired!(revoked_retention_days: 30)
|
|
233
|
+
cutoff = revoked_retention_days.days.ago
|
|
234
|
+
count = 0
|
|
235
|
+
count += where(token_digest: nil).where("code_expires_at < ?", Time.current).delete_all
|
|
236
|
+
count += where.not(revoked_at: nil).where("revoked_at < ?", cutoff).delete_all
|
|
237
|
+
count += where.not(expires_at: nil).where("expires_at < ?", cutoff).delete_all
|
|
238
|
+
count
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# RFC 8707 audience validation. Returns false if the token was
|
|
242
|
+
# issued for a different resource than the one currently asking.
|
|
243
|
+
# Per the 2026-07-28 MCP authorization spec: "MCP servers MUST
|
|
244
|
+
# validate that access tokens were issued specifically for them
|
|
245
|
+
# as the intended audience."
|
|
246
|
+
# Spec URL: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
|
|
247
|
+
def valid_for_resource?(requested_resource_uri)
|
|
248
|
+
allow_loopback = Rails.env.local?
|
|
249
|
+
stored = ResourceUri.canonicalize!(resource_uri, allow_loopback_http: allow_loopback)
|
|
250
|
+
requested = ResourceUri.canonicalize!(requested_resource_uri, allow_loopback_http: allow_loopback)
|
|
251
|
+
stored == requested
|
|
252
|
+
rescue ResourceUri::Invalid
|
|
253
|
+
false
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
private
|
|
257
|
+
|
|
258
|
+
def verify_pkce!(code_verifier)
|
|
259
|
+
raise OAuthError.new("invalid_grant", "Authorization code expired") if code_expires_at.nil? || code_expires_at < Time.current
|
|
260
|
+
|
|
261
|
+
expected = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false)
|
|
262
|
+
return if ActiveSupport::SecurityUtils.secure_compare(expected, code_challenge)
|
|
263
|
+
|
|
264
|
+
raise OAuthError.new("invalid_grant", "PKCE verification failed")
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|