refactor(auth): remove keys auth module and align tests
- remove server auth module and client keys page/routes/api to simplify flow - update mcp and scenario tests to match uuid routes and step schema
This commit is contained in:
@@ -4,7 +4,6 @@ import { AppConfig, validateAppConfig } from "./config/app.config";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
import { ScheduleModule } from "@nestjs/schedule";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { BrowserModule } from "./browser/browser.module";
|
||||
import { SessionEntity } from "./session/session.entity";
|
||||
import { EnvironmentEntity } from "./environment/environment.entity";
|
||||
@@ -50,7 +49,6 @@ import { SnippetModule } from "./snippet/snippet.module";
|
||||
synchronize: true,
|
||||
}),
|
||||
}),
|
||||
AuthModule,
|
||||
BrowserModule,
|
||||
EnvironmentModule,
|
||||
CredentialModule,
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import { Body, Controller, Get, Post } from "@nestjs/common";
|
||||
import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { LoginDto } from "./dto/login.dto";
|
||||
|
||||
@ApiTags("auth")
|
||||
@Controller()
|
||||
export class AuthController {
|
||||
constructor(private readonly authService: AuthService) {}
|
||||
|
||||
@Get("keys")
|
||||
@ApiOperation({
|
||||
summary: "List available key identifiers from the keys directory",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: "List of key names",
|
||||
schema: {
|
||||
properties: { keys: { type: "array", items: { type: "string" } } },
|
||||
},
|
||||
})
|
||||
listKeys(): { keys: string[] } {
|
||||
return { keys: this.authService.listKeys() };
|
||||
}
|
||||
|
||||
@Post("login")
|
||||
@ApiOperation({
|
||||
summary: "Log in using a file key and return the session token",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 201,
|
||||
description: "Login successful",
|
||||
schema: {
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
sessionName: { type: "string" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiResponse({ status: 400, description: "Key not found or invalid" })
|
||||
@ApiResponse({ status: 500, description: "Automation failed" })
|
||||
login(
|
||||
@Body() dto: LoginDto,
|
||||
): Promise<{ token: string; sessionName: string }> {
|
||||
return this.authService.login(
|
||||
dto.key,
|
||||
dto.environmentName,
|
||||
dto.sessionName,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthController } from "./auth.controller";
|
||||
import { AuthService } from "./auth.service";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
|
||||
@Module({
|
||||
imports: [SessionModule, EnvironmentModule],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
@@ -1,265 +0,0 @@
|
||||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { TraceLogger } from "../common/trace-logger";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { AppConfig } from "../config/app.config";
|
||||
import { chromium } from "playwright";
|
||||
import type { Page } from "playwright";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as crypto from "crypto";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { EnvironmentService } from "../environment/environment.service";
|
||||
|
||||
interface KeyDescriptor {
|
||||
keyFile?: string;
|
||||
login?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new TraceLogger(AuthService.name);
|
||||
private readonly keysDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService<AppConfig, true>,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
) {
|
||||
this.keysDir = path.resolve(this.config.get("KEYS_DIR"));
|
||||
}
|
||||
|
||||
listKeys(): string[] {
|
||||
if (!fs.existsSync(this.keysDir)) return [];
|
||||
return fs
|
||||
.readdirSync(this.keysDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => path.basename(f, ".json"));
|
||||
}
|
||||
|
||||
private loadKeyDescriptor(keyId: string): KeyDescriptor {
|
||||
const keyJsonPath = path.join(this.keysDir, `${keyId}.json`);
|
||||
if (!fs.existsSync(keyJsonPath)) {
|
||||
throw new BadRequestException(`Key not found: ${keyId}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(keyJsonPath, "utf-8")) as KeyDescriptor;
|
||||
} catch (err) {
|
||||
throw new BadRequestException(`Cannot read key descriptor: ${keyId}`, {
|
||||
cause: err,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async login(
|
||||
keyId: string,
|
||||
environmentName: string,
|
||||
sessionName?: string,
|
||||
): Promise<{ token: string; sessionName: string }> {
|
||||
const resolvedSession = sessionName ?? crypto.randomUUID();
|
||||
|
||||
const env = await this.environmentService
|
||||
.findAll()
|
||||
.then(({ data }) => data.find((e) => e.name === environmentName));
|
||||
if (!env)
|
||||
throw new NotFoundException(`Environment "${environmentName}" not found`);
|
||||
|
||||
const loginUrl = env.urls.id_url;
|
||||
const cabinetUrl = env.urls.cabinet_url;
|
||||
if (!loginUrl)
|
||||
throw new BadRequestException(
|
||||
`Environment "${environmentName}" is missing id_url`,
|
||||
);
|
||||
if (!cabinetUrl)
|
||||
throw new BadRequestException(
|
||||
`Environment "${environmentName}" is missing cabinet_url`,
|
||||
);
|
||||
|
||||
const descriptor = this.loadKeyDescriptor(keyId);
|
||||
const useLoginPassword = !!descriptor.login;
|
||||
|
||||
if (!useLoginPassword) {
|
||||
if (!descriptor.keyFile) {
|
||||
throw new BadRequestException(
|
||||
`Key descriptor for "${keyId}" must have either "login" or "keyFile"`,
|
||||
);
|
||||
}
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
throw new BadRequestException(
|
||||
`Key file not found: ${descriptor.keyFile}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
|
||||
});
|
||||
try {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
|
||||
this.logger.log(`Navigating to ${loginUrl}`);
|
||||
await page.goto(loginUrl, { waitUntil: "networkidle" });
|
||||
|
||||
if (useLoginPassword) {
|
||||
// Click "Логін і пароль" auth method
|
||||
await page.locator('p[aria-label="Логін і пароль"]').click();
|
||||
|
||||
// Fill login and password
|
||||
await page.getByLabel("Електронна пошта").fill(descriptor.login!);
|
||||
await page.getByLabel("Пароль").fill(descriptor.password);
|
||||
|
||||
// Click "Увійти"
|
||||
await page.locator('button:has-text("Увійти")').click();
|
||||
} else {
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile!);
|
||||
|
||||
// Click "Файловий ключ" button
|
||||
await page.getByText("Файловий ключ").click();
|
||||
|
||||
// Upload key file via hidden file input
|
||||
const fileInput = page.locator(
|
||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
||||
);
|
||||
await fileInput.setInputFiles(keyFilePath);
|
||||
|
||||
// Enter password
|
||||
await page
|
||||
.locator("#id-app-login-file-key-password")
|
||||
.fill(descriptor.password);
|
||||
|
||||
// Click "Продовжити"
|
||||
await page.locator("#id-app-login-file-key-sign-button").click();
|
||||
}
|
||||
|
||||
// Wait until redirected to cabinet
|
||||
this.logger.log(`Waiting for redirect to ${cabinetUrl}`);
|
||||
await page.waitForURL(cabinetUrl, { timeout: 30000 });
|
||||
|
||||
// Extract token from localStorage
|
||||
const token = await page.evaluate(() => localStorage.getItem("token"));
|
||||
|
||||
if (!token) {
|
||||
throw new InternalServerErrorException(
|
||||
"Login succeeded but token was not found in localStorage",
|
||||
);
|
||||
}
|
||||
|
||||
// Capture all cookies and localStorage from the browser context
|
||||
const cookies = await context.cookies();
|
||||
const localStorageData = await page.evaluate(() => {
|
||||
const entries: Record<string, string> = {};
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (k !== null) entries[k] = window.localStorage.getItem(k) ?? "";
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
|
||||
await this.sessionService.upsert(
|
||||
resolvedSession,
|
||||
token,
|
||||
cookies,
|
||||
localStorageData,
|
||||
);
|
||||
|
||||
// Register the live Playwright context — browser stays open for reuse
|
||||
this.sessionContextService.register(
|
||||
resolvedSession,
|
||||
browser,
|
||||
context,
|
||||
page,
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`Login successful for key ${keyId}, session: ${resolvedSession}`,
|
||||
);
|
||||
return { token, sessionName: resolvedSession };
|
||||
} catch (err) {
|
||||
// Close browser only on failure — on success it is kept alive in SessionContextService
|
||||
await browser.close().catch(() => {});
|
||||
if (
|
||||
err instanceof BadRequestException ||
|
||||
err instanceof InternalServerErrorException
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
this.logger.error(`Login failed: ${(err as Error).message}`);
|
||||
throw new InternalServerErrorException(
|
||||
`Login automation failed: ${(err as Error).message}`,
|
||||
{ cause: err },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async signWithKey(keyId: string, page: Page): Promise<void> {
|
||||
const descriptor = this.loadKeyDescriptor(keyId);
|
||||
|
||||
if (!descriptor.keyFile) {
|
||||
throw new BadRequestException(
|
||||
`Key descriptor for "${keyId}" must have "keyFile" to sign`,
|
||||
);
|
||||
}
|
||||
|
||||
const keyFilePath = path.resolve(this.keysDir, descriptor.keyFile);
|
||||
if (!fs.existsSync(keyFilePath)) {
|
||||
throw new BadRequestException(
|
||||
`Key file not found: ${descriptor.keyFile}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(`Signing with key ${keyId} on page: ${page.url()}`);
|
||||
|
||||
// Open the EDS sign widget (skip if it's already open)
|
||||
const isSignDialogOpen = await page
|
||||
.locator('input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]')
|
||||
.isVisible()
|
||||
.catch(() => false);
|
||||
if (!isSignDialogOpen) {
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /підпис|sign/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Select the file key tab inside the widget
|
||||
await page
|
||||
.locator('button, [role="tab"], li')
|
||||
.filter({ hasText: /файлов|file key/i })
|
||||
.first()
|
||||
.click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Upload the key file
|
||||
const fileInput = page.locator(
|
||||
'input[accept=".dat,.pfx,.pk8,.zs2,.jks"][type="file"]',
|
||||
);
|
||||
await fileInput.setInputFiles(keyFilePath);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Enter password
|
||||
await page.locator('input[type="password"]').fill(descriptor.password);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
// Submit
|
||||
await page
|
||||
.locator("button")
|
||||
.filter({ hasText: /підпис|sign|підтвер/i })
|
||||
.last()
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
this.logger.log(`Sign completed for key ${keyId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { IsNotEmpty, IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class LoginDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Key identifier — filename (without extension) from the keys/ directory",
|
||||
example: "3273334361",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
key: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: "Environment name to resolve login/cabinet URLs from",
|
||||
example: "liquio-diia-stg",
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
environmentName: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"Session name to store credentials under. Auto-generated UUID if omitted.",
|
||||
example: "my-test-session",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
sessionName?: string;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { McpController } from "./mcp.controller";
|
||||
import { McpService } from "./mcp.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
import { BrowserModule } from "../browser/browser.module";
|
||||
@@ -10,7 +9,6 @@ import { ScenarioModule } from "../scenario/scenario.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AuthModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
BrowserModule,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import { z } from "zod";
|
||||
import type { Request, Response } from "express";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { SessionService } from "../session/session.service";
|
||||
import { SessionContextService } from "../session/session-context.service";
|
||||
import { EnvironmentService } from "../environment/environment.service";
|
||||
@@ -17,7 +16,6 @@ import pkg from "../../package.json";
|
||||
@Injectable()
|
||||
export class McpService {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly sessionService: SessionService,
|
||||
private readonly sessionContextService: SessionContextService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@@ -33,53 +31,6 @@ export class McpService {
|
||||
}
|
||||
|
||||
private registerTools(server: McpServer): void {
|
||||
// ── Auth ──────────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
"list_keys",
|
||||
{ description: "List available key identifiers from the keys directory" },
|
||||
async () => {
|
||||
const keys = this.authService.listKeys();
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(keys) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"login",
|
||||
{
|
||||
description:
|
||||
"Log in using a file key against a named environment and store the session",
|
||||
inputSchema: {
|
||||
key: z
|
||||
.string()
|
||||
.describe(
|
||||
"Key identifier (filename without extension from keys/ dir)",
|
||||
),
|
||||
environmentName: z
|
||||
.string()
|
||||
.describe("Environment name to resolve login/cabinet URLs"),
|
||||
sessionName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Session name to store credentials under. Auto-UUID if omitted.",
|
||||
),
|
||||
},
|
||||
},
|
||||
async ({ key, environmentName, sessionName }) => {
|
||||
const result = await this.authService.login(
|
||||
key,
|
||||
environmentName,
|
||||
sessionName,
|
||||
);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(result) }],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// ── Sessions ──────────────────────────────────────────────────────────────
|
||||
|
||||
server.registerTool(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
|
||||
@@ -10,7 +10,6 @@ import { CredentialEntity } from "../credential/credential.entity";
|
||||
import { ScenarioService } from "./scenario.service";
|
||||
import { ScenarioController } from "./scenario.controller";
|
||||
import { ScenarioSchedulerService } from "./scenario-scheduler.service";
|
||||
import { AuthModule } from "../auth/auth.module";
|
||||
import { CodeExecutorModule } from "../code-executor/code-executor.module";
|
||||
import { SessionModule } from "../session/session.module";
|
||||
import { EnvironmentModule } from "../environment/environment.module";
|
||||
@@ -27,7 +26,6 @@ import { SnippetModule } from "../snippet/snippet.module";
|
||||
ScenarioCredentialEntity,
|
||||
CredentialEntity,
|
||||
]),
|
||||
AuthModule,
|
||||
CodeExecutorModule,
|
||||
SessionModule,
|
||||
EnvironmentModule,
|
||||
|
||||
@@ -334,7 +334,6 @@ export class ScenarioService {
|
||||
name: scenario.name,
|
||||
steps: scenario.steps.map((s) => ({
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
title: s.title,
|
||||
execCode: s.execCode,
|
||||
validateCode: s.validateCode,
|
||||
@@ -367,7 +366,6 @@ export class ScenarioService {
|
||||
this.stepRepo.create({
|
||||
scenarioId: scenario.id,
|
||||
order: s.order,
|
||||
type: s.type,
|
||||
title: s.title ?? null,
|
||||
execCode: s.execCode ?? null,
|
||||
validateCode: s.validateCode ?? null,
|
||||
|
||||
Reference in New Issue
Block a user