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:
parent
7180da1b1c
commit
9e31135eca
|
@ -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:
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -99,6 +99,7 @@ module SvgSprite
|
|||
fab-facebook
|
||||
fab-github
|
||||
fab-instagram
|
||||
fab-linkedin-in
|
||||
fab-linux
|
||||
fab-threads
|
||||
fab-threads-square
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
Loading…
Reference in New Issue