Adding some standard OpenID Connect providers (Google, Facebook) (WIP):

* refactoring, to allow several OIDC singletons
* settings for google and facebook
* backend code
This commit is contained in:
John Livingston 2024-04-22 13:03:31 +02:00
parent 4bc2d4fd51
commit 024186ba2c
No known key found for this signature in database
GPG Key ID: B17B5640CE66CDBC
16 changed files with 341 additions and 142 deletions

View File

@ -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: * For anonymous users: new "log in using an external account" dialog, with following options:
* remote Peertube account, * remote Peertube account,
* #128, #363 (**Experimental Feature**): possibility to configure an OpenID Connect provider on the instance level. * #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 * #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: 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). * #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).

View File

@ -236,8 +236,11 @@ function register (clientOptions: RegisterClientOptions): void {
return options.formValues['chat-no-anonymous'] !== false return options.formValues['chat-no-anonymous'] !== false
} }
if (name?.startsWith('external-auth-custom-oidc-')) { if (name?.startsWith('external-auth-')) {
return options.formValues['external-auth-custom-oidc'] !== true const m = name.match(/^external-auth-(\w+)-oidc-/)
if (m) {
return options.formValues['external-auth-' + m[1] + '-oidc'] !== true
}
} }
return false return false

View File

@ -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_label: Verwenden eines OpenID Connect Anbieters
external_auth_custom_oidc_button_label_label: Name für die Anmeldungsschaltfläche 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_discovery_url_label: Discovery URL
external_auth_custom_oidc_client_id_label: Client ID external_auth_oidc_client_id_label: Client ID
external_auth_custom_oidc_client_secret_label: Client secret external_auth_oidc_client_secret_label: Client secret
external_auth_custom_oidc_title: <h4>OpenID Connect</h4> external_auth_custom_oidc_title: <h4>OpenID Connect</h4>
external_auth_custom_oidc_button_label_description: Diese Bezeichnung wird den Nutzern external_auth_custom_oidc_button_label_description: Diese Bezeichnung wird den Nutzern
als Name der Schaltfläche zur Authentifizierung bei diesem OIDC-Anbieter angezeigt. als Name der Schaltfläche zur Authentifizierung bei diesem OIDC-Anbieter angezeigt.
@ -414,6 +414,6 @@ external_auth_description: "<h3>Externe Authentifizierung</h3>\nFür Benutzer, d
kein Peertubekonto haben, können Sie verschiedene Authentifizierungsmodi auf der kein Peertubekonto haben, können Sie verschiedene Authentifizierungsmodi auf der
Grundlage von externen Authentifizierungsanbietern aktivieren.\n" Grundlage von externen Authentifizierungsanbietern aktivieren.\n"
login_external_auth_alert_message: Authentifizierung fehlgeschlagen login_external_auth_alert_message: Authentifizierung fehlgeschlagen
external_auth_custom_oidc_redirect_uris_info_description: "<strong>Callback/Redirect external_auth_oidc_redirect_uris_info_description: "<strong>Callback/Redirect
URI:</strong>\nWenn Sie eine autorisierte Umleitungs-URI für die externe Anwendung URI:</strong>\nWenn Sie eine autorisierte Umleitungs-URI für die externe Anwendung
konfigurieren möchten, fügen Sie bitte diese URL hinzu:\n" konfigurieren möchten, fügen Sie bitte diese URL hinzu:\n"

View File

@ -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_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_discovery_url_label: "Discovery URL"
external_auth_custom_oidc_client_id_label: "Client ID" external_auth_oidc_client_id_label: "Client ID"
external_auth_custom_oidc_client_secret_label: "Client secret" external_auth_oidc_client_secret_label: "Client secret"
external_auth_custom_oidc_redirect_uris_info_description: | external_auth_oidc_redirect_uris_info_description: |
<strong>Callback/Redirect URI:</strong> <strong>Callback/Redirect URI:</strong>
If you want to configure authorized redirection URI on the external Application, please add this url: 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: "<h3>Chat behaviour</h3>" chat_behaviour_description: "<h3>Chat behaviour</h3>"
room_type_label: "Room type" room_type_label: "Room type"

View File

