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,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: 0.5px;
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 above %>
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>
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "jwt"
5
+
6
+ module Mcp
7
+ module Auth
8
+ class Engine < ::Rails::Engine
9
+ isolate_namespace Mcp::Auth
10
+
11
+ config.generators do |g|
12
+ g.test_framework :rspec
13
+ g.fixture_replacement :factory_bot
14
+ g.factory_bot dir: 'spec/factories'
15
+ end
16
+
17
+ # Load dependencies before initializers
18
+ config.before_initialize do
19
+ require 'mcp/auth/scope_registry'
20
+ end
21
+
22
+ initializer "mcp_auth.configure" do
23
+ config.mcp_auth = ActiveSupport::OrderedOptions.new
24
+ config.mcp_auth.oauth_secret = ENV.fetch('MCP_OAUTH_PRIVATE_KEY', nil)
25
+ config.mcp_auth.authorization_server_url = ENV.fetch('MCP_AUTHORIZATION_SERVER_URL', nil)
26
+ config.mcp_auth.access_token_lifetime = 3600 # 1 hour
27
+ config.mcp_auth.refresh_token_lifetime = 2_592_000 # 30 days
28
+ config.mcp_auth.authorization_code_lifetime = 1800 # 30 minutes
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcp
4
+ module Auth
5
+ # ScopeRegistry manages OAuth scopes for MCP Auth
6
+ #
7
+ # By default, provides basic MCP scopes (mcp:read, mcp:write) automatically.
8
+ # Applications can register custom scopes which will replace the defaults.
9
+ #
10
+ # Example:
11
+ # Mcp::Auth::ScopeRegistry.register_scope('mcp:tools',
12
+ # name: 'Tool Execution',
13
+ # description: 'Execute tools and actions',
14
+ # required: false
15
+ # )
16
+ class ScopeRegistry
17
+ class << self
18
+ # Custom scopes registered by the application
19
+ def custom_scopes
20
+ @custom_scopes ||= {}
21
+ end
22
+
23
+ # All available scopes
24
+ # If no scopes are registered, returns basic MCP scopes for backwards compatibility
25
+ def available_scopes
26
+ return custom_scopes unless custom_scopes.empty?
27
+
28
+ # Fallback: If no scopes registered, use basic MCP scopes
29
+ # This ensures backwards compatibility
30
+ {
31
+ 'mcp:read' => {
32
+ name: 'Read Access',
33
+ description: 'Read your data and resources',
34
+ required: true
35
+ },
36
+ 'mcp:write' => {
37
+ name: 'Write Access',
38
+ description: 'Create and modify data on your behalf',
39
+ required: false
40
+ }
41
+ }
42
+ end
43
+
44
+ # Register a custom scope
45
+ def register_scope(scope_key, name:, description:, required: false)
46
+ custom_scopes[scope_key.to_s] = {
47
+ name: name,
48
+ description: description,
49
+ required: required
50
+ }
51
+ end
52
+
53
+ # Clear all registered scopes (useful for testing)
54
+ def clear_scopes!
55
+ @custom_scopes = {}
56
+ end
57
+
58
+ # Check if a scope exists
59
+ def scope_exists?(scope)
60
+ available_scopes.key?(scope.to_s)
61
+ end
62
+
63
+ # Get scope metadata
64
+ def scope_metadata(scope)
65
+ available_scopes[scope.to_s] || {
66
+ name: scope,
67
+ description: scope,
68
+ required: false
69
+ }
70
+ end
71
+
72
+ # Validate and filter requested scopes
73
+ def validate_scopes(requested_scopes)
74
+ # If no scopes requested, return all required scopes
75
+ if requested_scopes.blank?
76
+ return available_scopes.select { |_, meta| meta[:required] }.keys
77
+ end
78
+
79
+ scopes = requested_scopes.is_a?(String) ? requested_scopes.split : requested_scopes
80
+
81
+ # Filter to only valid registered scopes
82
+ valid_scopes = scopes.select { |scope| scope_exists?(scope) }
83
+
84
+ # Always include required scopes
85
+ required_scopes = available_scopes.select { |_, meta| meta[:required] }.keys
86
+
87
+ (valid_scopes + required_scopes).uniq
88
+ end
89
+
90
+ # Format scopes for consent screen display
91
+ def format_for_display(requested_scopes)
92
+ scopes = requested_scopes.is_a?(String) ? requested_scopes.split : requested_scopes
93
+
94
+ scopes.map do |scope|
95
+ metadata = scope_metadata(scope)
96
+ {
97
+ key: scope,
98
+ name: metadata[:name],
99
+ description: metadata[:description],
100
+ required: metadata[:required]
101
+ }
102
+ end
103
+ end
104
+
105
+ # Get default scopes string for a client
106
+ def default_scope_string
107
+ # Return all registered scopes, or empty string if none
108
+ available_scopes.keys.join(' ')
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end