Possibility to configure an OpenID Connect provider on the instance level WIP (#128).
This commit is contained in:
parent
669b260307
commit
43d0fba274
@ -82,7 +82,7 @@ async function getConverseJSParams (
|
|||||||
try {
|
try {
|
||||||
const oidc = ExternalAuthOIDC.singleton()
|
const oidc = ExternalAuthOIDC.singleton()
|
||||||
if (await oidc.isOk()) {
|
if (await oidc.isOk()) {
|
||||||
const authUrl = oidc.getAuthUrl()
|
const authUrl = oidc.getConnectUrl()
|
||||||
const buttonLabel = oidc.getButtonLabel()
|
const buttonLabel = oidc.getButtonLabel()
|
||||||
if (authUrl && buttonLabel) {
|
if (authUrl && buttonLabel) {
|
||||||
externalAuthOIDC = {
|
externalAuthOIDC = {
|
||||||
|
@ -1,11 +1,23 @@
|
|||||||
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
||||||
|
import type { Request } from 'express'
|
||||||
import { URL } from 'url'
|
import { URL } from 'url'
|
||||||
import { Issuer, BaseClient } from 'openid-client'
|
import { Issuer, BaseClient, generators } from 'openid-client'
|
||||||
import { getBaseRouterRoute } from '../helpers'
|
import { getBaseRouterRoute } from '../helpers'
|
||||||
import { canonicalizePluginUri } from '../uri/canonicalize'
|
import { canonicalizePluginUri } from '../uri/canonicalize'
|
||||||
|
import { createCipheriv, createDecipheriv, randomBytes, Encoding } from 'node:crypto'
|
||||||
|
|
||||||
let singleton: ExternalAuthOIDC | undefined
|
let singleton: ExternalAuthOIDC | undefined
|
||||||
|
|
||||||
|
async function getRandomBytes (size: number): Promise<Buffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
randomBytes(size, (err, buf) => {
|
||||||
|
if (err) return reject(err)
|
||||||
|
|
||||||
|
return resolve(buf)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class handles the external OpenId Connect provider, if defined.
|
* This class handles the external OpenId Connect provider, if defined.
|
||||||
*/
|
*/
|
||||||
@ -15,13 +27,20 @@ class ExternalAuthOIDC {
|
|||||||
private readonly discoveryUrl: string | undefined
|
private readonly discoveryUrl: string | undefined
|
||||||
private readonly clientId: string | undefined
|
private readonly clientId: string | undefined
|
||||||
private readonly clientSecret: string | undefined
|
private readonly clientSecret: string | undefined
|
||||||
private readonly redirectUri: string
|
private readonly secretKey: string
|
||||||
|
private readonly redirectUrl: string
|
||||||
|
private readonly connectUrl: string
|
||||||
|
|
||||||
|
private readonly encryptionOptions = {
|
||||||
|
algorithm: 'aes256' as string,
|
||||||
|
inputEncoding: 'utf8' as Encoding,
|
||||||
|
outputEncoding: 'hex' as Encoding
|
||||||
|
}
|
||||||
|
|
||||||
private ok: boolean | undefined
|
private ok: boolean | undefined
|
||||||
|
|
||||||
private issuer: Issuer | undefined | null
|
private issuer: Issuer | undefined | null
|
||||||
private client: BaseClient | undefined | null
|
private client: BaseClient | undefined | null
|
||||||
private authorizationUrl: string | null
|
|
||||||
|
|
||||||
protected readonly logger: {
|
protected readonly logger: {
|
||||||
debug: (s: string) => void
|
debug: (s: string) => void
|
||||||
@ -37,7 +56,9 @@ class ExternalAuthOIDC {
|
|||||||
discoveryUrl: string | undefined,
|
discoveryUrl: string | undefined,
|
||||||
clientId: string | undefined,
|
clientId: string | undefined,
|
||||||
clientSecret: string | undefined,
|
clientSecret: string | undefined,
|
||||||
redirectUri: string
|
secretKey: string,
|
||||||
|
connectUrl: string,
|
||||||
|
redirectUrl: string
|
||||||
) {
|
) {
|
||||||
this.logger = {
|
this.logger = {
|
||||||
debug: (s) => logger.debug('[ExternalAuthOIDC] ' + s),
|
debug: (s) => logger.debug('[ExternalAuthOIDC] ' + s),
|
||||||
@ -47,8 +68,9 @@ class ExternalAuthOIDC {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.enabled = !!enabled
|
this.enabled = !!enabled
|
||||||
this.redirectUri = redirectUri
|
this.secretKey = secretKey
|
||||||
this.authorizationUrl = null
|
this.redirectUrl = redirectUrl
|
||||||
|
this.connectUrl = connectUrl
|
||||||
if (this.enabled) {
|
if (this.enabled) {
|
||||||
this.buttonLabel = buttonLabel
|
this.buttonLabel = buttonLabel
|
||||||
this.discoveryUrl = discoveryUrl
|
this.discoveryUrl = discoveryUrl
|
||||||
@ -72,8 +94,12 @@ class ExternalAuthOIDC {
|
|||||||
* This means that the feature will only be available when the load as complete.
|
* This means that the feature will only be available when the load as complete.
|
||||||
* @returns the url to open
|
* @returns the url to open
|
||||||
*/
|
*/
|
||||||
getAuthUrl (): string | null {
|
getConnectUrl (): string | null {
|
||||||
return this.authorizationUrl ?? null
|
if (!this.client) {
|
||||||
|
// Not loaded yet
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return this.connectUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -150,9 +176,6 @@ class ExternalAuthOIDC {
|
|||||||
// this.client === null means we already tried, but it failed.
|
// this.client === null means we already tried, but it failed.
|
||||||
if (this.client !== undefined) { return this.client }
|
if (this.client !== undefined) { return this.client }
|
||||||
|
|
||||||
// First, reset the authentication url:
|
|
||||||
this.authorizationUrl = null
|
|
||||||
|
|
||||||
if (!await this.isOk()) {
|
if (!await this.isOk()) {
|
||||||
this.issuer = null
|
this.issuer = null
|
||||||
this.client = null
|
this.client = null
|
||||||
@ -177,7 +200,7 @@ class ExternalAuthOIDC {
|
|||||||
this.client = new this.issuer.Client({
|
this.client = new this.issuer.Client({
|
||||||
client_id: this.clientId as string,
|
client_id: this.clientId as string,
|
||||||
client_secret: this.clientSecret as string,
|
client_secret: this.clientSecret as string,
|
||||||
redirect_uris: [this.redirectUri],
|
redirect_uris: [this.redirectUrl],
|
||||||
response_types: ['code']
|
response_types: ['code']
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -189,14 +212,102 @@ class ExternalAuthOIDC {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return this.client
|
||||||
this.authorizationUrl = this.client.authorizationUrl()
|
}
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err as string)
|
/**
|
||||||
this.authorizationUrl = null
|
* Returns everything that is needed to instanciate an OIDC authentication.
|
||||||
|
*/
|
||||||
|
async initAuthenticationProcess (): Promise<{
|
||||||
|
encryptedCodeVerifier: string
|
||||||
|
encryptedState: string
|
||||||
|
redirectUrl: string
|
||||||
|
}> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.initAuthentication')
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.client
|
const codeVerifier = generators.codeVerifier()
|
||||||
|
const codeChallenge = generators.codeChallenge(codeVerifier)
|
||||||
|
const state = generators.state()
|
||||||
|
|
||||||
|
const encryptedCodeVerifier = await this.encrypt(codeVerifier)
|
||||||
|
const encryptedState = await this.encrypt(state)
|
||||||
|
|
||||||
|
const redirectUrl = this.client.authorizationUrl({
|
||||||
|
scope: 'openid profile',
|
||||||
|
response_mode: 'form_post',
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
state
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
encryptedCodeVerifier,
|
||||||
|
encryptedState,
|
||||||
|
redirectUrl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentication process callback.
|
||||||
|
* @param req The ExpressJS request object.
|
||||||
|
* @return user info
|
||||||
|
*/
|
||||||
|
async validateAuthenticationProcess (req: Request, cookieNamePrefix: string): Promise<any> {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.validateAuthenticationProcess')
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedCodeVerifier = req.cookies[cookieNamePrefix + 'code-verifier']
|
||||||
|
if (!encryptedCodeVerifier) {
|
||||||
|
throw new Error('Received callback but code verifier not found in request cookies.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedState = req.cookies[cookieNamePrefix + 'state']
|
||||||
|
if (!encryptedState) {
|
||||||
|
throw new Error('Received callback but state not found in request cookies.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const codeVerifier = await this.decrypt(encryptedCodeVerifier)
|
||||||
|
const state = await this.decrypt(encryptedState)
|
||||||
|
|
||||||
|
const params = this.client.callbackParams(req)
|
||||||
|
const tokenSet = await this.client.callback(this.redirectUrl, params, {
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
state
|
||||||
|
})
|
||||||
|
|
||||||
|
const accessToken = tokenSet.access_token
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Missing access_token')
|
||||||
|
}
|
||||||
|
const userInfo = await this.client.userinfo(accessToken)
|
||||||
|
return userInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
private async encrypt (data: string): Promise<string> {
|
||||||
|
const { algorithm, inputEncoding, outputEncoding } = this.encryptionOptions
|
||||||
|
|
||||||
|
const iv = await getRandomBytes(16)
|
||||||
|
|
||||||
|
const cipher = createCipheriv(algorithm, this.secretKey, iv)
|
||||||
|
let encrypted = cipher.update(data, inputEncoding, outputEncoding)
|
||||||
|
encrypted += cipher.final(outputEncoding)
|
||||||
|
|
||||||
|
return iv.toString(outputEncoding) + ':' + encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
private async decrypt (data: string): Promise<string> {
|
||||||
|
const { algorithm, inputEncoding, outputEncoding } = this.encryptionOptions
|
||||||
|
|
||||||
|
const encryptedArray = data.split(':')
|
||||||
|
const iv = Buffer.from(encryptedArray[0], outputEncoding)
|
||||||
|
const encrypted = Buffer.from(encryptedArray[1], outputEncoding)
|
||||||
|
const decipher = createDecipheriv(algorithm, this.secretKey, iv)
|
||||||
|
|
||||||
|
// FIXME: dismiss the "as any" below (dont understand why Typescript is not happy without)
|
||||||
|
return decipher.update(encrypted as any, outputEncoding, inputEncoding) + decipher.final(inputEncoding)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -219,6 +330,10 @@ class ExternalAuthOIDC {
|
|||||||
'external-auth-custom-oidc-client-id',
|
'external-auth-custom-oidc-client-id',
|
||||||
'external-auth-custom-oidc-client-secret'
|
'external-auth-custom-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')
|
||||||
|
|
||||||
singleton = new ExternalAuthOIDC(
|
singleton = new ExternalAuthOIDC(
|
||||||
options.peertubeHelpers.logger,
|
options.peertubeHelpers.logger,
|
||||||
settings['external-auth-custom-oidc'] as boolean,
|
settings['external-auth-custom-oidc'] as boolean,
|
||||||
@ -226,7 +341,9 @@ class ExternalAuthOIDC {
|
|||||||
settings['external-auth-custom-oidc-discovery-url'] 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-id'] as string | undefined,
|
||||||
settings['external-auth-custom-oidc-client-secret'] as string | undefined,
|
settings['external-auth-custom-oidc-client-secret'] as string | undefined,
|
||||||
ExternalAuthOIDC.redirectUri(options)
|
secretKey,
|
||||||
|
ExternalAuthOIDC.connectUrl(options),
|
||||||
|
ExternalAuthOIDC.redirectUrl(options)
|
||||||
)
|
)
|
||||||
|
|
||||||
return singleton
|
return singleton
|
||||||
@ -243,7 +360,24 @@ class ExternalAuthOIDC {
|
|||||||
return singleton
|
return singleton
|
||||||
}
|
}
|
||||||
|
|
||||||
public static redirectUri (options: RegisterServerOptions): string {
|
/**
|
||||||
|
* 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'
|
||||||
|
return canonicalizePluginUri(options, path, {
|
||||||
|
removePluginVersion: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the redirect uri to require from the remote OIDC Provider.
|
||||||
|
* @param options Peertube server optiosn
|
||||||
|
* @returns the uri
|
||||||
|
*/
|
||||||
|
public static redirectUrl (options: RegisterServerOptions): string {
|
||||||
const path = getBaseRouterRoute(options) + 'oidc/cb'
|
const path = getBaseRouterRoute(options) + 'oidc/cb'
|
||||||
return canonicalizePluginUri(options, path, {
|
return canonicalizePluginUri(options, path, {
|
||||||
removePluginVersion: true
|
removePluginVersion: true
|
||||||
|
@ -3,6 +3,7 @@ import type { NextFunction, Request, Response } from 'express'
|
|||||||
import { initWebchatRouter } from './webchat'
|
import { initWebchatRouter } from './webchat'
|
||||||
import { initSettingsRouter } from './settings'
|
import { initSettingsRouter } from './settings'
|
||||||
import { initApiRouter } from './api'
|
import { initApiRouter } from './api'
|
||||||
|
import { initOIDCRouter } from './oidc'
|
||||||
|
|
||||||
async function initRouters (options: RegisterServerOptions): Promise<void> {
|
async function initRouters (options: RegisterServerOptions): Promise<void> {
|
||||||
const { getRouter } = options
|
const { getRouter } = options
|
||||||
@ -13,6 +14,7 @@ async function initRouters (options: RegisterServerOptions): Promise<void> {
|
|||||||
router.use('/webchat', await initWebchatRouter(options))
|
router.use('/webchat', await initWebchatRouter(options))
|
||||||
router.use('/settings', await initSettingsRouter(options))
|
router.use('/settings', await initSettingsRouter(options))
|
||||||
router.use('/api', await initApiRouter(options))
|
router.use('/api', await initApiRouter(options))
|
||||||
|
router.use('/oidc', await initOIDCRouter(options))
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
66
server/lib/routers/oidc.ts
Normal file
66
server/lib/routers/oidc.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
||||||
|
import type { Router, Request, Response, NextFunction, CookieOptions } from 'express'
|
||||||
|
import { asyncMiddleware } from '../middlewares/async'
|
||||||
|
import { ExternalAuthOIDC } from '../external-auth/oidc'
|
||||||
|
|
||||||
|
const cookieNamePrefix = 'peertube-plugin-livechat-oidc-'
|
||||||
|
const cookieOptions: CookieOptions = {
|
||||||
|
secure: true,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'none',
|
||||||
|
maxAge: 1000 * 60 * 10 // 10 minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initOIDCRouter (options: RegisterServerOptions): Promise<Router> {
|
||||||
|
const { peertubeHelpers, getRouter } = options
|
||||||
|
const router = getRouter()
|
||||||
|
const logger = peertubeHelpers.logger
|
||||||
|
|
||||||
|
router.get('/connect', asyncMiddleware(
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
logger.info('[oidc router] OIDC connect call')
|
||||||
|
try {
|
||||||
|
const oidc = ExternalAuthOIDC.singleton()
|
||||||
|
const oidcClient = await oidc.load()
|
||||||
|
if (!oidcClient) {
|
||||||
|
throw new Error('[oidc router] External Auth OIDC not loaded yet')
|
||||||
|
}
|
||||||
|
|
||||||
|
const authenticationProcess = await oidc.initAuthenticationProcess()
|
||||||
|
res.cookie(cookieNamePrefix + 'code-verifier', authenticationProcess.encryptedCodeVerifier, cookieOptions)
|
||||||
|
res.cookie(cookieNamePrefix + 'state', authenticationProcess.encryptedState, cookieOptions)
|
||||||
|
return res.redirect(authenticationProcess.redirectUrl)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[oidc router] Failed to process the OIDC callback: ' + (err as string))
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
router.get('/cb', asyncMiddleware(
|
||||||
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
|
logger.info('[oidc router] OIDC callback call')
|
||||||
|
try {
|
||||||
|
const oidc = ExternalAuthOIDC.singleton()
|
||||||
|
const oidcClient = await oidc.load()
|
||||||
|
if (!oidcClient) {
|
||||||
|
throw new Error('[oidc router] External Auth OIDC not loaded yet')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userInfos = await oidc.validateAuthenticationProcess(req, cookieNamePrefix)
|
||||||
|
logger.info(JSON.stringify(userInfos)) // FIXME
|
||||||
|
|
||||||
|
res.send('ok')
|
||||||
|
} catch (err) {
|
||||||
|
logger.error('[oidc router] Failed to process the OIDC callback: ' + (err as string))
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
initOIDCRouter
|
||||||
|
}
|
@ -206,7 +206,7 @@ function initExternalAuth (options: RegisterServerOptions): void {
|
|||||||
type: 'html',
|
type: 'html',
|
||||||
name: 'external-auth-custom-oidc-redirect-uris',
|
name: 'external-auth-custom-oidc-redirect-uris',
|
||||||
private: true,
|
private: true,
|
||||||
descriptionHTML: `<ul><li>${escapeHTML(ExternalAuthOIDC.redirectUri(options)) as string}</li></ul>`
|
descriptionHTML: `<ul><li>${escapeHTML(ExternalAuthOIDC.redirectUrl(options)) as string}</li></ul>`
|
||||||
})
|
})
|
||||||
|
|
||||||
registerSetting({
|
registerSetting({
|
||||||
|
Loading…
x
Reference in New Issue
Block a user