@ -2,8 +2,8 @@ login_remote_peertube_video_not_found_try_anyway_button: Svejedno pokušaj otvor
video na Peertube instanci video na Peertube instanci
external_auth_custom_oidc_button_label_label: Oznaka za gumb povezivanja external_auth_custom_oidc_button_label_label: Oznaka za gumb povezivanja
external_auth_custom_oidc_discovery_url_label: Discovery URL external_auth_custom_oidc_discovery_url_label: Discovery URL
external_auth_custom_oidc_client_id_label: ID klijenta external_auth_oidc_client_id_label: ID klijenta
external_auth_custom_oidc_client_secret_label: Tajna klijenta external_auth_oidc_client_secret_label: Tajna klijenta
videos_list_label: Aktiviraj chat za ova videa videos_list_label: Aktiviraj chat za ova videa
prosody_peertube_uri_label: Peertube URL za API pozive prosody_peertube_uri_label: Peertube URL za API pozive
save: Spremi save: Spremi

View File

@ -85,10 +85,10 @@ async function getConverseJSParams (
) )
} else { } else {
try { try {
const oidc = ExternalAuthOIDC.singleton() const customOidc = ExternalAuthOIDC.singleton('custom')
if (await oidc.isOk()) { if (await customOidc.isOk()) {
const authUrl = oidc.getConnectUrl() const authUrl = customOidc.getConnectUrl()
const buttonLabel = oidc.getButtonLabel() const buttonLabel = customOidc.getButtonLabel()
if (authUrl && buttonLabel) { if (authUrl && buttonLabel) {
externalAuthOIDC = { externalAuthOIDC = {
buttonLabel: buttonLabel, buttonLabel: buttonLabel,

View File

@ -1,14 +1,19 @@
import type { RegisterServerOptions } from '@peertube/peertube-types' import type { RegisterServerOptions } from '@peertube/peertube-types'
import { newResult, TestResult } from './utils' 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<TestResult> { export async function diagExternalAuthOIDC (
test: string,
_options: RegisterServerOptions,
singletonType: ExternalAuthOIDCType,
next: TestResult['next']
): Promise<TestResult> {
const result = newResult(test) const result = newResult(test)
result.label = 'Test External Auth Custom OIDC' result.label = 'Test External Auth OIDC: ' + singletonType
result.next = 'everything-ok' result.next = next
try { try {
const oidc = ExternalAuthOIDC.singleton() const oidc = ExternalAuthOIDC.singleton(singletonType)
if (oidc.isDisabledBySettings()) { if (oidc.isDisabledBySettings()) {
result.ok = true result.ok = true
@ -40,7 +45,7 @@ export async function diagExternalAuthCustomOIDC (test: string, _options: Regist
return result return result
} }
const oidc = ExternalAuthOIDC.singleton() const oidc = ExternalAuthOIDC.singleton(singletonType)
const oidcClient = await oidc.load() const oidcClient = await oidc.load()
if (oidcClient) { if (oidcClient) {
result.messages.push('Discovery URL loaded: ' + JSON.stringify(oidcClient.issuer.metadata)) result.messages.push('Discovery URL loaded: ' + JSON.stringify(oidcClient.issuer.metadata))

View File

@ -4,7 +4,7 @@ import { TestResult, newResult } from './utils'
import { diagDebug } from './debug' import { diagDebug } from './debug'
import { diagProsody } from './prosody' import { diagProsody } from './prosody'
import { diagVideo } from './video' import { diagVideo } from './video'
import { diagExternalAuthCustomOIDC } from './external-auth-custom-oidc' import { diagExternalAuthOIDC } from './external-auth-oidc'
import { helpUrl } from '../../../shared/lib/help' import { helpUrl } from '../../../shared/lib/help'
export async function diag (test: string, options: RegisterServerOptions): Promise<TestResult> { export async function diag (test: string, options: RegisterServerOptions): Promise<TestResult> {
@ -19,7 +19,11 @@ export async function diag (test: string, options: RegisterServerOptions): Promi
} else if (test === 'prosody') { } else if (test === 'prosody') {
result = await diagProsody(test, options) result = await diagProsody(test, options)
} else if (test === 'external-auth-custom-oidc') { } 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') { } else if (test === 'everything-ok') {
result = newResult(test) result = newResult(test)
result.label = 'Everything seems fine' result.label = 'Everything seems fine'

View File

@ -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 { interface MessageWithLevel {
level: 'info' | 'warning' | 'error' level: 'info' | 'warning' | 'error'
@ -15,7 +17,7 @@ export interface TestResult {
title: string title: string
message: string message: string
}> }>
next: nextValue | null next: NextValue | null
ok: boolean ok: boolean
test: string test: string
} }

View File

@ -49,13 +49,14 @@ function getMimeTypeFromArrayBuffer (arrayBuffer: ArrayBuffer): AcceptableAvatar
type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' | 'picture' type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' | 'picture'
interface UnserializedToken { interface UnserializedToken {
type: ExternalAuthOIDCType
jid: string jid: string
password: string password: string
nickname: string nickname: string
expire: Date expire: Date
} }
let singleton: ExternalAuthOIDC | undefined let singletons: Map<ExternalAuthOIDCType, ExternalAuthOIDC> | undefined
async function getRandomBytes (size: number): Promise<Buffer> { async function getRandomBytes (size: number): Promise<Buffer> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -67,10 +68,13 @@ async function getRandomBytes (size: number): Promise<Buffer> {
}) })
} }
type ExternalAuthOIDCType = 'custom' | 'google' | 'facebook'
/** /**
* This class handles the external OpenId Connect provider, if defined. * This class handles the external OpenId Connect provider, if defined.
*/ */
class ExternalAuthOIDC { class ExternalAuthOIDC {
private readonly singletonType: ExternalAuthOIDCType
private readonly enabled: boolean private readonly enabled: boolean
private readonly buttonLabel: string | undefined private readonly buttonLabel: string | undefined
private readonly discoveryUrl: string | undefined private readonly discoveryUrl: string | undefined
@ -82,7 +86,6 @@ class ExternalAuthOIDC {
private readonly externalVirtualhost: string private readonly externalVirtualhost: string
private readonly avatarsDir: string private readonly avatarsDir: string
private readonly avatarsFiles: string[] private readonly avatarsFiles: string[]
private pruneTimer?: NodeJS.Timer
private readonly encryptionOptions = { private readonly encryptionOptions = {
algorithm: 'aes256' as string, algorithm: 'aes256' as string,
@ -113,6 +116,7 @@ class ExternalAuthOIDC {
constructor (params: { constructor (params: {
logger: RegisterServerOptions['peertubeHelpers']['logger'] logger: RegisterServerOptions['peertubeHelpers']['logger']
singletonType: ExternalAuthOIDCType
enabled: boolean enabled: boolean
buttonLabel: string | undefined buttonLabel: string | undefined
discoveryUrl: string | undefined discoveryUrl: string | undefined
@ -132,6 +136,7 @@ class ExternalAuthOIDC {
error: (s) => params.logger.error('[ExternalAuthOIDC] ' + s) error: (s) => params.logger.error('[ExternalAuthOIDC] ' + s)
} }
this.singletonType = params.singletonType
this.enabled = !!params.enabled this.enabled = !!params.enabled
this.secretKey = params.secretKey this.secretKey = params.secretKey
this.redirectUrl = params.redirectUrl 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. * Indicates that the OIDC is disabled.
* Caution: this does not indicate if it is enabled, but poorly configured. * 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. // 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. // The browser will be able to use this encrypted data with the api/configuration/room API.
const tokenContent: UnserializedToken = { const tokenContent: UnserializedToken = {
type: this.type,
jid, jid,
password, password,
nickname, nickname,
// expires in 12 hours (user will just have to do the whole process again). // expires in 12 hours (user will just have to do the whole process again).
expire: (new Date(Date.now() + 12 * 3600 * 1000)) 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) let avatar = await this.readUserInfoPicture(userInfo)
if (!avatar) { if (!avatar) {
@ -447,12 +461,24 @@ class ExternalAuthOIDC {
*/ */
public async unserializeToken (token: string): Promise<UnserializedToken | null> { public async unserializeToken (token: string): Promise<UnserializedToken | null> {
try { 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 decrypted = await this.decrypt(token)
const o = JSON.parse(decrypted) // can fail const o = JSON.parse(decrypted) // can fail
if (typeof o !== 'object') { if (typeof o !== 'object') {
throw new Error('Invalid encrypted data') 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 === '') { if (typeof o.jid !== 'string' || o.jid === '') {
throw new Error('No jid') throw new Error('No jid')
} }
@ -473,6 +499,7 @@ class ExternalAuthOIDC {
} }
return { return {
type: o.type,
jid: o.jid, jid: o.jid,
password: o.password, password: o.password,
nickname: o.nickname, nickname: o.nickname,
@ -623,107 +650,146 @@ class ExternalAuthOIDC {
} }
/** /**
* Starts an interval timer to prune external users from Prosody. * frees the singletons
* @param options Peertube server options.
*/ */
public startPruneTimer (options: RegisterServerOptions): void { public static async destroySingletons (): Promise<void> {
this.stopPruneTimer() // just in case... if (!singletons) { return }
// every hour (every minutes in debug mode) stopPruneTimer()
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.`)
// eslint-disable-next-line @typescript-eslint/no-misused-promises const keys = singletons.keys()
this.pruneTimer = setInterval(async () => { for (const key of keys) {
try { const singleton = singletons.get(key)
if (!await this.isOk()) { return } if (!singleton) { continue }
singletons.delete(key)
}
this.logger.info('Pruning external users...') singletons = undefined
await pruneUsers(options)
} catch (err) {
this.logger.error('Error while pruning external users: ' + (err as string))
}
}, pruneInterval)
} }
/** /**
* Stops the prune timer. * Instanciate all singletons.
* Note: no need to destroy singletons before creating new ones.
*/ */
public stopPruneTimer (): void { public static async initSingletons (options: RegisterServerOptions): Promise<void> {
if (!this.pruneTimer) { return } const prosodyDomain = await getProsodyDomain(options)
clearInterval(this.pruneTimer) // FIXME: this is not optimal to call here.
this.pruneTimer = undefined const prosodyFilePaths = await getProsodyFilePaths(options)
}
/**
* frees the singleton
*/
public static async destroySingleton (): Promise<void> {
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<ExternalAuthOIDC> {
const settings = await options.settingsManager.getSettings([ const settings = await options.settingsManager.getSettings([
'external-auth-custom-oidc', 'external-auth-custom-oidc',
'external-auth-custom-oidc-button-label', 'external-auth-custom-oidc-button-label',
'external-auth-custom-oidc-discovery-url', 'external-auth-custom-oidc-discovery-url',
'external-auth-custom-oidc-client-id', '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 init = async function initSingleton (
const secretKey = (await getRandomBytes(16)).toString('hex') singletonType: ExternalAuthOIDCType,
buttonLabel: string | undefined,
discoveryUrl: string | undefined
): Promise<void> {
// 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. singletons ??= new Map<ExternalAuthOIDCType, ExternalAuthOIDC>()
const prosodyFilePaths = await getProsodyFilePaths(options) singletons.set(singletonType, singleton)
}
singleton = new ExternalAuthOIDC({ await Promise.all([
logger: options.peertubeHelpers.logger, init(
enabled: settings['external-auth-custom-oidc'] as boolean, 'custom',
buttonLabel: settings['external-auth-custom-oidc-button-label'] as string | undefined, settings['external-auth-custom-oidc-button-label'] as string | undefined,
discoveryUrl: settings['external-auth-custom-oidc-discovery-url'] as string | undefined, 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, init(
secretKey, 'google',
connectUrl: ExternalAuthOIDC.connectUrl(options), 'Google',
redirectUrl: ExternalAuthOIDC.redirectUrl(options), 'https://accounts.google.com'
externalVirtualhost: 'external.' + prosodyDomain, ),
avatarsDir: prosodyFilePaths.avatars, init(
avatarsFiles: prosodyFilePaths.avatarsFiles 'facebook',
}) 'Facebook',
'https://www.facebook.com'
)
])
singleton.startPruneTimer(options) startPruneTimer(options)
return singleton
} }
/** /**
* Gets the singleton, or raise an exception if it is too soon. * Gets the singleton, or raise an exception if it is too soon.
* @param ExternalAuthOIDCType The singleton type.
* @throws Error * @throws Error
* @returns the singleton * @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) { if (!singleton) {
throw new Error('ExternalAuthOIDC singleton is not initialized yet') throw new Error(`ExternalAuthOIDC singleton "${singletonType}" is not initiliazed yet`)
} }
return singleton 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. * Get the uri to start the authentication process.
* @param options Peertube server options * @param options Peertube server options
* @returns the uri * @returns the uri
*/ */
public static connectUrl (options: RegisterServerOptions): string { public static connectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string {
const path = getBaseRouterRoute(options) + 'oidc/connect' if (!/^\w+$/.test(type)) {
throw new Error('Invalid singleton type')
}
const path = getBaseRouterRoute(options) + 'oidc/' + type + '/connect'
return canonicalizePluginUri(options, path, { return canonicalizePluginUri(options, path, {
removePluginVersion: true removePluginVersion: true
}) })
@ -734,14 +800,67 @@ class ExternalAuthOIDC {
* @param options Peertube server optiosn * @param options Peertube server optiosn
* @returns the uri * @returns the uri
*/ */
public static redirectUrl (options: RegisterServerOptions): string { public static redirectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string {
const path = getBaseRouterRoute(options) + 'oidc/cb' if (!/^\w+$/.test(type)) {
throw new Error('Invalid singleton type')
}
const path = getBaseRouterRoute(options) + 'oidc/' + type + '/cb'
return canonicalizePluginUri(options, path, { return canonicalizePluginUri(options, path, {
removePluginVersion: true removePluginVersion: true
}) })
} }
} }
export { let pruneTimer: NodeJS.Timer | undefined
ExternalAuthOIDC
/**
* 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
} }

View File

@ -197,9 +197,13 @@ async function getProsodyConfig (options: RegisterServerOptionsV5): Promise<Pros
let useExternal: boolean = false let useExternal: boolean = false
try { try {
const oidc = ExternalAuthOIDC.singleton() const oidcs = ExternalAuthOIDC.allSingletons()
if (await oidc.isOk()) { for (const oidc of oidcs) {
useExternal = true if (await oidc.isOk()) {
// At least one external authentcation => we must enable the external virtual host.
useExternal = true
break
}
} }
} catch (err) { } catch (err) {
logger.error(err) logger.error(err)

View File

@ -21,8 +21,8 @@ async function initAuthApiRouter (options: RegisterServerOptions, router: Router
const token = req.header('X-Peertube-Plugin-Livechat-External-Auth-OIDC-Token') const token = req.header('X-Peertube-Plugin-Livechat-External-Auth-OIDC-Token')
if (token) { if (token) {
try { try {
const oidc = ExternalAuthOIDC.singleton() const oidc = ExternalAuthOIDC.singletonForToken(token)
if (await oidc.isOk()) { if (oidc && await oidc.isOk()) {
const unserializedToken = await oidc.unserializeToken(token) const unserializedToken = await oidc.unserializeToken(token)
if (unserializedToken) { if (unserializedToken) {
res.status(200).json({ res.status(200).json({

View File

@ -36,11 +36,12 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
const router = getRouter() const router = getRouter()
const logger = peertubeHelpers.logger const logger = peertubeHelpers.logger
router.get('/connect', asyncMiddleware( router.get('/:type?/connect', asyncMiddleware(
async (req: Request, res: Response, next: NextFunction) => { 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 { try {
const oidc = ExternalAuthOIDC.singleton() const oidc = ExternalAuthOIDC.singleton(singletonType)
const oidcClient = await oidc.load() const oidcClient = await oidc.load()
if (!oidcClient) { if (!oidcClient) {
throw new Error('[oidc router] External Auth OIDC not loaded yet') throw new Error('[oidc router] External Auth OIDC not loaded yet')
@ -57,9 +58,10 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
const cbHandler = asyncMiddleware( const cbHandler = asyncMiddleware(
async (req: Request, res: Response, _next: NextFunction) => { 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 { try {
const oidc = ExternalAuthOIDC.singleton() const oidc = ExternalAuthOIDC.singleton(singletonType)
const oidcClient = await oidc.load() const oidcClient = await oidc.load()
if (!oidcClient) { if (!oidcClient) {
throw new Error('[oidc router] External Auth OIDC not loaded yet') throw new Error('[oidc router] External Auth OIDC not loaded yet')
@ -102,8 +104,8 @@ async function initOIDCRouter (options: RegisterServerOptions): Promise<Router>
} }
} }
) )
router.get('/cb', cbHandler) router.get('/:type?/cb', cbHandler)
router.post('/cb', cbHandler) router.post('/:type?/cb', cbHandler)
return router return router
} }

View File

@ -3,7 +3,7 @@ import type { ConverseJSTheme } from '../../shared/lib/types'
import { ensureProsodyRunning } from './prosody/ctl' import { ensureProsodyRunning } from './prosody/ctl'
import { RoomChannel } from './room-channel' import { RoomChannel } from './room-channel'
import { BotsCtl } from './bots/ctl' import { BotsCtl } from './bots/ctl'
import { ExternalAuthOIDC } from './external-auth/oidc' import { ExternalAuthOIDC, ExternalAuthOIDCType } from './external-auth/oidc'
import { loc } from './loc' import { loc } from './loc'
const escapeHTML = require('escape-html') const escapeHTML = require('escape-html')
@ -22,31 +22,35 @@ async function initSettings (options: RegisterServerOptions): Promise<void> {
initThemingSettings(options) initThemingSettings(options)
initChatServerAdvancedSettings(options) initChatServerAdvancedSettings(options)
await ExternalAuthOIDC.initSingleton(options) await ExternalAuthOIDC.initSingletons(options)
const loadOidc = (): void => { const loadOidcs = (): void => {
try { const oidcs = ExternalAuthOIDC.allSingletons()
const oidc = ExternalAuthOIDC.singleton() for (const oidc of oidcs) {
oidc.isOk().then( try {
() => { const type = oidc.type
logger.info('Loading External Auth OIDC...') oidc.isOk().then(
oidc.load().then( () => {
() => { logger.info(`Loading External Auth OIDC ${type}...`)
logger.info('External Auth OIDC loaded') oidc.load().then(
}, () => {
() => { logger.info(`External Auth OIDC ${type} loaded`)
logger.error('Loading the External Auth OIDC failed') },
} () => {
) logger.error(`Loading the External Auth OIDC ${type} failed`)
}, }
() => { )
logger.info('No valid External Auth OIDC, nothing loaded') },
} () => {
) logger.info(`No valid External Auth OIDC ${type}, nothing loaded`)
} catch (err) { }
logger.error(err as string) )
} 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'] let currentProsodyRoomtype = (await settingsManager.getSettings(['prosody-room-type']))['prosody-room-type']
@ -56,9 +60,9 @@ async function initSettings (options: RegisterServerOptions): Promise<void> {
// To avoid race condition, we will just stop and start the bots at every settings saving. // To avoid race condition, we will just stop and start the bots at every settings saving.
await BotsCtl.destroySingleton() await BotsCtl.destroySingleton()
await BotsCtl.initSingleton(options) 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') peertubeHelpers.logger.info('Saving settings, ensuring prosody is running')
await ensureProsodyRunning(options) await ensureProsodyRunning(options)
@ -206,13 +210,13 @@ function initExternalAuth (options: RegisterServerOptions): void {
type: 'html', type: 'html',
name: 'external-auth-custom-oidc-redirect-uris-info', name: 'external-auth-custom-oidc-redirect-uris-info',
private: true, private: true,
descriptionHTML: loc('external_auth_custom_oidc_redirect_uris_info_description') descriptionHTML: loc('external_auth_oidc_redirect_uris_info_description')
}) })
registerSetting({ registerSetting({
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.redirectUrl(options)) as string}</li></ul>` descriptionHTML: `<ul><li>${escapeHTML(ExternalAuthOIDC.redirectUrl(options, 'custom')) as string}</li></ul>`
}) })
registerSetting({ registerSetting({
@ -232,15 +236,15 @@ function initExternalAuth (options: RegisterServerOptions): void {
}) })
registerSetting({ registerSetting({
name: 'external-auth-custom-oidc-client-id', name: 'external-auth-custom-oidc-client-id',
label: loc('external_auth_custom_oidc_client_id_label'), label: loc('external_auth_oidc_client_id_label'),
// descriptionHTML: loc('external_auth_custom_oidc_client_id_description'), // descriptionHTML: loc('external_auth_oidc_client_id_description'),
type: 'input', type: 'input',
private: true private: true
}) })
registerSetting({ registerSetting({
name: 'external-auth-custom-oidc-client-secret', name: 'external-auth-custom-oidc-client-secret',
label: loc('external_auth_custom_oidc_client_secret_label'), label: loc('external_auth_oidc_client_secret_label'),
// descriptionHTML: loc('external_auth_custom_oidc_client_secret_description'), // descriptionHTML: loc('external_auth_oidc_client_secret_description'),
type: 'input-password', type: 'input-password',
private: true private: true
}) })
@ -250,6 +254,52 @@ function initExternalAuth (options: RegisterServerOptions): void {
// - user-name property // - user-name property
// - display-name property // - display-name property
// - picture 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: `<ul><li>${escapeHTML(redirectUrl) as string}</li></ul>`
})
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
})
}
} }
/** /**

View File

@ -94,7 +94,7 @@ async function unregister (): Promise<any> {
await RoomChannel.destroySingleton() await RoomChannel.destroySingleton()
await BotConfiguration.destroySingleton() await BotConfiguration.destroySingleton()
await ExternalAuthOIDC.destroySingleton() await ExternalAuthOIDC.destroySingletons()
const module = __filename const module = __filename
OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`) OPTIONS?.peertubeHelpers.logger.info(`Unloading module ${module}...`)

View File

@ -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. Note: if your provider use the standard `/.well-known/openid-configuration` path, you can omit it.
For example `https://accounts.google.com` will work. 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. 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. You application Client secret.