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,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