Skip to content

Commit c39fc1e

Browse files
committed
RUBY-3303 Add OIDC machine workflow auth
1 parent f1dde69 commit c39fc1e

20 files changed

+860
-3
lines changed

lib/mongo/auth.rb

+3-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
require 'mongo/auth/cr'
2828
require 'mongo/auth/gssapi'
2929
require 'mongo/auth/ldap'
30+
require 'mongo/auth/oidc'
3031
require 'mongo/auth/scram'
3132
require 'mongo/auth/scram256'
3233
require 'mongo/auth/x509'
@@ -70,6 +71,7 @@ module Auth
7071
aws: Aws,
7172
gssapi: Gssapi,
7273
mongodb_cr: CR,
74+
mongodb_oidc: Oidc,
7375
mongodb_x509: X509,
7476
plain: LDAP,
7577
scram: Scram,
@@ -89,7 +91,7 @@ module Auth
8991
# value of speculativeAuthenticate field of hello response of
9092
# the handshake on the specified connection.
9193
#
92-
# @return [ Auth::Aws | Auth::CR | Auth::Gssapi | Auth::LDAP |
94+
# @return [ Auth::Aws | Auth::CR | Auth::Gssapi | Auth::LDAP | Auth::Oidc
9395
# Auth::Scram | Auth::Scram256 | Auth::X509 ] The authenticator.
9496
#
9597
# @since 2.0.0

lib/mongo/auth/oidc.rb

+86
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
# Copyright (C) 2014-2024 MongoDB, Inc.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
module Mongo
19+
module Auth
20+
21+
# Defines behavior for OIDC authentication.
22+
#
23+
# @api private
24+
class Oidc < Base
25+
attr_reader :speculative_auth_result
26+
27+
# The authentication mechanism string.
28+
#
29+
# @since 2.20.0
30+
MECHANISM = 'MONGODB-OIDC'.freeze
31+
32+
# Initializes the OIDC authenticator.
33+
#
34+
# @param [ Auth::User ] user The user to authenticate.
35+
# @param [ Mongo::Connection ] connection The connection to authenticate over.
36+
#
37+
# @option opts [ BSON::Document | nil ] speculative_auth_result The
38+
# value of speculativeAuthenticate field of hello response of
39+
# the handshake on the specified connection.
40+
def initialize(user, connection, **opts)
41+
super
42+
@speculative_auth_result = opts[:speculative_auth_result]
43+
@machine_workflow = MachineWorkflow::new(user.auth_mech_properties)
44+
end
45+
46+
# Log the user in on the current connection.
47+
#
48+
# @return [ BSON::Document ] The document of the authentication response.
49+
def login
50+
execute_workflow(connection, conversation)
51+
end
52+
53+
private
54+
55+
def execute_workflow(connection, conversation)
56+
# If there is a cached access token, try to authenticate with it. If
57+
# authentication fails with an Authentication error (18),
58+
# invalidate the access token, fetch a new access token, and try
59+
# to authenticate again.
60+
# If the server fails for any other reason, do not clear the cache.
61+
if cache.access_token?
62+
token = cache.access_token
63+
msg = conversation.start(connection, token)
64+
begin
65+
dispatch_msg(connection, conversation, msg)
66+
rescue AuthError => error
67+
cache.invalidate(token)
68+
execute_workflow(connection, conversation)
69+
end
70+
end
71+
# This is the normal flow when no token is in the cache. Execute the
72+
# machine callback to get the token, put it in the caches, and then
73+
# send the saslStart to the server.
74+
token = machine_workflow.execute
75+
cache.access_token = token
76+
connection.access_token = token
77+
msg = conversation.start(connection, token)
78+
dispatch_msg(connection, conversation, msg)
79+
end
80+
end
81+
end
82+
end
83+
84+
require 'mongo/auth/oidc/conversation'
85+
require 'mongo/auth/oidc/machine_workflow'
86+
require 'mongo/auth/oidc/token_cache'

lib/mongo/auth/oidc/conversation.rb

