diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 0b13f398160..4f7270a3a91 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -2324,6 +2324,10 @@ en:
name: "Discord"
title: "Log in with Discord"
sr_title: "Log in with Discord"
+ linkedin_oidc:
+ name: "LinkedIn"
+ title: "Log in with LinkedIn"
+ sr_title: "Log in with LinkedIn"
passkey:
name: "Log in with a passkey"
second_factor_toggle:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index b13f6ef2ab3..c34067cbd4b 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -1931,6 +1931,10 @@ en:
discord_secret: "Discord Client Secret Key Used for authenticating and enabling Discord related features on the site, such as Discord logins. This secret key corresponds to the Discord application created for the website, and is necessary for securely communicating with the Discord API."
discord_trusted_guilds: 'Only allow members of these Discord guilds to log in via Discord. Use the numeric ID for the guild. For more information, check the instructions here. Leave blank to allow any guild.'
+ enable_linkedin_oidc_logins: "Enable LinkedIn authentication, requires linkedin_client_id and linkedin_client_secret."
+ linkedin_oidc_client_id: "Client ID for LinkedIn authentication, registered at https://www.linkedin.com/developers/apps"
+ linkedin_oidc_client_secret: "Client secret for LinkedIn authentication, registered at https://www.linkedin.com/developers/apps"
+
enable_backups: "Allow administrators to create backups of the forum"
allow_restore: "Allow restore, which can replace ALL site data! Leave disabled unless you plan to restore a backup"
maximum_backups: "The maximum amount of backups to keep. Older backups are automatically deleted"
@@ -2688,6 +2692,7 @@ en:
other: "The list must contain exactly %{count} values."
markdown_linkify_tlds: "You cannot include a value of '*'."
google_oauth2_hd_groups: "You must configure all 'google oauth2 hd' settings before enabling this setting."
+ linkedin_oidc_credentials: "You must configure LinkedIn OIDC credentials ('linkedin_oidc_client_id' and 'linkedin_oidc_client_secret') before enabling this setting."
search_tokenize_chinese_enabled: "You must disable 'search_tokenize_chinese' before enabling this setting."
search_tokenize_japanese_enabled: "You must disable 'search_tokenize_japanese' before enabling this setting."
discourse_connect_cannot_be_enabled_if_second_factor_enforced: "You cannot enable DiscourseConnect if 2FA is enforced."
diff --git a/config/site_settings.yml b/config/site_settings.yml
index bdee1371f14..b6ba974fb71 100644
--- a/config/site_settings.yml
+++ b/config/site_settings.yml
@@ -504,6 +504,16 @@ login:
default: ""
type: list
list_type: simple
+ enable_linkedin_oidc_logins:
+ default: false
+ validator: "LinkedinOidcCredentialsValidator"
+ linkedin_oidc_client_id:
+ default: ""
+ regex: "^[a-z0-9]+$"
+ linkedin_oidc_client_secret:
+ default: ""
+ regex: "^[a-zA-Z0-9]+$"
+ secret: true
auth_skip_create_confirm:
default: false
client: true
diff --git a/lib/auth.rb b/lib/auth.rb
index 5380c826d0f..fec087fd031 100644
--- a/lib/auth.rb
+++ b/lib/auth.rb
@@ -10,5 +10,6 @@ require "auth/managed_authenticator"
require "auth/facebook_authenticator"
require "auth/github_authenticator"
require "auth/twitter_authenticator"
+require "auth/linkedin_oidc_authenticator"
require "auth/google_oauth2_authenticator"
require "auth/discord_authenticator"
diff --git a/lib/auth/linkedin_oidc_authenticator.rb b/lib/auth/linkedin_oidc_authenticator.rb
new file mode 100644
index 00000000000..c77006183fb
--- /dev/null
+++ b/lib/auth/linkedin_oidc_authenticator.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+class Auth::LinkedInOidcAuthenticator < Auth::ManagedAuthenticator
+ class LinkedInOidc < OmniAuth::Strategies::OAuth2
+ option :name, "linkedin_oidc"
+
+ option :client_options,
+ {
+ site: "https://api.linkedin.com",
+ authorize_url: "https://www.linkedin.com/oauth/v2/authorization?response_type=code",
+ token_url: "https://www.linkedin.com/oauth/v2/accessToken",
+ }
+
+ option :scope, "openid profile email"
+
+ uid { raw_info["sub"] }
+
+ info do
+ {
+ email: raw_info["email"],
+ first_name: raw_info["given_name"],
+ last_name: raw_info["family_name"],
+ image: raw_info["picture"],
+ }
+ end
+
+ extra { { "raw_info" => raw_info } }
+
+ def callback_url
+ full_host + script_name + callback_path
+ end
+
+ def raw_info
+ @raw_info ||= access_token.get(profile_endpoint).parsed
+ end
+
+ private
+
+ def profile_endpoint
+ "/v2/userinfo"
+ end
+ end
+
+ def name
+ "linkedin_oidc"
+ end
+
+ def enabled?
+ SiteSetting.enable_linkedin_oidc_logins
+ end
+
+ def register_middleware(omniauth)
+ omniauth.provider LinkedInOidc,
+ setup:
+ lambda { |env|
+ strategy = env["omniauth.strategy"]
+ strategy.options[:client_id] = SiteSetting.linkedin_oidc_client_id
+ strategy.options[:client_secret] = SiteSetting.linkedin_oidc_client_secret
+ }
+ end
+
+ # LinkedIn doesn't let users login to websites unless they verify their e-mail
+ # address, so whatever e-mail we get from LinkedIn must be verified.
+ def primary_email_verified?(_auth_token)
+ true
+ end
+end
diff --git a/lib/discourse.rb b/lib/discourse.rb
index 1af04378c0d..db8aaa63c34 100644
--- a/lib/discourse.rb
+++ b/lib/discourse.rb
@@ -491,6 +491,10 @@ module Discourse
Auth::AuthProvider.new(authenticator: Auth::GithubAuthenticator.new, icon: "fab-github"),
Auth::AuthProvider.new(authenticator: Auth::TwitterAuthenticator.new, icon: "fab-twitter"),
Auth::AuthProvider.new(authenticator: Auth::DiscordAuthenticator.new, icon: "fab-discord"),
+ Auth::AuthProvider.new(
+ authenticator: Auth::LinkedInOidcAuthenticator.new,
+ icon: "fab-linkedin-in",
+ ),
]
def self.auth_providers
diff --git a/lib/svg_sprite.rb b/lib/svg_sprite.rb
index f534c4d8754..ff2269cc8c5 100644
--- a/lib/svg_sprite.rb
+++ b/lib/svg_sprite.rb
@@ -99,6 +99,7 @@ module SvgSprite
fab-facebook
fab-github
fab-instagram
+ fab-linkedin-in
fab-linux
fab-threads
fab-threads-square
diff --git a/lib/validators/linkedin_oidc_credentials_validator.rb b/lib/validators/linkedin_oidc_credentials_validator.rb
new file mode 100644
index 00000000000..fab7faf68b3
--- /dev/null
+++ b/lib/validators/linkedin_oidc_credentials_validator.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class LinkedinOidcCredentialsValidator
+ def initialize(opts = {})
+ @opts = opts
+ end
+
+ def valid_value?(val)
+ return true if val == "f"
+ return false if credentials_missing?
+ true
+ end
+
+ def error_message
+ I18n.t("site_settings.errors.linkedin_oidc_credentials") if credentials_missing?
+ end
+
+ private
+
+ def credentials_missing?
+ SiteSetting.linkedin_oidc_client_id.blank? || SiteSetting.linkedin_oidc_client_secret.blank?
+ end
+end
diff --git a/spec/lib/auth/linkedin_oidc_authenticator_spec.rb b/spec/lib/auth/linkedin_oidc_authenticator_spec.rb
new file mode 100644
index 00000000000..55272421f66
--- /dev/null
+++ b/spec/lib/auth/linkedin_oidc_authenticator_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+RSpec.describe Auth::LinkedInOidcAuthenticator do
+ let(:hash) do
+ OmniAuth::AuthHash.new(
+ provider: "linkedin_oidc",
+ extra: {
+ raw_info: {
+ email: "100",
+ email_verified: true,
+ given_name: "Coding",
+ family_name: "Horror",
+ picture:
+ "https://media.licdn.com/dms/image/C5603AQH7UYSA0m_DNw/profile-displayphoto-shrink_100_100/0/1516350954443?e=1718841600&v=beta&t=1DdwKTzW2QdVuPtnk1C20oaYSkqeEa4ffuI6_NlXbB",
+ locale: {
+ country: "US",
+ language: "en",
+ },
+ },
+ },
+ info: {
+ email: "coding@horror.com",
+ first_name: "Coding",
+ last_name: "Horror",
+ image:
+ "https://media.licdn.com/dms/image/C5603AQH7UYSA0m_DNw/profile-displayphoto-shrink_100_100/0/1516350954443?e=1718841600&v=beta&t=1DdwKTzW2QdVuPtnk1C20oaYSkqeEa4ffuI6_NlXbB",
+ },
+ uid: "100",
+ )
+ end
+
+ let(:authenticator) { described_class.new }
+
+ describe "after_authenticate" do
+ it "works normally" do
+ result = authenticator.after_authenticate(hash)
+ expect(result.user).to eq(nil)
+ expect(result.failed).to eq(false)
+ expect(result.name).to eq("Coding Horror")
+ expect(result.email).to eq("coding@horror.com")
+ end
+ end
+end
diff --git a/spec/lib/validators/linkedin_oidc_credentials_validator_spec.rb b/spec/lib/validators/linkedin_oidc_credentials_validator_spec.rb
new file mode 100644
index 00000000000..e3419dbe181
--- /dev/null
+++ b/spec/lib/validators/linkedin_oidc_credentials_validator_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe LinkedinOidcCredentialsValidator do
+ subject(:validator) { described_class.new }
+
+ describe "#valid_value?" do
+ describe "when OIDC authentication credentials are configured" do
+ before do
+ SiteSetting.linkedin_oidc_client_id = "foo"
+ SiteSetting.linkedin_oidc_client_secret = "bar"
+ end
+
+ describe "when val is false" do
+ it "should be valid" do
+ expect(validator.valid_value?("f")).to eq(true)
+ end
+ end
+
+ describe "when value is true" do
+ it "should be valid" do
+ expect(validator.valid_value?("t")).to eq(true)
+ end
+ end
+ end
+
+ describe "when OIDC authentication credentials are not configured" do
+ before do
+ SiteSetting.linkedin_oidc_client_id = ""
+ SiteSetting.linkedin_oidc_client_secret = ""
+ end
+
+ describe "when value is false" do
+ it "should be valid" do
+ expect(validator.valid_value?("f")).to eq(true)
+ end
+ end
+
+ describe "when value is true" do
+ it "should not be valid" do
+ expect(validator.valid_value?("t")).to eq(false)
+
+ expect(validator.error_message).to eq(
+ I18n.t("site_settings.errors.linkedin_oidc_credentials"),
+ )
+ end
+ end
+ end
+ end
+end