From 2e101c5fe63a3db899fe25c902fad0a8d7baff35 Mon Sep 17 00:00:00 2001 From: Maikel Linke Date: Wed, 13 Mar 2024 16:56:28 +1100 Subject: [PATCH] Refresh OIDC token and try again Access tokens are only valid for half an hour. So if requesting a DFC API fails, it's likely due to an expired token and we refresh it. --- .env.test | 1 + .../dfc_provider/app/services/dfc_request.rb | 45 +++++++- .../spec/services/dfc_request_spec.rb | 53 +++++++++ .../refreshes_the_access_token_on_fail.yml | 105 ++++++++++++++++++ spec/support/vcr_setup.rb | 15 +++ 5 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 engines/dfc_provider/spec/services/dfc_request_spec.rb create mode 100644 spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml diff --git a/.env.test b/.env.test index 535d37a9e0..09881648aa 100644 --- a/.env.test +++ b/.env.test @@ -14,3 +14,4 @@ SITE_URL="test.host" OPENID_APP_ID="test-provider" OPENID_APP_SECRET="12345" +OPENID_REFRESH_TOKEN="dummy-refresh-token" diff --git a/engines/dfc_provider/app/services/dfc_request.rb b/engines/dfc_provider/app/services/dfc_request.rb index 48f666b115..8de0108d99 100644 --- a/engines/dfc_provider/app/services/dfc_request.rb +++ b/engines/dfc_provider/app/services/dfc_request.rb @@ -4,12 +4,31 @@ require "private_address_check" require "private_address_check/tcpsocket_ext" # Request a JSON document from a DFC API with authentication. +# +# All DFC API interactions are authenticated via OIDC tokens. If the user's +# access token is expired, we try to get a new one with the user's refresh +# token. class DfcRequest def initialize(user) @user = user end def get(url) + response = request(url) + + return response.body if response.status == 200 + + return "" if @user.oidc_account.updated_at > 15.minutes.ago + + refresh_access_token! + + response = request(url) + response.body + end + + private + + def request(url) connection = Faraday.new( request: { timeout: 30 }, headers: { @@ -17,18 +36,34 @@ class DfcRequest 'Authorization' => "Bearer #{@user.oidc_account.token}", } ) - response = only_public_connections do + + only_public_connections do connection.get(url) end - - response.body end - private - def only_public_connections(&) return yield if Rails.env.development? PrivateAddressCheck.only_public_connections(&) end + + def refresh_access_token! + strategy = OmniAuth::Strategies::OpenIDConnect.new( + Rails.application, + Devise.omniauth_configs[:openid_connect].options + # Don't try to call `Devise.omniauth(:openid_connect)` first. + # It results in an empty config hash and we lose our config. + ) + client = strategy.client + client.token_endpoint = strategy.config.token_endpoint + client.refresh_token = @user.oidc_account.refresh_token + + token = client.access_token! + + @user.oidc_account.update!( + token: token.access_token, + refresh_token: token.refresh_token + ) + end end diff --git a/engines/dfc_provider/spec/services/dfc_request_spec.rb b/engines/dfc_provider/spec/services/dfc_request_spec.rb new file mode 100644 index 0000000000..a4f0421fad --- /dev/null +++ b/engines/dfc_provider/spec/services/dfc_request_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require_relative "../spec_helper" + +describe DfcRequest do + subject(:api) { DfcRequest.new(user) } + + let(:user) { build(:oidc_user) } + let(:account) { user.oidc_account } + + it "gets a DFC document" do + stub_request(:get, "http://example.net/api"). + to_return(status: 200, body: '{"@context":"/"}') + + expect(api.get("http://example.net/api")).to eq '{"@context":"/"}' + end + + it "refreshes the access token on fail", vcr: true do + # Live VCR recordings require the following secret ENV variables: + # - OPENID_APP_ID + # - OPENID_APP_SECRET + # - OPENID_REFRESH_TOKEN + # You can set them in the .env.test.local file. + + stub_request(:get, "http://example.net/api"). + to_return(status: 401) + + # A refresh is only attempted if the token is stale. + account.refresh_token = ENV.fetch("OPENID_REFRESH_TOKEN") + account.updated_at = 1.day.ago + + expect { + api.get("http://example.net/api") + }.to change { + account.token + }.and change { + account.refresh_token + } + end + + it "doesn't try to refresh the token when it's still fresh" do + stub_request(:get, "http://example.net/api"). + to_return(status: 401) + + user.oidc_account.updated_at = 1.minute.ago + + expect(api.get("http://example.net/api")).to eq "" + + # Trying to reach the OIDC server via network request to refresh the token + # would raise errors because we didn't setup Webmock or VCR. + # The absence of errors makes this test pass. + end +end diff --git a/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml b/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml new file mode 100644 index 0000000000..9a14071f73 --- /dev/null +++ b/spec/fixtures/vcr_cassettes/DfcRequest/refreshes_the_access_token_on_fail.yml @@ -0,0 +1,105 @@ +--- +http_interactions: +- request: + method: get + uri: https://login.lescommuns.org/auth/realms/data-food-consortium/.well-known/openid-configuration + body: + encoding: US-ASCII + string: '' + headers: + User-Agent: + - SWD 2.0.3 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 15 Mar 2024 05:44:06 GMT + Content-Type: + - application/json;charset=UTF-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - Accept-Encoding + Set-Cookie: + - AUTH_SESSION_ID=1710481447.162.5206.870756|6055218c9898cae39f8ffd531999e49a; + Path=/; Secure; HttpOnly + Cache-Control: + - no-cache, must-revalidate, no-transform, no-store + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: '{"issuer":"https://login.lescommuns.org/auth/realms/data-food-consortium","authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth","token_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token","introspection_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token/introspect","userinfo_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/userinfo","end_session_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/logout","frontchannel_logout_session_supported":true,"frontchannel_logout_supported":true,"jwks_uri":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/certs","check_session_iframe":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/login-status-iframe.html","grant_types_supported":["authorization_code","implicit","refresh_token","password","client_credentials","urn:openid:params:grant-type:ciba","urn:ietf:params:oauth:grant-type:device_code"],"acr_values_supported":["0","1"],"response_types_supported":["code","none","id_token","token","id_token + token","code id_token","code token","code id_token token"],"subject_types_supported":["public","pairwise"],"id_token_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"id_token_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"id_token_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"userinfo_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"userinfo_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"userinfo_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"request_object_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512","none"],"request_object_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"request_object_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"response_modes_supported":["query","fragment","form_post","query.jwt","fragment.jwt","form_post.jwt","jwt"],"registration_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/clients-registrations/openid-connect","token_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"token_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"introspection_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"introspection_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"authorization_encryption_alg_values_supported":["RSA-OAEP","RSA-OAEP-256","RSA1_5"],"authorization_encryption_enc_values_supported":["A256GCM","A192GCM","A128GCM","A128CBC-HS256","A192CBC-HS384","A256CBC-HS512"],"claims_supported":["aud","sub","iss","auth_time","name","given_name","family_name","preferred_username","email","acr"],"claim_types_supported":["normal"],"claims_parameter_supported":true,"scopes_supported":["openid","microprofile-jwt","phone","roles","profile","email","address","web-origins","acr","offline_access"],"request_parameter_supported":true,"request_uri_parameter_supported":true,"require_request_uri_registration":true,"code_challenge_methods_supported":["plain","S256"],"tls_client_certificate_bound_access_tokens":true,"revocation_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/revoke","revocation_endpoint_auth_methods_supported":["private_key_jwt","client_secret_basic","client_secret_post","tls_client_auth","client_secret_jwt"],"revocation_endpoint_auth_signing_alg_values_supported":["PS384","ES384","RS384","HS256","HS512","ES256","RS256","HS384","ES512","PS256","PS512","RS512"],"backchannel_logout_supported":true,"backchannel_logout_session_supported":true,"device_authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth/device","backchannel_token_delivery_modes_supported":["poll","ping"],"backchannel_authentication_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/ciba/auth","backchannel_authentication_request_signing_alg_values_supported":["PS384","ES384","RS384","ES256","RS256","ES512","PS256","PS512","RS512"],"require_pushed_authorization_requests":false,"pushed_authorization_request_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/par/request","mtls_endpoint_aliases":{"token_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token","revocation_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/revoke","introspection_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token/introspect","device_authorization_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/auth/device","registration_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/clients-registrations/openid-connect","userinfo_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/userinfo","pushed_authorization_request_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/par/request","backchannel_authentication_endpoint":"https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/ext/ciba/auth"},"authorization_response_iss_parameter_supported":true}' + recorded_at: Fri, 15 Mar 2024 05:44:05 GMT +- request: + method: post + uri: https://login.lescommuns.org/auth/realms/data-food-consortium/protocol/openid-connect/token + body: + encoding: UTF-8 + string: grant_type=refresh_token&refresh_token= + headers: + User-Agent: + - Rack::OAuth2 (2.2.1) + Authorization: + - "" + Content-Type: + - application/x-www-form-urlencoded + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + Accept: + - "*/*" + response: + status: + code: 200 + message: OK + headers: + Date: + - Fri, 15 Mar 2024 05:44:07 GMT + Content-Type: + - application/json + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Vary: + - Accept-Encoding + Set-Cookie: + - AUTH_SESSION_ID=1710481448.492.2309.531618|6055218c9898cae39f8ffd531999e49a; + Path=/; Secure; HttpOnly + Cache-Control: + - no-store + Pragma: + - no-cache + Referrer-Policy: + - no-referrer + Strict-Transport-Security: + - max-age=15724800; includeSubDomains + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Xss-Protection: + - 1; mode=block + body: + encoding: ASCII-8BIT + string: '{"access_token":"","expires_in":1800,"refresh_expires_in":28510621,"refresh_token":"","token_type":"Bearer","id_token":"","not-before-policy":0,"session_state":"989db9a7-584c-4eeb-bff5-db77b53e8def","scope":"openid + profile email"}' + recorded_at: Fri, 15 Mar 2024 05:44:07 GMT +recorded_with: VCR 6.2.0 diff --git a/spec/support/vcr_setup.rb b/spec/support/vcr_setup.rb index 01cc2d9033..1b554f52f2 100644 --- a/spec/support/vcr_setup.rb +++ b/spec/support/vcr_setup.rb @@ -16,6 +16,9 @@ VCR.configure do |config| STRIPE_ACCOUNT STRIPE_CLIENT_ID STRIPE_ENDPOINT_SECRET + OPENID_APP_ID + OPENID_APP_SECRET + OPENID_REFRESH_TOKEN ].each do |env_var| config.filter_sensitive_data("") { ENV.fetch(env_var, nil) } end @@ -25,4 +28,16 @@ VCR.configure do |config| config.filter_sensitive_data('') { |interaction| interaction.response.body.match(/"client_secret": "(pi_.+)"/)&.public_send(:[], 1) } + config.filter_sensitive_data('') { |interaction| + interaction.request.headers['Authorization']&.public_send(:[], 0) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"access_token":"([^"]+)"/)&.public_send(:[], 1) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"id_token":"([^"]+)"/)&.public_send(:[], 1) + } + config.filter_sensitive_data('') { |interaction| + interaction.response.body.match(/"refresh_token":"([^"]+)"/)&.public_send(:[], 1) + } end