mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
feat(sidecar): Ozonetel admin auth service — RSA login, JWT cache
- Node crypto RSA encryption (not jsencrypt — server-side) - Pre-login public key fetch, encrypted login, JWT caching - Auto-refresh before token expiry (decoded from JWT payload) - Auth headers: Bearer token + userId + userName + isSuperAdmin - Registered in SupervisorModule with ConfigThemeModule import Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
123
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { publicEncrypt, constants as cryptoConstants } from 'crypto';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
|
// Ozonetel admin API auth — login with RSA-encrypted credentials, cache JWT.
|
||||||
|
// Used by supervisor barge endpoints to call dashboardApi.
|
||||||
|
//
|
||||||
|
// Auth flow (from CA-Admin source code):
|
||||||
|
// 1. GET /api/auth/public-key → { publicKey, keyId }
|
||||||
|
// 2. RSA-encrypt username + password with publicKey
|
||||||
|
// 3. POST /auth/login → JWT token
|
||||||
|
// 4. All admin API calls use: Authorization: Bearer <jwt>, userId, userName, isSuperAdmin
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OzonetelAdminAuthService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(OzonetelAdminAuthService.name);
|
||||||
|
private cachedToken: string | null = null;
|
||||||
|
private cachedUserId: string | null = null;
|
||||||
|
private cachedUserName: string | null = null;
|
||||||
|
private tokenExpiresAt = 0;
|
||||||
|
|
||||||
|
constructor(private readonly telephony: TelephonyConfigService) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
if (config.ozonetel.adminUsername && config.ozonetel.adminPassword) {
|
||||||
|
this.logger.log('Ozonetel admin credentials configured — will authenticate on first use');
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Ozonetel admin credentials not configured — supervisor barge will be unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private get apiBase(): string {
|
||||||
|
return 'https://api.cloudagent.ozonetel.com';
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
return {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'userId': this.cachedUserId ?? '',
|
||||||
|
'userName': this.cachedUserName ?? '',
|
||||||
|
'isSuperAdmin': 'true',
|
||||||
|
'dAccessType': 'false',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
return this.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
private rsaEncrypt(publicKeyPem: string, plaintext: string): string {
|
||||||
|
const buffer = Buffer.from(plaintext, 'utf8');
|
||||||
|
const encrypted = publicEncrypt(
|
||||||
|
{ key: publicKeyPem, padding: cryptoConstants.RSA_PKCS1_PADDING },
|
||||||
|
buffer,
|
||||||
|
);
|
||||||
|
return encrypted.toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async login(): Promise<string> {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
const { adminUsername, adminPassword } = config.ozonetel;
|
||||||
|
|
||||||
|
if (!adminUsername || !adminPassword) {
|
||||||
|
throw new Error('Ozonetel admin credentials not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Get RSA public key
|
||||||
|
this.logger.log('Fetching Ozonetel public key...');
|
||||||
|
const preLoginRes = await axios.get(`${this.apiBase}/api/auth/public-key`);
|
||||||
|
const { publicKey, keyId } = preLoginRes.data;
|
||||||
|
|
||||||
|
if (!publicKey || !keyId) {
|
||||||
|
throw new Error('Failed to get Ozonetel public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: RSA-encrypt credentials using Node crypto
|
||||||
|
const encryptedUsername = this.rsaEncrypt(publicKey, adminUsername);
|
||||||
|
const encryptedPassword = this.rsaEncrypt(publicKey, adminPassword);
|
||||||
|
|
||||||
|
// Step 3: Login
|
||||||
|
this.logger.log('Logging into Ozonetel admin portal...');
|
||||||
|
const loginRes = await axios.post(`${this.apiBase}/auth/login`, {
|
||||||
|
username: encryptedUsername,
|
||||||
|
password: encryptedPassword,
|
||||||
|
keyId,
|
||||||
|
ltype: 'PORTAL',
|
||||||
|
}, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = loginRes.data;
|
||||||
|
if (!data.token) {
|
||||||
|
throw new Error(`Ozonetel admin login failed: ${JSON.stringify(data)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cachedToken = data.token;
|
||||||
|
this.cachedUserId = data.userId?.toString() ?? data.UserId?.toString() ?? '';
|
||||||
|
this.cachedUserName = data.name ?? adminUsername;
|
||||||
|
|
||||||
|
// Decode token expiry — fallback to 6 hours
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(Buffer.from(data.token.split('.')[1], 'base64').toString());
|
||||||
|
this.tokenExpiresAt = (payload.exp ?? 0) * 1000 - 60_000; // refresh 1 min early
|
||||||
|
} catch {
|
||||||
|
this.tokenExpiresAt = Date.now() + 6 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Ozonetel admin login successful (userId=${this.cachedUserId}, expires in ${Math.round((this.tokenExpiresAt - Date.now()) / 60000)}min)`);
|
||||||
|
return this.cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConfigured(): boolean {
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
return !!(config.ozonetel.adminUsername && config.ozonetel.adminPassword);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
import { Module, forwardRef } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), ConfigThemeModule],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService, OzonetelAdminAuthService],
|
||||||
exports: [SupervisorService],
|
exports: [SupervisorService, OzonetelAdminAuthService],
|
||||||
})
|
})
|
||||||
export class SupervisorModule {}
|
export class SupervisorModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user