From 3c5068871749881877404dd89296863d1d251a3d Mon Sep 17 00:00:00 2001 From: jacksonhuether Date: Tue, 24 Feb 2026 15:40:04 -0500 Subject: [PATCH 1/3] Fix OAuth token request using full URL as HTTP request path `Net::HTTP::Post.new(uri.to_s)` passes the full URL (e.g. `https://auth.us-east-1.fragment.dev/oauth2/token`) as the HTTP request path instead of just the path component (`/oauth2/token`). This causes the raw HTTP request line to be: POST https://auth.us-east-1.fragment.dev/oauth2/token HTTP/1.1 Some servers (notably AWS Cognito behind CloudFront) do not handle absolute-form request URIs and misroute the request, returning 405 Method Not Allowed. Fix by using `uri.request_uri` which returns just the path and query string components. Co-Authored-By: Claude Opus 4.6 --- lib/fragment_client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/fragment_client.rb b/lib/fragment_client.rb index 957dffd..50ea0e6 100644 --- a/lib/fragment_client.rb +++ b/lib/fragment_client.rb @@ -164,7 +164,7 @@ def define_method_from_queries(queries) sig { returns(Token) } def create_token uri = URI.parse(@oauth_url.to_s) - post = Net::HTTP::Post.new(uri.to_s) + post = Net::HTTP::Post.new(uri.request_uri) post.basic_auth(@client_id, @client_secret) post.body = format('grant_type=client_credentials&scope=%s&client_id=%s', scope: @oauth_scope, id: @client_id) From aa9826cf80eb13132d391448159d812add897cb2 Mon Sep 17 00:00:00 2001 From: Steven Klaiber-Noble Date: Tue, 24 Feb 2026 15:49:51 -0800 Subject: [PATCH 2/3] Add RFC compliance tests for OAuth token request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validates the token request against RFC 7230 §5.3.1 (origin-form request-target) and RFC 6749 §4.4.2/§2.3.1 (client credentials grant format, HTTP Basic authentication). Intercepts Net::HTTP::Post to verify the raw request path since WebMock normalizes URLs and cannot detect absolute-form vs origin-form request-targets. Co-authored-by: Cursor --- test/unit_test.rb | 81 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/test/unit_test.rb b/test/unit_test.rb index de7ef5b..7609a1c 100644 --- a/test/unit_test.rb +++ b/test/unit_test.rb @@ -3,6 +3,7 @@ require 'minitest/autorun' require 'webmock/minitest' +require 'base64' require 'fragment_client' class UnitTest < Minitest::Test @@ -259,4 +260,84 @@ def test_extra_queries_file # Clean up query_file.unlink end + + def test_token_request_conforms_to_oauth2_and_http_specs + # Intercept Net::HTTP::Post to capture the raw request-target path. + # WebMock normalizes URLs before matching, so it cannot detect + # absolute-form vs origin-form request-targets. + captured_request_paths = [] + Net::HTTP::Post.define_method(:initialize) do |path, initheader = nil| + captured_request_paths << path + super(path, initheader) + end + + captured_auth_request = nil + stub_request(:post, 'https://auth.fragment.dev/oauth2/token') + .to_return do |request| + captured_auth_request = request + { status: 200, body: { access_token: 'test_token', expires_in: 3600 }.to_json } + end + + begin + FragmentClient.new('test_client_id', 'test_client_secret') + + # RFC 7230 §5.3.1: "When making a request directly to an origin server, + # [...] a client MUST send only the absolute-path and query components + # of the target URI as the request-target." + token_request_path = captured_request_paths.first + assert_equal '/oauth2/token', token_request_path, + "Token request must use origin-form request-target (RFC 7230 §5.3.1), got: #{token_request_path}" + + # RFC 6749 §4.4.2: "The client makes a request to the token endpoint by + # adding the following parameters using the 'application/x-www-form-urlencoded' + # format [...] grant_type: REQUIRED. Value MUST be set to 'client_credentials'." + body_params = URI.decode_www_form(captured_auth_request.body).to_h + assert_equal 'client_credentials', body_params['grant_type'], + 'grant_type must be client_credentials (RFC 6749 §4.4.2)' + + # RFC 6749 §4.4.2: "scope: OPTIONAL." + refute_nil body_params['scope'], + 'scope parameter should be present when configured (RFC 6749 §4.4.2)' + + # RFC 6749 §2.3.1: "Clients in possession of a client password MAY use + # the HTTP Basic authentication scheme [...] The client identifier is [...] + # used as the username; the client password [...] used as the password." + auth_header = captured_auth_request.headers['Authorization'] + assert_match(/\ABasic /, auth_header, + 'Must use HTTP Basic authentication (RFC 6749 §2.3.1)') + decoded_credentials = Base64.decode64(auth_header.sub('Basic ', '')) + client_id, client_secret = decoded_credentials.split(':', 2) + assert_equal 'test_client_id', client_id, + 'Basic auth username must be client_id (RFC 6749 §2.3.1)' + assert_equal 'test_client_secret', client_secret, + 'Basic auth password must be client_secret (RFC 6749 §2.3.1)' + ensure + verbose, $VERBOSE = $VERBOSE, nil + Net::HTTP::Post.remove_method(:initialize) + $VERBOSE = verbose + end + end + + def test_token_request_uses_origin_form_with_custom_oauth_url + captured_request_paths = [] + Net::HTTP::Post.define_method(:initialize) do |path, initheader = nil| + captured_request_paths << path + super(path, initheader) + end + + stub_request(:post, 'https://auth.us-east-1.fragment.dev/oauth2/token') + .to_return(status: 200, body: { access_token: 'test_token', expires_in: 3600 }.to_json) + + begin + FragmentClient.new('client_id', 'client_secret', + oauth_url: 'https://auth.us-east-1.fragment.dev/oauth2/token') + + assert_equal '/oauth2/token', captured_request_paths.first, + 'Custom oauth_url must also use origin-form request-target (RFC 7230 §5.3.1)' + ensure + verbose, $VERBOSE = $VERBOSE, nil + Net::HTTP::Post.remove_method(:initialize) + $VERBOSE = verbose + end + end end From 9b212b3a5eeeaef04f61a99ff6405bb303a87bb4 Mon Sep 17 00:00:00 2001 From: Steven Klaiber-Noble Date: Tue, 24 Feb 2026 15:49:54 -0800 Subject: [PATCH 3/3] Update GraphQL schema Co-authored-by: Cursor --- lib/fragment.schema.json | 64 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/lib/fragment.schema.json b/lib/fragment.schema.json index 8739883..7558e7a 100644 --- a/lib/fragment.schema.json +++ b/lib/fragment.schema.json @@ -1429,6 +1429,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "CADC", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CADT", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "CDF", "description": null, @@ -1555,6 +1567,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "EURC", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "FJD", "description": null, @@ -2209,6 +2227,12 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "USDG", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "USDT", "description": null, @@ -5069,6 +5093,16 @@ }, "defaultValue": null }, + { + "name": "consistencyMode", + "description": "- eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default).\n- strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`.\n- use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise.", + "type": { + "kind": "ENUM", + "name": "ReadBalanceConsistencyMode", + "ofType": null + }, + "defaultValue": null + }, { "name": "currency", "description": "The currency of the balance to query. Required if the account is a multi-currency Ledger Account or if the the Ledger Account has child Ledger Accounts with different currencies.", @@ -5267,6 +5301,16 @@ }, "defaultValue": null }, + { + "name": "consistencyMode", + "description": "- eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default).\n- strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`.\n- use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise.", + "type": { + "kind": "ENUM", + "name": "ReadBalanceConsistencyMode", + "ofType": null + }, + "defaultValue": null + }, { "name": "first", "description": "The number of currency amounts to return per page, when paginating forwards. Defaults to 20, maximum is 200.", @@ -5383,6 +5427,16 @@ }, "defaultValue": null }, + { + "name": "consistencyMode", + "description": "- eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default).\n- strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`.\n- use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise.", + "type": { + "kind": "ENUM", + "name": "ReadBalanceConsistencyMode", + "ofType": null + }, + "defaultValue": null + }, { "name": "currency", "description": "The currency of the balance to query. Required if the Ledger Account has child Ledger Accounts with different currencies.", @@ -5581,6 +5635,16 @@ }, "defaultValue": null }, + { + "name": "consistencyMode", + "description": "- eventual: Returns an eventually consistent balance, even if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` (default).\n- strong: Returns a strongly consistent balance or an error if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `eventual`.\n- use_account: Returns a strongly consistent balance if the Ledger Account's `totalBalanceUpdates` in its `consistencyConfig` is `strong` and an eventually consistent balance otherwise.", + "type": { + "kind": "ENUM", + "name": "ReadBalanceConsistencyMode", + "ofType": null + }, + "defaultValue": null + }, { "name": "first", "description": "The number of currency amounts to return per page, when paginating forwards. Defaults to 20, maximum is 200.",