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.
Files changed (55) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/LLMS.txt +484 -0
  4. data/README.md +572 -0
  5. data/app/controllers/toolchest/oauth/authorizations_controller.rb +152 -0
  6. data/app/controllers/toolchest/oauth/authorized_applications_controller.rb +68 -0
  7. data/app/controllers/toolchest/oauth/metadata_controller.rb +68 -0
  8. data/app/controllers/toolchest/oauth/registrations_controller.rb +53 -0
  9. data/app/controllers/toolchest/oauth/tokens_controller.rb +98 -0
  10. data/app/models/toolchest/oauth_access_grant.rb +66 -0
  11. data/app/models/toolchest/oauth_access_token.rb +71 -0
  12. data/app/models/toolchest/oauth_application.rb +26 -0
  13. data/app/models/toolchest/token.rb +51 -0
  14. data/app/views/toolchest/oauth/authorizations/new.html.erb +45 -0
  15. data/app/views/toolchest/oauth/authorized_applications/index.html.erb +34 -0
  16. data/config/routes.rb +18 -0
  17. data/lib/generators/toolchest/auth_generator.rb +55 -0
  18. data/lib/generators/toolchest/consent_generator.rb +34 -0
  19. data/lib/generators/toolchest/install_generator.rb +70 -0
  20. data/lib/generators/toolchest/oauth_views_generator.rb +51 -0
  21. data/lib/generators/toolchest/skills_generator.rb +356 -0
  22. data/lib/generators/toolchest/templates/application_toolbox.rb.tt +10 -0
  23. data/lib/generators/toolchest/templates/create_toolchest_oauth.rb.tt +39 -0
  24. data/lib/generators/toolchest/templates/create_toolchest_tokens.rb.tt +16 -0
  25. data/lib/generators/toolchest/templates/initializer.rb.tt +41 -0
  26. data/lib/generators/toolchest/templates/oauth_authorize.html.erb.tt +48 -0
  27. data/lib/generators/toolchest/templates/toolbox.rb.tt +19 -0
  28. data/lib/generators/toolchest/templates/toolbox_spec.rb.tt +23 -0
  29. data/lib/generators/toolchest/toolbox_generator.rb +44 -0
  30. data/lib/toolchest/app.rb +47 -0
  31. data/lib/toolchest/auth/base.rb +15 -0
  32. data/lib/toolchest/auth/none.rb +7 -0
  33. data/lib/toolchest/auth/oauth.rb +28 -0
  34. data/lib/toolchest/auth/token.rb +73 -0
  35. data/lib/toolchest/auth_context.rb +13 -0
  36. data/lib/toolchest/configuration.rb +82 -0
  37. data/lib/toolchest/current.rb +7 -0
  38. data/lib/toolchest/endpoint.rb +13 -0
  39. data/lib/toolchest/engine.rb +95 -0
  40. data/lib/toolchest/naming.rb +31 -0
  41. data/lib/toolchest/oauth/routes.rb +25 -0
  42. data/lib/toolchest/param_definition.rb +69 -0
  43. data/lib/toolchest/parameters.rb +71 -0
  44. data/lib/toolchest/rack_app.rb +114 -0
  45. data/lib/toolchest/renderer.rb +88 -0
  46. data/lib/toolchest/router.rb +277 -0
  47. data/lib/toolchest/rspec.rb +61 -0
  48. data/lib/toolchest/sampling_builder.rb +38 -0
  49. data/lib/toolchest/tasks/toolchest.rake +123 -0
  50. data/lib/toolchest/tool_builder.rb +19 -0
  51. data/lib/toolchest/tool_definition.rb +58 -0
  52. data/lib/toolchest/toolbox.rb +312 -0
  53. data/lib/toolchest/version.rb +3 -0
  54. data/lib/toolchest.rb +89 -0
  55. 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