diff --git a/.github/workflows/integration_tests.yaml b/.github/workflows/integration_tests.yaml new file mode 100644 index 0000000..954f6cb --- /dev/null +++ b/.github/workflows/integration_tests.yaml @@ -0,0 +1,24 @@ +name: Integration Tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + - name: Run integration tests + env: + API_BASE_URL: ${{ vars.API_BASE_URL }} + SERVICE_ID: ${{ vars.SERVICE_ID }} + SERVICE_TOKEN: ${{ secrets.SERVICE_TOKEN }} + ORG_TOKEN: ${{ secrets.ORG_TOKEN }} + run: bundle exec rake test diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..cf2812c --- /dev/null +++ b/test/README.md @@ -0,0 +1,35 @@ +# Tests + +Integration tests that run against a live Authlete API server. Each test creates and deletes its own OAuth client within a shared service. + +## Prerequisites + +- A running Authlete API server (cloud or self-hosted) +- A service and service access token +- Dependencies installed: `bundle install` + +## Run tests + +```bash +API_BASE_URL="" \ + SERVICE_ID="" \ + SERVICE_TOKEN="" \ + ORG_TOKEN="" \ + bundle exec rake test +``` + +Single file (`-v` for verbose): + +```bash +API_BASE_URL="" SERVICE_ID="" SERVICE_TOKEN="" \ + bundle exec ruby -Itest test/auth_grant_test.rb -v +``` + +## Environment variables + +| Variable | Required | Description | +|---|---|---| +| `API_BASE_URL` | Yes | Authlete API URL (e.g. `https://us.authlete.com`) | +| `SERVICE_ID` | Yes | Numeric service ID | +| `SERVICE_TOKEN` | Yes | Service access token | +| `ORG_TOKEN` | No | Org-level token for managing service/client settings. Falls back to `SERVICE_TOKEN`. | diff --git a/test/auth_grant_test.rb b/test/auth_grant_test.rb new file mode 100644 index 0000000..377db45 --- /dev/null +++ b/test/auth_grant_test.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +class AuthGrantFlowTest < Minitest::Test + include SdkHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + def test_authorization_code_flow + # --- Step 1: Authorization Request --- + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" + + auth_request = Authlete::Models::Components::AuthorizationRequest.new( + parameters: parameters + ) + response = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: auth_request + ) + + auth_resp = response.authorization_response + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION action, got #{auth_resp.action}" + + ticket = auth_resp.ticket + refute_nil ticket, 'Authorization ticket must not be nil' + + # --- Step 2: Authorization Issue (simulate user consent) --- + issue_request = Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: ticket, + subject: SUBJECT + ) + response = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: issue_request + ) + + issue_resp = response.authorization_issue_response + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION action, got #{issue_resp.action}" + + auth_code = issue_resp.authorization_code + refute_nil auth_code, 'Authorization code must not be nil' + + assert_includes issue_resp.response_content, 'code=', + 'Response content must contain code=' + assert_includes issue_resp.response_content, "state=#{STATE}", + 'Response content must contain state=' + + # --- Step 3: Token Request --- + token_request = Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{auth_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret + ) + response = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: token_request + ) + + token_resp = response.token_response + assert_equal 'OK', token_resp.action.serialize, + "Expected OK action for token, got #{token_resp.action}" + + access_token = token_resp.access_token + refute_nil access_token, 'Access token must not be nil' + + # --- Step 4: Introspection --- + introspection_request = Authlete::Models::Components::IntrospectionRequest.new( + token: access_token + ) + response = @authlete_client.introspection.process_request( + service_id: @service_id, + introspection_request: introspection_request + ) + + intro_resp = response.introspection_response + assert_equal 'OK', intro_resp.action.serialize, + "Expected OK action for introspection, got #{intro_resp.action}: #{intro_resp.result_message}" + + # --- Step 5: Revocation --- + revocation_request = Authlete::Models::Components::RevocationRequest.new( + parameters: "token=#{access_token}", + client_id: @client_id, + client_secret: @client_secret + ) + response = @authlete_client.revocation.process_request( + service_id: @service_id, + revocation_request: revocation_request + ) + + revocation_resp = response.revocation_response + assert_equal 'OK', revocation_resp.action.serialize, + "Expected OK action for revocation, got #{revocation_resp.action}" + end +end diff --git a/test/dpop_test.rb b/test/dpop_test.rb new file mode 100644 index 0000000..1f71ee5 --- /dev/null +++ b/test/dpop_test.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +# ============================================================================= +# Standard service — DPoP is optional (clients may use it or skip it). +# These tests verify that DPoP token binding works correctly when used, +# and that introspection correctly validates DPoP-bound tokens. +# ============================================================================= + +class DpopFlowTest < Minitest::Test + include SdkHelper + include DpopHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + token_endpoint: TOKEN_ENDPOINT, + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # Core DPoP success path: token endpoint accepts DPoP proof and issues a + # DPoP-bound access token. + def test_dpop_basic_flow + key = generate_ec_key + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Authorization request (no DPoP needed at auth endpoint) + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 2: Issue authorization code + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 3: Token request with DPoP proof + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret, + dpop: dpop_proof(key, 'POST', TOKEN_ENDPOINT), + htm: 'POST', + htu: TOKEN_ENDPOINT + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + end + + # DPoP-bound access token must be accepted at introspection when a valid + # DPoP proof (including ath) is provided. + def test_dpop_introspection_valid + key = generate_ec_key + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Obtain DPoP-bound access token + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret, + dpop: dpop_proof(key, 'POST', TOKEN_ENDPOINT), + htm: 'POST', + htu: TOKEN_ENDPOINT + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK at token endpoint, got #{token_resp.action}: #{token_resp.result_message}" + access_token = token_resp.access_token + refute_nil access_token + + # Introspect with a valid DPoP proof (htm=GET, ath=SHA256 of access token) + intro_resp = @authlete_client.introspection.process_request( + service_id: @service_id, + introspection_request: Authlete::Models::Components::IntrospectionRequest.new( + token: access_token, + dpop: dpop_proof(key, 'GET', RESOURCE_URL, access_token: access_token), + htm: 'GET', + htu: RESOURCE_URL + ) + ).introspection_response + + assert_equal 'OK', intro_resp.action.serialize, + "Expected OK at introspection, got #{intro_resp.action}: #{intro_resp.result_message}" + end + + # Introspecting a DPoP-bound access token without a DPoP proof must be + # rejected (Authlete should return UNAUTHORIZED or BAD_REQUEST, not OK). + def test_dpop_introspection_without_proof_rejected + key = generate_ec_key + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret, + dpop: dpop_proof(key, 'POST', TOKEN_ENDPOINT), + htm: 'POST', + htu: TOKEN_ENDPOINT + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK at token endpoint, got #{token_resp.action}: #{token_resp.result_message}" + access_token = token_resp.access_token + refute_nil access_token + + # Introspect without any DPoP proof — must not return OK + intro_resp = @authlete_client.introspection.process_request( + service_id: @service_id, + introspection_request: Authlete::Models::Components::IntrospectionRequest.new( + token: access_token + ) + ).introspection_response + + refute_equal 'OK', intro_resp.action.serialize, + 'Introspecting a DPoP-bound token without a proof must not return OK' + end +end + +# ============================================================================= +# Client with dpopRequired: true +# These tests verify that the server rejects token requests without a DPoP +# proof when the client is configured to require DPoP. +# ============================================================================= + +class DpopRequiredTest < Minitest::Test + include SdkHelper + include DpopHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + token_endpoint: TOKEN_ENDPOINT, + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + + # Create a client with dpop_required: true + begin + resp = @mgmt_authlete_client.clients.create( + service_id: @service_id, + client: Authlete::Models::Components::ClientInput.new( + client_name: "ruby-sdk-test-dpop-required-#{Time.now.to_i}", + client_type: Authlete::Models::Components::ClientType::CONFIDENTIAL, + grant_types: [Authlete::Models::Components::GrantType::AUTHORIZATION_CODE], + response_types: [Authlete::Models::Components::ResponseType::CODE], + redirect_uris: [REDIRECT_URI], + dpop_required: true + ) + ) + rescue Authlete::Models::Errors::ResultError => e + raise "Client creation failed [#{e.result_code}]: #{e.result_message}" + end + + @client = resp.client + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # Token request without a DPoP proof must be rejected when dpopRequired=true. + def test_token_without_dpop_rejected + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + refute_equal 'OK', token_resp.action.serialize, + 'Token request without DPoP proof must not succeed when dpopRequired=true' + end + + # Full DPoP flow must succeed even when dpopRequired=true. + def test_dpop_flow_succeeds_when_required + key = generate_ec_key + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret, + dpop: dpop_proof(key, 'POST', TOKEN_ENDPOINT), + htm: 'POST', + htu: TOKEN_ENDPOINT + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + end +end diff --git a/test/extra_properties_test.rb b/test/extra_properties_test.rb new file mode 100644 index 0000000..97c503e --- /dev/null +++ b/test/extra_properties_test.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +# ============================================================================= +# Extra Properties +# Properties can be attached to access tokens at authorization issue or at the +# token endpoint. Each property has a key, value, and hidden flag. +# +# Ref: https://www.authlete.com/developers/definitive_guide/extra_properties/ +# ============================================================================= + +class ExtraPropertiesTest < Minitest::Test + include SdkHelper + + Property = Authlete::Models::Components::Property + + VISIBLE_PROP = Property.new(key: 'tenant_id', value: 'acme-corp') + HIDDEN_PROP = Property.new(key: 'internal_user_tier', value: 'premium', hidden: true) + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # Sets visible + hidden properties at authorization issue and verifies the SDK + # correctly deserializes them in the token and introspection responses. + def test_properties_at_authorization_issue + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Authorization issue — attach properties here + issue_request = Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: obtain_ticket, + subject: SUBJECT, + properties: [VISIBLE_PROP, HIDDEN_PROP] + ) + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, authorization_issue_request: issue_request + ).authorization_issue_response + assert_equal 'LOCATION', issue_resp.action.serialize + + # Token request — no properties here + token_request = Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret + ) + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, token_request: token_request + ).token_response + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + + # SDK deserializes properties array with correct key/value/hidden fields + props = Array(token_resp.properties) + visible = props.find { |p| p.key == VISIBLE_PROP.key } + hidden = props.find { |p| p.key == HIDDEN_PROP.key } + refute_nil visible, 'Visible property must be in properties array' + refute_nil hidden, 'Hidden property must be in properties array' + assert_equal VISIBLE_PROP.value, visible.value + assert_equal HIDDEN_PROP.value, hidden.value + assert hidden.hidden, 'Hidden flag must be true' + + # Only visible property appears in response_content + response_json = JSON.parse(token_resp.response_content) + assert_equal VISIBLE_PROP.value, response_json[VISIBLE_PROP.key] + assert_nil response_json[HIDDEN_PROP.key] + + # Both accessible via introspection + intro_request = Authlete::Models::Components::IntrospectionRequest.new( + token: token_resp.access_token + ) + intro_props = Array(@authlete_client.introspection.process_request( + service_id: @service_id, introspection_request: intro_request + ).introspection_response.properties) + assert intro_props.any? { |p| p.key == VISIBLE_PROP.key } + assert intro_props.any? { |p| p.key == HIDDEN_PROP.key } + end + + # TokenRequest.properties should accept Array[Property] just like + # AuthorizationIssueRequest. + def test_properties_at_token_endpoint + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Authorization issue — no properties here + issue_request = Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: obtain_ticket, subject: SUBJECT + ) + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, authorization_issue_request: issue_request + ).authorization_issue_response + assert_equal 'LOCATION', issue_resp.action.serialize + + # Token request — attach properties here + token_request = Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret, + properties: [VISIBLE_PROP] + ) + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, token_request: token_request + ).token_response + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + + response_json = JSON.parse(token_resp.response_content) + assert_equal VISIBLE_PROP.value, response_json[VISIBLE_PROP.key] + end + + private + + def obtain_ticket + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + auth_request = Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, authorization_request: auth_request + ).authorization_response + assert_equal 'INTERACTION', auth_resp.action.serialize + auth_resp.ticket + end +end diff --git a/test/openid/auth_grant_test.rb b/test/openid/auth_grant_test.rb new file mode 100644 index 0000000..582386f --- /dev/null +++ b/test/openid/auth_grant_test.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative 'openid_helper' + +# ============================================================================= +# OIDC Authorization Code Flow +# Based on https://www.authlete.com/developers/tutorial/oidc/ +# ============================================================================= + +class OidcAuthGrantFlowTest < Minitest::Test + include SdkHelper + include OidcHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + setup_oidc_service(@mgmt_authlete_client, @service_id) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + def test_oidc_basic_flow + nonce = SecureRandom.hex(16) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Authorization request with scope=openid and nonce + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&scope=openid&nonce=#{nonce}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket, 'ticket must be present' + + # Step 2: Authorization issue (simulate user consent) + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, + subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code, 'authorization_code must be present' + + # Step 3: Token request + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token, 'access_token must be present' + + id_token = token_resp.id_token.to_s + refute_empty id_token, 'id_token must be present for openid scope' + + # Step 4: Validate ID token payload claims + assert_oidc_claims( + decode_jwt_payload(id_token), + expected_sub: SUBJECT, + expected_nonce: nonce, + expected_client_id: @client_id + ) + + # Step 5: Introspect the access token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end diff --git a/test/openid/dpop_test.rb b/test/openid/dpop_test.rb new file mode 100644 index 0000000..9e6168f --- /dev/null +++ b/test/openid/dpop_test.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative 'openid_helper' + +# ============================================================================= +# OIDC DPoP Flow +# DPoP + scope=openid — verifies that the server correctly issues both a +# DPoP-bound access_token and an id_token when the two are combined. +# DPoP binds the access_token to a key; the id_token is unaffected by DPoP. +# ============================================================================= + +class OidcDpopFlowTest < Minitest::Test + include SdkHelper + include OidcHelper + include DpopHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + setup_oidc_service(@mgmt_authlete_client, @service_id, token_endpoint: TOKEN_ENDPOINT) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + def test_dpop_oidc_flow + key = generate_ec_key + nonce = SecureRandom.hex(16) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Authorization request with scope=openid and nonce (no DPoP at auth endpoint) + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&scope=openid&nonce=#{nonce}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 2: Authorization issue + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, + subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 3: Token request with DPoP proof + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret, + dpop: dpop_proof(key, 'POST', TOKEN_ENDPOINT), + htm: 'POST', + htu: TOKEN_ENDPOINT + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + + id_token = token_resp.id_token.to_s + refute_empty id_token, 'id_token must be present for openid scope' + + # Step 4: Validate ID token payload claims + assert_oidc_claims( + decode_jwt_payload(id_token), + expected_sub: SUBJECT, + expected_nonce: nonce, + expected_client_id: @client_id + ) + end +end diff --git a/test/openid/openid_helper.rb b/test/openid/openid_helper.rb new file mode 100644 index 0000000..7812441 --- /dev/null +++ b/test/openid/openid_helper.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module OidcHelper + # Generates a private RSA JWK set suitable for ID token signing. + # Returns { jwks: , kid: }. + def generate_rsa_jwks + key = OpenSSL::PKey::RSA.generate(2048) + kid = SecureRandom.uuid + p = key.params + b64 = ->(v) { Base64.urlsafe_encode64(v.to_s(2), padding: false) } + jwk = { + 'kty' => 'RSA', 'kid' => kid, 'use' => 'sig', 'alg' => 'RS256', + 'n' => b64.(p['n']), 'e' => b64.(p['e']), 'd' => b64.(p['d']), + 'p' => b64.(p['p']), 'q' => b64.(p['q']), + 'dp' => b64.(p['dmp1']), 'dq' => b64.(p['dmq1']), 'qi' => b64.(p['iqmp']) + } + { jwks: JSON.generate({ 'keys' => [jwk] }), kid: kid } + end + + # Updates the service with OIDC settings (issuer, JWKS, id_token signing key). + # Optionally sets token_endpoint for DPoP tests. + def setup_oidc_service(sdk, service_id, token_endpoint: nil) + jwks_info = generate_rsa_jwks + @oidc_jwks_info = jwks_info + + sdk.services.update( + service_id: service_id, + service: Authlete::Models::Components::ServiceInput.new( + issuer: 'https://as.example.com', + jwks: jwks_info[:jwks], + id_token_signature_key_id: jwks_info[:kid], + token_endpoint: token_endpoint, + access_token_duration: TOKEN_DURATION_SECONDS, + supported_scopes: [ + Authlete::Models::Components::Scope.new(name: 'openid', default_entry: false) + ] + ) + ) + end + + # Decodes the payload segment of a JWT without verifying the signature. + def decode_jwt_payload(jwt) + segment = jwt.split('.')[1] + padded = segment + '=' * ((4 - segment.length % 4) % 4) + JSON.parse(Base64.urlsafe_decode64(padded)) + end + + # Asserts the standard OIDC claims on a decoded id_token payload. + def assert_oidc_claims(claims, expected_sub:, expected_nonce:, expected_client_id:) + assert_equal expected_sub, claims['sub'], + "id_token sub must equal subject (#{expected_sub})" + assert_equal expected_nonce, claims['nonce'], + 'id_token nonce must match the nonce from the authorization request' + refute_nil claims['iss'], 'id_token must have an iss claim' + aud = Array(claims['aud']) + assert aud.any? { |a| a.to_s == expected_client_id }, + "id_token aud must include client_id (#{expected_client_id}), got #{aud.inspect}" + end +end diff --git a/test/openid/par_test.rb b/test/openid/par_test.rb new file mode 100644 index 0000000..343f3a6 --- /dev/null +++ b/test/openid/par_test.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require_relative '../test_helper' +require_relative 'openid_helper' + +# ============================================================================= +# OIDC PAR (Pushed Authorization Request) Flow +# PAR + scope=openid — verifies that the id_token is correctly issued when +# PAR and OIDC are combined. +# ============================================================================= + +class OidcParFlowTest < Minitest::Test + include SdkHelper + include OidcHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + setup_oidc_service(@mgmt_authlete_client, @service_id) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + def test_par_oidc_flow + nonce = SecureRandom.hex(16) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Push authorization parameters including scope=openid and nonce + par_resp = @authlete_client.pushed_authorization.create( + service_id: @service_id, + pushed_authorization_request: Authlete::Models::Components::PushedAuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&scope=openid&nonce=#{nonce}&state=#{STATE}", + client_id: @client_id, + client_secret: @client_secret + ) + ).pushed_authorization_response + + assert_equal 'CREATED', par_resp.action.serialize, + "Expected CREATED, got #{par_resp.action}: #{par_resp.result_message}" + refute_nil par_resp.request_uri, 'request_uri must be present after PAR' + + # Step 2: Authorization request using request_uri + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "client_id=#{@client_id}" \ + "&request_uri=#{URI.encode_www_form_component(par_resp.request_uri)}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 3: Authorization issue + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, + subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 4: Token exchange + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + + id_token = token_resp.id_token.to_s + refute_empty id_token, 'id_token must be present for openid scope' + + # Step 5: Validate ID token payload claims + assert_oidc_claims( + decode_jwt_payload(id_token), + expected_sub: SUBJECT, + expected_nonce: nonce, + expected_client_id: @client_id + ) + + # Step 6: Introspect the access token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end diff --git a/test/openid/pkce_test.rb b/test/openid/pkce_test.rb new file mode 100644 index 0000000..4a925f8 --- /dev/null +++ b/test/openid/pkce_test.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require 'digest' +require_relative '../test_helper' +require_relative 'openid_helper' + +# ============================================================================= +# OIDC PKCE (S256) Flow +# Standard PKCE S256 flow with scope=openid — verifies that the id_token is +# correctly issued when PKCE and OIDC are combined. +# ============================================================================= + +class OidcPkceFlowTest < Minitest::Test + include SdkHelper + include OidcHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + setup_oidc_service(@mgmt_authlete_client, @service_id) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + def test_pkce_s256_oidc_flow + code_verifier = SecureRandom.urlsafe_base64(48) + code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + nonce = SecureRandom.hex(16) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Authorization request with PKCE S256 + scope=openid + nonce + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&scope=openid&nonce=#{nonce}&state=#{STATE}" \ + "&code_challenge=#{code_challenge}&code_challenge_method=S256" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 2: Authorization issue + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, + subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 3: Token request — must include code_verifier + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{code_verifier}", + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + + id_token = token_resp.id_token.to_s + refute_empty id_token, 'id_token must be present for openid scope' + + # Step 4: Validate ID token payload claims + assert_oidc_claims( + decode_jwt_payload(id_token), + expected_sub: SUBJECT, + expected_nonce: nonce, + expected_client_id: @client_id + ) + + # Step 5: Introspect the access token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end diff --git a/test/par_test.rb b/test/par_test.rb new file mode 100644 index 0000000..4455485 --- /dev/null +++ b/test/par_test.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +# ============================================================================= +# Standard service — PAR is optional. Tests verify the SDK correctly handles +# the PAR flow end-to-end (success and error paths). +# ============================================================================= + +class ParFlowTest < Minitest::Test + include SdkHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # Core SDK integration test: PAR success path + # 1. Build PushedAuthorizationRequest with form-encoded params + # 2. Call sdk.pushed_authorization.create() → assert CREATED, request_uri present + # 3. Use request_uri in auth request → assert INTERACTION + # 4. Issue auth code → assert LOCATION + # 5. Exchange for token → assert OK, access_token present + def test_par_basic_flow + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Push authorization parameters + par_params = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + par_resp = @authlete_client.pushed_authorization.create( + service_id: @service_id, + pushed_authorization_request: Authlete::Models::Components::PushedAuthorizationRequest.new( + parameters: par_params, + client_id: @client_id, + client_secret: @client_secret + ) + ).pushed_authorization_response + + assert_equal 'CREATED', par_resp.action.serialize, + "Expected CREATED, got #{par_resp.action}: #{par_resp.result_message}" + refute_nil par_resp.request_uri, 'request_uri must be present after PAR' + + request_uri = par_resp.request_uri + + # Step 2: Authorization request using request_uri + auth_params = "client_id=#{@client_id}&request_uri=#{URI.encode_www_form_component(request_uri)}" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: auth_params + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 3: Issue authorization code + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 4: Token exchange + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end + + # SDK error-handling test: omitting client_secret for a confidential client + # must surface a non-CREATED action rather than crashing or swallowing the error. + def test_par_missing_client_secret_rejected + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + par_params = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + par_resp = @authlete_client.pushed_authorization.create( + service_id: @service_id, + pushed_authorization_request: Authlete::Models::Components::PushedAuthorizationRequest.new( + parameters: par_params, + client_id: @client_id + # client_secret intentionally omitted + ) + ).pushed_authorization_response + + refute_equal 'CREATED', par_resp.action.serialize, + 'PAR request without client_secret must not succeed for a confidential client' + end +end + +# ============================================================================= +# Service with parRequired: true +# ============================================================================= + +class ParRequiredTest < Minitest::Test + include SdkHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + par_required: true, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + par_required: false, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + end + + # A direct authorization request (without PAR) must be rejected when parRequired=true + def test_direct_auth_request_rejected + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: parameters + ) + ).authorization_response + + refute_equal 'INTERACTION', auth_resp.action.serialize, + 'Direct auth request must be rejected when parRequired=true' + end + + # Full PAR flow must succeed even when parRequired=true + def test_par_flow_succeeds_when_required + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + par_params = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + par_resp = @authlete_client.pushed_authorization.create( + service_id: @service_id, + pushed_authorization_request: Authlete::Models::Components::PushedAuthorizationRequest.new( + parameters: par_params, + client_id: @client_id, + client_secret: @client_secret + ) + ).pushed_authorization_response + + assert_equal 'CREATED', par_resp.action.serialize, + "Expected CREATED, got #{par_resp.action}: #{par_resp.result_message}" + refute_nil par_resp.request_uri + + request_uri = par_resp.request_uri + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "client_id=#{@client_id}&request_uri=#{URI.encode_www_form_component(request_uri)}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end diff --git a/test/pkce_test.rb b/test/pkce_test.rb new file mode 100644 index 0000000..e81a47a --- /dev/null +++ b/test/pkce_test.rb @@ -0,0 +1,381 @@ +# frozen_string_literal: true + +require 'digest' +require_relative 'test_helper' + +module PkceHelper + def generate_code_verifier + # RFC 7636: 43-128 chars of unreserved characters + SecureRandom.urlsafe_base64(48) + end + + def s256_code_challenge(code_verifier) + Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier), padding: false) + end +end + +# ============================================================================= +# Standard service — PKCE is optional (clients may use it or skip it). +# These tests verify that PKCE works correctly when a client voluntarily uses it, +# and that a mismatched code_verifier is rejected at the token endpoint. +# ============================================================================= + +class PkceFlowTest < Minitest::Test + include SdkHelper + include PkceHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # S256 happy path: code_verifier verified at token endpoint + def test_pkce_s256_flow + code_verifier = generate_code_verifier + code_challenge = s256_code_challenge(code_verifier) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + # Step 1: Authorization request with code_challenge + S256 + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_challenge}&code_challenge_method=S256" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + refute_nil auth_resp.ticket + + # Step 2: Issue authorization code + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + refute_nil issue_resp.authorization_code + + # Step 3: Token request — must include code_verifier + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{code_verifier}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, client_id: @client_id, client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end + + # plain happy path: code_challenge == code_verifier + def test_pkce_plain_flow + code_verifier = generate_code_verifier + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_verifier}&code_challenge_method=plain" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{code_verifier}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, client_id: @client_id, client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end + + # A mismatched code_verifier must be rejected at the token endpoint + def test_wrong_code_verifier_rejected + code_verifier = generate_code_verifier + code_challenge = s256_code_challenge(code_verifier) + wrong_verifier = generate_code_verifier # intentionally different + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_challenge}&code_challenge_method=S256" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{wrong_verifier}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, client_id: @client_id, client_secret: @client_secret + ) + ).token_response + + refute_equal 'OK', token_resp.action.serialize, + 'Token request with wrong code_verifier must not succeed' + end +end + +# ============================================================================= +# Service with pkceRequired: true +# ============================================================================= + +class PkceRequiredTest < Minitest::Test + include SdkHelper + include PkceHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + pkce_required: true, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + pkce_required: false, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + end + + # Auth request without code_challenge must be rejected + def test_missing_code_challenge_rejected + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + refute_equal 'INTERACTION', auth_resp.action.serialize, + 'Auth request without code_challenge must be rejected when pkceRequired=true' + end + + # Valid S256 PKCE flow must still succeed + def test_pkce_s256_flow_succeeds + code_verifier = generate_code_verifier + code_challenge = s256_code_challenge(code_verifier) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_challenge}&code_challenge_method=S256" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{code_verifier}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, client_id: @client_id, client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end + +# ============================================================================= +# Service with pkceS256Required: true +# ============================================================================= + +class PkceS256RequiredTest < Minitest::Test + include SdkHelper + include PkceHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + pkce_s256_required: true, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + pkce_s256_required: false, access_token_duration: TOKEN_DURATION_SECONDS + ) + ) + end + + # plain method must be rejected when S256 is required + def test_plain_method_rejected + code_verifier = generate_code_verifier + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_verifier}&code_challenge_method=plain" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + refute_equal 'INTERACTION', auth_resp.action.serialize, + 'plain code_challenge_method must be rejected when pkceS256Required=true' + end + + # S256 must still succeed + def test_s256_flow_succeeds + code_verifier = generate_code_verifier + code_challenge = s256_code_challenge(code_verifier) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + parameters = "response_type=code&client_id=#{@client_id}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&state=#{STATE}" \ + "&code_challenge=#{code_challenge}&code_challenge_method=S256" + + auth_resp = @authlete_client.authorization.process_request( + service_id: @service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new(parameters: parameters) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = @authlete_client.authorization.issue_response( + service_id: @service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize + + token_params = "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}" \ + "&code_verifier=#{code_verifier}" + + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: token_params, client_id: @client_id, client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK, got #{token_resp.action}: #{token_resp.result_message}" + refute_nil token_resp.access_token + assert_token_valid(@authlete_client, @service_id, token_resp.access_token) + end +end diff --git a/test/refresh_token_test.rb b/test/refresh_token_test.rb new file mode 100644 index 0000000..1cd4181 --- /dev/null +++ b/test/refresh_token_test.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require_relative 'test_helper' + +# Shared helper: runs auth-code flow and returns the token_response. +module AuthCodeFlowHelper + def do_auth_code_flow(sdk, service_id, client_id, client_secret) + encoded_redirect = URI.encode_www_form_component(REDIRECT_URI) + + auth_resp = sdk.authorization.process_request( + service_id: service_id, + authorization_request: Authlete::Models::Components::AuthorizationRequest.new( + parameters: "response_type=code&client_id=#{client_id}" \ + "&redirect_uri=#{encoded_redirect}&state=#{STATE}" + ) + ).authorization_response + + assert_equal 'INTERACTION', auth_resp.action.serialize, + "Expected INTERACTION, got #{auth_resp.action}" + + issue_resp = sdk.authorization.issue_response( + service_id: service_id, + authorization_issue_request: Authlete::Models::Components::AuthorizationIssueRequest.new( + ticket: auth_resp.ticket, subject: SUBJECT + ) + ).authorization_issue_response + + assert_equal 'LOCATION', issue_resp.action.serialize, + "Expected LOCATION, got #{issue_resp.action}" + + token_resp = sdk.tokens.process_request( + service_id: service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=authorization_code" \ + "&code=#{issue_resp.authorization_code}" \ + "&redirect_uri=#{encoded_redirect}", + client_id: client_id, + client_secret: client_secret + ) + ).token_response + + assert_equal 'OK', token_resp.action.serialize, + "Expected OK at token endpoint, got #{token_resp.action}: #{token_resp.result_message}" + + token_resp + end +end + +# ============================================================================= +# Service with both AUTHORIZATION_CODE and REFRESH_TOKEN grant types enabled. +# Verifies that refresh tokens are issued, can be exchanged, and can be revoked. +# ============================================================================= + +class RefreshTokenFlowTest < Minitest::Test + include SdkHelper + include AuthCodeFlowHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + supported_grant_types: [ + Authlete::Models::Components::GrantType::AUTHORIZATION_CODE, + Authlete::Models::Components::GrantType::REFRESH_TOKEN + ], + access_token_duration: TOKEN_DURATION_SECONDS, + refresh_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + end + + # A refresh token must be issued alongside the access token + def test_refresh_token_issued + token_resp = do_auth_code_flow(@authlete_client, @service_id, @client_id, @client_secret) + refute_nil token_resp.refresh_token, + 'Refresh token must be issued when the refresh_token grant type is supported' + end + + # Exchanging a refresh token must yield a new access token + def test_refresh_token_flow + token_resp = do_auth_code_flow(@authlete_client, @service_id, @client_id, @client_secret) + refresh_token = token_resp.refresh_token + refute_nil refresh_token, 'Refresh token must be issued' + + refresh_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: "grant_type=refresh_token&refresh_token=#{refresh_token}", + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + assert_equal 'OK', refresh_resp.action.serialize, + "Expected OK for refresh token exchange, got #{refresh_resp.action}: #{refresh_resp.result_message}" + refute_nil refresh_resp.access_token, 'New access token must be issued on refresh' + + # Introspect the new access token. + intro_resp = @authlete_client.introspection.process_request( + service_id: @service_id, + introspection_request: Authlete::Models::Components::IntrospectionRequest.new( + token: refresh_resp.access_token + ) + ).introspection_response + + assert_equal 'OK', intro_resp.action.serialize, + "Expected OK for introspection of refreshed access token, got #{intro_resp.action}" + end + + # Revoking a refresh token must succeed + def test_refresh_token_revocation + token_resp = do_auth_code_flow(@authlete_client, @service_id, @client_id, @client_secret) + refresh_token = token_resp.refresh_token + refute_nil refresh_token, 'Refresh token must be issued' + + revocation_resp = @authlete_client.revocation.process_request( + service_id: @service_id, + revocation_request: Authlete::Models::Components::RevocationRequest.new( + parameters: "token=#{refresh_token}", + client_id: @client_id, + client_secret: @client_secret + ) + ).revocation_response + + assert_equal 'OK', revocation_resp.action.serialize, + "Expected OK for refresh token revocation, got #{revocation_resp.action}" + end +end + +# ============================================================================= +# Service with only AUTHORIZATION_CODE grant type (REFRESH_TOKEN not supported). +# Verifies that refresh tokens are not issued and the refresh_token grant is rejected. +# ============================================================================= + +class RefreshTokenNotSupportedTest < Minitest::Test + include SdkHelper + include AuthCodeFlowHelper + + def setup + @service_id = SERVICE_ID + @mgmt_authlete_client = create_sdk_client(MGMT_TOKEN) + @authlete_client = create_sdk_client(SERVICE_TOKEN) + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + supported_grant_types: [ + Authlete::Models::Components::GrantType::AUTHORIZATION_CODE + ], + access_token_duration: TOKEN_DURATION_SECONDS, + refresh_token_duration: TOKEN_DURATION_SECONDS + ) + ) + @client = create_test_client(@mgmt_authlete_client, @service_id) + @client_id = @client.client_id.to_s + @client_secret = @client.client_secret + end + + def teardown + @mgmt_authlete_client.clients.destroy(service_id: @service_id, client_id: @client_id) if @client_id + @mgmt_authlete_client.services.update( + service_id: @service_id, + service: Authlete::Models::Components::ServiceInput.new( + supported_grant_types: [ + Authlete::Models::Components::GrantType::AUTHORIZATION_CODE, + Authlete::Models::Components::GrantType::REFRESH_TOKEN + ], + access_token_duration: TOKEN_DURATION_SECONDS, + refresh_token_duration: TOKEN_DURATION_SECONDS + ) + ) + end + + # No refresh token must be issued when the grant type is not supported + def test_refresh_token_not_issued + token_resp = do_auth_code_flow(@authlete_client, @service_id, @client_id, @client_secret) + assert_nil token_resp.refresh_token, + 'Refresh token must not be issued when the refresh_token grant type is not supported' + end + + # The refresh_token grant must be rejected when not supported by the service + def test_refresh_token_rejected + token_resp = @authlete_client.tokens.process_request( + service_id: @service_id, + token_request: Authlete::Models::Components::TokenRequest.new( + parameters: 'grant_type=refresh_token&refresh_token=dummy_token', + client_id: @client_id, + client_secret: @client_secret + ) + ).token_response + + refute_equal 'OK', token_resp.action.serialize, + 'Refresh token grant must be rejected when not supported by the service' + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..6110877 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require 'minitest/autorun' +require 'faraday' +require 'json' +require 'uri' +require 'openssl' +require 'base64' +require 'securerandom' +require 'digest' +require 'authlete_ruby_sdk' + +# Environment configuration +API_BASE_URL = ENV.fetch('API_BASE_URL') +SERVICE_ID = ENV.fetch('SERVICE_ID', nil) +SERVICE_TOKEN = ENV.fetch('SERVICE_TOKEN', nil) + +# Management token — used for services.update, clients.create, clients.destroy. +# Falls back to SERVICE_TOKEN if ORG_TOKEN is not set. +MGMT_TOKEN = ENV.fetch('ORG_TOKEN', SERVICE_TOKEN) + +# IDP-related — only required for tests that manage service lifecycle via the IDP +IDP_BASE_URL = ENV.fetch('IDP_BASE_URL', nil) +ORG_TOKEN = ENV.fetch('AUTHLETE_ORG_TOKEN', nil) +ORG_ID = ENV.fetch('ORG_ID', '0').to_i +API_SERVER_ID = ENV.fetch('API_SERVER_ID', '0').to_i + +# OAuth flow constants +REDIRECT_URI = 'https://client.example.com/callback' +STATE = 'testState' +SUBJECT = 'testuser' +TOKEN_DURATION_SECONDS = 600 # 10 minutes — long enough for any test to complete + +module DpopHelper + TOKEN_ENDPOINT = 'https://as.example.com/token' + RESOURCE_URL = 'https://rs.example.com/api/resource' + USERINFO_URL = 'https://as.example.com/userinfo' + + def generate_ec_key + OpenSSL::PKey::EC.generate('prime256v1') + end + + def ec_public_jwk(pkey) + # Extract uncompressed EC point from SubjectPublicKeyInfo DER + asn1 = OpenSSL::ASN1.decode(pkey.public_to_der) + point_bytes = asn1.value[1].value # BIT STRING value → 04 || X || Y + x = point_bytes[1, 32] + y = point_bytes[33, 32] + { kty: 'EC', crv: 'P-256', + x: Base64.urlsafe_encode64(x, padding: false), + y: Base64.urlsafe_encode64(y, padding: false) } + end + + def dpop_proof(pkey, htm, htu, access_token: nil, nonce: nil) + header = { typ: 'dpop+jwt', alg: 'ES256', jwk: ec_public_jwk(pkey) } + payload = { jti: SecureRandom.uuid, htm: htm, htu: htu, iat: Time.now.to_i } + payload[:ath] = Base64.urlsafe_encode64(Digest::SHA256.digest(access_token), padding: false) if access_token + payload[:nonce] = nonce if nonce + + h = Base64.urlsafe_encode64(JSON.generate(header), padding: false) + p = Base64.urlsafe_encode64(JSON.generate(payload), padding: false) + signing_input = "#{h}.#{p}" + + der_sig = pkey.sign(OpenSSL::Digest::SHA256.new, signing_input) + raw_sig = der_to_raw_ec_sig(der_sig) + "#{signing_input}.#{Base64.urlsafe_encode64(raw_sig, padding: false)}" + end + + private + + def der_to_raw_ec_sig(der_sig, len = 32) + asn1 = OpenSSL::ASN1.decode(der_sig) + [asn1.value[0].value, asn1.value[1].value].map do |v| + # Ruby 4.0 / newer openssl gem: INTEGER values are OpenSSL::BN, not String + b = v.is_a?(OpenSSL::BN) ? v.to_s(2).b : v.b + b = b[1..] while b.bytesize > len && b.getbyte(0) == 0 + ("\x00".b * [len - b.bytesize, 0].max) + b + end.join + end +end + +module IdpHelper + # Faraday connection to the IDP, reused across calls. + def idp_conn + @idp_conn ||= Faraday.new(url: IDP_BASE_URL) do |f| + f.request :json + f.response :json + f.adapter Faraday.default_adapter + end + end + + # Create a service via the IDP API. + # Returns the parsed Service object (hash with string keys). + def idp_create_service(service_params = {}) + body = { + apiServerId: API_SERVER_ID, + organizationId: ORG_ID, + service: service_params + } + + resp = idp_conn.post('/api/service') do |req| + req.headers['Authorization'] = "Bearer #{ORG_TOKEN}" + req.body = body + end + + unless resp.success? + raise "IDP create service failed (#{resp.status}): #{resp.body}" + end + + resp.body + end + + # Create a service access token via the IDP API. + # Returns the access token string. + def idp_create_service_token(service_id) + body = { + organizationId: ORG_ID, + apiServerId: API_SERVER_ID, + serviceId: service_id, + description: "ruby-sdk-test-#{Time.now.to_i}" + } + + resp = idp_conn.post('/api/servicetoken/create') do |req| + req.headers['Authorization'] = "Bearer #{ORG_TOKEN}" + req.body = body + end + + unless resp.success? + raise "IDP create service token failed (#{resp.status}): #{resp.body}" + end + + resp.body['accessToken'] + end + + # Delete a service via the IDP API. + def idp_delete_service(service_id) + body = { + apiServerId: API_SERVER_ID, + organizationId: ORG_ID, + serviceId: service_id + } + + resp = idp_conn.post('/api/service/remove') do |req| + req.headers['Authorization'] = "Bearer #{ORG_TOKEN}" + req.body = body + end + + # 204 or 200 are both acceptable + unless resp.status == 204 || resp.success? + warn "IDP delete service failed (#{resp.status}): #{resp.body}" + end + end +end + +module SdkHelper + # Create an Authlete SDK client authenticated with the given service token. + def create_sdk_client(service_token) + Authlete::Client.new( + bearer: service_token, + server_url: API_BASE_URL + ) + end + + # Introspects an access token and asserts it is valid (action == OK). + def assert_token_valid(sdk, service_id, access_token) + intro_resp = sdk.introspection.process_request( + service_id: service_id, + introspection_request: Authlete::Models::Components::IntrospectionRequest.new( + token: access_token + ) + ).introspection_response + assert_equal 'OK', intro_resp.action.serialize, + "Expected OK for introspection, got #{intro_resp.action}: #{intro_resp.result_message}" + end + + # Create a confidential OAuth client on the given service via the SDK. + # Returns the Client object from the SDK response. + def create_test_client(sdk_client, service_id) + client_input = Authlete::Models::Components::ClientInput.new( + client_name: "ruby-sdk-test-client-#{Time.now.to_i}", + client_type: Authlete::Models::Components::ClientType::CONFIDENTIAL, + grant_types: [ + Authlete::Models::Components::GrantType::AUTHORIZATION_CODE, + Authlete::Models::Components::GrantType::REFRESH_TOKEN + ], + response_types: [ + Authlete::Models::Components::ResponseType::CODE + ], + redirect_uris: [REDIRECT_URI] + ) + + begin + resp = sdk_client.clients.create( + service_id: service_id.to_s, + client: client_input + ) + rescue Authlete::Models::Errors::ResultError => e + raise "Client creation failed [#{e.result_code}]: #{e.result_message}" + end + + resp.client + end +end