2024-05-23 09:42:14 +00:00
|
|
|
// SPDX-FileCopyrightText: 2024 John Livingston <https://www.john-livingston.fr/>
|
|
|
|
//
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
import type { RegisterServerOptions } from '@peertube/peertube-types'
|
2024-04-17 10:09:25 +00:00
|
|
|
import type { Request, Response, CookieOptions } from 'express'
|
2024-04-18 16:25:14 +00:00
|
|
|
import type { ExternalAccountInfos, AcceptableAvatarMimeType } from './types'
|
2024-04-22 12:28:55 +00:00
|
|
|
import type { ExternalAuthOIDCType } from '../../../shared/lib/types'
|
2024-04-17 13:12:37 +00:00
|
|
|
import { ExternalAuthenticationError } from './error'
|
2024-04-16 15:18:14 +00:00
|
|
|
import { getBaseRouterRoute } from '../helpers'
|
|
|
|
import { canonicalizePluginUri } from '../uri/canonicalize'
|
2024-04-17 13:12:37 +00:00
|
|
|
import { getProsodyDomain } from '../prosody/config/domain'
|
2024-04-18 18:16:44 +00:00
|
|
|
import { pruneUsers } from '../prosody/api/manage-users'
|
2024-04-18 16:48:32 +00:00
|
|
|
import { getProsodyFilePaths } from '../prosody/config'
|
2024-04-18 18:16:44 +00:00
|
|
|
import { debugNumericParameter } from '../debug'
|
2024-04-16 16:49:23 +00:00
|
|
|
import { createCipheriv, createDecipheriv, randomBytes, Encoding } from 'node:crypto'
|
2024-04-17 13:12:37 +00:00
|
|
|
import { Issuer, BaseClient, generators, UnknownObject } from 'openid-client'
|
|
|
|
import { JID } from '@xmpp/jid'
|
|
|
|
import { URL } from 'url'
|
2024-04-18 16:48:32 +00:00
|
|
|
import * as path from 'path'
|
|
|
|
import * as fs from 'fs'
|
2024-04-17 13:12:37 +00:00
|
|
|
|
2024-04-18 16:25:14 +00:00
|
|
|
const got = require('got')
|
|
|
|
|
|
|
|
function getMimeTypeFromArrayBuffer (arrayBuffer: ArrayBuffer): AcceptableAvatarMimeType | null {
|
|
|
|
const uint8arr = new Uint8Array(arrayBuffer)
|
|
|
|
|
|
|
|
const len = 4
|
|
|
|
if (uint8arr.length >= len) {
|
|
|
|
const signatureArr = new Array(len)
|
|
|
|
for (let i = 0; i < len; i++) {
|
|
|
|
signatureArr[i] = (new Uint8Array(arrayBuffer))[i].toString(16)
|
|
|
|
}
|
|
|
|
const signature = signatureArr.join('').toUpperCase()
|
|
|
|
|
|
|
|
switch (signature) {
|
|
|
|
case '89504E47':
|
|
|
|
return 'image/png'
|
|
|
|
case '47494638':
|
|
|
|
return 'image/gif'
|
|
|
|
case 'FFD8FFDB':
|
|
|
|
case 'FFD8FFE0':
|
|
|
|
return 'image/jpeg'
|
|
|
|
case '52494646':
|
|
|
|
case '57454250':
|
|
|
|
return 'image/webp'
|
|
|
|
default:
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
type UserInfoField = 'username' | 'last_name' | 'first_name' | 'nickname' | 'picture'
|
2024-04-15 16:29:09 +00:00
|
|
|
|
2024-04-17 16:30:39 +00:00
|
|
|
interface UnserializedToken {
|
2024-04-22 11:03:31 +00:00
|
|
|
type: ExternalAuthOIDCType
|
2024-04-17 16:30:39 +00:00
|
|
|
jid: string
|
|
|
|
password: string
|
|
|
|
nickname: string
|
|
|
|
expire: Date
|
|
|
|
}
|
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
let singletons: Map<ExternalAuthOIDCType, ExternalAuthOIDC> | undefined
|
2024-04-15 16:29:09 +00:00
|
|
|
|
2024-04-16 16:49:23 +00:00
|
|
|
async function getRandomBytes (size: number): Promise<Buffer> {
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
randomBytes(size, (err, buf) => {
|
|
|
|
if (err) return reject(err)
|
|
|
|
|
|
|
|
return resolve(buf)
|
|
|
|
})
|
|
|
|
})
|
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
/**
|
|
|
|
* This class handles the external OpenId Connect provider, if defined.
|
|
|
|
*/
|
|
|
|
class ExternalAuthOIDC {
|
2024-04-22 11:03:31 +00:00
|
|
|
private readonly singletonType: ExternalAuthOIDCType
|
2024-04-15 16:29:09 +00:00
|
|
|
private readonly enabled: boolean
|
|
|
|
private readonly buttonLabel: string | undefined
|
|
|
|
private readonly discoveryUrl: string | undefined
|
|
|
|
private readonly clientId: string | undefined
|
|
|
|
private readonly clientSecret: string | undefined
|
2024-04-16 16:49:23 +00:00
|
|
|
private readonly secretKey: string
|
|
|
|
private readonly redirectUrl: string
|
|
|
|
private readonly connectUrl: string
|
2024-04-17 13:12:37 +00:00
|
|
|
private readonly externalVirtualhost: string
|
2024-04-18 16:48:32 +00:00
|
|
|
private readonly avatarsDir: string
|
|
|
|
private readonly avatarsFiles: string[]
|
2024-04-16 16:49:23 +00:00
|
|
|
|
|
|
|
private readonly encryptionOptions = {
|
|
|
|
algorithm: 'aes256' as string,
|
|
|
|
inputEncoding: 'utf8' as Encoding,
|
|
|
|
outputEncoding: 'hex' as Encoding
|
|
|
|
}
|
2024-04-16 15:18:14 +00:00
|
|
|
|
2024-04-17 10:09:25 +00:00
|
|
|
private readonly cookieNamePrefix: string = 'peertube-plugin-livechat-oidc-'
|
|
|
|
private readonly cookieOptions: CookieOptions = {
|
|
|
|
secure: true,
|
|
|
|
httpOnly: true,
|
|
|
|
sameSite: 'none',
|
|
|
|
maxAge: 1000 * 60 * 10 // 10 minutes
|
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
private ok: boolean | undefined
|
2024-04-16 15:18:14 +00:00
|
|
|
|
2024-04-16 09:43:38 +00:00
|
|
|
private issuer: Issuer | undefined | null
|
2024-04-16 15:18:14 +00:00
|
|
|
private client: BaseClient | undefined | null
|
2024-04-17 13:12:37 +00:00
|
|
|
private providerHostName?: string
|
2024-04-16 15:18:14 +00:00
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
protected readonly logger: {
|
|
|
|
debug: (s: string) => void
|
|
|
|
info: (s: string) => void
|
|
|
|
warn: (s: string) => void
|
|
|
|
error: (s: string) => void
|
|
|
|
}
|
|
|
|
|
2024-04-17 13:12:37 +00:00
|
|
|
constructor (params: {
|
|
|
|
logger: RegisterServerOptions['peertubeHelpers']['logger']
|
2024-04-22 11:03:31 +00:00
|
|
|
singletonType: ExternalAuthOIDCType
|
2024-04-17 13:12:37 +00:00
|
|
|
enabled: boolean
|
|
|
|
buttonLabel: string | undefined
|
|
|
|
discoveryUrl: string | undefined
|
|
|
|
clientId: string | undefined
|
|
|
|
clientSecret: string | undefined
|
|
|
|
secretKey: string
|
|
|
|
connectUrl: string
|
2024-04-16 16:49:23 +00:00
|
|
|
redirectUrl: string
|
2024-04-17 13:12:37 +00:00
|
|
|
externalVirtualhost: string
|
2024-04-18 16:48:32 +00:00
|
|
|
avatarsDir: string
|
|
|
|
avatarsFiles: string[]
|
2024-04-17 13:12:37 +00:00
|
|
|
}) {
|
2024-04-15 16:29:09 +00:00
|
|
|
this.logger = {
|
2024-04-17 13:12:37 +00:00
|
|
|
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)
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
this.singletonType = params.singletonType
|
2024-04-17 13:12:37 +00:00
|
|
|
this.enabled = !!params.enabled
|
|
|
|
this.secretKey = params.secretKey
|
|
|
|
this.redirectUrl = params.redirectUrl
|
|
|
|
this.connectUrl = params.connectUrl
|
|
|
|
this.externalVirtualhost = params.externalVirtualhost
|
2024-04-18 16:48:32 +00:00
|
|
|
this.avatarsDir = params.avatarsDir
|
|
|
|
this.avatarsFiles = params.avatarsFiles
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
if (this.enabled) {
|
2024-04-17 13:12:37 +00:00
|
|
|
this.buttonLabel = params.buttonLabel
|
|
|
|
this.discoveryUrl = params.discoveryUrl
|
|
|
|
this.clientId = params.clientId
|
|
|
|
this.clientSecret = params.clientSecret
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
/**
|
|
|
|
* This singleton type.
|
|
|
|
*/
|
|
|
|
public get type (): ExternalAuthOIDCType {
|
|
|
|
return this.singletonType
|
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
/**
|
|
|
|
* Indicates that the OIDC is disabled.
|
|
|
|
* Caution: this does not indicate if it is enabled, but poorly configured.
|
|
|
|
* This method should only be used in the diagnostic tool.
|
|
|
|
*/
|
|
|
|
isDisabledBySettings (): boolean {
|
|
|
|
return !this.enabled
|
|
|
|
}
|
|
|
|
|
2024-04-16 15:18:14 +00:00
|
|
|
/**
|
|
|
|
* Get the url to open for external authentication.
|
|
|
|
* Note: If the singleton is not loaded yet, returns null.
|
|
|
|
* This means that the feature will only be available when the load as complete.
|
|
|
|
* @returns the url to open
|
|
|
|
*/
|
2024-04-16 16:49:23 +00:00
|
|
|
getConnectUrl (): string | null {
|
|
|
|
if (!this.client) {
|
|
|
|
// Not loaded yet
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
return this.connectUrl
|
2024-04-16 15:18:14 +00:00
|
|
|
}
|
|
|
|
|
2024-04-16 09:43:38 +00:00
|
|
|
/**
|
|
|
|
* Get the button
|
|
|
|
* @returns Button label
|
|
|
|
*/
|
|
|
|
getButtonLabel (): string | undefined {
|
|
|
|
return this.buttonLabel
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Get the discovery URL
|
|
|
|
* @returns discoveryURL
|
|
|
|
*/
|
|
|
|
getDiscoveryUrl (): string | undefined {
|
|
|
|
return this.discoveryUrl
|
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
/**
|
|
|
|
* Indicates if the OIDC provider is correctly configured.
|
|
|
|
* @param force If true, all checks will be forced again.
|
|
|
|
*/
|
|
|
|
async isOk (force?: boolean): Promise<boolean> {
|
|
|
|
// If we already checked it, just return the previous value.
|
|
|
|
if (!force && this.ok !== undefined) { return this.ok }
|
|
|
|
|
|
|
|
this.ok = (await this.check()).length === 0
|
|
|
|
return this.ok
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Check the configuration.
|
|
|
|
* Returns an error list.
|
|
|
|
* If error list is empty, consider the OIDC is correctly configured.
|
2024-04-17 13:12:37 +00:00
|
|
|
* Note: this function also fills this.providerHostName (as it also parse the discoveryUrl).
|
2024-04-15 16:29:09 +00:00
|
|
|
*/
|
|
|
|
async check (): Promise<string[]> {
|
|
|
|
if (!this.enabled) {
|
|
|
|
this.logger.debug('OIDC is disabled')
|
|
|
|
return ['OIDC disabled']
|
|
|
|
}
|
|
|
|
|
|
|
|
const errors: string[] = []
|
2024-04-16 09:43:38 +00:00
|
|
|
if ((this.buttonLabel ?? '') === '') {
|
2024-04-15 16:29:09 +00:00
|
|
|
errors.push('Missing button label')
|
|
|
|
}
|
2024-04-16 09:43:38 +00:00
|
|
|
if ((this.discoveryUrl ?? '') === '') {
|
2024-04-15 16:29:09 +00:00
|
|
|
errors.push('Missing discovery url')
|
|
|
|
} else {
|
|
|
|
try {
|
2024-04-16 09:43:38 +00:00
|
|
|
const uri = new URL(this.discoveryUrl ?? 'wrong url')
|
2024-04-15 16:29:09 +00:00
|
|
|
this.logger.debug('OIDC Discovery url is valid: ' + uri.toString())
|
2024-04-17 13:12:37 +00:00
|
|
|
|
|
|
|
this.providerHostName = uri.hostname
|
2024-04-15 16:29:09 +00:00
|
|
|
} catch (err) {
|
|
|
|
errors.push('Invalid discovery url')
|
|
|
|
}
|
|
|
|
}
|
2024-04-16 09:43:38 +00:00
|
|
|
if ((this.clientId ?? '') === '') {
|
2024-04-15 16:29:09 +00:00
|
|
|
errors.push('Missing client id')
|
|
|
|
}
|
2024-04-16 09:43:38 +00:00
|
|
|
if ((this.clientSecret ?? '') === '') {
|
2024-04-15 16:29:09 +00:00
|
|
|
errors.push('Missing client secret')
|
|
|
|
}
|
|
|
|
|
|
|
|
if (errors.length) {
|
|
|
|
this.logger.error('OIDC is not ok: ' + JSON.stringify(errors))
|
|
|
|
}
|
|
|
|
return errors
|
|
|
|
}
|
|
|
|
|
2024-04-16 09:43:38 +00:00
|
|
|
/**
|
2024-04-16 15:18:14 +00:00
|
|
|
* Ensure the issuer is loaded, and the client instanciated.
|
2024-04-16 09:43:38 +00:00
|
|
|
* @returns the issuer if enabled
|
|
|
|
*/
|
2024-04-16 15:18:14 +00:00
|
|
|
async load (): Promise<BaseClient | null> {
|
|
|
|
// this.client === null means we already tried, but it failed.
|
|
|
|
if (this.client !== undefined) { return this.client }
|
2024-04-16 09:43:38 +00:00
|
|
|
|
2024-04-16 15:18:14 +00:00
|
|
|
if (!await this.isOk()) {
|
|
|
|
this.issuer = null
|
|
|
|
this.client = null
|
|
|
|
return null
|
|
|
|
}
|
2024-04-16 09:43:38 +00:00
|
|
|
|
|
|
|
try {
|
|
|
|
this.issuer = await Issuer.discover(this.discoveryUrl as string)
|
|
|
|
this.logger.debug(`Discovered issuer, metadata are: ${JSON.stringify(this.issuer.metadata)}`)
|
|
|
|
} catch (err) {
|
|
|
|
this.logger.error(err as string)
|
|
|
|
this.issuer = null
|
2024-04-16 15:18:14 +00:00
|
|
|
this.client = null
|
2024-04-16 09:43:38 +00:00
|
|
|
}
|
2024-04-16 15:18:14 +00:00
|
|
|
|
|
|
|
if (!this.issuer) {
|
|
|
|
this.client = null
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
|
|
|
try {
|
|
|
|
this.client = new this.issuer.Client({
|
|
|
|
client_id: this.clientId as string,
|
|
|
|
client_secret: this.clientSecret as string,
|
2024-04-16 16:49:23 +00:00
|
|
|
redirect_uris: [this.redirectUrl],
|
2024-04-16 15:18:14 +00:00
|
|
|
response_types: ['code']
|
|
|
|
})
|
|
|
|
} catch (err) {
|
|
|
|
this.logger.error(err as string)
|
|
|
|
this.client = null
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!this.client) {
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
|
2024-04-16 16:49:23 +00:00
|
|
|
return this.client
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns everything that is needed to instanciate an OIDC authentication.
|
2024-04-17 10:09:25 +00:00
|
|
|
* @param req express request
|
|
|
|
* @param res express response. Will add some cookies.
|
|
|
|
* @return the url to which redirect
|
2024-04-16 16:49:23 +00:00
|
|
|
*/
|
2024-04-17 10:09:25 +00:00
|
|
|
async initAuthenticationProcess (req: Request, res: Response): Promise<string> {
|
2024-04-16 16:49:23 +00:00
|
|
|
if (!this.client) {
|
|
|
|
throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.initAuthentication')
|
2024-04-16 15:18:14 +00:00
|
|
|
}
|
|
|
|
|
2024-04-16 16:49:23 +00:00
|
|
|
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
|
|
|
|
})
|
|
|
|
|
2024-04-17 10:09:25 +00:00
|
|
|
res.cookie(this.cookieNamePrefix + 'code-verifier', encryptedCodeVerifier, this.cookieOptions)
|
|
|
|
res.cookie(this.cookieNamePrefix + 'state', encryptedState, this.cookieOptions)
|
|
|
|
return redirectUrl
|
2024-04-16 16:49:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Authentication process callback.
|
2024-04-17 10:09:25 +00:00
|
|
|
* @param req The ExpressJS request object. Will read cookies.
|
2024-04-17 13:12:37 +00:00
|
|
|
* @throws ExternalAuthenticationError when a specific message must be displayed to enduser.
|
|
|
|
* @throws Error in other cases.
|
2024-04-16 16:49:23 +00:00
|
|
|
* @return user info
|
|
|
|
*/
|
2024-04-17 13:12:37 +00:00
|
|
|
async validateAuthenticationProcess (req: Request): Promise<ExternalAccountInfos> {
|
2024-04-16 16:49:23 +00:00
|
|
|
if (!this.client) {
|
|
|
|
throw new Error('External Auth OIDC not loaded yet, too soon to call oidc.validateAuthenticationProcess')
|
|
|
|
}
|
|
|
|
|
2024-04-17 10:09:25 +00:00
|
|
|
const encryptedCodeVerifier = req.cookies[this.cookieNamePrefix + 'code-verifier']
|
2024-04-16 16:49:23 +00:00
|
|
|
if (!encryptedCodeVerifier) {
|
|
|
|
throw new Error('Received callback but code verifier not found in request cookies.')
|
|
|
|
}
|
|
|
|
|
2024-04-17 10:09:25 +00:00
|
|
|
const encryptedState = req.cookies[this.cookieNamePrefix + 'state']
|
2024-04-16 16:49:23 +00:00
|
|
|
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)
|
2024-04-17 13:12:37 +00:00
|
|
|
|
2024-04-18 08:23:52 +00:00
|
|
|
this.logger.debug('User info: ' + JSON.stringify(userInfo))
|
|
|
|
|
2024-04-17 13:12:37 +00:00
|
|
|
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).
|
2024-04-17 16:30:39 +00:00
|
|
|
const jid = this.computeJID(username).toString(false)
|
2024-04-17 13:12:37 +00:00
|
|
|
|
|
|
|
// 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')
|
|
|
|
|
2024-04-17 16:30:39 +00:00
|
|
|
// 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.
|
|
|
|
const tokenContent: UnserializedToken = {
|
2024-04-22 11:03:31 +00:00
|
|
|
type: this.type,
|
2024-04-17 16:30:39 +00:00
|
|
|
jid,
|
|
|
|
password,
|
|
|
|
nickname,
|
|
|
|
// expires in 12 hours (user will just have to do the whole process again).
|
|
|
|
expire: (new Date(Date.now() + 12 * 3600 * 1000))
|
|
|
|
}
|
2024-04-22 11:03:31 +00:00
|
|
|
// 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))
|
2024-04-17 16:30:39 +00:00
|
|
|
|
2024-04-18 16:48:32 +00:00
|
|
|
let avatar = await this.readUserInfoPicture(userInfo)
|
|
|
|
if (!avatar) {
|
|
|
|
this.logger.debug('No avatar from the external service, getting a random one.')
|
|
|
|
avatar = await this.getRandomAvatar()
|
|
|
|
}
|
2024-04-18 16:25:14 +00:00
|
|
|
|
2024-04-17 13:12:37 +00:00
|
|
|
return {
|
2024-04-17 16:30:39 +00:00
|
|
|
jid,
|
2024-04-17 13:12:37 +00:00
|
|
|
nickname,
|
2024-04-17 16:30:39 +00:00
|
|
|
password,
|
2024-04-18 16:25:14 +00:00
|
|
|
token,
|
|
|
|
avatar
|
2024-04-17 13:12:37 +00:00
|
|
|
}
|
2024-04-16 16:49:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
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)
|
2024-04-16 09:43:38 +00:00
|
|
|
}
|
|
|
|
|
2024-04-17 16:30:39 +00:00
|
|
|
/**
|
|
|
|
* Decrypt and unserialize a token associated to a previous authentication.
|
|
|
|
* @param token the token stored by the browser.
|
|
|
|
* @return authentication informations, or null if:
|
|
|
|
* if the token is expired, if the token is invalid.
|
|
|
|
* Can also fail (and return null) when server was restarted, or settings saved, as the secret key may have changed
|
|
|
|
* (this is not an issue, users just have to start the process again).
|
|
|
|
*/
|
|
|
|
public async unserializeToken (token: string): Promise<UnserializedToken | null> {
|
|
|
|
try {
|
2024-04-22 11:03:31 +00:00
|
|
|
// 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)
|
|
|
|
|
2024-04-17 16:30:39 +00:00
|
|
|
const decrypted = await this.decrypt(token)
|
|
|
|
const o = JSON.parse(decrypted) // can fail
|
|
|
|
|
|
|
|
if (typeof o !== 'object') {
|
|
|
|
throw new Error('Invalid encrypted data')
|
|
|
|
}
|
2024-04-22 11:03:31 +00:00
|
|
|
|
|
|
|
if (o.type !== this.type) {
|
|
|
|
throw new Error('Token type is not the expected one')
|
|
|
|
}
|
|
|
|
|
2024-04-17 16:30:39 +00:00
|
|
|
if (typeof o.jid !== 'string' || o.jid === '') {
|
|
|
|
throw new Error('No jid')
|
|
|
|
}
|
|
|
|
if (typeof o.password !== 'string' || o.password === '') {
|
|
|
|
throw new Error('No password')
|
|
|
|
}
|
|
|
|
if (typeof o.nickname !== 'string' || o.nickname === '') {
|
|
|
|
throw new Error('No nickname')
|
|
|
|
}
|
|
|
|
|
|
|
|
const expire = new Date(Date.parse(o.expire))
|
|
|
|
if (!(expire instanceof Date) || isNaN(expire.getTime())) {
|
|
|
|
throw new Error('Invalid expire date')
|
|
|
|
}
|
|
|
|
|
|
|
|
if (expire <= new Date()) {
|
|
|
|
throw new Error('Token expired')
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
2024-04-22 11:03:31 +00:00
|
|
|
type: o.type,
|
2024-04-17 16:30:39 +00:00
|
|
|
jid: o.jid,
|
|
|
|
password: o.password,
|
|
|
|
nickname: o.nickname,
|
|
|
|
expire
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
// This is not an error, as there are many legitimate cases (token expired, ...)
|
|
|
|
this.logger.info('Cant unserialize the token: ' + (err as string))
|
|
|
|
return null
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-17 13:12:37 +00:00
|
|
|
/**
|
|
|
|
* Get an attribute from the userInfos.
|
2024-04-18 08:23:52 +00:00
|
|
|
* @param userInfos userInfos returned by the remote OIDC Provider.
|
|
|
|
* @param normalizedFieldName the field to get (internal normalized name).
|
|
|
|
* @returns the value if present.
|
2024-04-17 13:12:37 +00:00
|
|
|
*/
|
2024-04-18 08:23:52 +00:00
|
|
|
private readUserInfoField (userInfos: UnknownObject, normalizedFieldName: UserInfoField): string | undefined {
|
|
|
|
// FIXME: do some explicit attribute mapping? (add settings for that?)
|
|
|
|
// For now, we will try some standards field names.
|
|
|
|
|
|
|
|
const guesses: string[] = [normalizedFieldName]
|
|
|
|
|
|
|
|
// Note: see "Standard Claims" section https://openid.net/specs/openid-connect-core-1_0.html
|
|
|
|
switch (normalizedFieldName) {
|
|
|
|
case 'username':
|
|
|
|
guesses.push('sub') // unique identifier, see https://openid.net/specs/openid-connect-core-1_0.html
|
|
|
|
break
|
|
|
|
case 'last_name':
|
|
|
|
guesses.push('family_name')
|
|
|
|
break
|
|
|
|
case 'first_name':
|
|
|
|
guesses.push('given_name')
|
|
|
|
break
|
|
|
|
case 'nickname':
|
2024-04-18 16:25:14 +00:00
|
|
|
guesses.push('preferred_username')
|
2024-04-18 08:23:52 +00:00
|
|
|
guesses.push('name')
|
|
|
|
break
|
|
|
|
}
|
|
|
|
|
|
|
|
for (const field of guesses) {
|
|
|
|
if (!(field in userInfos)) { continue }
|
|
|
|
if (typeof userInfos[field] !== 'string') { continue }
|
|
|
|
if (userInfos[field] === '') { continue }
|
|
|
|
return userInfos[field] as string
|
|
|
|
}
|
|
|
|
return undefined
|
2024-04-17 13:12:37 +00:00
|
|
|
}
|
|
|
|
|
2024-04-18 16:25:14 +00:00
|
|
|
/**
|
|
|
|
* Read and get the avatar for the remote user (if exists).
|
|
|
|
* @param userInfos userInfos returned by the remote OIDC Provider.
|
|
|
|
*/
|
|
|
|
private async readUserInfoPicture (userInfos: UnknownObject): Promise<undefined | ExternalAccountInfos['avatar']> {
|
|
|
|
// According to "Standard Claims" section https://openid.net/specs/openid-connect-core-1_0.html,
|
|
|
|
// there should be a `picture` field containing an URL to the avatar
|
|
|
|
const picture = this.readUserInfoField(userInfos, 'picture')
|
|
|
|
if (!picture) { return undefined }
|
|
|
|
|
|
|
|
try {
|
|
|
|
const url = new URL(picture)
|
|
|
|
const buf = await got(url.toString(), {
|
|
|
|
method: 'GET',
|
|
|
|
headers: {},
|
|
|
|
responseType: 'buffer'
|
|
|
|
}).buffer()
|
|
|
|
|
|
|
|
const mimeType = await getMimeTypeFromArrayBuffer(buf)
|
|
|
|
if (!mimeType) {
|
|
|
|
throw new Error('Failed to get the avatar file type')
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
mimetype: mimeType,
|
|
|
|
base64: buf.toString('base64')
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
this.logger.error(`Failed to get the user avatar: ${err as string}`)
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-18 16:48:32 +00:00
|
|
|
/**
|
|
|
|
* Gets a random default avatar from the current avatar set.
|
|
|
|
*/
|
|
|
|
private async getRandomAvatar (): Promise<undefined | ExternalAccountInfos['avatar']> {
|
|
|
|
try {
|
|
|
|
if (!this.avatarsDir || !this.avatarsFiles.length) {
|
|
|
|
throw new Error('Seems there is no default avatars')
|
|
|
|
}
|
|
|
|
|
|
|
|
const file = this.avatarsFiles[Math.floor(Math.random() * this.avatarsFiles.length)]
|
|
|
|
if (!file) {
|
|
|
|
throw new Error('No default avatar file')
|
|
|
|
}
|
|
|
|
|
|
|
|
const filePath = path.resolve(this.avatarsDir, file)
|
|
|
|
const buf = await fs.promises.readFile(filePath)
|
|
|
|
|
|
|
|
const mimeType = await getMimeTypeFromArrayBuffer(buf)
|
|
|
|
if (!mimeType) {
|
|
|
|
throw new Error('Failed to get the default avatar file type')
|
|
|
|
}
|
|
|
|
|
|
|
|
return {
|
|
|
|
mimetype: mimeType,
|
|
|
|
base64: buf.toString('base64')
|
|
|
|
}
|
|
|
|
} catch (err) {
|
|
|
|
this.logger.error(`Failed to get a default avatar: ${err as string}`)
|
|
|
|
return undefined
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-17 13:12:37 +00:00
|
|
|
/**
|
|
|
|
* 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'
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-18 18:16:44 +00:00
|
|
|
/**
|
2024-04-22 11:03:31 +00:00
|
|
|
* frees the singletons
|
2024-04-18 18:16:44 +00:00
|
|
|
*/
|
2024-04-22 11:03:31 +00:00
|
|
|
public static async destroySingletons (): Promise<void> {
|
|
|
|
if (!singletons) { return }
|
2024-04-18 18:16:44 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
stopPruneTimer()
|
2024-04-18 18:16:44 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
const keys = singletons.keys()
|
|
|
|
for (const key of keys) {
|
|
|
|
const singleton = singletons.get(key)
|
|
|
|
if (!singleton) { continue }
|
|
|
|
singletons.delete(key)
|
|
|
|
}
|
2024-04-18 18:16:44 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
singletons = undefined
|
2024-04-18 18:16:44 +00:00
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
/**
|
2024-04-22 11:03:31 +00:00
|
|
|
* Instanciate all singletons.
|
|
|
|
* Note: no need to destroy singletons before creating new ones.
|
2024-04-15 16:29:09 +00:00
|
|
|
*/
|
2024-04-22 11:03:31 +00:00
|
|
|
public static async initSingletons (options: RegisterServerOptions): Promise<void> {
|
|
|
|
const prosodyDomain = await getProsodyDomain(options)
|
|
|
|
// FIXME: this is not optimal to call here.
|
|
|
|
const prosodyFilePaths = await getProsodyFilePaths(options)
|
2024-04-15 16:29:09 +00:00
|
|
|
|
|
|
|
const settings = await options.settingsManager.getSettings([
|
|
|
|
'external-auth-custom-oidc',
|
|
|
|
'external-auth-custom-oidc-button-label',
|
|
|
|
'external-auth-custom-oidc-discovery-url',
|
|
|
|
'external-auth-custom-oidc-client-id',
|
2024-04-22 11:03:31 +00:00
|
|
|
'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'
|
2024-04-15 16:29:09 +00:00
|
|
|
])
|
2024-04-16 16:49:23 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
const init = async function initSingleton (
|
|
|
|
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 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
|
|
|
|
})
|
2024-04-16 09:43:38 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
singletons ??= new Map<ExternalAuthOIDCType, ExternalAuthOIDC>()
|
|
|
|
singletons.set(singletonType, singleton)
|
|
|
|
}
|
|
|
|
|
|
|
|
await Promise.all([
|
|
|
|
init(
|
|
|
|
'custom',
|
|
|
|
settings['external-auth-custom-oidc-button-label'] as string | undefined,
|
|
|
|
settings['external-auth-custom-oidc-discovery-url'] as string | undefined
|
|
|
|
),
|
|
|
|
init(
|
|
|
|
'google',
|
|
|
|
'Google',
|
|
|
|
'https://accounts.google.com'
|
|
|
|
),
|
|
|
|
init(
|
|
|
|
'facebook',
|
|
|
|
'Facebook',
|
|
|
|
'https://www.facebook.com'
|
|
|
|
)
|
|
|
|
])
|
2024-04-18 18:16:44 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
startPruneTimer(options)
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Gets the singleton, or raise an exception if it is too soon.
|
2024-04-22 11:03:31 +00:00
|
|
|
* @param ExternalAuthOIDCType The singleton type.
|
2024-04-17 14:35:26 +00:00
|
|
|
* @throws Error
|
2024-04-15 16:29:09 +00:00
|
|
|
* @returns the singleton
|
|
|
|
*/
|
2024-04-22 11:03:31 +00:00
|
|
|
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)
|
2024-04-15 16:29:09 +00:00
|
|
|
if (!singleton) {
|
2024-04-22 11:03:31 +00:00
|
|
|
throw new Error(`ExternalAuthOIDC singleton "${singletonType}" is not initiliazed yet`)
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|
|
|
|
return singleton
|
|
|
|
}
|
2024-04-16 15:18:14 +00:00
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2024-04-16 16:49:23 +00:00
|
|
|
/**
|
|
|
|
* Get the uri to start the authentication process.
|
|
|
|
* @param options Peertube server options
|
|
|
|
* @returns the uri
|
|
|
|
*/
|
2024-04-22 11:03:31 +00:00
|
|
|
public static connectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string {
|
|
|
|
if (!/^\w+$/.test(type)) {
|
|
|
|
throw new Error('Invalid singleton type')
|
|
|
|
}
|
|
|
|
const path = getBaseRouterRoute(options) + 'oidc/' + type + '/connect'
|
2024-04-16 16:49:23 +00:00
|
|
|
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
|
|
|
|
*/
|
2024-04-22 11:03:31 +00:00
|
|
|
public static redirectUrl (options: RegisterServerOptions, type: ExternalAuthOIDCType): string {
|
|
|
|
if (!/^\w+$/.test(type)) {
|
|
|
|
throw new Error('Invalid singleton type')
|
|
|
|
}
|
|
|
|
const path = getBaseRouterRoute(options) + 'oidc/' + type + '/cb'
|
2024-04-16 15:18:14 +00:00
|
|
|
return canonicalizePluginUri(options, path, {
|
|
|
|
removePluginVersion: true
|
|
|
|
})
|
|
|
|
}
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|
|
|
|
|
2024-04-22 11:03:31 +00:00
|
|
|
let pruneTimer: NodeJS.Timer | undefined
|
|
|
|
|
|
|
|
/**
|
|
|
|
* 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
|
|
|
|
}
|
|
|
|
|
2024-04-15 16:29:09 +00:00
|
|
|
export {
|
2024-04-22 11:03:31 +00:00
|
|
|
ExternalAuthOIDC,
|
|
|
|
ExternalAuthOIDCType
|
2024-04-15 16:29:09 +00:00
|
|
|
}
|