mcp-auth 0.1.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.
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ module Services
6
+ class AuthorizationService
7
+ class << self
8
+ # Generate authorization code with PKCE support
9
+ def generate_authorization_code(params, user:, org:)
10
+ code = SecureRandom.hex(32)
11
+
12
+ # Use provided scope or default to all registered scopes
13
+ scope = params[:scope].presence || Mcp::Auth::ScopeRegistry.default_scope_string
14
+
15
+ authorization_code = Mcp::Auth::AuthorizationCode.create!(
16
+ code: code,
17
+ client_id: params[:client_id],
18
+ redirect_uri: params[:redirect_uri],
19
+ code_challenge: params[:code_challenge],
20
+ code_challenge_method: params[:code_challenge_method],
21
+ resource: params[:resource],
22
+ scope: scope,
23
+ user: user,
24
+ org: org,
25
+ expires_at: authorization_code_lifetime.minutes.from_now
26
+ )
27
+
28
+ Rails.logger.info "[AuthorizationService] Authorization code generated for user #{user.id}"
29
+ authorization_code.code
30
+ rescue ActiveRecord::RecordInvalid => e
31
+ Rails.logger.error "[AuthorizationService] Failed to create authorization code: #{e.message}"
32
+ nil
33
+ end
34
+
35
+ # Validate authorization code without consuming it
36
+ def validate_authorization_code(code)
37
+ return nil if code.blank?
38
+
39
+ authorization_code = Mcp::Auth::AuthorizationCode.active.find_by(code: code)
40
+ return nil unless authorization_code
41
+
42
+ {
43
+ client_id: authorization_code.client_id,
44
+ redirect_uri: authorization_code.redirect_uri,
45
+ code_challenge: authorization_code.code_challenge,
46
+ code_challenge_method: authorization_code.code_challenge_method,
47
+ resource: authorization_code.resource,
48
+ scope: authorization_code.scope,
49
+ user_id: authorization_code.user_id,
50
+ org_id: authorization_code.org_id,
51
+ created_at: authorization_code.created_at.to_i
52
+ }
53
+ end
54
+
55
+ # Consume authorization code (one-time use)
56
+ def consume_authorization_code(code)
57
+ authorization_code = Mcp::Auth::AuthorizationCode.find_by(code: code)
58
+ return nil unless authorization_code
59
+
60
+ code_data = {
61
+ client_id: authorization_code.client_id,
62
+ redirect_uri: authorization_code.redirect_uri,
63
+ code_challenge: authorization_code.code_challenge,
64
+ code_challenge_method: authorization_code.code_challenge_method,
65
+ resource: authorization_code.resource,
66
+ scope: authorization_code.scope,
67
+ user_id: authorization_code.user_id,
68
+ org_id: authorization_code.org_id,
69
+ created_at: authorization_code.created_at.to_i
70
+ }
71
+
72
+ authorization_code.destroy
73
+ Rails.logger.info "[AuthorizationService] Authorization code consumed"
74
+ code_data
75
+ end
76
+
77
+ # Validate PKCE challenge (RFC 7636)
78
+ def validate_pkce?(code_challenge, code_verifier)
79
+ return false if code_verifier.blank? || code_challenge.blank?
80
+
81
+ # S256 method: BASE64URL(SHA256(code_verifier))
82
+ computed_challenge = Base64.urlsafe_encode64(
83
+ Digest::SHA256.digest(code_verifier),
84
+ padding: false
85
+ )
86
+
87
+ ActiveSupport::SecurityUtils.secure_compare(computed_challenge, code_challenge)
88
+ rescue StandardError => e
89
+ Rails.logger.error "[AuthorizationService] PKCE validation error: #{e.message}"
90
+ false
91
+ end
92
+
93
+ private
94
+
95
+ def authorization_code_lifetime
96
+ Rails.application.config.mcp_auth.authorization_code_lifetime || 30
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ module Services
6
+ class TokenService
7
+ class << self
8
+ # Validate access token with optional resource verification (RFC 8707)
9
+ def validate_access_token(token, resource: nil)
10
+ return nil if token.blank?
11
+
12
+ begin
13
+ payload = JWT.decode(token, oauth_secret, true, { algorithm: 'HS256' }).first
14
+
15
+ # Check expiration manually to ensure proper handling
16
+ if payload['exp']
17
+ return nil if payload['exp'] <= Time.current.to_i
18
+ end
19
+
20
+ # Validate audience if resource provided (RFC 8707 compliance)
21
+ if resource && payload['aud'].present?
22
+ unless audience_matches?(payload['aud'], resource)
23
+ Rails.logger.warn "[TokenService] Token audience mismatch: expected #{resource}, got #{payload['aud']}"
24
+ return nil
25
+ end
26
+ end
27
+
28
+ payload.symbolize_keys
29
+ rescue JWT::DecodeError, JWT::ExpiredSignature => e
30
+ Rails.logger.debug "[TokenService] Token validation failed: #{e.message}"
31
+ nil
32
+ rescue StandardError => e
33
+ Rails.logger.error "[TokenService] Token validation error: #{e.message}"
34
+ nil
35
+ end
36
+ end
37
+
38
+ # Generate JWT access token with proper audience binding
39
+ def generate_access_token(data, base_url:)
40
+ user_data = fetch_user_data(data)
41
+
42
+ # RFC 8707: Use provided resource or default to MCP API endpoint
43
+ audience = normalize_resource_uri(data[:resource].presence || "#{base_url}/mcp")
44
+
45
+ # Calculate expiration time
46
+ exp_time = data[:expires_at] ? data[:expires_at].to_i : (Time.current.to_i + token_lifetime)
47
+
48
+ payload = {
49
+ iss: base_url,
50
+ aud: audience,
51
+ sub: data[:user_id].to_s,
52
+ org: data[:org_id]&.to_s,
53
+ client_id: data[:client_id],
54
+ email: user_data[:email],
55
+ scope: data[:scope],
56
+ api_key_id: user_data[:api_key_id],
57
+ api_key_secret: user_data[:api_key_secret],
58
+ iat: Time.current.to_i,
59
+ exp: exp_time
60
+ }
61
+
62
+ token = JWT.encode(payload, oauth_secret, 'HS256')
63
+
64
+ # Store token in database for revocation support
65
+ store_access_token(token, data, audience)
66
+
67
+ token
68
+ rescue StandardError => e
69
+ Rails.logger.error "[TokenService] Failed to generate access token: #{e.message}"
70
+ raise
71
+ end
72
+
73
+ # Generate refresh token
74
+ def generate_refresh_token(data)
75
+ refresh_token = SecureRandom.hex(32)
76
+
77
+ # Use provided expires_at or default
78
+ expires_at = data[:expires_at] || refresh_token_lifetime.seconds.from_now
79
+
80
+ begin
81
+ Mcp::Auth::RefreshToken.create!(
82
+ token: refresh_token,
83
+ client_id: data[:client_id],
84
+ scope: data[:scope],
85
+ user_id: data[:user_id],
86
+ org_id: data[:org_id],
87
+ expires_at: expires_at
88
+ )
89
+
90
+ Rails.logger.info "[TokenService] Refresh token created for user #{data[:user_id]}"
91
+ refresh_token
92
+ rescue ActiveRecord::RecordInvalid => e
93
+ Rails.logger.error "[TokenService] Failed to create refresh token: #{e.message}"
94
+ nil
95
+ end
96
+ end
97
+
98
+ # Validate refresh token
99
+ def validate_refresh_token(refresh_token)
100
+ return nil if refresh_token.blank?
101
+
102
+ token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token)
103
+ return nil unless token_record
104
+
105
+ # Check if token is expired
106
+ return nil if token_record.expires_at < Time.current
107
+
108
+ Rails.logger.info "[TokenService] Refresh token validated for user #{token_record.user_id}"
109
+ {
110
+ client_id: token_record.client_id,
111
+ scope: token_record.scope,
112
+ user_id: token_record.user_id,
113
+ org_id: token_record.org_id
114
+ }
115
+ end
116
+
117
+ # Revoke refresh token (RFC 7009)
118
+ def revoke_refresh_token(refresh_token)
119
+ return false if refresh_token.blank?
120
+
121
+ token_record = Mcp::Auth::RefreshToken.find_by(token: refresh_token)
122
+ return false unless token_record
123
+
124
+ token_record.destroy
125
+ Rails.logger.info "[TokenService] Refresh token revoked"
126
+ true
127
+ end
128
+
129
+ # Generate complete token response
130
+ def generate_token_response(data, base_url:)
131
+ access_token = generate_access_token(data, base_url: base_url)
132
+ refresh_token = generate_refresh_token(data)
133
+
134
+ response = {
135
+ access_token: access_token,
136
+ token_type: 'Bearer',
137
+ expires_in: token_lifetime,
138
+ scope: data[:scope]
139
+ }
140
+
141
+ response[:refresh_token] = refresh_token if refresh_token
142
+ response
143
+ rescue StandardError => e
144
+ Rails.logger.error "[TokenService] Failed to generate token response: #{e.message}"
145
+ raise
146
+ end
147
+
148
+ private
149
+
150
+ def oauth_secret
151
+ secret = Mcp::Auth.configuration&.oauth_secret
152
+ secret.presence || Rails.application.secret_key_base
153
+ end
154
+
155
+ def token_lifetime
156
+ Mcp::Auth.configuration&.access_token_lifetime || 3600
157
+ end
158
+
159
+ def refresh_token_lifetime
160
+ Mcp::Auth.configuration&.refresh_token_lifetime || 2_592_000
161
+ end
162
+
163
+ # RFC 8707: Normalize resource URI (remove trailing slash, lowercase scheme/host)
164
+ def normalize_resource_uri(uri)
165
+ parsed = URI.parse(uri)
166
+ normalized = "#{parsed.scheme.downcase}://#{parsed.host.downcase}"
167
+ normalized += ":#{parsed.port}" if parsed.port && !default_port?(parsed)
168
+ normalized += parsed.path.chomp('/') if parsed.path.present? && parsed.path != '/'
169
+ normalized
170
+ rescue URI::InvalidURIError => e
171
+ Rails.logger.warn "[TokenService] Invalid resource URI: #{uri} - #{e.message}"
172
+ uri
173
+ end
174
+
175
+ def default_port?(parsed_uri)
176
+ (parsed_uri.scheme == 'http' && parsed_uri.port == 80) ||
177
+ (parsed_uri.scheme == 'https' && parsed_uri.port == 443)
178
+ end
179
+
180
+ # RFC 8707: Check if token audience matches requested resource
181
+ def audience_matches?(token_audience, resource)
182
+ normalized_audience = normalize_resource_uri(token_audience)
183
+ normalized_resource = normalize_resource_uri(resource)
184
+
185
+ # Exact match or audience is a prefix of resource
186
+ normalized_audience == normalized_resource ||
187
+ normalized_resource.start_with?(normalized_audience)
188
+ end
189
+
190
+ def store_access_token(token, data, audience)
191
+ # Use provided expires_at or default
192
+ expires_at = data[:expires_at] || token_lifetime.seconds.from_now
193
+
194
+ Mcp::Auth::AccessToken.create!(
195
+ token: token,
196
+ client_id: data[:client_id],
197
+ resource: audience,
198
+ scope: data[:scope],
199
+ user_id: data[:user_id],
200
+ org_id: data[:org_id],
201
+ expires_at: expires_at
202
+ )
203
+ Rails.logger.info "[TokenService] Access token stored for user #{data[:user_id]}"
204
+ rescue ActiveRecord::RecordInvalid => e
205
+ Rails.logger.error "[TokenService] Failed to store access token: #{e.message}"
206
+ end
207
+
208
+ def fetch_user_data(data)
209
+ if Mcp::Auth.configuration&.fetch_user_data
210
+ Mcp::Auth.configuration.fetch_user_data.call(data)
211
+ else
212
+ default_fetch_user_data(data[:user_id])
213
+ end
214
+ end
215
+
216
+ def default_fetch_user_data(user_id)
217
+ user = User.find(user_id)
218
+ {
219
+ email: user.email,
220
+ api_key_id: nil,
221
+ api_key_secret: nil
222
+ }
223
+ rescue ActiveRecord::RecordNotFound
224
+ { email: 'unknown@example.com', api_key_id: nil, api_key_secret: nil }
225
+ end
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
data/lib/mcp/auth.rb ADDED
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mcp/auth/version'
4
+ require 'mcp/auth/engine'
5
+ require 'mcp/auth/services/token_service'
6
+ require 'mcp/auth/services/authorization_service'
7
+
8
+ module Mcp
9
+ module Auth
10
+ class Error < StandardError; end
11
+
12
+ class << self
13
+ attr_accessor :configuration
14
+ end
15
+
16
+ def self.configure
17
+ self.configuration ||= Configuration.new
18
+ yield(configuration)
19
+
20
+ # Also set on Rails.application.config for controllers to access
21
+ Rails.application.config.mcp_auth = configuration if defined?(Rails)
22
+ end
23
+
24
+ class Configuration
25
+ attr_accessor :oauth_secret,
26
+ :authorization_server_url,
27
+ :access_token_lifetime,
28
+ :refresh_token_lifetime,
29
+ :authorization_code_lifetime,
30
+ :fetch_user_data,
31
+ :current_user_method,
32
+ :current_org_method,
33
+ :consent_view_path,
34
+ :use_custom_consent_view,
35
+ :mcp_server_path,
36
+ :mcp_docs_url,
37
+ :validate_scope_for_user
38
+
39
+ def initialize
40
+ @oauth_secret = nil
41
+ @authorization_server_url = nil
42
+ @access_token_lifetime = 3600 # 1 hour
43
+ @refresh_token_lifetime = 2_592_000 # 30 days
44
+ @authorization_code_lifetime = 1800 # 30 minutes
45
+ @fetch_user_data = nil
46
+ @current_user_method = :current_user
47
+ @current_org_method = nil
48
+ @consent_view_path = 'mcp/auth/consent'
49
+ @use_custom_consent_view = false
50
+ @mcp_server_path = '/mcp'
51
+ @mcp_docs_url = nil
52
+ @validate_scope_for_user = nil
53
+ end
54
+
55
+ # Register a custom scope for your application
56
+ def register_scope(scope_key, name:, description:, required: false)
57
+ Mcp::Auth::ScopeRegistry.register_scope(
58
+ scope_key,
59
+ name: name,
60
+ description: description,
61
+ required: required
62
+ )
63
+ end
64
+
65
+ # Get MCP documentation URL
66
+ def documentation_url(base_url = nil)
67
+ return @mcp_docs_url if @mcp_docs_url.present? && @mcp_docs_url.start_with?('http')
68
+
69
+ docs_path = @mcp_docs_url.presence || "#{@mcp_server_path}/docs"
70
+ base_url ? "#{base_url}#{docs_path}" : docs_path
71
+ end
72
+ end
73
+
74
+ # Helper methods for controllers
75
+ module ControllerHelpers
76
+ def mcp_user_id
77
+ request.env['mcp.user_id']
78
+ end
79
+
80
+ def mcp_org_id
81
+ request.env['mcp.org_id']
82
+ end
83
+
84
+ def mcp_email
85
+ request.env['mcp.email']
86
+ end
87
+
88
+ def mcp_token
89
+ request.env['mcp.token']
90
+ end
91
+
92
+ def mcp_scope
93
+ request.env['mcp.scope']
94
+ end
95
+
96
+ def mcp_api_key
97
+ request.env['mcp.api_key']
98
+ end
99
+
100
+ def mcp_authenticated?
101
+ mcp_user_id.present?
102
+ end
103
+ end
104
+ end
105
+ end
106
+
107
+ require 'mcp/auth/scope_registry'
108
+ require 'mcp/auth/services/token_service'
109
+ require 'mcp/auth/services/authorization_service'
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :mcp_auth do
4
+ desc "Clean up expired tokens and authorization codes"
5
+ task cleanup: :environment do
6
+ puts "Cleaning up expired MCP Auth tokens..."
7
+
8
+ expired_auth_codes = Mcp::Auth::AuthorizationCode.cleanup_expired
9
+ puts " - Removed #{expired_auth_codes} expired authorization codes"
10
+
11
+ expired_access_tokens = Mcp::Auth::AccessToken.cleanup_expired
12
+ puts " - Removed #{expired_access_tokens} expired access tokens"
13
+
14
+ expired_refresh_tokens = Mcp::Auth::RefreshToken.cleanup_expired
15
+ puts " - Removed #{expired_refresh_tokens} expired refresh tokens"
16
+
17
+ puts "Cleanup complete!"
18
+ end
19
+
20
+ desc "Show MCP Auth statistics"
21
+ task stats: :environment do
22
+ puts "\nMCP Auth Statistics"
23
+ puts "=" * 50
24
+
25
+ puts "\nOAuth Clients:"
26
+ puts " Total: #{Mcp::Auth::OauthClient.count}"
27
+
28
+ puts "\nAuthorization Codes:"
29
+ puts " Active: #{Mcp::Auth::AuthorizationCode.active.count}"
30
+ puts " Expired: #{Mcp::Auth::AuthorizationCode.expired.count}"
31
+ puts " Total: #{Mcp::Auth::AuthorizationCode.count}"
32
+
33
+ puts "\nAccess Tokens:"
34
+ puts " Active: #{Mcp::Auth::AccessToken.active.count}"
35
+ puts " Expired: #{Mcp::Auth::AccessToken.expired.count}"
36
+ puts " Total: #{Mcp::Auth::AccessToken.count}"
37
+
38
+ puts "\nRefresh Tokens:"
39
+ puts " Active: #{Mcp::Auth::RefreshToken.active.count}"
40
+ puts " Expired: #{Mcp::Auth::RefreshToken.expired.count}"
41
+ puts " Total: #{Mcp::Auth::RefreshToken.count}"
42
+
43
+ puts "\n" + "=" * 50
44
+ end
45
+
46
+ desc "Revoke all tokens for a specific client"
47
+ task :revoke_client_tokens, [:client_id] => :environment do |_t, args|
48
+ client_id = args[:client_id]
49
+
50
+ if client_id.blank?
51
+ puts "Error: Please provide a client_id"
52
+ puts "Usage: rake mcp_auth:revoke_client_tokens[CLIENT_ID]"
53
+ exit 1
54
+ end
55
+
56
+ puts "Revoking all tokens for client: #{client_id}"
57
+
58
+ auth_codes = Mcp::Auth::AuthorizationCode.where(client_id: client_id).delete_all
59
+ access_tokens = Mcp::Auth::AccessToken.where(client_id: client_id).delete_all
60
+ refresh_tokens = Mcp::Auth::RefreshToken.where(client_id: client_id).delete_all
61
+
62
+ puts " - Removed #{auth_codes} authorization codes"
63
+ puts " - Removed #{access_tokens} access tokens"
64
+ puts " - Removed #{refresh_tokens} refresh tokens"
65
+ puts "Complete!"
66
+ end
67
+
68
+ desc "Revoke all tokens for a specific user"
69
+ task :revoke_user_tokens, [:user_id] => :environment do |_t, args|
70
+ user_id = args[:user_id]
71
+
72
+ if user_id.blank?
73
+ puts "Error: Please provide a user_id"
74
+ puts "Usage: rake mcp_auth:revoke_user_tokens[USER_ID]"
75
+ exit 1
76
+ end
77
+
78
+ puts "Revoking all tokens for user: #{user_id}"
79
+
80
+ auth_codes = Mcp::Auth::AuthorizationCode.where(user_id: user_id).delete_all
81
+ access_tokens = Mcp::Auth::AccessToken.where(user_id: user_id).delete_all
82
+ refresh_tokens = Mcp::Auth::RefreshToken.where(user_id: user_id).delete_all
83
+
84
+ puts " - Removed #{auth_codes} authorization codes"
85
+ puts " - Removed #{access_tokens} access tokens"
86
+ puts " - Removed #{refresh_tokens} refresh tokens"
87
+ puts "Complete!"
88
+ end
89
+ end