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.
This commit is contained in:
Ted Johansson 2024-04-19 18:47:30 +08:00 committed by GitHub
parent 7180da1b1c
commit 9e31135eca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 207 additions and 0 deletions

View File

@ -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:

View File

@ -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 <a href="https://meta.discourse.org/t/configuring-discord-login-for-discourse/127129">here</a>. 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 <a href='https://www.linkedin.com/developers/apps' target='_blank'>https://www.linkedin.com/developers/apps</a>"
linkedin_oidc_client_secret: "Client secret for LinkedIn authentication, registered at <a href='https://www.linkedin.com/developers/apps' target='_blank'>https://www.linkedin.com/developers/apps</a>"
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."

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -99,6 +99,7 @@ module SvgSprite
fab-facebook
fab-github
fab-instagram
fab-linkedin-in
fab-linux
fab-threads
fab-threads-square

View File

@ -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

View File

@ -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

View File

@ -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