diff --git a/conformance/client.rb b/conformance/client.rb index c9f3ec5f..8234e468 100644 --- a/conformance/client.rb +++ b/conformance/client.rb @@ -66,13 +66,25 @@ def storage_for(context) end # Builds a `client_credentials`-only provider (machine-to-machine, no redirect). -# The pre-registered credentials are injected by the harness via context. +# The pre-registered credentials are injected by the harness via context: +# a shared secret for the basic scenario, or a PEM private key plus signing algorithm +# (default ES256, matching the TypeScript and Python conformance clients) +# for the `private_key_jwt` scenario. def build_client_credentials_provider(context) - MCP::Client::OAuth::ClientCredentialsProvider.new( - client_id: context["client_id"], - client_secret: context["client_secret"], - token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", - ) + if context["private_key_pem"] + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + token_endpoint_auth_method: "private_key_jwt", + private_key: context["private_key_pem"], + signing_algorithm: context["signing_algorithm"] || "ES256", + ) + else + MCP::Client::OAuth::ClientCredentialsProvider.new( + client_id: context["client_id"], + client_secret: context["client_secret"], + token_endpoint_auth_method: context["token_endpoint_auth_method"] || "client_secret_basic", + ) + end end # Builds an OAuth provider that drives the authorization code + PKCE + DCR flow diff --git a/conformance/expected_failures.yml b/conformance/expected_failures.yml index 1ab4cc45..19d3cadf 100644 --- a/conformance/expected_failures.yml +++ b/conformance/expected_failures.yml @@ -5,5 +5,4 @@ client: # TODO: Elicitation not implemented in Ruby client. - elicitation-sep1034-client-defaults # TODO: Remaining OAuth/auth scenarios not yet implemented in Ruby client. - - auth/client-credentials-jwt - auth/cross-app-access-complete-flow diff --git a/lib/mcp/client/oauth.rb b/lib/mcp/client/oauth.rb index fc33c175..6d1ab3fc 100644 --- a/lib/mcp/client/oauth.rb +++ b/lib/mcp/client/oauth.rb @@ -5,6 +5,7 @@ require_relative "oauth/in_memory_storage" require_relative "oauth/pkce" require_relative "oauth/storage_backed_provider" +require_relative "oauth/jwt_client_assertion" require_relative "oauth/provider" require_relative "oauth/client_credentials_provider" diff --git a/lib/mcp/client/oauth/client_credentials_provider.rb b/lib/mcp/client/oauth/client_credentials_provider.rb index fa01d5c7..688ff17f 100644 --- a/lib/mcp/client/oauth/client_credentials_provider.rb +++ b/lib/mcp/client/oauth/client_credentials_provider.rb @@ -15,14 +15,26 @@ module OAuth # Required keyword arguments: # # - `client_id` - String identifying the pre-registered confidential client. - # - `client_secret` - String shared secret. The `client_credentials` grant - # is for confidential clients, so a credential is mandatory. + # - `client_secret` - String shared secret for the `client_secret_basic` / + # `client_secret_post` methods. The `client_credentials` grant is for + # confidential clients, so a credential is mandatory; with + # `private_key_jwt` the credential is the `private_key` instead. # # Optional keyword arguments: # - # - `token_endpoint_auth_method` - `"client_secret_basic"` (default) or - # `"client_secret_post"`. `"none"` is rejected: an unauthenticated + # - `token_endpoint_auth_method` - `"client_secret_basic"` (default), + # `"client_secret_post"`, or `"private_key_jwt"` (RFC 7523 JWT client + # assertion, per the `io.modelcontextprotocol/oauth-client-credentials` + # extension / SEP-1046). `"none"` is rejected: an unauthenticated # `client_credentials` request is meaningless. + # - `private_key` - PEM string (or `OpenSSL::PKey::PKey`) used to sign + # the JWT client assertion. Required with `private_key_jwt`. + # The key is held on the provider and never written to `storage`. + # - `signing_algorithm` - `"ES256"` or `"RS256"`. Required with + # `private_key_jwt`; there is no default, so a mismatch with + # the server's `token_endpoint_auth_signing_alg_values_supported` fails + # loudly at construction instead of as a 401 (the TypeScript and Python SDKs + # also take the algorithm as an explicit option). # - `scope` - String of space-separated scopes to request when the server's # `WWW-Authenticate` and the Protected Resource Metadata do not specify one. # - `storage` - Object responding to `tokens`, `save_tokens(tokens)`, @@ -37,14 +49,16 @@ class ClientCredentialsProvider # missing or the requested client authentication method cannot carry them. class InvalidCredentialsError < ArgumentError; end - SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post"].freeze + SUPPORTED_AUTH_METHODS = ["client_secret_basic", "client_secret_post", "private_key_jwt"].freeze attr_reader :scope, :storage def initialize( client_id:, - client_secret:, + client_secret: nil, token_endpoint_auth_method: "client_secret_basic", + private_key: nil, + signing_algorithm: nil, scope: nil, storage: nil ) @@ -59,18 +73,37 @@ def initialize( "client_credentials request is not allowed." end - if blank?(client_secret) - raise InvalidCredentialsError, - "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + client_information = { "client_id" => client_id, "token_endpoint_auth_method" => token_endpoint_auth_method } + + if token_endpoint_auth_method == "private_key_jwt" + validate_private_key_jwt_arguments!( + client_secret: client_secret, + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + + # Fail fast on an unparseable key or a key/algorithm mismatch by + # signing a throwaway assertion now rather than at token time. + JWTClientAssertion.generate( + client_id: client_id, + audience: "urn:mcp:credential-validation", + private_key: private_key, + signing_algorithm: signing_algorithm, + ) + else + if blank?(client_secret) + raise InvalidCredentialsError, "client_secret is required for the client_credentials grant with #{token_endpoint_auth_method}." + end + + client_information["client_secret"] = client_secret end + @client_id = client_id + @private_key = private_key + @signing_algorithm = signing_algorithm @scope = scope @storage = storage || InMemoryStorage.new - @storage.save_client_information( - "client_id" => client_id, - "client_secret" => client_secret, - "token_endpoint_auth_method" => token_endpoint_auth_method, - ) + @storage.save_client_information(client_information) end # See `Provider#authorization_flow`. @@ -78,8 +111,33 @@ def authorization_flow :client_credentials end + # Returns a freshly signed RFC 7523 JWT client assertion for the `private_key_jwt` method. + # `audience` is the authorization server's issuer identifier. Called by `Flow#post_to_token_endpoint`. + def client_assertion(audience:) + JWTClientAssertion.generate( + client_id: @client_id, + audience: audience, + private_key: @private_key, + signing_algorithm: @signing_algorithm, + ) + end + private + def validate_private_key_jwt_arguments!(client_secret:, private_key:, signing_algorithm:) + unless client_secret.nil? + raise InvalidCredentialsError, "client_secret must not be set with private_key_jwt; the private key is the credential." + end + + if private_key.nil? + raise InvalidCredentialsError, "private_key is required for the client_credentials grant with private_key_jwt." + end + + return unless blank?(signing_algorithm) + + raise InvalidCredentialsError, "signing_algorithm is required with private_key_jwt (one of #{JWTClientAssertion::SUPPORTED_ALGORITHMS.inspect})." + end + def blank?(value) value.nil? || (value.is_a?(String) && value.strip.empty?) end diff --git a/lib/mcp/client/oauth/flow.rb b/lib/mcp/client/oauth/flow.rb index 7236f712..e3b63c0c 100644 --- a/lib/mcp/client/oauth/flow.rb +++ b/lib/mcp/client/oauth/flow.rb @@ -681,7 +681,26 @@ def post_to_token_endpoint(as_metadata:, client_info:, form:) client_secret = client_info_required_value(client_info, "client_secret") token_endpoint_auth_method = client_info_value(client_info, "token_endpoint_auth_method") - form = form.merge("client_id" => client_id) + form = if token_endpoint_auth_method == "private_key_jwt" + # RFC 7523 Section 2.2 JWT client assertion for the `private_key_jwt` method of + # the `io.modelcontextprotocol/oauth-client-credentials` extension (SEP-1046). + # The client identity travels in the assertion's `iss`/`sub` claims, so `client_id` is + # omitted from the body per RFC 7521 Section 4.2 (the `client_assertion` conveys the client identity). + # The audience is the issuer identifier that `ensure_issuer_matches!` already byte-validated. + unless @provider.respond_to?(:client_assertion) + raise AuthorizationError, + "token_endpoint_auth_method is private_key_jwt but the provider does not " \ + "implement `client_assertion(audience:)`." + end + + form.merge( + "client_assertion_type" => JWTClientAssertion::ASSERTION_TYPE, + "client_assertion" => @provider.client_assertion(audience: as_metadata["issuer"]), + ) + else + form.merge("client_id" => client_id) + end + headers = {} if client_secret case token_endpoint_auth_method diff --git a/lib/mcp/client/oauth/jwt_client_assertion.rb b/lib/mcp/client/oauth/jwt_client_assertion.rb new file mode 100644 index 00000000..5311a051 --- /dev/null +++ b/lib/mcp/client/oauth/jwt_client_assertion.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "base64" +require "json" +require "openssl" +require "securerandom" + +module MCP + class Client + module OAuth + # Builds RFC 7523 Section 2.2 JWT client assertions for the `private_key_jwt` + # client authentication method used by the `client_credentials` grant + # (MCP extension `io.modelcontextprotocol/oauth-client-credentials`, SEP-1046). + # + # The JWS is assembled with openssl so the SDK stays free of a JWT gem dependency + # (the TypeScript and Python SDKs use jose and PyJWT for the same assertion). + # Claims follow SEP-1046 and RFC 7523: `iss` and `sub` carry the client_id, + # `aud` carries the authorization server's issuer identifier, plus `exp`, `iat`, + # and a unique `jti`. + # + # - https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1046 + # - https://www.rfc-editor.org/rfc/rfc7523#section-2.2 + module JWTClientAssertion + # Raised when `signing_algorithm` is not supported. + class UnsupportedAlgorithmError < ArgumentError; end + + # Raised when the private key cannot be parsed or does not match + # the requested signing algorithm (e.g. an RSA key with ES256). + class InvalidKeyError < ArgumentError; end + + # RFC 7523 Section 2.2 `client_assertion_type` value. + ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + + # Assertion lifetime in seconds; matches the TypeScript SDK's `jwtLifetimeSeconds` + # and the Python SDK's `lifetime_seconds` default. + DEFAULT_LIFETIME = 300 + + # ES256 produces a raw `r || s` JWS signature of two 32-byte integers. + ES256_COMPONENT_BYTES = 32 + private_constant :ES256_COMPONENT_BYTES + + SUPPORTED_ALGORITHMS = ["ES256", "RS256"].freeze + + class << self + # Returns a signed compact-serialization JWT (`header.payload.signature`). + # + # @param client_id [String] The pre-registered OAuth client identifier. + # @param audience [String] The authorization server's issuer identifier. + # @param private_key [String, OpenSSL::PKey::PKey] PEM string (PKCS#8 or + # traditional encoding) or an already-parsed key. + # @param signing_algorithm [String] `"ES256"` (prime256v1 EC key) or + # `"RS256"` (RSA key). + # @param lifetime [Integer] Seconds until the `exp` claim expires. + def generate(client_id:, audience:, private_key:, signing_algorithm:, lifetime: DEFAULT_LIFETIME) + unless SUPPORTED_ALGORITHMS.include?(signing_algorithm) + raise UnsupportedAlgorithmError, + "signing_algorithm must be one of #{SUPPORTED_ALGORITHMS.inspect} (got #{signing_algorithm.inspect})." + end + + key = parse_key(private_key) + validate_key!(key, signing_algorithm) + + now = Time.now.to_i + header = { alg: signing_algorithm, typ: "JWT" } + claims = { + iss: client_id, + sub: client_id, + aud: audience, + exp: now + lifetime, + iat: now, + jti: SecureRandom.uuid, + } + + signing_input = "#{base64url(JSON.generate(header))}.#{base64url(JSON.generate(claims))}" + "#{signing_input}.#{base64url(sign(key, signing_algorithm, signing_input))}" + end + + private + + def parse_key(private_key) + return private_key if private_key.is_a?(OpenSSL::PKey::PKey) + + OpenSSL::PKey.read(private_key.to_s) + rescue OpenSSL::PKey::PKeyError => e + raise InvalidKeyError, "private_key could not be parsed as a PEM-encoded key: #{e.message}." + end + + def validate_key!(key, signing_algorithm) + case signing_algorithm + when "ES256" + unless key.is_a?(OpenSSL::PKey::EC) && key.group.curve_name == "prime256v1" + raise InvalidKeyError, "ES256 requires an EC private key on the prime256v1 (P-256) curve." + end + when "RS256" + unless key.is_a?(OpenSSL::PKey::RSA) + raise InvalidKeyError, "RS256 requires an RSA private key." + end + end + + return if key.private? + + raise InvalidKeyError, "private_key must contain the private component to sign assertions." + end + + def sign(key, signing_algorithm, signing_input) + der = key.sign(OpenSSL::Digest.new("SHA256"), signing_input) + return der unless signing_algorithm == "ES256" + + ecdsa_der_to_raw(der) + end + + # `OpenSSL::PKey::EC#sign` returns an ASN.1 DER `SEQUENCE { r, s }`, + # while JWS ES256 (RFC 7518 Section 3.4) requires the raw 64-byte + # `r || s` concatenation with each integer left-padded to 32 bytes. + def ecdsa_der_to_raw(der) + OpenSSL::ASN1.decode(der).value.map do |integer| + integer.value.to_s(16).rjust(ES256_COMPONENT_BYTES * 2, "0") + end.join.then { |hex| [hex].pack("H*") } + end + + def base64url(data) + Base64.urlsafe_encode64(data, padding: false) + end + end + end + end + end +end diff --git a/test/mcp/client/oauth/client_credentials_provider_test.rb b/test/mcp/client/oauth/client_credentials_provider_test.rb index c62395f5..c112aba8 100644 --- a/test/mcp/client/oauth/client_credentials_provider_test.rb +++ b/test/mcp/client/oauth/client_credentials_provider_test.rb @@ -68,6 +68,98 @@ def test_token_helpers_delegate_to_storage provider.clear_tokens! assert_nil(provider.tokens) end + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_es256_key + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + end + + def private_key_jwt_provider(key: generate_es256_key) + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: key, + signing_algorithm: "ES256", + ) + end + + def test_initialize_accepts_private_key_jwt + provider = private_key_jwt_provider + + info = provider.client_information + assert_equal("cc-client", info["client_id"]) + assert_equal("private_key_jwt", info["token_endpoint_auth_method"]) + end + + def test_initialize_private_key_jwt_does_not_persist_the_key_or_a_secret + # The PEM must never reach a (potentially persistent) storage backend. + provider = private_key_jwt_provider + + info = provider.client_information + refute(info.key?("client_secret")) + refute(info.values.any? { |value| value.to_s.include?("PRIVATE KEY") }) + end + + def test_initialize_private_key_jwt_requires_private_key + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_requires_signing_algorithm + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + ) + end + end + + def test_initialize_private_key_jwt_rejects_client_secret + assert_raises(ClientCredentialsProvider::InvalidCredentialsError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + client_secret: "cc-secret", + token_endpoint_auth_method: "private_key_jwt", + private_key: generate_es256_key, + signing_algorithm: "ES256", + ) + end + end + + def test_initialize_private_key_jwt_fails_fast_on_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: OpenSSL::PKey::RSA.new(2048), + signing_algorithm: "ES256", + ) + end + end + + def test_client_assertion_returns_signed_jwt_for_the_audience + provider = private_key_jwt_provider + + assertion = provider.client_assertion(audience: "https://auth.example.com") + payload_segment = assertion.split(".")[1] + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + end end end end diff --git a/test/mcp/client/oauth/flow_test.rb b/test/mcp/client/oauth/flow_test.rb index b92b9f95..b8b96cc3 100644 --- a/test/mcp/client/oauth/flow_test.rb +++ b/test/mcp/client/oauth/flow_test.rb @@ -175,6 +175,57 @@ def test_run_client_credentials_requests_scope_from_prm_scopes_supported end end + def test_run_client_credentials_with_private_key_jwt_sends_signed_assertion + # SEP-1046 / RFC 7523 Section 2.2: the token request authenticates + # with a signed JWT assertion; `client_id` and any shared secret are + # absent from the body, and the assertion's audience is the AS issuer. + stub_request(:get, @as_metadata_url).to_return( + status: 200, + headers: { "Content-Type" => "application/json" }, + body: JSON.generate( + issuer: @auth_base, + token_endpoint: "#{@auth_base}/token", + grant_types_supported: ["client_credentials"], + token_endpoint_auth_methods_supported: ["private_key_jwt"], + token_endpoint_auth_signing_alg_values_supported: ["ES256"], + ), + ) + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0); + # fall back to the pre-3.0 API on older Rubies. + key = if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate("prime256v1") + else + OpenSSL::PKey::EC.new("prime256v1").tap(&:generate_key) + end + provider = ClientCredentialsProvider.new( + client_id: "cc-client", + token_endpoint_auth_method: "private_key_jwt", + private_key: key, + signing_algorithm: "ES256", + ) + + result = Flow.new(provider: provider).run!(server_url: @server_url, resource_metadata_url: @prm_url) + + assert_equal(:authorized, result) + assert_equal("test-token-from-flow", provider.access_token) + assert_requested(:post, "#{@auth_base}/token") do |req| + form = URI.decode_www_form(req.body).to_h + payload_segment = form["client_assertion"].to_s.split(".")[1].to_s + claims = JSON.parse(Base64.urlsafe_decode64(payload_segment + "=" * (-payload_segment.length % 4))) + + form["grant_type"] == "client_credentials" && + form["client_assertion_type"] == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" && + !form.key?("client_id") && + !form.key?("client_secret") && + req.headers["Authorization"].nil? && + claims["iss"] == "cc-client" && + claims["sub"] == "cc-client" && + claims["aud"] == @auth_base && + form["resource"] == "https://srv.example.com/mcp" + end + end + def test_run_uses_authorization_code_grant_for_default_provider # A standard `Provider` declares `authorization_flow == :authorization_code`, # so `Flow` runs the interactive grant regardless of what `client_metadata[:grant_types]` happens to list. diff --git a/test/mcp/client/oauth/jwt_client_assertion_test.rb b/test/mcp/client/oauth/jwt_client_assertion_test.rb new file mode 100644 index 00000000..412f966c --- /dev/null +++ b/test/mcp/client/oauth/jwt_client_assertion_test.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "test_helper" +require "mcp/client/oauth" + +module MCP + class Client + module OAuth + class JWTClientAssertionTest < Minitest::Test + ES256_COMPONENT_BYTES = 32 + + # `OpenSSL::PKey::EC.generate` only exists from the openssl gem 2.2 (Ruby 3.0), + # while `EC#generate_key` raises on openssl 3.0+ where PKey objects are immutable, + # so branch on availability to keep CI green on every supported Ruby. + def generate_ec_key(curve) + if OpenSSL::PKey::EC.respond_to?(:generate) + OpenSSL::PKey::EC.generate(curve) + else + OpenSSL::PKey::EC.new(curve).tap(&:generate_key) + end + end + + def es256_key + @es256_key ||= generate_ec_key("prime256v1") + end + + def rs256_key + @rs256_key ||= OpenSSL::PKey::RSA.new(2048) + end + + def generate(private_key: es256_key, signing_algorithm: "ES256", **kwargs) + JWTClientAssertion.generate( + client_id: "cc-client", + audience: "https://auth.example.com", + private_key: private_key, + signing_algorithm: signing_algorithm, + **kwargs, + ) + end + + def decode_segment(segment) + JSON.parse(Base64.urlsafe_decode64(segment + "=" * (-segment.length % 4))) + end + + # JWS ES256 signatures are the raw 64-byte `r || s` concatenation; + # OpenSSL verifies the ASN.1 DER form, so convert back for verification. + def ecdsa_raw_to_der(raw) + r = OpenSSL::BN.new(raw[0, ES256_COMPONENT_BYTES], 2) + s = OpenSSL::BN.new(raw[ES256_COMPONENT_BYTES, ES256_COMPONENT_BYTES], 2) + OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::Integer.new(r), OpenSSL::ASN1::Integer.new(s)]).to_der + end + + def test_generate_builds_compact_jws_with_rfc7523_claims + before = Time.now.to_i + assertion = generate + after = Time.now.to_i + + header_segment, payload_segment, _signature_segment = assertion.split(".") + header = decode_segment(header_segment) + claims = decode_segment(payload_segment) + + assert_equal({ "alg" => "ES256", "typ" => "JWT" }, header) + assert_equal("cc-client", claims["iss"]) + assert_equal("cc-client", claims["sub"]) + assert_equal("https://auth.example.com", claims["aud"]) + assert_includes(before..after, claims["iat"]) + assert_equal(claims["iat"] + JWTClientAssertion::DEFAULT_LIFETIME, claims["exp"]) + refute_empty(claims["jti"]) + end + + def test_generate_uses_unpadded_base64url_segments + assertion = generate + + segments = assertion.split(".") + assert_equal(3, segments.size) + segments.each do |segment| + refute_match(%r{[+/=]}, segment, "JWS segments must be unpadded base64url") + end + end + + def test_generate_es256_signature_verifies_with_the_public_key + assertion = generate + + header_segment, payload_segment, signature_segment = assertion.split(".") + raw_signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal(ES256_COMPONENT_BYTES * 2, raw_signature.bytesize) + assert( + es256_key.verify( + OpenSSL::Digest.new("SHA256"), + ecdsa_raw_to_der(raw_signature), + "#{header_segment}.#{payload_segment}", + ), + "ES256 signature must verify against the signing input", + ) + end + + def test_generate_rs256_signature_verifies_with_the_public_key + assertion = generate(private_key: rs256_key, signing_algorithm: "RS256") + + header_segment, payload_segment, signature_segment = assertion.split(".") + signature = Base64.urlsafe_decode64(signature_segment + "=" * (-signature_segment.length % 4)) + + assert_equal("RS256", decode_segment(header_segment)["alg"]) + assert(rs256_key.verify(OpenSSL::Digest.new("SHA256"), signature, "#{header_segment}.#{payload_segment}")) + end + + def test_generate_accepts_pem_encoded_private_key + assertion = generate(private_key: es256_key.to_pem) + + refute_empty(assertion) + end + + def test_generate_honors_lifetime_override + assertion = generate(lifetime: 60) + + claims = decode_segment(assertion.split(".")[1]) + assert_equal(claims["iat"] + 60, claims["exp"]) + end + + def test_generate_uses_a_unique_jti_per_assertion + jtis = Array.new(2) { decode_segment(generate.split(".")[1])["jti"] } + + refute_equal(jtis[0], jtis[1]) + end + + def test_generate_rejects_unsupported_algorithm + assert_raises(JWTClientAssertion::UnsupportedAlgorithmError) do + generate(signing_algorithm: "HS256") + end + end + + def test_generate_rejects_key_algorithm_mismatch + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key, signing_algorithm: "ES256") + end + + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: es256_key, signing_algorithm: "RS256") + end + end + + def test_generate_rejects_ec_key_on_the_wrong_curve + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: generate_ec_key("secp384r1"), signing_algorithm: "ES256") + end + end + + def test_generate_rejects_unparseable_pem + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: "not a pem") + end + end + + def test_generate_rejects_public_only_key + # `RSA#public_key` is available on every supported openssl version, + # unlike `public_to_pem` (openssl 3.0+), so the public-only check is + # exercised with an RSA key. + assert_raises(JWTClientAssertion::InvalidKeyError) do + generate(private_key: rs256_key.public_key, signing_algorithm: "RS256") + end + end + end + end + end +end