From 024186ba2cce59442c792ca8344d22feb5648bee Mon Sep 17 00:00:00 2001 From: John Livingston Date: Mon, 22 Apr 2024 13:03:31 +0200 Subject: [PATCH] Adding some standard OpenID Connect providers (Google, Facebook) (WIP): * refactoring, to allow several OIDC singletons * settings for google and facebook * backend code --- CHANGELOG.md | 1 + client/admin-plugin-client-plugin.ts | 7 +- languages/de.yml | 6 +- languages/en.yml | 15 +- languages/hr.yml | 4 +- server/lib/conversejs/params.ts | 8 +- ...h-custom-oidc.ts => external-auth-oidc.ts} | 17 +- server/lib/diagnostic/index.ts | 8 +- server/lib/diagnostic/utils.ts | 6 +- server/lib/external-auth/oidc.ts | 261 +++++++++++++----- server/lib/prosody/config.ts | 10 +- server/lib/routers/api/auth.ts | 4 +- server/lib/routers/oidc.ts | 16 +- server/lib/settings.ts | 114 +++++--- server/main.ts | 2 +- .../en/documentation/admin/external_auth.md | 4 +- 16 files changed, 341 insertions(+), 142 deletions(-) rename server/lib/diagnostic/{external-auth-custom-oidc.ts => external-auth-oidc.ts} (74%) diff --git a/CHANGELOG.md b/CHANGELOG.md index eafdf245..d9066ae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ TODO: https://github.com/JohnXLivingston/peertube-plugin-livechat/issues/48 * For anonymous users: new "log in using an external account" dialog, with following options: * remote Peertube account, * #128, #363 (**Experimental Feature**): possibility to configure an OpenID Connect provider on the instance level. + * #128: adding some standard OpenID Connect providers (Google, Facebook). * #143: User colors: implementing [XEP-0392](https://xmpp.org/extensions/xep-0392.html) to have random colors on users nicknames * #330: Chat does no more use an iframe to display the chat besides the videos. * #330: Fullscreen chat: now uses a custom page (in other words: when opening the chat in a new tab, you will have the Peertube menu). diff --git a/client/admin-plugin-client-plugin.ts b/client/admin-plugin-client-plugin.ts index 9ae4667b..faf8cd0f 100644 --- a/client/admin-plugin-client-plugin.ts +++ b/client/admin-plugin-client-plugin.ts @@ -236,8 +236,11 @@ function register (clientOptions: RegisterClientOptions): void { return options.formValues['chat-no-anonymous'] !== false } - if (name?.startsWith('external-auth-custom-oidc-')) { - return options.formValues['external-auth-custom-oidc'] !== true + if (name?.startsWith('external-auth-')) { + const m = name.match(/^external-auth-(\w+)-oidc-/) + if (m) { + return options.formValues['external-auth-' + m[1] + '-oidc'] !== true + } } return false diff --git a/languages/de.yml b/languages/de.yml index ea4821a7..843a0e8f 100644 --- a/languages/de.yml +++ b/languages/de.yml @@ -401,8 +401,8 @@ login_using_external_account: Mit einem externem Account anmelden external_auth_custom_oidc_label: Verwenden eines OpenID Connect Anbieters external_auth_custom_oidc_button_label_label: Name für die Anmeldungsschaltfläche external_auth_custom_oidc_discovery_url_label: Discovery URL -external_auth_custom_oidc_client_id_label: Client ID -external_auth_custom_oidc_client_secret_label: Client secret +external_auth_oidc_client_id_label: Client ID +external_auth_oidc_client_secret_label: Client secret external_auth_custom_oidc_title:

OpenID Connect

external_auth_custom_oidc_button_label_description: Diese Bezeichnung wird den Nutzern als Name der Schaltfläche zur Authentifizierung bei diesem OIDC-Anbieter angezeigt. @@ -414,6 +414,6 @@ external_auth_description: "

Externe Authentifizierung

