mcp-auth 0.5.0 → 0.6.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 +4 -4
- data/CHANGELOG.md +217 -2
- data/README.md +16 -2
- data/app/controllers/mcp/auth/oauth_controller.rb +264 -56
- data/app/controllers/mcp/auth/well_known_controller.rb +23 -8
- data/app/models/mcp/auth/access_token.rb +5 -0
- data/app/models/mcp/auth/authorization_code.rb +4 -0
- data/app/models/mcp/auth/oauth_client.rb +154 -4
- data/app/models/mcp/auth/refresh_token.rb +12 -1
- data/app/views/mcp/auth/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/hash_secrets_generator.rb +48 -0
- data/lib/generators/mcp/auth/install_generator.rb +13 -1
- data/lib/generators/mcp/auth/templates/add_mcp_auth_confidential_client_and_reuse.rb.erb +39 -0
- data/lib/generators/mcp/auth/templates/hash_mcp_auth_secrets_at_rest.rb.erb +72 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +67 -4
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +13 -0
- data/lib/generators/mcp/auth/upgrade_generator.rb +60 -0
- data/lib/mcp/auth/engine.rb +35 -1
- data/lib/mcp/auth/protected_resource.rb +29 -6
- data/lib/mcp/auth/schema_guard.rb +67 -0
- data/lib/mcp/auth/scope_registry.rb +12 -0
- data/lib/mcp/auth/secret_hashing.rb +83 -0
- data/lib/mcp/auth/services/authorization_service.rb +18 -8
- data/lib/mcp/auth/services/token_service.rb +144 -23
- data/lib/mcp/auth/version.rb +1 -1
- data/lib/mcp/auth.rb +51 -4
- data/lib/tasks/mcp_auth_tasks.rake +40 -6
- metadata +18 -1
data/lib/mcp/auth/engine.rb
CHANGED
|
@@ -21,12 +21,46 @@ module Mcp
|
|
|
21
21
|
|
|
22
22
|
initializer "mcp_auth.configure" do
|
|
23
23
|
config.mcp_auth = ActiveSupport::OrderedOptions.new
|
|
24
|
-
config.mcp_auth.oauth_secret = ENV.fetch('MCP_OAUTH_PRIVATE_KEY', nil)
|
|
25
24
|
config.mcp_auth.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
|
|
26
25
|
config.mcp_auth.access_token_lifetime = 3600 # 1 hour
|
|
27
26
|
config.mcp_auth.refresh_token_lifetime = 2_592_000 # 30 days
|
|
28
27
|
config.mcp_auth.authorization_code_lifetime = 1800 # 30 minutes
|
|
29
28
|
end
|
|
29
|
+
|
|
30
|
+
# Keep OAuth credentials out of the host app's logs. Rails' default filters
|
|
31
|
+
# (:token, :secret, ...) miss the authorization `code` and the PKCE
|
|
32
|
+
# `code_verifier`, which together are enough to redeem a code; the
|
|
33
|
+
# redirect back to the client (`Redirected to ...?code=...`) is filtered too.
|
|
34
|
+
# Anchored regexes, not symbols: Rails matches symbols as SUBSTRINGS across
|
|
35
|
+
# the whole app (and ActiveRecord filter_attributes), so `:code` would also
|
|
36
|
+
# mask `postal_code`, `promo_code`, ...
|
|
37
|
+
OAUTH_FILTER_PARAMETERS = [
|
|
38
|
+
/\Acode\z/, /\Acode_verifier\z/, /\Aclient_secret\z/, /\Arefresh_token\z/,
|
|
39
|
+
/\Aaccess_token\z/, /\Aid_token\z/, /\Atoken\z/
|
|
40
|
+
].freeze
|
|
41
|
+
|
|
42
|
+
initializer 'mcp_auth.filter_parameters' do |app|
|
|
43
|
+
app.config.filter_parameters |= OAUTH_FILTER_PARAMETERS
|
|
44
|
+
app.config.filter_redirect << /[?&]code=/
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Surface a pending mcp-auth migration at boot so an operator who upgraded
|
|
48
|
+
# the gem without running migrations gets a clear, actionable warning
|
|
49
|
+
# instead of a cryptic runtime error on the first OAuth request. Never
|
|
50
|
+
# raises (missing_columns is fully guarded), so it can't break boot/CI.
|
|
51
|
+
config.after_initialize do
|
|
52
|
+
missing = Mcp::Auth::SchemaGuard.missing_columns
|
|
53
|
+
Rails.logger.warn("[mcp-auth] #{Mcp::Auth::SchemaGuard.guidance(missing)}") if missing.any?
|
|
54
|
+
|
|
55
|
+
# Same idea for the signing secret: warn at boot rather than only failing
|
|
56
|
+
# on the first token request (outside dev/test, where it's a hard error).
|
|
57
|
+
unless Rails.env.development? || Rails.env.test?
|
|
58
|
+
problem = Mcp::Auth::Services::TokenService.oauth_secret_problem
|
|
59
|
+
Rails.logger.warn("[mcp-auth] #{problem}") if problem
|
|
60
|
+
end
|
|
61
|
+
rescue StandardError => e
|
|
62
|
+
Rails.logger.debug { "[mcp-auth] boot checks skipped: #{e.class}" }
|
|
63
|
+
end
|
|
30
64
|
end
|
|
31
65
|
end
|
|
32
66
|
end
|
|
@@ -20,6 +20,21 @@ module Mcp
|
|
|
20
20
|
extend ActiveSupport::Concern
|
|
21
21
|
include Mcp::Auth::ControllerHelpers
|
|
22
22
|
|
|
23
|
+
# Build the RFC 9728 §5.1 / MCP-spec `WWW-Authenticate` header value for a
|
|
24
|
+
# 401 from a protected MCP resource. Exposed as a plain function (no request
|
|
25
|
+
# object needed) so it can be used from a Rack middleware guarding /mcp, not
|
|
26
|
+
# only from this controller concern — a 401 without this header leaves
|
|
27
|
+
# spec-compliant MCP clients (e.g. MCP Inspector) unable to discover the
|
|
28
|
+
# protected-resource metadata and refresh/re-authorize.
|
|
29
|
+
#
|
|
30
|
+
# headers['WWW-Authenticate'] =
|
|
31
|
+
# Mcp::Auth::ProtectedResource.www_authenticate(request.base_url)
|
|
32
|
+
def self.www_authenticate(base_url, error: 'invalid_token',
|
|
33
|
+
description: 'The access token is missing, invalid, or expired')
|
|
34
|
+
metadata_url = "#{base_url}/.well-known/oauth-protected-resource"
|
|
35
|
+
%(Bearer error="#{error}", error_description="#{description}", resource_metadata="#{metadata_url}")
|
|
36
|
+
end
|
|
37
|
+
|
|
23
38
|
# Validates the Bearer access token (signature, expiry, revocation status,
|
|
24
39
|
# and — when a resource is configured — the RFC 8707 audience). On success
|
|
25
40
|
# the decoded claims are stashed in request.env for ControllerHelpers and
|
|
@@ -66,21 +81,29 @@ module Mcp
|
|
|
66
81
|
header.split(' ', 2).last.presence
|
|
67
82
|
end
|
|
68
83
|
|
|
69
|
-
# Canonical resource identifier for this server (
|
|
70
|
-
# matching the audience minted into access tokens.
|
|
84
|
+
# Canonical resource identifier for this server (MCP server origin +
|
|
85
|
+
# mcp_server_path), matching the audience minted into access tokens.
|
|
71
86
|
def mcp_resource_identifier
|
|
72
87
|
path = Mcp::Auth.configuration&.mcp_server_path.presence || '/mcp'
|
|
73
88
|
path = "/#{path}" unless path.start_with?('/')
|
|
74
|
-
"#{
|
|
89
|
+
"#{mcp_server_origin}#{path.chomp('/')}"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The configured mcp_server_url when set, else the request origin (the host
|
|
93
|
+
# app MUST then restrict hosts via `config.hosts`). Deliberately NOT
|
|
94
|
+
# authorization_server_url: with a separate authorization server, the MCP
|
|
95
|
+
# resource (and its metadata) still lives on this app's host.
|
|
96
|
+
def mcp_server_origin
|
|
97
|
+
Mcp::Auth.configuration&.mcp_server_url.presence || request.base_url
|
|
75
98
|
end
|
|
76
99
|
|
|
77
100
|
# RFC 9728 §5.1 / MCP authorization spec: a 401 MUST advertise the
|
|
78
101
|
# protected-resource metadata document via WWW-Authenticate so clients can
|
|
79
|
-
# bootstrap the OAuth flow.
|
|
102
|
+
# bootstrap the OAuth flow. Set mcp_server_url to pin the metadata URL so
|
|
103
|
+
# a forged Host header can't steer clients to attacker-controlled metadata.
|
|
80
104
|
def render_mcp_unauthorized(error, description, status: :unauthorized)
|
|
81
|
-
metadata_url = "#{request.base_url}/.well-known/oauth-protected-resource"
|
|
82
105
|
response.headers['WWW-Authenticate'] =
|
|
83
|
-
|
|
106
|
+
Mcp::Auth::ProtectedResource.www_authenticate(mcp_server_origin, error: error, description: description)
|
|
84
107
|
render json: { error: error, error_description: description }, status: status
|
|
85
108
|
end
|
|
86
109
|
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mcp
|
|
4
|
+
module Auth
|
|
5
|
+
# Raised when the loaded gem needs database columns the app hasn't migrated
|
|
6
|
+
# yet. Carries an actionable message naming the exact command to run.
|
|
7
|
+
class PendingMigrationError < Mcp::Auth::Error; end
|
|
8
|
+
|
|
9
|
+
# Detects the common upgrade hazard: a new gem version ships migrations, the
|
|
10
|
+
# app is deployed WITHOUT running them, and the code then blows up at runtime
|
|
11
|
+
# with a cryptic `unknown attribute` / `UndefinedColumn`. This turns that into
|
|
12
|
+
# an explicit, self-explaining condition — a loud boot warning and a clear
|
|
13
|
+
# error at the OAuth endpoints — so the operator knows to run `rails db:migrate`.
|
|
14
|
+
module SchemaGuard
|
|
15
|
+
# Columns each release added beyond the original schema, keyed by model.
|
|
16
|
+
# Extend this whenever a future migration adds a column the code relies on.
|
|
17
|
+
REQUIRED_COLUMNS = {
|
|
18
|
+
'Mcp::Auth::OauthClient' => %w[token_endpoint_auth_method],
|
|
19
|
+
'Mcp::Auth::RefreshToken' => %w[family_id revoked_at]
|
|
20
|
+
}.freeze
|
|
21
|
+
|
|
22
|
+
module_function
|
|
23
|
+
|
|
24
|
+
# "table.column" entries the code needs but the database is missing. Empty
|
|
25
|
+
# when the schema is current. NEVER raises and returns [] when the database
|
|
26
|
+
# isn't available or a table doesn't exist yet (fresh install, `db:create`,
|
|
27
|
+
# asset precompile, CI without a database) so it can't break boot or tasks.
|
|
28
|
+
def missing_columns
|
|
29
|
+
REQUIRED_COLUMNS.flat_map do |model_name, columns|
|
|
30
|
+
model = model_name.constantize
|
|
31
|
+
next [] unless model.table_exists?
|
|
32
|
+
|
|
33
|
+
(columns - model.column_names).map { |column| "#{model.table_name}.#{column}" }
|
|
34
|
+
end
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
# ActiveRecord::NoDatabaseError, ConnectionNotEstablished, StatementInvalid,
|
|
37
|
+
# etc. — we simply can't check here, so treat as "nothing to report".
|
|
38
|
+
Rails.logger.debug { "[mcp-auth] schema check skipped: #{e.class}" } if defined?(Rails)
|
|
39
|
+
[]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Memoized fast path for the per-request guard. Columns are only ever ADDED
|
|
43
|
+
# by a migration + restart, so once the schema is current for this process
|
|
44
|
+
# it stays current — we can cache the affirmative and skip the check on
|
|
45
|
+
# every subsequent request. A negative result is NOT cached, so the guard
|
|
46
|
+
# keeps reporting the problem until it's actually resolved.
|
|
47
|
+
def up_to_date?
|
|
48
|
+
return true if @up_to_date
|
|
49
|
+
|
|
50
|
+
@up_to_date = missing_columns.empty?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Raise unless the schema is current (used where a hard failure is wanted).
|
|
54
|
+
def check!
|
|
55
|
+
missing = missing_columns
|
|
56
|
+
return true if missing.empty?
|
|
57
|
+
|
|
58
|
+
raise PendingMigrationError, guidance(missing)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def guidance(missing = missing_columns)
|
|
62
|
+
"mcp-auth #{Mcp::Auth::VERSION} needs a database migration — missing #{missing.join(', ')}. " \
|
|
63
|
+
'Run `rails g mcp:auth:upgrade && rails db:migrate`, then restart.'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -60,6 +60,18 @@ module Mcp
|
|
|
60
60
|
@custom_scopes = {}
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
+
# Scopes that may appear in a request without being rejected as unknown:
|
|
64
|
+
# registered scopes plus the standard OIDC scopes and offline_access.
|
|
65
|
+
def recognized_scope?(scope)
|
|
66
|
+
scope_exists?(scope) || (STANDARD_OIDC_SCOPES + %w[offline_access]).include?(scope.to_s)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Tokens of a scope string (or array) that are not recognized.
|
|
70
|
+
def unknown_scopes(requested_scopes)
|
|
71
|
+
scopes = requested_scopes.is_a?(String) ? requested_scopes.split : Array(requested_scopes)
|
|
72
|
+
scopes.reject { |scope| recognized_scope?(scope) }
|
|
73
|
+
end
|
|
74
|
+
|
|
63
75
|
# Check if a scope exists
|
|
64
76
|
def scope_exists?(scope)
|
|
65
77
|
available_scopes.key?(scope.to_s)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Mcp
|
|
6
|
+
module Auth
|
|
7
|
+
# One-way hashing for the secrets we persist: client secrets, access tokens,
|
|
8
|
+
# refresh tokens, and authorization codes.
|
|
9
|
+
#
|
|
10
|
+
# We only ever *verify* these values against a copy the caller presents later
|
|
11
|
+
# — we never need to read the original back. That makes a one-way hash
|
|
12
|
+
# strictly safer than reversible encryption: a database leak yields useless
|
|
13
|
+
# digests, and there is no decryption key that (if it also leaked) could turn
|
|
14
|
+
# them back into working credentials.
|
|
15
|
+
#
|
|
16
|
+
# The values are high-entropy (>= 256-bit SecureRandom, or signed JWTs), so a
|
|
17
|
+
# single unsalted SHA-256 is sufficient — there is no dictionary/brute-force
|
|
18
|
+
# risk the way there is for low-entropy passwords (which would need bcrypt/
|
|
19
|
+
# argon2). SHA-256 is also deterministic, which lets us look a token up
|
|
20
|
+
# directly by its digest through the existing unique index.
|
|
21
|
+
#
|
|
22
|
+
# Digests are stored with a `sha256$` prefix so they are self-identifying:
|
|
23
|
+
# this makes the backfill migration idempotent (a value already carrying the
|
|
24
|
+
# prefix is left alone) and leaves room to introduce a different scheme later
|
|
25
|
+
# without ambiguity.
|
|
26
|
+
module SecretHashing
|
|
27
|
+
PREFIX = 'sha256$'
|
|
28
|
+
|
|
29
|
+
module_function
|
|
30
|
+
|
|
31
|
+
# Digest a plaintext value for storage/lookup. ALWAYS hashes non-blank
|
|
32
|
+
# input — including a value that already carries the prefix. This is a
|
|
33
|
+
# security property, not a nicety: a presented credential is only ever the
|
|
34
|
+
# raw secret, so if we instead returned an already-`sha256$`-prefixed input
|
|
35
|
+
# unchanged, an attacker who read the stored digest from the database could
|
|
36
|
+
# replay that digest verbatim and it would match the row (defeating
|
|
37
|
+
# hashing-at-rest). Re-hashing a prefixed value means the stored digest is
|
|
38
|
+
# never itself a valid credential. (The backfill migration achieves
|
|
39
|
+
# idempotency with its own prefix guard, not via this method.)
|
|
40
|
+
def digest(value)
|
|
41
|
+
return value if value.blank?
|
|
42
|
+
|
|
43
|
+
"#{PREFIX}#{Digest::SHA256.hexdigest(value.to_s)}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def hashed?(value)
|
|
47
|
+
value.to_s.start_with?(PREFIX)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Whether transitional dual-read is active (see Configuration#secret_dual_read).
|
|
51
|
+
# Defaults to true when configuration is absent so an un-configured app
|
|
52
|
+
# still upgrades safely.
|
|
53
|
+
def dual_read?
|
|
54
|
+
config = Mcp::Auth.configuration
|
|
55
|
+
config.nil? || config.secret_dual_read != false
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The stored-column values a presented secret could legitimately match:
|
|
59
|
+
# always its digest, plus — while dual-read is enabled — the raw plaintext,
|
|
60
|
+
# so a legacy row not yet backfilled by the migration still resolves. Use
|
|
61
|
+
# with `where(column: candidates)`.
|
|
62
|
+
def lookup_candidates(value)
|
|
63
|
+
return [] if value.blank?
|
|
64
|
+
|
|
65
|
+
candidates = [digest(value)]
|
|
66
|
+
candidates << value.to_s if dual_read? && !hashed?(value)
|
|
67
|
+
candidates.uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Constant-time verification of a presented plaintext against a stored
|
|
71
|
+
# value. Matches the digest form, and — under dual-read — a legacy plaintext
|
|
72
|
+
# row as well.
|
|
73
|
+
def match?(stored, presented)
|
|
74
|
+
return false if stored.blank? || presented.blank?
|
|
75
|
+
|
|
76
|
+
return true if ActiveSupport::SecurityUtils.secure_compare(stored.to_s, digest(presented.to_s))
|
|
77
|
+
|
|
78
|
+
dual_read? && !hashed?(stored) &&
|
|
79
|
+
ActiveSupport::SecurityUtils.secure_compare(stored.to_s, presented.to_s)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -5,15 +5,17 @@ module Mcp
|
|
|
5
5
|
module Services
|
|
6
6
|
class AuthorizationService
|
|
7
7
|
class << self
|
|
8
|
-
# Generate authorization code with PKCE support
|
|
8
|
+
# Generate authorization code with PKCE support. The code is returned to
|
|
9
|
+
# the client in plaintext, but only its digest is persisted (hashed at
|
|
10
|
+
# rest).
|
|
9
11
|
def generate_authorization_code(params, user:, org:)
|
|
10
12
|
code = SecureRandom.hex(32)
|
|
11
13
|
|
|
12
14
|
# Use provided scope or default to all registered scopes
|
|
13
15
|
scope = params[:scope].presence || Mcp::Auth::ScopeRegistry.default_scope_string
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
code: code,
|
|
17
|
+
Mcp::Auth::AuthorizationCode.create!(
|
|
18
|
+
code: Mcp::Auth::SecretHashing.digest(code),
|
|
17
19
|
client_id: params[:client_id],
|
|
18
20
|
redirect_uri: params[:redirect_uri],
|
|
19
21
|
code_challenge: params[:code_challenge],
|
|
@@ -22,11 +24,11 @@ module Mcp
|
|
|
22
24
|
scope: scope,
|
|
23
25
|
user: user,
|
|
24
26
|
org: org,
|
|
25
|
-
expires_at: authorization_code_lifetime.
|
|
27
|
+
expires_at: authorization_code_lifetime.seconds.from_now
|
|
26
28
|
)
|
|
27
29
|
|
|
28
30
|
Rails.logger.info "[AuthorizationService] Authorization code generated for user #{user.id}"
|
|
29
|
-
|
|
31
|
+
code
|
|
30
32
|
rescue ActiveRecord::RecordInvalid => e
|
|
31
33
|
Rails.logger.error "[AuthorizationService] Failed to create authorization code: #{e.message}"
|
|
32
34
|
nil
|
|
@@ -36,7 +38,9 @@ module Mcp
|
|
|
36
38
|
def validate_authorization_code(code)
|
|
37
39
|
return nil if code.blank?
|
|
38
40
|
|
|
39
|
-
authorization_code = Mcp::Auth::AuthorizationCode.active
|
|
41
|
+
authorization_code = Mcp::Auth::AuthorizationCode.active
|
|
42
|
+
.where(code: Mcp::Auth::SecretHashing.lookup_candidates(code))
|
|
43
|
+
.first
|
|
40
44
|
return nil unless authorization_code
|
|
41
45
|
|
|
42
46
|
{
|
|
@@ -61,7 +65,8 @@ module Mcp
|
|
|
61
65
|
# and gets nil. Returns the code's data on success, nil if the code was
|
|
62
66
|
# already consumed (or never existed).
|
|
63
67
|
def consume_authorization_code(code)
|
|
64
|
-
authorization_code = Mcp::Auth::AuthorizationCode
|
|
68
|
+
authorization_code = Mcp::Auth::AuthorizationCode
|
|
69
|
+
.where(code: Mcp::Auth::SecretHashing.lookup_candidates(code)).first
|
|
65
70
|
return nil unless authorization_code
|
|
66
71
|
|
|
67
72
|
code_data = {
|
|
@@ -101,8 +106,13 @@ module Mcp
|
|
|
101
106
|
|
|
102
107
|
private
|
|
103
108
|
|
|
109
|
+
# Authorization-code TTL in SECONDS (matching every other lifetime in
|
|
110
|
+
# the gem). Read from the single canonical config source so an app that
|
|
111
|
+
# configures via Mcp::Auth.configure and one that relies on defaults
|
|
112
|
+
# agree; 1800s = 30 minutes. (Historically this was read from a second
|
|
113
|
+
# config object and applied as `.minutes`, yielding 30-HOUR codes.)
|
|
104
114
|
def authorization_code_lifetime
|
|
105
|
-
|
|
115
|
+
Mcp::Auth.configuration&.authorization_code_lifetime || 1800
|
|
106
116
|
end
|
|
107
117
|
end
|
|
108
118
|
end
|
|
@@ -4,6 +4,14 @@ module Mcp
|
|
|
4
4
|
module Auth
|
|
5
5
|
module Services
|
|
6
6
|
class TokenService
|
|
7
|
+
# Clock-skew tolerance (seconds) for `nbf` only, so a token minted by a
|
|
8
|
+
# server whose clock runs slightly ahead isn't rejected as "not yet
|
|
9
|
+
# valid". Expiry is deliberately strict — see validate_access_token.
|
|
10
|
+
CLOCK_SKEW_LEEWAY_SECONDS = 30
|
|
11
|
+
|
|
12
|
+
# Claims every access token must carry.
|
|
13
|
+
REQUIRED_CLAIMS = %w[iss aud sub exp].freeze
|
|
14
|
+
|
|
7
15
|
class << self
|
|
8
16
|
# Validate access token with optional resource verification (RFC 8707).
|
|
9
17
|
# Supports HS256, RS256, and ES256 — algorithm comes from configuration.
|
|
@@ -14,17 +22,36 @@ module Mcp
|
|
|
14
22
|
payload = decode_with_known_keys(token)
|
|
15
23
|
return nil unless payload
|
|
16
24
|
|
|
17
|
-
#
|
|
25
|
+
# Required claims, checked here too: ruby-jwt versions older than the
|
|
26
|
+
# `required_claims` decode option silently ignore it, and the gemspec
|
|
27
|
+
# allows them. Without this, a token lacking `aud` would skip the
|
|
28
|
+
# audience check below on those versions.
|
|
29
|
+
return nil unless REQUIRED_CLAIMS.all? { |claim| payload[claim].present? }
|
|
30
|
+
|
|
31
|
+
# Expiry is strict (no leeway): this server validates tokens it
|
|
32
|
+
# issued itself, so there's no cross-machine skew to absorb.
|
|
18
33
|
return nil if payload['exp'] && (payload['exp'] <= Time.current.to_i)
|
|
19
34
|
|
|
35
|
+
# Token-use separation: an id_token is signed with the same key/alg
|
|
36
|
+
# as an access token, so cryptographically it would otherwise verify
|
|
37
|
+
# here. Reject anything not minted as an access token. (Legacy tokens
|
|
38
|
+
# without the claim are still accepted; the DB row check below is the
|
|
39
|
+
# second gate — id_tokens are never stored.)
|
|
40
|
+
token_use = payload['token_use']
|
|
41
|
+
return nil unless token_use.nil? || token_use == 'access'
|
|
42
|
+
|
|
20
43
|
# Revocation check (RFC 7009): a JWT remains cryptographically valid
|
|
21
44
|
# until it expires, so a stored-and-still-present row is what makes
|
|
22
45
|
# `revoke` actually take effect. Without this, destroyed tokens would
|
|
23
|
-
# keep validating until natural expiry.
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
46
|
+
# keep validating until natural expiry. The row is keyed by the
|
|
47
|
+
# token's digest (tokens are hashed at rest).
|
|
48
|
+
candidates = SecretHashing.lookup_candidates(token)
|
|
49
|
+
return nil unless Mcp::Auth::AccessToken.active.where(token: candidates).exists?
|
|
50
|
+
|
|
51
|
+
# Validate audience if a resource was provided (RFC 8707). `aud` is a
|
|
52
|
+
# required claim (enforced at decode), so a token lacking it never
|
|
53
|
+
# reaches here — no silent skip on a missing audience.
|
|
54
|
+
if resource && !audience_matches?(payload['aud'], resource)
|
|
28
55
|
Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
|
|
29
56
|
return nil
|
|
30
57
|
end
|
|
@@ -46,7 +73,7 @@ module Mcp
|
|
|
46
73
|
def decode_with_known_keys(token)
|
|
47
74
|
last_error = nil
|
|
48
75
|
verification_keys.each do |key|
|
|
49
|
-
return JWT.decode(token, key, true,
|
|
76
|
+
return JWT.decode(token, key, true, decode_options).first
|
|
50
77
|
rescue JWT::DecodeError => e
|
|
51
78
|
last_error = e
|
|
52
79
|
end
|
|
@@ -55,6 +82,23 @@ module Mcp
|
|
|
55
82
|
nil
|
|
56
83
|
end
|
|
57
84
|
|
|
85
|
+
# Decode options shared across verification keys. The algorithm is
|
|
86
|
+
# pinned to a single value (blocks `alg:none` and RS↔HS confusion);
|
|
87
|
+
# `exp` is verified strictly and `nbf` with a small clock-skew leeway;
|
|
88
|
+
# and the
|
|
89
|
+
# core claims are required so a token missing `iss`/`aud`/`sub`/`exp`
|
|
90
|
+
# is rejected outright rather than silently passing later checks.
|
|
91
|
+
def decode_options
|
|
92
|
+
{
|
|
93
|
+
algorithm: signing_algorithm,
|
|
94
|
+
verify_expiration: true,
|
|
95
|
+
verify_not_before: true,
|
|
96
|
+
exp_leeway: 0,
|
|
97
|
+
nbf_leeway: CLOCK_SKEW_LEEWAY_SECONDS,
|
|
98
|
+
required_claims: REQUIRED_CLAIMS
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
58
102
|
# Generate JWT access token with proper audience binding
|
|
59
103
|
def generate_access_token(data, base_url:)
|
|
60
104
|
user_data = fetch_user_data(data)
|
|
@@ -76,6 +120,15 @@ module Mcp
|
|
|
76
120
|
client_id: data[:client_id],
|
|
77
121
|
email: user_data[:email],
|
|
78
122
|
scope: data[:scope],
|
|
123
|
+
# Unique token id (RFC 7519 jti). Guarantees every access token is
|
|
124
|
+
# distinct even when two are issued in the same second for the same
|
|
125
|
+
# principal/scope (e.g. rapid or concurrent refreshes) — without it
|
|
126
|
+
# the JWTs would be byte-identical and collide on the unique token
|
|
127
|
+
# index at storage time.
|
|
128
|
+
jti: SecureRandom.uuid,
|
|
129
|
+
# Marks this JWT as an access token so it can't be replayed as an
|
|
130
|
+
# id_token (or vice versa); verified in validate_access_token.
|
|
131
|
+
token_use: 'access',
|
|
79
132
|
# Only a non-sensitive API key *identifier* is embedded. A bearer
|
|
80
133
|
# JWT is decodable by anyone holding it (and is stored at rest), so
|
|
81
134
|
# the matching secret MUST NOT be placed in the token — the resource
|
|
@@ -97,7 +150,11 @@ module Mcp
|
|
|
97
150
|
raise
|
|
98
151
|
end
|
|
99
152
|
|
|
100
|
-
# Generate refresh token
|
|
153
|
+
# Generate refresh token. The opaque value is returned to the client in
|
|
154
|
+
# plaintext, but only its digest is persisted (hashed at rest). Tokens
|
|
155
|
+
# descend within a `family_id`: the first issuance starts a new family;
|
|
156
|
+
# a rotation reuses the presented token's family so a later replay can
|
|
157
|
+
# be detected as reuse.
|
|
101
158
|
def generate_refresh_token(data)
|
|
102
159
|
refresh_token = SecureRandom.hex(32)
|
|
103
160
|
|
|
@@ -106,11 +163,12 @@ module Mcp
|
|
|
106
163
|
|
|
107
164
|
begin
|
|
108
165
|
Mcp::Auth::RefreshToken.create!(
|
|
109
|
-
token: refresh_token,
|
|
166
|
+
token: SecretHashing.digest(refresh_token),
|
|
110
167
|
client_id: data[:client_id],
|
|
111
168
|
scope: data[:scope],
|
|
112
169
|
user_id: data[:user_id],
|
|
113
170
|
org_id: data[:org_id],
|
|
171
|
+
family_id: data[:family_id].presence || SecureRandom.uuid,
|
|
114
172
|
expires_at: expires_at
|
|
115
173
|
)
|
|
116
174
|
|
|
@@ -122,13 +180,54 @@ module Mcp
|
|
|
122
180
|
end
|
|
123
181
|
end
|
|
124
182
|
|
|
183
|
+
# Look up the refresh-token record for a presented value in ANY state
|
|
184
|
+
# (active, expired, or revoked) so the caller can implement reuse
|
|
185
|
+
# detection. Honors dual-read (digest or legacy plaintext).
|
|
186
|
+
def find_refresh_token(raw)
|
|
187
|
+
return nil if raw.blank?
|
|
188
|
+
|
|
189
|
+
Mcp::Auth::RefreshToken.where(token: SecretHashing.lookup_candidates(raw)).first
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Atomically consume a refresh token by flipping revoked_at from NULL.
|
|
193
|
+
# Returns true only for the request that actually performed the flip, so
|
|
194
|
+
# concurrent redemptions of the same token can't both rotate.
|
|
195
|
+
def rotate_refresh_token(record)
|
|
196
|
+
Mcp::Auth::RefreshToken
|
|
197
|
+
.where(id: record.id, revoked_at: nil)
|
|
198
|
+
.update_all(revoked_at: Time.current) == 1
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Revoke every still-live token in a family (used on reuse detection).
|
|
202
|
+
def revoke_refresh_family(family_id)
|
|
203
|
+
return 0 if family_id.blank?
|
|
204
|
+
|
|
205
|
+
Mcp::Auth::RefreshToken
|
|
206
|
+
.where(family_id: family_id, revoked_at: nil)
|
|
207
|
+
.update_all(revoked_at: Time.current)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Delete the access tokens for a (user, client) pair. Called on
|
|
211
|
+
# refresh-token-reuse (theft) detection so the already-issued access
|
|
212
|
+
# token(s) are cut off immediately, not left valid until natural expiry.
|
|
213
|
+
# Access tokens aren't grouped by family_id, so we scope by the
|
|
214
|
+
# principal that the compromised family belonged to.
|
|
215
|
+
def revoke_access_tokens_for(user_id:, client_id:)
|
|
216
|
+
Mcp::Auth::AccessToken.where(user_id: user_id, client_id: client_id).delete_all
|
|
217
|
+
end
|
|
218
|
+
|
|
125
219
|
# Validate refresh token
|
|
126
220
|
def validate_refresh_token(refresh_token)
|
|
127
221
|
return nil if refresh_token.blank?
|
|
128
222
|
|
|
129
|
-
token_record = Mcp::Auth::RefreshToken.
|
|
223
|
+
token_record = Mcp::Auth::RefreshToken.where(token: SecretHashing.lookup_candidates(refresh_token)).first
|
|
130
224
|
return nil unless token_record
|
|
131
225
|
|
|
226
|
+
# A rotated/revoked token is no longer active (RFC 7662): rotation now
|
|
227
|
+
# marks `revoked_at` instead of deleting the row, so introspection must
|
|
228
|
+
# exclude it explicitly rather than relying on the row being gone.
|
|
229
|
+
return nil if token_record.revoked?
|
|
230
|
+
|
|
132
231
|
# Check if token is expired
|
|
133
232
|
return nil if token_record.expires_at < Time.current
|
|
134
233
|
|
|
@@ -141,18 +240,6 @@ module Mcp
|
|
|
141
240
|
}
|
|
142
241
|
end
|
|
143
242
|
|
|
144
|
-
# Revoke refresh token (RFC 7009)
|
|
145
|
-
def revoke_refresh_token(refresh_token)
|
|
146
|
-
return false if refresh_token.blank?
|
|
147
|
-
|
|
148
|
-
token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token)
|
|
149
|
-
return false unless token_record
|
|
150
|
-
|
|
151
|
-
token_record.destroy
|
|
152
|
-
Rails.logger.info '[TokenService] Refresh token revoked'
|
|
153
|
-
true
|
|
154
|
-
end
|
|
155
|
-
|
|
156
243
|
# Generate complete token response
|
|
157
244
|
def generate_token_response(data, base_url:)
|
|
158
245
|
access_token = generate_access_token(data, base_url: base_url)
|
|
@@ -192,6 +279,7 @@ module Mcp
|
|
|
192
279
|
iss: base_url,
|
|
193
280
|
sub: data[:user_id].to_s,
|
|
194
281
|
aud: data[:client_id],
|
|
282
|
+
token_use: 'id', # distinguishes this from an access token
|
|
195
283
|
iat: Time.current.to_i,
|
|
196
284
|
exp: Time.current.to_i + token_lifetime
|
|
197
285
|
}
|
|
@@ -368,11 +456,44 @@ module Mcp
|
|
|
368
456
|
pub
|
|
369
457
|
end
|
|
370
458
|
|
|
459
|
+
# HMAC secret for HS256 signing. A dedicated secret MUST be configured:
|
|
460
|
+
# silently reusing Rails' secret_key_base (which also signs cookies and
|
|
461
|
+
# every MessageVerifier) breaks key separation. Outside development/test
|
|
462
|
+
# a missing secret — or one that IS secret_key_base, e.g. via an
|
|
463
|
+
# `ENV.fetch('MCP_HMAC_SECRET', secret_key_base)` initializer — is a hard
|
|
464
|
+
# error; in dev/test we fall back so the gem still boots.
|
|
371
465
|
def oauth_secret
|
|
372
466
|
secret = Mcp::Auth.configuration&.oauth_secret
|
|
467
|
+
problem = oauth_secret_problem(secret)
|
|
468
|
+
return secret if problem.nil?
|
|
469
|
+
|
|
470
|
+
raise Mcp::Auth::Error, problem unless local_env?
|
|
471
|
+
|
|
373
472
|
secret.presence || Rails.application.secret_key_base
|
|
374
473
|
end
|
|
375
474
|
|
|
475
|
+
# Why the configured HS256 secret is unacceptable outside dev/test, or
|
|
476
|
+
# nil when it is fine. Public so boot checks / `mcp_auth:doctor` can
|
|
477
|
+
# report it before the first token request fails.
|
|
478
|
+
def oauth_secret_problem(secret = Mcp::Auth.configuration&.oauth_secret)
|
|
479
|
+
return nil if asymmetric_signing? # RS256/ES256 don't use oauth_secret
|
|
480
|
+
|
|
481
|
+
if secret.blank?
|
|
482
|
+
'Mcp::Auth.configuration.oauth_secret must be set (e.g. ENV["MCP_HMAC_SECRET"]) — refusing to ' \
|
|
483
|
+
'sign tokens with Rails.application.secret_key_base outside development/test (key separation).'
|
|
484
|
+
elsif defined?(Rails) && Rails.application &&
|
|
485
|
+
ActiveSupport::SecurityUtils.secure_compare(secret.to_s, Rails.application.secret_key_base.to_s)
|
|
486
|
+
'Mcp::Auth.configuration.oauth_secret must not be Rails.application.secret_key_base — set a ' \
|
|
487
|
+
'dedicated secret (e.g. ENV["MCP_HMAC_SECRET"], generate with `rails secret`).'
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
public :oauth_secret_problem
|
|
492
|
+
|
|
493
|
+
def local_env?
|
|
494
|
+
defined?(Rails) && (Rails.env.development? || Rails.env.test?)
|
|
495
|
+
end
|
|
496
|
+
|
|
376
497
|
def token_lifetime
|
|
377
498
|
Mcp::Auth.configuration&.access_token_lifetime || 3600
|
|
378
499
|
end
|
|
@@ -421,7 +542,7 @@ module Mcp
|
|
|
421
542
|
expires_at = data[:expires_at] || token_lifetime.seconds.from_now
|
|
422
543
|
|
|
423
544
|
Mcp::Auth::AccessToken.create!(
|
|
424
|
-
token: token,
|
|
545
|
+
token: SecretHashing.digest(token),
|
|
425
546
|
client_id: data[:client_id],
|
|
426
547
|
resource: audience,
|
|
427
548
|
scope: data[:scope],
|