From 9e31135ecaf7d9a6aa32a7ea223a6aa9bdeb3ade Mon Sep 17 00:00:00 2001 From: Ted Johansson Date: Fri, 19 Apr 2024 18:47:30 +0800 Subject: [PATCH] FEATURE: Allow users to sign in using LinkedIn OpenID Connect (#26281) LinkedIn has grandfathered its old OAuth2 provider. This can only be used by existing apps. New apps have to use the new OIDC provider. This PR adds a linkedin_oidc provider to core. This will exist alongside the discourse-linkedin-auth plugin, which will be kept for those still using the deprecated provider. --- config/locales/client.en.yml | 4 ++ config/locales/server.en.yml | 5 ++ config/site_settings.yml | 10 +++ lib/auth.rb | 1 + lib/auth/linkedin_oidc_authenticator.rb | 67 +++++++++++++++++++ lib/discourse.rb | 4 ++ lib/svg_sprite.rb | 1 + .../linkedin_oidc_credentials_validator.rb | 23 +++++++ .../auth/linkedin_oidc_authenticator_spec.rb | 43 ++++++++++++ ...inkedin_oidc_credentials_validator_spec.rb | 49 ++++++++++++++ 10 files changed, 207 insertions(+) create mode 100644 lib/auth/linkedin_oidc_authenticator.rb create mode 100644 lib/validators/linkedin_oidc_credentials_validator.rb create mode 100644 spec/lib/auth/linkedin_oidc_authenticator_spec.rb create mode 100644 spec/lib/validators/linkedin_oidc_credentials_validator_spec.rb 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