From fc0716751f3d8f6ed1d3ff9c6c60ebfae2ed084c Mon Sep 17 00:00:00 2001 From: kaysiz Date: Mon, 6 Jul 2026 10:26:53 +0200 Subject: [PATCH 1/2] Add Bearer API key authentication for MDS endpoints Accept Authorization: Bearer tokens alongside Basic auth on DOI, media, and metadata routes, and forward credentials to the DataCite API via Maremma. Extract shared auth logic into ApiAuthenticatable to centralize Maremma credential handling, client relationships for Basic auth, and missing-auth responses. Add WebMock and request specs to verify bearer tokens are accepted and forwarded. --- app/controllers/application_controller.rb | 38 ++++++-- .../concerns/api_authenticatable.rb | 40 ++++++++ app/controllers/concerns/doiable.rb | 62 ++++++------ app/controllers/concerns/mediable.rb | 68 ++++++------- app/controllers/concerns/metadatable.rb | 97 ++++++++----------- app/controllers/dois_controller.rb | 10 +- app/controllers/media_controller.rb | 10 +- app/controllers/metadata_controller.rb | 8 +- spec/concerns/doiable_spec.rb | 19 +++- spec/concerns/mediable_spec.rb | 13 +++ spec/concerns/metadatable_spec.rb | 15 +++ spec/requests/doi_spec.rb | 8 ++ spec/requests/media_spec.rb | 10 ++ spec/requests/metadata_spec.rb | 10 ++ 14 files changed, 264 insertions(+), 144 deletions(-) create mode 100644 app/controllers/concerns/api_authenticatable.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 66e9403..eb4dbad 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,20 +1,25 @@ class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Basic::ControllerMethods + include ApiAuthenticatable include Bolognese::DoiUtils include Bolognese::Utils - attr_accessor :username, :password + attr_accessor :username, :password, :bearer before_action :set_raven_context after_action :set_consumer_header - # check that username and password exist - # store them in instance variables used for calling MDS API - def authenticate_user_with_basic_auth! - @username, @password = ActionController::HttpAuthentication::Basic::user_name_and_password(request) + # check that Basic or Bearer credentials exist + # store them in instance variables used for calling DataCite API + def authenticate_request! + @bearer = bearer_token_from_request + return if @bearer.present? - request_http_basic_authentication(ENV["REALM"], "An Authentication object was not found in the SecurityContext") if @username.blank? || @password.blank? + @username, @password = basic_credentials_from_request + return if @username.present? && @password.present? + + request_http_basic_authentication(ENV["REALM"], "An Authentication object was not found in the SecurityContext") end def set_consumer_header @@ -42,7 +47,7 @@ def route_not_found end if status == 401 - response.headers["WWW-Authenticate"] = "Basic realm=\"#{ENV['REALM']}\"" + response.headers["WWW-Authenticate"] = "Basic realm=\"#{ENV['REALM']}\", Bearer realm=\"#{ENV['REALM']}\"" response.headers.delete_if { |key| key == "X-Credential-Username" } message = "Bad credentials" elsif status == 403 @@ -82,4 +87,23 @@ def set_raven_context ) end end + + private + + def bearer_token_from_request + auth_header = request.authorization.to_s + return nil unless auth_header.match?(/\ABearer\s+/i) + + auth_header.sub(/\ABearer\s+/i, "").strip.presence + end + + def basic_credentials_from_request + auth_header = request.authorization.to_s + return ActionController::HttpAuthentication::Basic::user_name_and_password(request) unless auth_header.match?(/\ABasic\s+/i) + return ActionController::HttpAuthentication::Basic::user_name_and_password(request) if auth_header.start_with?("Basic ") + + raw_credentials = auth_header.sub(/\ABasic\s+/i, "").strip + decoded = Base64.decode64(raw_credentials) + decoded.split(":", 2) + end end diff --git a/app/controllers/concerns/api_authenticatable.rb b/app/controllers/concerns/api_authenticatable.rb new file mode 100644 index 0000000..6ff5ec9 --- /dev/null +++ b/app/controllers/concerns/api_authenticatable.rb @@ -0,0 +1,40 @@ +module ApiAuthenticatable + extend ActiveSupport::Concern + + class_methods do + def maremma_auth(options = {}) + return { bearer: options[:bearer] } if options[:bearer].present? + return { username: options[:username], password: options[:password] } if options[:username].present? && options[:password].present? + + nil + end + + def missing_auth_response + OpenStruct.new(body: { "errors" => [{ "title" => "Authentication credentials missing" }] }) + end + + def with_maremma_auth(options = {}) + auth = maremma_auth(options) + return missing_auth_response if auth.blank? + + yield auth + end + + def client_relationship(auth) + return nil unless auth.key?(:username) + + { + "client" => { + "data" => { + "type" => "clients", + "id" => auth[:username], + }, + }, + } + end + end + + def forward_auth_options + self.class.maremma_auth(bearer: bearer, username: username, password: password) + end +end \ No newline at end of file diff --git a/app/controllers/concerns/doiable.rb b/app/controllers/concerns/doiable.rb index 26db0c0..31265ed 100644 --- a/app/controllers/concerns/doiable.rb +++ b/app/controllers/concerns/doiable.rb @@ -1,5 +1,6 @@ module Doiable extend ActiveSupport::Concern + include ApiAuthenticatable included do def extract_url(doi: nil, data: nil) @@ -22,56 +23,51 @@ def extract_url(doi: nil, data: nil) end module ClassMethods - require "uri" def put_doi(doi, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - return OpenStruct.new(body: { "errors" => [{ "title" => "Not a valid HTTP(S) or FTP URL" }] }) unless /\A(http|https|ftp):\/\/[\S]+/.match(options[:url]) + with_maremma_auth(options) do |auth| + return OpenStruct.new(body: { "errors" => [{ "title" => "Not a valid HTTP(S) or FTP URL" }] }) unless /\A(http|https|ftp):\/\/[\S]+/.match(options[:url]) - data = { - "data" => { - "type" => "dois", - "attributes"=> { - "url" => options[:url], - "should_validate" => "true", - "source" => "mds", - "event" => "publish" - }, - "relationships"=> { - "client"=> { - "data"=> { - "type"=> "clients", - "id"=> options[:username] - } + data = { + "data" => { + "type" => "dois", + "attributes"=> { + "url" => options[:url], + "should_validate" => "true", + "source" => "mds", + "event" => "publish" } } } - } - url = "#{ENV['API_URL']}/dois/#{doi}" - Maremma.put(url, content_type: 'application/vnd.api+json', data: data.to_json, username: options[:username], password: options[:password]) + relationships = client_relationship(auth) + data["data"]["relationships"] = relationships if relationships + + url = "#{ENV['API_URL']}/dois/#{doi}" + Maremma.put(url, content_type: 'application/vnd.api+json', data: data.to_json, **auth) + end end def get_doi(doi, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}/get-url" - Maremma.get(url, username: options[:username], password: options[:password]) + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}/get-url" + Maremma.get(url, **auth) + end end def delete_doi(doi, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}" - Maremma.delete(url, username: options[:username], password: options[:password]) + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}" + Maremma.delete(url, **auth) + end end def get_dois(options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/get-dois" - Maremma.get(url, username: options[:username], password: options[:password]) + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/get-dois" + Maremma.get(url, **auth) + end end end end \ No newline at end of file diff --git a/app/controllers/concerns/mediable.rb b/app/controllers/concerns/mediable.rb index 167b1b0..8741595 100644 --- a/app/controllers/concerns/mediable.rb +++ b/app/controllers/concerns/mediable.rb @@ -1,57 +1,59 @@ module Mediable extend ActiveSupport::Concern + include ApiAuthenticatable module ClassMethods def create_media(doi, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - return OpenStruct.new(body: { "errors" => [{ "title" => "Media type and URL missing" }] }) unless options[:data].present? + with_maremma_auth(options) do |auth| + return OpenStruct.new(body: { "errors" => [{ "title" => "Media type and URL missing" }] }) unless options[:data].present? - media_type, url = options[:data].split("=") + media_type, url = options[:data].split("=") - data = { - "data" => { - "type" => "media", - "attributes"=> { + data = { + "data" => { + "type" => "media", + "attributes"=> { "mediaType" => media_type, - "url" => url + "url" => url + } } } - } - url = "#{ENV['API_URL']}/dois/#{doi}/media" - Maremma.post(url, content_type: 'application/vnd.api+json', data: data.to_json, username: options[:username], password: options[:password]) + url = "#{ENV['API_URL']}/dois/#{doi}/media" + Maremma.post(url, content_type: 'application/vnd.api+json', data: data.to_json, **auth) + end end def list_media(doi, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}/media" - response = Maremma.get(url, content_type: 'application/vnd.api+json', username: options[:username], password: options[:password]) - return response unless response.body["data"].present? + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}/media" + response = Maremma.get(url, content_type: 'application/vnd.api+json', **auth) + return response unless response.body["data"].present? - response.body["data"] = Array.wrap(response.body["data"]).map do |m| - "#{m.dig("attributes", "mediaType").to_s}=#{m.dig("attributes", "url").to_s}" - end.join("\n") - response + response.body["data"] = Array.wrap(response.body["data"]).map do |m| + "#{m.dig("attributes", "mediaType").to_s}=#{m.dig("attributes", "url").to_s}" + end.join("\n") + response + end end def get_media(doi, id, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}/media/#{id}" - response = Maremma.get(url, content_type: 'application/vnd.api+json', username: options[:username], password: options[:password]) - return response unless response.body["data"].present? - - m = response.body["data"] - response.body["data"] = "#{m.dig("attributes", "mediaType").to_s}=#{m.dig("attributes", "url").to_s}" - response + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}/media/#{id}" + response = Maremma.get(url, content_type: 'application/vnd.api+json', **auth) + return response unless response.body["data"].present? + + m = response.body["data"] + response.body["data"] = "#{m.dig("attributes", "mediaType").to_s}=#{m.dig("attributes", "url").to_s}" + response + end end def delete_media(doi, id, options={}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}/media/#{id}" - response = Maremma.delete(url, content_type: 'application/vnd.api+json', username: options[:username], password: options[:password]) + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}/media/#{id}" + Maremma.delete(url, content_type: 'application/vnd.api+json', **auth) + end end end end \ No newline at end of file diff --git a/app/controllers/concerns/metadatable.rb b/app/controllers/concerns/metadatable.rb index 2974ac7..9026c81 100644 --- a/app/controllers/concerns/metadatable.rb +++ b/app/controllers/concerns/metadatable.rb @@ -1,5 +1,6 @@ module Metadatable extend ActiveSupport::Concern + include ApiAuthenticatable require "bolognese" require "securerandom" @@ -98,70 +99,58 @@ module ClassMethods include Bolognese::DoiUtils def get_metadata(doi, options = {}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - url = "#{ENV['API_URL']}/dois/#{doi}" - Maremma.get(url, accept: "application/vnd.datacite.datacite+xml", username: options[:username], password: options[:password], raw: true) + with_maremma_auth(options) do |auth| + url = "#{ENV['API_URL']}/dois/#{doi}" + Maremma.get(url, accept: "application/vnd.datacite.datacite+xml", raw: true, **auth) + end end def create_metadata(doi, options = {}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - xml = options[:data].present? ? ::Base64.strict_encode64(options[:data]) : nil - - attributes = { - "doi" => doi, - "xml" => xml, - "should_validate" => "true", - "source" => "mds", - "event" => "show", - }.compact - - relationships = { - "client" => { + with_maremma_auth(options) do |auth| + xml = options[:data].present? ? ::Base64.strict_encode64(options[:data]) : nil + + attributes = { + "doi" => doi, + "xml" => xml, + "should_validate" => "true", + "source" => "mds", + "event" => "show", + }.compact + + data = { "data" => { - "type" => "clients", - "id" => options[:username], + "type" => "dois", + "attributes" => attributes, }, - }, - } - - data = { - "data" => { - "type" => "dois", - "attributes" => attributes, - "relationships" => relationships, - }, - } - - url = "#{ENV['API_URL']}/dois/#{doi}" - Maremma.put(url, content_type: "application/vnd.api+json", data: data.to_json, username: options[:username], password: options[:password]) + } + + relationships = client_relationship(auth) + data["data"]["relationships"] = relationships if relationships + + url = "#{ENV['API_URL']}/dois/#{doi}" + Maremma.put(url, content_type: "application/vnd.api+json", data: data.to_json, **auth) + end end def delete_metadata(doi, options = {}) - return OpenStruct.new(body: { "errors" => [{ "title" => "Username or password missing" }] }) unless options[:username].present? && options[:password].present? - - attributes = { - "event" => "hide", - } - - data = { - "data" => { - "type" => "dois", - "attributes" => attributes, - "relationships" => { - "client" => { - "data" => { - "type" => "clients", - "id" => options[:username], - }, - }, + with_maremma_auth(options) do |auth| + attributes = { + "event" => "hide", + } + + data = { + "data" => { + "type" => "dois", + "attributes" => attributes, }, - }, - } + } + + relationships = client_relationship(auth) + data["data"]["relationships"] = relationships if relationships - url = "#{ENV['API_URL']}/dois/#{doi}" - Maremma.patch(url, content_type: "application/vnd.api+json", data: data.to_json, username: options[:username], password: options[:password]) + url = "#{ENV['API_URL']}/dois/#{doi}" + Maremma.patch(url, content_type: "application/vnd.api+json", data: data.to_json, **auth) + end end end end diff --git a/app/controllers/dois_controller.rb b/app/controllers/dois_controller.rb index 5365433..bf6380b 100644 --- a/app/controllers/dois_controller.rb +++ b/app/controllers/dois_controller.rb @@ -1,12 +1,12 @@ class DoisController < ApplicationController include Doiable - prepend_before_action :authenticate_user_with_basic_auth! + prepend_before_action :authenticate_request! before_action :set_doi, only: %i(show destroy) before_action :set_raven_context, only: %i(update) def index - response = DoisController.get_dois(username: username, password: password) + response = DoisController.get_dois(**forward_auth_options) if response.status == 200 render plain: response.body.dig("data", "dois").join("\n"), status: :ok @@ -25,7 +25,7 @@ def index end def show - response = DoisController.get_doi(@doi, username: username, password: password) + response = DoisController.get_doi(@doi, **forward_auth_options) if response.status == 200 render plain: response.body.dig("data", "url"), status: :ok @@ -55,7 +55,7 @@ def update return head :bad_request end - response = DoisController.put_doi(doi, url: url, username: username, password: password) + response = DoisController.put_doi(doi, url: url, **forward_auth_options) if [200, 201].include?(response.status) render plain: "OK", status: :created elsif response.status == 401 @@ -73,7 +73,7 @@ def update end def destroy - response = DoisController.delete_doi(@doi, username: username, password: password) + response = DoisController.delete_doi(@doi, **forward_auth_options) if response.status == 204 render plain: "OK", status: :ok diff --git a/app/controllers/media_controller.rb b/app/controllers/media_controller.rb index e34e228..e582c4e 100644 --- a/app/controllers/media_controller.rb +++ b/app/controllers/media_controller.rb @@ -1,12 +1,12 @@ class MediaController < ApplicationController include Mediable - prepend_before_action :authenticate_user_with_basic_auth! + prepend_before_action :authenticate_request! before_action :set_doi before_action :set_media, only: [:show, :destroy] def index - response = MediaController.list_media(@doi, username: username, password: password) + response = MediaController.list_media(@doi, **forward_auth_options) if response.status == 200 && response.body["data"].present? render plain: response.body["data"], status: :ok @@ -27,7 +27,7 @@ def index end def show - response = MediaController.get_media(@doi, @id, username: username, password: password) + response = MediaController.get_media(@doi, @id, **forward_auth_options) if response.status == 200 render plain: response.body["data"], status: :ok @@ -46,7 +46,7 @@ def show end def create - response = MediaController.create_media(@doi, data: safe_params[:data], username: username, password: password) + response = MediaController.create_media(@doi, data: safe_params[:data], **forward_auth_options) if [200, 201].include?(response.status) render plain: "OK", status: :ok @@ -65,7 +65,7 @@ def create end def destroy - response = MediaController.delete_media(@doi, @id, username: username, password: password) + response = MediaController.delete_media(@doi, @id, **forward_auth_options) if response.status == 204 render plain: "OK", status: :ok diff --git a/app/controllers/metadata_controller.rb b/app/controllers/metadata_controller.rb index d254d35..eb0ffd5 100644 --- a/app/controllers/metadata_controller.rb +++ b/app/controllers/metadata_controller.rb @@ -1,7 +1,7 @@ class MetadataController < ApplicationController include Metadatable - prepend_before_action :authenticate_user_with_basic_auth! + prepend_before_action :authenticate_request! before_action :set_doi, only: [:destroy] before_action :set_raven_context, only: [:create_metadata] @@ -9,7 +9,7 @@ def index @doi = validate_doi(params[:doi_id]) fail AbstractController::ActionNotFound unless @doi.present? - response = MetadataController.get_metadata(@doi, username: username, password: password) + response = MetadataController.get_metadata(@doi, **forward_auth_options) if response.status == 200 render xml: response.body["data"], status: :ok @@ -47,7 +47,7 @@ def create return end - response = MetadataController.create_metadata(@doi, data: safe_params[:data], username: username, password: password) + response = MetadataController.create_metadata(@doi, data: safe_params[:data], **forward_auth_options) if [200, 201].include?(response.status) render plain: "OK (" + response.body.dig("data", "id").upcase + ")", status: :created, location: ENV["MDS_URL"] + "/metadata/" + @doi @@ -64,7 +64,7 @@ def create end def destroy - response = MetadataController.delete_metadata(@doi, username: username, password: password) + response = MetadataController.delete_metadata(@doi, **forward_auth_options) if response.status == 200 render plain: "OK", status: :ok diff --git a/spec/concerns/doiable_spec.rb b/spec/concerns/doiable_spec.rb index 1f7785d..21269e6 100644 --- a/spec/concerns/doiable_spec.rb +++ b/spec/concerns/doiable_spec.rb @@ -9,6 +9,19 @@ subject { DoisController } + context "bearer auth" do + it "forwards bearer token to API" do + url = "#{ENV['API_URL']}/dois/#{doi}/get-url" + stub = stub_request(:get, url) + .with(headers: { "Authorization" => "Bearer test-api-key" }) + .to_return(status: 404, body: { errors: [{ title: "Not found" }] }.to_json, headers: { "Content-Type" => "application/json" }) + + response = subject.get_doi(doi, bearer: "test-api-key") + expect(response.status).to eq(404) + expect(stub).to have_been_requested + end + end + context "put_doi" do # it "should register" do # options = { url: url, username: username, password: password } @@ -26,7 +39,7 @@ it "no password" do options = { username: username, password: nil } - expect(subject.put_doi(doi, options).body.dig("errors")).to eq([{"title"=>"Username or password missing"}]) + expect(subject.put_doi(doi, options).body.dig("errors")).to eq([{"title"=>"Authentication credentials missing"}]) end end @@ -47,7 +60,7 @@ it "no password" do options = { username: username, password: nil } - expect(subject.get_dois(options).body.dig("errors")).to eq([{"title"=>"Username or password missing"}]) + expect(subject.get_dois(options).body.dig("errors")).to eq([{"title"=>"Authentication credentials missing"}]) end end @@ -76,7 +89,7 @@ it "no password" do options = { username: username, password: nil } - expect(subject.delete_doi(doi, options).body.dig("errors")).to eq([{"title"=>"Username or password missing"}]) + expect(subject.delete_doi(doi, options).body.dig("errors")).to eq([{"title"=>"Authentication credentials missing"}]) end end diff --git a/spec/concerns/mediable_spec.rb b/spec/concerns/mediable_spec.rb index 1c6a3e3..58d0036 100644 --- a/spec/concerns/mediable_spec.rb +++ b/spec/concerns/mediable_spec.rb @@ -10,6 +10,19 @@ subject { MediaController } + context "bearer auth" do + it "forwards bearer token to API" do + url = "#{ENV['API_URL']}/dois/#{doi}/media" + stub = stub_request(:get, url) + .with(headers: { "Authorization" => "Bearer test-api-key" }) + .to_return(status: 200, body: { data: [] }.to_json, headers: { "Content-Type" => "application/json" }) + + response = subject.list_media(doi, bearer: "test-api-key") + expect(response.status).to eq(200) + expect(stub).to have_been_requested + end + end + context "create_media" do it "should register" do options = { data: data, username: username, password: password } diff --git a/spec/concerns/metadatable_spec.rb b/spec/concerns/metadatable_spec.rb index 39634e6..cfb0c7f 100644 --- a/spec/concerns/metadatable_spec.rb +++ b/spec/concerns/metadatable_spec.rb @@ -14,6 +14,21 @@ let(:doiv3a) { "10.14454/0000-0003" } let(:doiv4a) { "10.14454/0000-0003" } + context "bearer auth" do + subject { MetadataController } + + it "forwards bearer token to API" do + url = "#{ENV['API_URL']}/dois/#{doi}" + stub = stub_request(:get, url) + .with(headers: { "Authorization" => "Bearer test-api-key" }) + .to_return(status: 404, body: { errors: [{ title: "Not found" }] }.to_json, headers: { "Content-Type" => "application/json" }) + + response = subject.get_metadata(doi, bearer: "test-api-key") + expect(response.status).to eq(404) + expect(stub).to have_been_requested + end + end + context "versions" do subject { MetadataController } diff --git a/spec/requests/doi_spec.rb b/spec/requests/doi_spec.rb index 8169dea..7b37e54 100644 --- a/spec/requests/doi_spec.rb +++ b/spec/requests/doi_spec.rb @@ -3,6 +3,7 @@ describe "dois", type: :request, vcr: true do let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } let(:headers) { {"HTTP_AUTHORIZATION" => "Basic " + credentials } } + let(:bearer_headers) { { "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } describe "authentication", type: :request do let(:doi) { "10.14454/05MB-Q396" } @@ -22,6 +23,13 @@ #expect(last_response.status).to eq(401) expect(last_response.body).to eq("Bad credentials") end + + it "accepts bearer token auth" do + get "/doi/xxx", nil, bearer_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end end describe "/doi", type: :request do diff --git a/spec/requests/media_spec.rb b/spec/requests/media_spec.rb index 191c3f8..a16a473 100644 --- a/spec/requests/media_spec.rb +++ b/spec/requests/media_spec.rb @@ -2,6 +2,16 @@ describe "media", type: :request, vcr: true, order: :defined do let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } + let(:bearer_headers) { { "CONTENT_TYPE" => "text/plain;charset=UTF-8", "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } + + describe "authentication", type: :request do + it "accepts bearer token auth" do + get "/media/xxx", nil, bearer_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end + end describe "/media/10.0144/DUMMY.V0I1.45", type: :request do let(:doi_id) { "10.0144/DUMMY.V0I1.45" } diff --git a/spec/requests/metadata_spec.rb b/spec/requests/metadata_spec.rb index 23f4a9b..b2c3221 100644 --- a/spec/requests/metadata_spec.rb +++ b/spec/requests/metadata_spec.rb @@ -2,6 +2,16 @@ describe "metadata", type: :request, vcr: true, order: :defined do let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } + let(:bearer_headers) { { "ACCEPT" => "application/xml;charset=UTF-8", "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } + + describe "authentication", type: :request do + it "accepts bearer token auth" do + get "/metadata/xxx", nil, bearer_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end + end describe "/metadata/10.0144/DUMMY.V0I1.45", type: :request do let(:doi_id) { "10.0144/DUMMY.V0I1.45" } From baa667e7704aaa9dd2b71bfd8ead58be33613611 Mon Sep 17 00:00:00 2001 From: kaysiz Date: Mon, 6 Jul 2026 10:49:22 +0200 Subject: [PATCH 2/2] Accept API keys as Basic auth username on MDS Align with lupo API key authentication by allowing requests where the API key is sent as the Basic username with no password (e.g. curl --user DC.xxx:). Forward credentials to the DataCite API and omit client relationships when the username is an API key. --- app/controllers/application_controller.rb | 2 +- app/controllers/concerns/api_authenticatable.rb | 12 +++++++++++- spec/concerns/doiable_spec.rb | 16 ++++++++++++++++ spec/requests/doi_spec.rb | 9 +++++++++ spec/requests/media_spec.rb | 9 +++++++++ spec/requests/metadata_spec.rb | 9 +++++++++ 6 files changed, 55 insertions(+), 2 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index eb4dbad..46aba7d 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -17,7 +17,7 @@ def authenticate_request! return if @bearer.present? @username, @password = basic_credentials_from_request - return if @username.present? && @password.present? + return if @username.present? && (@password.present? || self.class.api_key_username?(@username)) request_http_basic_authentication(ENV["REALM"], "An Authentication object was not found in the SecurityContext") end diff --git a/app/controllers/concerns/api_authenticatable.rb b/app/controllers/concerns/api_authenticatable.rb index 6ff5ec9..215a34f 100644 --- a/app/controllers/concerns/api_authenticatable.rb +++ b/app/controllers/concerns/api_authenticatable.rb @@ -2,9 +2,18 @@ module ApiAuthenticatable extend ActiveSupport::Concern class_methods do + def api_key_username?(username) + username.present? && username.length > 20 && username.match?(/\ADC\./i) + end + def maremma_auth(options = {}) return { bearer: options[:bearer] } if options[:bearer].present? - return { username: options[:username], password: options[:password] } if options[:username].present? && options[:password].present? + + username = options[:username] + password = options[:password] + if username.present? && (password.present? || api_key_username?(username)) + return { username: username, password: password.to_s } + end nil end @@ -22,6 +31,7 @@ def with_maremma_auth(options = {}) def client_relationship(auth) return nil unless auth.key?(:username) + return nil if api_key_username?(auth[:username]) { "client" => { diff --git a/spec/concerns/doiable_spec.rb b/spec/concerns/doiable_spec.rb index 21269e6..40e7f36 100644 --- a/spec/concerns/doiable_spec.rb +++ b/spec/concerns/doiable_spec.rb @@ -9,6 +9,22 @@ subject { DoisController } + context "api key auth" do + let(:api_key) { "DC.frSFkVLbXUY5hygoWay5R51cWvEMODw3" } + + it "forwards api key as basic username to API" do + basic = ::Base64.strict_encode64("#{api_key}:") + url = "#{ENV['API_URL']}/dois/#{doi}/get-url" + stub = stub_request(:get, url) + .with(headers: { "Authorization" => "Basic #{basic}" }) + .to_return(status: 404, body: { errors: [{ title: "Not found" }] }.to_json, headers: { "Content-Type" => "application/json" }) + + response = subject.get_doi(doi, username: api_key, password: nil) + expect(response.status).to eq(404) + expect(stub).to have_been_requested + end + end + context "bearer auth" do it "forwards bearer token to API" do url = "#{ENV['API_URL']}/dois/#{doi}/get-url" diff --git a/spec/requests/doi_spec.rb b/spec/requests/doi_spec.rb index 7b37e54..1573e20 100644 --- a/spec/requests/doi_spec.rb +++ b/spec/requests/doi_spec.rb @@ -4,6 +4,8 @@ let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } let(:headers) { {"HTTP_AUTHORIZATION" => "Basic " + credentials } } let(:bearer_headers) { { "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } + let(:api_key) { "DC.frSFkVLbXUY5hygoWay5R51cWvEMODw3" } + let(:api_key_basic_headers) { { "HTTP_AUTHORIZATION" => "Basic " + ::Base64.strict_encode64("#{api_key}:") } } describe "authentication", type: :request do let(:doi) { "10.14454/05MB-Q396" } @@ -30,6 +32,13 @@ expect(last_response.status).to eq(404) expect(last_response.body).to eq("DOI not found") end + + it "accepts api key as basic username" do + get "/doi/xxx", nil, api_key_basic_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end end describe "/doi", type: :request do diff --git a/spec/requests/media_spec.rb b/spec/requests/media_spec.rb index a16a473..4ae8dc4 100644 --- a/spec/requests/media_spec.rb +++ b/spec/requests/media_spec.rb @@ -3,6 +3,8 @@ describe "media", type: :request, vcr: true, order: :defined do let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } let(:bearer_headers) { { "CONTENT_TYPE" => "text/plain;charset=UTF-8", "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } + let(:api_key) { "DC.frSFkVLbXUY5hygoWay5R51cWvEMODw3" } + let(:api_key_basic_headers) { { "CONTENT_TYPE" => "text/plain;charset=UTF-8", "HTTP_AUTHORIZATION" => "Basic " + ::Base64.strict_encode64("#{api_key}:") } } describe "authentication", type: :request do it "accepts bearer token auth" do @@ -11,6 +13,13 @@ expect(last_response.status).to eq(404) expect(last_response.body).to eq("DOI not found") end + + it "accepts api key as basic username" do + get "/media/xxx", nil, api_key_basic_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end end describe "/media/10.0144/DUMMY.V0I1.45", type: :request do diff --git a/spec/requests/metadata_spec.rb b/spec/requests/metadata_spec.rb index b2c3221..fdec47b 100644 --- a/spec/requests/metadata_spec.rb +++ b/spec/requests/metadata_spec.rb @@ -3,6 +3,8 @@ describe "metadata", type: :request, vcr: true, order: :defined do let(:credentials) { ::Base64.strict_encode64("#{ENV["MDS_USERNAME"]}:#{ENV["MDS_PASSWORD"]}") } let(:bearer_headers) { { "ACCEPT" => "application/xml;charset=UTF-8", "HTTP_AUTHORIZATION" => "Bearer test-api-key" } } + let(:api_key) { "DC.frSFkVLbXUY5hygoWay5R51cWvEMODw3" } + let(:api_key_basic_headers) { { "ACCEPT" => "application/xml;charset=UTF-8", "HTTP_AUTHORIZATION" => "Basic " + ::Base64.strict_encode64("#{api_key}:") } } describe "authentication", type: :request do it "accepts bearer token auth" do @@ -11,6 +13,13 @@ expect(last_response.status).to eq(404) expect(last_response.body).to eq("DOI not found") end + + it "accepts api key as basic username" do + get "/metadata/xxx", nil, api_key_basic_headers + + expect(last_response.status).to eq(404) + expect(last_response.body).to eq("DOI not found") + end end describe "/metadata/10.0144/DUMMY.V0I1.45", type: :request do