ruby_llm 2.0.0 → 2.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 +4 -4
- data/README.md +65 -8
- data/lib/generators/ruby_llm/generator_helpers.rb +68 -6
- data/lib/generators/ruby_llm/install/templates/create_messages_migration.rb.tt +1 -0
- data/lib/generators/ruby_llm/install/templates/create_ruby_llm_records_migration.rb.tt +36 -3
- data/lib/generators/ruby_llm/provider/scaffold.rb +32 -22
- data/lib/generators/ruby_llm/provider/templates/gem/ci.yml.erb +1 -5
- data/lib/generators/ruby_llm/provider/templates/gem/gemfile.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/gemspec.erb +1 -1
- data/lib/generators/ruby_llm/provider/templates/gem/rubocop.yml.erb +1 -1
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_ruby_llm_to_2_1.rb.tt +78 -0
- data/lib/generators/ruby_llm/upgrade/upgrade_generator.rb +6 -163
- data/lib/ruby_llm/accounting/usage.rb +86 -49
- data/lib/ruby_llm/active_record/acts_as.rb +3 -0
- data/lib/ruby_llm/active_record/attachment_helpers.rb +50 -25
- data/lib/ruby_llm/active_record/batch.rb +1 -1
- data/lib/ruby_llm/active_record/chat_methods.rb +136 -40
- data/lib/ruby_llm/active_record/mcp_credential.rb +41 -0
- data/lib/ruby_llm/active_record/message_methods.rb +32 -7
- data/lib/ruby_llm/active_record/model.rb +1 -1
- data/lib/ruby_llm/active_record/provider_file.rb +64 -0
- data/lib/ruby_llm/active_record/record.rb +19 -0
- data/lib/ruby_llm/active_record/tool_call.rb +1 -1
- data/lib/ruby_llm/active_record/usage.rb +53 -3
- data/lib/ruby_llm/agent.rb +198 -33
- data/lib/ruby_llm/aliases.json +66 -8
- data/lib/ruby_llm/attachment.rb +54 -10
- data/lib/ruby_llm/batch.rb +26 -3
- data/lib/ruby_llm/chat/tool_concurrency.rb +47 -23
- data/lib/ruby_llm/chat/tool_search.rb +26 -0
- data/lib/ruby_llm/chat.rb +488 -82
- data/lib/ruby_llm/choice.rb +38 -0
- data/lib/ruby_llm/configuration.rb +36 -11
- data/lib/ruby_llm/context.rb +45 -32
- data/lib/ruby_llm/cost.rb +42 -1
- data/lib/ruby_llm/embedding.rb +9 -2
- data/lib/ruby_llm/error.rb +34 -2
- data/lib/ruby_llm/evaluation/assertions.rb +40 -0
- data/lib/ruby_llm/evaluation/case.rb +52 -0
- data/lib/ruby_llm/evaluation/dataset.rb +65 -0
- data/lib/ruby_llm/evaluation/evaluator.rb +126 -0
- data/lib/ruby_llm/evaluation/evidence.rb +125 -0
- data/lib/ruby_llm/evaluation/minitest.rb +26 -0
- data/lib/ruby_llm/evaluation/report.rb +103 -0
- data/lib/ruby_llm/evaluation/result.rb +75 -0
- data/lib/ruby_llm/evaluation/reviewer.rb +20 -0
- data/lib/ruby_llm/evaluation/rspec.rb +23 -0
- data/lib/ruby_llm/evaluation/runner.rb +93 -0
- data/lib/ruby_llm/evaluation/trial.rb +101 -0
- data/lib/ruby_llm/evaluation.rb +289 -0
- data/lib/ruby_llm/image.rb +12 -4
- data/lib/ruby_llm/judge/builder.rb +51 -0
- data/lib/ruby_llm/judge/data.rb +43 -0
- data/lib/ruby_llm/judge/question.rb +101 -0
- data/lib/ruby_llm/judge.rb +179 -0
- data/lib/ruby_llm/judgment.rb +125 -0
- data/lib/ruby_llm/mcp/apps.rb +25 -0
- data/lib/ruby_llm/mcp/client.rb +213 -0
- data/lib/ruby_llm/mcp/collection.rb +56 -0
- data/lib/ruby_llm/mcp/content.rb +44 -0
- data/lib/ruby_llm/mcp/error.rb +27 -0
- data/lib/ruby_llm/mcp/http.rb +379 -0
- data/lib/ruby_llm/mcp/input_request.rb +118 -0
- data/lib/ruby_llm/mcp/input_required_error.rb +29 -0
- data/lib/ruby_llm/mcp/listener.rb +168 -0
- data/lib/ruby_llm/mcp/oauth/key.rb +78 -0
- data/lib/ruby_llm/mcp/oauth/memory_store.rb +28 -0
- data/lib/ruby_llm/mcp/oauth/proofs.rb +39 -0
- data/lib/ruby_llm/mcp/oauth.rb +653 -0
- data/lib/ruby_llm/mcp/param_headers.rb +83 -0
- data/lib/ruby_llm/mcp/prompt.rb +71 -0
- data/lib/ruby_llm/mcp/resource.rb +84 -0
- data/lib/ruby_llm/mcp/resource_template.rb +99 -0
- data/lib/ruby_llm/mcp/result.rb +76 -0
- data/lib/ruby_llm/mcp/session_expired.rb +10 -0
- data/lib/ruby_llm/mcp/stdio.rb +250 -0
- data/lib/ruby_llm/mcp/task.rb +151 -0
- data/lib/ruby_llm/mcp/tool.rb +130 -0
- data/lib/ruby_llm/mcp.rb +1101 -0
- data/lib/ruby_llm/message.rb +41 -6
- data/lib/ruby_llm/model.rb +2 -1
- data/lib/ruby_llm/models/aliases.rb +5 -2
- data/lib/ruby_llm/models/lookup.rb +80 -0
- data/lib/ruby_llm/models/registry.rb +2 -2
- data/lib/ruby_llm/models/schema.rb +1 -1
- data/lib/ruby_llm/models.json +23082 -10158
- data/lib/ruby_llm/models.rb +20 -69
- data/lib/ruby_llm/moderation.rb +7 -2
- data/lib/ruby_llm/ocr.rb +7 -2
- data/lib/ruby_llm/open_telemetry/attributes.rb +111 -0
- data/lib/ruby_llm/open_telemetry.rb +82 -0
- data/lib/ruby_llm/probability.rb +30 -0
- data/lib/ruby_llm/progress.rb +44 -0
- data/lib/ruby_llm/prompt/context.rb +55 -0
- data/lib/ruby_llm/prompt.rb +1 -1
- data/lib/ruby_llm/protocol/binary_streaming.rb +1 -1
- data/lib/ruby_llm/protocol/request_shapes/analysis.rb +190 -0
- data/lib/ruby_llm/protocol/request_shapes.rb +183 -0
- data/lib/ruby_llm/protocol/stored_uploads.rb +62 -0
- data/lib/ruby_llm/protocol/stream_accumulator.rb +9 -25
- data/lib/ruby_llm/protocol/streaming.rb +38 -31
- data/lib/ruby_llm/protocol.rb +192 -40
- data/lib/ruby_llm/protocols/anthropic/chat.rb +39 -32
- data/lib/ruby_llm/protocols/anthropic/media.rb +6 -2
- data/lib/ruby_llm/protocols/anthropic/models.rb +16 -1
- data/lib/ruby_llm/protocols/anthropic/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/anthropic/streaming.rb +23 -9
- data/lib/ruby_llm/protocols/anthropic/tools.rb +43 -0
- data/lib/ruby_llm/protocols/anthropic.rb +20 -1
- data/lib/ruby_llm/protocols/bedrock/async_videos.rb +9 -17
- data/lib/ruby_llm/protocols/bedrock/guardrails.rb +8 -9
- data/lib/ruby_llm/protocols/bedrock/rerank.rb +1 -17
- data/lib/ruby_llm/protocols/chat_completions/chat.rb +5 -7
- data/lib/ruby_llm/protocols/chat_completions/media.rb +34 -8
- data/lib/ruby_llm/protocols/chat_completions/request_shapes.rb +121 -0
- data/lib/ruby_llm/protocols/chat_completions/streaming.rb +3 -0
- data/lib/ruby_llm/protocols/chat_completions.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/batch_requests.rb +0 -5
- data/lib/ruby_llm/protocols/cohere/batches.rb +3 -7
- data/lib/ruby_llm/protocols/cohere/chat.rb +8 -0
- data/lib/ruby_llm/protocols/cohere/embeddings.rb +7 -4
- data/lib/ruby_llm/protocols/cohere/media.rb +6 -2
- data/lib/ruby_llm/protocols/cohere/ocr.rb +1 -1
- data/lib/ruby_llm/protocols/cohere/streaming.rb +1 -0
- data/lib/ruby_llm/protocols/cohere/tools.rb +5 -1
- data/lib/ruby_llm/protocols/cohere.rb +1 -0
- data/lib/ruby_llm/protocols/converse/chat.rb +22 -131
- data/lib/ruby_llm/protocols/converse/media.rb +16 -2
- data/lib/ruby_llm/protocols/converse/reasoning.rb +194 -0
- data/lib/ruby_llm/protocols/converse/request_shapes.rb +99 -0
- data/lib/ruby_llm/protocols/converse/streaming.rb +2 -2
- data/lib/ruby_llm/protocols/converse/thinking_stream.rb +2 -1
- data/lib/ruby_llm/protocols/converse.rb +3 -5
- data/lib/ruby_llm/protocols/deepseek/files.rb +2 -9
- data/lib/ruby_llm/protocols/elevenlabs/flows/images.rb +1 -7
- data/lib/ruby_llm/protocols/elevenlabs/flows/videos.rb +11 -17
- data/lib/ruby_llm/protocols/elevenlabs/streaming_transcription.rb +1 -4
- data/lib/ruby_llm/protocols/gemini/chat.rb +50 -21
- data/lib/ruby_llm/protocols/gemini/embeddings.rb +2 -2
- data/lib/ruby_llm/protocols/gemini/files.rb +1 -1
- data/lib/ruby_llm/protocols/gemini/images.rb +5 -1
- data/lib/ruby_llm/protocols/gemini/media.rb +15 -3
- data/lib/ruby_llm/protocols/gemini/request_shapes.rb +111 -0
- data/lib/ruby_llm/protocols/gemini/streaming.rb +2 -1
- data/lib/ruby_llm/protocols/gemini/tools.rb +17 -11
- data/lib/ruby_llm/protocols/gemini/transcription.rb +13 -0
- data/lib/ruby_llm/protocols/gemini/videos.rb +7 -2
- data/lib/ruby_llm/protocols/gemini.rb +1 -0
- data/lib/ruby_llm/protocols/gpustack/videos.rb +7 -9
- data/lib/ruby_llm/protocols/interactions/chat.rb +46 -18
- data/lib/ruby_llm/protocols/interactions/content.rb +9 -2
- data/lib/ruby_llm/protocols/interactions/request_shapes.rb +62 -0
- data/lib/ruby_llm/protocols/interactions/streaming.rb +15 -0
- data/lib/ruby_llm/protocols/interactions/tools.rb +28 -4
- data/lib/ruby_llm/protocols/interactions/transcription.rb +1 -11
- data/lib/ruby_llm/protocols/interactions.rb +1 -0
- data/lib/ruby_llm/protocols/invoke_model/cohere_embeddings.rb +2 -11
- data/lib/ruby_llm/protocols/invoke_model/embedding_batches.rb +2 -2
- data/lib/ruby_llm/protocols/invoke_model/stability_images.rb +2 -6
- data/lib/ruby_llm/protocols/invoke_model/titan_text_embeddings.rb +2 -8
- data/lib/ruby_llm/protocols/invoke_model.rb +1 -4
- data/lib/ruby_llm/protocols/mistral/content.rb +13 -0
- data/lib/ruby_llm/protocols/mistral/conversations/chat.rb +11 -1
- data/lib/ruby_llm/protocols/mistral/conversations/images.rb +5 -1
- data/lib/ruby_llm/protocols/mistral/conversations/request_shapes.rb +45 -0
- data/lib/ruby_llm/protocols/mistral/conversations.rb +1 -0
- data/lib/ruby_llm/protocols/mistral/multi_completion.rb +11 -1
- data/lib/ruby_llm/protocols/openai/decisions.rb +135 -0
- data/lib/ruby_llm/protocols/openai/files.rb +0 -7
- data/lib/ruby_llm/protocols/openrouter/batches.rb +13 -8
- data/lib/ruby_llm/protocols/openrouter/responses.rb +5 -1
- data/lib/ruby_llm/protocols/perplexity/agent.rb +116 -0
- data/lib/ruby_llm/protocols/perplexity/router.rb +2 -43
- data/lib/ruby_llm/protocols/responses/chat.rb +56 -22
- data/lib/ruby_llm/protocols/responses/media.rb +11 -7
- data/lib/ruby_llm/protocols/responses/request_shapes.rb +70 -0
- data/lib/ruby_llm/protocols/responses/streaming.rb +23 -7
- data/lib/ruby_llm/protocols/responses/token_counting.rb +2 -2
- data/lib/ruby_llm/protocols/responses/tools.rb +27 -0
- data/lib/ruby_llm/protocols/responses.rb +1 -0
- data/lib/ruby_llm/protocols/system_one/judgments.rb +42 -0
- data/lib/ruby_llm/protocols/system_one/models.rb +27 -0
- data/lib/ruby_llm/protocols/system_one/responses.rb +55 -0
- data/lib/ruby_llm/protocols/system_one.rb +27 -0
- data/lib/ruby_llm/protocols/vertexai/embedding_prediction/requests.rb +2 -2
- data/lib/ruby_llm/protocols/vertexai/files.rb +34 -6
- data/lib/ruby_llm/protocols/vertexai/ranking.rb +9 -30
- data/lib/ruby_llm/protocols/vertexai/research.rb +9 -41
- data/lib/ruby_llm/protocols/xai/streaming_transcription.rb +2 -5
- data/lib/ruby_llm/provider.rb +87 -15
- data/lib/ruby_llm/providers/anthropic.rb +23 -0
- data/lib/ruby_llm/providers/azure/chat.rb +4 -0
- data/lib/ruby_llm/providers/azure/images.rb +2 -2
- data/lib/ruby_llm/providers/azure/media.rb +8 -3
- data/lib/ruby_llm/providers/azure/videos.rb +7 -2
- data/lib/ruby_llm/providers/azure.rb +24 -4
- data/lib/ruby_llm/providers/bedrock/auth.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/anthropic.rb +1 -1
- data/lib/ruby_llm/providers/bedrock/mantle/voxtral.rb +1 -3
- data/lib/ruby_llm/providers/bedrock/mantle.rb +5 -9
- data/lib/ruby_llm/providers/bedrock.rb +38 -6
- data/lib/ruby_llm/providers/deepseek/chat.rb +6 -1
- data/lib/ruby_llm/providers/deepseek/responses.rb +6 -0
- data/lib/ruby_llm/providers/deepseek.rb +4 -0
- data/lib/ruby_llm/providers/gemini.rb +4 -0
- data/lib/ruby_llm/providers/gpustack/media.rb +8 -5
- data/lib/ruby_llm/providers/hetzner/chat.rb +39 -0
- data/lib/ruby_llm/providers/hetzner.rb +41 -0
- data/lib/ruby_llm/providers/mistral/chat.rb +16 -12
- data/lib/ruby_llm/providers/mistral/media.rb +8 -4
- data/lib/ruby_llm/providers/mistral/ocr.rb +1 -5
- data/lib/ruby_llm/providers/ollama/judgments.rb +26 -0
- data/lib/ruby_llm/providers/ollama/media.rb +6 -2
- data/lib/ruby_llm/providers/ollama/models.rb +2 -0
- data/lib/ruby_llm/providers/ollama.rb +11 -0
- data/lib/ruby_llm/providers/openai/capabilities.rb +3 -1
- data/lib/ruby_llm/providers/openai.rb +14 -0
- data/lib/ruby_llm/providers/openrouter/chat.rb +20 -24
- data/lib/ruby_llm/providers/openrouter/embeddings.rb +2 -0
- data/lib/ruby_llm/providers/openrouter/media.rb +5 -1
- data/lib/ruby_llm/providers/openrouter/streaming.rb +4 -1
- data/lib/ruby_llm/providers/openrouter/videos.rb +10 -5
- data/lib/ruby_llm/providers/openrouter.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/chat.rb +4 -0
- data/lib/ruby_llm/providers/perplexity/media.rb +5 -11
- data/lib/ruby_llm/providers/perplexity/models.rb +8 -4
- data/lib/ruby_llm/providers/perplexity.rb +11 -0
- data/lib/ruby_llm/providers/typesafe.rb +32 -0
- data/lib/ruby_llm/providers/vertexai/credentials.rb +63 -0
- data/lib/ruby_llm/providers/vertexai/gemini.rb +2 -2
- data/lib/ruby_llm/providers/vertexai/transcription.rb +1 -0
- data/lib/ruby_llm/providers/vertexai.rb +27 -28
- data/lib/ruby_llm/providers/xai/chat.rb +6 -0
- data/lib/ruby_llm/providers/xai/images.rb +9 -10
- data/lib/ruby_llm/providers/xai/responses.rb +10 -8
- data/lib/ruby_llm/providers/xai/videos.rb +11 -9
- data/lib/ruby_llm/providers/xai.rb +4 -0
- data/lib/ruby_llm/railtie.rb +11 -0
- data/lib/ruby_llm/request_shape/part.rb +117 -0
- data/lib/ruby_llm/request_shape/problem.rb +63 -0
- data/lib/ruby_llm/request_shape/tool_round.rb +64 -0
- data/lib/ruby_llm/request_shape/turn.rb +54 -0
- data/lib/ruby_llm/request_shape.rb +160 -0
- data/lib/ruby_llm/rerank.rb +9 -2
- data/lib/ruby_llm/research_job.rb +21 -5
- data/lib/ruby_llm/score.rb +42 -0
- data/lib/ruby_llm/search_results.rb +2 -2
- data/lib/ruby_llm/server_tool_call.rb +10 -2
- data/lib/ruby_llm/speech.rb +8 -1
- data/lib/ruby_llm/support/cancellation.rb +39 -0
- data/lib/ruby_llm/support/inspectable.rb +3 -2
- data/lib/ruby_llm/support/instrumentation.rb +38 -1
- data/lib/ruby_llm/support/process_cache.rb +60 -0
- data/lib/ruby_llm/support/progress_reporter.rb +33 -0
- data/lib/ruby_llm/thinking.rb +2 -1
- data/lib/ruby_llm/tokens.rb +57 -14
- data/lib/ruby_llm/tool.rb +50 -3
- data/lib/ruby_llm/tool_call.rb +17 -2
- data/lib/ruby_llm/transcription.rb +10 -3
- data/lib/ruby_llm/transport/connection.rb +127 -44
- data/lib/ruby_llm/transport/error_middleware.rb +90 -24
- data/lib/ruby_llm/transport/event_stream_parser.rb +121 -0
- data/lib/ruby_llm/transport/json_response.rb +33 -0
- data/lib/ruby_llm/transport/signing_middleware.rb +29 -0
- data/lib/ruby_llm/version.rb +1 -1
- data/lib/ruby_llm/video.rb +5 -3
- data/lib/ruby_llm/video_job.rb +48 -5
- data/lib/ruby_llm.rb +106 -5
- data/lib/tasks/ruby_llm/evaluations.rb +43 -0
- data/lib/tasks/ruby_llm.rake +6 -9
- data/skills/rubyllm/SKILL.md +33 -12
- metadata +95 -37
- data/lib/generators/ruby_llm/upgrade/legacy_content_sql.rb +0 -34
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/data.rb +0 -341
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/journal.rb +0 -171
- data/lib/generators/ruby_llm/upgrade/online_copy_migration/verification.rb +0 -197
- data/lib/generators/ruby_llm/upgrade/online_copy_migration.rb +0 -174
- data/lib/generators/ruby_llm/upgrade/templates/backfill_v2_data.rb.tt +0 -468
- data/lib/generators/ruby_llm/upgrade/templates/cleanup_v2_upgrade.rb.tt +0 -101
- data/lib/generators/ruby_llm/upgrade/templates/finish_v2_upgrade.rb.tt +0 -247
- data/lib/generators/ruby_llm/upgrade/templates/prepare_v2_upgrade.rb.tt +0 -662
- data/lib/generators/ruby_llm/upgrade/templates/ruby_llm_upgrade.rb.tt +0 -251
- data/lib/generators/ruby_llm/upgrade/templates/upgrade_initializer.rb.tt +0 -13
- data/lib/generators/ruby_llm/upgrade/upgrade_migration.rb +0 -359
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'openssl'
|
|
5
|
+
require 'strscan'
|
|
6
|
+
|
|
7
|
+
module RubyLLM
|
|
8
|
+
class MCP
|
|
9
|
+
# OAuth for one MCP server and one owner, following the 2026-07-28
|
|
10
|
+
# authorization spec: protected resource metadata, authorization server
|
|
11
|
+
# discovery, client ID metadata documents or dynamic registration when no
|
|
12
|
+
# client is configured, PKCE, resource indicators, issuer checks, and
|
|
13
|
+
# token refresh. Credentials live in the configured
|
|
14
|
+
# +mcp_credential_store+, keyed by owner and server.
|
|
15
|
+
#
|
|
16
|
+
# Two grants need no user to authorize: the client credentials grant
|
|
17
|
+
# of the OAuth Client Credentials extension, and the JWT bearer grant
|
|
18
|
+
# (RFC 7523 section 2.1). The latter presents a token the workload's
|
|
19
|
+
# platform issued (Workload Identity Federation) or an identity
|
|
20
|
+
# assertion grant the user's identity provider issued for their ID
|
|
21
|
+
# token (Enterprise-Managed Authorization). As the extensions' flows
|
|
22
|
+
# describe, a token is requested once the server rejects a request
|
|
23
|
+
# without one, and again before it expires. Pre-registered clients
|
|
24
|
+
# authenticate with their secret or with a private_key_jwt assertion
|
|
25
|
+
# (RFC 7523 section 2.2) addressed to the authorization server's
|
|
26
|
+
# issuer.
|
|
27
|
+
#
|
|
28
|
+
# A server that requires DPoP-bound tokens (RFC 9449) gets them: new
|
|
29
|
+
# tokens are bound to a key kept with them in the credentials, and
|
|
30
|
+
# every token request and server request carries a fresh proof with
|
|
31
|
+
# the nonce the server last supplied.
|
|
32
|
+
class OAuth # :nodoc:
|
|
33
|
+
PENDING_FOR = 600
|
|
34
|
+
REFRESH_EARLY = 60
|
|
35
|
+
ASSERTION_FOR = 60
|
|
36
|
+
GRANTS = %i[authorization_code client_credentials jwt_bearer].freeze
|
|
37
|
+
JWT_BEARER = 'urn:ietf:params:oauth:grant-type:jwt-bearer'
|
|
38
|
+
TOKEN_EXCHANGE = 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
39
|
+
ID_JAG = 'urn:ietf:params:oauth:token-type:id-jag'
|
|
40
|
+
ID_TOKEN = 'urn:ietf:params:oauth:token-type:id_token'
|
|
41
|
+
CLIENT_ASSERTION = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
42
|
+
CLIENT_CREDENTIALS = 'io.modelcontextprotocol/oauth-client-credentials'
|
|
43
|
+
ENTERPRISE_MANAGED = 'io.modelcontextprotocol/enterprise-managed-authorization'
|
|
44
|
+
SERVER_FIELDS = %w[
|
|
45
|
+
issuer token_endpoint token_endpoint_auth_methods_supported token_endpoint_auth_signing_alg_values_supported
|
|
46
|
+
authorization_response_iss_parameter_supported
|
|
47
|
+
].freeze
|
|
48
|
+
CLIENT_FIELDS = %w[client_id client_secret issuer server redirect_uri dpop_key].freeze
|
|
49
|
+
TOKEN = /[!\#$%&'*+.^_`|~0-9A-Za-z-]+/
|
|
50
|
+
PARAMETER = /#{TOKEN}\s*=/
|
|
51
|
+
QUOTED = /"((?:[^"\\]|\\.)*)"/
|
|
52
|
+
AUTHORIZATION_SERVER_PATHS = [
|
|
53
|
+
'/.well-known/oauth-authorization-server%<path>s',
|
|
54
|
+
'/.well-known/openid-configuration%<path>s',
|
|
55
|
+
'%<path>s/.well-known/openid-configuration'
|
|
56
|
+
].freeze
|
|
57
|
+
|
|
58
|
+
@refreshes = Hash.new { |refreshes, key| refreshes[key] = Mutex.new }
|
|
59
|
+
@refreshes_lock = Mutex.new
|
|
60
|
+
|
|
61
|
+
def self.memory_store
|
|
62
|
+
@memory_store ||= MemoryStore.new
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Returns the authorization extensions that the options of MCP.oauth
|
|
66
|
+
# use, as client capabilities declare them, so servers that require
|
|
67
|
+
# an extension know the client follows it.
|
|
68
|
+
def self.extensions(settings)
|
|
69
|
+
return {} unless settings
|
|
70
|
+
|
|
71
|
+
names = []
|
|
72
|
+
names << CLIENT_CREDENTIALS if settings[:grant] == :client_credentials
|
|
73
|
+
names << ENTERPRISE_MANAGED if settings[:identity_provider]
|
|
74
|
+
names.to_h { |name| [name, {}] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Runs the block while no other thread in this process refreshes the
|
|
78
|
+
# credentials stored under +key+.
|
|
79
|
+
def self.refreshing(key, &)
|
|
80
|
+
@refreshes_lock.synchronize { @refreshes[key] }.synchronize(&)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Reads the challenges of a WWW-Authenticate header (RFC 9110
|
|
84
|
+
# section 11.6.1) into their parameters, keyed by lowercase scheme.
|
|
85
|
+
def self.challenge(header)
|
|
86
|
+
scanner = StringScanner.new(header.to_s)
|
|
87
|
+
challenges = {}
|
|
88
|
+
while scanner.skip(/[\s,]*/) && (scheme = scanner.scan(TOKEN))
|
|
89
|
+
parameters = challenges[scheme.downcase] ||= {}
|
|
90
|
+
while scanner.skip(/[\s,]*/) && scanner.check(PARAMETER)
|
|
91
|
+
name = scanner.scan(TOKEN).downcase.to_sym
|
|
92
|
+
scanner.skip(/\s*=\s*/)
|
|
93
|
+
value = scanner.scan(QUOTED) ? scanner[1].gsub(/\\(.)/, '\1') : scanner.scan(/[^\s,]*/)
|
|
94
|
+
parameters[name] ||= value
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
challenges
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def initialize(server_url, owner:, scopes: nil, client_id: nil, client_secret: nil, grant: nil,
|
|
101
|
+
private_key: nil, assertion: nil, identity_provider: nil, config: RubyLLM.config,
|
|
102
|
+
resolve: ->(value) { value })
|
|
103
|
+
@server_url = server_url.to_s
|
|
104
|
+
@owner = owner
|
|
105
|
+
@scopes = scopes
|
|
106
|
+
@client_id = resolve.call(client_id)
|
|
107
|
+
@client_secret = resolve.call(client_secret)
|
|
108
|
+
@private_key = resolve.call(private_key)
|
|
109
|
+
@assertion = assertion
|
|
110
|
+
@identity_provider = identity_provider
|
|
111
|
+
@grant = grant || (assertion || identity_provider ? :jwt_bearer : :authorization_code)
|
|
112
|
+
@resolve = resolve
|
|
113
|
+
@config = config
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def authorized?
|
|
117
|
+
credential&.key?('access_token')
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def access_token
|
|
121
|
+
return unless authorized?
|
|
122
|
+
|
|
123
|
+
renew if expiring?
|
|
124
|
+
credential['access_token']
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Returns the headers that authorize a +verb+ request to the server:
|
|
128
|
+
# none without a token, and a proof of possession with a bound one.
|
|
129
|
+
def authorization_headers(verb = 'POST')
|
|
130
|
+
token = access_token or return {}
|
|
131
|
+
return { 'Authorization' => "Bearer #{token}" } unless credential['token_type'] == 'DPoP'
|
|
132
|
+
|
|
133
|
+
proof = proofs.sign(credential['dpop_key'], @server_url, token:, verb:)
|
|
134
|
+
{ 'Authorization' => "DPoP #{token}", 'DPoP' => proof }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Answers the server's rejection of a request, given its challenge,
|
|
138
|
+
# its DPoP nonce, and the ways the request +recovered+ before. Returns
|
|
139
|
+
# how to send it again, +:nonce+ with the nonce in its proof or
|
|
140
|
+
# +:token+ with a new token, or +nil+ when it is not worth sending
|
|
141
|
+
# again.
|
|
142
|
+
def recover(challenge, nonce: nil, recovered: [])
|
|
143
|
+
@challenge = challenge
|
|
144
|
+
remember_nonce(nonce)
|
|
145
|
+
recovery = nonce && nonce_requested? ? :nonce : :token
|
|
146
|
+
return if recovered.include?(recovery)
|
|
147
|
+
|
|
148
|
+
recovery if recovery == :nonce || renewed?
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Keeps the DPoP nonce the server supplied with a response for the
|
|
152
|
+
# next proof (RFC 9449 section 9).
|
|
153
|
+
def remember_nonce(nonce)
|
|
154
|
+
proofs.remember(@server_url, nonce)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def refresh
|
|
158
|
+
return false unless credential&.key?('refresh_token')
|
|
159
|
+
|
|
160
|
+
used = credential['access_token']
|
|
161
|
+
synchronize do
|
|
162
|
+
@credential = store.read(key)
|
|
163
|
+
next true if credential && credential['access_token'] != used
|
|
164
|
+
next false unless credential&.key?('refresh_token')
|
|
165
|
+
|
|
166
|
+
store_tokens(token_request('refresh_token', refresh_token: credential['refresh_token']))
|
|
167
|
+
true
|
|
168
|
+
end
|
|
169
|
+
rescue Error
|
|
170
|
+
false
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def authorization_url(redirect_uri:, challenge: nil)
|
|
174
|
+
raise ConfigurationError, "The #{@grant} grant needs no authorization" unless authorization_code?
|
|
175
|
+
|
|
176
|
+
@challenge = challenge
|
|
177
|
+
server = authorization_server
|
|
178
|
+
client = client_for(server, redirect_uri)
|
|
179
|
+
verifier = SecureRandom.urlsafe_base64(64)
|
|
180
|
+
state = SecureRandom.urlsafe_base64(32)
|
|
181
|
+
pending = client.merge('state' => state, 'verifier' => verifier, 'redirect_uri' => redirect_uri,
|
|
182
|
+
'issuer' => server['issuer'], 'scope' => scopes_for(server),
|
|
183
|
+
'dpop_key' => binding_key, 'expires_at' => Time.now.to_i + PENDING_FOR)
|
|
184
|
+
write(credential.to_h.merge('pending' => pending.merge('server' => server.slice(*SERVER_FIELDS))))
|
|
185
|
+
|
|
186
|
+
query = { response_type: 'code', client_id: client['client_id'], redirect_uri:, state:,
|
|
187
|
+
code_challenge: Base64.urlsafe_encode64(Digest::SHA256.digest(verifier), padding: false),
|
|
188
|
+
code_challenge_method: 'S256', resource:, scope: pending['scope'] }.compact
|
|
189
|
+
"#{endpoint(server['authorization_endpoint'])}?#{URI.encode_www_form(query)}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def authorize(params)
|
|
193
|
+
pending = credential&.fetch('pending', nil) or raise Error, 'No authorization in progress'
|
|
194
|
+
check_callback(pending, params)
|
|
195
|
+
tokens = token_request('authorization_code', code: value(params, :code), redirect_uri: pending['redirect_uri'],
|
|
196
|
+
code_verifier: pending['verifier'], client: pending)
|
|
197
|
+
store_tokens(tokens, client: pending.slice(*CLIENT_FIELDS, 'scope'))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def deauthorize
|
|
201
|
+
@credential = nil
|
|
202
|
+
store.delete(key)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
private
|
|
206
|
+
|
|
207
|
+
def credential
|
|
208
|
+
@credential ||= store.read(key)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def write(data)
|
|
212
|
+
@credential = data
|
|
213
|
+
store.write(key, data, owner: @owner)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def synchronize(&block)
|
|
217
|
+
self.class.refreshing(key) { store.respond_to?(:synchronize) ? store.synchronize(key, &block) : yield }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def store_tokens(tokens, client: nil)
|
|
221
|
+
data = credential.to_h.except('pending')
|
|
222
|
+
data = data.except(*CLIENT_FIELDS).merge(client) if client
|
|
223
|
+
data = data.merge('access_token' => tokens['access_token'], 'scope' => tokens['scope'] || data['scope'],
|
|
224
|
+
'expires_at' => (Time.now.to_i + tokens['expires_in'].to_i if tokens['expires_in']),
|
|
225
|
+
'token_type' => ('DPoP' if tokens['token_type'].to_s.casecmp?('DPoP')))
|
|
226
|
+
data['refresh_token'] = tokens['refresh_token'] if tokens['refresh_token']
|
|
227
|
+
write(data.compact)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def expiring?
|
|
231
|
+
credential['expires_at'] && credential['expires_at'] - REFRESH_EARLY < Time.now.to_i
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def authorization_code?
|
|
235
|
+
@grant == :authorization_code
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def renew
|
|
239
|
+
authorization_code? ? refresh : obtain
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def renewed?
|
|
243
|
+
authorization_code? ? authorized? && refresh : obtain
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Requests a token with the configured grant, unless another worker
|
|
247
|
+
# has replaced the one the server rejected. Failures raise
|
|
248
|
+
# UnauthorizedError: Client falls back to the older handshake on an
|
|
249
|
+
# MCP::Error, which would ask the authorization server again.
|
|
250
|
+
def obtain
|
|
251
|
+
rejected = credential&.fetch('access_token', nil)
|
|
252
|
+
synchronize do
|
|
253
|
+
@credential = store.read(key)
|
|
254
|
+
next true if authorized? && credential['access_token'] != rejected && !expiring?
|
|
255
|
+
|
|
256
|
+
server = authorization_server
|
|
257
|
+
client = granting_client(server)
|
|
258
|
+
scope = scopes_for(server)
|
|
259
|
+
grant_type, grant = grant_parameters(server, scope)
|
|
260
|
+
store_tokens(token_request(grant_type, client:, scope:, **grant), client:)
|
|
261
|
+
true
|
|
262
|
+
end
|
|
263
|
+
rescue Error => e
|
|
264
|
+
raise UnauthorizedError.new(e.message, response: e.response)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def granting_client(server)
|
|
268
|
+
if @grant == :client_credentials && @client_id.nil?
|
|
269
|
+
raise ConfigurationError, 'The client_credentials grant needs a client_id'
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
preregistered_client(server).merge('issuer' => server['issuer'], 'server' => server.slice(*SERVER_FIELDS),
|
|
273
|
+
'dpop_key' => binding_key).compact
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# RFC 9449 section 7.1 and RFC 9728 section 2: a server requires
|
|
277
|
+
# DPoP-bound tokens when it challenges with the DPoP scheme and not
|
|
278
|
+
# Bearer, or when its metadata says so.
|
|
279
|
+
def dpop_required?
|
|
280
|
+
schemes = @challenge.to_h.keys
|
|
281
|
+
@dpop_required || (schemes.include?('dpop') && !schemes.include?('bearer'))
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def binding_key
|
|
285
|
+
Key.generate.to_pem if dpop_required?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# RFC 9449 section 9: a server that wants a nonce in proofs answers
|
|
289
|
+
# with use_dpop_nonce and the nonce to use.
|
|
290
|
+
def nonce_requested?
|
|
291
|
+
@challenge.to_h.dig('dpop', :error) == 'use_dpop_nonce' && credential&.fetch('token_type', nil) == 'DPoP'
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def proofs
|
|
295
|
+
@proofs ||= Proofs.new
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# Assertions are resolved for every token, since workload platforms
|
|
299
|
+
# rotate the tokens they issue and ID tokens expire.
|
|
300
|
+
def grant_parameters(server, scope)
|
|
301
|
+
return ['client_credentials', {}] if @grant == :client_credentials
|
|
302
|
+
|
|
303
|
+
assertion = @identity_provider ? identity_assertion(server, scope) : @resolve.call(@assertion)
|
|
304
|
+
raise ConfigurationError, 'The jwt_bearer grant needs an assertion or an identity provider' unless assertion
|
|
305
|
+
|
|
306
|
+
[JWT_BEARER, { assertion: }]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# Exchanges the user's ID token at the identity provider for an
|
|
310
|
+
# Identity Assertion JWT Authorization Grant addressed to the
|
|
311
|
+
# server's authorization server (enterprise-managed authorization,
|
|
312
|
+
# section 4). Only that grant is forwarded: any other token the
|
|
313
|
+
# identity provider returns is the user's credential there.
|
|
314
|
+
def identity_assertion(server, scope)
|
|
315
|
+
provider = Hash(@resolve.call(@identity_provider)).to_h { |name, value| [name.to_sym, @resolve.call(value)] }
|
|
316
|
+
unless provider[:issuer] && provider[:id_token]
|
|
317
|
+
raise ConfigurationError, 'The identity provider needs an issuer and an id_token'
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
client = { 'client_id' => provider[:client_id], 'client_secret' => provider[:client_secret],
|
|
321
|
+
'server' => discover_authorization_server(provider[:issuer]) }
|
|
322
|
+
grant = token_request(TOKEN_EXCHANGE, client:, key: nil, requested_token_type: ID_JAG,
|
|
323
|
+
audience: server['issuer'], scope:, subject_token: provider[:id_token],
|
|
324
|
+
subject_token_type: ID_TOKEN)
|
|
325
|
+
return grant['access_token'] if grant['issued_token_type'] == ID_JAG
|
|
326
|
+
|
|
327
|
+
raise Error, "#{provider[:issuer]} did not issue an identity assertion grant"
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def check_callback(pending, params)
|
|
331
|
+
raise Error, 'The authorization expired; start again' if pending['expires_at'] < Time.now.to_i
|
|
332
|
+
|
|
333
|
+
check_issuer(pending, value(params, :iss))
|
|
334
|
+
check_state(pending, value(params, :state).to_s)
|
|
335
|
+
error = value(params, :error)
|
|
336
|
+
raise Error, "Authorization failed: #{value(params, :error_description) || error}" if error
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def check_issuer(pending, issuer)
|
|
340
|
+
raise Error, 'The authorization response came from the wrong issuer' if issuer && issuer != pending['issuer']
|
|
341
|
+
raise Error, 'The authorization server did not identify itself' if issuer.nil? && issuer_required?
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def check_state(pending, state)
|
|
345
|
+
expected = pending['state']
|
|
346
|
+
return if state.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(state, expected)
|
|
347
|
+
|
|
348
|
+
raise Error, 'The authorization state does not match'
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def issuer_required?
|
|
352
|
+
credential.dig('pending', 'server', 'authorization_response_iss_parameter_supported') == true
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def token_request(grant_type, client: credential, key: (signing_key if client['client_id'] == @client_id),
|
|
356
|
+
**params)
|
|
357
|
+
server = client['server'] or raise Error, 'No authorization server known; authorize first'
|
|
358
|
+
send_token_request(server, client, key, params.merge(grant_type:))
|
|
359
|
+
rescue Error => e
|
|
360
|
+
forget_registration(client) if oauth_error(e) == 'invalid_client'
|
|
361
|
+
raise
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# RFC 9449 section 8: an authorization server that wants a nonce in
|
|
365
|
+
# the proof answers use_dpop_nonce once with the nonce to use. Every
|
|
366
|
+
# attempt signs a new proof and a new client assertion.
|
|
367
|
+
def send_token_request(server, client, key, params, retried: false)
|
|
368
|
+
url = server['token_endpoint']
|
|
369
|
+
form = params.merge(client_id: client['client_id'], resource:)
|
|
370
|
+
headers = { 'Content-Type' => 'application/x-www-form-urlencoded', 'Accept' => 'application/json' }
|
|
371
|
+
authenticate(client, server, form, headers, key)
|
|
372
|
+
headers['DPoP'] = proofs.sign(client['dpop_key'], url) if client['dpop_key']
|
|
373
|
+
post(url, URI.encode_www_form(form.compact), headers)
|
|
374
|
+
rescue Error => e
|
|
375
|
+
raise if retried || !client['dpop_key'] || oauth_error(e) != 'use_dpop_nonce'
|
|
376
|
+
|
|
377
|
+
send_token_request(server, client, key, params, retried: true)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def oauth_error(error)
|
|
381
|
+
error.data['error'] if error.data.is_a?(Hash)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def forget_registration(client)
|
|
385
|
+
registration = registration_key(client['issuer'], client['redirect_uri'])
|
|
386
|
+
store.delete(registration) if store.read(registration)&.fetch('client_id', nil) == client['client_id']
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def authenticate(client, server, form, headers, key)
|
|
390
|
+
if key
|
|
391
|
+
form.delete(:client_id)
|
|
392
|
+
form.merge!(client_assertion_type: CLIENT_ASSERTION,
|
|
393
|
+
client_assertion: client_assertion(key, client['client_id'], server))
|
|
394
|
+
elsif client['client_secret']
|
|
395
|
+
methods = server['token_endpoint_auth_methods_supported'] || ['client_secret_basic']
|
|
396
|
+
if methods.include?('client_secret_basic')
|
|
397
|
+
headers['Authorization'] = "Basic #{basic_credentials(client['client_id'], client['client_secret'])}"
|
|
398
|
+
else
|
|
399
|
+
form[:client_secret] = client['client_secret']
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# RFC 6749 section 2.3.1: the client ID and secret are form-encoded
|
|
405
|
+
# before they become the user and password of Basic authentication.
|
|
406
|
+
def basic_credentials(client_id, client_secret)
|
|
407
|
+
Base64.strict_encode64([client_id, client_secret].map { |part| URI.encode_www_form_component(part) }.join(':'))
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# RFC 7523bis section 4: the issuer is the sole audience, so no other
|
|
411
|
+
# authorization server can replay the assertion, and the explicit
|
|
412
|
+
# type tells servers the client follows that rule.
|
|
413
|
+
def client_assertion(key, client_id, server)
|
|
414
|
+
now = Time.now.to_i
|
|
415
|
+
claims = { iss: client_id, sub: client_id, aud: server['issuer'], iat: now, exp: now + ASSERTION_FOR,
|
|
416
|
+
jti: SecureRandom.uuid }
|
|
417
|
+
algorithm = key.algorithm(server['token_endpoint_auth_signing_alg_values_supported'])
|
|
418
|
+
key.jwt(claims, algorithm:, typ: 'client-authentication+jwt')
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def signing_key
|
|
422
|
+
@signing_key ||= (Key.new(@private_key) if @private_key)
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def authorization_server
|
|
426
|
+
metadata = protected_resource_metadata
|
|
427
|
+
server = metadata ? described_authorization_server(metadata) : legacy_authorization_server
|
|
428
|
+
if authorization_code? && !Array(server['code_challenge_methods_supported']).include?('S256')
|
|
429
|
+
raise Error, "#{server['issuer']} does not support PKCE with S256"
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
server
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
def described_authorization_server(metadata)
|
|
436
|
+
issuer = Array(metadata['authorization_servers']).first
|
|
437
|
+
raise Error, "#{@server_url} names no authorization server" unless issuer
|
|
438
|
+
|
|
439
|
+
@resource = checked_resource(metadata['resource'])
|
|
440
|
+
@scopes_supported = metadata['scopes_supported']
|
|
441
|
+
@dpop_required = metadata['dpop_bound_access_tokens_required'] == true
|
|
442
|
+
discover_authorization_server(issuer)
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def protected_resource_metadata
|
|
446
|
+
url = challenged(:resource_metadata)
|
|
447
|
+
return get_json(url) if url && same_origin?(URI(url), URI(@server_url))
|
|
448
|
+
|
|
449
|
+
uri = URI(@server_url)
|
|
450
|
+
path = uri.path.chomp('/')
|
|
451
|
+
candidates = ["/.well-known/oauth-protected-resource#{path}", '/.well-known/oauth-protected-resource'].uniq
|
|
452
|
+
first_json(candidates.map { |candidate| URI.join(uri, candidate).to_s })
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
# Servers from the 2025-03-26 revision publish no protected resource
|
|
456
|
+
# metadata: their own origin is the authorization server, with default
|
|
457
|
+
# endpoints when it publishes no metadata either.
|
|
458
|
+
def legacy_authorization_server
|
|
459
|
+
origin = URI.join(@server_url, '/').to_s.chomp('/')
|
|
460
|
+
first_json(["#{origin}/.well-known/oauth-authorization-server"]) || {
|
|
461
|
+
'issuer' => origin, 'authorization_endpoint' => "#{origin}/authorize",
|
|
462
|
+
'token_endpoint' => "#{origin}/token", 'registration_endpoint' => "#{origin}/register",
|
|
463
|
+
'code_challenge_methods_supported' => ['S256']
|
|
464
|
+
}
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def checked_resource(resource)
|
|
468
|
+
return unless resource
|
|
469
|
+
return resource if covers?(URI(resource), URI(@server_url))
|
|
470
|
+
|
|
471
|
+
raise Error, "#{@server_url} published metadata for another resource: #{resource}"
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def same_origin?(url, server)
|
|
475
|
+
[url.scheme, url.host, url.port] == [server.scheme, server.host, server.port]
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def covers?(resource, server)
|
|
479
|
+
return false if resource.userinfo || server.userinfo
|
|
480
|
+
return false unless same_origin?(resource, server)
|
|
481
|
+
|
|
482
|
+
path = resource.path.chomp('/')
|
|
483
|
+
server.path == path || server.path.start_with?("#{path}/")
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
def discover_authorization_server(issuer)
|
|
487
|
+
server = authorization_server_metadata(issuer)
|
|
488
|
+
return server if same_issuer?(server['issuer'], issuer)
|
|
489
|
+
|
|
490
|
+
named = server['issuer']
|
|
491
|
+
raise Error, "#{issuer} metadata names a different issuer" unless named.is_a?(String) && URI(named).host
|
|
492
|
+
|
|
493
|
+
server = authorization_server_metadata(named)
|
|
494
|
+
raise Error, "#{issuer} metadata names a different issuer" unless same_issuer?(server['issuer'], named)
|
|
495
|
+
|
|
496
|
+
server
|
|
497
|
+
rescue URI::InvalidURIError
|
|
498
|
+
raise Error, "#{issuer} metadata names a different issuer"
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def authorization_server_metadata(issuer)
|
|
502
|
+
uri = URI(issuer)
|
|
503
|
+
path = uri.path.chomp('/')
|
|
504
|
+
urls = AUTHORIZATION_SERVER_PATHS.map { |pattern| URI.join(uri, format(pattern, path:)).to_s }
|
|
505
|
+
urls = urls.first(2) if path.empty?
|
|
506
|
+
first_json(urls.uniq) or raise Error, "#{issuer} publishes no authorization server metadata"
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
# RFC 3986 section 6.2.3: an empty path and "/" name the same resource.
|
|
510
|
+
def same_issuer?(one, other)
|
|
511
|
+
[one, other].map { |issuer| issuer.to_s.sub(%r{\A([a-z][a-z0-9+.-]*://[^/?#]+)/(?=[?#]|\z)}i, '\\1') }.uniq.one?
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def client_for(server, redirect_uri)
|
|
515
|
+
return preregistered_client(server) if @client_id
|
|
516
|
+
|
|
517
|
+
metadata_client_id = @config.mcp_client_id
|
|
518
|
+
if metadata_client_id && server['client_id_metadata_document_supported']
|
|
519
|
+
return { 'client_id' => metadata_client_id }
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
register(server, redirect_uri)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def preregistered_client(server)
|
|
526
|
+
issuer_key = "issuer:#{@client_id} #{@server_url}"
|
|
527
|
+
issuer = store.read(issuer_key)&.fetch('issuer', nil)
|
|
528
|
+
store.write(issuer_key, { 'issuer' => server['issuer'] }, owner: nil) unless issuer
|
|
529
|
+
if issuer && !same_issuer?(issuer, server['issuer'])
|
|
530
|
+
raise Error, "#{@client_id || 'The workload'} is registered with #{issuer}, " \
|
|
531
|
+
"but #{@server_url} now uses #{server['issuer']}"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
{ 'client_id' => @client_id, 'client_secret' => @client_secret }.compact
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def register(server, redirect_uri)
|
|
538
|
+
endpoint = server['registration_endpoint'] or raise Error, "#{server['issuer']} does not register clients"
|
|
539
|
+
registration = registration_key(server['issuer'], redirect_uri)
|
|
540
|
+
scope = scopes_for(server)
|
|
541
|
+
registered = store.read(registration)
|
|
542
|
+
if registered && registered_for?(registered['scope'], scope)
|
|
543
|
+
return registered.slice('client_id', 'client_secret')
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
body = JSON.generate({
|
|
547
|
+
client_name: @config.mcp_client_name, redirect_uris: [redirect_uri], response_types: ['code'],
|
|
548
|
+
grant_types: %w[authorization_code refresh_token], token_endpoint_auth_method: 'none',
|
|
549
|
+
application_type: HTTP.loopback?(redirect_uri) ? 'native' : 'web', scope:
|
|
550
|
+
}.compact)
|
|
551
|
+
client = post(endpoint, body, 'Content-Type' => 'application/json').slice('client_id', 'client_secret')
|
|
552
|
+
store.write(registration, client.merge('scope' => scope).compact, owner: nil)
|
|
553
|
+
client
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def registered_for?(registered, requested)
|
|
557
|
+
(requested.to_s.split - registered.to_s.split).empty?
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def registration_key(issuer, redirect_uri)
|
|
561
|
+
"client:#{issuer} #{redirect_uri}"
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
def scopes_for(server)
|
|
565
|
+
requested = challenged(:scope)&.split
|
|
566
|
+
scopes = if requested then Array(@scopes) + requested + credential.to_h['scope'].to_s.split
|
|
567
|
+
else Array(@scopes || @scopes_supported)
|
|
568
|
+
end
|
|
569
|
+
scopes += ['offline_access'] if offline_access?(server)
|
|
570
|
+
scopes.uniq.join(' ') unless scopes.empty?
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def offline_access?(server)
|
|
574
|
+
authorization_code? && Array(server['scopes_supported']).include?('offline_access')
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def challenged(name)
|
|
578
|
+
@challenge.to_h.each_value.filter_map { |parameters| parameters[name] }.first
|
|
579
|
+
end
|
|
580
|
+
|
|
581
|
+
def resource
|
|
582
|
+
@resource || @server_url.chomp('/')
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def first_json(urls)
|
|
586
|
+
urls.each do |url|
|
|
587
|
+
return get_json(url)
|
|
588
|
+
rescue Faraday::Error, Error
|
|
589
|
+
next
|
|
590
|
+
end
|
|
591
|
+
nil
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
def get_json(url)
|
|
595
|
+
parse(connection(url).get(url, nil, 'Accept' => 'application/json'))
|
|
596
|
+
end
|
|
597
|
+
|
|
598
|
+
def post(url, body, headers)
|
|
599
|
+
response = connection(url).post(url, body, headers)
|
|
600
|
+
proofs.remember(url, response.headers['dpop-nonce'])
|
|
601
|
+
parse(response)
|
|
602
|
+
rescue Faraday::Error => e
|
|
603
|
+
raise refusal(url, e.response && Faraday::Response.new(status: e.response[:status], body: e.response[:body],
|
|
604
|
+
response_headers: e.response[:headers]))
|
|
605
|
+
end
|
|
606
|
+
|
|
607
|
+
def refusal(url, response)
|
|
608
|
+
proofs.remember(url, Hash(response&.headers)['dpop-nonce'])
|
|
609
|
+
details = begin
|
|
610
|
+
JSON.parse(response&.body.to_s)
|
|
611
|
+
rescue JSON::ParserError
|
|
612
|
+
{}
|
|
613
|
+
end
|
|
614
|
+
details = {} unless details.is_a?(Hash)
|
|
615
|
+
Error.new("#{URI(url).host} refused the request: #{details['error_description'] || details['error']}",
|
|
616
|
+
data: details, response:)
|
|
617
|
+
end
|
|
618
|
+
|
|
619
|
+
def parse(response)
|
|
620
|
+
JSON.parse(response.body)
|
|
621
|
+
rescue JSON::ParserError
|
|
622
|
+
raise Error, 'The authorization server did not answer with JSON'
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
def connection(url)
|
|
626
|
+
endpoint(url)
|
|
627
|
+
Transport::Connection.basic(@config) { |faraday| faraday.adapter(@config.faraday_adapter) }
|
|
628
|
+
end
|
|
629
|
+
|
|
630
|
+
def endpoint(url)
|
|
631
|
+
uri = URI(url.to_s)
|
|
632
|
+
loopback_allowed = HTTP.loopback?(@server_url)
|
|
633
|
+
return url if uri.scheme == 'https' && uri.userinfo.nil?
|
|
634
|
+
return url if loopback_allowed && HTTP.secure?(uri)
|
|
635
|
+
|
|
636
|
+
raise Error, "OAuth endpoints must use HTTPS: #{url}"
|
|
637
|
+
end
|
|
638
|
+
|
|
639
|
+
def value(params, name)
|
|
640
|
+
params[name] || params[name.to_s]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
def store
|
|
644
|
+
@config.mcp_credential_store || self.class.memory_store
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def key
|
|
648
|
+
owner = @owner.respond_to?(:to_gid) ? @owner.to_gid.to_s : @owner.to_s
|
|
649
|
+
"#{owner}@#{@server_url}"
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
end
|
|
653
|
+
end
|