|
1 | 1 | # frozen_string_literal: true |
2 | 2 |
|
3 | | -module McpToolkit |
4 | | - module Auth |
5 | | - # SATELLITE side. Resolves the authenticated, scoped context for a tool call: |
6 | | - # |
7 | | - # 1. Introspect the bearer token against the central app (cached). |
8 | | - # 2. Reject if invalid / expired / not scoped to `required_application`. |
9 | | - # 3. Resolve the active central account id, enforcing tenancy: |
10 | | - # - accounts_user token => its single bound `account_id`. A supplied |
11 | | - # selector, if present, MUST match it. |
12 | | - # - user (superuser) token => the selector is REQUIRED and MUST be one of |
13 | | - # the token's `account_ids`. |
14 | | - # 4. Map that central account id to the LOCAL scope root via |
15 | | - # `config.account_resolver` (e.g. Account.find_by(synced_id:)) and return |
16 | | - # it as the tools' `scope_root`. |
17 | | - # |
18 | | - # The account selector mirrors what a gateway forwards: the resolved account id |
19 | | - # arrives as `_meta[config.account_meta_key]`. We also accept an `account_id` |
20 | | - # tool argument and the `config.account_id_header` header as fallbacks. |
21 | | - # |
22 | | - # Extracted from bsa-notifications' `McpServer::Authenticator`, made |
23 | | - # config-driven (account mapping + meta/header keys come from McpToolkit.config). |
24 | | - class Authenticator |
25 | | - Context = Struct.new(:scope_root, :introspection, keyword_init: true) |
| 3 | +# SATELLITE side. Resolves the authenticated, scoped context for a tool call: |
| 4 | +# |
| 5 | +# 1. Introspect the bearer token against the central app (cached). |
| 6 | +# 2. Reject if invalid / expired. |
| 7 | +# 3. Resolve the active central account id, enforcing tenancy: |
| 8 | +# - accounts_user token => its single bound `account_id`. A supplied |
| 9 | +# selector, if present, MUST match it. |
| 10 | +# - user (superuser) token => the selector is REQUIRED and MUST be one of |
| 11 | +# the token's `account_ids`. |
| 12 | +# 4. Map that central account id to the LOCAL scope root via |
| 13 | +# `config.account_resolver` (e.g. Account.find_by(synced_id:)) and return |
| 14 | +# it as the tools' `scope_root`. |
| 15 | +# |
| 16 | +# The exact `<app>__<action>` scope is enforced separately by Tools::Base |
| 17 | +# (#with_account / #with_authentication) via `authorized_for_scope?`; the |
| 18 | +# authenticator only validates the token and resolves the tenant. |
| 19 | +# |
| 20 | +# The account selector mirrors what a gateway forwards: the resolved account id |
| 21 | +# arrives as `_meta[config.account_meta_key]`. We also accept an `account_id` |
| 22 | +# tool argument and the `config.account_id_header` header as fallbacks. |
| 23 | +class McpToolkit::Auth::Authenticator |
| 24 | + Context = Struct.new(:scope_root, :introspection, keyword_init: true) |
26 | 25 |
|
27 | | - # @param token [String] the plaintext bearer the central app forwarded |
28 | | - # @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys) |
29 | | - # @param arguments [Hash] the tool-call arguments (may carry `account_id`) |
30 | | - # @param header_account_id [Integer,String,nil] the account-id header value |
31 | | - # @param config [McpToolkit::Configuration] |
32 | | - def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config) |
33 | | - new(token:, meta:, arguments:, header_account_id:, config:).call |
34 | | - end |
35 | | - |
36 | | - def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config) |
37 | | - @token = token |
38 | | - @meta = (meta || {}).transform_keys(&:to_s) |
39 | | - @arguments = (arguments || {}).transform_keys(&:to_s) |
40 | | - @header_account_id = header_account_id |
41 | | - @config = config |
42 | | - end |
| 26 | + # @param token [String] the plaintext bearer the central app forwarded |
| 27 | + # @param meta [Hash] the JSON-RPC `_meta` (string or symbol keys) |
| 28 | + # @param arguments [Hash] the tool-call arguments (may carry `account_id`) |
| 29 | + # @param header_account_id [Integer,String,nil] the account-id header value |
| 30 | + # @param config [McpToolkit::Configuration] |
| 31 | + def self.call(token:, meta: {}, arguments: {}, header_account_id: nil, config: McpToolkit.config) |
| 32 | + new(token:, meta:, arguments:, header_account_id:, config:).call |
| 33 | + end |
43 | 34 |
|
44 | | - def call |
45 | | - introspection = McpToolkit::Auth::Introspection.call(@token, config: @config) |
46 | | - raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid? |
| 35 | + def initialize(token:, meta:, arguments:, header_account_id:, config: McpToolkit.config) |
| 36 | + @token = token |
| 37 | + @meta = (meta || {}).transform_keys(&:to_s) |
| 38 | + @arguments = (arguments || {}).transform_keys(&:to_s) |
| 39 | + @header_account_id = header_account_id |
| 40 | + @config = config |
| 41 | + end |
47 | 42 |
|
48 | | - unless introspection.authorized_for_application?(@config.required_application) |
49 | | - raise McpToolkit::Errors::Unauthorized, |
50 | | - "token is not authorized for the #{@config.required_application.inspect} application" |
51 | | - end |
| 43 | + def call |
| 44 | + introspection = McpToolkit::Auth::Introspection.call(@token, config: @config) |
| 45 | + raise McpToolkit::Errors::Unauthorized, "invalid or expired token" unless introspection.valid? |
52 | 46 |
|
53 | | - central_account_id = resolve_account_id(introspection) |
54 | | - scope_root = @config.account_resolver.call(central_account_id) |
55 | | - unless scope_root |
56 | | - raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}" |
57 | | - end |
| 47 | + central_account_id = resolve_account_id(introspection) |
| 48 | + scope_root = @config.account_resolver.call(central_account_id) |
| 49 | + unless scope_root |
| 50 | + raise McpToolkit::Errors::Unauthorized, "no local scope found for account_id=#{central_account_id}" |
| 51 | + end |
58 | 52 |
|
59 | | - Context.new(scope_root:, introspection:) |
60 | | - end |
| 53 | + Context.new(scope_root:, introspection:) |
| 54 | + end |
61 | 55 |
|
62 | | - private |
| 56 | + private |
63 | 57 |
|
64 | | - def resolve_account_id(introspection) |
65 | | - candidate = candidate_account_id |
| 58 | + def resolve_account_id(introspection) |
| 59 | + candidate = candidate_account_id |
66 | 60 |
|
67 | | - if introspection.accounts_user? |
68 | | - bound = introspection.account_id |
69 | | - if candidate.present? && candidate.to_i != bound.to_i |
70 | | - raise McpToolkit::Errors::Unauthorized, |
71 | | - "account_id #{candidate} does not match this token's bound account" |
72 | | - end |
| 61 | + if introspection.accounts_user? |
| 62 | + bound = introspection.account_id |
| 63 | + if candidate.present? && candidate.to_i != bound.to_i |
| 64 | + raise McpToolkit::Errors::Unauthorized, |
| 65 | + "account_id #{candidate} does not match this token's bound account" |
| 66 | + end |
73 | 67 |
|
74 | | - return bound |
75 | | - end |
| 68 | + return bound |
| 69 | + end |
76 | 70 |
|
77 | | - # superuser / multi-account: selection is mandatory and must be authorized. |
78 | | - if candidate.blank? |
79 | | - raise McpToolkit::Errors::Unauthorized, |
80 | | - "this token spans multiple accounts; an account must be selected " \ |
81 | | - "via _meta[\"#{@config.account_meta_key}\"] (or the account_id argument)" |
82 | | - end |
| 71 | + # superuser / multi-account: selection is mandatory and must be authorized. |
| 72 | + if candidate.blank? |
| 73 | + raise McpToolkit::Errors::Unauthorized, |
| 74 | + "this token spans multiple accounts; an account must be selected " \ |
| 75 | + "via _meta[\"#{@config.account_meta_key}\"] (or the account_id argument)" |
| 76 | + end |
83 | 77 |
|
84 | | - unless introspection.authorized_account_ids.include?(candidate.to_i) |
85 | | - raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token" |
86 | | - end |
| 78 | + unless introspection.authorized_account_ids.include?(candidate.to_i) |
| 79 | + raise McpToolkit::Errors::Unauthorized, "account_id #{candidate} is not authorized for this token" |
| 80 | + end |
87 | 81 |
|
88 | | - candidate.to_i |
89 | | - end |
| 82 | + candidate.to_i |
| 83 | + end |
90 | 84 |
|
91 | | - def candidate_account_id |
92 | | - @meta[@config.account_meta_key].presence || |
93 | | - @arguments["account_id"].presence || |
94 | | - @header_account_id.presence |
95 | | - end |
96 | | - end |
| 85 | + def candidate_account_id |
| 86 | + @meta[@config.account_meta_key].presence || |
| 87 | + @arguments["account_id"].presence || |
| 88 | + @header_account_id.presence |
97 | 89 | end |
98 | 90 | end |
0 commit comments