aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--client/src/app/+login/login.component.ts5
-rw-r--r--client/src/app/core/auth/auth.service.ts45
-rw-r--r--client/src/app/menu/menu.component.ts8
-rw-r--r--client/src/root-helpers/plugins-manager.ts11
-rw-r--r--config/default.yaml5
-rw-r--r--config/production.yaml.example5
-rw-r--r--server/helpers/custom-validators/video-captions.ts9
-rw-r--r--server/helpers/custom-validators/video-imports.ts9
-rw-r--r--server/initializers/checker-after-init.ts3
-rw-r--r--server/initializers/checker-before-init.ts1
-rw-r--r--server/initializers/config.ts6
-rw-r--r--server/initializers/constants.ts6
-rw-r--r--server/initializers/installer.ts6
-rw-r--r--server/lib/auth/external-auth.ts72
-rw-r--r--server/lib/auth/oauth-model.ts75
-rw-r--r--server/lib/auth/oauth.ts14
-rw-r--r--server/lib/auth/tokens-cache.ts8
-rw-r--r--server/models/video/video-file.ts2
-rw-r--r--server/tests/api/users/index.ts1
-rw-r--r--server/tests/api/users/oauth.ts192
-rw-r--r--server/tests/api/users/users.ts184
-rw-r--r--server/tests/fixtures/peertube-plugin-test-external-auth-one/main.js12
-rw-r--r--server/tests/fixtures/peertube-plugin-test-id-pass-auth-two/main.js13
-rw-r--r--server/tests/plugins/external-auth.ts42
-rw-r--r--server/tests/plugins/id-and-pass-auth.ts34
-rw-r--r--server/types/express.d.ts1
-rw-r--r--server/types/lib.d.ts12
-rw-r--r--server/types/plugins/register-server-auth.model.ts21
-rw-r--r--shared/core-utils/plugins/hooks.ts8
-rw-r--r--shared/server-commands/requests/requests.ts2
-rw-r--r--support/doc/plugins/guide.md22
31 files changed, 537 insertions, 297 deletions
diff --git a/client/src/app/+login/login.component.ts b/client/src/app/+login/login.component.ts
index c1705807f..5f6aa842e 100644
--- a/client/src/app/+login/login.component.ts
+++ b/client/src/app/+login/login.component.ts
@@ -1,3 +1,4 @@
1import { environment } from 'src/environments/environment'
1import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core' 2import { AfterViewInit, Component, ElementRef, OnInit, ViewChild } from '@angular/core'
2import { ActivatedRoute, Router } from '@angular/router' 3import { ActivatedRoute, Router } from '@angular/router'
3import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core' 4import { AuthService, Notifier, RedirectService, SessionStorageService, UserService } from '@app/core'
@@ -7,7 +8,7 @@ import { USER_OTP_TOKEN_VALIDATOR } from '@app/shared/form-validators/user-valid
7import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms' 8import { FormReactive, FormReactiveService, InputTextComponent } from '@app/shared/shared-forms'
8import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance' 9import { InstanceAboutAccordionComponent } from '@app/shared/shared-instance'
9import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap' 10import { NgbAccordion, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
10import { PluginsManager } from '@root-helpers/plugins-manager' 11import { getExternalAuthHref } from '@shared/core-utils'
11import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models' 12import { RegisteredExternalAuthConfig, ServerConfig } from '@shared/models'
12 13
13@Component({ 14@Component({
@@ -119,7 +120,7 @@ export class LoginComponent extends FormReactive implements OnInit, AfterViewIni
119 } 120 }
120 121
121 getAuthHref (auth: RegisteredExternalAuthConfig) { 122 getAuthHref (auth: RegisteredExternalAuthConfig) {
122 return PluginsManager.getExternalAuthHref(auth) 123 return getExternalAuthHref(environment.apiUrl, auth)
123 } 124 }
124 125
125 login () { 126 login () {
diff --git a/client/src/app/core/auth/auth.service.ts b/client/src/app/core/auth/auth.service.ts
index 4de28e51e..ed7eabb76 100644
--- a/client/src/app/core/auth/auth.service.ts
+++ b/client/src/app/core/auth/auth.service.ts
@@ -5,10 +5,11 @@ import { HttpClient, HttpErrorResponse, HttpHeaders, HttpParams } from '@angular
5import { Injectable } from '@angular/core' 5import { Injectable } from '@angular/core'
6import { Router } from '@angular/router' 6import { Router } from '@angular/router'
7import { Notifier } from '@app/core/notification/notifier.service' 7import { Notifier } from '@app/core/notification/notifier.service'
8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage } from '@root-helpers/index' 8import { logger, OAuthUserTokens, objectToUrlEncoded, peertubeLocalStorage, PluginsManager } from '@root-helpers/index'
9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models' 9import { HttpStatusCode, MyUser as UserServerModel, OAuthClientLocal, User, UserLogin, UserRefreshToken } from '@shared/models'
10import { environment } from '../../../environments/environment' 10import { environment } from '../../../environments/environment'
11import { RestExtractor } from '../rest/rest-extractor.service' 11import { RestExtractor } from '../rest/rest-extractor.service'
12import { ServerService } from '../server'
12import { AuthStatus } from './auth-status.model' 13import { AuthStatus } from './auth-status.model'
13import { AuthUser } from './auth-user.model' 14import { AuthUser } from './auth-user.model'
14 15
@@ -44,6 +45,7 @@ export class AuthService {
44 private refreshingTokenObservable: Observable<any> 45 private refreshingTokenObservable: Observable<any>
45 46
46 constructor ( 47 constructor (
48 private serverService: ServerService,
47 private http: HttpClient, 49 private http: HttpClient,
48 private notifier: Notifier, 50 private notifier: Notifier,
49 private hotkeysService: HotkeysService, 51 private hotkeysService: HotkeysService,
@@ -213,25 +215,28 @@ Ensure you have correctly configured PeerTube (config/ directory), in particular
213 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded') 215 const headers = new HttpHeaders().set('Content-Type', 'application/x-www-form-urlencoded')
214 216
215 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers }) 217 this.refreshingTokenObservable = this.http.post<UserRefreshToken>(AuthService.BASE_TOKEN_URL, body, { headers })
216 .pipe( 218 .pipe(
217 map(res => this.handleRefreshToken(res)), 219 map(res => this.handleRefreshToken(res)),
218 tap(() => { 220 tap(() => {
219 this.refreshingTokenObservable = null 221 this.refreshingTokenObservable = null
220 }), 222 }),
221 catchError(err => { 223 catchError(err => {
222 this.refreshingTokenObservable = null 224 this.refreshingTokenObservable = null
223 225
224 logger.error(err) 226 logger.error(err)
225 logger.info('Cannot refresh token -> logout...') 227 logger.info('Cannot refresh token -> logout...')
226 this.logout() 228 this.logout()
227 this.router.navigate([ '/login' ]) 229
228 230 const externalLoginUrl = PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverService.getHTMLConfig())
229 return observableThrowError(() => ({ 231 if (externalLoginUrl) window.location.href = externalLoginUrl
230 error: $localize`You need to reconnect.` 232 else this.router.navigate([ '/login' ])
231 })) 233
232 }), 234 return observableThrowError(() => ({
233 share() 235 error: $localize`You need to reconnect.`
234 ) 236 }))
237 }),
238 share()
239 )
235 240
236 return this.refreshingTokenObservable 241 return this.refreshingTokenObservable
237 } 242 }
diff --git a/client/src/app/menu/menu.component.ts b/client/src/app/menu/menu.component.ts
index 63f01df92..568cb98bb 100644
--- a/client/src/app/menu/menu.component.ts
+++ b/client/src/app/menu/menu.component.ts
@@ -1,6 +1,7 @@
1import { HotkeysService } from 'angular2-hotkeys' 1import { HotkeysService } from 'angular2-hotkeys'
2import * as debug from 'debug' 2import * as debug from 'debug'
3import { switchMap } from 'rxjs/operators' 3import { switchMap } from 'rxjs/operators'
4import { environment } from 'src/environments/environment'
4import { ViewportScroller } from '@angular/common' 5import { ViewportScroller } from '@angular/common'
5import { Component, OnInit, ViewChild } from '@angular/core' 6import { Component, OnInit, ViewChild } from '@angular/core'
6import { Router } from '@angular/router' 7import { Router } from '@angular/router'
@@ -131,12 +132,7 @@ export class MenuComponent implements OnInit {
131 } 132 }
132 133
133 getExternalLoginHref () { 134 getExternalLoginHref () {
134 if (!this.serverConfig || this.serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined 135 return PluginsManager.getDefaultLoginHref(environment.apiUrl, this.serverConfig)
135
136 const externalAuths = this.serverConfig.plugin.registeredExternalAuths
137 if (externalAuths.length !== 1) return undefined
138
139 return PluginsManager.getExternalAuthHref(externalAuths[0])
140 } 136 }
141 137
142 isRegistrationAllowed () { 138 isRegistrationAllowed () {
diff --git a/client/src/root-helpers/plugins-manager.ts b/client/src/root-helpers/plugins-manager.ts
index 6c64e2b01..e5b06a94c 100644
--- a/client/src/root-helpers/plugins-manager.ts
+++ b/client/src/root-helpers/plugins-manager.ts
@@ -3,7 +3,7 @@ import * as debug from 'debug'
3import { firstValueFrom, ReplaySubject } from 'rxjs' 3import { firstValueFrom, ReplaySubject } from 'rxjs'
4import { first, shareReplay } from 'rxjs/operators' 4import { first, shareReplay } from 'rxjs/operators'
5import { RegisterClientHelpers } from 'src/types/register-client-option.model' 5import { RegisterClientHelpers } from 'src/types/register-client-option.model'
6import { getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks' 6import { getExternalAuthHref, getHookType, internalRunHook } from '@shared/core-utils/plugins/hooks'
7import { 7import {
8 ClientHookName, 8 ClientHookName,
9 clientHookObject, 9 clientHookObject,
@@ -16,7 +16,6 @@ import {
16 RegisterClientRouteOptions, 16 RegisterClientRouteOptions,
17 RegisterClientSettingsScriptOptions, 17 RegisterClientSettingsScriptOptions,
18 RegisterClientVideoFieldOptions, 18 RegisterClientVideoFieldOptions,
19 RegisteredExternalAuthConfig,
20 ServerConfigPlugin 19 ServerConfigPlugin
21} from '@shared/models' 20} from '@shared/models'
22import { environment } from '../environments/environment' 21import { environment } from '../environments/environment'
@@ -94,9 +93,13 @@ class PluginsManager {
94 return isTheme ? '/themes' : '/plugins' 93 return isTheme ? '/themes' : '/plugins'
95 } 94 }
96 95
97 static getExternalAuthHref (auth: RegisteredExternalAuthConfig) { 96 static getDefaultLoginHref (apiUrl: string, serverConfig: HTMLServerConfig) {
98 return environment.apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}` 97 if (!serverConfig || serverConfig.client.menu.login.redirectOnSingleExternalAuth !== true) return undefined
99 98
99 const externalAuths = serverConfig.plugin.registeredExternalAuths
100 if (externalAuths.length !== 1) return undefined
101
102 return getExternalAuthHref(apiUrl, externalAuths[0])
100 } 103 }
101 104
102 loadPluginsList (config: HTMLServerConfig) { 105 loadPluginsList (config: HTMLServerConfig) {
diff --git a/config/default.yaml b/config/default.yaml
index 1b7c3314d..d4977d003 100644
--- a/config/default.yaml
+++ b/config/default.yaml
@@ -37,6 +37,11 @@ rates_limit:
37 window: 10 minutes 37 window: 10 minutes
38 max: 10 38 max: 10
39 39
40oauth2:
41 token_lifetime:
42 access_token: '1 day'
43 refresh_token: '2 weeks'
44
40# Proxies to trust to get real client IP 45# Proxies to trust to get real client IP
41# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 46# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
42# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 47# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
diff --git a/config/production.yaml.example b/config/production.yaml.example
index da067b3b5..17dc6839b 100644
--- a/config/production.yaml.example
+++ b/config/production.yaml.example
@@ -35,6 +35,11 @@ rates_limit:
35 window: 10 minutes 35 window: 10 minutes
36 max: 10 36 max: 10
37 37
38oauth2:
39 token_lifetime:
40 access_token: '1 day'
41 refresh_token: '2 weeks'
42
38# Proxies to trust to get real client IP 43# Proxies to trust to get real client IP
39# If you run PeerTube just behind a local proxy (nginx), keep 'loopback' 44# If you run PeerTube just behind a local proxy (nginx), keep 'loopback'
40# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet) 45# If you run PeerTube behind a remote proxy, add the proxy IP address (or subnet)
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) {
8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined 8 return exists(value) && VIDEO_LANGUAGES[value] !== undefined
9} 9}
10 10
11const videoCaptionTypesRegex = Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT) 11// MacOS sends application/octet-stream
12 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 12const videoCaptionTypesRegex = [ ...Object.keys(MIMETYPES.VIDEO_CAPTIONS.MIMETYPE_EXT), 'application/octet-stream' ]
13 .map(m => `(${m})`) 13 .map(m => `(${m})`)
14 .join('|') 14 .join('|')
15
15function isVideoCaptionFile (files: UploadFilesForCheck, field: string) { 16function isVideoCaptionFile (files: UploadFilesForCheck, field: string) {
16 return isFileValid({ 17 return isFileValid({
17 files, 18 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) {
22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined 22 return exists(value) && VIDEO_IMPORT_STATES[value] !== undefined
23} 23}
24 24
25const videoTorrentImportRegex = Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT) 25// MacOS sends application/octet-stream
26 .concat([ 'application/octet-stream' ]) // MacOS sends application/octet-stream 26const videoTorrentImportRegex = [ ...Object.keys(MIMETYPES.TORRENT.MIMETYPE_EXT), 'application/octet-stream' ]
27 .map(m => `(${m})`) 27 .map(m => `(${m})`)
28 .join('|') 28 .join('|')
29
29function isVideoImportTorrentFile (files: UploadFilesForCheck) { 30function isVideoImportTorrentFile (files: UploadFilesForCheck) {
30 return isFileValid({ 31 return isFileValid({
31 files, 32 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 () {
174function checkStorageConfig () { 174function checkStorageConfig () {
175 // Check storage directory locations 175 // Check storage directory locations
176 if (isProdInstance()) { 176 if (isProdInstance()) {
177 const configStorage = config.get('storage') 177 const configStorage = config.get<{ [ name: string ]: string }>('storage')
178
178 for (const key of Object.keys(configStorage)) { 179 for (const key of Object.keys(configStorage)) {
179 if (configStorage[key].startsWith('storage/')) { 180 if (configStorage[key].startsWith('storage/')) {
180 logger.warn( 181 logger.warn(
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 () {
13 'webserver.https', 'webserver.hostname', 'webserver.port', 13 'webserver.https', 'webserver.hostname', 'webserver.port',
14 'secrets.peertube', 14 'secrets.peertube',
15 'trust_proxy', 15 'trust_proxy',
16 'oauth2.token_lifetime.access_token', 'oauth2.token_lifetime.refresh_token',
16 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max', 17 'database.hostname', 'database.port', 'database.username', 'database.password', 'database.pool.max',
17 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address', 18 'smtp.hostname', 'smtp.port', 'smtp.username', 'smtp.password', 'smtp.tls', 'smtp.from_address',
18 'email.body.signature', 'email.subject.prefix', 19 '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 = {
149 HOSTNAME: config.get<string>('webserver.hostname'), 149 HOSTNAME: config.get<string>('webserver.hostname'),
150 PORT: config.get<number>('webserver.port') 150 PORT: config.get<number>('webserver.port')
151 }, 151 },
152 OAUTH2: {
153 TOKEN_LIFETIME: {
154 ACCESS_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.access_token')),
155 REFRESH_TOKEN: parseDurationToMs(config.get<string>('oauth2.token_lifetime.refresh_token'))
156 }
157 },
152 RATES_LIMIT: { 158 RATES_LIMIT: {
153 API: { 159 API: {
154 WINDOW_MS: parseDurationToMs(config.get<string>('rates_limit.api.window')), 160 WINDOW_MS: parseDurationToMs(config.get<string>('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 = {
101 VIDEO_REDUNDANCIES: [ 'name' ] 101 VIDEO_REDUNDANCIES: [ 'name' ]
102} 102}
103 103
104const OAUTH_LIFETIME = {
105 ACCESS_TOKEN: 3600 * 24, // 1 day, for upload
106 REFRESH_TOKEN: 1209600 // 2 weeks
107}
108
109const ROUTE_CACHE_LIFETIME = { 104const ROUTE_CACHE_LIFETIME = {
110 FEEDS: '15 minutes', 105 FEEDS: '15 minutes',
111 ROBOTS: '2 hours', 106 ROBOTS: '2 hours',
@@ -1033,7 +1028,6 @@ export {
1033 JOB_ATTEMPTS, 1028 JOB_ATTEMPTS,
1034 AP_CLEANER, 1029 AP_CLEANER,
1035 LAST_MIGRATION_VERSION, 1030 LAST_MIGRATION_VERSION,
1036 OAUTH_LIFETIME,
1037 CUSTOM_HTML_TAG_COMMENTS, 1031 CUSTOM_HTML_TAG_COMMENTS,
1038 STATS_TIMESERIE, 1032 STATS_TIMESERIE,
1039 BROADCAST_CONCURRENCY, 1033 BROADCAST_CONCURRENCY,
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 () {
51 const tasks: Promise<any>[] = [] 51 const tasks: Promise<any>[] = []
52 52
53 // Cache directories 53 // Cache directories
54 for (const key of Object.keys(cacheDirectories)) { 54 for (const dir of cacheDirectories) {
55 const dir = cacheDirectories[key]
56 tasks.push(removeDirectoryOrContent(dir)) 55 tasks.push(removeDirectoryOrContent(dir))
57 } 56 }
58 57
@@ -87,8 +86,7 @@ function createDirectoriesIfNotExist () {
87 } 86 }
88 87
89 // Cache directories 88 // Cache directories
90 for (const key of Object.keys(cacheDirectories)) { 89 for (const dir of cacheDirectories) {
91 const dir = cacheDirectories[key]
92 tasks.push(ensureDir(dir)) 90 tasks.push(ensureDir(dir))
93 } 91 }
94 92
diff --git a/server/lib/auth/external-auth.ts b/server/lib/auth/external-auth.ts
index 053112801..bc5b74257 100644
--- a/server/lib/auth/external-auth.ts
+++ b/server/lib/auth/external-auth.ts
@@ -1,26 +1,35 @@
1 1
2import { isUserDisplayNameValid, isUserRoleValid, isUserUsernameValid } from '@server/helpers/custom-validators/users' 2import {
3 isUserAdminFlagsValid,
4 isUserDisplayNameValid,
5 isUserRoleValid,
6 isUserUsernameValid,
7 isUserVideoQuotaDailyValid,
8 isUserVideoQuotaValid
9} from '@server/helpers/custom-validators/users'
3import { logger } from '@server/helpers/logger' 10import { logger } from '@server/helpers/logger'
4import { generateRandomString } from '@server/helpers/utils' 11import { generateRandomString } from '@server/helpers/utils'
5import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants' 12import { PLUGIN_EXTERNAL_AUTH_TOKEN_LIFETIME } from '@server/initializers/constants'
6import { PluginManager } from '@server/lib/plugins/plugin-manager' 13import { PluginManager } from '@server/lib/plugins/plugin-manager'
7import { OAuthTokenModel } from '@server/models/oauth/oauth-token' 14import { OAuthTokenModel } from '@server/models/oauth/oauth-token'
15import { MUser } from '@server/types/models'
8import { 16import {
9 RegisterServerAuthenticatedResult, 17 RegisterServerAuthenticatedResult,
10 RegisterServerAuthPassOptions, 18 RegisterServerAuthPassOptions,
11 RegisterServerExternalAuthenticatedResult 19 RegisterServerExternalAuthenticatedResult
12} from '@server/types/plugins/register-server-auth.model' 20} from '@server/types/plugins/register-server-auth.model'
13import { UserRole } from '@shared/models' 21import { UserAdminFlag, UserRole } from '@shared/models'
22import { BypassLogin } from './oauth-model'
23
24export type ExternalUser =
25 Pick<MUser, 'username' | 'email' | 'role' | 'adminFlags' | 'videoQuotaDaily' | 'videoQuota'> &
26 { displayName: string }
14 27
15// Token is the key, expiration date is the value 28// Token is the key, expiration date is the value
16const authBypassTokens = new Map<string, { 29const authBypassTokens = new Map<string, {
17 expires: Date 30 expires: Date
18 user: { 31 user: ExternalUser
19 username: string 32 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
20 email: string
21 displayName: string
22 role: UserRole
23 }
24 authName: string 33 authName: string
25 npmName: string 34 npmName: string
26}>() 35}>()
@@ -56,7 +65,8 @@ async function onExternalUserAuthenticated (options: {
56 expires, 65 expires,
57 user, 66 user,
58 npmName, 67 npmName,
59 authName 68 authName,
69 userUpdater: authResult.userUpdater
60 }) 70 })
61 71
62 // Cleanup expired tokens 72 // Cleanup expired tokens
@@ -78,7 +88,7 @@ async function getAuthNameFromRefreshGrant (refreshToken?: string) {
78 return tokenModel?.authName 88 return tokenModel?.authName
79} 89}
80 90
81async function getBypassFromPasswordGrant (username: string, password: string) { 91async function getBypassFromPasswordGrant (username: string, password: string): Promise<BypassLogin> {
82 const plugins = PluginManager.Instance.getIdAndPassAuths() 92 const plugins = PluginManager.Instance.getIdAndPassAuths()
83 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = [] 93 const pluginAuths: { npmName?: string, registerAuthOptions: RegisterServerAuthPassOptions }[] = []
84 94
@@ -133,7 +143,8 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
133 bypass: true, 143 bypass: true,
134 pluginName: pluginAuth.npmName, 144 pluginName: pluginAuth.npmName,
135 authName: authOptions.authName, 145 authName: authOptions.authName,
136 user: buildUserResult(loginResult) 146 user: buildUserResult(loginResult),
147 userUpdater: loginResult.userUpdater
137 } 148 }
138 } catch (err) { 149 } catch (err) {
139 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err }) 150 logger.error('Error in auth method %s of plugin %s', authOptions.authName, pluginAuth.npmName, { err })
@@ -143,7 +154,7 @@ async function getBypassFromPasswordGrant (username: string, password: string) {
143 return undefined 154 return undefined
144} 155}
145 156
146function getBypassFromExternalAuth (username: string, externalAuthToken: string) { 157function getBypassFromExternalAuth (username: string, externalAuthToken: string): BypassLogin {
147 const obj = authBypassTokens.get(externalAuthToken) 158 const obj = authBypassTokens.get(externalAuthToken)
148 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token') 159 if (!obj) throw new Error('Cannot authenticate user with unknown bypass token')
149 160
@@ -167,33 +178,29 @@ function getBypassFromExternalAuth (username: string, externalAuthToken: string)
167 bypass: true, 178 bypass: true,
168 pluginName: npmName, 179 pluginName: npmName,
169 authName, 180 authName,
181 userUpdater: obj.userUpdater,
170 user 182 user
171 } 183 }
172} 184}
173 185
174function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) { 186function isAuthResultValid (npmName: string, authName: string, result: RegisterServerAuthenticatedResult) {
175 if (!isUserUsernameValid(result.username)) { 187 const returnError = (field: string) => {
176 logger.error('Auth method %s of plugin %s did not provide a valid username.', authName, npmName, { username: result.username }) 188 logger.error('Auth method %s of plugin %s did not provide a valid %s.', authName, npmName, field, { [field]: result[field] })
177 return false 189 return false
178 } 190 }
179 191
180 if (!result.email) { 192 if (!isUserUsernameValid(result.username)) return returnError('username')
181 logger.error('Auth method %s of plugin %s did not provide a valid email.', authName, npmName, { email: result.email }) 193 if (!result.email) return returnError('email')
182 return false
183 }
184 194
185 // role is optional 195 // Following fields are optional
186 if (result.role && !isUserRoleValid(result.role)) { 196 if (result.role && !isUserRoleValid(result.role)) return returnError('role')
187 logger.error('Auth method %s of plugin %s did not provide a valid role.', authName, npmName, { role: result.role }) 197 if (result.displayName && !isUserDisplayNameValid(result.displayName)) return returnError('displayName')
188 return false 198 if (result.adminFlags && !isUserAdminFlagsValid(result.adminFlags)) return returnError('adminFlags')
189 } 199 if (result.videoQuota && !isUserVideoQuotaValid(result.videoQuota + '')) return returnError('videoQuota')
200 if (result.videoQuotaDaily && !isUserVideoQuotaDailyValid(result.videoQuotaDaily + '')) return returnError('videoQuotaDaily')
190 201
191 // display name is optional 202 if (result.userUpdater && typeof result.userUpdater !== 'function') {
192 if (result.displayName && !isUserDisplayNameValid(result.displayName)) { 203 logger.error('Auth method %s of plugin %s did not provide a valid user updater function.', authName, npmName)
193 logger.error(
194 'Auth method %s of plugin %s did not provide a valid display name.',
195 authName, npmName, { displayName: result.displayName }
196 )
197 return false 204 return false
198 } 205 }
199 206
@@ -205,7 +212,12 @@ function buildUserResult (pluginResult: RegisterServerAuthenticatedResult) {
205 username: pluginResult.username, 212 username: pluginResult.username,
206 email: pluginResult.email, 213 email: pluginResult.email,
207 role: pluginResult.role ?? UserRole.USER, 214 role: pluginResult.role ?? UserRole.USER,
208 displayName: pluginResult.displayName || pluginResult.username 215 displayName: pluginResult.displayName || pluginResult.username,
216
217 adminFlags: pluginResult.adminFlags ?? UserAdminFlag.NONE,
218
219 videoQuota: pluginResult.videoQuota,
220 videoQuotaDaily: pluginResult.videoQuotaDaily
209 } 221 }
210} 222}
211 223
diff --git a/server/lib/auth/oauth-model.ts b/server/lib/auth/oauth-model.ts
index 322b69e3a..43909284f 100644
--- a/server/lib/auth/oauth-model.ts
+++ b/server/lib/auth/oauth-model.ts
@@ -1,11 +1,13 @@
1import express from 'express' 1import express from 'express'
2import { AccessDeniedError } from '@node-oauth/oauth2-server' 2import { AccessDeniedError } from '@node-oauth/oauth2-server'
3import { PluginManager } from '@server/lib/plugins/plugin-manager' 3import { PluginManager } from '@server/lib/plugins/plugin-manager'
4import { AccountModel } from '@server/models/account/account'
5import { AuthenticatedResultUpdaterFieldName, RegisterServerAuthenticatedResult } from '@server/types'
4import { MOAuthClient } from '@server/types/models' 6import { MOAuthClient } from '@server/types/models'
5import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token' 7import { MOAuthTokenUser } from '@server/types/models/oauth/oauth-token'
6import { MUser } from '@server/types/models/user/user' 8import { MUser, MUserDefault } from '@server/types/models/user/user'
7import { pick } from '@shared/core-utils' 9import { pick } from '@shared/core-utils'
8import { UserRole } from '@shared/models/users/user-role' 10import { AttributesOnly } from '@shared/typescript-utils'
9import { logger } from '../../helpers/logger' 11import { logger } from '../../helpers/logger'
10import { CONFIG } from '../../initializers/config' 12import { CONFIG } from '../../initializers/config'
11import { OAuthClientModel } from '../../models/oauth/oauth-client' 13import { OAuthClientModel } from '../../models/oauth/oauth-client'
@@ -13,6 +15,7 @@ import { OAuthTokenModel } from '../../models/oauth/oauth-token'
13import { UserModel } from '../../models/user/user' 15import { UserModel } from '../../models/user/user'
14import { findAvailableLocalActorName } from '../local-actor' 16import { findAvailableLocalActorName } from '../local-actor'
15import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user' 17import { buildUser, createUserAccountAndChannelAndPlaylist } from '../user'
18import { ExternalUser } from './external-auth'
16import { TokensCache } from './tokens-cache' 19import { TokensCache } from './tokens-cache'
17 20
18type TokenInfo = { 21type TokenInfo = {
@@ -26,12 +29,8 @@ export type BypassLogin = {
26 bypass: boolean 29 bypass: boolean
27 pluginName: string 30 pluginName: string
28 authName?: string 31 authName?: string
29 user: { 32 user: ExternalUser
30 username: string 33 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
31 email: string
32 displayName: string
33 role: UserRole
34 }
35} 34}
36 35
37async function getAccessToken (bearerToken: string) { 36async function getAccessToken (bearerToken: string) {
@@ -89,7 +88,9 @@ async function getUser (usernameOrEmail?: string, password?: string, bypassLogin
89 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName) 88 logger.info('Bypassing oauth login by plugin %s.', bypassLogin.pluginName)
90 89
91 let user = await UserModel.loadByEmail(bypassLogin.user.email) 90 let user = await UserModel.loadByEmail(bypassLogin.user.email)
91
92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user) 92 if (!user) user = await createUserFromExternal(bypassLogin.pluginName, bypassLogin.user)
93 else user = await updateUserFromExternal(user, bypassLogin.user, bypassLogin.userUpdater)
93 94
94 // Cannot create a user 95 // Cannot create a user
95 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.') 96 if (!user) throw new AccessDeniedError('Cannot create such user: an actor with that name already exists.')
@@ -219,16 +220,11 @@ export {
219 220
220// --------------------------------------------------------------------------- 221// ---------------------------------------------------------------------------
221 222
222async function createUserFromExternal (pluginAuth: string, options: { 223async function createUserFromExternal (pluginAuth: string, userOptions: ExternalUser) {
223 username: string 224 const username = await findAvailableLocalActorName(userOptions.username)
224 email: string
225 role: UserRole
226 displayName: string
227}) {
228 const username = await findAvailableLocalActorName(options.username)
229 225
230 const userToCreate = buildUser({ 226 const userToCreate = buildUser({
231 ...pick(options, [ 'email', 'role' ]), 227 ...pick(userOptions, [ 'email', 'role', 'adminFlags', 'videoQuota', 'videoQuotaDaily' ]),
232 228
233 username, 229 username,
234 emailVerified: null, 230 emailVerified: null,
@@ -238,12 +234,57 @@ async function createUserFromExternal (pluginAuth: string, options: {
238 234
239 const { user } = await createUserAccountAndChannelAndPlaylist({ 235 const { user } = await createUserAccountAndChannelAndPlaylist({
240 userToCreate, 236 userToCreate,
241 userDisplayName: options.displayName 237 userDisplayName: userOptions.displayName
242 }) 238 })
243 239
244 return user 240 return user
245} 241}
246 242
243async function updateUserFromExternal (
244 user: MUserDefault,
245 userOptions: ExternalUser,
246 userUpdater: RegisterServerAuthenticatedResult['userUpdater']
247) {
248 if (!userUpdater) return user
249
250 {
251 type UserAttributeKeys = keyof AttributesOnly<UserModel>
252 const mappingKeys: { [ id in UserAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
253 role: 'role',
254 adminFlags: 'adminFlags',
255 videoQuota: 'videoQuota',
256 videoQuotaDaily: 'videoQuotaDaily'
257 }
258
259 for (const modelKey of Object.keys(mappingKeys)) {
260 const pluginOptionKey = mappingKeys[modelKey]
261
262 const newValue = userUpdater({ fieldName: pluginOptionKey, currentValue: user[modelKey], newValue: userOptions[pluginOptionKey] })
263 user.set(modelKey, newValue)
264 }
265 }
266
267 {
268 type AccountAttributeKeys = keyof Partial<AttributesOnly<AccountModel>>
269 const mappingKeys: { [ id in AccountAttributeKeys ]?: AuthenticatedResultUpdaterFieldName } = {
270 name: 'displayName'
271 }
272
273 for (const modelKey of Object.keys(mappingKeys)) {
274 const optionKey = mappingKeys[modelKey]
275
276 const newValue = userUpdater({ fieldName: optionKey, currentValue: user.Account[modelKey], newValue: userOptions[optionKey] })
277 user.Account.set(modelKey, newValue)
278 }
279 }
280
281 logger.debug('Updated user %s with plugin userUpdated function.', user.email, { user, userOptions })
282
283 user.Account = await user.Account.save()
284
285 return user.save()
286}
287
247function checkUserValidityOrThrow (user: MUser) { 288function checkUserValidityOrThrow (user: MUser) {
248 if (user.blocked) throw new AccessDeniedError('User is blocked.') 289 if (user.blocked) throw new AccessDeniedError('User is blocked.')
249} 290}
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, {
10} from '@node-oauth/oauth2-server' 10} from '@node-oauth/oauth2-server'
11import { randomBytesPromise } from '@server/helpers/core-utils' 11import { randomBytesPromise } from '@server/helpers/core-utils'
12import { isOTPValid } from '@server/helpers/otp' 12import { isOTPValid } from '@server/helpers/otp'
13import { CONFIG } from '@server/initializers/config'
13import { MOAuthClient } from '@server/types/models' 14import { MOAuthClient } from '@server/types/models'
14import { sha1 } from '@shared/extra-utils' 15import { sha1 } from '@shared/extra-utils'
15import { HttpStatusCode } from '@shared/models' 16import { HttpStatusCode } from '@shared/models'
16import { OAUTH_LIFETIME, OTP } from '../../initializers/constants' 17import { OTP } from '../../initializers/constants'
17import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model' 18import { BypassLogin, getClient, getRefreshToken, getUser, revokeToken, saveToken } from './oauth-model'
18 19
19class MissingTwoFactorError extends Error { 20class MissingTwoFactorError extends Error {
@@ -32,8 +33,9 @@ class InvalidTwoFactorError extends Error {
32 * 33 *
33 */ 34 */
34const oAuthServer = new OAuth2Server({ 35const oAuthServer = new OAuth2Server({
35 accessTokenLifetime: OAUTH_LIFETIME.ACCESS_TOKEN, 36 // Wants seconds
36 refreshTokenLifetime: OAUTH_LIFETIME.REFRESH_TOKEN, 37 accessTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN / 1000,
38 refreshTokenLifetime: CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN / 1000,
37 39
38 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications 40 // See https://github.com/oauthjs/node-oauth2-server/wiki/Model-specification for the model specifications
39 model: require('./oauth-model') 41 model: require('./oauth-model')
@@ -182,10 +184,10 @@ function generateRandomToken () {
182 184
183function getTokenExpiresAt (type: 'access' | 'refresh') { 185function getTokenExpiresAt (type: 'access' | 'refresh') {
184 const lifetime = type === 'access' 186 const lifetime = type === 'access'
185 ? OAUTH_LIFETIME.ACCESS_TOKEN 187 ? CONFIG.OAUTH2.TOKEN_LIFETIME.ACCESS_TOKEN
186 : OAUTH_LIFETIME.REFRESH_TOKEN 188 : CONFIG.OAUTH2.TOKEN_LIFETIME.REFRESH_TOKEN
187 189
188 return new Date(Date.now() + lifetime * 1000) 190 return new Date(Date.now() + lifetime)
189} 191}
190 192
191async function buildToken () { 193async 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 {
36 const token = this.userHavingToken.get(userId) 36 const token = this.userHavingToken.get(userId)
37 37
38 if (token !== undefined) { 38 if (token !== undefined) {
39 this.accessTokenCache.del(token) 39 this.accessTokenCache.delete(token)
40 this.userHavingToken.del(userId) 40 this.userHavingToken.delete(userId)
41 } 41 }
42 } 42 }
43 43
@@ -45,8 +45,8 @@ export class TokensCache {
45 const tokenModel = this.accessTokenCache.get(token) 45 const tokenModel = this.accessTokenCache.get(token)
46 46
47 if (tokenModel !== undefined) { 47 if (tokenModel !== undefined) {
48 this.userHavingToken.del(tokenModel.userId) 48 this.userHavingToken.delete(tokenModel.userId)
49 this.accessTokenCache.del(token) 49 this.accessTokenCache.delete(token)
50 } 50 }
51 } 51 }
52} 52}
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<Partial<AttributesOnly<VideoFileModel>
439 if (!element) return videoFile.save({ transaction }) 439 if (!element) return videoFile.save({ transaction })
440 440
441 for (const k of Object.keys(videoFile.toJSON())) { 441 for (const k of Object.keys(videoFile.toJSON())) {
442 element[k] = videoFile[k] 442 element.set(k, videoFile[k])
443 } 443 }
444 444
445 return element.save({ transaction }) 445 return element.save({ transaction })
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 @@
1import './oauth'
1import './two-factor' 2import './two-factor'
2import './user-subscriptions' 3import './user-subscriptions'
3import './user-videos' 4import './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 @@
1/* eslint-disable @typescript-eslint/no-unused-expressions,@typescript-eslint/require-await */
2
3import { expect } from 'chai'
4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, OAuth2ErrorCode, PeerTubeProblemDocument } from '@shared/models'
6import { cleanupTests, createSingleServer, killallServers, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7
8describe('Test oauth', function () {
9 let server: PeerTubeServer
10
11 before(async function () {
12 this.timeout(30000)
13
14 server = await createSingleServer(1, {
15 rates_limit: {
16 login: {
17 max: 30
18 }
19 }
20 })
21
22 await setAccessTokensToServers([ server ])
23 })
24
25 describe('OAuth client', function () {
26
27 function expectInvalidClient (body: PeerTubeProblemDocument) {
28 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
29 expect(body.error).to.contain('client is invalid')
30 expect(body.type.startsWith('https://')).to.be.true
31 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
32 }
33
34 it('Should create a new client')
35
36 it('Should return the first client')
37
38 it('Should remove the last client')
39
40 it('Should not login with an invalid client id', async function () {
41 const client = { id: 'client', secret: server.store.client.secret }
42 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
43
44 expectInvalidClient(body)
45 })
46
47 it('Should not login with an invalid client secret', async function () {
48 const client = { id: server.store.client.id, secret: 'coucou' }
49 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
50
51 expectInvalidClient(body)
52 })
53 })
54
55 describe('Login', function () {
56
57 function expectInvalidCredentials (body: PeerTubeProblemDocument) {
58 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
59 expect(body.error).to.contain('credentials are invalid')
60 expect(body.type.startsWith('https://')).to.be.true
61 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
62 }
63
64 it('Should not login with an invalid username', async function () {
65 const user = { username: 'captain crochet', password: server.store.user.password }
66 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
67
68 expectInvalidCredentials(body)
69 })
70
71 it('Should not login with an invalid password', async function () {
72 const user = { username: server.store.user.username, password: 'mew_three' }
73 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
74
75 expectInvalidCredentials(body)
76 })
77
78 it('Should be able to login', async function () {
79 await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
80 })
81
82 it('Should be able to login with an insensitive username', async function () {
83 const user = { username: 'RoOt', password: server.store.user.password }
84 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
85
86 const user2 = { username: 'rOoT', password: server.store.user.password }
87 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
88
89 const user3 = { username: 'ROOt', password: server.store.user.password }
90 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
91 })
92 })
93
94 describe('Logout', function () {
95
96 it('Should logout (revoke token)', async function () {
97 await server.login.logout({ token: server.accessToken })
98 })
99
100 it('Should not be able to get the user information', async function () {
101 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
102 })
103
104 it('Should not be able to upload a video', async function () {
105 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
106 })
107
108 it('Should be able to login again', async function () {
109 const body = await server.login.login()
110 server.accessToken = body.access_token
111 server.refreshToken = body.refresh_token
112 })
113
114 it('Should be able to get my user information again', async function () {
115 await server.users.getMyInfo()
116 })
117
118 it('Should have an expired access token', async function () {
119 this.timeout(60000)
120
121 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
122 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
123
124 await killallServers([ server ])
125 await server.run()
126
127 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
128 })
129
130 it('Should not be able to refresh an access token with an expired refresh token', async function () {
131 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
132 })
133
134 it('Should refresh the token', async function () {
135 this.timeout(50000)
136
137 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
138 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
139
140 await killallServers([ server ])
141 await server.run()
142
143 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
144 server.accessToken = res.body.access_token
145 server.refreshToken = res.body.refresh_token
146 })
147
148 it('Should be able to get my user information again', async function () {
149 await server.users.getMyInfo()
150 })
151 })
152
153 describe('Custom token lifetime', function () {
154 before(async function () {
155 this.timeout(120_000)
156
157 await server.kill()
158 await server.run({
159 oauth2: {
160 token_lifetime: {
161 access_token: '2 seconds',
162 refresh_token: '2 seconds'
163 }
164 }
165 })
166 })
167
168 it('Should have a very short access token lifetime', async function () {
169 this.timeout(50000)
170
171 const { access_token: accessToken } = await server.login.login()
172 await server.users.getMyInfo({ token: accessToken })
173
174 await wait(3000)
175 await server.users.getMyInfo({ token: accessToken, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
176 })
177
178 it('Should have a very short refresh token lifetime', async function () {
179 this.timeout(50000)
180
181 const { refresh_token: refreshToken } = await server.login.login()
182 await server.login.refreshToken({ refreshToken })
183
184 await wait(3000)
185 await server.login.refreshToken({ refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
186 })
187 })
188
189 after(async function () {
190 await cleanupTests([ server ])
191 })
192})
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 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { testImage } from '@server/tests/shared' 4import { testImage } from '@server/tests/shared'
5import { AbuseState, HttpStatusCode, OAuth2ErrorCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models' 5import { AbuseState, HttpStatusCode, UserAdminFlag, UserRole, VideoPlaylistType } from '@shared/models'
6import { 6import { cleanupTests, createSingleServer, PeerTubeServer, setAccessTokensToServers } from '@shared/server-commands'
7 cleanupTests,
8 createSingleServer,
9 killallServers,
10 makePutBodyRequest,
11 PeerTubeServer,
12 setAccessTokensToServers
13} from '@shared/server-commands'
14 7
15describe('Test users', function () { 8describe('Test users', function () {
16 let server: PeerTubeServer 9 let server: PeerTubeServer
@@ -39,166 +32,6 @@ describe('Test users', function () {
39 await server.plugins.install({ npmName: 'peertube-theme-background-red' }) 32 await server.plugins.install({ npmName: 'peertube-theme-background-red' })
40 }) 33 })
41 34
42 describe('OAuth client', function () {
43 it('Should create a new client')
44
45 it('Should return the first client')
46
47 it('Should remove the last client')
48
49 it('Should not login with an invalid client id', async function () {
50 const client = { id: 'client', secret: server.store.client.secret }
51 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
52
53 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
54 expect(body.error).to.contain('client is invalid')
55 expect(body.type.startsWith('https://')).to.be.true
56 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
57 })
58
59 it('Should not login with an invalid client secret', async function () {
60 const client = { id: server.store.client.id, secret: 'coucou' }
61 const body = await server.login.login({ client, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
62
63 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_CLIENT)
64 expect(body.error).to.contain('client is invalid')
65 expect(body.type.startsWith('https://')).to.be.true
66 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_CLIENT)
67 })
68 })
69
70 describe('Login', function () {
71
72 it('Should not login with an invalid username', async function () {
73 const user = { username: 'captain crochet', password: server.store.user.password }
74 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
75
76 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
77 expect(body.error).to.contain('credentials are invalid')
78 expect(body.type.startsWith('https://')).to.be.true
79 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
80 })
81
82 it('Should not login with an invalid password', async function () {
83 const user = { username: server.store.user.username, password: 'mew_three' }
84 const body = await server.login.login({ user, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
85
86 expect(body.code).to.equal(OAuth2ErrorCode.INVALID_GRANT)
87 expect(body.error).to.contain('credentials are invalid')
88 expect(body.type.startsWith('https://')).to.be.true
89 expect(body.type).to.contain(OAuth2ErrorCode.INVALID_GRANT)
90 })
91
92 it('Should not be able to upload a video', async function () {
93 token = 'my_super_token'
94
95 await server.videos.upload({ token, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
96 })
97
98 it('Should not be able to follow', async function () {
99 token = 'my_super_token'
100
101 await server.follows.follow({
102 hosts: [ 'http://example.com' ],
103 token,
104 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
105 })
106 })
107
108 it('Should not be able to unfollow')
109
110 it('Should be able to login', async function () {
111 const body = await server.login.login({ expectedStatus: HttpStatusCode.OK_200 })
112
113 token = body.access_token
114 })
115
116 it('Should be able to login with an insensitive username', async function () {
117 const user = { username: 'RoOt', password: server.store.user.password }
118 await server.login.login({ user, expectedStatus: HttpStatusCode.OK_200 })
119
120 const user2 = { username: 'rOoT', password: server.store.user.password }
121 await server.login.login({ user: user2, expectedStatus: HttpStatusCode.OK_200 })
122
123 const user3 = { username: 'ROOt', password: server.store.user.password }
124 await server.login.login({ user: user3, expectedStatus: HttpStatusCode.OK_200 })
125 })
126 })
127
128 describe('Logout', function () {
129 it('Should logout (revoke token)', async function () {
130 await server.login.logout({ token: server.accessToken })
131 })
132
133 it('Should not be able to get the user information', async function () {
134 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
135 })
136
137 it('Should not be able to upload a video', async function () {
138 await server.videos.upload({ attributes: { name: 'video' }, expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
139 })
140
141 it('Should not be able to rate a video', async function () {
142 const path = '/api/v1/videos/'
143 const data = {
144 rating: 'likes'
145 }
146
147 const options = {
148 url: server.url,
149 path: path + videoId,
150 token: 'wrong token',
151 fields: data,
152 expectedStatus: HttpStatusCode.UNAUTHORIZED_401
153 }
154 await makePutBodyRequest(options)
155 })
156
157 it('Should be able to login again', async function () {
158 const body = await server.login.login()
159 server.accessToken = body.access_token
160 server.refreshToken = body.refresh_token
161 })
162
163 it('Should be able to get my user information again', async function () {
164 await server.users.getMyInfo()
165 })
166
167 it('Should have an expired access token', async function () {
168 this.timeout(60000)
169
170 await server.sql.setTokenField(server.accessToken, 'accessTokenExpiresAt', new Date().toISOString())
171 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', new Date().toISOString())
172
173 await killallServers([ server ])
174 await server.run()
175
176 await server.users.getMyInfo({ expectedStatus: HttpStatusCode.UNAUTHORIZED_401 })
177 })
178
179 it('Should not be able to refresh an access token with an expired refresh token', async function () {
180 await server.login.refreshToken({ refreshToken: server.refreshToken, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
181 })
182
183 it('Should refresh the token', async function () {
184 this.timeout(50000)
185
186 const futureDate = new Date(new Date().getTime() + 1000 * 60).toISOString()
187 await server.sql.setTokenField(server.accessToken, 'refreshTokenExpiresAt', futureDate)
188
189 await killallServers([ server ])
190 await server.run()
191
192 const res = await server.login.refreshToken({ refreshToken: server.refreshToken })
193 server.accessToken = res.body.access_token
194 server.refreshToken = res.body.refresh_token
195 })
196
197 it('Should be able to get my user information again', async function () {
198 await server.users.getMyInfo()
199 })
200 })
201
202 describe('Creating a user', function () { 35 describe('Creating a user', function () {
203 36
204 it('Should be able to create a new user', async function () { 37 it('Should be able to create a new user', async function () {
@@ -512,6 +345,7 @@ describe('Test users', function () {
512 }) 345 })
513 346
514 describe('Updating another user', function () { 347 describe('Updating another user', function () {
348
515 it('Should be able to update another user', async function () { 349 it('Should be able to update another user', async function () {
516 await server.users.update({ 350 await server.users.update({
517 userId, 351 userId,
@@ -562,13 +396,6 @@ describe('Test users', function () {
562 }) 396 })
563 }) 397 })
564 398
565 describe('Video blacklists', function () {
566
567 it('Should be able to list my video blacklist', async function () {
568 await server.blacklist.list({ token: userToken })
569 })
570 })
571
572 describe('Remove a user', function () { 399 describe('Remove a user', function () {
573 400
574 before(async function () { 401 before(async function () {
@@ -653,8 +480,9 @@ describe('Test users', function () {
653 }) 480 })
654 481
655 describe('User blocking', function () { 482 describe('User blocking', function () {
656 let user16Id 483 let user16Id: number
657 let user16AccessToken 484 let user16AccessToken: string
485
658 const user16 = { 486 const user16 = {
659 username: 'user_16', 487 username: 'user_16',
660 password: 'my super password' 488 password: 'my super password'
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..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
@@ -33,7 +33,17 @@ async function register ({
33 username: 'kefka', 33 username: 'kefka',
34 email: 'kefka@example.com', 34 email: 'kefka@example.com',
35 role: 0, 35 role: 0,
36 displayName: 'Kefka Palazzo' 36 displayName: 'Kefka Palazzo',
37 adminFlags: 1,
38 videoQuota: 42000,
39 videoQuotaDaily: 42100,
40
41 // Always use new value except for videoQuotaDaily field
42 userUpdater: ({ fieldName, currentValue, newValue }) => {
43 if (fieldName === 'videoQuotaDaily') return currentValue
44
45 return newValue
46 }
37 }) 47 })
38 }, 48 },
39 hookTokenValidity: (options) => { 49 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 ({
33 if (body.id === 'laguna' && body.password === 'laguna password') { 33 if (body.id === 'laguna' && body.password === 'laguna password') {
34 return Promise.resolve({ 34 return Promise.resolve({
35 username: 'laguna', 35 username: 'laguna',
36 email: 'laguna@example.com' 36 email: 'laguna@example.com',
37 displayName: 'Laguna Loire',
38 adminFlags: 1,
39 videoQuota: 42000,
40 videoQuotaDaily: 42100,
41
42 // Always use new value except for videoQuotaDaily field
43 userUpdater: ({ fieldName, currentValue, newValue }) => {
44 if (fieldName === 'videoQuotaDaily') return currentValue
45
46 return newValue
47 }
37 }) 48 })
38 } 49 }
39 50
diff --git a/server/tests/plugins/external-auth.ts b/server/tests/plugins/external-auth.ts
index 437777e90..e600f958f 100644
--- a/server/tests/plugins/external-auth.ts
+++ b/server/tests/plugins/external-auth.ts
@@ -2,7 +2,7 @@
2 2
3import { expect } from 'chai' 3import { expect } from 'chai'
4import { wait } from '@shared/core-utils' 4import { wait } from '@shared/core-utils'
5import { HttpStatusCode, UserRole } from '@shared/models' 5import { HttpStatusCode, UserAdminFlag, UserRole } from '@shared/models'
6import { 6import {
7 cleanupTests, 7 cleanupTests,
8 createSingleServer, 8 createSingleServer,
@@ -51,6 +51,7 @@ describe('Test external auth plugins', function () {
51 51
52 let kefkaAccessToken: string 52 let kefkaAccessToken: string
53 let kefkaRefreshToken: string 53 let kefkaRefreshToken: string
54 let kefkaId: number
54 55
55 let externalAuthToken: string 56 let externalAuthToken: string
56 57
@@ -156,6 +157,9 @@ describe('Test external auth plugins', function () {
156 expect(body.account.displayName).to.equal('cyan') 157 expect(body.account.displayName).to.equal('cyan')
157 expect(body.email).to.equal('cyan@example.com') 158 expect(body.email).to.equal('cyan@example.com')
158 expect(body.role.id).to.equal(UserRole.USER) 159 expect(body.role.id).to.equal(UserRole.USER)
160 expect(body.adminFlags).to.equal(UserAdminFlag.NONE)
161 expect(body.videoQuota).to.equal(5242880)
162 expect(body.videoQuotaDaily).to.equal(-1)
159 } 163 }
160 }) 164 })
161 165
@@ -178,6 +182,11 @@ describe('Test external auth plugins', function () {
178 expect(body.account.displayName).to.equal('Kefka Palazzo') 182 expect(body.account.displayName).to.equal('Kefka Palazzo')
179 expect(body.email).to.equal('kefka@example.com') 183 expect(body.email).to.equal('kefka@example.com')
180 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR) 184 expect(body.role.id).to.equal(UserRole.ADMINISTRATOR)
185 expect(body.adminFlags).to.equal(UserAdminFlag.BYPASS_VIDEO_AUTO_BLACKLIST)
186 expect(body.videoQuota).to.equal(42000)
187 expect(body.videoQuotaDaily).to.equal(42100)
188
189 kefkaId = body.id
181 } 190 }
182 }) 191 })
183 192
@@ -240,6 +249,37 @@ describe('Test external auth plugins', function () {
240 expect(body.role.id).to.equal(UserRole.USER) 249 expect(body.role.id).to.equal(UserRole.USER)
241 }) 250 })
242 251
252 it('Should login Kefka and update the profile', async function () {
253 {
254 await server.users.update({ userId: kefkaId, videoQuota: 43000, videoQuotaDaily: 43100 })
255 await server.users.updateMe({ token: kefkaAccessToken, displayName: 'kefka updated' })
256
257 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
258 expect(body.username).to.equal('kefka')
259 expect(body.account.displayName).to.equal('kefka updated')
260 expect(body.videoQuota).to.equal(43000)
261 expect(body.videoQuotaDaily).to.equal(43100)
262 }
263
264 {
265 const res = await loginExternal({
266 server,
267 npmName: 'test-external-auth-one',
268 authName: 'external-auth-2',
269 username: 'kefka'
270 })
271
272 kefkaAccessToken = res.access_token
273 kefkaRefreshToken = res.refresh_token
274
275 const body = await server.users.getMyInfo({ token: kefkaAccessToken })
276 expect(body.username).to.equal('kefka')
277 expect(body.account.displayName).to.equal('Kefka Palazzo')
278 expect(body.videoQuota).to.equal(42000)
279 expect(body.videoQuotaDaily).to.equal(43100)
280 }
281 })
282
243 it('Should not update an external auth email', async function () { 283 it('Should not update an external auth email', async function () {
244 await server.users.updateMe({ 284 await server.users.updateMe({
245 token: cyanAccessToken, 285 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 () {
13 13
14 let lagunaAccessToken: string 14 let lagunaAccessToken: string
15 let lagunaRefreshToken: string 15 let lagunaRefreshToken: string
16 let lagunaId: number
16 17
17 before(async function () { 18 before(async function () {
18 this.timeout(30000) 19 this.timeout(30000)
@@ -78,8 +79,10 @@ describe('Test id and pass auth plugins', function () {
78 const body = await server.users.getMyInfo({ token: lagunaAccessToken }) 79 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
79 80
80 expect(body.username).to.equal('laguna') 81 expect(body.username).to.equal('laguna')
81 expect(body.account.displayName).to.equal('laguna') 82 expect(body.account.displayName).to.equal('Laguna Loire')
82 expect(body.role.id).to.equal(UserRole.USER) 83 expect(body.role.id).to.equal(UserRole.USER)
84
85 lagunaId = body.id
83 } 86 }
84 }) 87 })
85 88
@@ -132,6 +135,33 @@ describe('Test id and pass auth plugins', function () {
132 expect(body.role.id).to.equal(UserRole.MODERATOR) 135 expect(body.role.id).to.equal(UserRole.MODERATOR)
133 }) 136 })
134 137
138 it('Should login Laguna and update the profile', async function () {
139 {
140 await server.users.update({ userId: lagunaId, videoQuota: 43000, videoQuotaDaily: 43100 })
141 await server.users.updateMe({ token: lagunaAccessToken, displayName: 'laguna updated' })
142
143 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
144 expect(body.username).to.equal('laguna')
145 expect(body.account.displayName).to.equal('laguna updated')
146 expect(body.videoQuota).to.equal(43000)
147 expect(body.videoQuotaDaily).to.equal(43100)
148 }
149
150 {
151 const body = await server.login.login({ user: { username: 'laguna', password: 'laguna password' } })
152 lagunaAccessToken = body.access_token
153 lagunaRefreshToken = body.refresh_token
154 }
155
156 {
157 const body = await server.users.getMyInfo({ token: lagunaAccessToken })
158 expect(body.username).to.equal('laguna')
159 expect(body.account.displayName).to.equal('Laguna Loire')
160 expect(body.videoQuota).to.equal(42000)
161 expect(body.videoQuotaDaily).to.equal(43100)
162 }
163 })
164
135 it('Should reject token of laguna by the plugin hook', async function () { 165 it('Should reject token of laguna by the plugin hook', async function () {
136 this.timeout(10000) 166 this.timeout(10000)
137 167
@@ -147,7 +177,7 @@ describe('Test id and pass auth plugins', function () {
147 await server.servers.waitUntilLog('valid username') 177 await server.servers.waitUntilLog('valid username')
148 178
149 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 179 await command.login({ user: { username: 'kiros', password: 'kiros password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
150 await server.servers.waitUntilLog('valid display name') 180 await server.servers.waitUntilLog('valid displayName')
151 181
152 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 }) 182 await command.login({ user: { username: 'raine', password: 'raine password' }, expectedStatus: HttpStatusCode.BAD_REQUEST_400 })
153 await server.servers.waitUntilLog('valid role') 183 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 @@
1
2import { OutgoingHttpHeaders } from 'http' 1import { OutgoingHttpHeaders } from 'http'
3import { RegisterServerAuthExternalOptions } from '@server/types' 2import { RegisterServerAuthExternalOptions } from '@server/types'
4import { 3import {
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 @@
1type ObjectKeys<T> =
2 T extends object
3 ? `${Exclude<keyof T, symbol>}`[]
4 : T extends number
5 ? []
6 : T extends any | string
7 ? string[]
8 : never
9
10interface ObjectConstructor {
11 keys<T> (o: T): ObjectKeys<T>
12}
diff --git a/server/types/plugins/register-server-auth.model.ts b/server/types/plugins/register-server-auth.model.ts
index 79c18c406..e10968c20 100644
--- a/server/types/plugins/register-server-auth.model.ts
+++ b/server/types/plugins/register-server-auth.model.ts
@@ -1,14 +1,33 @@
1import express from 'express' 1import express from 'express'
2import { UserRole } from '@shared/models' 2import { UserAdminFlag, UserRole } from '@shared/models'
3import { MOAuthToken, MUser } from '../models' 3import { MOAuthToken, MUser } from '../models'
4 4
5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions 5export type RegisterServerAuthOptions = RegisterServerAuthPassOptions | RegisterServerAuthExternalOptions
6 6
7export type AuthenticatedResultUpdaterFieldName = 'displayName' | 'role' | 'adminFlags' | 'videoQuota' | 'videoQuotaDaily'
8
7export interface RegisterServerAuthenticatedResult { 9export interface RegisterServerAuthenticatedResult {
10 // Update the user profile if it already exists
11 // Default behaviour is no update
12 // Introduced in PeerTube >= 5.1
13 userUpdater?: <T> (options: {
14 fieldName: AuthenticatedResultUpdaterFieldName
15 currentValue: T
16 newValue: T
17 }) => T
18
8 username: string 19 username: string
9 email: string 20 email: string
10 role?: UserRole 21 role?: UserRole
11 displayName?: string 22 displayName?: string
23
24 // PeerTube >= 5.1
25 adminFlags?: UserAdminFlag
26
27 // PeerTube >= 5.1
28 videoQuota?: number
29 // PeerTube >= 5.1
30 videoQuotaDaily?: number
12} 31}
13 32
14export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult { 33export interface RegisterServerExternalAuthenticatedResult extends RegisterServerAuthenticatedResult {
diff --git a/shared/core-utils/plugins/hooks.ts b/shared/core-utils/plugins/hooks.ts
index 3784969b5..96bcc945e 100644
--- a/shared/core-utils/plugins/hooks.ts
+++ b/shared/core-utils/plugins/hooks.ts
@@ -1,3 +1,4 @@
1import { RegisteredExternalAuthConfig } from '@shared/models'
1import { HookType } from '../../models/plugins/hook-type.enum' 2import { HookType } from '../../models/plugins/hook-type.enum'
2import { isCatchable, isPromise } from '../common/promises' 3import { isCatchable, isPromise } from '../common/promises'
3 4
@@ -49,7 +50,12 @@ async function internalRunHook <T> (options: {
49 return result 50 return result
50} 51}
51 52
53function getExternalAuthHref (apiUrl: string, auth: RegisteredExternalAuthConfig) {
54 return apiUrl + `/plugins/${auth.name}/${auth.version}/auth/${auth.authName}`
55}
56
52export { 57export {
53 getHookType, 58 getHookType,
54 internalRunHook 59 internalRunHook,
60 getExternalAuthHref
55} 61}
diff --git a/shared/server-commands/requests/requests.ts b/shared/server-commands/requests/requests.ts
index dc9cf4e01..cb0e1a5fb 100644
--- a/shared/server-commands/requests/requests.ts
+++ b/shared/server-commands/requests/requests.ts
@@ -199,7 +199,7 @@ function buildRequest (req: request.Test, options: CommonRequestParams) {
199 return req.expect((res) => { 199 return req.expect((res) => {
200 if (options.expectedStatus && res.status !== options.expectedStatus) { 200 if (options.expectedStatus && res.status !== options.expectedStatus) {
201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` + 201 throw new Error(`Expected status ${options.expectedStatus}, got ${res.status}. ` +
202 `\nThe server responded this error: "${res.body?.error ?? res.text}".\n` + 202 `\nThe server responded: "${res.body?.error ?? res.text}".\n` +
203 'You may take a closer look at the logs. To see how to do so, check out this page: ' + 203 'You may take a closer look at the logs. To see how to do so, check out this page: ' +
204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs') 204 'https://github.com/Chocobozzz/PeerTube/blob/develop/support/doc/development/tests.md#debug-server-logs')
205 } 205 }
diff --git a/support/doc/plugins/guide.md b/support/doc/plugins/guide.md
index a1131ced5..9ddab3ece 100644
--- a/support/doc/plugins/guide.md
+++ b/support/doc/plugins/guide.md
@@ -433,7 +433,27 @@ function register (...) {
433 username: 'user' 433 username: 'user'
434 email: 'user@example.com' 434 email: 'user@example.com'
435 role: 2 435 role: 2
436 displayName: 'User display name' 436 displayName: 'User display name',
437
438 // Custom admin flags (bypass video auto moderation etc.)
439 // https://github.com/Chocobozzz/PeerTube/blob/develop/shared/models/users/user-flag.model.ts
440 // PeerTube >= 5.1
441 adminFlags: 0,
442 // Quota in bytes
443 // PeerTube >= 5.1
444 videoQuota: 1024 * 1024 * 1024, // 1GB
445 // PeerTube >= 5.1
446 videoQuotaDaily: -1, // Unlimited
447
448 // Update the user profile if it already exists
449 // Default behaviour is no update
450 // Introduced in PeerTube >= 5.1
451 userUpdater: ({ fieldName, currentValue, newValue }) => {
452 // Always use new value except for videoQuotaDaily field
453 if (fieldName === 'videoQuotaDaily') return currentValue
454
455 return newValue
456 }
437 }) 457 })
438 }) 458 })
439 459