Possibility to configure an OpenID Connect provider on the instance level WIP (#128).
This commit is contained in:
parent
6c75863472
commit
8574ab581d
@ -46,6 +46,7 @@ export const tplExternalLoginModal = (el, o) => {
|
||||
return
|
||||
}
|
||||
// TODO
|
||||
console.info('Got external account information', data)
|
||||
console.error('not implemented yet')
|
||||
}
|
||||
|
||||
|
14
package-lock.json
generated
14
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
9
server/lib/external-auth/error.ts
Normal file
9
server/lib/external-auth/error.ts
Normal 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
|
||||
}
|
@ -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<string[]> {
|
||||
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<any> {
|
||||
async validateAuthenticationProcess (req: Request): Promise<ExternalAccountInfos> {
|
||||
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<string> {
|
||||
@ -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
|
||||
}
|
||||
|
10
server/lib/external-auth/types.ts
Normal file
10
server/lib/external-auth/types.ts
Normal file
@ -0,0 +1,10 @@
|
||||
interface ExternalAccountInfos {
|
||||
nickname: string
|
||||
jid: string
|
||||
password: string
|
||||
// TODO: avatar
|
||||
}
|
||||
|
||||
export {
|
||||
ExternalAccountInfos
|
||||
}
|
@ -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<Router>
|
||||
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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
@ -109,7 +109,7 @@ type ChatIncludeMode = 'chat-only' | ChatPeertubeIncludeMode
|
||||
|
||||
interface OIDCAuthResultError {
|
||||
ok: true
|
||||
username: string
|
||||
jid: string
|
||||
password: string
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user