Skip to content

Commit

Permalink
RUBY-3303 Add OIDC machine workflow auth
Browse files Browse the repository at this point in the history
  • Loading branch information
durran committed Jun 3, 2024
1 parent f1dde69 commit c39fc1e
Show file tree
Hide file tree
Showing 20 changed files with 860 additions and 3 deletions.
4 changes: 3 additions & 1 deletion lib/mongo/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -70,6 +71,7 @@ module Auth
aws: Aws,
gssapi: Gssapi,
mongodb_cr: CR,
mongodb_oidc: Oidc,
mongodb_x509: X509,
plain: LDAP,
scram: Scram,
Expand All @@ -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
Expand Down
86 changes: 86 additions & 0 deletions lib/mongo/auth/oidc.rb
Original file line number Diff line number Diff line change
@@ -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'
76 changes: 76 additions & 0 deletions lib/mongo/auth/oidc/conversation.rb
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions lib/mongo/auth/oidc/machine_workflow.rb
Original file line number Diff line number Diff line change
@@ -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'
59 changes: 59 additions & 0 deletions lib/mongo/auth/oidc/machine_workflow/azure_callback.rb
Original file line number Diff line number Diff line change
@@ -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
47 changes: 47 additions & 0 deletions lib/mongo/auth/oidc/machine_workflow/callback_factory.rb
Original file line number Diff line number Diff line change
@@ -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

Loading

0 comments on commit c39fc1e

Please sign in to comment.