From b65f5367baf799b425be0bcfb9220922751bb6eb Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 29 Dec 2022 14:18:07 +0100 Subject: Add ability to customize token lifetime --- server/initializers/checker-before-init.ts | 1 + server/initializers/config.ts | 6 + server/initializers/constants.ts | 6 - server/lib/auth/oauth.ts | 14 ++- server/lib/auth/tokens-cache.ts | 8 +- server/tests/api/users/index.ts | 1 + server/tests/api/users/oauth.ts | 192 +++++++++++++++++++++++++++++ server/tests/api/users/users.ts | 184 +-------------------------- 8 files changed, 218 insertions(+), 194 deletions(-) create mode 100644 server/tests/api/users/oauth.ts (limited to 'server') diff --git a/server/initializers/checker-before-init.ts b/server/initializers/checker-before-init.ts index 39713a266..57852241c 100644 --- a/server/initializers/checker-before-init.ts +++ b/server/initializers/checker-before-init.ts @@ -13,6 +13,7 @@ function checkMissedConfig () { 'webserver.https', 'webserver.hostname', 'webserver.port', 'secrets.peertube', 'trust_proxy', + 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token', 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 'email.body.signature', 'email.subject.prefix', diff --git a/server/initializers/config.ts b/server/initializers/config.ts index c2f8b19fd..28aaf36a9 100644 --- a/server/initializers/config.ts +++ b/server/initializers/config.ts @@ -149,6 +149,12 @@ const CONFIG = { HOSTNAME: config.get('webserver.hostname'), PORT: config.get('webserver.port') }, + OAUTH2: { + TOKEN_LIFETIME: { + ACCESS_TOKEN: parseDurationToMs(config.get('oauth2.token_lifetime.access_token')), + REFRESH_TOKEN: parseDurationToMs(config.get('oauth2.token_lifetime.refresh_token')) + } + }, RATES_LIMIT: { API: { WINDOW_MS: parseDurationToMs(config.get('rates_limit.api.window')), diff --git a/server/initializers/constants.ts b/server/initializers/constants.ts index ec5045078..0dab524d9 100644 --- a/server/initializers/constants.ts +++ b/server/initializers/constants.ts @@ -101,11 +101,6 @@ const SORTABLE_COLUMNS = { VIDEO_REDUNDANCIES: [ 'name' ] } -const OAUTH_LIFETIME = { - ACCESS_TOKEN: 3600 * 24, // 1 day, for upload - REFRESH_TOKEN: 1209600 // 2 weeks -} - const ROUTE_CACHE_LIFETIME = { FEEDS: '15 minutes', ROBOTS: '2 hours', @@ -1033,7 +1028,6 @@ export { JOB_ATTEMPTS, AP_CLEANER, LAST_MIGRATION_VERSION, - OAUTH_LIFETIME, CUSTOM_HTML_TAG_COMMENTS, STATS_TIMESERIE, BROADCAST_CONCURRENCY, diff --git a/server/lib/auth/oauth.ts b/server/lib/auth/oauth.ts index bc0d4301f..2905c79a2 100644 --- a/server/lib/auth/oauth.ts +++ b/server/lib/auth/oauth.ts @@ -10,10 +10,11 @@ import OAuth2Server, { } from '@node-oauth/oauth2-server' import { randomBytesPromise } from '@server/helpers/core-utils' import { isOTPValid } from '@server/helpers/otp' +import { CONFIG } from '@server/initializers/config' import { MOAuthClient } from '@server/types/models' import { sha1 } from '@shared/extra-utils' import { HttpStatusCode } from '@shared/models' -import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' +import { OTP } from '../../initializers/constants' import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' class MissingTwoFactorError extends Error { @@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error { * */ const oAuthServer = new OAuth2Server({ - accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, - refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, + // Wants seconds + accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000, + refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000, // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications model: require('./oauth-model') @@ -182,10 +184,10 @@ function generateRandomToken () { function getTokenExpiresAt (type: 'access' | 'refresh') { const lifetime = type === 'access' - ? OAUTH_LIFETIME.ACCESS_TOKEN - : OAUTH_LIFETIME.REFRESH_TOKEN + ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN + : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN - return new Date(Date.now() + lifetime * 1000) + return new Date(Date.now() + lifetime) } async function buildToken () { diff --git a/server/lib/auth/tokens-cache.ts b/server/lib/auth/tokens-cache.ts index 410708a35..43efc7d02 100644 --- a/server/lib/auth/tokens-cache.ts +++ b/server/lib/auth/tokens-cache.ts @@ -36,8 +36,8 @@ export class TokensCache { const token = this.userHavingToken.get(userId) if (token !== undefined) { - this.accessTokenCache.del(token) - this.userHavingToken.del(userId) + this.accessTokenCache.delete(token) + this.userHavingToken.delete(userId) } } @@ -45,8 +45,8 @@ export class TokensCache { const tokenModel = this.accessTokenCache.get(token) if (tokenModel !== undefined) { - this.userHavingToken.del(tokenModel.userId) - this.accessTokenCache.del(token) + this.userHavingToken.delete(tokenModel.userId) + this.accessTokenCache.delete(token) } } } diff --git a/server/tests/api/users/index.ts b/server/tests/api/users/index.ts index 643f1a531..0313845ef 100644 --- a/server/tests/api/users/index.ts +++ b/server/tests/api/users/index.ts @@ -1,3 +1,4 @@ +import './oauth' import './two-factor' import './user-subscriptions' import './user-videos' diff --git a/server/tests/api/users/oauth.ts b/server/tests/api/users/oauth.ts new file mode 100644 index 000000000..6a3da5ea2 --- /dev/null +++ b/server/tests/api/users/oauth.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */ + +import { expect } from 'chai' +import { wait } from '@shared/core-utils' +import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models' +import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' + +describe('Test oauth', function () { + let server: PeerTubeServer + + before(async function () { + this.timeout(30000) + + server = await createSingleServer(1, { + rates_limit: { + login: { + max: 30 + } + } + }) + + await setAccessTokensToServers([ server ]) + }) + + describe('OAuth client', function () { + + function expectInvalidClient (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) + expect(body.error).to.contain('client is invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) + } + + it('Should create a new client') + + it('Should return the first client') + + it('Should remove the last client') + + it('Should not login with an invalid client id', async function () { + const client = { id: 'client', secret: server.store.client.secret } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + + it('Should not login with an invalid client secret', async function () { + const client = { id: server.store.client.id, secret: 'coucou' } + const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidClient(body) + }) + }) + + describe('Login', function () { + + function expectInvalidCredentials (body: PeerTubeProblemDocument) { + expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) + expect(body.error).to.contain('credentials are invalid') + expect(body.type.startsWith('https://')).to.be.true + expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) + } + + it('Should not login with an invalid username', async function () { + const user = { username: 'captain crochet', password: server.store.user.password } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should not login with an invalid password', async function () { + const user = { username: server.store.user.username, password: 'mew_three' } + const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + + expectInvalidCredentials(body) + }) + + it('Should be able to login', async function () { + await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) + }) + + it('Should be able to login with an insensitive username', async function () { + const user = { username: 'RoOt', password: server.store.user.password } + await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) + + const user2 = { username: 'rOoT', password: server.store.user.password } + await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) + + const user3 = { username: 'ROOt', password: server.store.user.password } + await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) + }) + }) + + describe('Logout', function () { + + it('Should logout (revoke token)', async function () { + await server.login.logout({ token: server.accessToken }) + }) + + it('Should not be able to get the user information', async function () { + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to upload a video', async function () { + await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should be able to login again', async function () { + const body = await server.login.login() + server.accessToken = body.access_token + server.refreshToken = body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + + it('Should have an expired access token', async function () { + this.timeout(60000) + + await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) + await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) + + await killallServers([ server ]) + await server.run() + + await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should not be able to refresh an access token with an expired refresh token', async function () { + await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + + it('Should refresh the token', async function () { + this.timeout(50000) + + const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() + await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) + + await killallServers([ server ]) + await server.run() + + const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) + server.accessToken = res.body.access_token + server.refreshToken = res.body.refresh_token + }) + + it('Should be able to get my user information again', async function () { + await server.users.getMyInfo() + }) + }) + + describe('Custom token lifetime', function () { + before(async function () { + this.timeout(120_000) + + await server.kill() + await server.run({ + oauth2: { + token_lifetime: { + access_token: '2 seconds', + refresh_token: '2 seconds' + } + } + }) + }) + + it('Should have a very short access token lifetime', async function () { + this.timeout(50000) + + const { access_token: accessToken } = await server.login.login() + await server.users.getMyInfo({ token: accessToken }) + + await wait(3000) + await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) + }) + + it('Should have a very short refresh token lifetime', async function () { + this.timeout(50000) + + const { refresh_token: refreshToken } = await server.login.login() + await server.login.refreshToken({ refreshToken }) + + await wait(3000) + await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) + }) + }) + + after(async function () { + await cleanupTests([ server ]) + }) +}) diff --git a/server/tests/api/users/users.ts b/server/tests/api/users/users.ts index 421b3ce16..93e2e489a 100644 --- a/server/tests/api/users/users.ts +++ b/server/tests/api/users/users.ts @@ -2,15 +2,8 @@ import { expect } from 'chai' import { testImage } from '@server/tests/shared' -import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' -import { - cleanupTests, - createSingleServer, - killallServers, - makePutBodyRequest, - PeerTubeServer, - setAccessTokensToServers -} from '@shared/server-commands' +import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' +import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands' describe('Test users', function () { let server: PeerTubeServer @@ -39,166 +32,6 @@ describe('Test users', function () { await server.plugins.install({ npmName: 'peertube-theme-background-red' }) }) - describe('OAuth client', function () { - it('Should create a new client') - - it('Should return the first client') - - it('Should remove the last client') - - it('Should not login with an invalid client id', async function () { - const client = { id: 'client', secret: server.store.client.secret } - const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) - expect(body.error).to.contain('client is invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) - }) - - it('Should not login with an invalid client secret', async function () { - const client = { id: server.store.client.id, secret: 'coucou' } - const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT) - expect(body.error).to.contain('client is invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT) - }) - }) - - describe('Login', function () { - - it('Should not login with an invalid username', async function () { - const user = { username: 'captain crochet', password: server.store.user.password } - const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) - expect(body.error).to.contain('credentials are invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) - }) - - it('Should not login with an invalid password', async function () { - const user = { username: server.store.user.username, password: 'mew_three' } - const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - - expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT) - expect(body.error).to.contain('credentials are invalid') - expect(body.type.startsWith('https://')).to.be.true - expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT) - }) - - it('Should not be able to upload a video', async function () { - token = 'my_super_token' - - await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to follow', async function () { - token = 'my_super_token' - - await server.follows.follow({ - hosts: [ 'http://example.com' ], - token, - expectedStatus: HttpStatusCode.UNAUTHORIZED_401 - }) - }) - - it('Should not be able to unfollow') - - it('Should be able to login', async function () { - const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 }) - - token = body.access_token - }) - - it('Should be able to login with an insensitive username', async function () { - const user = { username: 'RoOt', password: server.store.user.password } - await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 }) - - const user2 = { username: 'rOoT', password: server.store.user.password } - await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 }) - - const user3 = { username: 'ROOt', password: server.store.user.password } - await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 }) - }) - }) - - describe('Logout', function () { - it('Should logout (revoke token)', async function () { - await server.login.logout({ token: server.accessToken }) - }) - - it('Should not be able to get the user information', async function () { - await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to upload a video', async function () { - await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to rate a video', async function () { - const path = '/api/v1/videos/' - const data = { - rating: 'likes' - } - - const options = { - url: server.url, - path: path + videoId, - token: 'wrong token', - fields: data, - expectedStatus: HttpStatusCode.UNAUTHORIZED_401 - } - await makePutBodyRequest(options) - }) - - it('Should be able to login again', async function () { - const body = await server.login.login() - server.accessToken = body.access_token - server.refreshToken = body.refresh_token - }) - - it('Should be able to get my user information again', async function () { - await server.users.getMyInfo() - }) - - it('Should have an expired access token', async function () { - this.timeout(60000) - - await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString()) - await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString()) - - await killallServers([ server ]) - await server.run() - - await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 }) - }) - - it('Should not be able to refresh an access token with an expired refresh token', async function () { - await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - }) - - it('Should refresh the token', async function () { - this.timeout(50000) - - const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString() - await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate) - - await killallServers([ server ]) - await server.run() - - const res = await server.login.refreshToken({ refreshToken: server.refreshToken }) - server.accessToken = res.body.access_token - server.refreshToken = res.body.refresh_token - }) - - it('Should be able to get my user information again', async function () { - await server.users.getMyInfo() - }) - }) - describe('Creating a user', function () { it('Should be able to create a new user', async function () { @@ -512,6 +345,7 @@ describe('Test users', function () { }) describe('Updating another user', function () { + it('Should be able to update another user', async function () { await server.users.update({ userId, @@ -562,13 +396,6 @@ describe('Test users', function () { }) }) - describe('Video blacklists', function () { - - it('Should be able to list my video blacklist', async function () { - await server.blacklist.list({ token: userToken }) - }) - }) - describe('Remove a user', function () { before(async function () { @@ -653,8 +480,9 @@ describe('Test users', function () { }) describe('User blocking', function () { - let user16Id - let user16AccessToken + let user16Id: number + let user16AccessToken: string + const user16 = { username: 'user_16', password: 'my super password' -- cgit v1.2.3 From 7e0c26066a5c59af742ae56bddaff9635debe034 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Thu, 29 Dec 2022 15:31:40 +0100 Subject: External auth can set more user fields videoQuota, videoQuotaDaily, adminFlags --- server/lib/auth/external-auth.ts | 60 +++++++++++----------- server/lib/auth/oauth-model.ts | 22 +++----- .../peertube-plugin-test-external-auth-one/main.js | 5 +- server/tests/plugins/external-auth.ts | 8 ++- server/types/plugins/register-server-auth.model.ts | 7 ++- 5 files changed, 54 insertions(+), 48 deletions(-) (limited to 'server') diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts index 053112801..155ec03d8 100644 --- a/server/lib/auth/external-auth.ts +++ b/server/lib/auth/external-auth.ts @@ -1,26 +1,33 @@ -import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' +import { + isUserAdminFlagsValid, + isUserDisplayNameValid, + isUserRoleValid, + isUserUsernameValid, + isUserVideoQuotaDailyValid, + isUserVideoQuotaValid +} from '@server/helpers/custom-validators/users' import { logger } from '@server/helpers/logger' import { generateRandomString } from '@server/helpers/utils' import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' import { PluginManager } from '@server/lib/plugins/plugin-manager' import { OAuthTokenModel } from '@server/models/oauth/oauth-token' +import { MUser } from '@server/types/models' import { RegisterServerAuthenticatedResult, RegisterServerAuthPassOptions, RegisterServerExternalAuthenticatedResult } from '@server/types/plugins/register-server-auth.model' -import { UserRole } from '@shared/models' +import { UserAdminFlag, UserRole } from '@shared/models' + +export type ExternalUser = + Pick & + { displayName: string } // Token is the key, expiration date is the value const authBypassTokens = new Map() @@ -172,30 +179,20 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string) } function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { - if (!isUserUsernameValid(result.username)) { - logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) + const returnError = (field: string) => { + logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] }) return false } - if (!result.email) { - logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) - return false - } + if (!isUserUsernameValid(result.username)) return returnError('username') + if (!result.email) return returnError('email') - // role is optional - if (result.role && !isUserRoleValid(result.role)) { - logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) - return false - } - - // display name is optional - if (result.displayName && !isUserDisplayNameValid(result.displayName)) { - logger.error( - 'Auth method %s of plugin %s did not provide a valid display name.', - authName, npmName, { displayName: result.displayName } - ) - return false - } + // Following fields are optional + if (result.role && !isUserRoleValid(result.role)) return returnError('role') + if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName') + if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags') + if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') + if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') return true } @@ -205,7 +202,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) { username: pluginResult.username, email: pluginResult.email, role: pluginResult.role ?? UserRole.USER, - displayName: pluginResult.displayName || pluginResult.username + displayName: pluginResult.displayName || pluginResult.username, + + adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE, + + videoQuota: pluginResult.videoQuota, + videoQuotaDaily: pluginResult.videoQuotaDaily } } diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts index 322b69e3a..603cc0f5f 100644 --- a/server/lib/auth/oauth-model.ts +++ b/server/lib/auth/oauth-model.ts @@ -5,7 +5,6 @@ import { MOAuthClient } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' import { MUser } from '@server/types/models/user/user' import { pick } from '@shared/core-utils' -import { UserRole } from '@shared/models/users/user-role' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' import { OAuthClientModel } from '../../models/oauth/oauth-client' @@ -13,6 +12,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token' import { UserModel } from '../../models/user/user' import { findAvailableLocalActorName } from '../local-actor' import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' +import { ExternalUser } from './external-auth' import { TokensCache } from './tokens-cache' type TokenInfo = { @@ -26,12 +26,7 @@ export type BypassLogin = { bypass: boolean pluginName: string authName?: string - user: { - username: string - email: string - displayName: string - role: UserRole - } + user: ExternalUser } async function getAccessToken (bearerToken: string) { @@ -219,16 +214,11 @@ export { // --------------------------------------------------------------------------- -async function createUserFromExternal (pluginAuth: string, options: { - username: string - email: string - role: UserRole - displayName: string -}) { - const username = await findAvailableLocalActorName(options.username) +async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) { + const username = await findAvailableLocalActorName(userOptions.username) const userToCreate = buildUser({ - ...pick(options, [ 'email', 'role' ]), + ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]), username, emailVerified: null, @@ -238,7 +228,7 @@ async function createUserFromExternal (pluginAuth: string, options: { const { user } = await createUserAccountAndChannelAndPlaylist({ userToCreate, - userDisplayName: options.displayName + userDisplayName: userOptions.displayName }) return user diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js index c65b8d3a8..cdbaf11ac 100644 --- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js +++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js @@ -33,7 +33,10 @@ async function register ({ username: 'kefka', email: 'kefka@example.com', role: 0, - displayName: 'Kefka Palazzo' + displayName: 'Kefka Palazzo', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100 }) }, hookTokenValidity: (options) => { diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts index 437777e90..ee78ae5aa 100644 --- a/server/tests/plugins/external-auth.ts +++ b/server/tests/plugins/external-auth.ts @@ -2,7 +2,7 @@ import { expect } from 'chai' import { wait } from '@shared/core-utils' -import { HttpStatusCode, UserRole } from '@shared/models' +import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models' import { cleanupTests, createSingleServer, @@ -156,6 +156,9 @@ describe('Test external auth plugins', function () { expect(body.account.displayName).to.equal('cyan') expect(body.email).to.equal('cyan@example.com') expect(body.role.id).to.equal(UserRole.USER) + expect(body.adminFlags).to.equal(UserAdminFlag.NONE) + expect(body.videoQuota).to.equal(5242880) + expect(body.videoQuotaDaily).to.equal(-1) } }) @@ -178,6 +181,9 @@ describe('Test external auth plugins', function () { expect(body.account.displayName).to.equal('Kefka Palazzo') expect(body.email).to.equal('kefka@example.com') expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) + expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(42100) } }) diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts index 79c18c406..a17fc4b0f 100644 --- a/server/types/plugins/register-server-auth.model.ts +++ b/server/types/plugins/register-server-auth.model.ts @@ -1,5 +1,5 @@ import express from 'express' -import { UserRole } from '@shared/models' +import { UserAdminFlag, UserRole } from '@shared/models' import { MOAuthToken, MUser } from '../models' export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions @@ -9,6 +9,11 @@ export interface RegisterServerAuthenticatedResult { email: string role?: UserRole displayName?: string + + adminFlags?: UserAdminFlag + + videoQuota?: number + videoQuotaDaily?: number } export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { -- cgit v1.2.3 From 60b880acdfa85eab5c9ec09ba1283f82ae58ec85 Mon Sep 17 00:00:00 2001 From: Chocobozzz Date: Fri, 30 Dec 2022 10:12:20 +0100 Subject: External auth can update user on login --- server/helpers/custom-validators/video-captions.ts | 9 ++-- server/helpers/custom-validators/video-imports.ts | 9 ++-- server/initializers/checker-after-init.ts | 3 +- server/initializers/installer.ts | 6 +-- server/lib/auth/external-auth.ts | 18 ++++++-- server/lib/auth/oauth-model.ts | 53 +++++++++++++++++++++- server/models/video/video-file.ts | 2 +- .../peertube-plugin-test-external-auth-one/main.js | 9 +++- .../peertube-plugin-test-id-pass-auth-two/main.js | 13 +++++- server/tests/plugins/external-auth.ts | 34 ++++++++++++++ server/tests/plugins/id-and-pass-auth.ts | 34 +++++++++++++- server/types/express.d.ts | 1 - server/types/lib.d.ts | 12 +++++ server/types/plugins/register-server-auth.model.ts | 14 ++++++ 14 files changed, 193 insertions(+), 24 deletions(-) create mode 100644 server/types/lib.d.ts (limited to 'server') diff --git a/server/helpers/custom-validators/video-captions.ts b/server/helpers/custom-validators/video-captions.ts index 59ba005fe..d5b09ea03 100644 --- a/server/helpers/custom-validators/video-captions.ts +++ b/server/helpers/custom-validators/video-captions.ts @@ -8,10 +8,11 @@ function isVideoCaptionLanguageValid (value: any) { return exists(value) && VIDEO_LANGUAGES[value] !== undefined } -const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) - .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream - .map(m => `(${m})`) - .join('|') +// MacOS sends application/octet-stream +const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ] + .map(m => `(${m})`) + .join('|') + function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { return isFileValid({ files, diff --git a/server/helpers/custom-validators/video-imports.ts b/server/helpers/custom-validators/video-imports.ts index af93aea56..da8962cb6 100644 --- a/server/helpers/custom-validators/video-imports.ts +++ b/server/helpers/custom-validators/video-imports.ts @@ -22,10 +22,11 @@ function isVideoImportStateValid (value: any) { return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined } -const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) - .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream - .map(m => `(${m})`) - .join('|') +// MacOS sends application/octet-stream +const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ] + .map(m => `(${m})`) + .join('|') + function isVideoImportTorrentFile (files: UploadFilesForCheck) { return isFileValid({ files, diff --git a/server/initializers/checker-after-init.ts b/server/initializers/checker-after-init.ts index 09e878eee..e6432641b 100644 --- a/server/initializers/checker-after-init.ts +++ b/server/initializers/checker-after-init.ts @@ -174,7 +174,8 @@ function checkRemoteRedundancyConfig () { function checkStorageConfig () { // Check storage directory locations if (isProdInstance()) { - const configStorage = config.get('storage') + const configStorage = config.get<{ [ name: string ]: string }>('storage') + for (const key of Object.keys(configStorage)) { if (configStorage[key].startsWith('storage/')) { logger.warn( diff --git a/server/initializers/installer.ts b/server/initializers/installer.ts index f5d8eedf1..f48f348a7 100644 --- a/server/initializers/installer.ts +++ b/server/initializers/installer.ts @@ -51,8 +51,7 @@ function removeCacheAndTmpDirectories () { const tasks: Promise[] = [] // Cache directories - for (const key of Object.keys(cacheDirectories)) { - const dir = cacheDirectories[key] + for (const dir of cacheDirectories) { tasks.push(removeDirectoryOrContent(dir)) } @@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () { } // Cache directories - for (const key of Object.keys(cacheDirectories)) { - const dir = cacheDirectories[key] + for (const dir of cacheDirectories) { tasks.push(ensureDir(dir)) } diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts index 155ec03d8..bc5b74257 100644 --- a/server/lib/auth/external-auth.ts +++ b/server/lib/auth/external-auth.ts @@ -19,6 +19,7 @@ import { RegisterServerExternalAuthenticatedResult } from '@server/types/plugins/register-server-auth.model' import { UserAdminFlag, UserRole } from '@shared/models' +import { BypassLogin } from './oauth-model' export type ExternalUser = Pick & @@ -28,6 +29,7 @@ export type ExternalUser = const authBypassTokens = new Map() @@ -63,7 +65,8 @@ async function onExternalUserAuthenticated (options: { expires, user, npmName, - authName + authName, + userUpdater: authResult.userUpdater }) // Cleanup expired tokens @@ -85,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) { return tokenModel?.authName } -async function getBypassFromPasswordGrant (username: string, password: string) { +async function getBypassFromPasswordGrant (username: string, password: string): Promise { const plugins = PluginManager.Instance.getIdAndPassAuths() const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] @@ -140,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) { bypass: true, pluginName: pluginAuth.npmName, authName: authOptions.authName, - user: buildUserResult(loginResult) + user: buildUserResult(loginResult), + userUpdater: loginResult.userUpdater } } catch (err) { logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) @@ -150,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) { return undefined } -function getBypassFromExternalAuth (username: string, externalAuthToken: string) { +function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin { const obj = authBypassTokens.get(externalAuthToken) if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') @@ -174,6 +178,7 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string) bypass: true, pluginName: npmName, authName, + userUpdater: obj.userUpdater, user } } @@ -194,6 +199,11 @@ function isAuthResultValid (npmName: string, authName: string, result: RegisterS if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota') if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily') + if (result.userUpdater && typeof result.userUpdater !== 'function') { + logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName) + return false + } + return true } diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts index 603cc0f5f..43909284f 100644 --- a/server/lib/auth/oauth-model.ts +++ b/server/lib/auth/oauth-model.ts @@ -1,10 +1,13 @@ import express from 'express' import { AccessDeniedError } from '@node-oauth/oauth2-server' import { PluginManager } from '@server/lib/plugins/plugin-manager' +import { AccountModel } from '@server/models/account/account' +import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types' import { MOAuthClient } from '@server/types/models' import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' -import { MUser } from '@server/types/models/user/user' +import { MUser, MUserDefault } from '@server/types/models/user/user' import { pick } from '@shared/core-utils' +import { AttributesOnly } from '@shared/typescript-utils' import { logger } from '../../helpers/logger' import { CONFIG } from '../../initializers/config' import { OAuthClientModel } from '../../models/oauth/oauth-client' @@ -27,6 +30,7 @@ export type BypassLogin = { pluginName: string authName?: string user: ExternalUser + userUpdater: RegisterServerAuthenticatedResult['userUpdater'] } async function getAccessToken (bearerToken: string) { @@ -84,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) let user = await UserModel.loadByEmail(bypassLogin.user.email) + if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) + else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater) // Cannot create a user if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') @@ -234,6 +240,51 @@ async function createUserFromExternal (pluginAuth: string, userOptions: External return user } +async function updateUserFromExternal ( + user: MUserDefault, + userOptions: ExternalUser, + userUpdater: RegisterServerAuthenticatedResult['userUpdater'] +) { + if (!userUpdater) return user + + { + type UserAttributeKeys = keyof AttributesOnly + const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { + role: 'role', + adminFlags: 'adminFlags', + videoQuota: 'videoQuota', + videoQuotaDaily: 'videoQuotaDaily' + } + + for (const modelKey of Object.keys(mappingKeys)) { + const pluginOptionKey = mappingKeys[modelKey] + + const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] }) + user.set(modelKey, newValue) + } + } + + { + type AccountAttributeKeys = keyof Partial> + const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = { + name: 'displayName' + } + + for (const modelKey of Object.keys(mappingKeys)) { + const optionKey = mappingKeys[modelKey] + + const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] }) + user.Account.set(modelKey, newValue) + } + } + + logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions }) + + user.Account = await user.Account.save() + + return user.save() +} + function checkUserValidityOrThrow (user: MUser) { if (user.blocked) throw new AccessDeniedError('User is blocked.') } diff --git a/server/models/video/video-file.ts b/server/models/video/video-file.ts index 9c4e6d078..9b42955ef 100644 --- a/server/models/video/video-file.ts +++ b/server/models/video/video-file.ts @@ -439,7 +439,7 @@ export class VideoFileModel extends Model if (!element) return videoFile.save({ transaction }) for (const k of Object.keys(videoFile.toJSON())) { - element[k] = videoFile[k] + element.set(k, videoFile[k]) } return element.save({ transaction }) diff --git a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js index cdbaf11ac..58bc27661 100644 --- a/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js +++ b/server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js @@ -36,7 +36,14 @@ async function register ({ displayName: 'Kefka Palazzo', adminFlags: 1, videoQuota: 42000, - videoQuotaDaily: 42100 + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } }) }, hookTokenValidity: (options) => { diff --git a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js index ceab7b60d..fad5abf60 100644 --- a/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js +++ b/server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js @@ -33,7 +33,18 @@ async function register ({ if (body.id === 'laguna' && body.password === 'laguna password') { return Promise.resolve({ username: 'laguna', - email: 'laguna@example.com' + email: 'laguna@example.com', + displayName: 'Laguna Loire', + adminFlags: 1, + videoQuota: 42000, + videoQuotaDaily: 42100, + + // Always use new value except for videoQuotaDaily field + userUpdater: ({ fieldName, currentValue, newValue }) => { + if (fieldName === 'videoQuotaDaily') return currentValue + + return newValue + } }) } diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts index ee78ae5aa..e600f958f 100644 --- a/server/tests/plugins/external-auth.ts +++ b/server/tests/plugins/external-auth.ts @@ -51,6 +51,7 @@ describe('Test external auth plugins', function () { let kefkaAccessToken: string let kefkaRefreshToken: string + let kefkaId: number let externalAuthToken: string @@ -184,6 +185,8 @@ describe('Test external auth plugins', function () { expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST) expect(body.videoQuota).to.equal(42000) expect(body.videoQuotaDaily).to.equal(42100) + + kefkaId = body.id } }) @@ -246,6 +249,37 @@ describe('Test external auth plugins', function () { expect(body.role.id).to.equal(UserRole.USER) }) + it('Should login Kefka and update the profile', async function () { + { + await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' }) + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('kefka updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const res = await loginExternal({ + server, + npmName: 'test-external-auth-one', + authName: 'external-auth-2', + username: 'kefka' + }) + + kefkaAccessToken = res.access_token + kefkaRefreshToken = res.refresh_token + + const body = await server.users.getMyInfo({ token: kefkaAccessToken }) + expect(body.username).to.equal('kefka') + expect(body.account.displayName).to.equal('Kefka Palazzo') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + it('Should not update an external auth email', async function () { await server.users.updateMe({ token: cyanAccessToken, diff --git a/server/tests/plugins/id-and-pass-auth.ts b/server/tests/plugins/id-and-pass-auth.ts index fc24a5656..10155c28b 100644 --- a/server/tests/plugins/id-and-pass-auth.ts +++ b/server/tests/plugins/id-and-pass-auth.ts @@ -13,6 +13,7 @@ describe('Test id and pass auth plugins', function () { let lagunaAccessToken: string let lagunaRefreshToken: string + let lagunaId: number before(async function () { this.timeout(30000) @@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () { const body = await server.users.getMyInfo({ token: lagunaAccessToken }) expect(body.username).to.equal('laguna') - expect(body.account.displayName).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') expect(body.role.id).to.equal(UserRole.USER) + + lagunaId = body.id } }) @@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () { expect(body.role.id).to.equal(UserRole.MODERATOR) }) + it('Should login Laguna and update the profile', async function () { + { + await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 }) + await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' }) + + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('laguna updated') + expect(body.videoQuota).to.equal(43000) + expect(body.videoQuotaDaily).to.equal(43100) + } + + { + const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } }) + lagunaAccessToken = body.access_token + lagunaRefreshToken = body.refresh_token + } + + { + const body = await server.users.getMyInfo({ token: lagunaAccessToken }) + expect(body.username).to.equal('laguna') + expect(body.account.displayName).to.equal('Laguna Loire') + expect(body.videoQuota).to.equal(42000) + expect(body.videoQuotaDaily).to.equal(43100) + } + }) + it('Should reject token of laguna by the plugin hook', async function () { this.timeout(10000) @@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () { await server.servers.waitUntilLog('valid username') await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) - await server.servers.waitUntilLog('valid display name') + await server.servers.waitUntilLog('valid displayName') await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) await server.servers.waitUntilLog('valid role') diff --git a/server/types/express.d.ts b/server/types/express.d.ts index 99244d2a0..6fea4dac2 100644 --- a/server/types/express.d.ts +++ b/server/types/express.d.ts @@ -1,4 +1,3 @@ - import { OutgoingHttpHeaders } from 'http' import { RegisterServerAuthExternalOptions } from '@server/types' import { diff --git a/server/types/lib.d.ts b/server/types/lib.d.ts new file mode 100644 index 000000000..c901e2032 --- /dev/null +++ b/server/types/lib.d.ts @@ -0,0 +1,12 @@ +type ObjectKeys = + T extends object + ? `${Exclude}`[] + : T extends number + ? [] + : T extends any | string + ? string[] + : never + +interface ObjectConstructor { + keys (o: T): ObjectKeys +} diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts index a17fc4b0f..e10968c20 100644 --- a/server/types/plugins/register-server-auth.model.ts +++ b/server/types/plugins/register-server-auth.model.ts @@ -4,15 +4,29 @@ import { MOAuthToken, MUser } from '../models' export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions +export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily' + export interface RegisterServerAuthenticatedResult { + // Update the user profile if it already exists + // Default behaviour is no update + // Introduced in PeerTube >= 5.1 + userUpdater?: (options: { + fieldName: AuthenticatedResultUpdaterFieldName + currentValue: T + newValue: T + }) => T + username: string email: string role?: UserRole displayName?: string + // PeerTube >= 5.1 adminFlags?: UserAdminFlag + // PeerTube >= 5.1 videoQuota?: number + // PeerTube >= 5.1 videoQuotaDaily?: number } -- cgit v1.2.3