\nFür Benutzer, d kein Peertubekonto haben, können Sie verschiedene Authentifizierungsmodi auf der Grundlage von externen Authentifizierungsanbietern aktivieren.\n" login_external_auth_alert_message: Authentifizierung fehlgeschlagen -external_auth_custom_oidc_redirect_uris_info_description: "Callback/Redirect +external_auth_oidc_redirect_uris_info_description: "Callback/Redirect URI:\nWenn Sie eine autorisierte Umleitungs-URI für die externe Anwendung konfigurieren möchten, fügen Sie bitte diese URL hinzu:\n" diff --git a/languages/en.yml b/languages/en.yml index fb6a5c3c..aa9582ff 100644 --- a/languages/en.yml +++ b/languages/en.yml @@ -83,12 +83,21 @@ external_auth_custom_oidc_button_label_label: "Label for the connection button" external_auth_custom_oidc_button_label_description: "This label will be displayed to users, as the button label to authenticate with this OIDC provider." external_auth_custom_oidc_discovery_url_label: "Discovery URL" -external_auth_custom_oidc_client_id_label: "Client ID" -external_auth_custom_oidc_client_secret_label: "Client secret" -external_auth_custom_oidc_redirect_uris_info_description: | +external_auth_oidc_client_id_label: "Client ID" +external_auth_oidc_client_secret_label: "Client secret" +external_auth_oidc_redirect_uris_info_description: | Callback/Redirect URI: If you want to configure authorized redirection URI on the external Application, please add this url: +external_auth_google_oidc_label: 'Use Google' +external_auth_google_oidc_description: | + Enabling this adds a "login with Google" button. + You have to configure a Google OAuth application. +external_auth_facebook_oidc_label: 'Use Facebook' +external_auth_facebook_oidc_description: | + Enabling this adds a "login with Facebook" button. + You have to configure a Facebook OAuth application. + chat_behaviour_description: "

Chat behaviour

