From e74c3afd2b70a2cfdb44b5b5be35d450e41ab850 Mon Sep 17 00:00:00 2001 From: Alistair Davidson Date: Wed, 13 Aug 2025 11:12:46 +0100 Subject: [PATCH 1/3] Add token_authentication_concern and spec to support the authentication process for the commissioner reporting app, but do not include them in any controllers yet - a future PR will do that --- .../concerns/token_authentication_concern.rb | 70 +++++ .../token_authentication_concern_spec.rb | 281 ++++++++++++++++++ 2 files changed, 351 insertions(+) create mode 100644 app/controllers/concerns/token_authentication_concern.rb create mode 100644 spec/controllers/concerns/token_authentication_concern_spec.rb diff --git a/app/controllers/concerns/token_authentication_concern.rb b/app/controllers/concerns/token_authentication_concern.rb new file mode 100644 index 0000000000..a37c7aa5e7 --- /dev/null +++ b/app/controllers/concerns/token_authentication_concern.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module TokenAuthenticationConcern + extend ActiveSupport::Concern + + included do + private + + def client_id_error!(token) + if token.blank? + render json: { errors: "invalid_request" }, status: :unauthorized + else + render json: { errors: "unauthorized_client" }, status: :forbidden + end + end + + def authenticate_app_by_client_id! + if Flipper.enabled?(:reporting_api) + # ...as per the spec at https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + given_client_id = params.fetch("client_id", nil) + unless given_client_id == Settings.reporting_api.client_app.client_id + client_id_error!(given_client_id) + end + end + end + + def jwt_if_given + params[:jwt] || + request.headers["Authorization"]&.gsub(/(Bearer\s+)?([:alnum:]*)/, '\2') + end + + def authenticate_user_by_jwt! + jwt = jwt_if_given + jwt_info = decode_jwt!(jwt) + if jwt_info + data = jwt_info.first["data"] + @current_user = + User.find_by( + id: data.dig("user", "id"), + session_token: data.dig("user", "session_token"), + reporting_api_session_token: + data.dig("user", "reporting_api_session_token") + ) + if @current_user + session["user"] = data["user"] + session["cis2_info"] = data["cis2_info"] + authenticate_user! + else + session.clear + client_id_error!(jwt) + Rails.logger.warn "Couldn't find user id #{data.dig("user", "id")} with tokens" + end + end + rescue JWT::DecodeError, NoMethodError + Rails.logger.warn "invalid JWT" + client_id_error!(jwt) + end + end + + def decode_jwt!(jwt) + if jwt + JWT.decode( + jwt, + Settings.reporting_api.client_app.secret, + true, + { algorithm: "HS512" } + ) + end + end +end \ No newline at end of file diff --git a/spec/controllers/concerns/token_authentication_concern_spec.rb b/spec/controllers/concerns/token_authentication_concern_spec.rb new file mode 100644 index 0000000000..ff9d66dd37 --- /dev/null +++ b/spec/controllers/concerns/token_authentication_concern_spec.rb @@ -0,0 +1,281 @@ +# frozen_string_literal: true + +describe TokenAuthenticationConcern do + let(:user) { @user = build(:user) } + let(:mock_request) { instance_double(request.class, headers: {}) } + let(:an_object_which_includes_the_concern) do + Class + .new do # rubocop:disable Style/BlockDelimiters + include TokenAuthenticationConcern + attr_accessor :request, :session + + def authenticate_user! + end + + def initialize(request: nil, session: {}) + @request = request + @session = session + end + + def params + {} + end + + def render(content = {}, args = {}) + end + + def current_user + @user + end + end # rubocop:disable Style/MethodCalledOnDoEndBlock + .new(request: mock_request) + end + + describe "#jwt_if_given" do + context "when there is a jwt param" do + before do + allow(an_object_which_includes_the_concern).to receive(:params).and_return({ jwt: "myjwt" }) + end + + it "returns the jwt param" do + expect(an_object_which_includes_the_concern.send(:jwt_if_given)).to eq("myjwt") + end + end + + context "when there is no jwt param" do + context "but there is an Authorization header" do + before do + an_object_which_includes_the_concern.request = + instance_double( + request.class, + headers: { + "Authorization" => "Bearer myjwt" + } + ) + end + + it "returns the value of the Authorization header, without the leading 'Bearer'" do + result = an_object_which_includes_the_concern.send(:jwt_if_given) + expect(result).to eq("myjwt") + end + end + + context "and there is no Authorization header" do + it "returns nil" do + expect(an_object_which_includes_the_concern.send(:jwt_if_given)).to be_nil + end + end + end + end + + describe "#authenticate_app_by_client_id!" do + let(:client_id) { "something" } + + context "when the :reporting_api feature flag is enabled" do + before { Flipper.enable(:reporting_api) } + + context "and the client_id param is provided" do + before do + allow(an_object_which_includes_the_concern).to receive(:params).and_return( + { client_id: client_id }.with_indifferent_access + ) + end + + context "and the client_id param contains the reporting app's client_id" do + let(:client_id) { Settings.reporting_api.client_app.client_id } + + it "does not cause a token error" do + expect(an_object_which_includes_the_concern).not_to receive(:client_id_error!) + an_object_which_includes_the_concern.send(:authenticate_app_by_client_id!) + end + end + + context "and the client_id param does not contain the reporting app client_id" do + it "causes a token error" do + expect(an_object_which_includes_the_concern).to receive(:client_id_error!) + an_object_which_includes_the_concern.send(:authenticate_app_by_client_id!) + end + end + end + end + end + + describe "#client_id_error!" do + context "given an empty token" do + let(:token) { "" } + + it "renders a invalid_request error, with status :unauthorized" do + expect(an_object_which_includes_the_concern).to receive(:render).with( + json: { + errors: "invalid_request" + }, + status: :unauthorized + ) + an_object_which_includes_the_concern.send(:client_id_error!, token) + end + end + + context "given a token that is not empty, but does not match the reporting app's client_id" do + let(:token) { "unmatched token" } + + it "renders a unauthorized_client error, with status forbidden" do + expect(an_object_which_includes_the_concern).to receive(:render).with( + json: { + errors: "unauthorized_client" + }, + status: :forbidden + ) + an_object_which_includes_the_concern.send(:client_id_error!, token) + end + end + end + + describe "#authenticate_user_by_jwt!" do + let(:jwt) { "" } + let(:user_id) { 0 } + let(:session_token) { "123456abcdef" } + let(:reporting_api_session_token) { "0987654321123456abcdef" } + + let(:user) do + create( + :user, + session_token: session_token, + reporting_api_session_token: reporting_api_session_token + ) + end + + before do + an_object_which_includes_the_concern.request = + instance_double(request.class, headers: { "Authorization" => jwt }) + end + + context "when a valid jwt is given" do + let(:jwt) { "validjwt" } + let(:user_info) do + [ + { + "data" => { + "user" => { + "id" => user_id, + "session_token" => session_token, + "reporting_api_session_token" => reporting_api_session_token + }, + "cis2_info" => { + "some_key" => "some value" + } + } + } + ] + end + + before do + allow(an_object_which_includes_the_concern).to receive(:decode_jwt!).with(jwt).and_return( + user_info + ) + allow(an_object_which_includes_the_concern).to receive(:authenticate_user!) + end + + it "decodes the JWT" do + expect(an_object_which_includes_the_concern).to receive(:decode_jwt!).with(jwt) + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + end + + context "when a User exists with the values of id, session_token and reporting_api_session_token" do + let(:user_id) { user.id } + + it "copies the user key into session['user']" do + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + expect(an_object_which_includes_the_concern.session["user"]).to eq( + user_info.first["data"]["user"] + ) + end + + it "copies the cis2_info key into session['cis2_info']" do + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + expect(an_object_which_includes_the_concern.session["cis2_info"]).to eq( + user_info.first["data"]["cis2_info"] + ) + end + + it "calls authenticate_user!" do + expect(an_object_which_includes_the_concern).to receive(:authenticate_user!) + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + end + end + + context "when a User does not exist with the values of id, session_token and reporting_api_session_token" do + let(:user_id) { user.id } + + before do + user.update!( + session_token: "someothersessiontoken", + reporting_api_session_token: "someotherpwdauthsessiontoken" + ) + an_object_which_includes_the_concern.session = { + user_id: user.id, + some_other_session_var: "some value" + } + end + + it "clears the session" do + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + expect(an_object_which_includes_the_concern.session).to be_empty + end + + it "calls client_id_error!" do + expect(an_object_which_includes_the_concern).to receive(:client_id_error!) + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + end + end + end + + context "when a valid jwt is not given" do + it "causes a client_id_error!" do + expect(an_object_which_includes_the_concern).to receive(:client_id_error!) + an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) + end + end + end + + describe "decode_jwt!" do + context "given a jwt" do + let(:jwt) { "somejwt" } + let(:decoded_jwt) do + { "some_key" => { "some nested_key" => "some nested value" } } + end + + it "tries to decode it with the mavis reporting app secret" do + expect(JWT).to receive(:decode).with( + jwt, + Settings.reporting_api.client_app.secret, + true, + { algorithm: "HS512" } + ) #.and_return(decoded_jwt) + an_object_which_includes_the_concern.send(:decode_jwt!, jwt) + end + + context "when decoding works" do + before do + allow(JWT).to receive(:decode).with( + jwt, + Settings.reporting_api.client_app.secret, + true, + { algorithm: "HS512" } + ).and_return(decoded_jwt) + end + + it "returns the decoded JWT" do + expect(an_object_which_includes_the_concern.send(:decode_jwt!, jwt)).to eq(decoded_jwt) + end + end + + context "when decoding does not work" do + it "raises an exception" do + expect { an_object_which_includes_the_concern.send(:decode_jwt!, jwt) }.to raise_error( + JWT::DecodeError + ) + end + end + end + end +end \ No newline at end of file From c8d2037cfc57b2f8e5f0dd4ffca97218bef1bcf0 Mon Sep 17 00:00:00 2001 From: Al Davidson Date: Wed, 13 Aug 2025 12:16:00 +0100 Subject: [PATCH 2/3] fetch / slice for jwt hash elements Co-authored-by: Thomas Leese --- app/controllers/concerns/token_authentication_concern.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/app/controllers/concerns/token_authentication_concern.rb b/app/controllers/concerns/token_authentication_concern.rb index a37c7aa5e7..42a1e45a15 100644 --- a/app/controllers/concerns/token_authentication_concern.rb +++ b/app/controllers/concerns/token_authentication_concern.rb @@ -36,10 +36,9 @@ def authenticate_user_by_jwt! data = jwt_info.first["data"] @current_user = User.find_by( - id: data.dig("user", "id"), - session_token: data.dig("user", "session_token"), - reporting_api_session_token: - data.dig("user", "reporting_api_session_token") + data + .fetch("user", {}) + .slice("id", "session_token", "reporting_api_session_token") ) if @current_user session["user"] = data["user"] @@ -67,4 +66,4 @@ def decode_jwt!(jwt) ) end end -end \ No newline at end of file +end From 98639b0372dc2606df774cee68b4e6fd2f12166a Mon Sep 17 00:00:00 2001 From: Alistair Davidson Date: Wed, 13 Aug 2025 12:18:45 +0100 Subject: [PATCH 3/3] move token_authentication_concern under reporting_api namespace --- .../token_authentication_concern.rb | 10 ++- .../token_authentication_concern_spec.rb | 80 ++++++++++++------- 2 files changed, 59 insertions(+), 31 deletions(-) rename app/controllers/concerns/{ => reporting_api}/token_authentication_concern.rb (89%) rename spec/controllers/concerns/{ => reporting_api}/token_authentication_concern_spec.rb (82%) diff --git a/app/controllers/concerns/token_authentication_concern.rb b/app/controllers/concerns/reporting_api/token_authentication_concern.rb similarity index 89% rename from app/controllers/concerns/token_authentication_concern.rb rename to app/controllers/concerns/reporting_api/token_authentication_concern.rb index 42a1e45a15..354abef964 100644 --- a/app/controllers/concerns/token_authentication_concern.rb +++ b/app/controllers/concerns/reporting_api/token_authentication_concern.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -module TokenAuthenticationConcern +module ReportingAPI::TokenAuthenticationConcern extend ActiveSupport::Concern included do @@ -36,9 +36,11 @@ def authenticate_user_by_jwt! data = jwt_info.first["data"] @current_user = User.find_by( - data - .fetch("user", {}) - .slice("id", "session_token", "reporting_api_session_token") + data.fetch("user", {}).slice( + "id", + "session_token", + "reporting_api_session_token" + ) ) if @current_user session["user"] = data["user"] diff --git a/spec/controllers/concerns/token_authentication_concern_spec.rb b/spec/controllers/concerns/reporting_api/token_authentication_concern_spec.rb similarity index 82% rename from spec/controllers/concerns/token_authentication_concern_spec.rb rename to spec/controllers/concerns/reporting_api/token_authentication_concern_spec.rb index ff9d66dd37..96a82f9b95 100644 --- a/spec/controllers/concerns/token_authentication_concern_spec.rb +++ b/spec/controllers/concerns/reporting_api/token_authentication_concern_spec.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -describe TokenAuthenticationConcern do +describe ReportingAPI::TokenAuthenticationConcern do let(:user) { @user = build(:user) } let(:mock_request) { instance_double(request.class, headers: {}) } let(:an_object_which_includes_the_concern) do Class .new do # rubocop:disable Style/BlockDelimiters - include TokenAuthenticationConcern + include ReportingAPI::TokenAuthenticationConcern attr_accessor :request, :session def authenticate_user! @@ -34,11 +34,15 @@ def current_user describe "#jwt_if_given" do context "when there is a jwt param" do before do - allow(an_object_which_includes_the_concern).to receive(:params).and_return({ jwt: "myjwt" }) + allow(an_object_which_includes_the_concern).to receive( + :params + ).and_return({ jwt: "myjwt" }) end it "returns the jwt param" do - expect(an_object_which_includes_the_concern.send(:jwt_if_given)).to eq("myjwt") + expect(an_object_which_includes_the_concern.send(:jwt_if_given)).to eq( + "myjwt" + ) end end @@ -62,7 +66,9 @@ def current_user context "and there is no Authorization header" do it "returns nil" do - expect(an_object_which_includes_the_concern.send(:jwt_if_given)).to be_nil + expect( + an_object_which_includes_the_concern.send(:jwt_if_given) + ).to be_nil end end end @@ -76,24 +82,32 @@ def current_user context "and the client_id param is provided" do before do - allow(an_object_which_includes_the_concern).to receive(:params).and_return( - { client_id: client_id }.with_indifferent_access - ) + allow(an_object_which_includes_the_concern).to receive( + :params + ).and_return({ client_id: client_id }.with_indifferent_access) end context "and the client_id param contains the reporting app's client_id" do let(:client_id) { Settings.reporting_api.client_app.client_id } it "does not cause a token error" do - expect(an_object_which_includes_the_concern).not_to receive(:client_id_error!) - an_object_which_includes_the_concern.send(:authenticate_app_by_client_id!) + expect(an_object_which_includes_the_concern).not_to receive( + :client_id_error! + ) + an_object_which_includes_the_concern.send( + :authenticate_app_by_client_id! + ) end end context "and the client_id param does not contain the reporting app client_id" do it "causes a token error" do - expect(an_object_which_includes_the_concern).to receive(:client_id_error!) - an_object_which_includes_the_concern.send(:authenticate_app_by_client_id!) + expect(an_object_which_includes_the_concern).to receive( + :client_id_error! + ) + an_object_which_includes_the_concern.send( + :authenticate_app_by_client_id! + ) end end end @@ -169,14 +183,18 @@ def current_user end before do - allow(an_object_which_includes_the_concern).to receive(:decode_jwt!).with(jwt).and_return( - user_info + allow(an_object_which_includes_the_concern).to receive( + :decode_jwt! + ).with(jwt).and_return(user_info) + allow(an_object_which_includes_the_concern).to receive( + :authenticate_user! ) - allow(an_object_which_includes_the_concern).to receive(:authenticate_user!) end it "decodes the JWT" do - expect(an_object_which_includes_the_concern).to receive(:decode_jwt!).with(jwt) + expect(an_object_which_includes_the_concern).to receive( + :decode_jwt! + ).with(jwt) an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) end @@ -192,13 +210,15 @@ def current_user it "copies the cis2_info key into session['cis2_info']" do an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) - expect(an_object_which_includes_the_concern.session["cis2_info"]).to eq( - user_info.first["data"]["cis2_info"] - ) + expect( + an_object_which_includes_the_concern.session["cis2_info"] + ).to eq(user_info.first["data"]["cis2_info"]) end it "calls authenticate_user!" do - expect(an_object_which_includes_the_concern).to receive(:authenticate_user!) + expect(an_object_which_includes_the_concern).to receive( + :authenticate_user! + ) an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) end end @@ -223,7 +243,9 @@ def current_user end it "calls client_id_error!" do - expect(an_object_which_includes_the_concern).to receive(:client_id_error!) + expect(an_object_which_includes_the_concern).to receive( + :client_id_error! + ) an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) end end @@ -231,7 +253,9 @@ def current_user context "when a valid jwt is not given" do it "causes a client_id_error!" do - expect(an_object_which_includes_the_concern).to receive(:client_id_error!) + expect(an_object_which_includes_the_concern).to receive( + :client_id_error! + ) an_object_which_includes_the_concern.send(:authenticate_user_by_jwt!) end end @@ -265,17 +289,19 @@ def current_user end it "returns the decoded JWT" do - expect(an_object_which_includes_the_concern.send(:decode_jwt!, jwt)).to eq(decoded_jwt) + expect( + an_object_which_includes_the_concern.send(:decode_jwt!, jwt) + ).to eq(decoded_jwt) end end context "when decoding does not work" do it "raises an exception" do - expect { an_object_which_includes_the_concern.send(:decode_jwt!, jwt) }.to raise_error( - JWT::DecodeError - ) + expect { + an_object_which_includes_the_concern.send(:decode_jwt!, jwt) + }.to raise_error(JWT::DecodeError) end end end end -end \ No newline at end of file +end