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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +43 -0
- data/CONTRIBUTING.md +107 -0
- data/LICENSE.txt +21 -0
- data/README.md +869 -0
- data/Rakefile +8 -0
- data/app/controllers/mcp/auth/oauth_controller.rb +494 -0
- data/app/controllers/mcp/auth/well_known_controller.rb +147 -0
- data/app/models/mcp/auth/access_token.rb +30 -0
- data/app/models/mcp/auth/authorization_code.rb +33 -0
- data/app/models/mcp/auth/oauth_client.rb +60 -0
- data/app/models/mcp/auth/refresh_token.rb +32 -0
- data/app/views/mcp/auth/consent.html.erb +527 -0
- data/config/routes.rb +43 -0
- data/lib/generators/mcp/auth/install_generator.rb +80 -0
- data/lib/generators/mcp/auth/templates/README +114 -0
- data/lib/generators/mcp/auth/templates/create_access_tokens.rb.erb +23 -0
- data/lib/generators/mcp/auth/templates/create_authorization_codes.rb.erb +26 -0
- data/lib/generators/mcp/auth/templates/create_oauth_clients.rb.erb +22 -0
- data/lib/generators/mcp/auth/templates/create_refresh_tokens.rb.erb +22 -0
- data/lib/generators/mcp/auth/templates/initializer.rb +199 -0
- data/lib/generators/mcp/auth/templates/views/consent.html.erb +527 -0
- data/lib/mcp/auth/engine.rb +32 -0
- data/lib/mcp/auth/scope_registry.rb +113 -0
- data/lib/mcp/auth/services/authorization_service.rb +102 -0
- data/lib/mcp/auth/services/token_service.rb +230 -0
- data/lib/mcp/auth/version.rb +7 -0
- data/lib/mcp/auth.rb +109 -0
- data/lib/tasks/mcp_auth_tasks.rake +89 -0
- metadata +254 -0
|
@@ -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
|