From 4425b8ae67df490f2a1906aab1b13149d3a440a7 Mon Sep 17 00:00:00 2001 From: David Taylor Date: Fri, 9 Nov 2018 12:49:51 +0000 Subject: [PATCH] FEATURE: OpenID Connect support --- app/models/user_associated_account.rb | 3 + config/settings.yml | 13 ++ ...8115009_create_user_associated_accounts.rb | 18 +++ lib/omniauth_open_id_connect.rb | 127 ++++++++++++++++++ plugin.rb | 94 +++++++++++++ 5 files changed, 255 insertions(+) create mode 100644 app/models/user_associated_account.rb create mode 100644 config/settings.yml create mode 100644 db/migrate/20181108115009_create_user_associated_accounts.rb create mode 100644 lib/omniauth_open_id_connect.rb create mode 100644 plugin.rb diff --git a/app/models/user_associated_account.rb b/app/models/user_associated_account.rb new file mode 100644 index 0000000..6e2716e --- /dev/null +++ b/app/models/user_associated_account.rb @@ -0,0 +1,3 @@ +class UserAssociatedAccount < ActiveRecord::Base + belongs_to :user +end diff --git a/config/settings.yml b/config/settings.yml new file mode 100644 index 0000000..24d418c --- /dev/null +++ b/config/settings.yml @@ -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 \ No newline at end of file diff --git a/db/migrate/20181108115009_create_user_associated_accounts.rb b/db/migrate/20181108115009_create_user_associated_accounts.rb new file mode 100644 index 0000000..f9994f7 --- /dev/null +++ b/db/migrate/20181108115009_create_user_associated_accounts.rb @@ -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 diff --git a/lib/omniauth_open_id_connect.rb b/lib/omniauth_open_id_connect.rb new file mode 100644 index 0000000..6b6c7fd --- /dev/null +++ b/lib/omniauth_open_id_connect.rb @@ -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' diff --git a/plugin.rb b/plugin.rb new file mode 100644 index 0000000..7097a15 --- /dev/null +++ b/plugin.rb @@ -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