" room_type_label: "Room type" diff --git a/languages/hr.yml b/languages/hr.yml index 26c5ddeb..a9b930ab 100644 --- a/languages/hr.yml +++ b/languages/hr.yml @@ -2,8 +2,8 @@ login_remote_peertube_video_not_found_try_anyway_button: Svejedno pokušaj otvor video na Peertube instanci external_auth_custom_oidc_button_label_label: Oznaka za gumb povezivanja external_auth_custom_oidc_discovery_url_label: Discovery URL -external_auth_custom_oidc_client_id_label: ID klijenta -external_auth_custom_oidc_client_secret_label: Tajna klijenta +external_auth_oidc_client_id_label: ID klijenta +external_auth_oidc_client_secret_label: Tajna klijenta videos_list_label: Aktiviraj chat za ova videa prosody_peertube_uri_label: Peertube URL za API pozive save: Spremi diff --git a/server/lib/conversejs/params.ts b/server/lib/conversejs/params.ts index f25335a2..b97d0b38 100644 --- a/server/lib/conversejs/params.ts +++ b/server/lib/conversejs/params.ts @@ -85,10 +85,10 @@ async function getConverseJSParams ( ) } else { try { - const oidc = ExternalAuthOIDC.singleton() - if (await oidc.isOk()) { - const authUrl = oidc.getConnectUrl() - const buttonLabel = oidc.getButtonLabel() + const customOidc = ExternalAuthOIDC.singleton('custom') + if (await customOidc.isOk()) { + const authUrl = customOidc.getConnectUrl() + const buttonLabel = customOidc.getButtonLabel() if (authUrl && buttonLabel) { externalAuthOIDC = { buttonLabel: buttonLabel, diff --git a/server/lib/diagnostic/external-auth-custom-oidc.ts b/server/lib/diagnostic/external-auth-oidc.ts similarity index 74% rename from server/lib/diagnostic/external-auth-custom-oidc.ts rename to server/lib/diagnostic/external-auth-oidc.ts index e901c50a..da3616cc 100644 --- a/server/lib/diagnostic/external-auth-custom-oidc.ts +++ b/server/lib/diagnostic/external-auth-oidc.ts @@ -1,14 +1,19 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' import { newResult, TestResult } from './utils' -import { ExternalAuthOIDC } from '../external-auth/oidc' +import { ExternalAuthOIDC, ExternalAuthOIDCType } from '../external-auth/oidc' -export async function diagExternalAuthCustomOIDC (test: string, _options: RegisterServerOptions): Promise { +export async function diagExternalAuthOIDC ( + test: string, + _options: RegisterServerOptions, + singletonType: ExternalAuthOIDCType, + next: TestResult['next'] +): Promise { const result = newResult(test) - result.label = 'Test External Auth Custom OIDC' - result.next = 'everything-ok' + result.label = 'Test External Auth OIDC: ' + singletonType + result.next = next try { - const oidc = ExternalAuthOIDC.singleton() + const oidc = ExternalAuthOIDC.singleton(singletonType) if (oidc.isDisabledBySettings()) { result.ok = true @@ -40,7 +45,7 @@ export async function diagExternalAuthCustomOIDC (test: string, _options: Regist return result } - const oidc = ExternalAuthOIDC.singleton() + const oidc = ExternalAuthOIDC.singleton(singletonType) const oidcClient = await oidc.load() if (oidcClient) { result.messages.push('Discovery URL loaded: ' + JSON.stringify(oidcClient.issuer.metadata)) diff --git a/server/lib/diagnostic/index.ts b/server/lib/diagnostic/index.ts index 497bca58..f3ce0436 100644 --- a/server/lib/diagnostic/index.ts +++ b/server/lib/diagnostic/index.ts @@ -4,7 +4,7 @@ import { TestResult, newResult } from './utils' import { diagDebug } from './debug' import { diagProsody } from './prosody' import { diagVideo } from './video' -import { diagExternalAuthCustomOIDC } from './external-auth-custom-oidc' +import { diagExternalAuthOIDC } from './external-auth-oidc' import { helpUrl } from '../../../shared/lib/help' export async function diag (test: string, options: RegisterServerOptions): Promise { @@ -19,7 +19,11 @@ export async function diag (test: string, options: RegisterServerOptions): Promi } else if (test === 'prosody') { result = await diagProsody(test, options) } else if (test === 'external-auth-custom-oidc') { - result = await diagExternalAuthCustomOIDC(test, options) + result = await diagExternalAuthOIDC(test, options, 'custom', 'external-auth-google-oidc') + } else if (test === 'external-auth-google-oidc') { + result = await diagExternalAuthOIDC(test, options, 'google', 'external-auth-facebook-oidc') + } else if (test === 'external-auth-facebook-oidc') { + result = await diagExternalAuthOIDC(test, options, 'facebook', 'everything-ok') } else if (test === 'everything-ok') { result = newResult(test) result.label = 'Everything seems fine' diff --git a/server/lib/diagnostic/utils.ts b/server/lib/diagnostic/utils.ts index 5bb4211e..ce8f1a70 100644 --- a/server/lib/diagnostic/utils.ts +++ b/server/lib/diagnostic/utils.ts @@ -1,4 +1,6 @@ -type nextValue = 'backend' | 'debug' | 'webchat-video' | 'prosody' | 'external-auth-custom-oidc' | 'everything-ok' +type NextValue = 'backend' | 'debug' | 'webchat-video' | 'prosody' +| 'external-auth-custom-oidc' | 'external-auth-google-oidc' | 'external-auth-facebook-oidc' +| 'everything-ok' interface MessageWithLevel { level: 'info' | 'warning' | 'error' @@ -15,7 +17,7 @@ export interface TestResult { title: string message: string }> - next: nextValue | null + next: NextValue | null ok: boolean test: string } diff --git a/server/lib/external-auth/oidc.ts b/server/lib/external-auth/oidc.ts index 46dd25b0..a9fd04e4 100644 --- a/server/lib/external-auth/oidc.ts +++ b/server/lib/external-auth/oidc.ts @@ -49,13 +49,14 @@ function getMimeTypeFromArrayBuffer (arrayBuffer: ArrayBuffer): AcceptableAvatar type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' | 'picture' interface UnserializedToken { + type: ExternalAuthOIDCType jid: string password: string nickname: string expire: Date } -let singleton: ExternalAuthOIDC | undefined +let singletons: Map | undefined async function getRandomBytes (size: number): Promise { return new Promise((resolve, reject) => { @@ -67,10 +68,13 @@ async function getRandomBytes (size: number): Promise { }) } +type ExternalAuthOIDCType = 'custom' | 'google' | 'facebook' + /** * This class handles the external OpenId Connect provider, if defined. */ class ExternalAuthOIDC { + private readonly singletonType: ExternalAuthOIDCType private readonly enabled: boolean private readonly buttonLabel: string | undefined private readonly discoveryUrl: string | undefined @@ -82,7 +86,6 @@ class ExternalAuthOIDC { private readonly externalVirtualhost: string private readonly avatarsDir: string private readonly avatarsFiles: string[] - private pruneTimer?: NodeJS.Timer private readonly encryptionOptions = { algorithm: 'aes256' as string, @@ -113,6 +116,7 @@ class ExternalAuthOIDC { constructor (params: { logger: RegisterServerOptions['peertubeHelpers']['logger'] + singletonType: ExternalAuthOIDCType enabled: boolean buttonLabel: string | undefined discoveryUrl: string | undefined @@ -132,6 +136,7 @@ class ExternalAuthOIDC { error: (s) => params.logger.error('[ExternalAuthOIDC] ' + s) } + this.singletonType = params.singletonType this.enabled = !!params.enabled this.secretKey = params.secretKey this.redirectUrl = params.redirectUrl @@ -148,6 +153,13 @@ class ExternalAuthOIDC { } } + /** + * This singleton type. + */ + public get type (): ExternalAuthOIDCType { + return this.singletonType + } + /** * Indicates that the OIDC is disabled. * Caution: this does not indicate if it is enabled, but poorly configured. @@ -390,13 +402,15 @@ class ExternalAuthOIDC { // Now we will encrypt jid + password, and return it to the browser. // The browser will be able to use this encrypted data with the api/configuration/room API. const tokenContent: UnserializedToken = { + type: this.type, jid, password, nickname, // expires in 12 hours (user will just have to do the whole process again). expire: (new Date(Date.now() + 12 * 3600 * 1000)) } - const token = await this.encrypt(JSON.stringify(tokenContent)) + // Token is prefixed by the type, so we can get the correct singleton for deserializing. + const token = this.type + '-' + await this.encrypt(JSON.stringify(tokenContent)) let avatar = await this.readUserInfoPicture(userInfo) if (!avatar) { @@ -447,12 +461,24 @@ class ExternalAuthOIDC { */ public async unserializeToken (token: string): Promise { try { + // First, check the prefix: + if (!token.startsWith(this.type + '-')) { + throw new Error('Wrong token prefix') + } + // Removing the prefix: + token = token.substring(this.type.length + 1) + const decrypted = await this.decrypt(token) const o = JSON.parse(decrypted) // can fail if (typeof o !== 'object') { throw new Error('Invalid encrypted data') } + + if (o.type !== this.type) { + throw new Error('Token type is not the expected one') + } + if (typeof o.jid !== 'string' || o.jid === '') { throw new Error('No jid') } @@ -473,6 +499,7 @@ class ExternalAuthOIDC { } return { + type: o.type, jid: o.jid, password: o.password, nickname: o.nickname, @@ -623,107 +650,146 @@ class ExternalAuthOIDC { } /** - * Starts an interval timer to prune external users from Prosody. - * @param options Peertube server options. + * frees the singletons */ - public startPruneTimer (options: RegisterServerOptions): void { - this.stopPruneTimer() // just in case... + public static async destroySingletons (): Promise { + if (!singletons) { return } - // every hour (every minutes in debug mode) - const pruneInterval = debugNumericParameter(options, 'externalAccountPruneInterval', 60 * 1000, 60 * 60 * 1000) - this.logger.info(`Creating a timer for external account pruning, every ${Math.round(pruneInterval / 1000)}s.`) + stopPruneTimer() - // eslint-disable-next-line @typescript-eslint/no-misused-promises - this.pruneTimer = setInterval(async () => { - try { - if (!await this.isOk()) { return } + const keys = singletons.keys() + for (const key of keys) { + const singleton = singletons.get(key) + if (!singleton) { continue } + singletons.delete(key) + } - this.logger.info('Pruning external users...') - await pruneUsers(options) - } catch (err) { - this.logger.error('Error while pruning external users: ' + (err as string)) - } - }, pruneInterval) + singletons = undefined } /** - * Stops the prune timer. + * Instanciate all singletons. + * Note: no need to destroy singletons before creating new ones. */ - public stopPruneTimer (): void { - if (!this.pruneTimer) { return } - clearInterval(this.pruneTimer) - this.pruneTimer = undefined - } + public static async initSingletons (options: RegisterServerOptions): Promise { + const prosodyDomain = await getProsodyDomain(options) + // FIXME: this is not optimal to call here. + const prosodyFilePaths = await getProsodyFilePaths(options) - /** - * frees the singleton - */ - public static async destroySingleton (): Promise { - if (!singleton) { return } - singleton.stopPruneTimer() - singleton = undefined - } - - /** - * Instanciate the singleton. - * Note: no need to destroy the singleton before creating a new one. - */ - public static async initSingleton (options: RegisterServerOptions): Promise { const settings = await options.settingsManager.getSettings([ 'external-auth-custom-oidc', 'external-auth-custom-oidc-button-label', 'external-auth-custom-oidc-discovery-url', 'external-auth-custom-oidc-client-id', - 'external-auth-custom-oidc-client-secret' + 'external-auth-custom-oidc-client-secret', + 'external-auth-google-oidc', + 'external-auth-google-oidc-client-id', + 'external-auth-google-oidc-client-secret', + 'external-auth-facebook-oidc', + 'external-auth-facebook-oidc-client-id', + 'external-auth-facebook-oidc-client-secret' ]) - // Generating a secret key that will be used for the authenticatio process (can change on restart). - const secretKey = (await getRandomBytes(16)).toString('hex') + const init = async function initSingleton ( + singletonType: ExternalAuthOIDCType, + buttonLabel: string | undefined, + discoveryUrl: string | undefined + ): Promise { + // Generating a secret key that will be used for the authenticatio process (can change on restart). + const secretKey = (await getRandomBytes(16)).toString('hex') - const prosodyDomain = await getProsodyDomain(options) + const singleton = new ExternalAuthOIDC({ + logger: options.peertubeHelpers.logger, + singletonType, + enabled: settings['external-auth-' + singletonType + '-oidc'] as boolean, + buttonLabel, + discoveryUrl, + clientId: settings['external-auth-' + singletonType + '-oidc-client-id'] as string | undefined, + clientSecret: settings['external-auth-' + singletonType + '-oidc-client-secret'] as string | undefined, + secretKey, + connectUrl: ExternalAuthOIDC.connectUrl(options, singletonType), + redirectUrl: ExternalAuthOIDC.redirectUrl(options, singletonType), + externalVirtualhost: 'external.' + prosodyDomain, + avatarsDir: prosodyFilePaths.avatars, + avatarsFiles: prosodyFilePaths.avatarsFiles + }) - // FIXME: this is not optimal to call here. - const prosodyFilePaths = await getProsodyFilePaths(options) + singletons ??= new Map() + singletons.set(singletonType, singleton) + } - singleton = new ExternalAuthOIDC({ - logger: options.peertubeHelpers.logger, - enabled: settings['external-auth-custom-oidc'] as boolean, - buttonLabel: settings['external-auth-custom-oidc-button-label'] as string | undefined, - discoveryUrl: settings['external-auth-custom-oidc-discovery-url'] as string | undefined, - clientId: settings['external-auth-custom-oidc-client-id'] as string | undefined, - clientSecret: settings['external-auth-custom-oidc-client-secret'] as string | undefined, - secretKey, - connectUrl: ExternalAuthOIDC.connectUrl(options), - redirectUrl: ExternalAuthOIDC.redirectUrl(options), - externalVirtualhost: 'external.' + prosodyDomain, - avatarsDir: prosodyFilePaths.avatars, - avatarsFiles: prosodyFilePaths.avatarsFiles - }) + await Promise.all([ + init( + 'custom', + settings['external-auth-custom-oidc-button-label'] as string | undefined, + settings['external-auth-custom-oidc-discovery-url'] as string | undefined + ), + init( + 'google', + 'Google', + 'https://accounts.google.com' + ), + init( + 'facebook', + 'Facebook', + 'https://www.facebook.com' + ) + ]) - singleton.startPruneTimer(options) - - return singleton + startPruneTimer(options) } /** * Gets the singleton, or raise an exception if it is too soon. + * @param ExternalAuthOIDCType The singleton type. * @throws Error * @returns the singleton */ - public static singleton (): ExternalAuthOIDC { + public static singleton (singletonType: ExternalAuthOIDCType | string): ExternalAuthOIDC { + if (!singletons) { + throw new Error('ExternalAuthOIDC singletons are not initialized yet') + } + const singleton = singletons.get(singletonType as ExternalAuthOIDCType) if (!singleton) { - throw new Error('ExternalAuthOIDC singleton is not initialized yet') + throw new Error(`ExternalAuthOIDC singleton "${singletonType}" is not initiliazed yet`) } return singleton } + /** + * Get all initialiazed singletons. + */ + public static allSingletons (): ExternalAuthOIDC[] { + if (!singletons) { return [] } + return Array.from(singletons.values()) + } + + /** + * Reading header X-Peertube-Plugin-Livechat-External-Auth-OIDC-Token, + * got the singleton that is supposed to read the token. + * Note: the token must be unserialized before supposing it is valid! + * @param token the authentication token + */ + public static singletonForToken (token: string): ExternalAuthOIDC | null { + try { + const m = token.match(/^(\w+)-/) + if (!m) { return null } + return ExternalAuthOIDC.singleton(m[1]) + } catch (err) { + return null + } + } + /** * Get the uri to start the authentication process. * @param options Peertube server options * @returns the uri */ - public static connectUrl (options: RegisterServerOptions): string { - const path = getBaseRouterRoute(options) + 'oidc/connect' + public static connectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string { + if (!/^\w+$/.test(type)) { + throw new Error('Invalid singleton type') + } + const path = getBaseRouterRoute(options) + 'oidc/' + type + '/connect' return canonicalizePluginUri(options, path, { removePluginVersion: true }) @@ -734,14 +800,67 @@ class ExternalAuthOIDC { * @param options Peertube server optiosn * @returns the uri */ - public static redirectUrl (options: RegisterServerOptions): string { - const path = getBaseRouterRoute(options) + 'oidc/cb' + public static redirectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string { + if (!/^\w+$/.test(type)) { + throw new Error('Invalid singleton type') + } + const path = getBaseRouterRoute(options) + 'oidc/' + type + '/cb' return canonicalizePluginUri(options, path, { removePluginVersion: true }) } } -export { - ExternalAuthOIDC +let pruneTimer: NodeJS.Timer | undefined + +/** + * Starts an interval timer to prune external users from Prosody. + * @param options Peertube server options. + */ +function startPruneTimer (options: RegisterServerOptions): void { + stopPruneTimer() // just in case... + + const logger = { + debug: (s: string) => options.peertubeHelpers.logger.debug('[ExternalAuthOIDC startPruneTimer] ' + s), + info: (s: string) => options.peertubeHelpers.logger.info('[ExternalAuthOIDC startPruneTimer] ' + s), + warn: (s: string) => options.peertubeHelpers.logger.warn('[ExternalAuthOIDC startPruneTimer] ' + s), + error: (s: string) => options.peertubeHelpers.logger.error('[ExternalAuthOIDC startPruneTimer] ' + s) + } + + // every hour (every minutes in debug mode) + const pruneInterval = debugNumericParameter(options, 'externalAccountPruneInterval', 60 * 1000, 60 * 60 * 1000) + logger.info(`Creating a timer for external account pruning, every ${Math.round(pruneInterval / 1000)}s.`) + + // eslint-disable-next-line @typescript-eslint/no-misused-promises + pruneTimer = setInterval(async () => { + try { + // Checking if at least one active singleton + let ok = false + for (const oidc of ExternalAuthOIDC.allSingletons()) { + if (!await oidc.isOk()) { continue } + ok = true + break + } + if (!ok) { return } + + logger.info('Pruning external users...') + await pruneUsers(options) + } catch (err) { + logger.error('Error while pruning external users: ' + (err as string)) + } + }, pruneInterval) +} + +/** + * Stops the prune timer. + */ +function stopPruneTimer (): void { + if (!pruneTimer) { return } + clearInterval(pruneTimer) + pruneTimer = undefined +} + +export { + ExternalAuthOIDC, + ExternalAuthOIDCType } diff --git a/server/lib/prosody/config.ts b/server/lib/prosody/config.ts index 74fbb87b..b7b8b269 100644 --- a/server/lib/prosody/config.ts +++ b/server/lib/prosody/config.ts @@ -197,9 +197,13 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise we must enable the external virtual host. + useExternal = true + break + } } } catch (err) { logger.error(err) diff --git a/server/lib/routers/api/auth.ts b/server/lib/routers/api/auth.ts index c8237e85..b59c4990 100644 --- a/server/lib/routers/api/auth.ts +++ b/server/lib/routers/api/auth.ts @@ -21,8 +21,8 @@ async function initAuthApiRouter (options: RegisterServerOptions, router: Router const token = req.header('X-Peertube-Plugin-Livechat-External-Auth-OIDC-Token') if (token) { try { - const oidc = ExternalAuthOIDC.singleton() - if (await oidc.isOk()) { + const oidc = ExternalAuthOIDC.singletonForToken(token) + if (oidc && await oidc.isOk()) { const unserializedToken = await oidc.unserializeToken(token) if (unserializedToken) { res.status(200).json({ diff --git a/server/lib/routers/oidc.ts b/server/lib/routers/oidc.ts index a9b3a6e2..c778e842 100644 --- a/server/lib/routers/oidc.ts +++ b/server/lib/routers/oidc.ts @@ -36,11 +36,12 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise const router = getRouter() const logger = peertubeHelpers.logger - router.get('/connect', asyncMiddleware( + router.get('/:type?/connect', asyncMiddleware( async (req: Request, res: Response, next: NextFunction) => { - logger.info('[oidc router] OIDC connect call') + const singletonType = req.params.type ?? 'custom' + logger.info('[oidc router] OIDC connect call (' + singletonType + ')') try { - const oidc = ExternalAuthOIDC.singleton() + const oidc = ExternalAuthOIDC.singleton(singletonType) const oidcClient = await oidc.load() if (!oidcClient) { throw new Error('[oidc router] External Auth OIDC not loaded yet') @@ -57,9 +58,10 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise const cbHandler = asyncMiddleware( async (req: Request, res: Response, _next: NextFunction) => { - logger.info('[oidc router] OIDC callback call') + const singletonType = req.params.type ?? 'custom' + logger.info('[oidc router] OIDC callback call (' + singletonType + ')') try { - const oidc = ExternalAuthOIDC.singleton() + const oidc = ExternalAuthOIDC.singleton(singletonType) const oidcClient = await oidc.load() if (!oidcClient) { throw new Error('[oidc router] External Auth OIDC not loaded yet') @@ -102,8 +104,8 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise } } ) - router.get('/cb', cbHandler) - router.post('/cb', cbHandler) + router.get('/:type?/cb', cbHandler) + router.post('/:type?/cb', cbHandler) return router } diff --git a/server/lib/settings.ts b/server/lib/settings.ts index 4ccc5b7f..64c62844 100644 --- a/server/lib/settings.ts +++ b/server/lib/settings.ts @@ -3,7 +3,7 @@ import type { ConverseJSTheme } from '../../shared/lib/types' import { ensureProsodyRunning } from './prosody/ctl' import { RoomChannel } from './room-channel' import { BotsCtl } from './bots/ctl' -import { ExternalAuthOIDC } from './external-auth/oidc' +import { ExternalAuthOIDC, ExternalAuthOIDCType } from './external-auth/oidc' import { loc } from './loc' const escapeHTML = require('escape-html') @@ -22,31 +22,35 @@ async function initSettings (options: RegisterServerOptions): Promise { initThemingSettings(options) initChatServerAdvancedSettings(options) - await ExternalAuthOIDC.initSingleton(options) - const loadOidc = (): void => { - try { - const oidc = ExternalAuthOIDC.singleton() - oidc.isOk().then( - () => { - logger.info('Loading External Auth OIDC...') - oidc.load().then( - () => { - logger.info('External Auth OIDC loaded') - }, - () => { - logger.error('Loading the External Auth OIDC failed') - } - ) - }, - () => { - logger.info('No valid External Auth OIDC, nothing loaded') - } - ) - } catch (err) { - logger.error(err as string) + await ExternalAuthOIDC.initSingletons(options) + const loadOidcs = (): void => { + const oidcs = ExternalAuthOIDC.allSingletons() + for (const oidc of oidcs) { + try { + const type = oidc.type + oidc.isOk().then( + () => { + logger.info(`Loading External Auth OIDC ${type}...`) + oidc.load().then( + () => { + logger.info(`External Auth OIDC ${type} loaded`) + }, + () => { + logger.error(`Loading the External Auth OIDC ${type} failed`) + } + ) + }, + () => { + logger.info(`No valid External Auth OIDC ${type}, nothing loaded`) + } + ) + } catch (err) { + logger.error(err as string) + continue + } } } - loadOidc() // we don't have to wait (can take time, it will do external http requests) + loadOidcs() // we don't have to wait (can take time, it will do external http requests) let currentProsodyRoomtype = (await settingsManager.getSettings(['prosody-room-type']))['prosody-room-type'] @@ -56,9 +60,9 @@ async function initSettings (options: RegisterServerOptions): Promise { // To avoid race condition, we will just stop and start the bots at every settings saving. await BotsCtl.destroySingleton() await BotsCtl.initSingleton(options) - loadOidc() // we don't have to wait (can take time, it will do external http requests) + loadOidcs() // we don't have to wait (can take time, it will do external http requests) - await ExternalAuthOIDC.initSingleton(options) + await ExternalAuthOIDC.initSingletons(options) peertubeHelpers.logger.info('Saving settings, ensuring prosody is running') await ensureProsodyRunning(options) @@ -206,13 +210,13 @@ function initExternalAuth (options: RegisterServerOptions): void { type: 'html', name: 'external-auth-custom-oidc-redirect-uris-info', private: true, - descriptionHTML: loc('external_auth_custom_oidc_redirect_uris_info_description') + descriptionHTML: loc('external_auth_oidc_redirect_uris_info_description') }) registerSetting({ type: 'html', name: 'external-auth-custom-oidc-redirect-uris', private: true, - descriptionHTML: `
  • ${escapeHTML(ExternalAuthOIDC.redirectUrl(options)) as string}
