actionmcp 0.52.2 → 0.53.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/README.md +2 -0
- data/app/controllers/action_mcp/oauth/endpoints_controller.rb +264 -0
- data/app/controllers/action_mcp/oauth/metadata_controller.rb +129 -0
- data/app/models/action_mcp/session.rb +99 -0
- data/config/routes.rb +10 -0
- data/db/migrate/20250608112101_add_oauth_to_sessions.rb +19 -0
- data/lib/action_mcp/client/oauth_client_provider/memory_storage.rb +47 -0
- data/lib/action_mcp/client/oauth_client_provider.rb +234 -0
- data/lib/action_mcp/client/streamable_http_transport.rb +25 -1
- data/lib/action_mcp/client.rb +15 -2
- data/lib/action_mcp/configuration.rb +65 -6
- data/lib/action_mcp/engine.rb +8 -0
- data/lib/action_mcp/gateway.rb +95 -6
- data/lib/action_mcp/oauth/error.rb +79 -0
- data/lib/action_mcp/oauth/memory_storage.rb +112 -0
- data/lib/action_mcp/oauth/middleware.rb +100 -0
- data/lib/action_mcp/oauth/provider.rb +390 -0
- data/lib/action_mcp/omniauth/mcp_strategy.rb +176 -0
- data/lib/action_mcp/version.rb +1 -1
- data/lib/action_mcp.rb +6 -1
- data/lib/generators/action_mcp/config/templates/mcp.yml +71 -3
- metadata +81 -1
@@ -0,0 +1,112 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# In-memory storage for OAuth tokens and codes
|
6
|
+
# This is suitable for development and testing, but not for production
|
7
|
+
class MemoryStorage
|
8
|
+
def initialize
|
9
|
+
@authorization_codes = {}
|
10
|
+
@access_tokens = {}
|
11
|
+
@refresh_tokens = {}
|
12
|
+
@mutex = Mutex.new
|
13
|
+
end
|
14
|
+
|
15
|
+
# Authorization code storage
|
16
|
+
def store_authorization_code(code, data)
|
17
|
+
@mutex.synchronize do
|
18
|
+
@authorization_codes[code] = data
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def retrieve_authorization_code(code)
|
23
|
+
@mutex.synchronize do
|
24
|
+
@authorization_codes[code]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def remove_authorization_code(code)
|
29
|
+
@mutex.synchronize do
|
30
|
+
@authorization_codes.delete(code)
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Access token storage
|
35
|
+
def store_access_token(token, data)
|
36
|
+
@mutex.synchronize do
|
37
|
+
@access_tokens[token] = data
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
def retrieve_access_token(token)
|
42
|
+
@mutex.synchronize do
|
43
|
+
@access_tokens[token]
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def remove_access_token(token)
|
48
|
+
@mutex.synchronize do
|
49
|
+
@access_tokens.delete(token)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
|
53
|
+
# Refresh token storage
|
54
|
+
def store_refresh_token(token, data)
|
55
|
+
@mutex.synchronize do
|
56
|
+
@refresh_tokens[token] = data
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def retrieve_refresh_token(token)
|
61
|
+
@mutex.synchronize do
|
62
|
+
@refresh_tokens[token]
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
def update_refresh_token(token, new_access_token)
|
67
|
+
@mutex.synchronize do
|
68
|
+
if @refresh_tokens[token]
|
69
|
+
@refresh_tokens[token][:access_token] = new_access_token
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def remove_refresh_token(token)
|
75
|
+
@mutex.synchronize do
|
76
|
+
@refresh_tokens.delete(token)
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
# Cleanup expired tokens (optional utility method)
|
81
|
+
def cleanup_expired
|
82
|
+
current_time = Time.current
|
83
|
+
|
84
|
+
@mutex.synchronize do
|
85
|
+
@authorization_codes.reject! { |_, data| data[:expires_at] < current_time }
|
86
|
+
@access_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
87
|
+
@refresh_tokens.reject! { |_, data| data[:expires_at] < current_time }
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
# Statistics (for debugging/monitoring)
|
92
|
+
def stats
|
93
|
+
@mutex.synchronize do
|
94
|
+
{
|
95
|
+
authorization_codes: @authorization_codes.size,
|
96
|
+
access_tokens: @access_tokens.size,
|
97
|
+
refresh_tokens: @refresh_tokens.size
|
98
|
+
}
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Clear all data (for testing)
|
103
|
+
def clear_all
|
104
|
+
@mutex.synchronize do
|
105
|
+
@authorization_codes.clear
|
106
|
+
@access_tokens.clear
|
107
|
+
@refresh_tokens.clear
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module ActionMCP
|
4
|
+
module OAuth
|
5
|
+
# OAuth middleware that integrates with Omniauth for request authentication
|
6
|
+
# Handles Bearer token validation for API requests
|
7
|
+
class Middleware
|
8
|
+
def initialize(app)
|
9
|
+
@app = app
|
10
|
+
end
|
11
|
+
|
12
|
+
def call(env)
|
13
|
+
request = ActionDispatch::Request.new(env)
|
14
|
+
|
15
|
+
# Skip OAuth processing for non-MCP requests or if OAuth not configured
|
16
|
+
return @app.call(env) unless should_process_oauth?(request)
|
17
|
+
|
18
|
+
|
19
|
+
# Validate Bearer token for API requests
|
20
|
+
if bearer_token = extract_bearer_token(request)
|
21
|
+
validate_oauth_token(request, bearer_token)
|
22
|
+
end
|
23
|
+
|
24
|
+
@app.call(env)
|
25
|
+
rescue ActionMCP::OAuth::Error => e
|
26
|
+
oauth_error_response(e)
|
27
|
+
end
|
28
|
+
|
29
|
+
private
|
30
|
+
|
31
|
+
def should_process_oauth?(request)
|
32
|
+
# Check if OAuth is enabled in configuration
|
33
|
+
auth_methods = ActionMCP.configuration.authentication_methods
|
34
|
+
return false unless auth_methods&.include?("oauth")
|
35
|
+
|
36
|
+
# Process all MCP requests (ActionMCP serves at root "/") and OAuth-related paths
|
37
|
+
true
|
38
|
+
end
|
39
|
+
|
40
|
+
|
41
|
+
def extract_bearer_token(request)
|
42
|
+
auth_header = request.headers["Authorization"] || request.headers["authorization"]
|
43
|
+
return nil unless auth_header&.start_with?("Bearer ")
|
44
|
+
|
45
|
+
auth_header.split(" ", 2).last
|
46
|
+
end
|
47
|
+
|
48
|
+
def validate_oauth_token(request, token)
|
49
|
+
# Use the OAuth provider for token introspection
|
50
|
+
token_info = ActionMCP::OAuth::Provider.introspect_token(token)
|
51
|
+
|
52
|
+
if token_info && token_info[:active]
|
53
|
+
# Store OAuth token info in request environment for Gateway
|
54
|
+
request.env["action_mcp.oauth_token_info"] = token_info
|
55
|
+
request.env["action_mcp.oauth_token"] = token
|
56
|
+
else
|
57
|
+
raise ActionMCP::OAuth::InvalidTokenError, "Invalid or expired OAuth token"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def oauth_error_response(error)
|
62
|
+
status = case error
|
63
|
+
when ActionMCP::OAuth::InvalidTokenError
|
64
|
+
401
|
65
|
+
when ActionMCP::OAuth::InsufficientScopeError
|
66
|
+
403
|
67
|
+
else
|
68
|
+
400
|
69
|
+
end
|
70
|
+
|
71
|
+
headers = {
|
72
|
+
"Content-Type" => "application/json",
|
73
|
+
"WWW-Authenticate" => www_authenticate_header(error)
|
74
|
+
}
|
75
|
+
|
76
|
+
body = {
|
77
|
+
error: error.oauth_error_code,
|
78
|
+
error_description: error.message
|
79
|
+
}.to_json
|
80
|
+
|
81
|
+
[ status, headers, [ body ] ]
|
82
|
+
end
|
83
|
+
|
84
|
+
def www_authenticate_header(error)
|
85
|
+
params = []
|
86
|
+
params << 'realm="MCP API"'
|
87
|
+
|
88
|
+
case error
|
89
|
+
when ActionMCP::OAuth::InvalidTokenError
|
90
|
+
params << 'error="invalid_token"'
|
91
|
+
when ActionMCP::OAuth::InsufficientScopeError
|
92
|
+
params << 'error="insufficient_scope"'
|
93
|
+
params << "scope=\"#{error.required_scope}\"" if error.required_scope
|
94
|
+
end
|
95
|
+
|
96
|
+
"Bearer #{params.join(', ')}"
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,390 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "securerandom"
|
4
|
+
require "digest"
|
5
|
+
require "base64"
|
6
|
+
|
7
|
+
module ActionMCP
|
8
|
+
module OAuth
|
9
|
+
# OAuth 2.1 Provider implementation
|
10
|
+
# Handles authorization codes, access tokens, refresh tokens, and token validation
|
11
|
+
class Provider
|
12
|
+
class << self
|
13
|
+
# Generate authorization code for OAuth flow
|
14
|
+
# @param client_id [String] OAuth client identifier
|
15
|
+
# @param redirect_uri [String] Client redirect URI
|
16
|
+
# @param scope [String] Requested scope
|
17
|
+
# @param code_challenge [String] PKCE code challenge
|
18
|
+
# @param code_challenge_method [String] PKCE challenge method (S256, plain)
|
19
|
+
# @param user_id [String] User identifier
|
20
|
+
# @return [String] Authorization code
|
21
|
+
def generate_authorization_code(client_id:, redirect_uri:, scope:, code_challenge: nil, code_challenge_method: nil, user_id:)
|
22
|
+
# Validate scope
|
23
|
+
validate_scope(scope) if scope
|
24
|
+
|
25
|
+
code = SecureRandom.urlsafe_base64(32)
|
26
|
+
|
27
|
+
# Store authorization code with metadata
|
28
|
+
store_authorization_code(code, {
|
29
|
+
client_id: client_id,
|
30
|
+
redirect_uri: redirect_uri,
|
31
|
+
scope: scope,
|
32
|
+
code_challenge: code_challenge,
|
33
|
+
code_challenge_method: code_challenge_method,
|
34
|
+
user_id: user_id,
|
35
|
+
created_at: Time.current,
|
36
|
+
expires_at: 10.minutes.from_now
|
37
|
+
})
|
38
|
+
|
39
|
+
code
|
40
|
+
end
|
41
|
+
|
42
|
+
# Exchange authorization code for access token
|
43
|
+
# @param code [String] Authorization code
|
44
|
+
# @param client_id [String] OAuth client identifier
|
45
|
+
# @param client_secret [String] OAuth client secret (optional for public clients)
|
46
|
+
# @param redirect_uri [String] Client redirect URI
|
47
|
+
# @param code_verifier [String] PKCE code verifier
|
48
|
+
# @return [Hash] Token response with access_token, token_type, expires_in, scope
|
49
|
+
def exchange_code_for_token(code:, client_id:, client_secret: nil, redirect_uri:, code_verifier: nil)
|
50
|
+
# Retrieve and validate authorization code
|
51
|
+
code_data = retrieve_authorization_code(code)
|
52
|
+
raise InvalidGrantError, "Invalid authorization code" unless code_data
|
53
|
+
raise InvalidGrantError, "Authorization code expired" if code_data[:expires_at] < Time.current
|
54
|
+
|
55
|
+
# Validate client
|
56
|
+
validate_client(client_id, client_secret)
|
57
|
+
|
58
|
+
# Validate redirect URI matches
|
59
|
+
unless code_data[:redirect_uri] == redirect_uri
|
60
|
+
raise InvalidGrantError, "Redirect URI mismatch"
|
61
|
+
end
|
62
|
+
|
63
|
+
# Validate client ID matches
|
64
|
+
unless code_data[:client_id] == client_id
|
65
|
+
raise InvalidGrantError, "Client ID mismatch"
|
66
|
+
end
|
67
|
+
|
68
|
+
# Validate PKCE if challenge was provided during authorization
|
69
|
+
if code_data[:code_challenge]
|
70
|
+
validate_pkce(code_data[:code_challenge], code_data[:code_challenge_method], code_verifier)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Generate access token
|
74
|
+
access_token = generate_access_token(
|
75
|
+
client_id: client_id,
|
76
|
+
scope: code_data[:scope],
|
77
|
+
user_id: code_data[:user_id]
|
78
|
+
)
|
79
|
+
|
80
|
+
# Generate refresh token if enabled
|
81
|
+
refresh_token = nil
|
82
|
+
if oauth_config["enable_refresh_tokens"]
|
83
|
+
refresh_token = generate_refresh_token(
|
84
|
+
client_id: client_id,
|
85
|
+
scope: code_data[:scope],
|
86
|
+
user_id: code_data[:user_id],
|
87
|
+
access_token: access_token
|
88
|
+
)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Remove used authorization code
|
92
|
+
remove_authorization_code(code)
|
93
|
+
|
94
|
+
# Return token response
|
95
|
+
response = {
|
96
|
+
access_token: access_token,
|
97
|
+
token_type: "Bearer",
|
98
|
+
expires_in: token_expires_in,
|
99
|
+
scope: code_data[:scope]
|
100
|
+
}
|
101
|
+
response[:refresh_token] = refresh_token if refresh_token
|
102
|
+
response
|
103
|
+
end
|
104
|
+
|
105
|
+
# Refresh access token using refresh token
|
106
|
+
# @param refresh_token [String] Refresh token
|
107
|
+
# @param client_id [String] OAuth client identifier
|
108
|
+
# @param client_secret [String] OAuth client secret
|
109
|
+
# @param scope [String] Requested scope (optional, must be subset of original)
|
110
|
+
# @return [Hash] New token response
|
111
|
+
def refresh_access_token(refresh_token:, client_id:, client_secret: nil, scope: nil)
|
112
|
+
# Retrieve refresh token data
|
113
|
+
token_data = retrieve_refresh_token(refresh_token)
|
114
|
+
raise InvalidGrantError, "Invalid refresh token" unless token_data
|
115
|
+
raise InvalidGrantError, "Refresh token expired" if token_data[:expires_at] < Time.current
|
116
|
+
|
117
|
+
# Validate client
|
118
|
+
validate_client(client_id, client_secret)
|
119
|
+
|
120
|
+
# Validate client ID matches
|
121
|
+
unless token_data[:client_id] == client_id
|
122
|
+
raise InvalidGrantError, "Client ID mismatch"
|
123
|
+
end
|
124
|
+
|
125
|
+
# Validate scope if provided
|
126
|
+
if scope
|
127
|
+
requested_scopes = scope.split(" ")
|
128
|
+
original_scopes = token_data[:scope].split(" ")
|
129
|
+
unless (requested_scopes - original_scopes).empty?
|
130
|
+
raise InvalidScopeError, "Requested scope exceeds original scope"
|
131
|
+
end
|
132
|
+
else
|
133
|
+
scope = token_data[:scope]
|
134
|
+
end
|
135
|
+
|
136
|
+
# Revoke old access token
|
137
|
+
revoke_access_token(token_data[:access_token]) if token_data[:access_token]
|
138
|
+
|
139
|
+
# Generate new access token
|
140
|
+
access_token = generate_access_token(
|
141
|
+
client_id: client_id,
|
142
|
+
scope: scope,
|
143
|
+
user_id: token_data[:user_id]
|
144
|
+
)
|
145
|
+
|
146
|
+
# Update refresh token with new access token
|
147
|
+
update_refresh_token(refresh_token, access_token)
|
148
|
+
|
149
|
+
{
|
150
|
+
access_token: access_token,
|
151
|
+
token_type: "Bearer",
|
152
|
+
expires_in: token_expires_in,
|
153
|
+
scope: scope
|
154
|
+
}
|
155
|
+
end
|
156
|
+
|
157
|
+
# Validate access token and return token info
|
158
|
+
# @param access_token [String] Access token to validate
|
159
|
+
# @return [Hash] Token info with active, client_id, scope, user_id, exp
|
160
|
+
def introspect_token(access_token)
|
161
|
+
token_data = retrieve_access_token(access_token)
|
162
|
+
|
163
|
+
unless token_data
|
164
|
+
return { active: false }
|
165
|
+
end
|
166
|
+
|
167
|
+
if token_data[:expires_at] < Time.current
|
168
|
+
remove_access_token(access_token)
|
169
|
+
return { active: false }
|
170
|
+
end
|
171
|
+
|
172
|
+
{
|
173
|
+
active: true,
|
174
|
+
client_id: token_data[:client_id],
|
175
|
+
scope: token_data[:scope],
|
176
|
+
user_id: token_data[:user_id],
|
177
|
+
exp: token_data[:expires_at].to_i,
|
178
|
+
iat: token_data[:created_at].to_i,
|
179
|
+
token_type: "Bearer"
|
180
|
+
}
|
181
|
+
end
|
182
|
+
|
183
|
+
# Revoke access or refresh token
|
184
|
+
# @param token [String] Token to revoke
|
185
|
+
# @param token_type_hint [String] Type hint: "access_token" or "refresh_token"
|
186
|
+
# @return [Boolean] True if token was revoked
|
187
|
+
def revoke_token(token, token_type_hint: nil)
|
188
|
+
revoked = false
|
189
|
+
|
190
|
+
# Try access token first if hint suggests it or no hint provided
|
191
|
+
if token_type_hint == "access_token" || token_type_hint.nil?
|
192
|
+
if retrieve_access_token(token)
|
193
|
+
revoke_access_token(token)
|
194
|
+
revoked = true
|
195
|
+
end
|
196
|
+
end
|
197
|
+
|
198
|
+
# Try refresh token if not revoked yet
|
199
|
+
if !revoked && (token_type_hint == "refresh_token" || token_type_hint.nil?)
|
200
|
+
if retrieve_refresh_token(token)
|
201
|
+
revoke_refresh_token(token)
|
202
|
+
revoked = true
|
203
|
+
end
|
204
|
+
end
|
205
|
+
|
206
|
+
revoked
|
207
|
+
end
|
208
|
+
|
209
|
+
# Client Credentials Grant (for server-to-server)
|
210
|
+
# @param client_id [String] OAuth client identifier
|
211
|
+
# @param client_secret [String] OAuth client secret
|
212
|
+
# @param scope [String] Requested scope
|
213
|
+
# @return [Hash] Token response
|
214
|
+
def client_credentials_grant(client_id:, client_secret:, scope: nil)
|
215
|
+
unless oauth_config["enable_client_credentials"]
|
216
|
+
raise UnsupportedGrantTypeError, "Client credentials grant not supported"
|
217
|
+
end
|
218
|
+
|
219
|
+
# Validate client credentials
|
220
|
+
validate_client(client_id, client_secret, require_secret: true)
|
221
|
+
|
222
|
+
# Validate scope
|
223
|
+
if scope
|
224
|
+
validate_scope(scope)
|
225
|
+
else
|
226
|
+
scope = default_scope
|
227
|
+
end
|
228
|
+
|
229
|
+
# Generate access token (no user context for client credentials)
|
230
|
+
access_token = generate_access_token(
|
231
|
+
client_id: client_id,
|
232
|
+
scope: scope,
|
233
|
+
user_id: nil
|
234
|
+
)
|
235
|
+
|
236
|
+
{
|
237
|
+
access_token: access_token,
|
238
|
+
token_type: "Bearer",
|
239
|
+
expires_in: token_expires_in,
|
240
|
+
scope: scope
|
241
|
+
}
|
242
|
+
end
|
243
|
+
|
244
|
+
private
|
245
|
+
|
246
|
+
def oauth_config
|
247
|
+
@oauth_config ||= ActionMCP.configuration.oauth_config || {}
|
248
|
+
end
|
249
|
+
|
250
|
+
def validate_client(client_id, client_secret, require_secret: false)
|
251
|
+
# This should be implemented by the application
|
252
|
+
# For now, we'll use a simple validation approach
|
253
|
+
provider_class = oauth_config["provider"]
|
254
|
+
if provider_class && provider_class.respond_to?(:validate_client)
|
255
|
+
provider_class.validate_client(client_id, client_secret)
|
256
|
+
elsif require_secret && client_secret.nil?
|
257
|
+
raise InvalidClientError, "Client authentication required"
|
258
|
+
end
|
259
|
+
# Default: allow any client for development
|
260
|
+
end
|
261
|
+
|
262
|
+
def validate_pkce(code_challenge, method, code_verifier)
|
263
|
+
raise InvalidGrantError, "Code verifier required" unless code_verifier
|
264
|
+
|
265
|
+
case method
|
266
|
+
when "S256"
|
267
|
+
expected_challenge = Base64.urlsafe_encode64(
|
268
|
+
Digest::SHA256.digest(code_verifier), padding: false
|
269
|
+
)
|
270
|
+
unless code_challenge == expected_challenge
|
271
|
+
raise InvalidGrantError, "Invalid code verifier"
|
272
|
+
end
|
273
|
+
when "plain"
|
274
|
+
unless oauth_config["allow_plain_pkce"]
|
275
|
+
raise InvalidGrantError, "Plain PKCE not allowed"
|
276
|
+
end
|
277
|
+
unless code_challenge == code_verifier
|
278
|
+
raise InvalidGrantError, "Invalid code verifier"
|
279
|
+
end
|
280
|
+
else
|
281
|
+
raise InvalidGrantError, "Unsupported code challenge method"
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def validate_scope(scope)
|
286
|
+
supported_scopes = oauth_config["scopes_supported"] || [ "mcp:tools", "mcp:resources", "mcp:prompts" ]
|
287
|
+
requested_scopes = scope.split(" ")
|
288
|
+
unsupported = requested_scopes - supported_scopes
|
289
|
+
if unsupported.any?
|
290
|
+
raise InvalidScopeError, "Unsupported scopes: #{unsupported.join(', ')}"
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def default_scope
|
295
|
+
oauth_config["default_scope"] || "mcp:tools mcp:resources mcp:prompts"
|
296
|
+
end
|
297
|
+
|
298
|
+
def generate_access_token(client_id:, scope:, user_id:)
|
299
|
+
token = SecureRandom.urlsafe_base64(32)
|
300
|
+
|
301
|
+
store_access_token(token, {
|
302
|
+
client_id: client_id,
|
303
|
+
scope: scope,
|
304
|
+
user_id: user_id,
|
305
|
+
created_at: Time.current,
|
306
|
+
expires_at: token_expires_in.seconds.from_now
|
307
|
+
})
|
308
|
+
|
309
|
+
token
|
310
|
+
end
|
311
|
+
|
312
|
+
def generate_refresh_token(client_id:, scope:, user_id:, access_token:)
|
313
|
+
token = SecureRandom.urlsafe_base64(32)
|
314
|
+
|
315
|
+
store_refresh_token(token, {
|
316
|
+
client_id: client_id,
|
317
|
+
scope: scope,
|
318
|
+
user_id: user_id,
|
319
|
+
access_token: access_token,
|
320
|
+
created_at: Time.current,
|
321
|
+
expires_at: refresh_token_expires_in.seconds.from_now
|
322
|
+
})
|
323
|
+
|
324
|
+
token
|
325
|
+
end
|
326
|
+
|
327
|
+
def token_expires_in
|
328
|
+
oauth_config["access_token_expires_in"] || 3600 # 1 hour
|
329
|
+
end
|
330
|
+
|
331
|
+
def refresh_token_expires_in
|
332
|
+
oauth_config["refresh_token_expires_in"] || 7.days.to_i # 1 week
|
333
|
+
end
|
334
|
+
|
335
|
+
# Storage methods - these delegate to a configurable storage backend
|
336
|
+
def storage
|
337
|
+
@storage ||= begin
|
338
|
+
storage_class = oauth_config["storage"] || "ActionMCP::OAuth::MemoryStorage"
|
339
|
+
storage_class = storage_class.constantize if storage_class.is_a?(String)
|
340
|
+
storage_class.new
|
341
|
+
end
|
342
|
+
end
|
343
|
+
|
344
|
+
def store_authorization_code(code, data)
|
345
|
+
storage.store_authorization_code(code, data)
|
346
|
+
end
|
347
|
+
|
348
|
+
def retrieve_authorization_code(code)
|
349
|
+
storage.retrieve_authorization_code(code)
|
350
|
+
end
|
351
|
+
|
352
|
+
def remove_authorization_code(code)
|
353
|
+
storage.remove_authorization_code(code)
|
354
|
+
end
|
355
|
+
|
356
|
+
def store_access_token(token, data)
|
357
|
+
storage.store_access_token(token, data)
|
358
|
+
end
|
359
|
+
|
360
|
+
def retrieve_access_token(token)
|
361
|
+
storage.retrieve_access_token(token)
|
362
|
+
end
|
363
|
+
|
364
|
+
def remove_access_token(token)
|
365
|
+
storage.remove_access_token(token)
|
366
|
+
end
|
367
|
+
|
368
|
+
def revoke_access_token(token)
|
369
|
+
storage.remove_access_token(token)
|
370
|
+
end
|
371
|
+
|
372
|
+
def store_refresh_token(token, data)
|
373
|
+
storage.store_refresh_token(token, data)
|
374
|
+
end
|
375
|
+
|
376
|
+
def retrieve_refresh_token(token)
|
377
|
+
storage.retrieve_refresh_token(token)
|
378
|
+
end
|
379
|
+
|
380
|
+
def update_refresh_token(token, new_access_token)
|
381
|
+
storage.update_refresh_token(token, new_access_token)
|
382
|
+
end
|
383
|
+
|
384
|
+
def revoke_refresh_token(token)
|
385
|
+
storage.remove_refresh_token(token)
|
386
|
+
end
|
387
|
+
end
|
388
|
+
end
|
389
|
+
end
|
390
|
+
end
|