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.
Files changed (112) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +35 -0
  3. data/CONNECTORS_FRAMEWORK.md +799 -0
  4. data/CONTRIBUTING.md +60 -0
  5. data/MCP_CLIENT.md +168 -0
  6. data/MIT-LICENSE +20 -0
  7. data/README.md +146 -0
  8. data/app/connectors/clickup/connector.rb +13 -0
  9. data/app/connectors/gmail/api.rb +114 -0
  10. data/app/connectors/gmail/connector.rb +921 -0
  11. data/app/connectors/gmail/mime_builder.rb +261 -0
  12. data/app/connectors/gmail/mime_parser.rb +106 -0
  13. data/app/connectors/gmail/polling.rb +154 -0
  14. data/app/connectors/remote_mcp/connector.rb +18 -0
  15. data/app/connectors/resend/connector.rb +218 -0
  16. data/app/controllers/concerns/connectors/grant_access.rb +43 -0
  17. data/app/controllers/connectors/actions_controller.rb +66 -0
  18. data/app/controllers/connectors/application_controller.rb +5 -0
  19. data/app/controllers/connectors/credentials_controller.rb +245 -0
  20. data/app/controllers/connectors/grants_controller.rb +123 -0
  21. data/app/controllers/connectors/mcp_controller.rb +88 -0
  22. data/app/controllers/connectors/oauth_controller.rb +134 -0
  23. data/app/controllers/connectors/types_controller.rb +144 -0
  24. data/app/controllers/connectors/webhooks_controller.rb +105 -0
  25. data/app/jobs/connectors/application_job.rb +4 -0
  26. data/app/jobs/connectors/deliver_webhook_job.rb +27 -0
  27. data/app/jobs/connectors/poll_job.rb +49 -0
  28. data/app/models/connectors/application_record.rb +5 -0
  29. data/app/models/connectors/credential_share.rb +31 -0
  30. data/app/models/connectors/grant.rb +103 -0
  31. data/app/models/connectors/mcp_authorization.rb +6 -0
  32. data/app/models/connectors/mcp_interaction.rb +6 -0
  33. data/app/models/connectors/poll_state.rb +17 -0
  34. data/app/models/connectors/webhook_event.rb +19 -0
  35. data/config/routes.rb +68 -0
  36. data/db/migrate/20260518210324_create_connectors_grants.rb +49 -0
  37. data/db/migrate/20260518214609_create_connectors_webhook_events.rb +35 -0
  38. data/db/migrate/20260521140000_create_connectors_credential_shares.rb +26 -0
  39. data/db/migrate/20260922120000_create_connectors_poll_states.rb +12 -0
  40. data/db/migrate/20260922130000_create_connectors_mcp_transactions.rb +18 -0
  41. data/docs/adding-connectors.md +92 -0
  42. data/docs/architecture.md +71 -0
  43. data/docs/releasing.md +60 -0
  44. data/lib/connectors/action.rb +90 -0
  45. data/lib/connectors/action_builder.rb +125 -0
  46. data/lib/connectors/action_runner.rb +99 -0
  47. data/lib/connectors/auth/scheme/api_key.rb +51 -0
  48. data/lib/connectors/auth/scheme/oauth2.rb +23 -0
  49. data/lib/connectors/auth/scheme.rb +35 -0
  50. data/lib/connectors/auth.rb +4 -0
  51. data/lib/connectors/auth_injection.rb +65 -0
  52. data/lib/connectors/client_builder.rb +87 -0
  53. data/lib/connectors/configuration.rb +138 -0
  54. data/lib/connectors/connector.rb +565 -0
  55. data/lib/connectors/credential_schema.rb +219 -0
  56. data/lib/connectors/credential_tester.rb +85 -0
  57. data/lib/connectors/credential_type_registry.rb +162 -0
  58. data/lib/connectors/credential_types/http_auth.rb +171 -0
  59. data/lib/connectors/engine.rb +123 -0
  60. data/lib/connectors/errors.rb +88 -0
  61. data/lib/connectors/grant_policy.rb +13 -0
  62. data/lib/connectors/mcp/access.rb +50 -0
  63. data/lib/connectors/mcp/authorization.rb +177 -0
  64. data/lib/connectors/mcp/authorization_context.rb +25 -0
  65. data/lib/connectors/mcp/authorization_discovery.rb +42 -0
  66. data/lib/connectors/mcp/cancellation.rb +36 -0
  67. data/lib/connectors/mcp/client.rb +137 -0
  68. data/lib/connectors/mcp/connection_config.rb +48 -0
  69. data/lib/connectors/mcp/http.rb +108 -0
  70. data/lib/connectors/mcp/interaction.rb +82 -0
  71. data/lib/connectors/mcp/pending_transaction.rb +26 -0
  72. data/lib/connectors/mcp/protocol/2026-07-28.json +3963 -0
  73. data/lib/connectors/mcp/protocol/LICENSE +216 -0
  74. data/lib/connectors/mcp/protocol/README.md +8 -0
  75. data/lib/connectors/mcp/protocol_schema.rb +30 -0
  76. data/lib/connectors/mcp/schema.rb +45 -0
  77. data/lib/connectors/mcp/settings.rb +27 -0
  78. data/lib/connectors/mcp/token_endpoint.rb +48 -0
  79. data/lib/connectors/mcp/transport.rb +105 -0
  80. data/lib/connectors/mcp.rb +69 -0
  81. data/lib/connectors/middleware/authenticate_generic.rb +79 -0
  82. data/lib/connectors/middleware/auto_refresh.rb +71 -0
  83. data/lib/connectors/middleware/error_normalization.rb +45 -0
  84. data/lib/connectors/middleware/grant_status.rb +19 -0
  85. data/lib/connectors/middleware/pre_authentication.rb +54 -0
  86. data/lib/connectors/middleware/rate_limit.rb +40 -0
  87. data/lib/connectors/oauth/authorize_url.rb +78 -0
  88. data/lib/connectors/oauth/client_authentication.rb +24 -0
  89. data/lib/connectors/oauth/client_credentials.rb +43 -0
  90. data/lib/connectors/oauth/grant_writer.rb +73 -0
  91. data/lib/connectors/oauth/pkce.rb +32 -0
  92. data/lib/connectors/oauth/revoke.rb +72 -0
  93. data/lib/connectors/oauth/state.rb +41 -0
  94. data/lib/connectors/oauth/token_exchange.rb +69 -0
  95. data/lib/connectors/oauth/token_response.rb +40 -0
  96. data/lib/connectors/oauth.rb +4 -0
  97. data/lib/connectors/oauth1.rb +151 -0
  98. data/lib/connectors/permission_check.rb +33 -0
  99. data/lib/connectors/poll_runner.rb +50 -0
  100. data/lib/connectors/pre_authentication_helpers.rb +76 -0
  101. data/lib/connectors/registry.rb +38 -0
  102. data/lib/connectors/version.rb +3 -0
  103. data/lib/connectors/webhook_context.rb +62 -0
  104. data/lib/connectors/webhook_lifecycle.rb +69 -0
  105. data/lib/connectors/webhook_methods.rb +48 -0
  106. data/lib/connectors/webhooks/verifier.rb +31 -0
  107. data/lib/connectors/webhooks.rb +5 -0
  108. data/lib/connectors.rb +50 -0
  109. data/lib/tasks/connectors_mcp.rake +9 -0
  110. data/lib/tasks/connectors_tasks.rake +4 -0
  111. data/openapi.yaml +1099 -0
  112. 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