From c39fc1e3a5ffcdf66056dccdfdb5672902a76217 Mon Sep 17 00:00:00 2001 From: Durran Jordan Date: Mon, 3 Jun 2024 17:11:37 -0400 Subject: [PATCH] RUBY-3303 Add OIDC machine workflow auth --- lib/mongo/auth.rb | 4 +- lib/mongo/auth/oidc.rb | 86 +++++++++++++++++++ lib/mongo/auth/oidc/conversation.rb | 76 ++++++++++++++++ lib/mongo/auth/oidc/machine_workflow.rb | 58 +++++++++++++ .../oidc/machine_workflow/azure_callback.rb | 59 +++++++++++++ .../oidc/machine_workflow/callback_factory.rb | 47 ++++++++++ .../oidc/machine_workflow/gcp_callback.rb | 60 +++++++++++++ .../oidc/machine_workflow/test_callback.rb | 43 ++++++++++ lib/mongo/auth/oidc/token_cache.rb | 52 +++++++++++ lib/mongo/error.rb | 1 + lib/mongo/error/oidc_error.rb | 23 +++++ spec/mongo/auth/invalid_mechanism_spec.rb | 2 +- spec/mongo/auth/oidc/conversation_spec.rb | 56 ++++++++++++ .../machine_workflow/azure_callback_spec.rb | 60 +++++++++++++ .../machine_workflow/callback_factory_spec.rb | 71 +++++++++++++++ .../machine_workflow/gcp_callback_spec.rb | 60 +++++++++++++ .../machine_workflow/test_callback_spec.rb | 43 ++++++++++ spec/mongo/auth/oidc/token_cache_spec.rb | 45 ++++++++++ spec/mongo/auth/user_spec.rb | 2 +- spec/mongo/auth_spec.rb | 15 ++++ 20 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 lib/mongo/auth/oidc.rb create mode 100644 lib/mongo/auth/oidc/conversation.rb create mode 100644 lib/mongo/auth/oidc/machine_workflow.rb create mode 100644 lib/mongo/auth/oidc/machine_workflow/azure_callback.rb create mode 100644 lib/mongo/auth/oidc/machine_workflow/callback_factory.rb create mode 100644 lib/mongo/auth/oidc/machine_workflow/gcp_callback.rb create mode 100644 lib/mongo/auth/oidc/machine_workflow/test_callback.rb create mode 100644 lib/mongo/auth/oidc/token_cache.rb create mode 100644 lib/mongo/error/oidc_error.rb create mode 100644 spec/mongo/auth/oidc/conversation_spec.rb create mode 100644 spec/mongo/auth/oidc/machine_workflow/azure_callback_spec.rb create mode 100644 spec/mongo/auth/oidc/machine_workflow/callback_factory_spec.rb create mode 100644 spec/mongo/auth/oidc/machine_workflow/gcp_callback_spec.rb create mode 100644 spec/mongo/auth/oidc/machine_workflow/test_callback_spec.rb create mode 100644 spec/mongo/auth/oidc/token_cache_spec.rb diff --git a/lib/mongo/auth.rb b/lib/mongo/auth.rb index 65d77cd89f..01aefcebe3 100644 --- a/lib/mongo/auth.rb +++ b/lib/mongo/auth.rb @@ -27,6 +27,7 @@ require 'mongo/auth/cr' require 'mongo/auth/gssapi' require 'mongo/auth/ldap' +require 'mongo/auth/oidc' require 'mongo/auth/scram' require 'mongo/auth/scram256' require 'mongo/auth/x509' @@ -70,6 +71,7 @@ module Auth aws: Aws, gssapi: Gssapi, mongodb_cr: CR, + mongodb_oidc: Oidc, mongodb_x509: X509, plain: LDAP, scram: Scram, @@ -89,7 +91,7 @@ module Auth # value of speculativeAuthenticate field of hello response of # the handshake on the specified connection. # - # @return [ Auth::Aws | Auth::CR | Auth::Gssapi | Auth::LDAP | + # @return [ Auth::Aws | Auth::CR | Auth::Gssapi | Auth::LDAP | Auth::Oidc # Auth::Scram | Auth::Scram256 | Auth::X509 ] The authenticator. # # @since 2.0.0 diff --git a/lib/mongo/auth/oidc.rb b/lib/mongo/auth/oidc.rb new file mode 100644 index 0000000000..81c581394e --- /dev/null +++ b/lib/mongo/auth/oidc.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2014-2024 MongoDB, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + + # Defines behavior for OIDC authentication. + # + # @api private + class Oidc < Base + attr_reader :speculative_auth_result + + # The authentication mechanism string. + # + # @since 2.20.0 + MECHANISM = 'MONGODB-OIDC'.freeze + + # Initializes the OIDC authenticator. + # + # @param [ Auth::User ] user The user to authenticate. + # @param [ Mongo::Connection ] connection The connection to authenticate over. + # + # @option opts [ BSON::Document | nil ] speculative_auth_result The + # value of speculativeAuthenticate field of hello response of + # the handshake on the specified connection. + def initialize(user, connection, **opts) + super + @speculative_auth_result = opts[:speculative_auth_result] + @machine_workflow = MachineWorkflow::new(user.auth_mech_properties) + end + + # Log the user in on the current connection. + # + # @return [ BSON::Document ] The document of the authentication response. + def login + execute_workflow(connection, conversation) + end + + private + + def execute_workflow(connection, conversation) + # If there is a cached access token, try to authenticate with it. If + # authentication fails with an Authentication error (18), + # invalidate the access token, fetch a new access token, and try + # to authenticate again. + # If the server fails for any other reason, do not clear the cache. + if cache.access_token? + token = cache.access_token + msg = conversation.start(connection, token) + begin + dispatch_msg(connection, conversation, msg) + rescue AuthError => error + cache.invalidate(token) + execute_workflow(connection, conversation) + end + end + # This is the normal flow when no token is in the cache. Execute the + # machine callback to get the token, put it in the caches, and then + # send the saslStart to the server. + token = machine_workflow.execute + cache.access_token = token + connection.access_token = token + msg = conversation.start(connection, token) + dispatch_msg(connection, conversation, msg) + end + end + end +end + +require 'mongo/auth/oidc/conversation' +require 'mongo/auth/oidc/machine_workflow' +require 'mongo/auth/oidc/token_cache' diff --git a/lib/mongo/auth/oidc/conversation.rb b/lib/mongo/auth/oidc/conversation.rb new file mode 100644 index 0000000000..c2e3eafc42 --- /dev/null +++ b/lib/mongo/auth/oidc/conversation.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + # Defines behaviour around a single OIDC conversation between the + # client and the server. + # + # @api private + class Conversation < ConversationBase + # The base client message. + START_MESSAGE = { saslStart: 1, mechanism: Oidc::MECHANISM }.freeze + + # Create the new conversation. + # + # @example Create the new conversation. + # Conversation.new(user, 'test.example.com') + # + # @param [ Auth::User ] user The user to converse about. + # @param [ Mongo::Connection ] connection The connection to + # authenticate over. + # + # @since 2.20.0 + def initialize(user, connection, **opts) + super + end + + # OIDC machine workflow is always a saslStart with the payload being + # the serialized jwt token. + # + # @param [ String ] token The access token. + # + # @return [ Hash ] The start document. + def client_start_document(token) + START_MESSAGE.merge(payload: finish_payload(token)) + end + + # Gets the serialized jwt payload for the token. + # + # @param [ String ] token The access token. + # + # @return [ BSON::Binary ] The serialized payload. + def finish_payload(token) + payload = { jwt: token }.to_bson.to_s + BSON::Binary.new(payload) + end + + # Start the OIDC conversation. This returns the first message that + # needs to be sent to the server. + # + # @param [ Server::Connection ] connection The connection being authenticated. + # + # @return [ Protocol::Message ] The first OIDC conversation message. + def start(connection, token) + selector = client_start_document(token) + build_message(connection, '$external', selector) + end + end + end + end +end diff --git a/lib/mongo/auth/oidc/machine_workflow.rb b/lib/mongo/auth/oidc/machine_workflow.rb new file mode 100644 index 0000000000..6f3d676c29 --- /dev/null +++ b/lib/mongo/auth/oidc/machine_workflow.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + # The machine callback workflow is a 1 step execution of the callback + # to get an OIDC token to connect with. + class MachineWorkflow + attr_reader :callback, :callback_lock, :last_executed + + # The number of seconds to throttle the callback execution. + THROTTLE_S = 0.1 + # The default timeout for callback execution. + TIMEOUT_S = 60 + + def initialize(auth_mech_properties) + @callback = CallbackFactory.get_callback(auth_mech_properties) + @callback_lock = Mutex.new + @last_executed = Time.now.to_i - THROTTLE_S + end + + # Execute the machine callback. + def execute + # Aquire lock before executing the callback and throttle calling it + # to every 100ms. + callback_lock.synchronize do + difference = Time.now.to_i - last_executed + if difference <= THROTTLE_S + sleep(difference) + end + last_executed = Time.now.to_i + callback({ timeout: TIMEOUT_S, version: 1}) + end + end + end + end + end +end + +require 'mongo/auth/oidc/machine_workflow/azure_callback' +require 'mongo/auth/oidc/machine_workflow/gcp_callback' +require 'mongo/auth/oidc/machine_workflow/test_callback' +require 'mongo/auth/oidc/machine_workflow/callback_factory' diff --git a/lib/mongo/auth/oidc/machine_workflow/azure_callback.rb b/lib/mongo/auth/oidc/machine_workflow/azure_callback.rb new file mode 100644 index 0000000000..c3cd5d8883 --- /dev/null +++ b/lib/mongo/auth/oidc/machine_workflow/azure_callback.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + class MachineWorkflow + class AzureCallback + # The base Azure endpoint + AZURE_BASE_URI = 'http://169.254.169.254/metadata/identity/oauth2/token' + # The Azure headers. + AZURE_HEADERS = { Metadata: 'true', Accept: 'application/json' }.freeze + + attr_reader :token_resource, :username + + def initialize(auth_mech_properties) + @token_resource = auth_mech_properties[:TOKEN_RESOURCE] + end + + # Hits the Azure endpoint in order to get the token. + def execute(params = {}) + query = { resource: token_resource, 'api-version' => '2018-02-01' } + if username + query[:client_id] = username + end + uri = URI(AZURE_BASE_URI); + uri.query = ::URI.encode_www_form(query) + request = Net::HTTP::Get.new(uri, AZURE_HEADERS) + response = Timeout.timeout(params[:timeout]) do + Net::HTTP.start(uri.hostname, uri.port, use_ssl: false) do |http| + http.request(request) + end + end + if response.code != '200' + raise Error::OidcError, + "Azure metadata host responded with code #{response.code}" + end + result = JSON.parse(response.body) + { access_token: result['access_token'], expires_in: result['expires_in'] } + end + end + end + end + end +end diff --git a/lib/mongo/auth/oidc/machine_workflow/callback_factory.rb b/lib/mongo/auth/oidc/machine_workflow/callback_factory.rb new file mode 100644 index 0000000000..e4c772da18 --- /dev/null +++ b/lib/mongo/auth/oidc/machine_workflow/callback_factory.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + class MachineWorkflow + module CallbackFactory + # Map of environment name to the workflow callbacks. + CALLBACKS = { + 'azure' => AzureCallback, + 'gcp' => GcpCallback, + 'test' => TestCallback + } + + # Gets the callback based on the auth mechanism properties. + module_function def get_callback(auth_mech_properties) + if auth_mech_properties[:OIDC_CALLBACK] + auth_mech_properties[:OIDC_CALLBACK] + else + callback = CALLBACKS[auth_mech_properties[:ENVIRONMENT]] + if !callback + raise Error::OidcError, "No OIDC machine callback found for ENVIRONMENT: #{auth_mech_properties[:ENVIRONMENT]}" + end + callback.new(auth_mech_properties) + end + end + end + end + end + end +end + diff --git a/lib/mongo/auth/oidc/machine_workflow/gcp_callback.rb b/lib/mongo/auth/oidc/machine_workflow/gcp_callback.rb new file mode 100644 index 0000000000..d2ba0123de --- /dev/null +++ b/lib/mongo/auth/oidc/machine_workflow/gcp_callback.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + class MachineWorkflow + class GcpCallback + # The base GCP endpoint + GCP_BASE_URI = 'http://metadata/computeMetadata/v1/instance/service-accounts/default/identity'.freeze + # The GCP headers. + GCP_HEADERS = { 'Metadata-Flavor': 'Google' }.freeze + + attr_reader :token_resource + + # Initialize the Gcp callback. + # + # @params [ Hash ] auth_mech_properties The auth mech properties. + def initialize(auth_mech_properties) + @token_resource = auth_mech_properties[:TOKEN_RESOURCE] + end + + # Hits the GCP endpoint in order to get the token. The token_resource will + # become the audience parameter in the URI. + # + # @returns [ Hash ] A hash with the access token. + def execute(params = {}) + uri = URI(GCP_BASE_URI); + uri.query = ::URI.encode_www_form({ audience: token_resource }) + request = Net::HTTP::Get.new(uri, GCP_HEADERS) + response = Timeout.timeout(params[:timeout]) do + Net::HTTP.start(uri.hostname, uri.port, use_ssl: false) do |http| + http.request(request) + end + end + if response.code != '200' + raise Error::OidcError, + "GCP metadata host responded with code #{response.code}" + end + { access_token: JSON.parse(response.body) } + end + end + end + end + end +end diff --git a/lib/mongo/auth/oidc/machine_workflow/test_callback.rb b/lib/mongo/auth/oidc/machine_workflow/test_callback.rb new file mode 100644 index 0000000000..517f281d1c --- /dev/null +++ b/lib/mongo/auth/oidc/machine_workflow/test_callback.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + class MachineWorkflow + class TestCallback + def initialize(auth_mech_properties) + end + + # Loads the token from the filesystem based on the OIDC_TOKEN_FILE + # environment variable. + # + # @params [ Hash ] params timeout The timeout before cancelling. + # + # @returns [ Hash ] The access token. + def execute(params = {}) + Timeout.timeout(params[:timeout]) do + location = ENV.fetch('OIDC_TOKEN_FILE') + token = File.read(location) + { access_token: token } + end + end + end + end + end + end +end diff --git a/lib/mongo/auth/oidc/token_cache.rb b/lib/mongo/auth/oidc/token_cache.rb new file mode 100644 index 0000000000..6769d1e023 --- /dev/null +++ b/lib/mongo/auth/oidc/token_cache.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + module Auth + class Oidc + # Represents a cache of the OIDC access token. + class TokenCache + attr_accessor :access_token + attr_reader :lock + + def initialize + @lock = Mutex.new + end + + # Is there an access token present in the cache? + # + # @returns [ Boolean ] True if present, false if not. + def access_token? + !!@access_token + end + + # Invalidate the token. Will only invalidate if the token + # matches the existing one and only one thread at a time + # may invalidate the token. + # + # @params [ String ] token The access token to invalidate. + def invalidate(token) + lock.synchronize do + if (access_token == token) + @access_token = nil + end + end + end + end + end + end +end \ No newline at end of file diff --git a/lib/mongo/error.rb b/lib/mongo/error.rb index 92d6d5f4b3..741774c91a 100644 --- a/lib/mongo/error.rb +++ b/lib/mongo/error.rb @@ -190,6 +190,7 @@ def write_concern_error_labels require 'mongo/error/no_service_connection_available' require 'mongo/error/no_server_available' require 'mongo/error/no_srv_records' +require 'mongo/error/oidc_error' require 'mongo/error/session_ended' require 'mongo/error/sessions_not_supported' require 'mongo/error/session_not_materialized' diff --git a/lib/mongo/error/oidc_error.rb b/lib/mongo/error/oidc_error.rb new file mode 100644 index 0000000000..d502456eec --- /dev/null +++ b/lib/mongo/error/oidc_error.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +# rubocop:todo all + +# Copyright (C) 2024 MongoDB Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +module Mongo + class Error + class OidcError < Error + end + end +end diff --git a/spec/mongo/auth/invalid_mechanism_spec.rb b/spec/mongo/auth/invalid_mechanism_spec.rb index ca82146b02..01706fdd3d 100644 --- a/spec/mongo/auth/invalid_mechanism_spec.rb +++ b/spec/mongo/auth/invalid_mechanism_spec.rb @@ -8,7 +8,7 @@ let(:exception) { described_class.new(:foo) } it 'includes all built in mechanisms' do - expect(exception.message).to eq(':foo is invalid, please use one of the following mechanisms: :aws, :gssapi, :mongodb_cr, :mongodb_x509, :plain, :scram, :scram256') + expect(exception.message).to eq(':foo is invalid, please use one of the following mechanisms: :aws, :gssapi, :mongodb_cr, :mongodb_oidc, :mongodb_x509, :plain, :scram, :scram256') end end end diff --git a/spec/mongo/auth/oidc/conversation_spec.rb b/spec/mongo/auth/oidc/conversation_spec.rb new file mode 100644 index 0000000000..080d7efd43 --- /dev/null +++ b/spec/mongo/auth/oidc/conversation_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::Conversation do + + let(:user) do + Mongo::Auth::User.new(user: 'test') + end + + let(:connection) do + double('connection') + end + + let(:conversation) do + described_class.new(user, connection) + end + + let(:features) do + double('features') + end + + describe '#start' do + + before do + expect(connection).to receive(:features).and_return(features) + expect(connection).to receive(:mongos?).and_return(false) + expect(features).to receive(:op_msg_enabled?).and_return(true) + end + + let(:token) do + 'token' + end + + let(:msg) do + conversation.start(connection, token) + end + + let(:selector) do + msg.instance_variable_get(:@main_document) + end + + it 'sets the sasl start flag' do + expect(selector[:saslStart]).to eq(1) + end + + it 'sets the mechanism' do + expect(selector[:mechanism]).to eq('MONGODB-OIDC') + end + + it 'sets the payload' do + expect(selector[:payload].data).to eq({ jwt: token }.to_bson.to_s) + end + end +end diff --git a/spec/mongo/auth/oidc/machine_workflow/azure_callback_spec.rb b/spec/mongo/auth/oidc/machine_workflow/azure_callback_spec.rb new file mode 100644 index 0000000000..9207449f64 --- /dev/null +++ b/spec/mongo/auth/oidc/machine_workflow/azure_callback_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::MachineWorkflow::AzureCallback do + + let(:properties) do + { TOKEN_RESOURCE: 'audience' } + end + + let(:callback) do + described_class.new(properties) + end + + describe '#execute' do + + context 'when the response is a 200' do + + let(:response) do + double('response') + end + + before do + expect(response).to receive(:code).and_return('200') + expect(response).to receive(:body).and_return('{ "access_token": "token", "expires_in": 500 }') + allow(Net::HTTP).to receive(:start).with('169.254.169.254', 80, use_ssl: false).and_return(response) + end + + let(:result) do + callback.execute({ timeout: 50 }) + end + + it 'returns the token' do + expect(result[:access_token]).to eq('token') + end + end + + context 'when the response is not a 200' do + + let(:response) do + double('response') + end + + before do + expect(response).to receive(:code).twice.and_return('500') + allow(Net::HTTP).to receive(:start).with('169.254.169.254', 80, use_ssl: false).and_return(response) + end + + let(:result) do + end + + it 'raises an error' do + expect { + callback.execute({ timeout: 50 }) + }.to raise_error(Mongo::Error::OidcError) + end + end + end +end diff --git a/spec/mongo/auth/oidc/machine_workflow/callback_factory_spec.rb b/spec/mongo/auth/oidc/machine_workflow/callback_factory_spec.rb new file mode 100644 index 0000000000..6f471afa8c --- /dev/null +++ b/spec/mongo/auth/oidc/machine_workflow/callback_factory_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::MachineWorkflow::CallbackFactory do + + describe '.get_callback' do + + context 'when an OIDC_CALLBACK auth mech property is provided' do + + class OidcCallback + def execute(params) + { access_token: 'test' } + end + end + + let(:callback) do + described_class.get_callback({ OIDC_CALLBACK: OidcCallback.new }) + end + + it 'returns the user provided callback' do + expect(callback).to be_a OidcCallback + end + end + + context 'when an ENVIRONMENT auth mech property is provided' do + + context 'when the value is azure' do + + let(:callback) do + described_class.get_callback({ ENVIRONMENT: 'azure', TOKEN_RESOURCE: 'resource' }) + end + + it 'returns the azure callback' do + expect(callback).to be_a Mongo::Auth::Oidc::MachineWorkflow::AzureCallback + end + end + + context 'when the valie is gcp' do + + let(:callback) do + described_class.get_callback({ ENVIRONMENT: 'gcp', TOKEN_RESOURCE: 'client_id' }) + end + + it 'returns the gcp callback' do + expect(callback).to be_a Mongo::Auth::Oidc::MachineWorkflow::GcpCallback + end + end + + context 'when the value is test' do + let(:callback) do + described_class.get_callback({ ENVIRONMENT: 'test' }) + end + + it 'returns the test callback' do + expect(callback).to be_a Mongo::Auth::Oidc::MachineWorkflow::TestCallback + end + end + + context 'when the value is unknown' do + + it 'raises an oidc error' do + expect { + described_class.get_callback({ ENVIRONMENT: 'nothing' }) + }.to raise_error(Mongo::Error::OidcError) + end + end + end + end +end diff --git a/spec/mongo/auth/oidc/machine_workflow/gcp_callback_spec.rb b/spec/mongo/auth/oidc/machine_workflow/gcp_callback_spec.rb new file mode 100644 index 0000000000..c6127b05d3 --- /dev/null +++ b/spec/mongo/auth/oidc/machine_workflow/gcp_callback_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::MachineWorkflow::GcpCallback do + + let(:properties) do + { TOKEN_RESOURCE: 'audience' } + end + + let(:callback) do + described_class.new(properties) + end + + describe '#execute' do + + context 'when the response is a 200' do + + let(:response) do + double('response') + end + + before do + expect(response).to receive(:code).and_return('200') + expect(response).to receive(:body).and_return('"token"') + allow(Net::HTTP).to receive(:start).with('metadata', 80, use_ssl: false).and_return(response) + end + + let(:result) do + callback.execute({ timeout: 50 }) + end + + it 'returns the token' do + expect(result[:access_token]).to eq('token') + end + end + + context 'when the response is not a 200' do + + let(:response) do + double('response') + end + + before do + expect(response).to receive(:code).twice.and_return('500') + allow(Net::HTTP).to receive(:start).with('metadata', 80, use_ssl: false).and_return(response) + end + + let(:result) do + end + + it 'raises an error' do + expect { + callback.execute({ timeout: 50 }) + }.to raise_error(Mongo::Error::OidcError) + end + end + end +end diff --git a/spec/mongo/auth/oidc/machine_workflow/test_callback_spec.rb b/spec/mongo/auth/oidc/machine_workflow/test_callback_spec.rb new file mode 100644 index 0000000000..c0e7f3badb --- /dev/null +++ b/spec/mongo/auth/oidc/machine_workflow/test_callback_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::MachineWorkflow::TestCallback do + + let(:callback) do + described_class.new({}) + end + + describe '#execute' do + + context 'when a token path is provided' do + + let(:path) do + '/path/to/file' + end + + before do + allow(ENV).to receive(:fetch).with('OIDC_TOKEN_FILE').and_return(path) + allow(File).to receive(:read).with(path).and_return('token') + end + + let(:result) do + callback.execute({ timeout: 50 }) + end + + it 'returns the token' do + expect(result[:access_token]).to eq('token') + end + end + + context 'when a token path is not provided' do + + it 'raises an error' do + expect { + callback.execute({ timeout: 50 }) + }.to raise_error(KeyError) + end + end + end +end diff --git a/spec/mongo/auth/oidc/token_cache_spec.rb b/spec/mongo/auth/oidc/token_cache_spec.rb new file mode 100644 index 0000000000..9b47f7271f --- /dev/null +++ b/spec/mongo/auth/oidc/token_cache_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true +# rubocop:todo all + +require 'spec_helper' + +describe Mongo::Auth::Oidc::TokenCache do + + let(:cache) do + described_class.new + end + + describe '#invalidate' do + let(:token_one) do + 'token_one' + end + + let(:token_two) do + 'token_two' + end + + context 'when the token matches the existing token' do + + before do + cache.access_token = token_one + cache.invalidate(token_one) + end + + it 'invalidates the token' do + expect(cache.access_token).to be_nil + end + end + + context 'when the token does not equal the existing token' do + + before do + cache.access_token = token_one + cache.invalidate(token_two) + end + + it 'does not invalidate the token' do + expect(cache.access_token).to eq(token_one) + end + end + end +end diff --git a/spec/mongo/auth/user_spec.rb b/spec/mongo/auth/user_spec.rb index 5ccc1d3584..cd0ee730ee 100644 --- a/spec/mongo/auth/user_spec.rb +++ b/spec/mongo/auth/user_spec.rb @@ -50,7 +50,7 @@ it 'raises ArgumentError' do expect do user - end.to raise_error(Mongo::Auth::InvalidMechanism, ":invalid is invalid, please use one of the following mechanisms: :aws, :gssapi, :mongodb_cr, :mongodb_x509, :plain, :scram, :scram256") + end.to raise_error(Mongo::Auth::InvalidMechanism, ":invalid is invalid, please use one of the following mechanisms: :aws, :gssapi, :mongodb_cr, :mongodb_oidc, :mongodb_x509, :plain, :scram, :scram256") end end diff --git a/spec/mongo/auth_spec.rb b/spec/mongo/auth_spec.rb index a770aacdd6..66efc926b6 100644 --- a/spec/mongo/auth_spec.rb +++ b/spec/mongo/auth_spec.rb @@ -7,6 +7,21 @@ describe '#get' do + context 'when a mongodb_oidc user is provided' do + + let(:user) do + Mongo::Auth::User.new(auth_mech: :mongodb_oidc, auth_mech_properties: { ENVIRONMENT: 'test' }) + end + + let(:oidc) do + described_class.get(user, double('connection')) + end + + it 'returns Oidc' do + expect(oidc).to be_a(Mongo::Auth::Oidc) + end + end + context 'when a mongodb_cr user is provided' do let(:user) do