From 3a5f27e75155af37335ffb5c4045b11892acf7e7 Mon Sep 17 00:00:00 2001
From: John Livingston <git@john-livingston.fr>
Date: Wed, 17 Apr 2024 16:35:26 +0200
Subject: [PATCH] Possibility to configure an OpenID Connect provider on the
 instance level WIP (#128).

---
 .../README.md                                 |  5 +
 ...mod_http_peertubelivechat_manage_users.lua | 95 +++++++++++++++++++
 server/lib/external-auth/oidc.ts              |  1 +
 server/lib/prosody/api/manage-users.ts        | 66 +++++++++++++
 server/lib/prosody/config.ts                  | 17 ++++
 server/lib/prosody/config/content.ts          | 29 ++++++
 server/lib/routers/oidc.ts                    | 18 +++-
 7 files changed, 230 insertions(+), 1 deletion(-)
 create mode 100644 prosody-modules/mod_http_peertubelivechat_manage_users/README.md
 create mode 100644 prosody-modules/mod_http_peertubelivechat_manage_users/mod_http_peertubelivechat_manage_users.lua
 create mode 100644 server/lib/prosody/api/manage-users.ts

diff --git a/prosody-modules/mod_http_peertubelivechat_manage_users/README.md b/prosody-modules/mod_http_peertubelivechat_manage_users/README.md
new file mode 100644
index 00000000..da2c039c
--- /dev/null
+++ b/prosody-modules/mod_http_peertubelivechat_manage_users/README.md
@@ -0,0 +1,5 @@
+# mod_http_peertubelivechat_manage_users
+
+This module is a custom module that allows Peertube server to manage users for some virtualhosts.
+
+This module is part of peertube-plugin-livechat, and is under the same LICENSE.
diff --git a/prosody-modules/mod_http_peertubelivechat_manage_users/mod_http_peertubelivechat_manage_users.lua b/prosody-modules/mod_http_peertubelivechat_manage_users/mod_http_peertubelivechat_manage_users.lua
new file mode 100644
index 00000000..52cf7cbc
--- /dev/null
+++ b/prosody-modules/mod_http_peertubelivechat_manage_users/mod_http_peertubelivechat_manage_users.lua
@@ -0,0 +1,95 @@
+local json = require "util.json";
+local jid_split = require"util.jid".split;
+local usermanager = require "core.usermanager";
+
+module:depends"http";
+
+local module_host = module:get_host(); -- this module is not global
+
+function check_auth(routes)
+  local function check_request_auth(event)
+    local apikey = module:get_option_string("peertubelivechat_manage_users_apikey", "")
+    if apikey == "" then
+      return false, 500;
+    end
+    if event.request.headers.authorization ~= "Bearer " .. apikey then
+      return false, 401;
+    end
+    return true;
+  end
+
+  for route, handler in pairs(routes) do
+    routes[route] = function (event, ...)
+      local permit, code = check_request_auth(event);
+      if not permit then
+        return code;
+      end
+      return handler(event, ...);
+    end;
+  end
+  return routes;
+end
+
+
+local function ensure_user(event)
+  local request, response = event.request, event.response;
+  event.response.headers["Content-Type"] = "application/json";
+
+  local config = json.decode(request.body);
+  if not config.jid then
+    return json.encode({
+      result = "failed";
+    });
+  end
+
+  module:log("debug", "Calling ensure_user", config.jid);
+
+  local username, host = jid_split(config.jid);
+  if module_host ~= host then
+    module:log("error", "Wrong host", host);
+    return json.encode({
+      result = "failed";
+      message = "Wrong host"
+    });
+  end
+
+  -- TODO: handle avatars.
+
+  -- if user exists, just update.
+  if usermanager.user_exists(username, host) then
+    module:log("debug", "User already exists, updating", filename);
+    if not usermanager.set_password(username, config.password, host, nil) then
+      module:log("error", "Failed to update the password", host);
+      return json.encode({
+        result = "failed";
+        message = "Failed to update the password"
+      });
+    end
+    return json.encode({
+      result = "ok";
+      message = "User updated"
+    });
+  end
+
+  -- we must create the user.
+  module:log("debug", "User does not exists, creating", filename);
+  if (not usermanager.create_user(username, config.password, host)) then
+    module:log("error", "Failed to create the user", host);
+    return json.encode({
+      result = "failed";
+      message = "Failed to create the user"
+    });
+  end
+  return json.encode({
+    result = "ok";
+    message = "User created"
+  });
+end
+
+-- TODO: add a function to prune user that have not logged in since X days.
+
+module:provides("http", {
+  route = check_auth {
+    ["POST /" .. module_host .. "/ensure-user"] = ensure_user;
+  };
+});
diff --git a/server/lib/external-auth/oidc.ts b/server/lib/external-auth/oidc.ts
index d391a74d..b07dc4f8 100644
--- a/server/lib/external-auth/oidc.ts
+++ b/server/lib/external-auth/oidc.ts
@@ -457,6 +457,7 @@ class ExternalAuthOIDC {
 
   /**
    * Gets the singleton, or raise an exception if it is too soon.
+   * @throws Error
    * @returns the singleton
    */
   public static singleton (): ExternalAuthOIDC {
diff --git a/server/lib/prosody/api/manage-users.ts b/server/lib/prosody/api/manage-users.ts
new file mode 100644
index 00000000..b62d0daa
--- /dev/null
+++ b/server/lib/prosody/api/manage-users.ts
@@ -0,0 +1,66 @@
+import type { RegisterServerOptions } from '@peertube/peertube-types'
+import type { ExternalAccountInfos } from '../../external-auth/types'
+import { getCurrentProsody } from './host'
+import { getProsodyDomain } from '../config/domain'
+import { getAPIKey } from '../../apikey'
+const got = require('got')
+
+/**
+ * Created or updates a user.
+ * Can be used to manage external accounts for example (create the user the first time, update infos next time).
+ * Uses an API provided by mod_http_peertubelivechat_manage_users.
+ *
+ * @param options Peertube server options
+ * @param data up-to-date user infos.
+ * @returns true if success
+ */
+async function ensureUser (options: RegisterServerOptions, infos: ExternalAccountInfos): Promise<boolean> {
+  const logger = options.peertubeHelpers.logger
+
+  const currentProsody = getCurrentProsody()
+  if (!currentProsody) {
+    throw new Error('It seems that prosody is not binded... Cant call API.')
+  }
+
+  const prosodyDomain = await getProsodyDomain(options)
+
+  logger.info('Calling ensureUser for ' + infos.jid)
+
+  // Requesting on localhost, because currentProsody.host does not always resolves correctly (docker use case, ...)
+  const apiUrl = `http://localhost:${currentProsody.port}/` +
+    'peertubelivechat_manage_users/' +
+    `external.${prosodyDomain}/` + // the virtual host name
+    'ensure-user'
+  const apiData = {
+    jid: infos.jid,
+    nickname: infos.nickname,
+    password: infos.password
+  }
+  try {
+    logger.debug('Calling ensure-user API on url: ' + apiUrl)
+    const result = await got(apiUrl, {
+      method: 'POST',
+      headers: {
+        authorization: 'Bearer ' + await getAPIKey(options),
+        host: currentProsody.host
+      },
+      json: apiData,
+      responseType: 'json',
+      resolveBodyOnly: true
+    })
+
+    logger.debug('ensure-user API response: ' + JSON.stringify(result))
+    if (result.result !== 'ok') {
+      logger.error('ensure-user API has failed: ' + JSON.stringify(result))
+      return false
+    }
+  } catch (err) {
+    logger.error(`ensure-user failed: ' ${err as string}`)
+    return false
+  }
+  return true
+}
+
+export {
+  ensureUser
+}
diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts
index f26a108e..74fbb87b 100644
--- a/server/lib/prosody/config.ts
+++ b/server/lib/prosody/config.ts
@@ -13,6 +13,7 @@ import { parseExternalComponents } from './config/components'
 import { getRemoteServerInfosDir } from '../federation/storage'
 import { BotConfiguration } from '../configuration/bot'
 import { debugMucAdmins } from '../debug'
+import { ExternalAuthOIDC } from '../external-auth/oidc'
 
 async function getWorkingDir (options: RegisterServerOptions): Promise<string> {
   const peertubeHelpers = options.peertubeHelpers
@@ -194,6 +195,17 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
   const useBots = !settings['disable-channel-configuration']
   const bots: ProsodyConfig['bots'] = {}
 
+  let useExternal: boolean = false
+  try {
+    const oidc = ExternalAuthOIDC.singleton()
+    if (await oidc.isOk()) {
+      useExternal = true
+    }
+  } catch (err) {
+    logger.error(err)
+    useExternal = false
+  }
+
   // Note: for the bots to connect, we must allow multiplexing.
   // This will be done on the http (BOSH/Websocket) port, as it only listen on localhost.
   // TODO: to improve performance, try to avoid multiplexing, and find a better way for bots to connect.
@@ -243,6 +255,11 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
   if (!disableAnon) {
     config.useAnonymous(autoBanIP)
   }
+
+  if (useExternal) {
+    config.useExternal(apikey)
+  }
+
   config.useHttpAuthentication(authApiUrl)
   const useWS = !!options.registerWebSocketRoute // this comes with Peertube >=5.0.0, and is a prerequisite to websocket
   config.usePeertubeBoshAndWebsocket(prosodyDomain, port, publicServerUrl, useWS, useMultiplexing)
diff --git a/server/lib/prosody/config/content.ts b/server/lib/prosody/config/content.ts
index 4c847bab..2dd71342 100644
--- a/server/lib/prosody/config/content.ts
+++ b/server/lib/prosody/config/content.ts
@@ -140,6 +140,7 @@ class ProsodyConfigContent {
   global: ProsodyConfigGlobal
   authenticated?: ProsodyConfigVirtualHost
   anon?: ProsodyConfigVirtualHost
+  external?: ProsodyConfigVirtualHost
   muc: ProsodyConfigComponent
   bot?: ProsodyConfigVirtualHost
   externalComponents: ProsodyConfigComponent[] = []
@@ -222,6 +223,19 @@ class ProsodyConfigContent {
     }
   }
 
+  /**
+   * Activates the virtual host for external account authentication (OpenID Connect, ...)
+   */
+  useExternal (apikey: string): void {
+    this.external = new ProsodyConfigVirtualHost('external.' + this.prosodyDomain)
+    this.external.set('modules_enabled', [
+      'ping',
+      'http',
+      'http_peertubelivechat_manage_users'
+    ])
+    this.external.set('peertubelivechat_manage_users_apikey', apikey)
+  }
+
   useHttpAuthentication (url: string): void {
     this.authenticated = new ProsodyConfigVirtualHost(this.prosodyDomain)
 
@@ -304,6 +318,17 @@ class ProsodyConfigContent {
       this.authenticated.set('http_host', prosodyDomain)
       this.authenticated.set('http_external_url', 'http://' + prosodyDomain)
     }
+
+    if (this.external) {
+      this.external.set('allow_anonymous_s2s', false)
+      this.external.add('modules_enabled', 'http')
+      this.external.add('modules_enabled', 'bosh')
+      if (useWS) {
+        this.external.add('modules_enabled', 'websocket')
+      }
+      this.external.set('http_host', prosodyDomain)
+      this.external.set('http_external_url', 'http://' + prosodyDomain)
+    }
   }
 
   useC2S (c2sPort: string): void {
@@ -501,6 +526,10 @@ class ProsodyConfigContent {
       content += this.bot.write()
       content += '\n\n'
     }
+    if (this.external) {
+      content += this.external.write()
+      content += '\n\n'
+    }
     content += this.muc.write()
     content += '\n\n'
     for (const externalComponent of this.externalComponents) {
diff --git a/server/lib/routers/oidc.ts b/server/lib/routers/oidc.ts
index f5a8878e..e23d15f4 100644
--- a/server/lib/routers/oidc.ts
+++ b/server/lib/routers/oidc.ts
@@ -4,6 +4,7 @@ import type { OIDCAuthResult } from '../../../shared/lib/types'
 import { asyncMiddleware } from '../middlewares/async'
 import { ExternalAuthOIDC } from '../external-auth/oidc'
 import { ExternalAuthenticationError } from '../external-auth/error'
+import { ensureUser } from '../prosody/api/manage-users'
 
 /**
  * When using a popup for OIDC, writes the HTML/Javascript to close the popup
@@ -65,7 +66,22 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
         }
 
         const externalAccountInfos = await oidc.validateAuthenticationProcess(req)
-        logger.info(JSON.stringify(externalAccountInfos)) // FIXME (normalize data type, process, ...)
+        logger.debug(JSON.stringify(
+          Object.assign(
+            {},
+            externalAccountInfos,
+            {
+              password: '**removed**' // removing the password from logs!
+            }
+          )
+        ))
+
+        // Now we create or update the user:
+        if (!await ensureUser(options, externalAccountInfos)) {
+          throw new ExternalAuthenticationError(
+            'Failing to create your account, please try again later or report this issue'
+          )
+        }
 
         res.send(popupResultHTML({
           ok: true,