Update jsdocs and auto-formatting

This commit is contained in:
advplyr 2024-08-04 16:13:40 -05:00
parent 15c6fce648
commit eca51457b7
3 changed files with 229 additions and 191 deletions

View File

@ -13,7 +13,6 @@ const Logger = require('./Logger')
* @class Class for handling all the authentication related functionality. * @class Class for handling all the authentication related functionality.
*/ */
class Auth { class Auth {
constructor() { constructor() {
// Map of openId sessions indexed by oauth2 state-variable // Map of openId sessions indexed by oauth2 state-variable
this.openIdAuthSession = new Map() this.openIdAuthSession = new Map()
@ -24,40 +23,52 @@ class Auth {
*/ */
async initPassportJs() { async initPassportJs() {
// Check if we should load the local strategy (username + password login) // Check if we should load the local strategy (username + password login)
if (global.ServerSettings.authActiveAuthMethods.includes("local")) { if (global.ServerSettings.authActiveAuthMethods.includes('local')) {
this.initAuthStrategyPassword() this.initAuthStrategyPassword()
} }
// Check if we should load the openid strategy // Check if we should load the openid strategy
if (global.ServerSettings.authActiveAuthMethods.includes("openid")) { if (global.ServerSettings.authActiveAuthMethods.includes('openid')) {
this.initAuthStrategyOpenID() this.initAuthStrategyOpenID()
} }
// Load the JwtStrategy (always) -> for bearer token auth // Load the JwtStrategy (always) -> for bearer token auth
passport.use(new JwtStrategy({ passport.use(
new JwtStrategy(
{
jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]), jwtFromRequest: ExtractJwt.fromExtractors([ExtractJwt.fromAuthHeaderAsBearerToken(), ExtractJwt.fromUrlQueryParameter('token')]),
secretOrKey: Database.serverSettings.tokenSecret secretOrKey: Database.serverSettings.tokenSecret
}, this.jwtAuthCheck.bind(this))) },
this.jwtAuthCheck.bind(this)
)
)
// define how to seralize a user (to be put into the session) // define how to seralize a user (to be put into the session)
passport.serializeUser(function (user, cb) { passport.serializeUser(function (user, cb) {
process.nextTick(function () { process.nextTick(function () {
// only store id to session // only store id to session
return cb(null, JSON.stringify({ return cb(
id: user.id, null,
})) JSON.stringify({
id: user.id
})
)
}) })
}) })
// define how to deseralize a user (use the ID to get it from the database) // define how to deseralize a user (use the ID to get it from the database)
passport.deserializeUser((function (user, cb) { passport.deserializeUser(
process.nextTick((async function () { function (user, cb) {
process.nextTick(
async function () {
const parsedUserInfo = JSON.parse(user) const parsedUserInfo = JSON.parse(user)
// load the user by ID that is stored in the session // load the user by ID that is stored in the session
const dbUser = await Database.userModel.getUserById(parsedUserInfo.id) const dbUser = await Database.userModel.getUserById(parsedUserInfo.id)
return cb(null, dbUser) return cb(null, dbUser)
}).bind(this)) }.bind(this)
}).bind(this)) )
}.bind(this)
)
} }
/** /**
@ -92,13 +103,17 @@ class Auth {
client_secret: global.ServerSettings.authOpenIDClientSecret, client_secret: global.ServerSettings.authOpenIDClientSecret,
id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm id_token_signed_response_alg: global.ServerSettings.authOpenIDTokenSigningAlgorithm
}) })
passport.use('openid-client', new OpenIDClient.Strategy({ passport.use(
'openid-client',
new OpenIDClient.Strategy(
{
client: openIdClient, client: openIdClient,
params: { params: {
redirect_uri: '/auth/openid/callback', redirect_uri: '/auth/openid/callback',
scope: 'openid profile email' scope: 'openid profile email'
} }
}, async (tokenset, userinfo, done) => { },
async (tokenset, userinfo, done) => {
try { try {
Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2)) Logger.debug(`[Auth] openid callback userinfo=`, JSON.stringify(userinfo, null, 2))
@ -128,7 +143,9 @@ class Auth {
return done(null, null, 'Unauthorized') return done(null, null, 'Unauthorized')
} }
})) }
)
)
} }
/** /**
@ -181,7 +198,6 @@ class Auth {
return null return null
} }
user = await Database.userModel.getUserByUsername(username) user = await Database.userModel.getUserByUsername(username)
if (user?.authOpenIDSub) { if (user?.authOpenIDSub) {
@ -220,7 +236,8 @@ class Auth {
*/ */
validateGroupClaim(userinfo) { validateGroupClaim(userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
if (!groupClaimName) // Allow no group claim when configured like this if (!groupClaimName)
// Allow no group claim when configured like this
return true return true
// If configured it must exist in userinfo // If configured it must exist in userinfo
@ -238,16 +255,16 @@ class Auth {
*/ */
async setUserGroup(user, userinfo) { async setUserGroup(user, userinfo) {
const groupClaimName = Database.serverSettings.authOpenIDGroupClaim const groupClaimName = Database.serverSettings.authOpenIDGroupClaim
if (!groupClaimName) // No group claim configured, don't set anything if (!groupClaimName)
// No group claim configured, don't set anything
return return
if (!userinfo[groupClaimName]) if (!userinfo[groupClaimName]) throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
throw new Error(`Group claim ${groupClaimName} not found in userinfo`)
const groupsList = userinfo[groupClaimName].map(group => group.toLowerCase()) const groupsList = userinfo[groupClaimName].map((group) => group.toLowerCase())
const rolesInOrderOfPriority = ['admin', 'user', 'guest'] const rolesInOrderOfPriority = ['admin', 'user', 'guest']
let userType = rolesInOrderOfPriority.find(role => groupsList.includes(role)) let userType = rolesInOrderOfPriority.find((role) => groupsList.includes(role))
if (userType) { if (userType) {
if (user.type === 'root') { if (user.type === 'root') {
// Check OpenID Group // Check OpenID Group
@ -277,15 +294,14 @@ class Auth {
*/ */
async updateUserPermissions(user, userinfo) { async updateUserPermissions(user, userinfo) {
const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim const absPermissionsClaim = Database.serverSettings.authOpenIDAdvancedPermsClaim
if (!absPermissionsClaim) // No advanced permissions claim configured, don't set anything if (!absPermissionsClaim)
// No advanced permissions claim configured, don't set anything
return return
if (user.type === 'admin' || user.type === 'root') if (user.type === 'admin' || user.type === 'root') return
return
const absPermissions = userinfo[absPermissionsClaim] const absPermissions = userinfo[absPermissionsClaim]
if (!absPermissions) if (!absPermissions) throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
throw new Error(`Advanced permissions claim ${absPermissionsClaim} not found in userinfo`)
if (user.updatePermissionsFromExternalJSON(absPermissions)) { if (user.updatePermissionsFromExternalJSON(absPermissions)) {
Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`) Logger.info(`[Auth] openid callback: Updating advanced perms for user "${user.username}" using "${JSON.stringify(absPermissions)}"`)
@ -422,7 +438,7 @@ class Auth {
} }
// Generate a state on web flow or if no state supplied // Generate a state on web flow or if no state supplied
const state = (!isMobileFlow || !req.query.state) ? OpenIDClient.generators.random() : req.query.state const state = !isMobileFlow || !req.query.state ? OpenIDClient.generators.random() : req.query.state
// Redirect URL for the SSO provider // Redirect URL for the SSO provider
let redirectUri let redirectUri
@ -508,8 +524,7 @@ class Auth {
function isValidRedirectUri(uri) { function isValidRedirectUri(uri) {
// Check if the redirect_uri is in the whitelist // Check if the redirect_uri is in the whitelist
return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || return Database.serverSettings.authOpenIDMobileRedirectURIs.includes(uri) || (Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
(Database.serverSettings.authOpenIDMobileRedirectURIs.length === 1 && Database.serverSettings.authOpenIDMobileRedirectURIs[0] === '*')
} }
}) })
@ -545,7 +560,9 @@ class Auth {
}) })
// openid strategy callback route (this receives the token from the configured openid login provider) // openid strategy callback route (this receives the token from the configured openid login provider)
router.get('/auth/openid/callback', (req, res, next) => { router.get(
'/auth/openid/callback',
(req, res, next) => {
const oidcStrategy = passport._strategy('openid-client') const oidcStrategy = passport._strategy('openid-client')
const sessionKey = oidcStrategy._key const sessionKey = oidcStrategy._key
@ -603,14 +620,14 @@ class Auth {
} }
} }
// While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request // While not required by the standard, the passport plugin re-sends the original redirect_uri in the token request
// We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided // We need to set it correctly, as some SSO providers (e.g. keycloak) check that parameter when it is provided
// We set it here again because the passport param can change between requests // We set it here again because the passport param can change between requests
return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next) return passport.authenticate('openid-client', { redirect_uri: req.session[sessionKey].sso_redirect_uri }, passportCallback(req, res, next))(req, res, next)
}, },
// on a successfull login: read the cookies and react like the client requested (callback or json) // on a successfull login: read the cookies and react like the client requested (callback or json)
this.handleLoginSuccessBasedOnCookie.bind(this)) this.handleLoginSuccessBasedOnCookie.bind(this)
)
/** /**
* Helper route used to auto-populate the openid URLs in config/authentication * Helper route used to auto-populate the openid URLs in config/authentication
@ -625,7 +642,7 @@ class Auth {
} }
if (!req.query.issuer) { if (!req.query.issuer) {
return res.status(400).send('Invalid request. Query param \'issuer\' is required') return res.status(400).send("Invalid request. Query param 'issuer' is required")
} }
// Strip trailing slash // Strip trailing slash
@ -641,10 +658,12 @@ class Auth {
} }
} catch (error) { } catch (error) {
Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error) Logger.error(`[Auth] Failed to get openid configuration. Invalid URL "${configUrl}"`, error)
return res.status(400).send('Invalid request. Query param \'issuer\' is invalid') return res.status(400).send("Invalid request. Query param 'issuer' is invalid")
} }
axios.get(configUrl.toString()).then(({ data }) => { axios
.get(configUrl.toString())
.then(({ data }) => {
res.json({ res.json({
issuer: data.issuer, issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint, authorization_endpoint: data.authorization_endpoint,
@ -654,7 +673,8 @@ class Auth {
jwks_uri: data.jwks_uri, jwks_uri: data.jwks_uri,
id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported id_token_signing_alg_values_supported: data.id_token_signing_alg_values_supported
}) })
}).catch((error) => { })
.catch((error) => {
Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error) Logger.error(`[Auth] Failed to get openid configuration at "${configUrl}"`, error)
res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`) res.status(error.statusCode || 400).send(`${error.code || 'UNKNOWN'}: Failed to get openid configuration`)
}) })
@ -683,7 +703,7 @@ class Auth {
let postLogoutRedirectUri = null let postLogoutRedirectUri = null
if (authMethod === 'openid') { if (authMethod === 'openid') {
const protocol = (req.secure || req.get('x-forwarded-proto') === 'https') ? 'https' : 'http' const protocol = req.secure || req.get('x-forwarded-proto') === 'https' ? 'https' : 'http'
const host = req.get('host') const host = req.get('host')
// TODO: ABS does currently not support subfolders for installation // TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl // If we want to support it we need to include a config for the serverurl
@ -727,7 +747,7 @@ class Auth {
next() next()
} else { } else {
// try JWT to authenticate // try JWT to authenticate
passport.authenticate("jwt")(req, res, next) passport.authenticate('jwt')(req, res, next)
} }
} }
@ -750,8 +770,7 @@ class Auth {
static validateAccessToken(token) { static validateAccessToken(token) {
try { try {
return jwt.verify(token, global.ServerSettings.tokenSecret) return jwt.verify(token, global.ServerSettings.tokenSecret)
} } catch (err) {
catch (err) {
return null return null
} }
} }
@ -760,7 +779,8 @@ class Auth {
* Generate a token which is used to encrpt/protect the jwts. * Generate a token which is used to encrpt/protect the jwts.
*/ */
async initTokenSecret() { async initTokenSecret() {
if (process.env.TOKEN_SECRET) { // User can supply their own token secret if (process.env.TOKEN_SECRET) {
// User can supply their own token secret
Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET Database.serverSettings.tokenSecret = process.env.TOKEN_SECRET
} else { } else {
Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64') Database.serverSettings.tokenSecret = require('crypto').randomBytes(256).toString('base64')

View File

@ -1,4 +1,4 @@
const uuidv4 = require("uuid").v4 const uuidv4 = require('uuid').v4
const Logger = require('../Logger') const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
@ -7,18 +7,32 @@ const User = require('../objects/user/User')
const { toNumber } = require('../utils/index') const { toNumber } = require('../utils/index')
/**
* @typedef UserControllerRequestProps
* @property {import('../objects/user/User')} user - User that made the request
* @property {import('../objects/user/User')} [reqUser] - User for req param id
*
* @typedef {import('express').Request & UserControllerRequestProps} UserControllerRequest
* @typedef {import('express').Response} UserControllerResponse
*/
class UserController { class UserController {
constructor() {} constructor() {}
/**
*
* @param {UserControllerRequest} req
* @param {UserControllerResponse} res
*/
async findAll(req, res) { async findAll(req, res) {
if (!req.user.isAdminOrUp) return res.sendStatus(403) if (!req.user.isAdminOrUp) return res.sendStatus(403)
const hideRootToken = !req.user.isRoot const hideRootToken = !req.user.isRoot
const includes = (req.query.include || '').split(',').map(i => i.trim()) const includes = (req.query.include || '').split(',').map((i) => i.trim())
// Minimal toJSONForBrowser does not include mediaProgress and bookmarks // Minimal toJSONForBrowser does not include mediaProgress and bookmarks
const allUsers = await Database.userModel.getOldUsers() const allUsers = await Database.userModel.getOldUsers()
const users = allUsers.map(u => u.toJSONForBrowser(hideRootToken, true)) const users = allUsers.map((u) => u.toJSONForBrowser(hideRootToken, true))
if (includes.includes('latestSession')) { if (includes.includes('latestSession')) {
for (const user of users) { for (const user of users) {
@ -37,8 +51,8 @@ class UserController {
* Get a single user toJSONForBrowser * Get a single user toJSONForBrowser
* Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt` * Media progress items include: `displayTitle`, `displaySubtitle` (for podcasts), `coverPath` and `mediaUpdatedAt`
* *
* @param {import("express").Request} req * @param {UserControllerRequest} req
* @param {import("express").Response} res * @param {UserControllerResponse} res
*/ */
async findOne(req, res) { async findOne(req, res) {
if (!req.user.isAdminOrUp) { if (!req.user.isAdminOrUp) {
@ -67,7 +81,7 @@ class UserController {
] ]
}) })
const oldMediaProgresses = mediaProgresses.map(mp => { const oldMediaProgresses = mediaProgresses.map((mp) => {
const oldMediaProgress = mp.getOldMediaProgress() const oldMediaProgress = mp.getOldMediaProgress()
oldMediaProgress.displayTitle = mp.mediaItem?.title oldMediaProgress.displayTitle = mp.mediaItem?.title
if (mp.mediaItem?.podcast) { if (mp.mediaItem?.podcast) {
@ -119,8 +133,8 @@ class UserController {
* PATCH: /api/users/:id * PATCH: /api/users/:id
* Update user * Update user
* *
* @param {import('express').Request} req * @param {UserControllerRequest} req
* @param {import('express').Response} res * @param {UserControllerResponse} res
*/ */
async update(req, res) { async update(req, res) {
const user = req.reqUser const user = req.reqUser
@ -197,8 +211,8 @@ class UserController {
/** /**
* PATCH: /api/users/:id/openid-unlink * PATCH: /api/users/:id/openid-unlink
* *
* @param {import('express').Request} req * @param {UserControllerRequest} req
* @param {import('express').Response} res * @param {UserControllerResponse} res
*/ */
async unlinkFromOpenID(req, res) { async unlinkFromOpenID(req, res) {
Logger.debug(`[UserController] Unlinking user "${req.reqUser.username}" from OpenID with sub "${req.reqUser.authOpenIDSub}"`) Logger.debug(`[UserController] Unlinking user "${req.reqUser.username}" from OpenID with sub "${req.reqUser.authOpenIDSub}"`)

View File

@ -19,6 +19,8 @@ class User extends Model {
this.pash this.pash
/** @type {string} */ /** @type {string} */
this.type this.type
/** @type {string} */
this.token
/** @type {boolean} */ /** @type {boolean} */
this.isActive this.isActive
/** @type {boolean} */ /** @type {boolean} */
@ -35,6 +37,8 @@ class User extends Model {
this.createdAt this.createdAt
/** @type {Date} */ /** @type {Date} */
this.updatedAt this.updatedAt
/** @type {import('./MediaProgress')[]?} - Only included when extended */
this.mediaProgresses
} }
/** /**