` + descriptionHTML: `
  • ${escapeHTML(ExternalAuthOIDC.redirectUrl(options, 'custom')) as string}
` }) registerSetting({ @@ -232,15 +236,15 @@ function initExternalAuth (options: RegisterServerOptions): void { }) registerSetting({ name: 'external-auth-custom-oidc-client-id', - label: loc('external_auth_custom_oidc_client_id_label'), - // descriptionHTML: loc('external_auth_custom_oidc_client_id_description'), + label: loc('external_auth_oidc_client_id_label'), + // descriptionHTML: loc('external_auth_oidc_client_id_description'), type: 'input', private: true }) registerSetting({ name: 'external-auth-custom-oidc-client-secret', - label: loc('external_auth_custom_oidc_client_secret_label'), - // descriptionHTML: loc('external_auth_custom_oidc_client_secret_description'), + label: loc('external_auth_oidc_client_secret_label'), + // descriptionHTML: loc('external_auth_oidc_client_secret_description'), type: 'input-password', private: true }) @@ -250,6 +254,52 @@ function initExternalAuth (options: RegisterServerOptions): void { // - user-name property // - display-name property // - picture property + + // Standard providers.... + for (const provider of ['google', 'facebook']) { + let redirectUrl + try { + redirectUrl = ExternalAuthOIDC.redirectUrl(options, provider as ExternalAuthOIDCType) + } catch (err) { + options.peertubeHelpers.logger.error('Cant load redirect url for provider ' + provider) + options.peertubeHelpers.logger.error(err) + continue + } + registerSetting({ + name: 'external-auth-' + provider + '-oidc', + label: loc('external_auth_' + provider + '_oidc_label'), + descriptionHTML: loc('external_auth_' + provider + '_oidc_description'), + type: 'input-checkbox', + default: false, + private: true + }) + registerSetting({ + type: 'html', + name: 'external-auth-' + provider + '-oidc-redirect-uris-info', + private: true, + descriptionHTML: loc('external_auth_oidc_redirect_uris_info_description') + }) + registerSetting({ + type: 'html', + name: 'external-auth-' + provider + '-oidc-redirect-uris', + private: true, + descriptionHTML: `
  • ${escapeHTML(redirectUrl) as string}
