From 8574ab581d100f20bbe874b9777cdf91bcd3973c Mon Sep 17 00:00:00 2001 From: John Livingston Date: Wed, 17 Apr 2024 15:12:37 +0200 Subject: [PATCH] Possibility to configure an OpenID Connect provider on the instance level WIP (#128). --- .../livechat-external-login-modal.js | 1 + package-lock.json | 14 ++ package.json | 2 + server/lib/external-auth/error.ts | 9 + server/lib/external-auth/oidc.ts | 173 ++++++++++++++---- server/lib/external-auth/types.ts | 10 + server/lib/routers/oidc.ts | 15 +- shared/lib/types.ts | 2 +- 8 files changed, 183 insertions(+), 43 deletions(-) create mode 100644 server/lib/external-auth/error.ts create mode 100644 server/lib/external-auth/types.ts diff --git a/conversejs/custom/templates/livechat-external-login-modal.js b/conversejs/custom/templates/livechat-external-login-modal.js index bba2570d..e6065b8b 100644 --- a/conversejs/custom/templates/livechat-external-login-modal.js +++ b/conversejs/custom/templates/livechat-external-login-modal.js @@ -46,6 +46,7 @@ export const tplExternalLoginModal = (el, o) => { return } // TODO + console.info('Got external account information', data) console.error('not implemented yet') } diff --git a/package-lock.json b/package-lock.json index d3858938..4af6d923 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "8.4.0", "license": "AGPL-3.0", "dependencies": { + "@xmpp/jid": "^0.13.1", "async": "^3.2.2", "decache": "^4.6.0", "escape-html": "^1.0.3", @@ -31,6 +32,7 @@ "@types/mustache": "^4.2.2", "@types/node": "^16.11.6", "@types/winston": "^2.4.4", + "@types/xmpp__jid": "^1.3.5", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", "commander": "^11.0.0", @@ -4109,6 +4111,12 @@ "winston": "*" } }, + "node_modules/@types/xmpp__jid": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/xmpp__jid/-/xmpp__jid-1.3.5.tgz", + "integrity": "sha512-7nbg+XOOswcLAqjU6f5qBzdmoMw8JvcpPP36O+DACZyPAi88LGw8ulmy05F7jpvjXlGAFGrFvnmF5d9OrU+LfQ==", + "dev": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "4.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz", @@ -15553,6 +15561,12 @@ "winston": "*" } }, + "@types/xmpp__jid": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/xmpp__jid/-/xmpp__jid-1.3.5.tgz", + "integrity": "sha512-7nbg+XOOswcLAqjU6f5qBzdmoMw8JvcpPP36O+DACZyPAi88LGw8ulmy05F7jpvjXlGAFGrFvnmF5d9OrU+LfQ==", + "dev": true + }, "@typescript-eslint/eslint-plugin": { "version": "4.29.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz", diff --git a/package.json b/package.json index c0c9e69a..9d99838f 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "dist/assets/styles/configuration.css" ], "dependencies": { + "@xmpp/jid": "^0.13.1", "async": "^3.2.2", "decache": "^4.6.0", "escape-html": "^1.0.3", @@ -55,6 +56,7 @@ "@types/mustache": "^4.2.2", "@types/node": "^16.11.6", "@types/winston": "^2.4.4", + "@types/xmpp__jid": "^1.3.5", "@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/parser": "^4.29.0", "commander": "^11.0.0", diff --git a/server/lib/external-auth/error.ts b/server/lib/external-auth/error.ts new file mode 100644 index 00000000..ad7b2ffb --- /dev/null +++ b/server/lib/external-auth/error.ts @@ -0,0 +1,9 @@ + +/** + * This class handles some errors related to external authentication, where message must be displayed to end user. + */ +class ExternalAuthenticationError extends Error {} + +export { + ExternalAuthenticationError +} diff --git a/server/lib/external-auth/oidc.ts b/server/lib/external-auth/oidc.ts index 6147661b..d391a74d 100644 --- a/server/lib/external-auth/oidc.ts +++ b/server/lib/external-auth/oidc.ts @@ -1,10 +1,16 @@ import type { RegisterServerOptions } from '@peertube/peertube-types' import type { Request, Response, CookieOptions } from 'express' -import { URL } from 'url' -import { Issuer, BaseClient, generators } from 'openid-client' +import type { ExternalAccountInfos } from './types' +import { ExternalAuthenticationError } from './error' import { getBaseRouterRoute } from '../helpers' import { canonicalizePluginUri } from '../uri/canonicalize' +import { getProsodyDomain } from '../prosody/config/domain' import { createCipheriv, createDecipheriv, randomBytes, Encoding } from 'node:crypto' +import { Issuer, BaseClient, generators, UnknownObject } from 'openid-client' +import { JID } from '@xmpp/jid' +import { URL } from 'url' + +type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' let singleton: ExternalAuthOIDC | undefined @@ -30,6 +36,7 @@ class ExternalAuthOIDC { private readonly secretKey: string private readonly redirectUrl: string private readonly connectUrl: string + private readonly externalVirtualhost: string private readonly encryptionOptions = { algorithm: 'aes256' as string, @@ -49,6 +56,7 @@ class ExternalAuthOIDC { private issuer: Issuer | undefined | null private client: BaseClient | undefined | null + private providerHostName?: string protected readonly logger: { debug: (s: string) => void @@ -57,33 +65,35 @@ class ExternalAuthOIDC { error: (s: string) => void } - constructor ( - logger: RegisterServerOptions['peertubeHelpers']['logger'], - enabled: boolean, - buttonLabel: string | undefined, - discoveryUrl: string | undefined, - clientId: string | undefined, - clientSecret: string | undefined, - secretKey: string, - connectUrl: string, + constructor (params: { + logger: RegisterServerOptions['peertubeHelpers']['logger'] + enabled: boolean + buttonLabel: string | undefined + discoveryUrl: string | undefined + clientId: string | undefined + clientSecret: string | undefined + secretKey: string + connectUrl: string redirectUrl: string - ) { + externalVirtualhost: string + }) { this.logger = { - debug: (s) => logger.debug('[ExternalAuthOIDC] ' + s), - info: (s) => logger.info('[ExternalAuthOIDC] ' + s), - warn: (s) => logger.warn('[ExternalAuthOIDC] ' + s), - error: (s) => logger.error('[ExternalAuthOIDC] ' + s) + debug: (s) => params.logger.debug('[ExternalAuthOIDC] ' + s), + info: (s) => params.logger.info('[ExternalAuthOIDC] ' + s), + warn: (s) => params.logger.warn('[ExternalAuthOIDC] ' + s), + error: (s) => params.logger.error('[ExternalAuthOIDC] ' + s) } - this.enabled = !!enabled - this.secretKey = secretKey - this.redirectUrl = redirectUrl - this.connectUrl = connectUrl + this.enabled = !!params.enabled + this.secretKey = params.secretKey + this.redirectUrl = params.redirectUrl + this.connectUrl = params.connectUrl + this.externalVirtualhost = params.externalVirtualhost if (this.enabled) { - this.buttonLabel = buttonLabel - this.discoveryUrl = discoveryUrl - this.clientId = clientId - this.clientSecret = clientSecret + this.buttonLabel = params.buttonLabel + this.discoveryUrl = params.discoveryUrl + this.clientId = params.clientId + this.clientSecret = params.clientSecret } } @@ -142,6 +152,7 @@ class ExternalAuthOIDC { * Check the configuration. * Returns an error list. * If error list is empty, consider the OIDC is correctly configured. + * Note: this function also fills this.providerHostName (as it also parse the discoveryUrl). */ async check (): Promise { if (!this.enabled) { @@ -159,6 +170,8 @@ class ExternalAuthOIDC { try { const uri = new URL(this.discoveryUrl ?? 'wrong url') this.logger.debug('OIDC Discovery url is valid: ' + uri.toString()) + + this.providerHostName = uri.hostname } catch (err) { errors.push('Invalid discovery url') } @@ -257,9 +270,11 @@ class ExternalAuthOIDC { /** * Authentication process callback. * @param req The ExpressJS request object. Will read cookies. + * @throws ExternalAuthenticationError when a specific message must be displayed to enduser. + * @throws Error in other cases. * @return user info */ - async validateAuthenticationProcess (req: Request): Promise { + async validateAuthenticationProcess (req: Request): Promise { if (!this.client) { throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.validateAuthenticationProcess') } @@ -288,7 +303,42 @@ class ExternalAuthOIDC { throw new Error('Missing access_token') } const userInfo = await this.client.userinfo(accessToken) - return userInfo + + if (!userInfo) { + throw new ExternalAuthenticationError('Can\'t retrieve userInfos') + } + + const username = this.readUserInfoField(userInfo, 'username') + if (username === undefined) { + throw new ExternalAuthenticationError('Missing username in userInfos') + } + + let nickname: string | undefined = this.readUserInfoField(userInfo, 'nickname') + if (nickname === undefined) { + const lastname = this.readUserInfoField(userInfo, 'last_name') + const firstname = this.readUserInfoField(userInfo, 'first_name') + if (lastname !== undefined && firstname !== undefined) { + nickname = firstname + ' ' + lastname + } else if (firstname !== undefined) { + nickname = firstname + } else if (lastname !== undefined) { + nickname = lastname + } + } + nickname ??= username + + // Computing the JID (can throw Error/ExternalAuthenticationError). + const jid = this.computeJID(username) + + // Computing a random Password + // (16 bytes in hex => 32 chars (but only numbers and abdcef), 256^16 should be enougth). + const password = (await getRandomBytes(16)).toString('hex') + + return { + jid: jid.toString(false), + nickname, + password + } } private async encrypt (data: string): Promise { @@ -315,6 +365,54 @@ class ExternalAuthOIDC { return decipher.update(encrypted as any, outputEncoding, inputEncoding) + decipher.final(inputEncoding) } + /** + * Get an attribute from the userInfos. + * @param userInfos userInfos returned by the remote OIDC Provider + * @param field the field to get + * @returns the value if present + */ + private readUserInfoField (userInfos: UnknownObject, field: UserInfoField): string | undefined { + // FIXME: do some attribute mapping? (add settings for that?) + if (!(field in userInfos)) { return undefined } + if (typeof userInfos[field] !== 'string') { return undefined } + if (userInfos[field] === '') { return undefined } + return userInfos[field] as string + } + + /** + * Compute the JID to use for this remote account. + * Format will be: "username+remote.domain.tld@external.instance.tld" + * @param username the remote username + * @throws ExternalAuthenticationError if the computed JID is not valid. + * @throws Error + * @returns The JID. + */ + private computeJID (username: string): JID { + if (!this.providerHostName) { + this.logger.error('Missing providerHostName, callong computeJID before check()?') + throw new Error('Can\'t compute JID') + } + try { + const jid = new JID(username + '+' + this.providerHostName, this.externalVirtualhost) + + // Checking JID is not too long. + // Following https://xmpp.org/extensions/xep-0029.html , there is no exact limit, + // but we should definitively not accept anything. + // Using 256 as suggested (for the escaped version) + if (jid.toString(false).length > 256) { + throw new ExternalAuthenticationError( + 'Resulting identifier for your account is too long' + ) + } + return jid + } catch (err) { + this.logger.error(err as string) + throw new ExternalAuthenticationError( + 'Resulting identifier for your account is invalid, please report this issue' + ) + } + } + /** * frees the singleton */ @@ -339,17 +437,20 @@ class ExternalAuthOIDC { // Generating a secret key that will be used for the authenticatio process (can change on restart). const secretKey = (await getRandomBytes(16)).toString('hex') - singleton = new ExternalAuthOIDC( - options.peertubeHelpers.logger, - settings['external-auth-custom-oidc'] as boolean, - settings['external-auth-custom-oidc-button-label'] as string | undefined, - settings['external-auth-custom-oidc-discovery-url'] as string | undefined, - settings['external-auth-custom-oidc-client-id'] as string | undefined, - settings['external-auth-custom-oidc-client-secret'] as string | undefined, + const prosodyDomain = await getProsodyDomain(options) + + 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, - ExternalAuthOIDC.connectUrl(options), - ExternalAuthOIDC.redirectUrl(options) - ) + connectUrl: ExternalAuthOIDC.connectUrl(options), + redirectUrl: ExternalAuthOIDC.redirectUrl(options), + externalVirtualhost: 'external.' + prosodyDomain + }) return singleton } diff --git a/server/lib/external-auth/types.ts b/server/lib/external-auth/types.ts new file mode 100644 index 00000000..4e5f5476 --- /dev/null +++ b/server/lib/external-auth/types.ts @@ -0,0 +1,10 @@ +interface ExternalAccountInfos { + nickname: string + jid: string + password: string + // TODO: avatar +} + +export { + ExternalAccountInfos +} diff --git a/server/lib/routers/oidc.ts b/server/lib/routers/oidc.ts index 934939b8..f5a8878e 100644 --- a/server/lib/routers/oidc.ts +++ b/server/lib/routers/oidc.ts @@ -3,6 +3,7 @@ import type { Router, Request, Response, NextFunction } from 'express' import type { OIDCAuthResult } from '../../../shared/lib/types' import { asyncMiddleware } from '../middlewares/async' import { ExternalAuthOIDC } from '../external-auth/oidc' +import { ExternalAuthenticationError } from '../external-auth/error' /** * When using a popup for OIDC, writes the HTML/Javascript to close the popup @@ -63,19 +64,21 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise throw new Error('[oidc router] External Auth OIDC not loaded yet') } - const userInfos = await oidc.validateAuthenticationProcess(req) - logger.info(JSON.stringify(userInfos)) // FIXME (normalize data type, process, ...) + const externalAccountInfos = await oidc.validateAuthenticationProcess(req) + logger.info(JSON.stringify(externalAccountInfos)) // FIXME (normalize data type, process, ...) res.send(popupResultHTML({ ok: true, - username: userInfos.username, - password: 'TODO' + jid: externalAccountInfos.jid, + password: externalAccountInfos.password })) } catch (err) { logger.error('[oidc router] Failed to process the OIDC callback: ' + (err as string)) - res.sendStatus(500) + const message = err instanceof ExternalAuthenticationError ? err.message : undefined + res.status(500) res.send(popupResultHTML({ - ok: false + ok: false, + message })) } } diff --git a/shared/lib/types.ts b/shared/lib/types.ts index 24dfcb4a..1ff3da5d 100644 --- a/shared/lib/types.ts +++ b/shared/lib/types.ts @@ -109,7 +109,7 @@ type ChatIncludeMode = 'chat-only' | ChatPeertubeIncludeMode interface OIDCAuthResultError { ok: true - username: string + jid: string password: string }