2023-11-05 21:11:37 +01:00
const axios = require ( 'axios' )
2023-03-24 18:21:25 +01:00
const passport = require ( 'passport' )
2022-07-07 02:01:27 +02:00
const bcrypt = require ( './libs/bcryptjs' )
2022-07-07 01:45:43 +02:00
const jwt = require ( './libs/jsonwebtoken' )
2023-04-16 17:08:13 +02:00
const LocalStrategy = require ( './libs/passportLocal' )
const JwtStrategy = require ( 'passport-jwt' ) . Strategy
const ExtractJwt = require ( 'passport-jwt' ) . ExtractJwt
2023-11-04 21:36:43 +01:00
const OpenIDClient = require ( 'openid-client' )
2023-09-13 18:35:39 +02:00
const Database = require ( './Database' )
2023-11-04 21:36:43 +01:00
const Logger = require ( './Logger' )
2023-03-24 18:21:25 +01:00
/ * *
* @ class Class for handling all the authentication related functionality .
* /
2021-08-18 00:01:11 +02:00
class Auth {
2023-03-24 18:21:25 +01:00
2023-09-13 18:35:39 +02:00
constructor ( ) {
2023-12-04 22:36:34 +01:00
// Map of openId sessions indexed by oauth2 state-variable
this . openIdAuthSession = new Map ( )
2021-08-18 00:01:11 +02:00
}
2023-03-24 18:21:25 +01:00
/ * *
2023-09-20 19:37:55 +02:00
* Inializes all passportjs strategies and other passportjs ralated initialization .
2023-03-24 18:21:25 +01:00
* /
2023-09-16 20:42:48 +02:00
async initPassportJs ( ) {
2023-09-20 19:37:55 +02:00
// Check if we should load the local strategy (username + password login)
2023-03-24 18:21:25 +01:00
if ( global . ServerSettings . authActiveAuthMethods . includes ( "local" ) ) {
2023-11-10 23:11:51 +01:00
this . initAuthStrategyPassword ( )
}
// Check if we should load the openid strategy
if ( global . ServerSettings . authActiveAuthMethods . includes ( "openid" ) ) {
this . initAuthStrategyOpenID ( )
2023-03-24 18:21:25 +01:00
}
2023-04-14 20:26:29 +02:00
2023-03-24 18:21:25 +01:00
// Load the JwtStrategy (always) -> for bearer token auth
passport . use ( new JwtStrategy ( {
2023-09-26 00:05:58 +02:00
jwtFromRequest : ExtractJwt . fromExtractors ( [ ExtractJwt . fromAuthHeaderAsBearerToken ( ) , ExtractJwt . fromUrlQueryParameter ( 'token' ) ] ) ,
2023-09-23 20:30:28 +02:00
secretOrKey : Database . serverSettings . tokenSecret
2023-03-24 18:21:25 +01:00
} , this . jwtAuthCheck . bind ( this ) ) )
// define how to seralize a user (to be put into the session)
passport . serializeUser ( function ( user , cb ) {
process . nextTick ( function ( ) {
2023-09-20 19:37:55 +02:00
// only store id to session
2023-03-24 18:31:58 +01:00
return cb ( null , JSON . stringify ( {
2023-11-04 21:36:43 +01:00
id : user . id ,
2023-04-16 17:08:13 +02:00
} ) )
} )
} )
2023-03-24 18:21:25 +01:00
2023-09-20 19:37:55 +02:00
// define how to deseralize a user (use the ID to get it from the database)
2023-03-24 18:31:58 +01:00
passport . deserializeUser ( ( function ( user , cb ) {
2023-09-13 18:35:39 +02:00
process . nextTick ( ( async function ( ) {
2023-03-24 18:31:58 +01:00
const parsedUserInfo = JSON . parse ( user )
2023-09-20 19:37:55 +02:00
// load the user by ID that is stored in the session
const dbUser = await Database . userModel . getUserById ( parsedUserInfo . id )
2023-04-16 17:08:13 +02:00
return cb ( null , dbUser )
} ) . bind ( this ) )
} ) . bind ( this ) )
2021-08-18 00:01:11 +02:00
}
2023-11-10 23:11:51 +01:00
/ * *
* Passport use LocalStrategy
* /
initAuthStrategyPassword ( ) {
passport . use ( new LocalStrategy ( this . localAuthCheckUserPw . bind ( this ) ) )
}
/ * *
* Passport use OpenIDClient . Strategy
* /
initAuthStrategyOpenID ( ) {
2023-11-19 19:57:17 +01:00
if ( ! Database . serverSettings . isOpenIDAuthSettingsValid ) {
Logger . error ( ` [Auth] Cannot init openid auth strategy - invalid settings ` )
return
}
2024-02-27 00:20:11 +01:00
// Custom req timeout see: https://github.com/panva/node-openid-client/blob/main/docs/README.md#customizing
OpenIDClient . custom . setHttpOptionsDefaults ( { timeout : 10000 } )
2023-11-10 23:11:51 +01:00
const openIdIssuerClient = new OpenIDClient . Issuer ( {
issuer : global . ServerSettings . authOpenIDIssuerURL ,
authorization _endpoint : global . ServerSettings . authOpenIDAuthorizationURL ,
token _endpoint : global . ServerSettings . authOpenIDTokenURL ,
userinfo _endpoint : global . ServerSettings . authOpenIDUserInfoURL ,
2024-01-24 22:47:50 +01:00
jwks _uri : global . ServerSettings . authOpenIDJwksURL ,
end _session _endpoint : global . ServerSettings . authOpenIDLogoutURL
2023-11-10 23:11:51 +01:00
} ) . Client
const openIdClient = new openIdIssuerClient ( {
client _id : global . ServerSettings . authOpenIDClientID ,
client _secret : global . ServerSettings . authOpenIDClientSecret
} )
passport . use ( 'openid-client' , new OpenIDClient . Strategy ( {
client : openIdClient ,
params : {
redirect _uri : '/auth/openid/callback' ,
scope : 'openid profile email'
}
} , async ( tokenset , userinfo , done ) => {
2024-03-19 17:57:24 +01:00
Logger . debug ( ` [Auth] openid callback userinfo= ` , JSON . stringify ( userinfo , null , 2 ) )
2023-11-10 23:11:51 +01:00
2023-11-11 20:10:24 +01:00
let failureMessage = 'Unauthorized'
2023-11-10 23:11:51 +01:00
if ( ! userinfo . sub ) {
Logger . error ( ` [Auth] openid callback invalid userinfo, no sub ` )
2023-11-11 20:10:24 +01:00
return done ( null , null , failureMessage )
2023-11-10 23:11:51 +01:00
}
2024-03-19 17:57:24 +01:00
// Check if the claims itself are returned correctly
const groupClaimName = Database . serverSettings . authOpenIDGroupClaim ;
if ( groupClaimName ) {
if ( ! userinfo [ groupClaimName ] ) {
Logger . error ( ` [Auth] openid callback invalid: Group claim ${ groupClaimName } configured, but not found or empty in userinfo ` )
return done ( null , null , failureMessage )
}
const groupsList = userinfo [ groupClaimName ]
const targetRoles = [ 'admin' , 'user' , 'guest' ]
// Convert the list to lowercase for case-insensitive comparison
const groupsListLowercase = groupsList . map ( group => group . toLowerCase ( ) )
// Check if any of the target roles exist in the groups list
const containsTargetRole = targetRoles . some ( role => groupsListLowercase . includes ( role . toLowerCase ( ) ) )
if ( ! containsTargetRole ) {
Logger . info ( ` [Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: ` , groupsList )
return done ( null , null , failureMessage )
}
}
const advancedPermsClaimName = Database . serverSettings . authOpenIDAdvancedPermsClaim
if ( advancedPermsClaimName && ! userinfo [ advancedPermsClaimName ] ) {
Logger . error ( ` [Auth] openid callback invalid: Advanced perms claim ${ advancedPermsClaimName } configured, but not found or empty in userinfo ` )
return done ( null , null , failureMessage )
}
2023-11-10 23:11:51 +01:00
// First check for matching user by sub
let user = await Database . userModel . getUserByOpenIDSub ( userinfo . sub )
if ( ! user ) {
// Optionally match existing by email or username based on server setting "authOpenIDMatchExistingBy"
if ( Database . serverSettings . authOpenIDMatchExistingBy === 'email' && userinfo . email && userinfo . email _verified ) {
Logger . info ( ` [Auth] openid: User not found, checking existing with email " ${ userinfo . email } " ` )
user = await Database . userModel . getUserByEmail ( userinfo . email )
// Check that user is not already matched
if ( user ? . authOpenIDSub ) {
Logger . warn ( ` [Auth] openid: User found with email " ${ userinfo . email } " but is already matched with sub " ${ user . authOpenIDSub } " ` )
2023-11-11 20:10:24 +01:00
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
2023-11-10 23:11:51 +01:00
user = null
}
} else if ( Database . serverSettings . authOpenIDMatchExistingBy === 'username' && userinfo . preferred _username ) {
Logger . info ( ` [Auth] openid: User not found, checking existing with username " ${ userinfo . preferred _username } " ` )
user = await Database . userModel . getUserByUsername ( userinfo . preferred _username )
// Check that user is not already matched
if ( user ? . authOpenIDSub ) {
Logger . warn ( ` [Auth] openid: User found with username " ${ userinfo . preferred _username } " but is already matched with sub " ${ user . authOpenIDSub } " ` )
2023-11-11 20:10:24 +01:00
// TODO: Message isn't actually returned to the user yet. Need to override the passport authenticated callback
failureMessage = 'A matching user was found but is already matched with another user from your auth provider'
2023-11-10 23:11:51 +01:00
user = null
}
}
// If existing user was matched and isActive then save sub to user
if ( user ? . isActive ) {
Logger . info ( ` [Auth] openid: New user found matching existing user " ${ user . username } " ` )
user . authOpenIDSub = userinfo . sub
await Database . userModel . updateFromOld ( user )
} else if ( user && ! user . isActive ) {
Logger . warn ( ` [Auth] openid: New user found matching existing user " ${ user . username } " but that user is deactivated ` )
}
// Optionally auto register the user
if ( ! user && Database . serverSettings . authOpenIDAutoRegister ) {
Logger . info ( ` [Auth] openid: Auto-registering user with sub " ${ userinfo . sub } " ` , userinfo )
user = await Database . userModel . createUserFromOpenIdUserInfo ( userinfo , this )
}
}
if ( ! user ? . isActive ) {
2023-11-11 20:10:24 +01:00
if ( user && ! user . isActive ) {
failureMessage = 'Unauthorized'
}
2023-11-10 23:11:51 +01:00
// deny login
2023-11-11 20:10:24 +01:00
done ( null , null , failureMessage )
2023-11-10 23:11:51 +01:00
return
}
2024-03-19 17:57:24 +01:00
// Set user group if name of groups claim is configured
if ( groupClaimName ) {
const groupsList = userinfo [ groupClaimName ] ? userinfo [ groupClaimName ] . map ( group => group . toLowerCase ( ) ) : [ ]
const rolesInOrderOfPriority = [ 'admin' , 'user' , 'guest' ]
let userType = null
for ( let role of rolesInOrderOfPriority ) {
if ( groupsList . includes ( role ) ) {
userType = role // This will override with the highest priority role found
break // Stop searching once the highest priority role is found
}
}
// Actually already checked above, but just to be sure
if ( ! userType ) {
Logger . error ( ` [Auth] openid callback: Denying access because neither admin nor user or guest is included is inside the group claim. Groups found: ` , groupsList )
return done ( null , null , failureMessage )
}
Logger . debug ( ` [Auth] openid callback: Setting user ${ user . username } type to ${ userType } ` )
user . type = userType
await Database . userModel . updateFromOld ( user )
}
if ( advancedPermsClaimName ) {
try {
Logger . debug ( ` [Auth] openid callback: Updating advanced perms for user ${ user . username } to ${ JSON . stringify ( userinfo [ advancedPermsClaimName ] ) } ` )
user . updatePermissionsFromExternalJSON ( userinfo [ advancedPermsClaimName ] )
await Database . userModel . updateFromOld ( user )
} catch ( error ) {
Logger . error ( ` [Auth] openid callback: Error updating advanced perms for user, error: ` , error )
return done ( null , null , failureMessage )
}
}
2024-01-24 22:47:50 +01:00
// We also have to save the id_token for later (used for logout) because we cannot set cookies here
user . openid _id _token = tokenset . id _token
2023-11-10 23:11:51 +01:00
// permit login
return done ( null , user )
} ) )
}
/ * *
* Unuse strategy
*
* @ param { string } name
* /
unuseAuthStrategy ( name ) {
passport . unuse ( name )
}
/ * *
* Use strategy
*
* @ param { string } name
* /
useAuthStrategy ( name ) {
if ( name === 'openid' ) {
this . initAuthStrategyOpenID ( )
} else if ( name === 'local' ) {
this . initAuthStrategyPassword ( )
} else {
Logger . error ( '[Auth] Invalid auth strategy ' + name )
}
}
2024-01-25 16:05:41 +01:00
/ * *
* Returns if the given auth method is API based .
*
* @ param { string } authMethod
* @ returns { boolean }
* /
isAuthMethodAPIBased ( authMethod ) {
return [ 'api' , 'openid-mobile' ] . includes ( authMethod )
}
2023-09-17 19:42:42 +02:00
/ * *
2024-01-24 22:47:50 +01:00
* Stores the client ' s choice of login callback method in temporary cookies .
*
* The ` authMethod ` parameter specifies the authentication strategy and can have the following values :
* - 'local' : Standard authentication ,
* - 'api' : Authentication for API use
* - 'openid' : OpenID authentication directly over web
* - 'openid-mobile' : OpenID authentication , but done via an mobile device
2023-09-24 19:36:36 +02:00
*
* @ param { import ( 'express' ) . Request } req
* @ param { import ( 'express' ) . Response } res
2024-01-24 22:47:50 +01:00
* @ param { string } authMethod - The authentication method , default is 'local' .
2023-09-17 19:42:42 +02:00
* /
2024-01-24 22:47:50 +01:00
paramsToCookies ( req , res , authMethod = 'local' ) {
const TWO _MINUTES = 120000 // 2 minutes in milliseconds
const callback = req . query . redirect _uri || req . query . callback
2024-01-25 16:05:41 +01:00
// Additional handling for non-API based authMethod
if ( ! this . isAuthMethodAPIBased ( authMethod ) ) {
2024-01-24 22:47:50 +01:00
// Store 'auth_state' if present in the request
2023-09-26 00:05:58 +02:00
if ( req . query . state ) {
2024-01-24 22:47:50 +01:00
res . cookie ( 'auth_state' , req . query . state , { maxAge : TWO _MINUTES , httpOnly : true } )
2023-09-26 00:05:58 +02:00
}
2024-01-24 22:47:50 +01:00
// Validate and store the callback URL
2023-09-26 00:05:58 +02:00
if ( ! callback ) {
2024-01-24 22:47:50 +01:00
return res . status ( 400 ) . send ( { message : 'No callback parameter' } )
2023-09-17 19:42:42 +02:00
}
2024-01-24 22:47:50 +01:00
res . cookie ( 'auth_cb' , callback , { maxAge : TWO _MINUTES , httpOnly : true } )
2023-09-17 19:42:42 +02:00
}
2024-01-24 22:47:50 +01:00
2024-01-25 11:20:44 +01:00
// Store the authentication method for long
res . cookie ( 'auth_method' , authMethod , { maxAge : 1000 * 60 * 60 * 24 * 365 * 10 , httpOnly : true } )
2023-09-17 19:42:42 +02:00
}
/ * *
* Informs the client in the right mode about a successfull login and the token
* ( clients choise is restored from cookies ) .
2023-09-24 19:36:36 +02:00
*
* @ param { import ( 'express' ) . Request } req
* @ param { import ( 'express' ) . Response } res
2023-09-17 19:42:42 +02:00
* /
async handleLoginSuccessBasedOnCookie ( req , res ) {
2023-09-20 19:37:55 +02:00
// get userLogin json (information about the user, server and the session)
2023-09-17 19:42:42 +02:00
const data _json = await this . getUserLoginResponsePayload ( req . user )
2024-01-25 16:05:41 +01:00
if ( this . isAuthMethodAPIBased ( req . cookies . auth _method ) ) {
2023-09-17 19:42:42 +02:00
// REST request - send data
res . json ( data _json )
2023-09-24 22:36:35 +02:00
} else {
2023-09-17 19:42:42 +02:00
// UI request -> check if we have a callback url
// TODO: do we want to somehow limit the values for auth_cb?
2023-09-26 00:05:58 +02:00
if ( req . cookies . auth _cb ) {
let stateQuery = req . cookies . auth _state ? ` &state= ${ req . cookies . auth _state } ` : ''
2023-09-20 19:37:55 +02:00
// UI request -> redirect to auth_cb url and send the jwt token as parameter
2023-09-26 00:05:58 +02:00
res . redirect ( 302 , ` ${ req . cookies . auth _cb } ?setToken= ${ data _json . user . token } ${ stateQuery } ` )
2023-09-24 22:36:35 +02:00
} else {
res . status ( 400 ) . send ( 'No callback or already expired' )
2023-09-17 19:42:42 +02:00
}
}
}
2023-03-24 18:21:25 +01:00
/ * *
* Creates all ( express ) routes required for authentication .
2023-09-24 19:36:36 +02:00
*
* @ param { import ( 'express' ) . Router } router
2023-03-24 18:21:25 +01:00
* /
2023-09-20 19:37:55 +02:00
async initAuthRoutes ( router ) {
2023-03-24 18:21:25 +01:00
// Local strategy login route (takes username and password)
2023-09-24 19:36:36 +02:00
router . post ( '/login' , passport . authenticate ( 'local' ) , async ( req , res ) => {
// return the user login response json if the login was successfull
res . json ( await this . getUserLoginResponsePayload ( req . user ) )
} )
2023-03-24 18:21:25 +01:00
2023-04-14 20:26:29 +02:00
// openid strategy login route (this redirects to the configured openid login provider)
2023-09-14 19:49:19 +02:00
router . get ( '/auth/openid' , ( req , res , next ) => {
2024-01-25 11:13:34 +01:00
// Get the OIDC client from the strategy
// We need to call the client manually, because the strategy does not support forwarding the code challenge
// for API or mobile clients
const oidcStrategy = passport . _strategy ( 'openid-client' )
const client = oidcStrategy . _client
const sessionKey = oidcStrategy . _key
2023-11-11 17:52:05 +01:00
try {
2024-01-25 11:13:34 +01:00
const protocol = req . secure || req . get ( 'x-forwarded-proto' ) === 'https' ? 'https' : 'http'
const hostUrl = new URL ( ` ${ protocol } :// ${ req . get ( 'host' ) } ` )
const isMobileFlow = req . query . response _type === 'code' || req . query . redirect _uri || req . query . code _challenge
// Only allow code flow (for mobile clients)
if ( req . query . response _type && req . query . response _type !== 'code' ) {
Logger . debug ( ` [Auth] OIDC Invalid response_type= ${ req . query . response _type } ` )
return res . status ( 400 ) . send ( 'Invalid response_type, only code supported' )
2023-11-05 19:37:05 +01:00
}
2024-01-25 11:13:34 +01:00
// Generate a state on web flow or if no state supplied
const state = ( ! isMobileFlow || ! req . query . state ) ? OpenIDClient . generators . random ( ) : req . query . state
// Redirect URL for the SSO provider
let redirectUri
if ( isMobileFlow ) {
// Mobile required redirect uri
// If it is in the whitelist, we will save into this.openIdAuthSession and set the redirect uri to /auth/openid/mobile-redirect
// where we will handle the redirect to it
if ( ! req . query . redirect _uri || ! isValidRedirectUri ( req . query . redirect _uri ) ) {
Logger . debug ( ` [Auth] Invalid redirect_uri= ${ req . query . redirect _uri } ` )
2023-12-04 22:36:34 +01:00
return res . status ( 400 ) . send ( 'Invalid redirect_uri' )
}
2024-01-25 11:13:34 +01:00
// We cannot save the supplied redirect_uri in the session, because it the mobile client uses browser instead of the API
// for the request to mobile-redirect and as such the session is not shared
this . openIdAuthSession . set ( state , { mobile _redirect _uri : req . query . redirect _uri } )
2023-11-11 17:52:05 +01:00
2024-01-25 11:13:34 +01:00
redirectUri = new URL ( '/auth/openid/mobile-redirect' , hostUrl ) . toString ( )
2023-11-11 17:52:05 +01:00
} else {
2024-01-25 11:13:34 +01:00
redirectUri = new URL ( '/auth/openid/callback' , hostUrl ) . toString ( )
2023-11-11 17:52:05 +01:00
2024-01-25 11:13:34 +01:00
if ( req . query . state ) {
Logger . debug ( ` [Auth] Invalid state - not allowed on web openid flow ` )
return res . status ( 400 ) . send ( 'Invalid state, not allowed on web flow' )
}
2023-11-11 17:52:05 +01:00
}
2024-01-25 11:13:34 +01:00
oidcStrategy . _params . redirect _uri = redirectUri
Logger . debug ( ` [Auth] OIDC redirect_uri= ${ redirectUri } ` )
let { code _challenge , code _challenge _method , code _verifier } = generatePkce ( req , isMobileFlow )
2023-11-04 21:36:43 +01:00
2023-11-11 17:52:05 +01:00
req . session [ sessionKey ] = {
... req . session [ sessionKey ] ,
2024-01-25 11:44:20 +01:00
state : state ,
max _age : oidcStrategy . _params . max _age ,
response _type : 'code' ,
2024-01-25 11:13:34 +01:00
code _verifier : code _verifier , // not null if web flow
2023-12-05 09:43:06 +01:00
mobile : req . query . redirect _uri , // Used in the abs callback later, set mobile if redirect_uri is filled out
sso _redirect _uri : oidcStrategy . _params . redirect _uri // Save the redirect_uri (for the SSO Provider) for the callback
2023-11-11 17:52:05 +01:00
}
2023-11-05 19:37:05 +01:00
2024-03-19 17:57:24 +01:00
var scope = 'openid profile email'
if ( global . ServerSettings . authOpenIDGroupClaim ) {
scope += ' ' + global . ServerSettings . authOpenIDGroupClaim
}
if ( global . ServerSettings . authOpenIDAdvancedPermsClaim ) {
scope += ' ' + global . ServerSettings . authOpenIDAdvancedPermsClaim
}
2023-11-11 17:52:05 +01:00
const authorizationUrl = client . authorizationUrl ( {
2024-01-25 11:44:20 +01:00
... oidcStrategy . _params ,
state : state ,
2023-11-11 17:52:05 +01:00
response _type : 'code' ,
2024-03-19 17:57:24 +01:00
scope : scope ,
2023-11-11 17:52:05 +01:00
code _challenge ,
2023-12-05 00:18:58 +01:00
code _challenge _method
2023-11-11 17:52:05 +01:00
} )
2023-11-05 19:37:05 +01:00
2024-01-25 11:13:34 +01:00
this . paramsToCookies ( req , res , isMobileFlow ? 'openid-mobile' : 'openid' )
2023-11-05 19:37:05 +01:00
2023-11-11 17:52:05 +01:00
res . redirect ( authorizationUrl )
} catch ( error ) {
Logger . error ( ` [Auth] Error in /auth/openid route: ${ error } ` )
res . status ( 500 ) . send ( 'Internal Server Error' )
2023-11-05 19:37:05 +01:00
}
2024-01-25 11:13:34 +01:00
function generatePkce ( req , isMobileFlow ) {
if ( isMobileFlow ) {
if ( ! req . query . code _challenge ) {
throw new Error ( 'code_challenge required for mobile flow (PKCE)' )
}
if ( req . query . code _challenge _method && req . query . code _challenge _method !== 'S256' ) {
throw new Error ( 'Only S256 code_challenge_method method supported' )
}
return {
code _challenge : req . query . code _challenge ,
code _challenge _method : req . query . code _challenge _method || 'S256'
}
} else {
const code _verifier = OpenIDClient . generators . codeVerifier ( )
const code _challenge = OpenIDClient . generators . codeChallenge ( code _verifier )
return { code _challenge , code _challenge _method : 'S256' , code _verifier }
}
}
function isValidRedirectUri ( uri ) {
// Check if the redirect_uri is in the whitelist
return Database . serverSettings . authOpenIDMobileRedirectURIs . includes ( uri ) ||
( Database . serverSettings . authOpenIDMobileRedirectURIs . length === 1 && Database . serverSettings . authOpenIDMobileRedirectURIs [ 0 ] === '*' )
}
2023-11-11 17:52:05 +01:00
} )
2023-11-05 19:37:05 +01:00
2023-12-04 22:36:34 +01:00
// This will be the oauth2 callback route for mobile clients
// It will redirect to an app-link like audiobookshelf://oauth
router . get ( '/auth/openid/mobile-redirect' , ( req , res ) => {
try {
// Extract the state parameter from the request
const { state , code } = req . query
2023-12-17 17:41:39 +01:00
2023-12-04 22:36:34 +01:00
// Check if the state provided is in our list
if ( ! state || ! this . openIdAuthSession . has ( state ) ) {
Logger . error ( '[Auth] /auth/openid/mobile-redirect route: State parameter mismatch' )
return res . status ( 400 ) . send ( 'State parameter mismatch' )
}
2023-12-05 09:43:06 +01:00
let mobile _redirect _uri = this . openIdAuthSession . get ( state ) . mobile _redirect _uri
2023-12-04 22:36:34 +01:00
2023-12-05 09:43:06 +01:00
if ( ! mobile _redirect _uri ) {
2023-12-04 22:36:34 +01:00
Logger . error ( '[Auth] No redirect URI' )
return res . status ( 400 ) . send ( 'No redirect URI' )
}
this . openIdAuthSession . delete ( state )
2023-12-05 09:43:06 +01:00
const redirectUri = ` ${ mobile _redirect _uri } ?code= ${ encodeURIComponent ( code ) } &state= ${ encodeURIComponent ( state ) } `
2023-12-04 22:36:34 +01:00
// Redirect to the overwrite URI saved in the map
res . redirect ( redirectUri )
} catch ( error ) {
Logger . error ( ` [Auth] Error in /auth/openid/mobile-redirect route: ${ error } ` )
res . status ( 500 ) . send ( 'Internal Server Error' )
}
} )
2023-11-11 17:52:05 +01:00
// openid strategy callback route (this receives the token from the configured openid login provider)
router . get ( '/auth/openid/callback' , ( req , res , next ) => {
const oidcStrategy = passport . _strategy ( 'openid-client' )
const sessionKey = oidcStrategy . _key
2023-11-04 21:36:43 +01:00
2023-11-11 17:52:05 +01:00
if ( ! req . session [ sessionKey ] ) {
return res . status ( 400 ) . send ( 'No session' )
}
2023-11-05 19:37:05 +01:00
2023-11-11 17:52:05 +01:00
// If the client sends us a code_verifier, we will tell passport to use this to send this in the token request
// The code_verifier will be validated by the oauth2 provider by comparing it to the code_challenge in the first request
// Crucial for API/Mobile clients
if ( req . query . code _verifier ) {
req . session [ sessionKey ] . code _verifier = req . query . code _verifier
}
2023-04-14 20:26:29 +02:00
2023-11-28 20:07:49 +01:00
function handleAuthError ( isMobile , errorCode , errorMessage , logMessage , response ) {
2024-03-19 17:57:24 +01:00
Logger . error ( JSON . stringify ( logMessage , null , 2 ) )
2023-11-28 20:07:49 +01:00
if ( response ) {
// Depending on the error, it can also have a body
// We also log the request header the passport plugin sents for the URL
const header = response . req ? . _header . replace ( /Authorization: [^\r\n]*/i , 'Authorization: REDACTED' )
2024-03-19 17:57:24 +01:00
Logger . debug ( header + '\n' + JSON . stringify ( response . body , null , 2 ) )
2023-11-28 17:29:22 +01:00
}
if ( isMobile ) {
return res . status ( errorCode ) . send ( errorMessage )
} else {
return res . redirect ( ` /login?error= ${ encodeURIComponent ( errorMessage ) } &autoLaunch=0 ` )
}
}
2023-11-28 23:37:19 +01:00
function passportCallback ( req , res , next ) {
2023-11-28 17:29:22 +01:00
return ( err , user , info ) => {
const isMobile = req . session [ sessionKey ] ? . mobile === true
if ( err ) {
2023-11-28 20:07:49 +01:00
return handleAuthError ( isMobile , 500 , 'Error in callback' , ` [Auth] Error in openid callback - ${ err } ` , err ? . response )
2023-11-28 17:29:22 +01:00
}
if ( ! user ) {
// Info usually contains the error message from the SSO provider
2023-11-28 21:16:39 +01:00
return handleAuthError ( isMobile , 401 , 'Unauthorized' , ` [Auth] No data in openid callback - ${ info } ` , info ? . response )
2023-11-28 17:29:22 +01:00
}
2023-11-28 23:37:19 +01:00
2023-11-28 17:29:22 +01:00
req . logIn ( user , ( loginError ) => {
if ( loginError ) {
return handleAuthError ( isMobile , 500 , 'Error during login' , ` [Auth] Error in openid callback: ${ loginError } ` )
}
2024-01-24 22:47:50 +01:00
// The id_token does not provide access to the user, but is used to identify the user to the SSO provider
// instead it containts a JWT with userinfo like user email, username, etc.
// the client will get to know it anyway in the logout url according to the oauth2 spec
// so it is safe to send it to the client, but we use strict settings
2024-01-25 15:13:56 +01:00
res . cookie ( 'openid_id_token' , user . openid _id _token , { maxAge : 1000 * 60 * 60 * 24 * 365 * 10 , httpOnly : true , secure : true , sameSite : 'Strict' } )
2023-11-28 17:29:22 +01:00
next ( )
} )
}
}
2023-11-11 17:52:05 +01:00
// 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
2023-12-05 09:43:06 +01:00
// 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 )
2023-11-11 17:52:05 +01:00
} ,
2023-09-20 19:37:55 +02:00
// on a successfull login: read the cookies and react like the client requested (callback or json)
2023-09-26 00:05:58 +02:00
this . handleLoginSuccessBasedOnCookie . bind ( this ) )
2023-04-14 20:26:29 +02:00
2023-11-05 21:11:37 +01:00
/ * *
2023-12-17 17:41:39 +01:00
* Helper route used to auto - populate the openid URLs in config / authentication
* Takes an issuer URL as a query param and requests the config data at "/.well-known/openid-configuration"
*
* @ example / auth / openid / config ? issuer = http : //192.168.1.66:9000/application/o/audiobookshelf/
2023-11-05 21:11:37 +01:00
* /
2023-12-17 17:41:39 +01:00
router . get ( '/auth/openid/config' , this . isAuthenticated , async ( req , res ) => {
if ( ! req . user . isAdminOrUp ) {
Logger . error ( ` [Auth] Non-admin user " ${ req . user . username } " attempted to get issuer config ` )
return res . sendStatus ( 403 )
}
2023-11-05 21:11:37 +01:00
if ( ! req . query . issuer ) {
return res . status ( 400 ) . send ( 'Invalid request. Query param \'issuer\' is required' )
}
2023-12-17 17:41:39 +01:00
// Strip trailing slash
2023-11-05 21:11:37 +01:00
let issuerUrl = req . query . issuer
if ( issuerUrl . endsWith ( '/' ) ) issuerUrl = issuerUrl . slice ( 0 , - 1 )
2023-12-17 17:41:39 +01:00
// Append config pathname and validate URL
let configUrl = null
try {
configUrl = new URL ( ` ${ issuerUrl } /.well-known/openid-configuration ` )
if ( ! configUrl . pathname . endsWith ( '/.well-known/openid-configuration' ) ) {
throw new Error ( 'Invalid pathname' )
}
} catch ( 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' )
}
axios . get ( configUrl . toString ( ) ) . then ( ( { data } ) => {
2023-11-05 21:11:37 +01:00
res . json ( {
issuer : data . issuer ,
authorization _endpoint : data . authorization _endpoint ,
token _endpoint : data . token _endpoint ,
userinfo _endpoint : data . userinfo _endpoint ,
end _session _endpoint : data . end _session _endpoint ,
jwks _uri : data . jwks _uri
} )
} ) . catch ( ( 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 ` )
} )
} )
2023-03-24 18:21:25 +01:00
// Logout route
2023-04-16 17:08:13 +02:00
router . post ( '/logout' , ( req , res ) => {
2023-03-24 18:21:25 +01:00
// TODO: invalidate possible JWTs
2023-04-16 17:08:13 +02:00
req . logout ( ( err ) => {
if ( err ) {
res . sendStatus ( 500 )
} else {
2024-01-24 22:47:50 +01:00
const authMethod = req . cookies . auth _method
res . clearCookie ( 'auth_method' )
2024-03-12 18:07:13 +01:00
let logoutUrl = null
2024-01-24 22:47:50 +01:00
if ( authMethod === 'openid' || authMethod === 'openid-mobile' ) {
// If we are using openid, we need to redirect to the logout endpoint
// node-openid-client does not support doing it over passport
const oidcStrategy = passport . _strategy ( 'openid-client' )
const client = oidcStrategy . _client
2024-03-12 18:07:13 +01:00
if ( client . issuer . end _session _endpoint && client . issuer . end _session _endpoint . length > 0 ) {
let postLogoutRedirectUri = null
if ( authMethod === 'openid' ) {
const protocol = ( req . secure || req . get ( 'x-forwarded-proto' ) === 'https' ) ? 'https' : 'http'
const host = req . get ( 'host' )
// TODO: ABS does currently not support subfolders for installation
// If we want to support it we need to include a config for the serverurl
postLogoutRedirectUri = ` ${ protocol } :// ${ host } /login `
}
// else for openid-mobile we keep postLogoutRedirectUri on null
// nice would be to redirect to the app here, but for example Authentik does not implement
// the post_logout_redirect_uri parameter at all and for other providers
// we would also need again to implement (and even before get to know somehow for 3rd party apps)
// the correct app link like audiobookshelf://login (and maybe also provide a redirect like mobile-redirect).
// Instead because its null (and this way the parameter will be omitted completly), the client/app can simply append something like
// &post_logout_redirect_uri=audiobookshelf://login to the received logout url by itself which is the simplest solution
// (The URL needs to be whitelisted in the config of the SSO/ID provider)
logoutUrl = client . endSessionUrl ( {
id _token _hint : req . cookies . openid _id _token ,
post _logout _redirect _uri : postLogoutRedirectUri
} )
2024-01-24 22:47:50 +01:00
}
res . clearCookie ( 'openid_id_token' )
}
2024-03-12 18:07:13 +01:00
// Tell the user agent (browser) to redirect to the authentification provider's logout URL
// (or redirect_url: null if we don't have one)
res . send ( { redirect _url : logoutUrl } )
2023-04-16 17:08:13 +02:00
}
} )
2023-03-24 18:21:25 +01:00
} )
2021-08-18 00:01:11 +02:00
}
2023-03-24 18:21:25 +01:00
/ * *
* middleware to use in express to only allow authenticated users .
2023-09-24 19:36:36 +02:00
* @ param { import ( 'express' ) . Request } req
* @ param { import ( 'express' ) . Response } res
* @ param { import ( 'express' ) . NextFunction } next
2023-03-24 18:21:25 +01:00
* /
isAuthenticated ( req , res , next ) {
// check if session cookie says that we are authenticated
if ( req . isAuthenticated ( ) ) {
2021-08-18 00:01:11 +02:00
next ( )
2023-03-24 18:21:25 +01:00
} else {
// try JWT to authenticate
passport . authenticate ( "jwt" ) ( req , res , next )
2021-08-18 00:01:11 +02:00
}
}
2023-03-24 18:21:25 +01:00
/ * *
2023-09-24 19:36:36 +02:00
* Function to generate a jwt token for a given user
*
2023-11-08 23:14:57 +01:00
* @ param { { id : string , username : string } } user
2023-09-24 19:36:36 +02:00
* @ returns { string } token
2023-03-24 18:21:25 +01:00
* /
generateAccessToken ( user ) {
2023-04-16 17:08:13 +02:00
return jwt . sign ( { userId : user . id , username : user . username } , global . ServerSettings . tokenSecret )
2023-03-24 18:21:25 +01:00
}
2023-09-13 18:35:39 +02:00
/ * *
2023-09-24 19:36:36 +02:00
* Function to validate a jwt token for a given user
*
2023-09-13 18:35:39 +02:00
* @ param { string } token
2023-09-24 19:36:36 +02:00
* @ returns { Object } tokens data
2023-09-13 18:35:39 +02:00
* /
static validateAccessToken ( token ) {
try {
return jwt . verify ( token , global . ServerSettings . tokenSecret )
}
catch ( err ) {
return null
}
}
2023-03-24 18:21:25 +01:00
/ * *
2023-09-20 19:37:55 +02:00
* Generate a token which is used to encrpt / protect the jwts .
2023-03-24 18:21:25 +01:00
* /
2022-07-19 00:19:16 +02:00
async initTokenSecret ( ) {
if ( process . env . TOKEN _SECRET ) { // User can supply their own token secret
2023-09-23 20:42:28 +02:00
Database . serverSettings . tokenSecret = process . env . TOKEN _SECRET
2022-07-19 00:19:16 +02:00
} else {
2023-09-23 20:42:28 +02:00
Database . serverSettings . tokenSecret = require ( 'crypto' ) . randomBytes ( 256 ) . toString ( 'base64' )
2022-07-19 00:19:16 +02:00
}
2023-07-05 01:14:44 +02:00
await Database . updateServerSettings ( )
2022-07-19 00:19:16 +02:00
// New token secret creation added in v2.1.0 so generate new API tokens for each user
2023-08-20 20:34:03 +02:00
const users = await Database . userModel . getOldUsers ( )
2023-07-22 22:32:20 +02:00
if ( users . length ) {
for ( const user of users ) {
2023-11-08 23:14:57 +01:00
user . token = await this . generateAccessToken ( user )
2022-07-19 00:19:16 +02:00
}
2023-07-22 22:32:20 +02:00
await Database . updateBulkUsers ( users )
2022-07-19 00:19:16 +02:00
}
}
2023-03-24 18:21:25 +01:00
/ * *
* Checks if the user in the validated jwt _payload really exists and is active .
* @ param { Object } jwt _payload
* @ param { function } done
* /
2023-09-20 20:06:16 +02:00
async jwtAuthCheck ( jwt _payload , done ) {
2023-09-20 19:37:55 +02:00
// load user by id from the jwt token
2023-09-26 00:05:58 +02:00
const user = await Database . userModel . getUserByIdOrOldId ( jwt _payload . userId )
2021-09-22 03:57:33 +02:00
2023-09-26 00:05:58 +02:00
if ( ! user ? . isActive ) {
2023-09-20 19:37:55 +02:00
// deny login
2023-03-24 18:21:25 +01:00
done ( null , null )
return
2021-09-22 03:57:33 +02:00
}
2023-09-20 19:37:55 +02:00
// approve login
2023-03-24 18:21:25 +01:00
done ( null , user )
return
}
/ * *
2023-04-16 17:08:13 +02:00
* Checks if a username and password tuple is valid and the user active .
2023-03-24 18:21:25 +01:00
* @ param { string } username
* @ param { string } password
2024-02-17 23:06:25 +01:00
* @ param { Promise < function > } done
2023-03-24 18:21:25 +01:00
* /
2023-04-16 17:08:13 +02:00
async localAuthCheckUserPw ( username , password , done ) {
2023-09-20 19:37:55 +02:00
// Load the user given it's username
2023-09-16 21:45:04 +02:00
const user = await Database . userModel . getUserByUsername ( username . toLowerCase ( ) )
2021-09-22 03:57:33 +02:00
2023-12-02 23:17:52 +01:00
if ( ! user ? . isActive ) {
2023-03-24 18:21:25 +01:00
done ( null , null )
return
2021-08-18 00:01:11 +02:00
}
2023-03-24 18:21:25 +01:00
// Check passwordless root user
2023-12-02 23:17:52 +01:00
if ( user . type === 'root' && ! user . pash ) {
2023-03-24 18:21:25 +01:00
if ( password ) {
2023-09-20 19:37:55 +02:00
// deny login
2023-03-24 18:21:25 +01:00
done ( null , null )
return
}
2023-09-20 19:37:55 +02:00
// approve login
2023-03-24 18:21:25 +01:00
done ( null , user )
return
2023-12-02 23:17:52 +01:00
} else if ( ! user . pash ) {
Logger . error ( ` [Auth] User " ${ user . username } "/" ${ user . type } " attempted to login without a password set ` )
done ( null , null )
return
2021-09-11 02:55:02 +02:00
}
2023-03-24 18:21:25 +01:00
// Check password match
2023-04-16 17:08:13 +02:00
const compare = await bcrypt . compare ( password , user . pash )
2023-03-24 18:21:25 +01:00
if ( compare ) {
2023-09-20 19:37:55 +02:00
// approve login
2023-03-24 18:21:25 +01:00
done ( null , user )
return
2021-08-18 00:01:11 +02:00
}
2023-09-20 19:37:55 +02:00
// deny login
2023-03-24 18:21:25 +01:00
done ( null , null )
return
2021-08-18 00:01:11 +02:00
}
2023-03-24 18:21:25 +01:00
/ * *
* Hashes a password with bcrypt .
* @ param { string } password
2024-02-17 23:06:25 +01:00
* @ returns { Promise < string > } hash
2023-03-24 18:21:25 +01:00
* /
2021-08-18 00:01:11 +02:00
hashPass ( password ) {
return new Promise ( ( resolve ) => {
bcrypt . hash ( password , 8 , ( err , hash ) => {
if ( err ) {
resolve ( null )
} else {
resolve ( hash )
}
} )
} )
}
2023-03-24 18:21:25 +01:00
/ * *
2023-09-24 19:36:36 +02:00
* Return the login info payload for a user
*
* @ param { Object } user
* @ returns { Promise < Object > } jsonPayload
2023-03-24 18:21:25 +01:00
* /
2023-09-13 18:35:39 +02:00
async getUserLoginResponsePayload ( user ) {
const libraryIds = await Database . libraryModel . getAllLibraryIds ( )
2022-04-30 00:43:46 +02:00
return {
user : user . toJSONForBrowser ( ) ,
2023-07-22 21:25:20 +02:00
userDefaultLibraryId : user . getDefaultLibraryId ( libraryIds ) ,
2023-07-05 01:14:44 +02:00
serverSettings : Database . serverSettings . toJSONForBrowser ( ) ,
ereaderDevices : Database . emailSettings . getEReaderDevices ( user ) ,
2022-05-21 18:21:03 +02:00
Source : global . Source
2022-04-30 00:43:46 +02:00
}
}
2023-11-23 22:14:49 +01:00
/ * *
*
* @ param { string } password
2024-02-17 23:06:25 +01:00
* @ param { import ( './models/User' ) } user
* @ returns { Promise < boolean > }
2023-11-23 22:14:49 +01:00
* /
comparePassword ( password , user ) {
if ( user . type === 'root' && ! password && ! user . pash ) return true
if ( ! password || ! user . pash ) return false
return bcrypt . compare ( password , user . pash )
}
/ * *
* User changes their password from request
*
* @ param { import ( 'express' ) . Request } req
* @ param { import ( 'express' ) . Response } res
* /
async userChangePassword ( req , res ) {
let { password , newPassword } = req . body
newPassword = newPassword || ''
const matchingUser = req . user
// Only root can have an empty password
if ( matchingUser . type !== 'root' && ! newPassword ) {
return res . json ( {
error : 'Invalid new password - Only root can have an empty password'
} )
}
// Check password match
const compare = await this . comparePassword ( password , matchingUser )
if ( ! compare ) {
return res . json ( {
error : 'Invalid password'
} )
}
let pw = ''
if ( newPassword ) {
pw = await this . hashPass ( newPassword )
if ( ! pw ) {
return res . json ( {
error : 'Hash failed'
} )
}
}
matchingUser . pash = pw
const success = await Database . updateUser ( matchingUser )
if ( success ) {
Logger . info ( ` [Auth] User " ${ matchingUser . username } " changed password ` )
res . json ( {
success : true
} )
} else {
res . json ( {
error : 'Unknown error'
} )
}
}
2021-08-18 00:01:11 +02:00
}
2023-03-24 18:21:25 +01:00
2021-08-18 00:01:11 +02:00
module . exports = Auth