` + }) + registerSetting({ + name: 'external-auth-' + provider + '-oidc-client-id', + label: loc('external_auth_oidc_client_id_label'), + // descriptionHTML: loc('external_auth_' + provider + '_oidc_client_id_description'), + type: 'input', + private: true + }) + registerSetting({ + name: 'external-auth-' + provider + '-oidc-client-secret', + label: loc('external_auth_oidc_client_secret_label'), + // descriptionHTML: loc('external_auth_' + provider + '_oidc_client_secret_description'), + type: 'input-password', + private: true + }) + } } /** diff --git a/server/main.ts b/server/main.ts index ae37b674..d18bddbb 100644 --- a/server/main.ts +++ b/server/main.ts @@ -94,7 +94,7 @@ async function unregister (): Promise { await RoomChannel.destroySingleton() await BotConfiguration.destroySingleton() - await ExternalAuthOIDC.destroySingleton() + await ExternalAuthOIDC.destroySingletons() const module = __filename OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`) diff --git a/support/documentation/content/en/documentation/admin/external_auth.md b/support/documentation/content/en/documentation/admin/external_auth.md index f3b7fa46..333c0d10 100644 --- a/support/documentation/content/en/documentation/admin/external_auth.md +++ b/support/documentation/content/en/documentation/admin/external_auth.md @@ -63,11 +63,11 @@ Just set here the discovery url, that should be something like `https://example. Note: if your provider use the standard `/.well-known/openid-configuration` path, you can omit it. For example `https://accounts.google.com` will work. -### {{% livechat_label external_auth_custom_oidc_client_id_label %}} +### {{% livechat_label external_auth_oidc_client_id_label %}} Your application Client ID. -### {{% livechat_label external_auth_custom_oidc_client_secret_label %}} +### {{% livechat_label external_auth_oidc_client_secret_label %}} You application Client secret.