FEATURE: OpenID Connect support
This commit is contained in:
parent
f49bd357ef
commit
4425b8ae67
|
@ -0,0 +1,3 @@
|
|||
class UserAssociatedAccount < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
end
|
|
@ -0,0 +1,13 @@
|
|||
plugins:
|
||||
openid_connect_issuer:
|
||||
default: ""
|
||||
openid_connect_client_id:
|
||||
default: ""
|
||||
openid_connect_client_secret:
|
||||
default: ""
|
||||
openid_connect_authorize_scope:
|
||||
default: "openid"
|
||||
openid_connect_token_scope:
|
||||
default: ""
|
||||
openid_connect_use_userinfo:
|
||||
default: true
|
|
@ -0,0 +1,18 @@
|
|||
class CreateUserAssociatedAccounts < ActiveRecord::Migration[5.2]
|
||||
def change
|
||||
create_table :user_associated_accounts do |t|
|
||||
t.string :provider_name, null: false
|
||||
t.string :provider_uid, null: false
|
||||
t.integer :user_id, null: false
|
||||
t.datetime :last_used, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
||||
t.jsonb :info, null: false, default: {}
|
||||
t.jsonb :credentials, null: false, default: {}
|
||||
t.jsonb :extra, null: false, default: {}
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_associated_accounts, [:provider_name, :provider_uid], unique: true, name: 'associated_accounts_provider_uid'
|
||||
add_index :user_associated_accounts, [:provider_name, :user_id], unique: true, name: 'associated_accounts_provider_user'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,127 @@
|
|||
require 'omniauth-oauth2'
|
||||
|
||||
module ::OmniAuth
|
||||
module Strategies
|
||||
class OpenIDConnect < OmniAuth::Strategies::OAuth2
|
||||
option :scope, "openid"
|
||||
option :discovery, true
|
||||
option :cache, lambda { |key, &blk| blk.call } # Default no-op cache
|
||||
|
||||
option :client_options,
|
||||
site: 'https://op.com/',
|
||||
authorize_url: 'authorize',
|
||||
token_url: 'token',
|
||||
userinfo_endpoint: 'userinfo',
|
||||
auth_scheme: :basic_auth
|
||||
|
||||
def discover!
|
||||
discovery_document = options.cache.call("openid_discovery_#{options[:client_options][:discovery_document]}") do
|
||||
client.request(:get, options[:client_options][:discovery_document], parse: :json).parsed
|
||||
end
|
||||
options[:client_options][:authorize_url] = discovery_document["authorization_endpoint"].to_s
|
||||
options[:client_options][:token_url] = discovery_document["token_endpoint"].to_s
|
||||
options[:client_options][:userinfo_endpoint] = discovery_document["userinfo_endpoint"].to_s
|
||||
options[:client_options][:site] = discovery_document["issuer"].to_s
|
||||
end
|
||||
|
||||
def request_phase
|
||||
discover! if options[:discovery]
|
||||
super
|
||||
end
|
||||
|
||||
def authorize_params
|
||||
super.tap do |params|
|
||||
params[:scope] = options[:scope]
|
||||
session['omniauth.nonce'] = params[:nonce] = SecureRandom.hex(32)
|
||||
end
|
||||
end
|
||||
|
||||
uid { id_token_info['sub'] }
|
||||
|
||||
info do
|
||||
data_source = options.use_userinfo ? userinfo_response : id_token_info
|
||||
prune!(
|
||||
name: data_source['name'],
|
||||
email: data_source['email'],
|
||||
first_name: data_source['given_name'],
|
||||
last_name: data_source['family_name'],
|
||||
nickname: data_source['preferred_username'],
|
||||
picture: data_source['picture']
|
||||
)
|
||||
end
|
||||
|
||||
extra do
|
||||
hash = {}
|
||||
hash[:raw_info] = options.use_userinfo ? userinfo_response : id_token_info
|
||||
prune! hash
|
||||
end
|
||||
|
||||
def userinfo_response
|
||||
@raw_info ||= access_token.get(options[:client_options][:userinfo_endpoint]).parsed
|
||||
return fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected")) unless @raw_info['sub'] == id_token_info['sub']
|
||||
@raw_info
|
||||
end
|
||||
|
||||
def id_token_info
|
||||
# Verify the claims in the JWT
|
||||
# The signature does not need to be verified because the
|
||||
# token was acquired via a direct server-server connection to the issuer
|
||||
@id_token_info ||= JWT.decode(
|
||||
access_token['id_token'], nil, false,
|
||||
:verify_iss => true,
|
||||
'iss' => options[:client_options][:site],
|
||||
:verify_aud => true,
|
||||
'aud' => options.client_id,
|
||||
:verify_sub => false,
|
||||
:verify_expiration => true,
|
||||
:verify_not_before => true,
|
||||
:verify_iat => true,
|
||||
:verify_jti => false
|
||||
).first
|
||||
end
|
||||
|
||||
def callback_phase
|
||||
discover! if options[:discovery]
|
||||
oauth2_callback_phase = super
|
||||
if id_token_info["nonce"].empty? || id_token_info["nonce"] != session.delete("omniauth.nonce")
|
||||
return fail!(:csrf_detected, CallbackError.new(:csrf_detected, "CSRF detected"))
|
||||
end
|
||||
oauth2_callback_phase
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def callback_url
|
||||
# return "http://localhost:8080/auth/callback/"
|
||||
full_host + script_name + callback_path
|
||||
end
|
||||
|
||||
def get_token_options
|
||||
{ redirect_uri: callback_url,
|
||||
grant_type: 'authorization_code',
|
||||
code: request.params["code"],
|
||||
client_id: options[:client_id],
|
||||
client_secret: options[:client_secret]
|
||||
}.merge(token_params.to_hash(symbolize_keys: true))
|
||||
end
|
||||
|
||||
def prune!(hash)
|
||||
hash.delete_if do |_, v|
|
||||
prune!(v) if v.is_a?(Hash)
|
||||
v.nil? || (v.respond_to?(:empty?) && v.empty?)
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def build_access_token
|
||||
return super if options.use_userinfo
|
||||
response = client.request(:get, options[:client_options][:token_url], params: get_token_options)
|
||||
::OAuth2::AccessToken.from_hash(client, response.parsed)
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
OmniAuth.config.add_camelization 'openid_connect', 'OpenIDConnect'
|
|
@ -0,0 +1,94 @@
|
|||
# name: discourse-openid-connect
|
||||
# about: Add support for openid-connect as a login provider
|
||||
# version: 1.0
|
||||
# authors: David Taylor
|
||||
# url: https://github.com/discourse/discourse-openid-connect
|
||||
|
||||
require_relative "lib/omniauth_open_id_connect"
|
||||
require_relative 'app/models/user_associated_account'
|
||||
|
||||
class Auth::ManagedAuthenticator < Auth::Authenticator
|
||||
def match_by_email
|
||||
false
|
||||
end
|
||||
|
||||
def after_authenticate(auth_token)
|
||||
# puts "after authenticate ", auth_token.to_json
|
||||
|
||||
result = Auth::Result.new
|
||||
|
||||
result.authenticator_name = "OpenID Connect"
|
||||
|
||||
result.extra_data = {
|
||||
provider: auth_token[:provider],
|
||||
uid: auth_token[:uid],
|
||||
info: auth_token[:info],
|
||||
extra: auth_token[:extra],
|
||||
credentials: auth_token[:credentials]
|
||||
}
|
||||
|
||||
data = auth_token[:info]
|
||||
result.email = email = data[:email]
|
||||
result.name = name = "#{data[:first_name]} #{data[:last_name]}"
|
||||
result.username = data[:nickname]
|
||||
|
||||
association = UserAssociatedAccount.find_by(provider_name: auth_token[:provider], provider_uid: auth_token[:uid])
|
||||
|
||||
if match_by_email && association.nil? && user = User.find_by_email(email)
|
||||
association = UserAssociatedAccount.create!(user: user, provider_name: auth_token[:provider], provider_uid: auth_token[:uid], info: auth_token[:info], credentials: auth_token[:credentials], extra: auth_token[:extra])
|
||||
end
|
||||
|
||||
result.user = association&.user
|
||||
result.email_valid = true
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def after_create_account(user, auth)
|
||||
data = auth[:extra_data]
|
||||
association = UserAssociatedAccount.create!(
|
||||
user: user,
|
||||
provider_name: data[:provider],
|
||||
provider_uid: data[:uid],
|
||||
info: data[:info],
|
||||
credentials: data[:credentials],
|
||||
extra: data[:extra]
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
class OpenIDConnectAuthenticator < Auth::ManagedAuthenticator
|
||||
|
||||
def name
|
||||
'openid_connect'
|
||||
end
|
||||
|
||||
def enabled?
|
||||
true
|
||||
end
|
||||
|
||||
def register_middleware(omniauth)
|
||||
omniauth.provider :openid_connect,
|
||||
name: :openid_connect,
|
||||
cache: lambda { |key, &blk| Rails.cache.fetch(key, expires_in: 10.minutes, &blk) },
|
||||
setup: lambda { |env|
|
||||
opts = env['omniauth.strategy'].options
|
||||
opts.deep_merge!(
|
||||
use_userinfo: SiteSetting.openid_connect_use_userinfo,
|
||||
client_id: SiteSetting.openid_connect_client_id,
|
||||
client_secret: SiteSetting.openid_connect_client_secret,
|
||||
client_options: {
|
||||
discovery_document: SiteSetting.openid_connect_issuer,
|
||||
},
|
||||
scope: SiteSetting.openid_connect_authorize_scope,
|
||||
token_params: {
|
||||
scope: SiteSetting.openid_connect_token_scope,
|
||||
}
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
auth_provider title: 'with OpenID Connect',
|
||||
authenticator: OpenIDConnectAuthenticator.new(),
|
||||
full_screen_login: true
|
Loading…
Reference in New Issue