0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-08 17:53:55 +08:00
discourse/plugins/discourse-openid-connect/spec/lib/openid_connect_authenticator_spec.rb
Chris Alberti 30d8364f0a
DEV: Support mTLS for OIDC auth (#41524)
Adds support for mTLS for OIDC auth in the discourse-openid-connect
plugin.

If the SiteSettings `openid_connect_mtls_client_cert` and
`openid_connect_mtls_client_key` are set, then tell OmniAuth to use
auth_scheme `tls_client_auth` and pass the cert and key to the
connection ssl options. OmniAuth will skip sending the client_secret in
favor of the cert.

Optionally, SiteSetting `openid_connect_mtls_client_key_passcode` can be
provided to decrypt the private key.

Related to dev topic t/185887
2026-07-17 15:59:55 -05:00

324 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
require_relative "../../lib/omniauth_open_id_connect"
describe OpenIDConnectAuthenticator do
let(:authenticator) { described_class.new }
fab!(:user)
let(:hash) do
OmniAuth::AuthHash.new(
provider: "oidc",
uid: "123456789",
info: {
name: "John Doe",
email: user.email,
},
extra: {
raw_info: {
email: user.email,
name: "John Doe",
},
},
)
end
context "when email_verified is not supplied" do
# Some IDPs do not supply this information
# In this case we trust that they have verified the address
it "matches the user" do
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(user)
end
end
context "when email_verified is true" do
it "matches the user" do
hash[:extra][:raw_info][:email_verified] = true
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(user)
end
it "matches the user as a true string" do
hash[:extra][:raw_info][:email_verified] = "true"
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(user)
end
it "matches the user as a titlecase true string" do
hash[:extra][:raw_info][:email_verified] = "True"
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(user)
end
end
context "when email_verified is false" do
it "does not match the user" do
hash[:extra][:raw_info][:email_verified] = false
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(nil)
end
it "does not match the user as a false string" do
hash[:extra][:raw_info][:email_verified] = "false"
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(nil)
end
end
context "when match_by_email is false" do
it "does not match the user" do
SiteSetting.openid_connect_match_by_email = false
result = authenticator.after_authenticate(hash)
expect(result.user).to eq(nil)
end
end
describe "group syncing" do
context "when openid_connect_groups_claim is blank" do
it "does not provide groups" do
expect(authenticator.provides_groups?).to eq(false)
end
it "does not set associated_groups" do
hash[:extra][:raw_info][:groups] = %w[group1 group2]
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to be_nil
end
end
context "when openid_connect_groups_claim is set" do
before { SiteSetting.openid_connect_groups_claim = "groups" }
it "provides groups" do
expect(authenticator.provides_groups?).to eq(true)
end
it "extracts groups from the claim" do
hash[:extra][:raw_info][:groups] = %w[group1 group2]
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq(
[{ id: "group1", name: "group1" }, { id: "group2", name: "group2" }],
)
end
it "handles an empty groups array" do
hash[:extra][:raw_info][:groups] = []
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq([])
end
it "treats a missing claim as an empty groups list" do
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq([])
end
it "logs an error and clears groups when the claim is not an array" do
hash[:extra][:raw_info][:groups] = "not_an_array"
Rails.logger.expects(:error).with(includes("not an array"))
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq([])
end
it "falls back to the id_token when the claim is missing from raw_info" do
hash[:extra][:id_token_info] = { "groups" => %w[group1 group2] }
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq(
[{ id: "group1", name: "group1" }, { id: "group2", name: "group2" }],
)
end
it "prefers raw_info over the id_token when both contain the claim" do
hash[:extra][:raw_info][:groups] = %w[from_userinfo]
hash[:extra][:id_token_info] = { "groups" => %w[from_id_token] }
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq([{ id: "from_userinfo", name: "from_userinfo" }])
end
end
context "with a custom claim name" do
before { SiteSetting.openid_connect_groups_claim = "cognito:groups" }
it "reads from the correct claim" do
hash[:extra][:raw_info]["cognito:groups"] = %w[admins editors]
result = authenticator.after_authenticate(hash)
expect(result.associated_groups).to eq(
[{ id: "admins", name: "admins" }, { id: "editors", name: "editors" }],
)
end
end
end
describe "user field syncing" do
fab!(:user_field)
it "leaves user_field_values empty when no mappings are configured" do
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq({})
end
context "with a mapping configured" do
before do
SiteSetting.openid_connect_user_field_mappings = [
{ "claim" => "department", "user_field_id" => user_field.id },
].to_json
end
it "pulls the value from raw_info" do
hash[:extra][:raw_info][:department] = "Engineering"
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq(user_field.id.to_s => "Engineering")
end
it "falls back to id_token_info when the claim is missing from raw_info" do
hash[:extra][:id_token_info] = { "department" => "Engineering" }
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq(user_field.id.to_s => "Engineering")
end
it "joins array values with commas" do
hash[:extra][:raw_info][:department] = %w[Eng Ops]
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq(user_field.id.to_s => "Eng,Ops")
end
it "skips mappings whose claim is missing entirely" do
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq({})
end
it "clears the field when raw_info has the claim set to an empty string" do
hash[:extra][:raw_info][:department] = ""
hash[:extra][:id_token_info] = { "department" => "Engineering" }
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq(user_field.id.to_s => "")
end
it "clears the field when raw_info has the claim set to null" do
hash[:extra][:raw_info][:department] = nil
hash[:extra][:id_token_info] = { "department" => "Engineering" }
result = authenticator.after_authenticate(hash)
expect(result.user_field_values).to eq(user_field.id.to_s => "")
end
end
end
describe "mTLS support" do
let!(:mtls_key) { OpenSSL::PKey::RSA.new(2048) }
let!(:mtls_cert) do
cert = OpenSSL::X509::Certificate.new
cert.subject = OpenSSL::X509::Name.parse("/CN=test")
cert.issuer = cert.subject
cert.not_before = Time.now
cert.not_after = Time.now + 365 * 86_400
cert.public_key = mtls_key.public_key
cert.sign(mtls_key, OpenSSL::Digest.new("SHA256"))
cert
end
it "returns empty hash when no mTLS settings are configured" do
SiteSetting.openid_connect_mtls_client_cert = ""
SiteSetting.openid_connect_mtls_client_key = ""
expect(authenticator.mtls_ssl_options).to eq({})
end
it "parses valid PEM certificate and key" do
SiteSetting.openid_connect_mtls_client_cert = mtls_cert.to_pem
SiteSetting.openid_connect_mtls_client_key = mtls_key.to_pem
result = authenticator.mtls_ssl_options
expect(result[:client_cert]).to be_a(OpenSSL::X509::Certificate)
expect(result[:client_key]).to be_a(OpenSSL::PKey::RSA)
end
it "raises OpenSSL error for invalid cert PEM" do
SiteSetting.openid_connect_mtls_client_cert = "not-a-cert"
SiteSetting.openid_connect_mtls_client_key = mtls_key.to_pem
Rails.logger.expects(:error).with(includes("Failed to parse mTLS"))
expect { authenticator.mtls_ssl_options }.to raise_error(OpenSSL::OpenSSLError)
end
it "raises OpenSSL error for invalid key PEM" do
SiteSetting.openid_connect_mtls_client_cert = mtls_cert.to_pem
SiteSetting.openid_connect_mtls_client_key = "not-a-key"
Rails.logger.expects(:error).with(includes("Failed to parse mTLS"))
expect { authenticator.mtls_ssl_options }.to raise_error(OpenSSL::OpenSSLError)
end
it "decrypts a key with a passcode when the setting is provided" do
encrypted_key = mtls_key.export(OpenSSL::Cipher.new("aes-256-cbc"), "some_passphrase")
SiteSetting.openid_connect_mtls_client_cert = mtls_cert.to_pem
SiteSetting.openid_connect_mtls_client_key = encrypted_key
SiteSetting.openid_connect_mtls_client_key_passcode = "some_passphrase"
result = authenticator.mtls_ssl_options
expect(result[:client_key]).to be_a(OpenSSL::PKey::RSA)
end
it "raises OpenSSL error when the passphrase is wrong" do
encrypted_key = mtls_key.export(OpenSSL::Cipher.new("aes-256-cbc"), "some_passphrase")
SiteSetting.openid_connect_mtls_client_key = encrypted_key
SiteSetting.openid_connect_mtls_client_key_passcode = "wrong_passphrase"
SiteSetting.openid_connect_mtls_client_cert = mtls_cert.to_pem
Rails.logger.expects(:error).with(includes("Failed to parse mTLS"))
expect { authenticator.mtls_ssl_options }.to raise_error(OpenSSL::OpenSSLError)
end
end
describe "discovery document fetching" do
let(:document_url) do
SiteSetting.openid_connect_discovery_document =
"https://id.example.com/.well-known/openid-configuration"
end
let(:document) do
{
issuer: "https://id.example.com/",
authorization_endpoint: "https://id.example.com/authorize",
token_endpoint: "https://id.example.com/token",
userinfo_endpoint: "https://id.example.com/userinfo",
}.to_json
end
after { Discourse.cache.delete("openid-connect-discovery-#{document_url}") }
it "loads the document correctly" do
stub_request(:get, document_url).to_return(body: document)
expect(authenticator.discovery_document.keys).to contain_exactly(
"issuer",
"authorization_endpoint",
"token_endpoint",
"userinfo_endpoint",
)
end
it "handles a non-200 response" do
stub_request(:get, document_url).to_return(status: 404)
expect(authenticator.discovery_document).to eq(nil)
end
it "handles a network error" do
stub_request(:get, document_url).to_timeout
expect(authenticator.discovery_document).to eq(nil)
end
it "handles invalid json" do
stub_request(:get, document_url).to_return(body: "this is not the json you're looking for")
expect(authenticator.discovery_document).to eq(nil)
end
it "caches a success response" do
stub = stub_request(:get, document_url).to_return(body: document)
expect(authenticator.discovery_document).not_to eq(nil)
expect(authenticator.discovery_document).not_to eq(nil)
expect(stub).to have_been_requested.once
end
it "caches a failed response" do
stub = stub_request(:get, document_url).to_return(status: 404)
expect(authenticator.discovery_document).to eq(nil)
expect(authenticator.discovery_document).to eq(nil)
expect(stub).to have_been_requested.once
end
end
end