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,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ class AuthorizationCode < ActiveRecord::Base
6
+ self.table_name = "mcp_auth_authorization_codes"
7
+
8
+ belongs_to :user
9
+ belongs_to :org, optional: true
10
+ belongs_to :oauth_client,
11
+ class_name: "Mcp::Auth::OauthClient",
12
+ foreign_key: :client_id,
13
+ primary_key: :client_id,
14
+ optional: true
15
+
16
+ validates :code, presence: true, uniqueness: true
17
+ validates :client_id, presence: true
18
+ validates :redirect_uri, presence: true
19
+ validates :expires_at, presence: true
20
+
21
+ scope :active, -> { where('expires_at > ?', Time.current) }
22
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
23
+
24
+ def expired?
25
+ expires_at <= Time.current
26
+ end
27
+
28
+ def self.cleanup_expired
29
+ expired.delete_all
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ class OauthClient < ActiveRecord::Base
6
+ self.table_name = "mcp_auth_oauth_clients"
7
+ self.primary_key = "client_id"
8
+
9
+ # Set defaults BEFORE validation
10
+ before_validation :set_defaults, on: :create
11
+
12
+ validates :client_id, presence: true, uniqueness: true
13
+ validates :client_secret, presence: true
14
+
15
+ serialize :redirect_uris, coder: JSON
16
+ serialize :grant_types, coder: JSON
17
+ serialize :response_types, coder: JSON
18
+
19
+ has_many :authorization_codes,
20
+ class_name: "Mcp::Auth::AuthorizationCode",
21
+ foreign_key: :client_id,
22
+ primary_key: :client_id,
23
+ dependent: :destroy
24
+
25
+ has_many :access_tokens,
26
+ class_name: "Mcp::Auth::AccessToken",
27
+ foreign_key: :client_id,
28
+ primary_key: :client_id,
29
+ dependent: :destroy
30
+
31
+ has_many :refresh_tokens,
32
+ class_name: "Mcp::Auth::RefreshToken",
33
+ foreign_key: :client_id,
34
+ primary_key: :client_id,
35
+ dependent: :destroy
36
+
37
+ def self.find_by_client_id(client_id)
38
+ find_by(client_id: client_id)
39
+ end
40
+
41
+ def valid_redirect_uri?(uri)
42
+ redirect_uris&.include?(uri)
43
+ end
44
+
45
+ def supports_grant_type?(grant_type)
46
+ grant_types&.include?(grant_type)
47
+ end
48
+
49
+ private
50
+
51
+ def set_defaults
52
+ self.client_id ||= SecureRandom.uuid
53
+ self.client_secret ||= SecureRandom.hex(32)
54
+ self.grant_types ||= %w[authorization_code refresh_token]
55
+ self.response_types ||= %w[code]
56
+ self.scope ||= Mcp::Auth::ScopeRegistry.default_scope_string
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ class RefreshToken < ActiveRecord::Base
6
+ self.table_name = "mcp_auth_refresh_tokens"
7
+
8
+ belongs_to :user
9
+ belongs_to :org, optional: true
10
+ belongs_to :oauth_client,
11
+ class_name: "Mcp::Auth::OauthClient",
12
+ foreign_key: :client_id,
13
+ primary_key: :client_id,
14
+ optional: true
15
+
16
+ validates :token, presence: true, uniqueness: true
17
+ validates :client_id, presence: true
18
+ validates :expires_at, presence: true
19
+
20
+ scope :active, -> { where('expires_at > ?', Time.current) }
21
+ scope :expired, -> { where('expires_at <= ?', Time.current) }
22
+
23
+ def expired?
24
+ expires_at <= Time.current
25
+ end
26
+
27
+ def self.cleanup_expired
28
+ expired.delete_all
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,527 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>Authorization Request</title>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <%= csrf_meta_tags %>
7
+
8
+ <style>
9
+ * {
10
+ margin: 0;
11
+ padding: 0;
12
+ box-sizing: border-box;
13
+ }
14
+
15
+ body {
16
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
17
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
18
+ min-height: 100vh;
19
+ display: flex;
20
+ align-items: center;
21
+ justify-content: center;
22
+ padding: 20px;
23
+ }
24
+
25
+ .container {
26
+ background: white;
27
+ border-radius: 12px;
28
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
29
+ max-width: 480px;
30
+ width: 100%;
31
+ overflow: hidden;
32
+ }
33
+
34
+ .header {
35
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
36
+ color: white;
37
+ padding: 30px;
38
+ text-align: center;
39
+ }
40
+
41
+ .header h1 {
42
+ font-size: 24px;
43
+ font-weight: 600;
44
+ margin-bottom: 8px;
45
+ }
46
+
47
+ .header p {
48
+ font-size: 14px;
49
+ opacity: 0.9;
50
+ }
51
+
52
+ .content {
53
+ padding: 30px;
54
+ }
55
+
56
+ .client-info {
57
+ background: #f7fafc;
58
+ border-radius: 8px;
59
+ padding: 20px;
60
+ margin-bottom: 24px;
61
+ border-left: 4px solid #667eea;
62
+ }
63
+
64
+ .client-info strong {
65
+ display: block;
66
+ color: #2d3748;
67
+ font-size: 16px;
68
+ margin-bottom: 4px;
69
+ }
70
+
71
+ .client-info p {
72
+ color: #718096;
73
+ font-size: 14px;
74
+ }
75
+
76
+ .scopes {
77
+ margin-bottom: 24px;
78
+ }
79
+
80
+ .scopes h3 {
81
+ color: #2d3748;
82
+ font-size: 16px;
83
+ margin-bottom: 16px;
84
+ font-weight: 600;
85
+ }
86
+
87
+ .scope-item {
88
+ display: flex;
89
+ align-items: flex-start;
90
+ padding: 14px 16px;
91
+ margin-bottom: 10px;
92
+ background: #f7fafc;
93
+ border: 1px solid #e2e8f0;
94
+ border-radius: 8px;
95
+ transition: all 0.2s;
96
+ cursor: pointer;
97
+ }
98
+
99
+ .scope-item:hover:not(.required) {
100
+ background: #edf2f7;
101
+ border-color: #667eea;
102
+ }
103
+
104
+ .scope-item.required {
105
+ border-left: 3px solid #f59e0b;
106
+ cursor: default;
107
+ }
108
+
109
+ .scope-item.selected {
110
+ background: #f0f4ff;
111
+ border-color: #667eea;
112
+ }
113
+
114
+ .scope-checkbox {
115
+ flex-shrink: 0;
116
+ width: 20px;
117
+ height: 20px;
118
+ margin-right: 12px;
119
+ margin-top: 2px;
120
+ cursor: pointer;
121
+ accent-color: #667eea;
122
+ }
123
+
124
+ .scope-checkbox:disabled {
125
+ cursor: not-allowed;
126
+ opacity: 0.5;
127
+ }
128
+
129
+ .scope-content {
130
+ flex: 1;
131
+ }
132
+
133
+ .scope-header {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ margin-bottom: 4px;
138
+ }
139
+
140
+ .scope-name {
141
+ color: #2d3748;
142
+ font-size: 14px;
143
+ font-weight: 600;
144
+ }
145
+
146
+ .scope-badge {
147
+ font-size: 10px;
148
+ background: #f59e0b;
149
+ color: white;
150
+ padding: 3px 8px;
151
+ border-radius: 12px;
152
+ text-transform: uppercase;
153
+ font-weight: 600;
154
+ letter-spacing: 1px;
155
+ }
156
+
157
+ .scope-description {
158
+ color: #718096;
159
+ font-size: 13px;
160
+ line-height: 1.5;
161
+ }
162
+
163
+ .warning-box {
164
+ background: #fef3c7;
165
+ border: 1px solid #fbbf24;
166
+ border-radius: 8px;
167
+ padding: 14px;
168
+ margin-bottom: 24px;
169
+ display: flex;
170
+ align-items: start;
171
+ gap: 10px;
172
+ }
173
+
174
+ .warning-icon {
175
+ color: #f59e0b;
176
+ font-size: 18px;
177
+ flex-shrink: 0;
178
+ }
179
+
180
+ .warning-text {
181
+ font-size: 13px;
182
+ color: #78350f;
183
+ line-height: 1.5;
184
+ }
185
+
186
+ .select-all-container {
187
+ margin-bottom: 16px;
188
+ padding: 12px;
189
+ background: #f0fdf4;
190
+ border: 1px solid #86efac;
191
+ border-radius: 8px;
192
+ display: flex;
193
+ align-items: center;
194
+ gap: 10px;
195
+ }
196
+
197
+ .select-all-checkbox {
198
+ width: 18px;
199
+ height: 18px;
200
+ cursor: pointer;
201
+ accent-color: #667eea;
202
+ }
203
+
204
+ .select-all-label {
205
+ color: #166534;
206
+ font-size: 14px;
207
+ font-weight: 500;
208
+ cursor: pointer;
209
+ user-select: none;
210
+ }
211
+
212
+ .actions {
213
+ display: flex;
214
+ gap: 12px;
215
+ margin-top: 24px;
216
+ }
217
+
218
+ button {
219
+ flex: 1;
220
+ padding: 14px 24px;
221
+ border: none;
222
+ border-radius: 6px;
223
+ font-size: 15px;
224
+ font-weight: 600;
225
+ cursor: pointer;
226
+ transition: all 0.2s;
227
+ text-transform: none;
228
+ }
229
+
230
+ button:disabled {
231
+ opacity: 0.5;
232
+ cursor: not-allowed;
233
+ }
234
+
235
+ .btn-approve {
236
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
237
+ color: white;
238
+ }
239
+
240
+ .btn-approve:hover:not(:disabled) {
241
+ transform: translateY(-2px);
242
+ box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
243
+ }
244
+
245
+ .btn-deny {
246
+ background: #e2e8f0;
247
+ color: #4a5568;
248
+ }
249
+
250
+ .btn-deny:hover {
251
+ background: #cbd5e0;
252
+ }
253
+
254
+ .security-note {
255
+ margin-top: 24px;
256
+ padding: 16px;
257
+ background: #f0fdf4;
258
+ border: 1px solid #86efac;
259
+ border-radius: 6px;
260
+ font-size: 13px;
261
+ color: #166534;
262
+ line-height: 1.5;
263
+ }
264
+
265
+ .security-note strong {
266
+ display: block;
267
+ margin-bottom: 4px;
268
+ }
269
+
270
+ .selected-count {
271
+ margin-top: 16px;
272
+ padding: 10px;
273
+ background: #eff6ff;
274
+ border: 1px solid #93c5fd;
275
+ border-radius: 6px;
276
+ text-align: center;
277
+ font-size: 13px;
278
+ color: #1e40af;
279
+ }
280
+
281
+ @media (max-width: 600px) {
282
+ .container { margin: 10px; }
283
+ .header { padding: 24px; }
284
+ .content { padding: 24px; }
285
+ .actions { flex-direction: column-reverse; }
286
+ }
287
+ </style>
288
+ </head>
289
+ <body>
290
+ <div class="container">
291
+ <div class="header">
292
+ <h1>🔐 Authorization Request</h1>
293
+ <p>Review and select the permissions you want to grant</p>
294
+ </div>
295
+
296
+ <div class="content">
297
+ <div class="client-info">
298
+ <strong><%= @client_name %></strong>
299
+ <p>wants to access your MCP server</p>
300
+ </div>
301
+
302
+ <% if @requested_scopes.any? { |s| s[:required] } %>
303
+ <div class="warning-box">
304
+ <div class="warning-icon">⚠️</div>
305
+ <div class="warning-text">
306
+ Some permissions are required for the application to function properly and cannot be deselected.
307
+ </div>
308
+ </div>
309
+ <% end %>
310
+
311
+ <div class="scopes">
312
+ <h3>Select permissions to grant:</h3>
313
+
314
+ <% optional_scopes = @requested_scopes.reject { |s| s[:required] } %>
315
+ <% if optional_scopes.any? %>
316
+ <div class="select-all-container">
317
+ <input type="checkbox" id="selectAll" class="select-all-checkbox" checked>
318
+ <label for="selectAll" class="select-all-label">Select all optional permissions</label>
319
+ </div>
320
+ <% end %>
321
+
322
+ <%= form_with url: oauth_approve_path, method: :post, local: true, id: 'consentForm' do |f| %>
323
+ <% @authorization_params.each do |key, value| %>
324
+ <%= f.hidden_field key, value: value, id: nil %>
325
+ <% end %>
326
+
327
+ <!-- Add hidden fields for required scopes since disabled checkboxes don't submit-->
328
+ <% @requested_scopes.select { |s| s[:required] }.each do |scope| %>
329
+ <%= hidden_field_tag 'scopes[]', scope[:key] %>
330
+ <% end %>
331
+
332
+ <% @requested_scopes.each_with_index do |scope, index| %>
333
+ <div class="scope-item <%= 'required selected' if scope[:required] %> <%= 'selected' if scope[:pre_selected] && !scope[:required] %>"
334
+ data-scope-key="<%= scope[:key] %>"
335
+ data-required="<%= scope[:required] %>"
336
+ data-pre-selected="<%= scope[:pre_selected] %>">
337
+ <% if scope[:required] %>
338
+ <!-- Required scope: show disabled checkbox for UI, value submitted via hidden field abov-->
339
+ <input type="checkbox"
340
+ class="scope-checkbox"
341
+ id="scope_<%= index %>"
342
+ checked="checked"
343
+ disabled="disabled" />
344
+ <% else %>
345
+ <!-- Optional scope: checkbox value will be submitted, JS will handle pre-selection-->
346
+ <input type="checkbox"
347
+ name="scopes[]"
348
+ value="<%= scope[:key] %>"
349
+ class="scope-checkbox"
350
+ id="scope_<%= index %>" />
351
+ <% end %>
352
+ <div class="scope-content">
353
+ <div class="scope-header">
354
+ <label for="scope_<%= index %>" class="scope-name"><%= scope[:name] %></label>
355
+ <% if scope[:required] %>
356
+ <span class="scope-badge">Required</span>
357
+ <% end %>
358
+ </div>
359
+ <div class="scope-description">
360
+ <%= scope[:description] %>
361
+ </div>
362
+ </div>
363
+ </div>
364
+ <% end %>
365
+
366
+ <div class="selected-count" id="selectedCount">
367
+ <span id="countText"></span>
368
+ </div>
369
+
370
+ <div class="actions">
371
+ <%= f.button 'Deny', type: 'button', class: 'btn-deny', onclick: 'denyAccess()' %>
372
+ <%= f.button 'Authorize', type: 'submit', name: 'approved', value: 'true', class: 'btn-approve', id: 'approveBtn' %>
373
+ </div>
374
+ <% end %>
375
+
376
+ <%= form_with url: oauth_approve_path, method: :post, local: true, id: 'denyForm' do |f| %>
377
+ <% @authorization_params.each do |key, value| %>
378
+ <%= f.hidden_field key, value: value, id: nil %>
379
+ <% end %>
380
+ <%= f.hidden_field :approved, value: 'false' %>
381
+ <% end %>
382
+ </div>
383
+
384
+ <div class="security-note">
385
+ <strong>Security Notice</strong>
386
+ You can select which permissions to grant. Only selected permissions will be authorized. Required permissions are pre-selected and cannot be changed. You can revoke access at any time from your account settings.
387
+ </div>
388
+ </div>
389
+ </div>
390
+
391
+ <script>
392
+ // Handle clicking on scope item (not checkbox)
393
+ document.addEventListener('DOMContentLoaded', function() {
394
+ // Pre-select checkboxes based on data-pre-selected attribute
395
+ const scopeItems = document.querySelectorAll('.scope-item');
396
+ scopeItems.forEach(item => {
397
+ const preSelected = item.getAttribute('data-pre-selected') === 'true';
398
+ const checkbox = item.querySelector('.scope-checkbox');
399
+
400
+ if (checkbox && !checkbox.disabled && preSelected) {
401
+ checkbox.checked = true;
402
+ item.classList.add('selected');
403
+ }
404
+ });
405
+
406
+ // Handle scope item clicks
407
+ scopeItems.forEach(item => {
408
+ item.addEventListener('click', function(e) {
409
+ // Don't toggle if clicking directly on checkbox or label
410
+ if (e.target.type === 'checkbox' || e.target.tagName === 'LABEL') {
411
+ return;
412
+ }
413
+
414
+ const checkbox = this.querySelector('.scope-checkbox');
415
+ if (checkbox && !checkbox.disabled) {
416
+ checkbox.checked = !checkbox.checked;
417
+ updateScopeItem(this, checkbox.checked);
418
+ updateSelectAll();
419
+ updateSelectedCount();
420
+ }
421
+ });
422
+ });
423
+
424
+ // Handle checkbox changes
425
+ const checkboxes = document.querySelectorAll('.scope-checkbox');
426
+ checkboxes.forEach(checkbox => {
427
+ checkbox.addEventListener('change', function() {
428
+ const scopeItem = this.closest('.scope-item');
429
+ updateScopeItem(scopeItem, this.checked);
430
+ updateSelectAll();
431
+ updateSelectedCount();
432
+ });
433
+ });
434
+
435
+ // Select all functionality
436
+ const selectAllCheckbox = document.getElementById('selectAll');
437
+ if (selectAllCheckbox) {
438
+ selectAllCheckbox.addEventListener('change', function() {
439
+ const optionalCheckboxes = document.querySelectorAll('.scope-checkbox:not(:disabled)');
440
+ optionalCheckboxes.forEach(checkbox => {
441
+ checkbox.checked = this.checked;
442
+ const scopeItem = checkbox.closest('.scope-item');
443
+ updateScopeItem(scopeItem, checkbox.checked);
444
+ });
445
+ updateSelectedCount();
446
+ });
447
+ }
448
+
449
+ // Initialize
450
+ updateSelectAll();
451
+ updateSelectedCount();
452
+ });
453
+
454
+ // Update visual state of scope item
455
+ function updateScopeItem(element, checked) {
456
+ if (checked) {
457
+ element.classList.add('selected');
458
+ } else {
459
+ element.classList.remove('selected');
460
+ }
461
+ }
462
+
463
+ // Update "Select All" checkbox state
464
+ function updateSelectAll() {
465
+ const selectAllCheckbox = document.getElementById('selectAll');
466
+ if (!selectAllCheckbox) return;
467
+
468
+ const optionalCheckboxes = Array.from(document.querySelectorAll('.scope-checkbox:not(:disabled)'));
469
+ const checkedOptional = optionalCheckboxes.filter(cb => cb.checked);
470
+
471
+ if (checkedOptional.length === 0) {
472
+ selectAllCheckbox.checked = false;
473
+ selectAllCheckbox.indeterminate = false;
474
+ } else if (checkedOptional.length === optionalCheckboxes.length) {
475
+ selectAllCheckbox.checked = true;
476
+ selectAllCheckbox.indeterminate = false;
477
+ } else {
478
+ selectAllCheckbox.checked = false;
479
+ selectAllCheckbox.indeterminate = true;
480
+ }
481
+ }
482
+
483
+ // Update selected count display
484
+ function updateSelectedCount() {
485
+ const checkboxes = document.querySelectorAll('.scope-checkbox:checked');
486
+ const total = document.querySelectorAll('.scope-checkbox').length;
487
+ const selected = checkboxes.length;
488
+ const required = document.querySelectorAll('.scope-checkbox:checked:disabled').length;
489
+ const optional = selected - required;
490
+
491
+ const countText = document.getElementById('countText');
492
+ const approveBtn = document.getElementById('approveBtn');
493
+
494
+ if (selected === 0) {
495
+ countText.textContent = 'No permissions selected. Please select at least the required permissions to continue.';
496
+ approveBtn.disabled = true;
497
+ } else {
498
+ let text = `${selected} of ${total} permission${total !== 1 ? 's' : ''} selected`;
499
+ if (required > 0) {
500
+ text += ` (${required} required`;
501
+ if (optional > 0) {
502
+ text += `, ${optional} optional`;
503
+ }
504
+ text += ')';
505
+ }
506
+ countText.textContent = text;
507
+ approveBtn.disabled = false;
508
+ }
509
+ }
510
+
511
+ // Handle deny button
512
+ function denyAccess() {
513
+ document.getElementById('denyForm').submit();
514
+ }
515
+
516
+ // Validate form before submission
517
+ document.getElementById('consentForm').addEventListener('submit', function(e) {
518
+ const selectedScopes = document.querySelectorAll('.scope-checkbox:checked');
519
+ if (selectedScopes.length === 0) {
520
+ e.preventDefault();
521
+ alert('Please select at least one permission to authorize.');
522
+ return false;
523
+ }
524
+ });
525
+ </script>
526
+ </body>
527
+ </html>
data/config/routes.rb ADDED
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ Mcp::Auth::Engine.routes.draw do
4
+ # RFC 9728: OAuth 2.0 Protected Resource Metadata
5
+ match '/.well-known/oauth-protected-resource',
6
+ to: 'well_known#protected_resource',
7
+ via: %i[get options]
8
+
9
+ match '/.well-known/oauth-protected-resource/*path',
10
+ to: 'well_known#protected_resource',
11
+ via: %i[get options]
12
+
13
+ # RFC 8414: OAuth 2.0 Authorization Server Metadata
14
+ match '/.well-known/oauth-authorization-server',
15
+ to: 'well_known#authorization_server',
16
+ via: %i[get options]
17
+
18
+ # OpenID Connect Discovery
19
+ match '/.well-known/openid-configuration',
20
+ to: 'well_known#openid_configuration',
21
+ via: %i[get options]
22
+
23
+ match '/.well-known/jwks.json',
24
+ to: 'well_known#jwks',
25
+ via: %i[get options]
26
+
27
+ # OAuth 2.1 endpoints
28
+ match '/oauth/authorize', to: 'oauth#authorize', via: %i[get post options]
29
+ post '/oauth/approve', to: 'oauth#approve'
30
+ match '/oauth/token', to: 'oauth#token', via: %i[post options]
31
+
32
+ # RFC 7591: Dynamic Client Registration
33
+ match '/oauth/register', to: 'oauth#register', via: %i[post options]
34
+
35
+ # RFC 7009: Token Revocation
36
+ match '/oauth/revoke', to: 'oauth#revoke', via: %i[post options]
37
+
38
+ # RFC 7662: Token Introspection
39
+ match '/oauth/introspect', to: 'oauth#introspect', via: %i[post options]
40
+
41
+ # OpenID Connect UserInfo
42
+ match '/oauth/userinfo', to: 'oauth#userinfo', via: %i[get options]
43
+ end