import * as plugins from '../plugins.js'; import type { Cloudly } from '../classes.cloudly.js'; import { logger } from '../logger.js'; import { Authorization } from './classes.authorization.js'; import { User } from './classes.user.js'; export interface IJwtData { userId: string; status: 'loggedIn' | 'loggedOut'; expiresAt: number; } interface IReq_AdminValidateIdentity { method: 'adminValidateIdentity'; request: { identity: plugins.servezoneInterfaces.data.IIdentity; }; response: { valid: boolean; reason?: string; }; } export class CloudlyAuthManager { cloudlyRef: Cloudly; public get db() { return this.cloudlyRef.mongodbConnector.smartdataDb; } public CUser = plugins.smartdata.setDefaultManagerForDoc(this, User); public CAuthorization = plugins.smartdata.setDefaultManagerForDoc(this, Authorization); public typedrouter = new plugins.typedrequest.TypedRouter(); public smartjwtInstance!: plugins.smartjwt.SmartJwt; constructor(cloudlyRef: Cloudly) { this.cloudlyRef = cloudlyRef; this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); } public async createNewSecureToken() { return plugins.smartunique.uniSimple('secureToken', 64); } public async start() { // lets setup the smartjwtInstance this.smartjwtInstance = new plugins.smartjwt.SmartJwt(); await this.smartjwtInstance.init(); const kvStore = await this.cloudlyRef.config.appData.getKvStore(); const existingJwtKeys: plugins.tsclass.network.IJwtKeypair = (await kvStore.readKey( 'jwtKeypair', )) as plugins.tsclass.network.IJwtKeypair; if (!existingJwtKeys) { await this.smartjwtInstance.createNewKeyPair(); const newJwtKeys = this.smartjwtInstance.getKeyPairAsJson(); await kvStore.writeKey('jwtKeypair', newJwtKeys); } else { this.smartjwtInstance.setKeyPairAsJson(existingJwtKeys); } await this.bootstrapInitialAdmin(); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'adminLoginWithUsernameAndPassword', async (dataArg) => { let jwt: string; let expiresAtTimestamp: number = Date.now() + 3600 * 1000 * 24 * 7; const user = await User.findUserByUsernameAndPassword(dataArg.username, dataArg.password); if (!user) { logger.log('warn', 'login failed'); throw new plugins.typedrequest.TypedResponseError('login failed'); } else { jwt = await this.smartjwtInstance.createJWT({ userId: user.id, status: 'loggedIn', expiresAt: expiresAtTimestamp, }); logger.log('success', 'login successful'); } return { identity: { jwt, userId: user.id, name: user.data.username || user.id, expiresAt: expiresAtTimestamp, role: user.data.role, type: user.data.type, }, }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler('adminValidateIdentity', async (dataArg) => { const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false); return { valid, reason: valid ? undefined : 'identity is not valid', }; }), ); } private async bootstrapInitialAdmin() { const users = await this.CUser.getInstances({}); const hasHumanUser = users.some((userArg) => userArg.data?.type === 'human'); if (hasHumanUser) { return; } const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount; let username: string; let password: string; let hostedBootstrapActionId: string | undefined; if (adminAccount) { const separatorIndex = adminAccount.indexOf(':'); if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) { throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format'); } username = adminAccount.slice(0, separatorIndex).trim(); password = adminAccount.slice(separatorIndex + 1); if (!username || !password) { throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password'); } } else { const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap(); if (!hostedBootstrap) { throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available'); } username = hostedBootstrap.username; password = hostedBootstrap.password; hostedBootstrapActionId = hostedBootstrap.actionId; } const user = new this.CUser({ id: await this.CUser.getNewId(), data: { type: 'human', username, password, role: 'admin', }, }); await user.save(); logger.log('success', `created initial admin user ${username}`); if (hostedBootstrapActionId) { await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction( hostedBootstrapActionId, 'Cloudly created the initial admin user.', ).catch((errorArg) => { logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`); }); } } public async stop() {} public validIdentityGuard = new plugins.smartguard.Guard<{ identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { try { const jwt = dataArg.identity?.jwt; if (!jwt) { return false; } const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const expired = jwtData.expiresAt < Date.now(); return ( jwtData.status === 'loggedIn' && !expired && dataArg.identity.expiresAt === jwtData.expiresAt && dataArg.identity.userId === jwtData.userId ); } catch { return false; } }, { failedHint: 'identity is not valid.', name: 'validIdentityGuard', }, ); public adminIdentityGuard = new plugins.smartguard.Guard<{ identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { const validIdentity = await this.validIdentityGuard.exec(dataArg); if (!validIdentity) { return false; } const jwt = dataArg.identity.jwt; const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const user = await this.CUser.getInstance({ id: jwtData.userId }); return user?.data.role === 'admin'; }, { failedHint: 'identity is not valid or user is not admin.', name: 'adminIdentityGuard', }, ); public adminOrClusterIdentityGuard = new plugins.smartguard.Guard<{ identity: plugins.servezoneInterfaces.data.IIdentity; }>( async (dataArg) => { const validIdentity = await this.validIdentityGuard.exec(dataArg); if (!validIdentity) { return false; } const jwt = dataArg.identity.jwt; const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt); const user = await this.CUser.getInstance({ id: jwtData.userId }); return user?.data.role === 'admin' || user?.data.role === 'cluster'; }, { failedHint: 'identity is not valid or user is not admin or cluster.', name: 'adminOrClusterIdentityGuard', }, ); }