connectors 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 +35 -0
- data/CONNECTORS_FRAMEWORK.md +799 -0
- data/CONTRIBUTING.md +60 -0
- data/MCP_CLIENT.md +168 -0
- data/MIT-LICENSE +20 -0
- data/README.md +146 -0
- data/app/connectors/clickup/connector.rb +13 -0
- data/app/connectors/gmail/api.rb +114 -0
- data/app/connectors/gmail/connector.rb +921 -0
- data/app/connectors/gmail/mime_builder.rb +261 -0
- data/app/connectors/gmail/mime_parser.rb +106 -0
- data/app/connectors/gmail/polling.rb +154 -0
- data/app/connectors/remote_mcp/connector.rb +18 -0
- data/app/connectors/resend/connector.rb +218 -0
- data/app/controllers/concerns/connectors/grant_access.rb +43 -0
- data/app/controllers/connectors/actions_controller.rb +66 -0
- data/app/controllers/connectors/application_controller.rb +5 -0
- data/app/controllers/connectors/credentials_controller.rb +245 -0
- data/app/controllers/connectors/grants_controller.rb +123 -0
- data/app/controllers/connectors/mcp_controller.rb +88 -0
- data/app/controllers/connectors/oauth_controller.rb +134 -0
- data/app/controllers/connectors/types_controller.rb +144 -0
- data/app/controllers/connectors/webhooks_controller.rb +105 -0
- data/app/jobs/connectors/application_job.rb +4 -0
- data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
- data/app/jobs/connectors/poll_job.rb +49 -0
- data/app/models/connectors/application_record.rb +5 -0
- data/app/models/connectors/credential_share.rb +31 -0
- data/app/models/connectors/grant.rb +103 -0
- data/app/models/connectors/mcp_authorization.rb +6 -0
- data/app/models/connectors/mcp_interaction.rb +6 -0
- data/app/models/connectors/poll_state.rb +17 -0
- data/app/models/connectors/webhook_event.rb +19 -0
- data/config/routes.rb +68 -0
- data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
- data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
- data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
- data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
- data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
- data/docs/adding-connectors.md +92 -0
- data/docs/architecture.md +71 -0
- data/docs/releasing.md +60 -0
- data/lib/connectors/action.rb +90 -0
- data/lib/connectors/action_builder.rb +125 -0
- data/lib/connectors/action_runner.rb +99 -0
- data/lib/connectors/auth/scheme/api_key.rb +51 -0
- data/lib/connectors/auth/scheme/oauth2.rb +23 -0
- data/lib/connectors/auth/scheme.rb +35 -0
- data/lib/connectors/auth.rb +4 -0
- data/lib/connectors/auth_injection.rb +65 -0
- data/lib/connectors/client_builder.rb +87 -0
- data/lib/connectors/configuration.rb +138 -0
- data/lib/connectors/connector.rb +565 -0
- data/lib/connectors/credential_schema.rb +219 -0
- data/lib/connectors/credential_tester.rb +85 -0
- data/lib/connectors/credential_type_registry.rb +162 -0
- data/lib/connectors/credential_types/http_auth.rb +171 -0
- data/lib/connectors/engine.rb +123 -0
- data/lib/connectors/errors.rb +88 -0
- data/lib/connectors/grant_policy.rb +13 -0
- data/lib/connectors/mcp/access.rb +50 -0
- data/lib/connectors/mcp/authorization.rb +177 -0
- data/lib/connectors/mcp/authorization_context.rb +25 -0
- data/lib/connectors/mcp/authorization_discovery.rb +42 -0
- data/lib/connectors/mcp/cancellation.rb +36 -0
- data/lib/connectors/mcp/client.rb +137 -0
- data/lib/connectors/mcp/connection_config.rb +48 -0
- data/lib/connectors/mcp/http.rb +108 -0
- data/lib/connectors/mcp/interaction.rb +82 -0
- data/lib/connectors/mcp/pending_transaction.rb +26 -0
- data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
- data/lib/connectors/mcp/protocol/LICENSE +216 -0
- data/lib/connectors/mcp/protocol/README.md +8 -0
- data/lib/connectors/mcp/protocol_schema.rb +30 -0
- data/lib/connectors/mcp/schema.rb +45 -0
- data/lib/connectors/mcp/settings.rb +27 -0
- data/lib/connectors/mcp/token_endpoint.rb +48 -0
- data/lib/connectors/mcp/transport.rb +105 -0
- data/lib/connectors/mcp.rb +69 -0
- data/lib/connectors/middleware/authenticate_generic.rb +79 -0
- data/lib/connectors/middleware/auto_refresh.rb +71 -0
- data/lib/connectors/middleware/error_normalization.rb +45 -0
- data/lib/connectors/middleware/grant_status.rb +19 -0
- data/lib/connectors/middleware/pre_authentication.rb +54 -0
- data/lib/connectors/middleware/rate_limit.rb +40 -0
- data/lib/connectors/oauth/authorize_url.rb +78 -0
- data/lib/connectors/oauth/client_authentication.rb +24 -0
- data/lib/connectors/oauth/client_credentials.rb +43 -0
- data/lib/connectors/oauth/grant_writer.rb +73 -0
- data/lib/connectors/oauth/pkce.rb +32 -0
- data/lib/connectors/oauth/revoke.rb +72 -0
- data/lib/connectors/oauth/state.rb +41 -0
- data/lib/connectors/oauth/token_exchange.rb +69 -0
- data/lib/connectors/oauth/token_response.rb +40 -0
- data/lib/connectors/oauth.rb +4 -0
- data/lib/connectors/oauth1.rb +151 -0
- data/lib/connectors/permission_check.rb +33 -0
- data/lib/connectors/poll_runner.rb +50 -0
- data/lib/connectors/pre_authentication_helpers.rb +76 -0
- data/lib/connectors/registry.rb +38 -0
- data/lib/connectors/version.rb +3 -0
- data/lib/connectors/webhook_context.rb +62 -0
- data/lib/connectors/webhook_lifecycle.rb +69 -0
- data/lib/connectors/webhook_methods.rb +48 -0
- data/lib/connectors/webhooks/verifier.rb +31 -0
- data/lib/connectors/webhooks.rb +5 -0
- data/lib/connectors.rb +50 -0
- data/lib/tasks/connectors_mcp.rake +9 -0
- data/lib/tasks/connectors_tasks.rake +4 -0
- data/openapi.yaml +1099 -0
- metadata +263 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Middleware
|
|
5
|
+
# Catches AuthenticationFailed (raised by ErrorNormalization on 401), asks
|
|
6
|
+
# the connector to refresh its credentials, then retries the request once.
|
|
7
|
+
# Bails out if:
|
|
8
|
+
# - the connector hasn't overridden #refresh!
|
|
9
|
+
# - the request was already retried in this call chain
|
|
10
|
+
# - the refresh itself fails (re-raises the underlying error)
|
|
11
|
+
class AutoRefresh < Faraday::Middleware
|
|
12
|
+
def initialize(app, grant:)
|
|
13
|
+
super(app)
|
|
14
|
+
@grant = grant
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(env)
|
|
18
|
+
credentials = @grant.credentials_hash.deep_dup
|
|
19
|
+
request_body = env.body
|
|
20
|
+
@app.call(env)
|
|
21
|
+
rescue Connectors::AuthenticationFailed => e
|
|
22
|
+
raise e if env[:connectors_already_refreshed]
|
|
23
|
+
raise e unless refreshable?
|
|
24
|
+
|
|
25
|
+
perform_refresh!(credentials)
|
|
26
|
+
env[:connectors_already_refreshed] = true
|
|
27
|
+
env[:request_headers].delete("Authorization")
|
|
28
|
+
env.body = request_body
|
|
29
|
+
@app.call(env)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
# True only when the connector subclass actually implements refresh!.
|
|
35
|
+
# The base Connector#refresh! raises NotImplementedError, which we don't
|
|
36
|
+
# want to trigger accidentally.
|
|
37
|
+
def refreshable?
|
|
38
|
+
klass = @grant.connector.class
|
|
39
|
+
klass.instance_method(:refresh!).owner != Connectors::Connector
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def perform_refresh!(failed_credentials)
|
|
43
|
+
failure = nil
|
|
44
|
+
@grant.with_persistence_lock do
|
|
45
|
+
raise Connectors::AuthenticationFailed, "connection has been revoked" if @grant.revoked?
|
|
46
|
+
# Another worker may have refreshed while this request was in flight.
|
|
47
|
+
next unless @grant.credentials_hash == failed_credentials
|
|
48
|
+
|
|
49
|
+
begin
|
|
50
|
+
@grant.connector.refresh!
|
|
51
|
+
@grant.reload if @grant.persisted?
|
|
52
|
+
rescue Connectors::Error => e
|
|
53
|
+
mark_errored!
|
|
54
|
+
failure = e
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
mark_errored!
|
|
57
|
+
failure = Connectors::AuthenticationFailed.new("token refresh failed: #{e.class}")
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
# Raise after committing failure bookkeeping, not inside the transaction.
|
|
61
|
+
raise failure if failure
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def mark_errored!
|
|
65
|
+
@grant.update_columns(status: Connectors::Grant.statuses[:errored])
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Middleware
|
|
5
|
+
# Converts non-2xx HTTP responses into typed Connectors::* exceptions so
|
|
6
|
+
# callers can rescue with intent rather than parsing status codes.
|
|
7
|
+
class ErrorNormalization < Faraday::Middleware
|
|
8
|
+
def initialize(app, token_expired_status: 401)
|
|
9
|
+
super(app)
|
|
10
|
+
@token_expired_status = token_expired_status
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def on_complete(env)
|
|
14
|
+
return if env.status.between?(200, 299)
|
|
15
|
+
|
|
16
|
+
if env.status == @token_expired_status
|
|
17
|
+
raise Connectors::AuthenticationFailed.new(
|
|
18
|
+
"authentication failed (status #{env.status})",
|
|
19
|
+
status: env.status,
|
|
20
|
+
body: env.body
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
case env.status
|
|
25
|
+
when 403
|
|
26
|
+
raise Connectors::Forbidden.new("request forbidden (status 403)", status: env.status, body: env.body)
|
|
27
|
+
when 429
|
|
28
|
+
retry_after = env.response_headers["retry-after"] || env.response_headers["Retry-After"]
|
|
29
|
+
raise Connectors::RateLimited.new(
|
|
30
|
+
"remote rate limit (status 429)",
|
|
31
|
+
status: 429,
|
|
32
|
+
body: env.body,
|
|
33
|
+
retry_after: retry_after&.to_i
|
|
34
|
+
)
|
|
35
|
+
else
|
|
36
|
+
raise Connectors::ApiError.new(
|
|
37
|
+
"API request failed (status #{env.status})",
|
|
38
|
+
status: env.status,
|
|
39
|
+
body: env.body
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Middleware
|
|
5
|
+
# Runs inside the retry middleware so every attempt checks revocation
|
|
6
|
+
# before pre-authentication or provider credentials can be used.
|
|
7
|
+
class GrantStatus < Faraday::Middleware
|
|
8
|
+
def initialize(app, grant:)
|
|
9
|
+
super(app)
|
|
10
|
+
@grant = grant
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(env)
|
|
14
|
+
@grant.ensure_not_revoked!
|
|
15
|
+
@app.call(env)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module Middleware
|
|
3
|
+
# Runs the connector's `pre_authentication` block (Phase 3) before each
|
|
4
|
+
# outgoing request — but only when the cached credentials look stale.
|
|
5
|
+
# Mirrors n8n's contract at packages/workflow/src/interfaces.ts:374-377;
|
|
6
|
+
# n8n's runtime gates re-runs on `credentialsExpired: boolean` (see
|
|
7
|
+
# `ICredentialsHelper.runPreAuthentication` at :232-238). We use the
|
|
8
|
+
# same conceptual signal: if the credentials hash carries an
|
|
9
|
+
# `expires_at` (unix seconds) and it's in the past — OR no expires_at
|
|
10
|
+
# has ever been set — the hook fires and its return value is merged
|
|
11
|
+
# into the grant's credentials before the AuthenticateGeneric step.
|
|
12
|
+
#
|
|
13
|
+
# Reference impl: CrowdStrikeOAuth2Api.credentials.ts:62-76 — fetches
|
|
14
|
+
# an access token from `/oauth2/token` using client_id + client_secret,
|
|
15
|
+
# returns `{ sessionToken }` which feeds `authenticate.headers.Authorization`.
|
|
16
|
+
class PreAuthentication < Faraday::Middleware
|
|
17
|
+
def initialize(app, grant:, pre_auth_block:)
|
|
18
|
+
super(app)
|
|
19
|
+
@grant = grant
|
|
20
|
+
@block = pre_auth_block
|
|
21
|
+
@helpers = PreAuthenticationHelpers.new
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(env)
|
|
25
|
+
refresh_if_expired!
|
|
26
|
+
@app.call(env)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def refresh_if_expired!
|
|
32
|
+
return unless credentials_expired?
|
|
33
|
+
@grant.with_persistence_lock do
|
|
34
|
+
next unless credentials_expired?
|
|
35
|
+
delta = run_block
|
|
36
|
+
next if delta.nil? || delta.empty?
|
|
37
|
+
@grant.update_credentials!(delta.transform_keys(&:to_s))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def credentials_expired?
|
|
42
|
+
expires_at = @grant.credentials_hash["expires_at"]
|
|
43
|
+
return true if expires_at.nil?
|
|
44
|
+
Time.now.to_i >= expires_at.to_i
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def run_block
|
|
48
|
+
@block.call(@grant.credentials_hash, @helpers)
|
|
49
|
+
rescue => e
|
|
50
|
+
raise Connectors::AuthenticationFailed.new("pre_authentication failed: #{e.class}: #{e.message}")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Middleware
|
|
5
|
+
# Per-grant token-bucket rate limiter backed by Rails.cache (typically Solid
|
|
6
|
+
# Cache in production). No-op unless the connector class declares
|
|
7
|
+
# `rate_limit count, per: window`.
|
|
8
|
+
#
|
|
9
|
+
# class SlackConnector < Connectors::Connector
|
|
10
|
+
# connector key: :slack, auth: :oauth2, base_url: "..."
|
|
11
|
+
# rate_limit 50, per: 1.minute
|
|
12
|
+
# end
|
|
13
|
+
class RateLimit < Faraday::Middleware
|
|
14
|
+
def initialize(app, grant:)
|
|
15
|
+
super(app)
|
|
16
|
+
@grant = grant
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(env)
|
|
20
|
+
config = @grant.connector.class.rate_limit_config
|
|
21
|
+
enforce!(config) if config
|
|
22
|
+
@app.call(env)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def enforce!(config)
|
|
28
|
+
key = "connectors:ratelimit:#{@grant.connector_key}:#{@grant.id}"
|
|
29
|
+
count = Rails.cache.increment(key, 1, expires_in: config[:per])
|
|
30
|
+
count ||= Rails.cache.write(key, 1, expires_in: config[:per]) && 1
|
|
31
|
+
|
|
32
|
+
return if count <= config[:limit]
|
|
33
|
+
raise Connectors::RateLimited.new(
|
|
34
|
+
"local rate limit exceeded (#{config[:limit]} per #{config[:per].inspect})",
|
|
35
|
+
status: 429
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module OAuth
|
|
5
|
+
# Builds the URL we redirect the owner to so they can authorize the
|
|
6
|
+
# connector. Includes an encrypted `state` token that the callback validates.
|
|
7
|
+
class AuthorizeUrl
|
|
8
|
+
def self.for(connector_class, owner:, return_to: nil, scope: nil, name: nil, grant: nil)
|
|
9
|
+
new(connector_class).build(owner: owner, return_to: return_to, scope: scope, name: name, grant: grant)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(connector_class)
|
|
13
|
+
@connector_class = connector_class
|
|
14
|
+
@config = connector_class.oauth2_config or
|
|
15
|
+
raise Connectors::Error.new("#{connector_class}: oauth2 ... DSL not declared")
|
|
16
|
+
@secrets = Connectors.configuration.oauth_credentials_for(connector_class.connector_key)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The connector declares a DEFAULT scope set; callers can override it
|
|
20
|
+
# per request via the `scope:` argument (multi-tenant hosts often
|
|
21
|
+
# need a wider or narrower scope than the connector's baseline).
|
|
22
|
+
# n8n's `Gmail OAuth2 API.credentials.ts` does the same thing — the
|
|
23
|
+
# node ships defaults, the workflow editor can ask for more.
|
|
24
|
+
def build(owner:, return_to: nil, scope: nil, name: nil, grant: nil)
|
|
25
|
+
pkce = pkce_params if @config[:grant_type].to_s == "pkce"
|
|
26
|
+
|
|
27
|
+
extras = {}
|
|
28
|
+
extras["cv"] = pkce[:verifier] if pkce
|
|
29
|
+
extras["n"] = name if name.to_s.length.positive?
|
|
30
|
+
if grant
|
|
31
|
+
extras["g"] = grant.id
|
|
32
|
+
extras["v"] = GrantWriter.fingerprint(grant)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
state = State.encode(
|
|
36
|
+
connector_key: @connector_class.connector_key,
|
|
37
|
+
owner_gid: owner.to_global_id.to_s,
|
|
38
|
+
return_to: return_to,
|
|
39
|
+
extra: extras.empty? ? nil : extras
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
effective_scope = scope.presence || @config[:scope]
|
|
43
|
+
|
|
44
|
+
params = {
|
|
45
|
+
response_type: "code",
|
|
46
|
+
client_id: @secrets[:client_id],
|
|
47
|
+
redirect_uri: redirect_uri,
|
|
48
|
+
scope: effective_scope,
|
|
49
|
+
state: state
|
|
50
|
+
}
|
|
51
|
+
if pkce
|
|
52
|
+
params[:code_challenge] = pkce[:challenge]
|
|
53
|
+
params[:code_challenge_method] = pkce[:method]
|
|
54
|
+
end
|
|
55
|
+
params = params.merge(@config[:extra_authorize_params] || {}).compact
|
|
56
|
+
|
|
57
|
+
uri = URI.parse(@config[:authorize_url])
|
|
58
|
+
existing = URI.decode_www_form(uri.query.to_s)
|
|
59
|
+
uri.query = URI.encode_www_form(existing + params.to_a)
|
|
60
|
+
uri.to_s
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def pkce_params
|
|
66
|
+
Connectors::OAuth::Pkce.generate
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Reads from Connectors.configuration.app_callback_url when the host
|
|
70
|
+
# has wired a separate frontend callback page (Activepieces-style split
|
|
71
|
+
# frontend/backend); otherwise falls back to the engine's own
|
|
72
|
+
# `/<connector>/callback` route (n8n-style monolithic).
|
|
73
|
+
def redirect_uri
|
|
74
|
+
Connectors.configuration.resolved_app_callback_url(@connector_class.connector_key)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
module OAuth
|
|
6
|
+
# RFC 6749 client authentication, shared by code, refresh and client-
|
|
7
|
+
# credentials grants. A public PKCE client sends only its client ID.
|
|
8
|
+
module ClientAuthentication
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def apply(body, headers, secrets:, authentication:)
|
|
12
|
+
if secrets[:client_secret].blank? || authentication == "body"
|
|
13
|
+
body[:client_id] = secrets[:client_id]
|
|
14
|
+
body[:client_secret] = secrets[:client_secret] if secrets[:client_secret].present?
|
|
15
|
+
elsif authentication == "header"
|
|
16
|
+
encoded = [ secrets[:client_id], secrets[:client_secret] ].map { |value| URI.encode_www_form_component(value.to_s) }
|
|
17
|
+
headers["Authorization"] = "Basic #{Base64.strict_encode64(encoded.join(':'))}"
|
|
18
|
+
else
|
|
19
|
+
raise ArgumentError, "unsupported OAuth client authentication: #{authentication.inspect}"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
module OAuth
|
|
6
|
+
# Server-to-server OAuth2 flow — no user redirect. POSTs
|
|
7
|
+
# `grant_type=client_credentials` + `client_id`/`client_secret` to the
|
|
8
|
+
# provider's token endpoint and returns a normalized token hash that the
|
|
9
|
+
# OAuthController writes to a new or explicitly selected Grant.
|
|
10
|
+
#
|
|
11
|
+
# n8n parity: `OAuth2Api.credentials.ts:1-45` declares the `grantType`
|
|
12
|
+
# field with `clientCredentials` as one option; the runtime branch lives
|
|
13
|
+
# at `oauth.service.ts:783-789`.
|
|
14
|
+
class ClientCredentials
|
|
15
|
+
def self.exchange(connector_class)
|
|
16
|
+
new(connector_class).exchange
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(connector_class)
|
|
20
|
+
@connector_class = connector_class
|
|
21
|
+
@config = connector_class.oauth2_config or
|
|
22
|
+
raise Connectors::Error.new("#{connector_class}: oauth2 ... DSL not declared")
|
|
23
|
+
@secrets = Connectors.configuration.oauth_credentials_for(connector_class.connector_key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exchange
|
|
27
|
+
body = { grant_type: "client_credentials" }
|
|
28
|
+
body[:scope] = @config[:scope] if @config[:scope].to_s.length.positive?
|
|
29
|
+
|
|
30
|
+
headers = { "Content-Type" => "application/x-www-form-urlencoded", "Accept" => "application/json" }
|
|
31
|
+
|
|
32
|
+
ClientAuthentication.apply(body, headers, secrets: @secrets, authentication: @config[:authentication])
|
|
33
|
+
|
|
34
|
+
response = Faraday.post(@config[:token_url], URI.encode_www_form(body), headers) do |request|
|
|
35
|
+
request.options.open_timeout = ClientBuilder::DEFAULT_OPEN_TIMEOUT
|
|
36
|
+
request.options.timeout = ClientBuilder::DEFAULT_TIMEOUT
|
|
37
|
+
end
|
|
38
|
+
parsed = TokenResponse.parse(response)
|
|
39
|
+
@connector_class.post_token_exchange(parsed, TokenResponse.normalize(parsed))
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module OAuth
|
|
5
|
+
# New authorizations create new connections. Reconnection must name an
|
|
6
|
+
# existing connection and preserve its owner, provider and known account.
|
|
7
|
+
class GrantWriter
|
|
8
|
+
def self.fingerprint(grant)
|
|
9
|
+
Digest::SHA256.hexdigest(JSON.generate([ grant.credentials, grant.external_account_id, grant.status ]))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(owner:, connector_class:, grant_id: nil, fingerprint: nil)
|
|
13
|
+
@owner = owner
|
|
14
|
+
@connector_class = connector_class
|
|
15
|
+
@grant_id = grant_id
|
|
16
|
+
@fingerprint = fingerprint
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def validate!
|
|
20
|
+
return unless @grant_id
|
|
21
|
+
verify_snapshot!(target)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(tokens, name: nil)
|
|
25
|
+
unless @grant_id
|
|
26
|
+
grant = Grant.new(owner: @owner, connector_key: @connector_class.connector_key.to_s)
|
|
27
|
+
return persist(grant, tokens, name)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
grant = target
|
|
31
|
+
grant.with_lock do
|
|
32
|
+
# Ownership/credentials may have changed while the provider request
|
|
33
|
+
# was in flight. Never overwrite that newer connection snapshot.
|
|
34
|
+
verify_snapshot!(grant)
|
|
35
|
+
persist(grant, tokens, name)
|
|
36
|
+
end
|
|
37
|
+
grant
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def target
|
|
43
|
+
Grant.where(owner: @owner).for_connector(@connector_class.connector_key).find(@grant_id)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def verify_snapshot!(grant)
|
|
47
|
+
unless grant.owner == @owner && grant.connector_key == @connector_class.connector_key.to_s &&
|
|
48
|
+
@fingerprint == self.class.fingerprint(grant)
|
|
49
|
+
raise Connectors::Error, "connection changed during authorization; start again"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def persist(grant, tokens, name)
|
|
54
|
+
account_id = @connector_class.external_account_id_from_credentials(tokens)
|
|
55
|
+
if grant.external_account_id.present? && account_id.present? && grant.external_account_id != account_id
|
|
56
|
+
raise Connectors::Error, "authorized account does not match this connection"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
credentials = grant.stored_credentials_hash.merge(tokens)
|
|
60
|
+
grant.assign_attributes(
|
|
61
|
+
credentials: credentials,
|
|
62
|
+
status: :active,
|
|
63
|
+
expires_at: (Time.at(credentials["expires_at"]) if credentials["expires_at"]),
|
|
64
|
+
last_used_at: Time.current,
|
|
65
|
+
external_account_id: account_id.presence || grant.external_account_id
|
|
66
|
+
)
|
|
67
|
+
grant.display_name = name if name.present?
|
|
68
|
+
grant.save!
|
|
69
|
+
grant
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "base64"
|
|
4
|
+
|
|
5
|
+
module Connectors
|
|
6
|
+
module OAuth
|
|
7
|
+
# RFC 7636 — Proof Key for Code Exchange. We generate a random
|
|
8
|
+
# `code_verifier`, derive `code_challenge = BASE64URL(SHA256(verifier))`
|
|
9
|
+
# (method `S256`), and embed the verifier in the signed OAuth state
|
|
10
|
+
# token so the callback can include it in the token exchange.
|
|
11
|
+
#
|
|
12
|
+
# n8n parity: `oauth.service.ts:578-586` — same `S256` derivation,
|
|
13
|
+
# verifier stashed in encrypted credential data until callback. We use
|
|
14
|
+
# the signed state token instead of a DB write so the flow stays
|
|
15
|
+
# stateless on the engine side.
|
|
16
|
+
module Pkce
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
VERIFIER_BYTES = 64
|
|
20
|
+
|
|
21
|
+
def generate
|
|
22
|
+
verifier = base64url(SecureRandom.random_bytes(VERIFIER_BYTES))
|
|
23
|
+
challenge = base64url(Digest::SHA256.digest(verifier))
|
|
24
|
+
{ verifier: verifier, challenge: challenge, method: "S256" }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def base64url(bytes)
|
|
28
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
module OAuth
|
|
6
|
+
# RFC 7009 token revocation. POSTs `token=<access_token>` to the
|
|
7
|
+
# provider's revoke endpoint. The connector class declares the URL via
|
|
8
|
+
# `revoke_token_url "https://..."`; some providers want a different
|
|
9
|
+
# request shape (Google's revoke is `?token=`, etc.), so a connector
|
|
10
|
+
# can also override the whole call with a block:
|
|
11
|
+
#
|
|
12
|
+
# revoke_token do |connector, grant|
|
|
13
|
+
# connector.client.delete("oauth/tokens/#{grant.credentials_hash['access_token']}")
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# n8n parity: per-provider, no centralized contract — n8n's oauth
|
|
17
|
+
# service implements it ad-hoc on a per-credential basis. We give
|
|
18
|
+
# connectors a single uniform DSL.
|
|
19
|
+
class Revoke
|
|
20
|
+
def self.call(connector_class, grant)
|
|
21
|
+
new(connector_class, grant).call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(connector_class, grant)
|
|
25
|
+
@connector_class = connector_class
|
|
26
|
+
@grant = grant
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
if (block = @connector_class.revoke_token_block)
|
|
31
|
+
@connector_class.new(@grant).instance_exec(@grant, &block)
|
|
32
|
+
return true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
url = @connector_class.revoke_token_url or
|
|
36
|
+
raise Connectors::Error.new("#{@connector_class}: no revoke_token_url or revoke_token block declared")
|
|
37
|
+
|
|
38
|
+
token = @grant.credentials_hash["access_token"] || @grant.credentials_hash["refresh_token"]
|
|
39
|
+
raise Connectors::Error.new("grant has no access_token to revoke") if token.to_s.empty?
|
|
40
|
+
|
|
41
|
+
secrets = secrets_for_connector
|
|
42
|
+
body = { token: token, client_id: secrets[:client_id], client_secret: secrets[:client_secret] }.compact
|
|
43
|
+
|
|
44
|
+
response = Faraday.post(url, URI.encode_www_form(body), {
|
|
45
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
46
|
+
"Accept" => "application/json"
|
|
47
|
+
}) do |request|
|
|
48
|
+
request.options.open_timeout = ClientBuilder::DEFAULT_OPEN_TIMEOUT
|
|
49
|
+
request.options.timeout = ClientBuilder::DEFAULT_TIMEOUT
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
if response.status >= 400
|
|
53
|
+
raise Connectors::AuthenticationFailed.new(
|
|
54
|
+
"OAuth token revocation failed (#{response.status}): #{response.body}",
|
|
55
|
+
status: response.status,
|
|
56
|
+
body: response.body
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def secrets_for_connector
|
|
66
|
+
Connectors.configuration.oauth_credentials_for(@connector_class.connector_key)
|
|
67
|
+
rescue Connectors::Error
|
|
68
|
+
{}
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module OAuth
|
|
5
|
+
# Stateless CSRF token for the OAuth authorize → callback round trip.
|
|
6
|
+
# Carries the connector key, owner GlobalID, and an optional return-to URL,
|
|
7
|
+
# authenticated and encrypted so PKCE/OAuth1 secrets never appear in the
|
|
8
|
+
# authorization URL. Expires in 15 minutes. This is not a session cookie.
|
|
9
|
+
module State
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
EXPIRES_IN = 15.minutes
|
|
13
|
+
PURPOSE = :connectors_oauth_state
|
|
14
|
+
|
|
15
|
+
def encode(connector_key:, owner_gid:, return_to: nil, extra: nil)
|
|
16
|
+
payload = {
|
|
17
|
+
"k" => connector_key.to_s,
|
|
18
|
+
"o" => owner_gid,
|
|
19
|
+
"r" => return_to,
|
|
20
|
+
"n" => SecureRandom.hex(16),
|
|
21
|
+
"t" => Time.now.to_i
|
|
22
|
+
}
|
|
23
|
+
payload["x"] = extra if extra.is_a?(Hash) && extra.any?
|
|
24
|
+
encryptor.encrypt_and_sign(payload, expires_in: EXPIRES_IN, purpose: PURPOSE)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def decode(token)
|
|
28
|
+
return nil unless token.is_a?(String) && token.present?
|
|
29
|
+
encryptor.decrypt_and_verify(token, purpose: PURPOSE).presence
|
|
30
|
+
rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
|
|
31
|
+
nil
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def encryptor
|
|
35
|
+
cipher = "aes-256-gcm"
|
|
36
|
+
key = Rails.application.key_generator.generate_key(PURPOSE.to_s, ActiveSupport::MessageEncryptor.key_len(cipher))
|
|
37
|
+
ActiveSupport::MessageEncryptor.new(key, cipher: cipher, serializer: JSON)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "uri"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
module OAuth
|
|
6
|
+
# Talks to the provider's token endpoint for both the authorization-code
|
|
7
|
+
# exchange (initial connection) and refresh-token grant (token rotation).
|
|
8
|
+
# Returns a normalized hash that can be merged directly into a Grant's
|
|
9
|
+
# credentials column.
|
|
10
|
+
class TokenExchange
|
|
11
|
+
def self.exchange_code(connector_class, code:, code_verifier: nil)
|
|
12
|
+
new(connector_class).exchange_code(code, code_verifier: code_verifier)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.refresh(connector_class, refresh_token:)
|
|
16
|
+
new(connector_class).refresh(refresh_token)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(connector_class)
|
|
20
|
+
@connector_class = connector_class
|
|
21
|
+
@config = connector_class.oauth2_config or
|
|
22
|
+
raise Connectors::Error.new("#{connector_class}: oauth2 ... DSL not declared")
|
|
23
|
+
@secrets = Connectors.configuration.oauth_credentials_for(connector_class.connector_key)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def exchange_code(code, code_verifier: nil)
|
|
27
|
+
# PKCE supplements client authentication for confidential clients.
|
|
28
|
+
params = {
|
|
29
|
+
grant_type: "authorization_code",
|
|
30
|
+
code: code,
|
|
31
|
+
redirect_uri: redirect_uri
|
|
32
|
+
}
|
|
33
|
+
params[:code_verifier] = code_verifier if code_verifier
|
|
34
|
+
post_token(params)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def refresh(refresh_token)
|
|
38
|
+
post_token(
|
|
39
|
+
grant_type: "refresh_token",
|
|
40
|
+
refresh_token: refresh_token
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def post_token(params)
|
|
47
|
+
headers = {
|
|
48
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
49
|
+
"Accept" => "application/json"
|
|
50
|
+
}
|
|
51
|
+
ClientAuthentication.apply(params, headers, secrets: @secrets, authentication: @config[:authentication])
|
|
52
|
+
response = Faraday.post(@config[:token_url], URI.encode_www_form(params.compact), headers) do |request|
|
|
53
|
+
request.options.open_timeout = ClientBuilder::DEFAULT_OPEN_TIMEOUT
|
|
54
|
+
request.options.timeout = ClientBuilder::DEFAULT_TIMEOUT
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
body = TokenResponse.parse(response)
|
|
58
|
+
@connector_class.post_token_exchange(body, TokenResponse.normalize(body))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Must match the redirect_uri sent during the authorize leg or the
|
|
62
|
+
# provider rejects the exchange with `redirect_uri_mismatch`. Both
|
|
63
|
+
# legs read from the same config slot so they stay in lockstep.
|
|
64
|
+
def redirect_uri
|
|
65
|
+
Connectors.configuration.resolved_app_callback_url(@connector_class.connector_key)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|