toolchest 0.3.2
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/LICENSE +21 -0
- data/LLMS.txt +484 -0
- data/README.md +572 -0
- data/app/controllers/toolchest/oauth/authorizations_controller.rb +152 -0
- data/app/controllers/toolchest/oauth/authorized_applications_controller.rb +68 -0
- data/app/controllers/toolchest/oauth/metadata_controller.rb +68 -0
- data/app/controllers/toolchest/oauth/registrations_controller.rb +53 -0
- data/app/controllers/toolchest/oauth/tokens_controller.rb +98 -0
- data/app/models/toolchest/oauth_access_grant.rb +66 -0
- data/app/models/toolchest/oauth_access_token.rb +71 -0
- data/app/models/toolchest/oauth_application.rb +26 -0
- data/app/models/toolchest/token.rb +51 -0
- data/app/views/toolchest/oauth/authorizations/new.html.erb +45 -0
- data/app/views/toolchest/oauth/authorized_applications/index.html.erb +34 -0
- data/config/routes.rb +18 -0
- data/lib/generators/toolchest/auth_generator.rb +55 -0
- data/lib/generators/toolchest/consent_generator.rb +34 -0
- data/lib/generators/toolchest/install_generator.rb +70 -0
- data/lib/generators/toolchest/oauth_views_generator.rb +51 -0
- data/lib/generators/toolchest/skills_generator.rb +356 -0
- data/lib/generators/toolchest/templates/application_toolbox.rb.tt +10 -0
- data/lib/generators/toolchest/templates/create_toolchest_oauth.rb.tt +39 -0
- data/lib/generators/toolchest/templates/create_toolchest_tokens.rb.tt +16 -0
- data/lib/generators/toolchest/templates/initializer.rb.tt +41 -0
- data/lib/generators/toolchest/templates/oauth_authorize.html.erb.tt +48 -0
- data/lib/generators/toolchest/templates/toolbox.rb.tt +19 -0
- data/lib/generators/toolchest/templates/toolbox_spec.rb.tt +23 -0
- data/lib/generators/toolchest/toolbox_generator.rb +44 -0
- data/lib/toolchest/app.rb +47 -0
- data/lib/toolchest/auth/base.rb +15 -0
- data/lib/toolchest/auth/none.rb +7 -0
- data/lib/toolchest/auth/oauth.rb +28 -0
- data/lib/toolchest/auth/token.rb +73 -0
- data/lib/toolchest/auth_context.rb +13 -0
- data/lib/toolchest/configuration.rb +82 -0
- data/lib/toolchest/current.rb +7 -0
- data/lib/toolchest/endpoint.rb +13 -0
- data/lib/toolchest/engine.rb +95 -0
- data/lib/toolchest/naming.rb +31 -0
- data/lib/toolchest/oauth/routes.rb +25 -0
- data/lib/toolchest/param_definition.rb +69 -0
- data/lib/toolchest/parameters.rb +71 -0
- data/lib/toolchest/rack_app.rb +114 -0
- data/lib/toolchest/renderer.rb +88 -0
- data/lib/toolchest/router.rb +277 -0
- data/lib/toolchest/rspec.rb +61 -0
- data/lib/toolchest/sampling_builder.rb +38 -0
- data/lib/toolchest/tasks/toolchest.rake +123 -0
- data/lib/toolchest/tool_builder.rb +19 -0
- data/lib/toolchest/tool_definition.rb +58 -0
- data/lib/toolchest/toolbox.rb +312 -0
- data/lib/toolchest/version.rb +3 -0
- data/lib/toolchest.rb +89 -0
- metadata +122 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Oauth
|
|
3
|
+
class AuthorizationsController < ::ApplicationController
|
|
4
|
+
before_action :authenticate_resource_owner!
|
|
5
|
+
before_action :validate_client!
|
|
6
|
+
|
|
7
|
+
# GET /mcp/oauth/authorize — consent screen
|
|
8
|
+
def new
|
|
9
|
+
@client_name = @application.name
|
|
10
|
+
@redirect_uri = params[:redirect_uri]
|
|
11
|
+
@optional = toolchest_config.optional_scopes
|
|
12
|
+
@original_scope = requested_scopes.join(" ")
|
|
13
|
+
|
|
14
|
+
requested = requested_scopes
|
|
15
|
+
allowed = toolchest_config.resolve_allowed_scopes(@current_resource_owner, requested)
|
|
16
|
+
known = toolchest_config.scopes.keys
|
|
17
|
+
allowed = allowed & known if known.any?
|
|
18
|
+
required = Array(toolchest_config.required_scopes) & known
|
|
19
|
+
visible = (allowed | required).uniq
|
|
20
|
+
|
|
21
|
+
@scope_list = visible.map { |s|
|
|
22
|
+
{ name: s, description: toolchest_config.scopes[s] || s, required: required.include?(s) }
|
|
23
|
+
}
|
|
24
|
+
@oauth_params = oauth_hidden_params
|
|
25
|
+
@authorize_url = "#{request.script_name}/oauth/authorize"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# DELETE /mcp/oauth/authorize — user denied
|
|
29
|
+
def deny
|
|
30
|
+
redirect_url = build_redirect(params[:redirect_uri],
|
|
31
|
+
error: "access_denied",
|
|
32
|
+
state: params[:state]
|
|
33
|
+
)
|
|
34
|
+
redirect_to redirect_url, allow_other_host: true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# POST /mcp/oauth/authorize — approve and redirect with code
|
|
38
|
+
def create
|
|
39
|
+
requested = original_requested_scopes
|
|
40
|
+
allowed = toolchest_config.resolve_allowed_scopes(@current_resource_owner, requested)
|
|
41
|
+
known = toolchest_config.scopes.keys
|
|
42
|
+
allowed = allowed & known if known.any?
|
|
43
|
+
required = Array(toolchest_config.required_scopes) & known
|
|
44
|
+
|
|
45
|
+
granted = if toolchest_config.optional_scopes
|
|
46
|
+
submitted = Array(params[:scope])
|
|
47
|
+
(submitted & allowed) | required
|
|
48
|
+
else
|
|
49
|
+
allowed | required
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
grant = Toolchest::OauthAccessGrant.create_for(
|
|
53
|
+
application: @application,
|
|
54
|
+
resource_owner_id: current_resource_owner_id,
|
|
55
|
+
redirect_uri: params[:redirect_uri],
|
|
56
|
+
scopes: granted.join(" "),
|
|
57
|
+
mount_key: mount_key,
|
|
58
|
+
code_challenge: params[:code_challenge],
|
|
59
|
+
code_challenge_method: params[:code_challenge_method]
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
redirect_url = build_redirect(params[:redirect_uri],
|
|
63
|
+
code: grant.raw_code,
|
|
64
|
+
state: params[:state]
|
|
65
|
+
)
|
|
66
|
+
redirect_to redirect_url, allow_other_host: true
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def toolchest_config = Toolchest.configuration(mount_key.to_sym)
|
|
72
|
+
|
|
73
|
+
def mount_key
|
|
74
|
+
# 1. From env (inside a mount — /admin-mcp/oauth/authorize)
|
|
75
|
+
return request.env["toolchest.mount_key"] if request.env["toolchest.mount_key"]
|
|
76
|
+
|
|
77
|
+
# 2. From resource param (RFC 8707 — CC always sends this)
|
|
78
|
+
if params[:resource].present?
|
|
79
|
+
resource_path = URI.parse(params[:resource]).path rescue nil
|
|
80
|
+
if resource_path
|
|
81
|
+
found = Toolchest.mount_keys.find { |k|
|
|
82
|
+
Toolchest.configuration(k).mount_path == resource_path
|
|
83
|
+
}
|
|
84
|
+
return found.to_s if found
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
"default"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def authenticate_resource_owner!
|
|
92
|
+
user = toolchest_config.resolve_current_user(request)
|
|
93
|
+
if user
|
|
94
|
+
@current_resource_owner = user
|
|
95
|
+
else
|
|
96
|
+
login_path = toolchest_config.login_path || "/login"
|
|
97
|
+
redirect_to "#{login_path}?return_to=#{CGI.escape(request.url)}", allow_other_host: true
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def current_resource_owner_id
|
|
102
|
+
owner = @current_resource_owner
|
|
103
|
+
(owner.respond_to?(:id) ? owner.id : owner).to_s
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_client!
|
|
107
|
+
# Applications are global — look up by uid only (no mount_key filter)
|
|
108
|
+
@application = Toolchest::OauthApplication.find_by(uid: params[:client_id])
|
|
109
|
+
unless @application
|
|
110
|
+
render json: { error: "invalid_client", error_description: "Unknown client_id" }, status: :bad_request
|
|
111
|
+
return
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if params[:redirect_uri].present? && !@application.redirect_uri_matches?(params[:redirect_uri])
|
|
115
|
+
render json: { error: "invalid_redirect_uri" }, status: :bad_request
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def requested_scopes
|
|
120
|
+
scope = params[:scope]
|
|
121
|
+
case scope
|
|
122
|
+
when Array then scope.flat_map { |s| s.split(" ") }
|
|
123
|
+
when String then scope.split(" ")
|
|
124
|
+
else []
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def oauth_hidden_params
|
|
129
|
+
params.to_unsafe_h.slice(
|
|
130
|
+
"client_id", "state", "redirect_uri", "response_type", "response_mode",
|
|
131
|
+
"access_type", "code_challenge", "code_challenge_method", "resource"
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def original_requested_scopes
|
|
136
|
+
if params[:original_scope].present?
|
|
137
|
+
params[:original_scope].split(" ")
|
|
138
|
+
else
|
|
139
|
+
requested_scopes
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_redirect(base_uri, query_params)
|
|
144
|
+
uri = URI.parse(base_uri)
|
|
145
|
+
existing = URI.decode_www_form(uri.query || "")
|
|
146
|
+
query_params.compact.each { |k, v| existing << [k.to_s, v.to_s] }
|
|
147
|
+
uri.query = URI.encode_www_form(existing)
|
|
148
|
+
uri.to_s
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Oauth
|
|
3
|
+
class AuthorizedApplicationsController < ::ApplicationController
|
|
4
|
+
before_action :authenticate_resource_owner!
|
|
5
|
+
|
|
6
|
+
# GET /mcp/oauth/authorized_applications
|
|
7
|
+
def index = @applications = authorized_applications
|
|
8
|
+
|
|
9
|
+
# DELETE /mcp/oauth/authorized_applications/:id
|
|
10
|
+
def destroy
|
|
11
|
+
app = Toolchest::OauthApplication.find_by(id: params[:id])
|
|
12
|
+
|
|
13
|
+
unless app
|
|
14
|
+
redirect_to "#{request.script_name}/oauth/authorized_applications",
|
|
15
|
+
alert: "Application not found."
|
|
16
|
+
return
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Toolchest::OauthAccessToken.revoke_all_for(app, current_resource_owner_id)
|
|
20
|
+
Toolchest::OauthAccessGrant.revoke_all_for(app, current_resource_owner_id)
|
|
21
|
+
|
|
22
|
+
redirect_to "#{request.script_name}/oauth/authorized_applications",
|
|
23
|
+
notice: "#{app.name} has been disconnected."
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def toolchest_config = Toolchest.configuration(mount_key.to_sym)
|
|
29
|
+
|
|
30
|
+
def authenticate_resource_owner!
|
|
31
|
+
user = toolchest_config.resolve_current_user(request)
|
|
32
|
+
if user
|
|
33
|
+
@current_resource_owner = user
|
|
34
|
+
else
|
|
35
|
+
login_path = toolchest_config.login_path || "/login"
|
|
36
|
+
redirect_to "#{login_path}?return_to=#{CGI.escape(request.url)}", allow_other_host: true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def current_resource_owner_id
|
|
41
|
+
owner = @current_resource_owner
|
|
42
|
+
(owner.respond_to?(:id) ? owner.id : owner).to_s
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def mount_key = request.env["toolchest.mount_key"] || "default"
|
|
46
|
+
|
|
47
|
+
def authorized_applications
|
|
48
|
+
tokens_scope = Toolchest::OauthAccessToken
|
|
49
|
+
.where(resource_owner_id: current_resource_owner_id, revoked_at: nil, mount_key: mount_key)
|
|
50
|
+
|
|
51
|
+
app_ids = tokens_scope.select(:application_id).distinct
|
|
52
|
+
|
|
53
|
+
Toolchest::OauthApplication.where(id: app_ids).map do |app|
|
|
54
|
+
tokens = tokens_scope.where(application: app)
|
|
55
|
+
latest = tokens.order(created_at: :desc).first
|
|
56
|
+
scopes = tokens.flat_map(&:scopes_array).uniq
|
|
57
|
+
|
|
58
|
+
{
|
|
59
|
+
application: app,
|
|
60
|
+
scopes: scopes,
|
|
61
|
+
connected_at: tokens.minimum(:created_at),
|
|
62
|
+
last_used_at: latest&.updated_at
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Oauth
|
|
3
|
+
class MetadataController < ::ApplicationController
|
|
4
|
+
# GET /.well-known/oauth-authorization-server(/*rest)
|
|
5
|
+
def authorization_server
|
|
6
|
+
mount_path, cfg = resolve_mount
|
|
7
|
+
return if performed?
|
|
8
|
+
|
|
9
|
+
render json: {
|
|
10
|
+
issuer: "#{request.base_url}#{mount_path}",
|
|
11
|
+
authorization_endpoint: "#{request.base_url}#{mount_path}/oauth/authorize",
|
|
12
|
+
token_endpoint: "#{request.base_url}#{mount_path}/oauth/token",
|
|
13
|
+
registration_endpoint: "#{request.base_url}#{mount_path}/oauth/register",
|
|
14
|
+
response_types_supported: ["code"],
|
|
15
|
+
grant_types_supported: ["authorization_code", "refresh_token"],
|
|
16
|
+
token_endpoint_auth_methods_supported: ["none"],
|
|
17
|
+
scopes_supported: cfg.scopes.keys,
|
|
18
|
+
code_challenge_methods_supported: ["S256"]
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# GET /.well-known/oauth-protected-resource(/*rest)
|
|
23
|
+
def protected_resource
|
|
24
|
+
mount_path, cfg = resolve_mount
|
|
25
|
+
return if performed?
|
|
26
|
+
|
|
27
|
+
render json: {
|
|
28
|
+
resource: "#{request.base_url}#{mount_path}",
|
|
29
|
+
authorization_servers: ["#{request.base_url}#{mount_path}"],
|
|
30
|
+
scopes_supported: cfg.scopes.keys,
|
|
31
|
+
bearer_methods_supported: ["header"]
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
# Returns [mount_path, config] or renders 404 and returns [nil, nil].
|
|
38
|
+
def resolve_mount
|
|
39
|
+
if params[:rest].present?
|
|
40
|
+
# Suffixed path (RFC 8414) — must match a configured mount exactly.
|
|
41
|
+
path = "/#{params[:rest]}"
|
|
42
|
+
key = Toolchest.mount_keys.find { |k| Toolchest.configuration(k).mount_path == path }
|
|
43
|
+
unless key
|
|
44
|
+
head :not_found
|
|
45
|
+
return [nil, nil]
|
|
46
|
+
end
|
|
47
|
+
return [path, Toolchest.configuration(key)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# No suffix (e.g. Cursor). Use default_oauth_mount if set.
|
|
51
|
+
if Toolchest.default_oauth_mount
|
|
52
|
+
cfg = Toolchest.configuration(Toolchest.default_oauth_mount)
|
|
53
|
+
return [cfg.mount_path || "/mcp", cfg]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Auto-resolve when there's exactly one OAuth mount.
|
|
57
|
+
oauth_mounts = Toolchest.mount_keys.select { |k| Toolchest.configuration(k).auth == :oauth }
|
|
58
|
+
if oauth_mounts.size == 1
|
|
59
|
+
cfg = Toolchest.configuration(oauth_mounts.first)
|
|
60
|
+
return [cfg.mount_path || "/mcp", cfg]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
head :not_found
|
|
64
|
+
[nil, nil]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Oauth
|
|
3
|
+
class RegistrationsController < ::ApplicationController
|
|
4
|
+
skip_forgery_protection
|
|
5
|
+
wrap_parameters false
|
|
6
|
+
|
|
7
|
+
# POST /register — Dynamic Client Registration (RFC 7591)
|
|
8
|
+
# Applications are global (not mount-scoped). Mount scoping happens
|
|
9
|
+
# at authorization time via the resource param.
|
|
10
|
+
def create
|
|
11
|
+
name = (params[:client_name] || "MCP Client").truncate(255)
|
|
12
|
+
uris = Array(params[:redirect_uris])
|
|
13
|
+
|
|
14
|
+
if uris.size > 10
|
|
15
|
+
return render json: {
|
|
16
|
+
error: "invalid_client_metadata",
|
|
17
|
+
error_description: "Too many redirect URIs (max 10)"
|
|
18
|
+
}, status: :bad_request
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
if uris.any? { |u| u.to_s.length > 2048 }
|
|
22
|
+
return render json: {
|
|
23
|
+
error: "invalid_client_metadata",
|
|
24
|
+
error_description: "Redirect URI too long (max 2048 characters)"
|
|
25
|
+
}, status: :bad_request
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
application = Toolchest::OauthApplication.new(
|
|
29
|
+
name: name,
|
|
30
|
+
redirect_uri: uris.join("\n"),
|
|
31
|
+
confidential: false
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if application.save
|
|
35
|
+
render json: {
|
|
36
|
+
client_name: application.name,
|
|
37
|
+
client_id: application.uid,
|
|
38
|
+
client_id_issued_at: application.created_at.to_i,
|
|
39
|
+
redirect_uris: application.redirect_uris,
|
|
40
|
+
grant_types: params[:grant_types] || ["authorization_code"],
|
|
41
|
+
response_types: params[:response_types] || ["code"],
|
|
42
|
+
token_endpoint_auth_method: params[:token_endpoint_auth_method] || "none"
|
|
43
|
+
}, status: :created
|
|
44
|
+
else
|
|
45
|
+
render json: {
|
|
46
|
+
error: "invalid_client_metadata",
|
|
47
|
+
error_description: application.errors.full_messages.join(", ")
|
|
48
|
+
}, status: :bad_request
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
module Toolchest
|
|
2
|
+
module Oauth
|
|
3
|
+
class TokensController < ::ApplicationController
|
|
4
|
+
skip_forgery_protection
|
|
5
|
+
|
|
6
|
+
# POST /mcp/oauth/token
|
|
7
|
+
def create
|
|
8
|
+
case params[:grant_type]
|
|
9
|
+
when "authorization_code"
|
|
10
|
+
handle_authorization_code
|
|
11
|
+
when "refresh_token"
|
|
12
|
+
handle_refresh_token
|
|
13
|
+
else
|
|
14
|
+
error_response("unsupported_grant_type", "Grant type '#{params[:grant_type]}' is not supported")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def mount_key = request.env["toolchest.mount_key"] || "default"
|
|
21
|
+
|
|
22
|
+
def toolchest_config = Toolchest.configuration(mount_key.to_sym)
|
|
23
|
+
|
|
24
|
+
def handle_authorization_code
|
|
25
|
+
grant = Toolchest::OauthAccessGrant.find_by_code(params[:code])
|
|
26
|
+
|
|
27
|
+
unless grant
|
|
28
|
+
return error_response("invalid_grant", "Authorization code not found or expired")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
app = grant.application
|
|
32
|
+
|
|
33
|
+
if params[:client_id].present? && app.uid != params[:client_id]
|
|
34
|
+
return error_response("invalid_client", "Client ID mismatch")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if grant.redirect_uri.present? && grant.redirect_uri != params[:redirect_uri]
|
|
38
|
+
return error_response("invalid_grant", "Redirect URI mismatch")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if !grant.uses_pkce? && !app.confidential?
|
|
42
|
+
return error_response("invalid_request", "PKCE required for public clients")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
unless grant.verify_pkce(params[:code_verifier])
|
|
46
|
+
return error_response("invalid_grant", "PKCE verification failed")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
grant.revoke!
|
|
50
|
+
|
|
51
|
+
token = Toolchest::OauthAccessToken.create_for(
|
|
52
|
+
application: app,
|
|
53
|
+
resource_owner_id: grant.resource_owner_id,
|
|
54
|
+
scopes: grant.scopes,
|
|
55
|
+
mount_key: grant.mount_key,
|
|
56
|
+
expires_in: toolchest_config.access_token_expires_in
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
render json: token_response(token)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def handle_refresh_token
|
|
63
|
+
old_token = Toolchest::OauthAccessToken.find_by_refresh_token(
|
|
64
|
+
params[:refresh_token], mount_key: mount_key
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
unless old_token
|
|
68
|
+
return error_response("invalid_grant", "Refresh token invalid or expired")
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
old_token.revoke!
|
|
72
|
+
|
|
73
|
+
token = Toolchest::OauthAccessToken.create_for(
|
|
74
|
+
application: old_token.application,
|
|
75
|
+
resource_owner_id: old_token.resource_owner_id,
|
|
76
|
+
scopes: old_token.scopes,
|
|
77
|
+
mount_key: old_token.mount_key,
|
|
78
|
+
expires_in: toolchest_config.access_token_expires_in
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
render json: token_response(token)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def token_response(token)
|
|
85
|
+
response = {
|
|
86
|
+
access_token: token.raw_token,
|
|
87
|
+
token_type: "bearer"
|
|
88
|
+
}
|
|
89
|
+
response[:expires_in] = (token.expires_at - Time.current).to_i if token.expires_at
|
|
90
|
+
response[:refresh_token] = token.raw_refresh_token if token.raw_refresh_token
|
|
91
|
+
response[:scope] = token.scopes if token.scopes.present?
|
|
92
|
+
response
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def error_response(error, description) = render json: { error: error, error_description: description }, status: :bad_request
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Toolchest
|
|
6
|
+
class OauthAccessGrant < ActiveRecord::Base
|
|
7
|
+
self.table_name = "toolchest_oauth_access_grants"
|
|
8
|
+
|
|
9
|
+
belongs_to :application, class_name: "Toolchest::OauthApplication"
|
|
10
|
+
|
|
11
|
+
def self.revoke_all_for(application, resource_owner_id)
|
|
12
|
+
where(application: application, resource_owner_id: resource_owner_id.to_s, revoked_at: nil)
|
|
13
|
+
.update_all(revoked_at: Time.current)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
scope :active, -> {
|
|
17
|
+
where(revoked_at: nil)
|
|
18
|
+
.where("expires_at > ?", Time.current)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
def expired? = expires_at < Time.current
|
|
22
|
+
|
|
23
|
+
def revoked? = revoked_at.present?
|
|
24
|
+
|
|
25
|
+
def revoke! = update!(revoked_at: Time.current)
|
|
26
|
+
|
|
27
|
+
def uses_pkce? = code_challenge.present?
|
|
28
|
+
|
|
29
|
+
def verify_pkce(code_verifier)
|
|
30
|
+
return true unless uses_pkce?
|
|
31
|
+
return false if code_verifier.blank?
|
|
32
|
+
|
|
33
|
+
generated = Base64.urlsafe_encode64(
|
|
34
|
+
Digest::SHA256.digest(code_verifier),
|
|
35
|
+
padding: false
|
|
36
|
+
)
|
|
37
|
+
ActiveSupport::SecurityUtils.secure_compare(generated, code_challenge)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def create_for(application:, resource_owner_id:, redirect_uri:, scopes:, mount_key: "default",
|
|
42
|
+
expires_in: 300, code_challenge: nil, code_challenge_method: nil)
|
|
43
|
+
raw_code = SecureRandom.urlsafe_base64(32)
|
|
44
|
+
|
|
45
|
+
grant = create!(
|
|
46
|
+
application: application,
|
|
47
|
+
resource_owner_id: resource_owner_id,
|
|
48
|
+
token_digest: Digest::SHA256.hexdigest(raw_code),
|
|
49
|
+
redirect_uri: redirect_uri,
|
|
50
|
+
scopes: scopes,
|
|
51
|
+
mount_key: mount_key,
|
|
52
|
+
expires_at: Time.current + expires_in.seconds,
|
|
53
|
+
code_challenge: code_challenge,
|
|
54
|
+
code_challenge_method: code_challenge_method
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
grant.instance_variable_set(:@raw_code, raw_code)
|
|
58
|
+
grant
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def find_by_code(raw_code) = active.find_by(token_digest: Digest::SHA256.hexdigest(raw_code))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def raw_code = @raw_code
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Toolchest
|
|
5
|
+
class OauthAccessToken < ActiveRecord::Base
|
|
6
|
+
self.table_name = "toolchest_oauth_access_tokens"
|
|
7
|
+
|
|
8
|
+
belongs_to :application, class_name: "Toolchest::OauthApplication", optional: true
|
|
9
|
+
|
|
10
|
+
scope :active, -> {
|
|
11
|
+
where(revoked_at: nil)
|
|
12
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
def expired? = expires_at.present? && expires_at < Time.current
|
|
16
|
+
|
|
17
|
+
def revoked? = revoked_at.present?
|
|
18
|
+
|
|
19
|
+
def revoke! = update!(revoked_at: Time.current)
|
|
20
|
+
|
|
21
|
+
def accessible? = !revoked? && !expired?
|
|
22
|
+
|
|
23
|
+
def scopes_array = (scopes || "").split(" ").reject(&:empty?)
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def revoke_all_for(application, resource_owner_id)
|
|
27
|
+
where(application: application, resource_owner_id: resource_owner_id.to_s, revoked_at: nil)
|
|
28
|
+
.update_all(revoked_at: Time.current)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_for(application:, resource_owner_id:, scopes:, mount_key: "default", expires_in: 7200)
|
|
32
|
+
raw_token = SecureRandom.urlsafe_base64(32)
|
|
33
|
+
raw_refresh = SecureRandom.urlsafe_base64(32)
|
|
34
|
+
|
|
35
|
+
token = create!(
|
|
36
|
+
application: application,
|
|
37
|
+
resource_owner_id: resource_owner_id,
|
|
38
|
+
token: Digest::SHA256.hexdigest(raw_token),
|
|
39
|
+
refresh_token: Digest::SHA256.hexdigest(raw_refresh),
|
|
40
|
+
scopes: scopes,
|
|
41
|
+
mount_key: mount_key,
|
|
42
|
+
expires_at: expires_in ? Time.current + expires_in.seconds : nil
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
token.instance_variable_set(:@raw_token, raw_token)
|
|
46
|
+
token.instance_variable_set(:@raw_refresh_token, raw_refresh)
|
|
47
|
+
token
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Timing-safe by design: we hash the raw token before lookup, so the
|
|
51
|
+
# database comparison runs against the hash (which the attacker doesn't
|
|
52
|
+
# know). No constant-time comparison needed here.
|
|
53
|
+
def find_by_token(raw_token, mount_key: nil)
|
|
54
|
+
scope = active.where(token: Digest::SHA256.hexdigest(raw_token))
|
|
55
|
+
scope = scope.where(mount_key: mount_key) if mount_key
|
|
56
|
+
scope.first
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# See find_by_token for timing safety rationale.
|
|
60
|
+
def find_by_refresh_token(raw_refresh, mount_key: nil)
|
|
61
|
+
scope = active.where(refresh_token: Digest::SHA256.hexdigest(raw_refresh))
|
|
62
|
+
scope = scope.where(mount_key: mount_key) if mount_key
|
|
63
|
+
scope.first
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def raw_token = @raw_token
|
|
68
|
+
|
|
69
|
+
def raw_refresh_token = @raw_refresh_token
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Toolchest
|
|
4
|
+
class OauthApplication < ActiveRecord::Base
|
|
5
|
+
self.table_name = "toolchest_oauth_applications"
|
|
6
|
+
|
|
7
|
+
has_many :access_grants, class_name: "Toolchest::OauthAccessGrant",
|
|
8
|
+
foreign_key: :application_id, dependent: :delete_all
|
|
9
|
+
has_many :access_tokens, class_name: "Toolchest::OauthAccessToken",
|
|
10
|
+
foreign_key: :application_id, dependent: :delete_all
|
|
11
|
+
|
|
12
|
+
validates :name, :uid, presence: true
|
|
13
|
+
validates :uid, uniqueness: true
|
|
14
|
+
validates :redirect_uri, presence: true
|
|
15
|
+
|
|
16
|
+
before_validation :generate_uid, on: :create
|
|
17
|
+
|
|
18
|
+
def redirect_uris = redirect_uri&.split("\n")&.map(&:strip)&.reject(&:empty?) || []
|
|
19
|
+
|
|
20
|
+
def redirect_uri_matches?(uri) = redirect_uris.include?(uri)
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def generate_uid = self.uid ||= SecureRandom.urlsafe_base64(32)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "openssl"
|
|
2
|
+
|
|
3
|
+
module Toolchest
|
|
4
|
+
class Token < ActiveRecord::Base
|
|
5
|
+
self.table_name = "toolchest_tokens"
|
|
6
|
+
|
|
7
|
+
scope :active, -> {
|
|
8
|
+
where(revoked_at: nil)
|
|
9
|
+
.where("expires_at IS NULL OR expires_at > ?", Time.current)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
def expired? = expires_at.present? && expires_at < Time.current
|
|
13
|
+
|
|
14
|
+
def revoked? = revoked_at.present?
|
|
15
|
+
|
|
16
|
+
def accessible? = !revoked? && !expired?
|
|
17
|
+
|
|
18
|
+
def revoke! = update!(revoked_at: Time.current)
|
|
19
|
+
|
|
20
|
+
def scopes_array = (scopes || "").split(" ").reject(&:empty?)
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
def find_by_raw_token(raw_token)
|
|
24
|
+
digest = OpenSSL::Digest::SHA256.hexdigest(raw_token)
|
|
25
|
+
active.find_by(token_digest: digest)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def generate(owner: nil, name: nil, scopes: nil, namespace: "default", expires_at: nil)
|
|
29
|
+
raw = "tcht_#{SecureRandom.hex(24)}"
|
|
30
|
+
digest = OpenSSL::Digest::SHA256.hexdigest(raw)
|
|
31
|
+
|
|
32
|
+
owner_type, owner_id = owner&.split(":", 2)
|
|
33
|
+
|
|
34
|
+
token = create!(
|
|
35
|
+
token_digest: digest,
|
|
36
|
+
name: name,
|
|
37
|
+
owner_type: owner_type,
|
|
38
|
+
owner_id: owner_id,
|
|
39
|
+
scopes: scopes,
|
|
40
|
+
namespace: namespace,
|
|
41
|
+
expires_at: expires_at
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
token.instance_variable_set(:@raw_token, raw)
|
|
45
|
+
token
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def raw_token = @raw_token
|
|
50
|
+
end
|
|
51
|
+
end
|