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,90 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Declarative description of one thing a connector can DO with a credential
|
|
3
|
+
# — Slack "post message", Resend "send email", GitHub "create issue". The
|
|
4
|
+
# equivalent of an n8n node's `(resource, operation)` slot
|
|
5
|
+
# (packages/workflow/src/interfaces.ts NodeProperties) and Activepieces'
|
|
6
|
+
# `createAction(...)` declaration.
|
|
7
|
+
#
|
|
8
|
+
# The class is a value object — `ActionBuilder` materialises it from the
|
|
9
|
+
# DSL, `Connector.actions` stores them, `ActionRunner` invokes them.
|
|
10
|
+
# Frontend / agents read `to_manifest` to know what inputs the action
|
|
11
|
+
# accepts and what shape it returns.
|
|
12
|
+
#
|
|
13
|
+
# action :send_email,
|
|
14
|
+
# display_name: "Send Email",
|
|
15
|
+
# description: "Send a transactional email via Resend." do
|
|
16
|
+
# field :from, type: "string", required: true,
|
|
17
|
+
# placeholder: "Updates <updates@yourdomain.com>"
|
|
18
|
+
# field :to, type: "string", required: true,
|
|
19
|
+
# type_options: { multiple_values: true }
|
|
20
|
+
# field :subject, type: "string", required: true
|
|
21
|
+
# field :html, type: "string", type_options: { editor: "html" }
|
|
22
|
+
# field :text, type: "string", type_options: { rows: 4 }
|
|
23
|
+
#
|
|
24
|
+
# output do
|
|
25
|
+
# field :id, type: "string", description: "Resend message id."
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# execute do |input|
|
|
29
|
+
# send_email(**input.symbolize_keys)
|
|
30
|
+
# end
|
|
31
|
+
# end
|
|
32
|
+
class Action
|
|
33
|
+
# n8n's `NodePropertyTypes` (interfaces.ts:1561-1584) — a superset of what
|
|
34
|
+
# credentials accept. Action params can be richer than credential fields
|
|
35
|
+
# because they're the user's per-execution inputs, not the auth setup.
|
|
36
|
+
ALLOWED_TYPES = %w[
|
|
37
|
+
string number boolean
|
|
38
|
+
options multiOptions
|
|
39
|
+
json hidden notice
|
|
40
|
+
collection fixedCollection
|
|
41
|
+
dateTime color
|
|
42
|
+
resourceLocator resourceMapper
|
|
43
|
+
filter assignmentCollection
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
attr_reader :key, :display_name, :description, :params, :output, :execute_block,
|
|
47
|
+
:tags, :deprecated
|
|
48
|
+
|
|
49
|
+
def initialize(key:, display_name:, description: nil,
|
|
50
|
+
params: [], output: [], execute_block: nil,
|
|
51
|
+
tags: [], deprecated: false)
|
|
52
|
+
raise ArgumentError, "execute block is required for action #{key.inspect}" if execute_block.nil?
|
|
53
|
+
|
|
54
|
+
@key = key.to_sym
|
|
55
|
+
@display_name = display_name || key.to_s.tr("_", " ").capitalize
|
|
56
|
+
@description = description
|
|
57
|
+
@params = params
|
|
58
|
+
@output = output
|
|
59
|
+
@execute_block = execute_block
|
|
60
|
+
@tags = Array(tags).map(&:to_sym)
|
|
61
|
+
@deprecated = deprecated == true
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Frontend / agent-facing shape. camelCase the keys that go into the
|
|
65
|
+
# property objects so they look identical to credential properties (the
|
|
66
|
+
# generic form renderer handles both with no special-casing). Output
|
|
67
|
+
# shape is the same — fields describing what the runner returns.
|
|
68
|
+
def to_manifest
|
|
69
|
+
{
|
|
70
|
+
name: key.to_s,
|
|
71
|
+
display_name: display_name,
|
|
72
|
+
description: description,
|
|
73
|
+
deprecated: deprecated,
|
|
74
|
+
tags: tags.map(&:to_s),
|
|
75
|
+
properties: params.map(&:to_property),
|
|
76
|
+
output: output.map(&:to_property)
|
|
77
|
+
}.compact
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Names of params marked `required: true`. Used by ActionRunner to
|
|
81
|
+
# short-circuit before the execute block runs.
|
|
82
|
+
def required_param_names
|
|
83
|
+
params.select(&:required?).map { |f| f.name.to_s }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def param_names
|
|
87
|
+
params.map { |f| f.name.to_s }
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# DSL surface for the `action` block on a Connector subclass. Collects
|
|
3
|
+
# `field`, optional `output`, and `execute` declarations into an Action
|
|
4
|
+
# value object.
|
|
5
|
+
#
|
|
6
|
+
# Reuses `CredentialSchema::Field` as the property struct because n8n's
|
|
7
|
+
# node-property shape and credential-property shape are identical (both
|
|
8
|
+
# `INodeProperties` at packages/workflow/src/interfaces.ts:1773-1812) —
|
|
9
|
+
# only the allowed `type` set differs (see `Action::ALLOWED_TYPES`).
|
|
10
|
+
class ActionBuilder
|
|
11
|
+
Field = CredentialSchema::Field
|
|
12
|
+
|
|
13
|
+
def self.build(key, display_name: nil, description: nil, tags: [], deprecated: false, &block)
|
|
14
|
+
builder = new(key, display_name: display_name, description: description,
|
|
15
|
+
tags: tags, deprecated: deprecated)
|
|
16
|
+
builder.instance_eval(&block) if block
|
|
17
|
+
builder.to_action
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(key, display_name:, description:, tags:, deprecated:)
|
|
21
|
+
@key = key
|
|
22
|
+
@display_name = display_name
|
|
23
|
+
@description = description
|
|
24
|
+
@tags = tags
|
|
25
|
+
@deprecated = deprecated
|
|
26
|
+
@params = []
|
|
27
|
+
@output = []
|
|
28
|
+
@execute_block = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Declare one input parameter. Mirrors `CredentialSchema#field` with the
|
|
32
|
+
# broader `Action::ALLOWED_TYPES` set — same kwargs, same semantics.
|
|
33
|
+
def field(name,
|
|
34
|
+
type: "string",
|
|
35
|
+
display_name: nil,
|
|
36
|
+
default: nil,
|
|
37
|
+
placeholder: nil,
|
|
38
|
+
description: nil,
|
|
39
|
+
hint: nil,
|
|
40
|
+
required: false,
|
|
41
|
+
no_data_expression: false,
|
|
42
|
+
type_options: nil,
|
|
43
|
+
display_options: nil,
|
|
44
|
+
options: nil)
|
|
45
|
+
raise ArgumentError, "unknown action field type #{type.inspect}; allowed: #{Action::ALLOWED_TYPES.inspect}" \
|
|
46
|
+
unless Action::ALLOWED_TYPES.include?(type.to_s)
|
|
47
|
+
|
|
48
|
+
@params << Field.new(
|
|
49
|
+
name: name.to_sym,
|
|
50
|
+
display_name: display_name,
|
|
51
|
+
type: type.to_s,
|
|
52
|
+
default: default,
|
|
53
|
+
placeholder: placeholder,
|
|
54
|
+
description: description,
|
|
55
|
+
hint: hint,
|
|
56
|
+
required: required == true,
|
|
57
|
+
no_data_expression: no_data_expression == true,
|
|
58
|
+
type_options: type_options,
|
|
59
|
+
display_options: display_options,
|
|
60
|
+
options: options
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Declare the shape of the value the action returns. Optional but
|
|
65
|
+
# encouraged — agents and downstream nodes use it to know what fields
|
|
66
|
+
# they can reference (Activepieces calls this `returns:`). The same
|
|
67
|
+
# `field` DSL is used inside the block.
|
|
68
|
+
def output(&block)
|
|
69
|
+
raise ArgumentError, "output requires a block" if block.nil?
|
|
70
|
+
collector = OutputCollector.new
|
|
71
|
+
collector.instance_eval(&block)
|
|
72
|
+
@output = collector.fields
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Capture the execute block. Invoked by `ActionRunner` in the connector
|
|
76
|
+
# instance's context, so `self.client`, `self.send_email`, etc. are all
|
|
77
|
+
# accessible. Receives one positional Hash of params.
|
|
78
|
+
def execute(&block)
|
|
79
|
+
raise ArgumentError, "execute requires a block" if block.nil?
|
|
80
|
+
@execute_block = block
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def to_action
|
|
84
|
+
Action.new(
|
|
85
|
+
key: @key,
|
|
86
|
+
display_name: @display_name,
|
|
87
|
+
description: @description,
|
|
88
|
+
params: @params,
|
|
89
|
+
output: @output,
|
|
90
|
+
execute_block: @execute_block,
|
|
91
|
+
tags: @tags,
|
|
92
|
+
deprecated: @deprecated
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Tiny sibling builder so `output do field ... end` can reuse the same
|
|
97
|
+
# `field` semantics without picking up the outer builder's @params slot.
|
|
98
|
+
class OutputCollector
|
|
99
|
+
attr_reader :fields
|
|
100
|
+
|
|
101
|
+
def initialize
|
|
102
|
+
@fields = []
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def field(name,
|
|
106
|
+
type: "string",
|
|
107
|
+
display_name: nil,
|
|
108
|
+
description: nil,
|
|
109
|
+
hint: nil,
|
|
110
|
+
type_options: nil,
|
|
111
|
+
options: nil)
|
|
112
|
+
@fields << CredentialSchema::Field.new(
|
|
113
|
+
name: name.to_sym,
|
|
114
|
+
display_name: display_name,
|
|
115
|
+
type: type.to_s,
|
|
116
|
+
description: description,
|
|
117
|
+
hint: hint,
|
|
118
|
+
required: false,
|
|
119
|
+
type_options: type_options,
|
|
120
|
+
options: options
|
|
121
|
+
)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Validates an Action's input params and invokes its execute block in the
|
|
3
|
+
# connector instance's context. Returns a normalized response envelope —
|
|
4
|
+
# both the controller and the (eventual) workflow executor consume the
|
|
5
|
+
# same shape.
|
|
6
|
+
#
|
|
7
|
+
# result = ActionRunner.call(grant, :send_email, { "from" => "x", "to" => "y", ... })
|
|
8
|
+
# # => { status: "ok", action: "send_email", data: { "id" => "abc-123" } }
|
|
9
|
+
# # or
|
|
10
|
+
# # => { status: "error", action: "send_email",
|
|
11
|
+
# # error: { type: "api_error", message: "...", status: 422 } }
|
|
12
|
+
#
|
|
13
|
+
# The runner does not catch arbitrary StandardError — only Connectors::
|
|
14
|
+
# errors. Anything else propagates so the caller can render a 500. This
|
|
15
|
+
# mirrors how the workflow executor expects "unexpected" failures to be
|
|
16
|
+
# visible in logs rather than silently absorbed.
|
|
17
|
+
class ActionRunner
|
|
18
|
+
OK = "ok".freeze
|
|
19
|
+
ERROR = "error".freeze
|
|
20
|
+
|
|
21
|
+
def self.call(grant, action_name, input)
|
|
22
|
+
new(grant, action_name, input).call
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(grant, action_name, input)
|
|
26
|
+
@grant = grant
|
|
27
|
+
@action_name = action_name
|
|
28
|
+
@input = (input || {}).transform_keys(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call
|
|
32
|
+
action = @grant.connector.class.action_lookup(@action_name)
|
|
33
|
+
@grant.ensure_not_revoked!
|
|
34
|
+
validate!(action)
|
|
35
|
+
|
|
36
|
+
result = @grant.connector.instance_exec(coerced_input(action), &action.execute_block)
|
|
37
|
+
|
|
38
|
+
{ status: OK, action: action.key.to_s, data: result }
|
|
39
|
+
rescue Connectors::InvalidActionParams => e
|
|
40
|
+
error_envelope(action&.key,
|
|
41
|
+
type: "invalid_params", message: e.message,
|
|
42
|
+
details: { missing: e.missing, unknown: e.unknown })
|
|
43
|
+
rescue Connectors::AuthenticationFailed => e
|
|
44
|
+
error_envelope(action&.key, type: "authentication_failed", message: e.message,
|
|
45
|
+
details: { status: safe_status(e) })
|
|
46
|
+
rescue Connectors::Forbidden => e
|
|
47
|
+
error_envelope(action&.key, type: "forbidden", message: e.message, details: { status: safe_status(e) })
|
|
48
|
+
rescue Connectors::RateLimited => e
|
|
49
|
+
error_envelope(action&.key, type: "rate_limited", message: e.message,
|
|
50
|
+
details: { status: safe_status(e), retry_after: e.retry_after })
|
|
51
|
+
rescue Connectors::ApiError => e
|
|
52
|
+
error_envelope(action&.key, type: "api_error", message: e.message,
|
|
53
|
+
details: { status: safe_status(e), body: e.body })
|
|
54
|
+
rescue Connectors::UnknownAction => e
|
|
55
|
+
# `action` is nil here — re-raise so the controller renders a 404
|
|
56
|
+
# rather than a 200 with an error body (mirrors UnknownConnector).
|
|
57
|
+
raise
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def validate!(action)
|
|
63
|
+
provided = @input.keys
|
|
64
|
+
missing = action.required_param_names.reject { |k| @input.key?(k) && !blank?(@input[k]) }
|
|
65
|
+
raise Connectors::InvalidActionParams.new(missing: missing) if missing.any?
|
|
66
|
+
# Note: we intentionally don't reject `unknown:` keys. n8n nodes
|
|
67
|
+
# frequently pass `additionalFields` collections that pack many
|
|
68
|
+
# optional values under one key; the connector's execute block
|
|
69
|
+
# decides which subset to honor. Strict-mode rejection can come
|
|
70
|
+
# later as an opt-in DSL flag.
|
|
71
|
+
_ = provided
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Whitelist incoming keys to the action's declared param names so the
|
|
75
|
+
# execute block doesn't see surprise data from a malformed caller. Keys
|
|
76
|
+
# arrive as strings (JSON), which is what every connector method in the
|
|
77
|
+
# codebase keyword-splats from — execute blocks call `**input.symbolize_keys`.
|
|
78
|
+
def coerced_input(action)
|
|
79
|
+
allowed = action.param_names.to_set
|
|
80
|
+
@input.slice(*allowed)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def blank?(value)
|
|
84
|
+
value.nil? || (value.respond_to?(:empty?) && value.empty?)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def safe_status(err)
|
|
88
|
+
err.respond_to?(:status) ? err.status : nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def error_envelope(action_key, type:, message:, details: {})
|
|
92
|
+
{
|
|
93
|
+
status: ERROR,
|
|
94
|
+
action: action_key.to_s,
|
|
95
|
+
error: { type: type, message: message }.merge(details.compact)
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Auth
|
|
5
|
+
class Scheme
|
|
6
|
+
# Attaches a static API key from grant.credentials["api_key"]. The
|
|
7
|
+
# location (header vs query), parameter name, and optional prefix are
|
|
8
|
+
# declared by the connector class via the `api_key_in` DSL:
|
|
9
|
+
#
|
|
10
|
+
# class GithubConnector < Connectors::Connector
|
|
11
|
+
# connector key: :github, auth: :api_key, base_url: "https://api.github.com"
|
|
12
|
+
# api_key_in :header, name: "Authorization", prefix: "token "
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# Default placement is `Authorization: Bearer <key>`.
|
|
16
|
+
class ApiKey < Scheme
|
|
17
|
+
register_as :api_key
|
|
18
|
+
|
|
19
|
+
DEFAULT_OPTIONS = { location: :header, name: "Authorization", prefix: "Bearer " }.freeze
|
|
20
|
+
|
|
21
|
+
def on_request(env)
|
|
22
|
+
key = @grant.credentials_hash["api_key"]
|
|
23
|
+
if key.nil? || key.to_s.empty?
|
|
24
|
+
raise Connectors::AuthenticationFailed.new("grant #{@grant.id} has no api_key")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
opts = connector_options
|
|
28
|
+
value = "#{opts[:prefix]}#{key}"
|
|
29
|
+
|
|
30
|
+
case opts[:location]
|
|
31
|
+
when :header
|
|
32
|
+
env.request_headers[opts[:name]] = value
|
|
33
|
+
when :query
|
|
34
|
+
existing = env.url.query
|
|
35
|
+
pair = "#{opts[:name]}=#{CGI.escape(value)}"
|
|
36
|
+
env.url.query = [ existing, pair ].compact.reject(&:empty?).join("&")
|
|
37
|
+
else
|
|
38
|
+
raise Connectors::Error.new("ApiKey scheme: unknown location #{opts[:location].inspect}")
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def connector_options
|
|
45
|
+
klass = @grant.connector.class
|
|
46
|
+
(klass.respond_to?(:api_key_options) && klass.api_key_options) || DEFAULT_OPTIONS
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
module Auth
|
|
3
|
+
class Scheme
|
|
4
|
+
# Attaches an OAuth2 access token as a Bearer Authorization header. The
|
|
5
|
+
# token is read from grant.credentials["access_token"]. Token refresh is
|
|
6
|
+
# handled separately by the AutoRefresh middleware + the connector's
|
|
7
|
+
# #refresh! method.
|
|
8
|
+
class OAuth2 < Scheme
|
|
9
|
+
register_as :oauth2
|
|
10
|
+
|
|
11
|
+
def on_request(env)
|
|
12
|
+
token = @grant.credentials_hash["access_token"]
|
|
13
|
+
if token.nil? || token.to_s.empty?
|
|
14
|
+
raise Connectors::AuthenticationFailed.new(
|
|
15
|
+
"grant #{@grant.id} has no access_token"
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
env.request_headers["Authorization"] = "Bearer #{token}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
|
|
3
|
+
module Connectors
|
|
4
|
+
module Auth
|
|
5
|
+
# Abstract base class for auth schemes (OAuth2, ApiKey, HMAC, etc.). A
|
|
6
|
+
# scheme is just a Faraday middleware that mutates the outgoing request
|
|
7
|
+
# env with whatever the third-party service expects (Bearer header, signed
|
|
8
|
+
# query, etc.).
|
|
9
|
+
#
|
|
10
|
+
# Subclasses register themselves with a symbolic key via `register_as`,
|
|
11
|
+
# which is what the connector DSL `auth: :oauth2` resolves against.
|
|
12
|
+
class Scheme < Faraday::Middleware
|
|
13
|
+
class << self
|
|
14
|
+
def register_as(name)
|
|
15
|
+
Scheme.registry[name.to_sym] = self
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def lookup(name)
|
|
19
|
+
registry.fetch(name.to_sym) { raise UnknownAuthScheme.new(name, known: registry.keys) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def registry
|
|
23
|
+
@registry ||= {}
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(app, grant:)
|
|
28
|
+
super(app)
|
|
29
|
+
@grant = grant
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Subclasses override on_request(env) to attach credentials.
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
module Connectors
|
|
2
|
+
# Resolves n8n-style `={{$credentials.x}}` templates inside an
|
|
3
|
+
# `IAuthenticateGeneric` properties block (n8n source:
|
|
4
|
+
# packages/workflow/src/interfaces.ts:269-288, 197-208).
|
|
5
|
+
#
|
|
6
|
+
# Scope on purpose: this is NOT a general-purpose expression engine. It
|
|
7
|
+
# only understands `$credentials.<field>` lookups (with optional bracket
|
|
8
|
+
# access). Anything richer belongs in the automations expression engine,
|
|
9
|
+
# which the connectors engine must remain independent of.
|
|
10
|
+
#
|
|
11
|
+
# A string that starts with `=` is a template — its `{{ ... }}` segments
|
|
12
|
+
# are evaluated. Strings without a leading `=` are passed through as
|
|
13
|
+
# literals (matches n8n's `={{expression}}` convention).
|
|
14
|
+
module AuthInjection
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Walk a properties Hash (`{ headers: {...}, qs: {...}, body: {...},
|
|
18
|
+
# auth: { username:, password: } }`), resolving every leaf string against
|
|
19
|
+
# `credentials`. Non-string leaves pass through.
|
|
20
|
+
def resolve(properties, credentials)
|
|
21
|
+
walk(properties, credentials || {})
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def walk(value, credentials)
|
|
25
|
+
case value
|
|
26
|
+
when Hash
|
|
27
|
+
# Both keys AND values can be templates. n8n's HttpHeaderAuth uses
|
|
28
|
+
# `{ '={{$credentials.name}}': '={{$credentials.value}}' }` — the
|
|
29
|
+
# header *name* is user-supplied (see
|
|
30
|
+
# packages/nodes-base/credentials/HttpHeaderAuth.credentials.ts:38-45).
|
|
31
|
+
value.each_with_object({}) do |(k, v), out|
|
|
32
|
+
resolved_key = k.is_a?(String) ? resolve_string(k, credentials) : k
|
|
33
|
+
out[resolved_key] = walk(v, credentials)
|
|
34
|
+
end
|
|
35
|
+
when Array then value.map { |v| walk(v, credentials) }
|
|
36
|
+
when String then resolve_string(value, credentials)
|
|
37
|
+
else value
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
EXPR = /\{\{\s*(.+?)\s*\}\}/.freeze
|
|
42
|
+
|
|
43
|
+
def resolve_string(str, credentials)
|
|
44
|
+
return str unless str.start_with?("=")
|
|
45
|
+
str[1..].gsub(EXPR) { lookup(Regexp.last_match(1), credentials) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Supports `$credentials.field`, `$credentials["field"]`, `$credentials['field']`.
|
|
49
|
+
# Missing keys resolve to "" (n8n behavior — keeps templates from raising
|
|
50
|
+
# mid-request when the field is optional).
|
|
51
|
+
def lookup(expr, credentials)
|
|
52
|
+
expr = expr.to_s.strip
|
|
53
|
+
if (m = expr.match(/\A\$credentials\.([A-Za-z_][A-Za-z0-9_]*)\z/))
|
|
54
|
+
credentials[m[1]].to_s
|
|
55
|
+
elsif (m = expr.match(/\A\$credentials\[(["'])(.+?)\1\]\z/))
|
|
56
|
+
credentials[m[2]].to_s
|
|
57
|
+
else
|
|
58
|
+
# Unknown expression syntax — pass through verbatim wrapped, so a
|
|
59
|
+
# user typo is visible in the outgoing request rather than silently
|
|
60
|
+
# erasing the header.
|
|
61
|
+
"{{#{expr}}}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require "faraday"
|
|
2
|
+
require "faraday/retry"
|
|
3
|
+
|
|
4
|
+
module Connectors
|
|
5
|
+
# Assembles the Faraday client used by a connector. Middleware order matters:
|
|
6
|
+
# outermost (declared first) wraps everything below. On the response side,
|
|
7
|
+
# outer middleware runs LAST, so anything that needs the parsed JSON body
|
|
8
|
+
# (ErrorNormalization, AutoRefresh's rescue path) must sit OUTER to the JSON
|
|
9
|
+
# response parser.
|
|
10
|
+
#
|
|
11
|
+
# Stack (outer → inner):
|
|
12
|
+
# 1. JSON request encoder — body marshaling
|
|
13
|
+
# 2. RateLimit — per-grant quota
|
|
14
|
+
# 3. AutoRefresh — rescues 401 -> refresh -> retry
|
|
15
|
+
# 4. ErrorNormalization — raises after retry exhaustion
|
|
16
|
+
# 5. Retry / JSON response parser — inspect parsed responses
|
|
17
|
+
# 6. GrantStatus — reject revoked grants on each attempt
|
|
18
|
+
# 7. PreAuthentication (Phase 3) — proactive token refresh
|
|
19
|
+
# 8. Auth injection — declarative or imperative (one or the other)
|
|
20
|
+
# 9. Adapter — sends the HTTP request
|
|
21
|
+
class ClientBuilder
|
|
22
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
23
|
+
DEFAULT_TIMEOUT = 30
|
|
24
|
+
|
|
25
|
+
# `authenticate_config` is the connector class's declarative
|
|
26
|
+
# `authenticate type: :generic, properties: {...}` block (Phase 1). When
|
|
27
|
+
# present, AuthenticateGeneric middleware applies it. When absent, fall
|
|
28
|
+
# back to the imperative `auth_scheme` middleware (legacy path —
|
|
29
|
+
# `Auth::Scheme::ApiKey` / `Auth::Scheme::OAuth2`).
|
|
30
|
+
#
|
|
31
|
+
# `pre_auth_block` is the connector's `pre_authentication` block (Phase 3).
|
|
32
|
+
# When present, PreAuthentication middleware runs it before each request
|
|
33
|
+
# whose grant credentials look stale (expires_at missing or in the past).
|
|
34
|
+
def initialize(base_url:, grant:, auth_scheme:, authenticate_config: nil, pre_auth_block: nil,
|
|
35
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT, timeout: DEFAULT_TIMEOUT)
|
|
36
|
+
@base_url = base_url
|
|
37
|
+
@grant = grant
|
|
38
|
+
@auth_scheme = auth_scheme
|
|
39
|
+
@authenticate_config = authenticate_config
|
|
40
|
+
@pre_auth_block = pre_auth_block
|
|
41
|
+
@open_timeout = open_timeout
|
|
42
|
+
@timeout = timeout
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def build
|
|
46
|
+
grant = @grant
|
|
47
|
+
auth_scheme = @auth_scheme
|
|
48
|
+
authenticate_config = @authenticate_config
|
|
49
|
+
pre_auth_block = @pre_auth_block
|
|
50
|
+
open_timeout = @open_timeout
|
|
51
|
+
timeout = @timeout
|
|
52
|
+
|
|
53
|
+
Faraday.new(url: @base_url) do |f|
|
|
54
|
+
f.options.open_timeout = open_timeout
|
|
55
|
+
f.options.timeout = timeout
|
|
56
|
+
|
|
57
|
+
f.request :json
|
|
58
|
+
f.use Middleware::RateLimit, grant: grant
|
|
59
|
+
f.use Middleware::AutoRefresh, grant: grant
|
|
60
|
+
f.use Middleware::ErrorNormalization,
|
|
61
|
+
token_expired_status: grant.connector.class.oauth2_config&.fetch(:token_expired_status, 401) || 401
|
|
62
|
+
f.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
|
|
63
|
+
retry_statuses: [ 502, 503, 504 ],
|
|
64
|
+
methods: [ :get, :head, :options, :put, :delete ]
|
|
65
|
+
f.response :json, content_type: /\bjson\z/
|
|
66
|
+
f.use Middleware::GrantStatus, grant: grant
|
|
67
|
+
|
|
68
|
+
# PreAuthentication MUST sit before AuthenticateGeneric so the fresh
|
|
69
|
+
# credentials it writes to the grant are visible to the template
|
|
70
|
+
# resolver on the same request.
|
|
71
|
+
if pre_auth_block
|
|
72
|
+
f.use Middleware::PreAuthentication, grant: grant, pre_auth_block: pre_auth_block
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
if authenticate_config
|
|
76
|
+
f.use Middleware::AuthenticateGeneric,
|
|
77
|
+
grant: grant,
|
|
78
|
+
authenticate_config: authenticate_config
|
|
79
|
+
else
|
|
80
|
+
f.use auth_scheme, grant: grant
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
f.adapter Faraday.default_adapter
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|