+76
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
# Copyright (C) 2024 MongoDB Inc.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
module Mongo
19+
module Auth
20+
class Oidc
21+
# Defines behaviour around a single OIDC conversation between the
22+
# client and the server.
23+
#
24+
# @api private
25+
class Conversation < ConversationBase
26+
# The base client message.
27+
START_MESSAGE = { saslStart: 1, mechanism: Oidc::MECHANISM }.freeze
28+
29+
# Create the new conversation.
30+
#
31+
# @example Create the new conversation.
32+
# Conversation.new(user, 'test.example.com')
33+
#
34+
# @param [ Auth::User ] user The user to converse about.
35+
# @param [ Mongo::Connection ] connection The connection to
36+
# authenticate over.
37+
#
38+
# @since 2.20.0
39+
def initialize(user, connection, **opts)
40+
super
41+
end
42+
43+
# OIDC machine workflow is always a saslStart with the payload being
44+
# the serialized jwt token.
45+
#
46+
# @param [ String ] token The access token.
47+
#
48+
# @return [ Hash ] The start document.
49+
def client_start_document(token)
50+
START_MESSAGE.merge(payload: finish_payload(token))
51+
end
52+
53+
# Gets the serialized jwt payload for the token.
54+
#
55+
# @param [ String ] token The access token.
56+
#
57+
# @return [ BSON::Binary ] The serialized payload.
58+
def finish_payload(token)
59+
payload = { jwt: token }.to_bson.to_s
60+
BSON::Binary.new(payload)
61+
end
62+
63+
# Start the OIDC conversation. This returns the first message that
64+
# needs to be sent to the server.
65+
#
66+
# @param [ Server::Connection ] connection The connection being authenticated.
67+
#
68+
# @return [ Protocol::Message ] The first OIDC conversation message.
69+
def start(connection, token)
70+
selector = client_start_document(token)
71+
build_message(connection, '$external', selector)
72+
end
73+
end
74+
end
75+
end
76+
end
+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
# Copyright (C) 2024 MongoDB Inc.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
module Mongo
19+
module Auth
20+
class Oidc
21+
# The machine callback workflow is a 1 step execution of the callback
22+
# to get an OIDC token to connect with.
23+
class MachineWorkflow
24+
attr_reader :callback, :callback_lock, :last_executed
25+
26+
# The number of seconds to throttle the callback execution.
27+
THROTTLE_S = 0.1
28+
# The default timeout for callback execution.
29+
TIMEOUT_S = 60
30+
31+
def initialize(auth_mech_properties)
32+
@callback = CallbackFactory.get_callback(auth_mech_properties)
33+
@callback_lock = Mutex.new
34+
@last_executed = Time.now.to_i - THROTTLE_S
35+
end
36+
37+
# Execute the machine callback.
38+
def execute
39+
# Aquire lock before executing the callback and throttle calling it
40+
# to every 100ms.
41+
callback_lock.synchronize do
42+
difference = Time.now.to_i - last_executed
43+
if difference <= THROTTLE_S
44+
sleep(difference)
45+
end
46+
last_executed = Time.now.to_i
47+
callback({ timeout: TIMEOUT_S, version: 1})
48+
end
49+
end
50+
end
51+
end
52+
end
53+
end
54+
55+
require 'mongo/auth/oidc/machine_workflow/azure_callback'
56+
require 'mongo/auth/oidc/machine_workflow/gcp_callback'
57+
require 'mongo/auth/oidc/machine_workflow/test_callback'
58+
require 'mongo/auth/oidc/machine_workflow/callback_factory'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
# Copyright (C) 2024 MongoDB Inc.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
module Mongo
19+
module Auth
20+
class Oidc
21+
class MachineWorkflow
22+
class AzureCallback
23+
# The base Azure endpoint
24+
AZURE_BASE_URI = 'http://169.254.169.254/metadata/identity/oauth2/token'
25+
# The Azure headers.
26+
AZURE_HEADERS = { Metadata: 'true', Accept: 'application/json' }.freeze
27+
28+
attr_reader :token_resource, :username
29+
30+
def initialize(auth_mech_properties)
31+
@token_resource = auth_mech_properties[:TOKEN_RESOURCE]
32+
end
33+
34+
# Hits the Azure endpoint in order to get the token.
35+
def execute(params = {})
36+
query = { resource: token_resource, 'api-version' => '2018-02-01' }
37+
if username
38+
query[:client_id] = username
39+
end
40+
uri = URI(AZURE_BASE_URI);
41+
uri.query = ::URI.encode_www_form(query)
42+
request = Net::HTTP::Get.new(uri, AZURE_HEADERS)
43+
response = Timeout.timeout(params[:timeout]) do
44+
Net::HTTP.start(uri.hostname, uri.port, use_ssl: false) do |http|
45+
http.request(request)
46+
end
47+
end
48+
if response.code != '200'
49+
raise Error::OidcError,
50+
"Azure metadata host responded with code #{response.code}"
51+
end
52+
result = JSON.parse(response.body)
53+
{ access_token: result['access_token'], expires_in: result['expires_in'] }
54+
end
55+
end
56+
end
57+
end
58+
end
59+
end
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# frozen_string_literal: true
2+
# rubocop:todo all
3+
4+
# Copyright (C) 2024 MongoDB Inc.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
18+
module Mongo
19+
module Auth
20+
class Oidc
21+
class MachineWorkflow
22+
module CallbackFactory
23+
# Map of environment name to the workflow callbacks.
24+
CALLBACKS = {
25+
'azure' => AzureCallback,
26+
'gcp' => GcpCallback,
27+
'test' => TestCallback
28+
}
29+
30+
# Gets the callback based on the auth mechanism properties.
31+
module_function def get_callback(auth_mech_properties)
32+
if auth_mech_properties[:OIDC_CALLBACK]
33+
auth_mech_properties[:OIDC_CALLBACK]
34+
else
35+
callback = CALLBACKS[auth_mech_properties[:ENVIRONMENT]]
36+
if !callback
37+
raise Error::OidcError, "No OIDC machine callback found for ENVIRONMENT: #{auth_mech_properties[:ENVIRONMENT]}"
38+
end
39+
callback.new(auth_mech_properties)
40+
end
41+
end
42+
end
43+
end
44+
end
45+
end
46+
end
47+

0 commit comments

Comments
 (0)