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:
2026-04-12 16:05:24 +05:30
parent 27a3fbcfed
commit c23792496b
2 changed files with 128 additions and 3 deletions

View 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);
}
}