Possibility to configure an OpenID Connect provider on the instance level WIP (#128).

This commit is contained in:
John Livingston 2024-04-17 15:12:37 +02:00
parent 6c75863472
commit 8574ab581d
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
8 changed files with 183 additions and 43 deletions

View File

@ -46,6 +46,7 @@ export const tplExternalLoginModal = (el, o) => {
return return
} }
// TODO // TODO
console.info('Got external account information', data)
console.error('not implemented yet') console.error('not implemented yet')
} }

14
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "8.4.0", "version": "8.4.0",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
"@xmpp/jid": "^0.13.1",
"async": "^3.2.2", "async": "^3.2.2",
"decache": "^4.6.0", "decache": "^4.6.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@ -31,6 +32,7 @@
"@types/mustache": "^4.2.2", "@types/mustache": "^4.2.2",
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"@types/xmpp__jid": "^1.3.5",
"@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0", "@typescript-eslint/parser": "^4.29.0",
"commander": "^11.0.0", "commander": "^11.0.0",
@ -4109,6 +4111,12 @@
"winston": "*" "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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "4.29.0", "version": "4.29.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz",
@ -15553,6 +15561,12 @@
"winston": "*" "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": { "@typescript-eslint/eslint-plugin": {
"version": "4.29.0", "version": "4.29.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.29.0.tgz",

View File

@ -33,6 +33,7 @@
"dist/assets/styles/configuration.css" "dist/assets/styles/configuration.css"
], ],
"dependencies": { "dependencies": {
"@xmpp/jid": "^0.13.1",
"async": "^3.2.2", "async": "^3.2.2",
"decache": "^4.6.0", "decache": "^4.6.0",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
@ -55,6 +56,7 @@
"@types/mustache": "^4.2.2", "@types/mustache": "^4.2.2",
"@types/node": "^16.11.6", "@types/node": "^16.11.6",
"@types/winston": "^2.4.4", "@types/winston": "^2.4.4",
"@types/xmpp__jid": "^1.3.5",
"@typescript-eslint/eslint-plugin": "^4.29.0", "@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/parser": "^4.29.0", "@typescript-eslint/parser": "^4.29.0",
"commander": "^11.0.0", "commander": "^11.0.0",

View File

@ -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
}

View File

@ -1,10 +1,16 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import type { Request, Response, CookieOptions } from 'express' import type { Request, Response, CookieOptions } from 'express'
import { URL } from 'url' import type { ExternalAccountInfos } from './types'
import { Issuer, BaseClient, generators } from 'openid-client' import { ExternalAuthenticationError } from './error'
import { getBaseRouterRoute } from '../helpers' import { getBaseRouterRoute } from '../helpers'
import { canonicalizePluginUri } from '../uri/canonicalize' import { canonicalizePluginUri } from '../uri/canonicalize'
import { getProsodyDomain } from '../prosody/config/domain'
import { createCipheriv, createDecipheriv, randomBytes, Encoding } from 'node:crypto' 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 let singleton: ExternalAuthOIDC | undefined
@ -30,6 +36,7 @@ class ExternalAuthOIDC {
private readonly secretKey: string private readonly secretKey: string
private readonly redirectUrl: string private readonly redirectUrl: string
private readonly connectUrl: string private readonly connectUrl: string
private readonly externalVirtualhost: string
private readonly encryptionOptions = { private readonly encryptionOptions = {
algorithm: 'aes256' as string, algorithm: 'aes256' as string,
@ -49,6 +56,7 @@ class ExternalAuthOIDC {
private issuer: Issuer | undefined | null private issuer: Issuer | undefined | null
private client: BaseClient | undefined | null private client: BaseClient | undefined | null
private providerHostName?: string
protected readonly logger: { protected readonly logger: {
debug: (s: string) => void debug: (s: string) => void
@ -57,33 +65,35 @@ class ExternalAuthOIDC {
error: (s: string) => void error: (s: string) => void
} }
constructor ( constructor (params: {
logger: RegisterServerOptions['peertubeHelpers']['logger'], logger: RegisterServerOptions['peertubeHelpers']['logger']
enabled: boolean, enabled: boolean
buttonLabel: string | undefined, buttonLabel: string | undefined
discoveryUrl: string | undefined, discoveryUrl: string | undefined
clientId: string | undefined, clientId: string | undefined
clientSecret: string | undefined, clientSecret: string | undefined
secretKey: string, secretKey: string
connectUrl: string, connectUrl: string
redirectUrl: string redirectUrl: string
) { externalVirtualhost: string
}) {
this.logger = { this.logger = {
debug: (s) => logger.debug('[ExternalAuthOIDC] ' + s), debug: (s) => params.logger.debug('[ExternalAuthOIDC] ' + s),
info: (s) => logger.info('[ExternalAuthOIDC] ' + s), info: (s) => params.logger.info('[ExternalAuthOIDC] ' + s),
warn: (s) => logger.warn('[ExternalAuthOIDC] ' + s), warn: (s) => params.logger.warn('[ExternalAuthOIDC] ' + s),
error: (s) => logger.error('[ExternalAuthOIDC] ' + s) error: (s) => params.logger.error('[ExternalAuthOIDC] ' + s)
} }
this.enabled = !!enabled this.enabled = !!params.enabled
this.secretKey = secretKey this.secretKey = params.secretKey
this.redirectUrl = redirectUrl this.redirectUrl = params.redirectUrl
this.connectUrl = connectUrl this.connectUrl = params.connectUrl
this.externalVirtualhost = params.externalVirtualhost
if (this.enabled) { if (this.enabled) {
this.buttonLabel = buttonLabel this.buttonLabel = params.buttonLabel
this.discoveryUrl = discoveryUrl this.discoveryUrl = params.discoveryUrl
this.clientId = clientId this.clientId = params.clientId
this.clientSecret = clientSecret this.clientSecret = params.clientSecret
} }
} }
@ -142,6 +152,7 @@ class ExternalAuthOIDC {
* Check the configuration. * Check the configuration.
* Returns an error list. * Returns an error list.
* If error list is empty, consider the OIDC is correctly configured. * 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<string[]> { async check (): Promise<string[]> {
if (!this.enabled) { if (!this.enabled) {
@ -159,6 +170,8 @@ class ExternalAuthOIDC {
try { try {
const uri = new URL(this.discoveryUrl ?? 'wrong url') const uri = new URL(this.discoveryUrl ?? 'wrong url')
this.logger.debug('OIDC Discovery url is valid: ' + uri.toString()) this.logger.debug('OIDC Discovery url is valid: ' + uri.toString())
this.providerHostName = uri.hostname
} catch (err) { } catch (err) {
errors.push('Invalid discovery url') errors.push('Invalid discovery url')
} }
@ -257,9 +270,11 @@ class ExternalAuthOIDC {
/** /**
* Authentication process callback. * Authentication process callback.
* @param req The ExpressJS request object. Will read cookies. * @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 * @return user info
*/ */
async validateAuthenticationProcess (req: Request): Promise<any> { async validateAuthenticationProcess (req: Request): Promise<ExternalAccountInfos> {
if (!this.client) { if (!this.client) {
throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.validateAuthenticationProcess') 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') throw new Error('Missing access_token')
} }
const userInfo = await this.client.userinfo(accessToken) 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<string> { private async encrypt (data: string): Promise<string> {
@ -315,6 +365,54 @@ class ExternalAuthOIDC {
return decipher.update(encrypted as any, outputEncoding, inputEncoding) + decipher.final(inputEncoding) 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 * 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). // Generating a secret key that will be used for the authenticatio process (can change on restart).
const secretKey = (await getRandomBytes(16)).toString('hex') const secretKey = (await getRandomBytes(16)).toString('hex')
singleton = new ExternalAuthOIDC( const prosodyDomain = await getProsodyDomain(options)
options.peertubeHelpers.logger,
settings['external-auth-custom-oidc'] as boolean, singleton = new ExternalAuthOIDC({
settings['external-auth-custom-oidc-button-label'] as string | undefined, logger: options.peertubeHelpers.logger,
settings['external-auth-custom-oidc-discovery-url'] as string | undefined, enabled: settings['external-auth-custom-oidc'] as boolean,
settings['external-auth-custom-oidc-client-id'] as string | undefined, buttonLabel: settings['external-auth-custom-oidc-button-label'] as string | undefined,
settings['external-auth-custom-oidc-client-secret'] 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, secretKey,
ExternalAuthOIDC.connectUrl(options), connectUrl: ExternalAuthOIDC.connectUrl(options),
ExternalAuthOIDC.redirectUrl(options) redirectUrl: ExternalAuthOIDC.redirectUrl(options),
) externalVirtualhost: 'external.' + prosodyDomain
})
return singleton return singleton
} }

View File

@ -0,0 +1,10 @@
interface ExternalAccountInfos {
nickname: string
jid: string
password: string
// TODO: avatar
}
export {
ExternalAccountInfos
}

View File

@ -3,6 +3,7 @@ import type { Router, Request, Response, NextFunction } from 'express'
import type { OIDCAuthResult } from '../../../shared/lib/types' import type { OIDCAuthResult } from '../../../shared/lib/types'
import { asyncMiddleware } from '../middlewares/async' import { asyncMiddleware } from '../middlewares/async'
import { ExternalAuthOIDC } from '../external-auth/oidc' 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 * When using a popup for OIDC, writes the HTML/Javascript to close the popup
@ -63,19 +64,21 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
throw new Error('[oidc router] External Auth OIDC not loaded yet') throw new Error('[oidc router] External Auth OIDC not loaded yet')
} }
const userInfos = await oidc.validateAuthenticationProcess(req) const externalAccountInfos = await oidc.validateAuthenticationProcess(req)
logger.info(JSON.stringify(userInfos)) // FIXME (normalize data type, process, ...) logger.info(JSON.stringify(externalAccountInfos)) // FIXME (normalize data type, process, ...)
res.send(popupResultHTML({ res.send(popupResultHTML({
ok: true, ok: true,
username: userInfos.username, jid: externalAccountInfos.jid,
password: 'TODO' password: externalAccountInfos.password
})) }))
} catch (err) { } catch (err) {
logger.error('[oidc router] Failed to process the OIDC callback: ' + (err as string)) 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({ res.send(popupResultHTML({
ok: false ok: false,
message
})) }))
} }
} }

View File

@ -109,7 +109,7 @@ type ChatIncludeMode = 'chat-only' | ChatPeertubeIncludeMode
interface OIDCAuthResultError { interface OIDCAuthResultError {
ok: true ok: true
username: string jid: string
password: string password: string
} }