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,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
|