fix(mcp): подготовить доступ и отзыв разрешений для приёмки

This commit is contained in:
Denozordec committed 2026-11-03 03:05:24 +07:00
commit ef7bb79d4e
311 files changed
+73119

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
import js from '@eslint/js'
import globals from 'globals'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist', 'data']),
{
files: ['**/*.ts'],
extends: [js.configs.recommended, tseslint.configs.recommended],
languageOptions: {
globals: globals.node,
},
rules: {
'@typescript-eslint/no-unused-vars': [
'error',
{ ignoreRestSiblings: true, argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
},
},
])
+44
View File
@@ -0,0 +1,44 @@
{
"name": "@authportal/api",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsup",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "node dist/server.js",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@authportal/db": "workspace:*",
"@authportal/shared": "workspace:*",
"@fastify/compress": "^8.3.1",
"@fastify/cors": "^11.0.1",
"@fastify/formbody": "^9.0.0",
"@fastify/helmet": "^13.0.1",
"@fastify/jwt": "^9.1.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^8.2.0",
"@node-rs/argon2": "^2.0.2",
"@simplewebauthn/server": "^13.3.2",
"better-sqlite3": "^11.10.0",
"fastify": "^5.4.0",
"jose": "^6.2.8",
"zod": "catalog:"
},
"devDependencies": {
"@eslint/js": "catalog:",
"@types/node": "catalog:",
"@vitest/coverage-v8": "4.1.10",
"eslint": "catalog:",
"globals": "catalog:",
"tsup": "^8.5.0",
"tsx": "^4.20.3",
"typescript": "catalog:",
"typescript-eslint": "catalog:",
"vitest": "catalog:"
}
}
+293
View File
@@ -0,0 +1,293 @@
import type { FastifyError, FastifyInstance } from 'fastify'
import Fastify from 'fastify'
import compress from '@fastify/compress'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import rateLimit from '@fastify/rate-limit'
import formbody from '@fastify/formbody'
import fjwt from '@fastify/jwt'
import fastifyStatic from '@fastify/static'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import {
APP_IDS,
allPermissionKeys,
} from '@authportal/shared'
import {
closeDb,
createDb,
getAppSwitcherConfig,
getUserApps,
hasAnyUser,
listAdmins,
healthCheck,
migrateSchema,
setUserAccess,
users,
type AppDb,
type Sqlite,
} from '@authportal/db'
import { hashPassword, setPasswordConcurrency } from './lib/password.js'
import { randomUUID } from 'node:crypto'
import {
assertBootstrapPassword,
parseTrustProxy,
type AppConfig,
} from './config.js'
import { authRoutes } from './routes/auth.js'
import { adminRoutes } from './routes/admin.js'
import { auditAdminRoutes } from './routes/audit.js'
import { auditIngestRoutes } from './routes/ingest-audit.js'
import { oidcRoutes } from './routes/oidc.js'
import { webauthnRoutes } from './routes/webauthn.js'
import { startAuditRetentionJob } from './services/audit-retention.js'
import { startHousekeepingJob } from './services/housekeeping.js'
import { configurePortalRevocationSender } from './services/portal-revocations.js'
import { startBackupJob } from './services/backup.js'
import {
ensureOidcSigningKey,
isOidcKeyReady,
resetOidcKeyCache,
} from './lib/oidc/keys.js'
import { createOriginChecker } from './lib/cors.js'
/** Пути сервера: для них SPA-fallback не применяется (404 JSON вместо index.html). */
const API_PREFIXES = ['/api', '/oauth', '/.well-known', '/assets/']
function boolFlag(v: string): boolean {
return v === '1' || v.toLowerCase() === 'true'
}
const HASHED_ASSET_RE = /[\\/]assets[\\/]/
const INDEX_HTML_RE = /[\\/]index\.html$/
declare module 'fastify' {
interface FastifyInstance {
config: AppConfig
db: AppDb
sqlite: Sqlite
}
}
export async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
if (!hasAnyUser(app.db)) {
assertBootstrapPassword(app.config)
const now = new Date().toISOString()
const passwordHash = await hashPassword(app.config.adminPassword)
const id = randomUUID()
app.db
.insert(users)
.values({
id,
email: app.config.adminEmail.toLowerCase(),
name: app.config.adminName,
passwordHash,
isAdmin: true,
disabled: false,
createdAt: now,
updatedAt: now,
})
.run()
setUserAccess(app.db, id, [...APP_IDS], allPermissionKeys())
app.log.info(`bootstrap admin created: ${app.config.adminEmail}`)
return
}
// Ensure existing admins have app tiles if access was never set
for (const user of listAdmins(app.db)) {
const apps = getUserApps(app.db, user.id)
if (apps.length === 0) {
setUserAccess(app.db, user.id, [...APP_IDS], allPermissionKeys())
app.log.info(`granted full catalog access to admin ${user.email}`)
}
}
}
export async function buildApp(opts: {
config: AppConfig
databaseUrl?: string
}): Promise<FastifyInstance> {
const config = opts.config
const dbUrl = opts.databaseUrl ?? config.databaseUrl
const path = dbUrl.replace(/^sqlite:/, '')
if (path !== ':memory:') {
mkdirSync(dirname(resolve(path)), { recursive: true })
}
setPasswordConcurrency(config.argon2Concurrency)
const { db, sqlite } = createDb(dbUrl)
migrateSchema(sqlite)
const app = Fastify({
logger: {
level: config.logLevel,
// Секреты не должны попадать в логи, даже если сериализатор начнёт отдавать заголовки.
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'res.headers["set-cookie"]',
],
censor: '[redacted]',
},
},
trustProxy: parseTrustProxy(config.trustProxy),
// keepAlive выше idle-таймаута Traefik (60 с), иначе 502 на переиспользованном соединении
keepAliveTimeout: 65_000,
connectionTimeout: 0,
requestTimeout: 30_000,
bodyLimit: 1_048_576,
})
app.decorate('config', config)
app.decorate('db', db)
app.decorate('sqlite', sqlite)
// Configure before bootstrap/access writes; failed opt-in must close this owned handle.
try { configurePortalRevocationSender(app) } catch (error) { closeDb(sqlite); throw error }
app.addHook('onClose', async () => {
closeDb(sqlite)
})
app.setErrorHandler((err: FastifyError | Error, req, reply) => {
const status =
err.name === 'ZodError'
? 400
: (err as FastifyError).statusCode && (err as FastifyError).statusCode! >= 400
? (err as FastifyError).statusCode!
: 500
if (status >= 500) {
req.log.error(err)
return reply.status(500).send({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
}
const code =
status === 429
? 'RATE_LIMITED'
: status === 400
? 'BAD_REQUEST'
: ((err as FastifyError).code ?? 'ERROR')
return reply.status(status).send({ error: { code, message: err.message } })
})
const originAllowed = createOriginChecker(config, () =>
getAppSwitcherConfig(db).apps.map((a) => a.url),
)
if (boolFlag(config.enableCompress)) {
// Предсжатая статика уже с Content-Encoding — плагин её не трогает.
await app.register(compress, { threshold: 1024, encodings: ['br', 'gzip'] })
}
await app.register(formbody)
await app.register(cors, {
origin: (origin, cb) => cb(null, !origin || originAllowed(origin)),
credentials: true,
})
await app.register(helmet, {
// SPA целиком на своём origin; inline-стили нужны React/Tailwind (style-атрибуты).
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'blob:'],
fontSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
},
},
})
await app.register(rateLimit, {
max: config.rateLimitMax,
timeWindow: '1 minute',
// пробы Docker/Traefik не должны упираться в лимит
allowList: (req) => req.url === '/health' || req.url === '/ready',
})
await app.register(fjwt, { secret: config.jwtSecret })
// liveness: процесс жив и отвечает; БД не трогаем, чтобы пробы не рестартили
// контейнер из-за временной блокировки.
app.get('/health', async () => ({ ok: true }))
// readiness: БД отвечает и ключ подписи OIDC загружен.
app.get('/ready', async (_request, reply) => {
try {
healthCheck(sqlite)
} catch {
return reply.status(503).send({ ok: false, reason: 'db' })
}
if (!isOidcKeyReady()) {
return reply.status(503).send({ ok: false, reason: 'oidc_key' })
}
return { ok: true }
})
app.get('/api/v1/health', async () => ({ ok: true, service: 'auth-portal' }))
resetOidcKeyCache()
await ensureOidcSigningKey(app)
await ensureBootstrapAdmin(app)
await app.register(authRoutes)
await app.register(adminRoutes)
await app.register(auditAdminRoutes)
await app.register(auditIngestRoutes)
await app.register(oidcRoutes)
await app.register(webauthnRoutes)
if (process.env.NODE_ENV !== 'test') {
const stopRetention = startAuditRetentionJob(app)
const stopHousekeeping = startHousekeepingJob(app)
const stopBackup = startBackupJob(app)
app.addHook('onClose', async () => {
stopRetention()
stopHousekeeping()
stopBackup()
})
if (!config.oidcRsaPrivateKey && config.backupIntervalHours <= 0) {
app.log.warn(
'OIDC-ключ подписи хранится только в SQLite, а встроенный бэкап выключен: ' +
'задайте OIDC_RSA_PRIVATE_KEY или BACKUP_INTERVAL_HOURS / запускайте `backup` по cron',
)
}
}
if (config.staticDir && existsSync(config.staticDir)) {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// .gz/.br для статики готовит Dockerfile на этапе сборки, здесь только отдаём.
preCompressed: true,
// Cache-Control выставляем сами (иначе плагин перезаписывает заголовок).
cacheControl: false,
setHeaders(res, filePath) {
if (HASHED_ASSET_RE.test(filePath)) {
// Vite кладёт хеш в имя файла → кэш на год без ревалидации.
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
} else if (INDEX_HTML_RE.test(filePath)) {
// Точка входа должна ревалидироваться, иначе клиент застрянет на старых чанках.
res.setHeader('Cache-Control', 'no-cache')
} else {
res.setHeader('Cache-Control', 'public, max-age=0')
}
},
})
}
const spa = Boolean(config.staticDir && existsSync(config.staticDir))
app.setNotFoundHandler((req, reply) => {
if (
spa &&
req.method === 'GET' &&
!API_PREFIXES.some((p) => req.url.startsWith(p))
) {
return reply.sendFile('index.html')
}
return reply
.status(404)
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
})
return app
}
+184
View File
@@ -0,0 +1,184 @@
import { z } from 'zod'
const boolFromEnv = (v: string | undefined, fallback: boolean) => {
if (v === undefined || v === '') return fallback
return v === '1' || v.toLowerCase() === 'true'
}
/** Loopback + приватные сети (Docker/Traefik). */
export const DEFAULT_TRUST_PROXY =
'127.0.0.1/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7'
export function parseTrustProxy(raw: string): boolean | string[] {
const v = raw.trim()
if (v === 'true') return true
if (v === 'false' || v === '') return false
return v.split(',').map((s) => s.trim()).filter(Boolean)
}
export const configSchema = z.object({
databaseUrl: z.string().default('sqlite:data/app.db'),
jwtSecret: z.string().min(8),
jwtTtlHours: z.coerce.number().positive().default(1),
refreshTtlDays: z.coerce.number().positive().default(14),
issuer: z.string().url().default('https://auth.shnt.top'),
/** OIDC issuer URL; defaults to `issuer`. */
oidcIssuer: z.string().url().optional(),
/** Optional PEM PKCS8 RSA private key; otherwise auto-generated & persisted. */
oidcRsaPrivateKey: z.string().optional(),
adminEmail: z.string().email().default('admin@shnt.top'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
/** Каталог бэкапов SQLite; по умолчанию <каталог БД>/backups. */
backupDir: z.string().optional(),
/** Сколько последних копий хранить. */
backupKeep: z.coerce.number().int().positive().default(7),
/** Период встроенного бэкапа в часах; 0 — выключен (используйте cron/CLI). */
backupIntervalHours: z.coerce.number().min(0).default(0),
/** Сжатие ответов в Node (без Traefik compress). Включайте, только если перед приложением нет сжимающего прокси. */
enableCompress: z.string().default('false'),
/** Глобальный лимит запросов на IP в минуту (per-route лимиты задаются отдельно). */
rateLimitMax: z.coerce.number().int().positive().default(200),
/** Доп. разрешённые CORS-origins через запятую ('*' — все). */
corsOrigins: z.string().default(''),
/** Одновременных argon2-хешей (каждый ~19 МиБ). */
argon2Concurrency: z.coerce.number().int().positive().default(2),
/** 'true' | 'false' | список IP/CIDR доверенных прокси. */
trustProxy: z.string().default(DEFAULT_TRUST_PROXY),
/** Сколько дней хранить истёкшие/отозванные сессии и коды перед удалением. */
sessionRetentionDays: z.coerce.number().positive().default(7),
/** Максимум на graceful shutdown, затем принудительный выход. */
shutdownTimeoutMs: z.coerce.number().int().positive().default(8000),
isProd: z.boolean(),
auditIngestSecret: z.string().min(8).optional(),
portalRevocationsEnabled: z.boolean().default(false),
portalRevocationsTarget: z.string().optional(),
portalRevocationsSecret: z.string().optional(),
webauthnRpID: z.string().min(1),
webauthnRpName: z.string().min(1).default('Auth Portal'),
webauthnOrigins: z.array(z.string().url()).min(1),
})
export type AppConfig = z.infer<typeof configSchema>
const WEAK_JWT_SECRETS = new Set([
'change-me-in-production',
'dev-secret-change-me',
'secret',
'changeme',
])
/** В production запрещены известные дефолты JWT_SECRET; короткий секрет — только предупреждение. */
function assertProductionSecrets(config: AppConfig): void {
if (!config.isProd) return
if (WEAK_JWT_SECRETS.has(config.jwtSecret)) {
throw new Error(
'JWT_SECRET: в production нельзя использовать значение по умолчанию — задайте уникальный секрет',
)
}
if (config.jwtSecret.length < 16) {
// Не роняем старые установки с коротким, но уникальным секретом.
process.emitWarning(
'JWT_SECRET короче 16 символов — смените на более длинный (смена разлогинит пользователей)',
)
}
}
/** Пароль первого администратора: в production — не дефолтный и не короче 8 символов. */
export function assertBootstrapPassword(config: AppConfig): void {
if (!config.isProd) return
if (!config.adminPassword || config.adminPassword === 'admin' || config.adminPassword.length < 8) {
throw new Error(
'ADMIN_PASSWORD: для создания первого администратора в production задайте пароль не короче 8 символов (не "admin")',
)
}
}
export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
const isProd = env.NODE_ENV === 'production'
const jwtSecret = env.JWT_SECRET ?? (isProd ? '' : 'dev-secret-change-me')
const auditIngestSecret =
env.AUDIT_INGEST_SECRET ??
(isProd ? undefined : 'dev-audit-ingest-secret')
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
const { rpID, origins } = webauthnFromIssuer(issuer, env, isProd)
const config = configSchema.parse({
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
jwtSecret,
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
issuer,
oidcIssuer: env.OIDC_ISSUER || issuer,
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
adminEmail: env.ADMIN_EMAIL ?? 'admin@shnt.top',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist:
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
shutdownTimeoutMs: env.SHUTDOWN_TIMEOUT_MS ?? 8000,
sessionRetentionDays: env.SESSION_RETENTION_DAYS ?? 7,
backupDir: env.BACKUP_DIR || undefined,
backupKeep: env.BACKUP_KEEP ?? 7,
backupIntervalHours: env.BACKUP_INTERVAL_HOURS ?? 0,
enableCompress: env.ENABLE_COMPRESS ?? 'false',
rateLimitMax: env.RATE_LIMIT_MAX ?? 200,
corsOrigins: env.CORS_ORIGINS ?? '',
argon2Concurrency: env.ARGON2_CONCURRENCY ?? 2,
trustProxy: env.TRUST_PROXY ?? DEFAULT_TRUST_PROXY,
isProd:
boolFromEnv(env.NODE_ENV === 'production' ? 'true' : undefined, false) ||
isProd,
auditIngestSecret,
portalRevocationsEnabled: boolFromEnv(env.MCP_REVOCATIONS_ENABLED, false),
portalRevocationsTarget: env.MCP_REVOCATIONS_TARGET || undefined,
portalRevocationsSecret: env.MCP_REVOCATIONS_SECRET || undefined,
webauthnRpID: rpID,
webauthnRpName: env.WEBAUTHN_RP_NAME || 'Auth Portal',
webauthnOrigins: origins,
})
assertProductionSecrets(config)
return config
}
function webauthnFromIssuer(
issuer: string,
env: NodeJS.ProcessEnv,
isProd: boolean,
): { rpID: string; origins: string[] } {
let issuerUrl: URL
try {
issuerUrl = new URL(issuer)
} catch {
issuerUrl = new URL('https://auth.shnt.top')
}
const rpID = (env.WEBAUTHN_RP_ID || issuerUrl.hostname).trim()
const origins = new Set<string>()
origins.add(issuerUrl.origin)
const extra = env.WEBAUTHN_ORIGINS ?? ''
for (const raw of extra.split(',')) {
const value = raw.trim().replace(/\/$/, '')
if (!value) continue
try {
origins.add(new URL(value).origin)
} catch {
/* skip invalid */
}
}
if (!isProd) {
origins.add('http://localhost:5173')
origins.add('http://localhost:8080')
}
return { rpID, origins: [...origins] }
}
export function oidcIssuerFromConfig(config: AppConfig): string {
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
}
+40
View File
@@ -0,0 +1,40 @@
import type { FastifyInstance, FastifyRequest } from 'fastify'
import {
appendAudit,
type AppendAuditInput,
} from '@authportal/db'
export function clientIp(request: FastifyRequest): string | null {
// request.ip учитывает X-Forwarded-For только от доверенных прокси (TRUST_PROXY).
return request.ip ?? null
}
export function safeAudit(
app: FastifyInstance,
input: AppendAuditInput,
): void {
try {
appendAudit(app.db, input)
} catch (err) {
app.log.warn({ err }, 'audit_log append failed')
}
}
export function actorFromRequest(request: FastifyRequest): Pick<
AppendAuditInput,
'actorUserId' | 'actorEmail' | 'actorName'
> {
const u = request.authUser
if (!u) {
return {
actorUserId: null,
actorEmail: null,
actorName: null,
}
}
return {
actorUserId: u.id,
actorEmail: u.email,
actorName: u.name,
}
}
+51
View File
@@ -0,0 +1,51 @@
/** Мини-кэш с TTL и вытеснением по LRU (Map сохраняет порядок вставки). */
export class TtlCache<K, V> {
private readonly map = new Map<K, { value: V; expires: number }>()
constructor(
private readonly opts: {
max: number
ttlMs: number
now?: () => number
},
) {}
private now(): number {
return (this.opts.now ?? Date.now)()
}
get(key: K): V | undefined {
const hit = this.map.get(key)
if (!hit) return undefined
if (hit.expires <= this.now()) {
this.map.delete(key)
return undefined
}
// освежаем позицию для LRU
this.map.delete(key)
this.map.set(key, hit)
return hit.value
}
set(key: K, value: V): void {
this.map.delete(key)
this.map.set(key, { value, expires: this.now() + this.opts.ttlMs })
while (this.map.size > this.opts.max) {
const oldest = this.map.keys().next()
if (oldest.done) break
this.map.delete(oldest.value)
}
}
delete(key: K): void {
this.map.delete(key)
}
clear(): void {
this.map.clear()
}
get size(): number {
return this.map.size
}
}
+35
View File
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from 'node:crypto'
import { verifyPassword } from './password.js'
const PREFIX = 'sha256:'
/**
* Секреты OIDC-клиентов — 32 случайных байта (высокая энтропия), поэтому
* медленный argon2 не нужен: SHA-256 + сравнение за постоянное время.
*/
export function hashClientSecret(secret: string): string {
return PREFIX + createHash('sha256').update(secret).digest('hex')
}
export type ClientSecretCheck = { ok: boolean; needsRehash: boolean }
export async function checkClientSecret(
stored: string,
secret: string,
): Promise<ClientSecretCheck> {
if (stored.startsWith(PREFIX)) {
const a = Buffer.from(stored)
const b = Buffer.from(hashClientSecret(secret))
return {
ok: a.length === b.length && timingSafeEqual(a, b),
needsRehash: false,
}
}
// Устаревший формат (argon2): проверяем и просим перехешировать при успехе.
try {
const ok = await verifyPassword(stored, secret)
return { ok, needsRehash: ok }
} catch {
return { ok: false, needsRehash: false }
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { FastifyInstance, FastifyReply } from 'fastify'
import { randomBytes } from 'node:crypto'
import {
createRefreshSession,
touchLastLogin,
type UserRow,
} from '@authportal/db'
import { issueAccessToken } from './issue-access-token.js'
import { safeAudit } from './audit.js'
import { targetAppFromReturnTo } from './target-app.js'
export const REFRESH_COOKIE = 'refresh_token'
export function completeLogin(
app: FastifyInstance,
reply: FastifyReply,
user: UserRow,
opts: {
method: 'password' | 'passkey'
returnTo?: string
ip: string | null
userAgent: string | null
},
) {
const body = issueAccessToken(app, user)
const refreshRaw = randomBytes(32).toString('hex')
const refreshExpires = new Date(
Date.now() + app.config.refreshTtlDays * 24 * 60 * 60 * 1000,
)
createRefreshSession(app.db, user.id, refreshRaw, refreshExpires, {
ip: opts.ip,
userAgent: opts.userAgent,
})
touchLastLogin(app.db, user.id, opts.ip)
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`,
)
const targetApp = targetAppFromReturnTo(opts.returnTo)
const isPasskey = opts.method === 'passkey'
safeAudit(app, {
action: isPasskey ? 'auth.passkey_login' : 'auth.login',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: isPasskey
? `Вход (passkey): ${user.email}`
: `Вход: ${user.email}`,
details: {
method: opts.method,
user_agent: opts.userAgent,
return_to: opts.returnTo ?? null,
target_app: targetApp,
},
ip: opts.ip,
})
return body
}
+27
View File
@@ -0,0 +1,27 @@
/** Значение cookie без построения RegExp на каждый запрос. */
export function parseCookie(
header: string | undefined,
name: string,
): string | null {
if (!header) return null
let from = 0
while (from < header.length) {
let end = header.indexOf(';', from)
if (end === -1) end = header.length
const eq = header.indexOf('=', from)
if (eq !== -1 && eq < end) {
const key = header.slice(from, eq).trim()
if (key === name) {
const raw = header.slice(eq + 1, end).trim()
if (!raw) return null
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}
}
from = end + 1
}
return null
}
+50
View File
@@ -0,0 +1,50 @@
import { isReturnToAllowed } from '@authportal/shared'
import type { AppConfig } from '../config.js'
import { oidcIssuerFromConfig } from '../config.js'
function originOf(url: string): string | null {
try {
return new URL(url).origin
} catch {
return null
}
}
/**
* Проверка Origin для CORS с credentials. Разрешены: CORS_ORIGINS, issuer,
* приложения из App Switcher и всё, что разрешает RETURN_TO_ALLOWLIST
* (та же isReturnToAllowed, что для return_to: ".suffix", localhost, полные origin, private). '*' в CORS_ORIGINS —
* разрешить всё (только осознанно).
*/
export function createOriginChecker(
config: AppConfig,
switcherUrls: () => string[],
): (origin: string) => boolean {
const explicit = new Set<string>()
let allowAll = false
for (const raw of config.corsOrigins.split(',')) {
const v = raw.trim()
if (!v) continue
if (v === '*') allowAll = true
else {
const o = originOf(v)
if (o) explicit.add(o)
}
}
for (const u of [config.issuer, oidcIssuerFromConfig(config)]) {
const o = originOf(u)
if (o) explicit.add(o)
}
return (origin: string) => {
if (allowAll) return true
const o = originOf(origin)
if (!o) return false
if (explicit.has(o)) return true
// Тот же allowlist, что у return_to (.suffix, localhost, полные origin, private).
if (isReturnToAllowed(o, config.returnToAllowlist)) return true
for (const u of switcherUrls()) {
if (originOf(u) === o) return true
}
return false
}
}
+55
View File
@@ -0,0 +1,55 @@
import type { FastifyInstance } from 'fastify'
import {
allPermissionKeys,
normalizePermissionKeys,
tenantsClaimForUser,
type LoginResponse,
} from '@authportal/shared'
import {
getAppSwitcherConfig,
getUserApps,
getUserPermissions,
type UserRow,
} from '@authportal/db'
import { toMe } from '../plugins/auth-guards.js'
import { getUserProfile } from './user-profile.js'
/** Mint access JWT + Me from current DB state (login / reissue). */
export function issueAccessToken(
app: FastifyInstance,
user: UserRow,
): LoginResponse {
// Профиль из кэша (инвалидируется записями в users/доступ); user — свежая строка вызывающего.
const profile = getUserProfile(app.db, user.id)
const apps = profile?.apps ?? getUserApps(app.db, user.id)
const permissions = user.isAdmin
? allPermissionKeys()
: (profile?.permissions ??
normalizePermissionKeys(getUserPermissions(app.db, user.id)))
const switcher = getAppSwitcherConfig(app.db)
const tenants = tenantsClaimForUser(switcher, apps)
const me = toMe(user, apps, permissions)
const expiresAt = new Date(
Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000,
)
const accessToken = app.jwt.sign(
{
sub: user.id,
email: user.email,
name: user.name,
apps,
permissions,
tenants,
bgp_tenant_id: tenants.bgp,
is_admin: user.isAdmin,
iss: app.config.issuer,
},
{ expiresIn: `${app.config.jwtTtlHours}h` },
)
return {
access_token: accessToken,
expires_at: expiresAt.toISOString(),
token_type: 'Bearer',
user: me,
}
}
+232
View File
@@ -0,0 +1,232 @@
import {
exportJWK,
exportPKCS8,
generateKeyPair,
importJWK,
importPKCS8,
jwtVerify,
SignJWT,
type JWK,
} from 'jose'
import { createHash, randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
import {
getActiveOidcSigningKey,
insertOidcSigningKey,
listOidcSigningKeys,
} from '@authportal/db'
import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
export type OidcKeyMaterial = {
kid: string
privateKey: CryptoKey
publicJwk: JWK
}
let cached: OidcKeyMaterial | null = null
// importJWK — асинхронный разбор JWK; без кэша он выполнялся на каждый /oauth/userinfo.
const importedPublicKeys = new Map<string, CryptoKey>()
function publicJwkFromPrivateExport(jwk: JWK, kid: string): JWK {
return {
kty: jwk.kty,
n: jwk.n,
e: jwk.e,
alg: 'RS256',
use: 'sig',
kid,
}
}
async function materialFromPem(
kid: string,
privatePem: string,
publicJwkJson?: string,
): Promise<OidcKeyMaterial> {
// jose defaults extractable=false for private keys → exportJWK throws
const privateKey = await importPKCS8(privatePem, 'RS256', {
extractable: true,
})
if (publicJwkJson) {
try {
const stored = JSON.parse(publicJwkJson) as JWK
return {
kid,
privateKey,
publicJwk: publicJwkFromPrivateExport({ ...stored, kid }, kid),
}
} catch {
// fall through to derive from private key
}
}
const full = await exportJWK(privateKey)
return {
kid,
privateKey,
publicJwk: publicJwkFromPrivateExport(full, kid),
}
}
/**
* Ensure an active RSA signing key exists (env PEM or DB / generate).
*/
export async function ensureOidcSigningKey(
app: FastifyInstance,
): Promise<OidcKeyMaterial> {
if (cached) return cached
const envPem = app.config.oidcRsaPrivateKey?.trim()
if (envPem) {
const kid = createHash('sha256').update(envPem).digest('hex').slice(0, 16)
cached = await materialFromPem(kid, envPem)
return cached
}
const existing = getActiveOidcSigningKey(app.db)
if (existing) {
cached = await materialFromPem(
existing.kid,
existing.privatePem,
existing.publicJwkJson,
)
return cached
}
const { privateKey, publicKey } = await generateKeyPair('RS256', {
extractable: true,
})
const kid = randomUUID().replace(/-/g, '').slice(0, 16)
const privatePem = await exportPKCS8(privateKey)
const publicJwk = await exportJWK(publicKey)
publicJwk.alg = 'RS256'
publicJwk.use = 'sig'
publicJwk.kid = kid
insertOidcSigningKey(app.db, {
kid,
privatePem,
publicJwkJson: JSON.stringify(publicJwk),
})
cached = { kid, privateKey, publicJwk }
app.log.info({ kid }, 'OIDC RS256 signing key generated')
return cached
}
/** Ключ подписи загружен — приложение готово выдавать токены. */
export function isOidcKeyReady(): boolean {
return cached !== null
}
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
jwksCache = new WeakMap()
}
export function buildOidcDiscovery(config: AppConfig) {
const issuer = oidcIssuerFromConfig(config)
return {
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
userinfo_endpoint: `${issuer}/oauth/userinfo`,
jwks_uri: `${issuer}/.well-known/jwks.json`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256'],
scopes_supported: ['openid', 'profile', 'email', 'groups'],
token_endpoint_auth_methods_supported: [
'client_secret_post',
'client_secret_basic',
],
claims_supported: [
'sub',
'iss',
'aud',
'exp',
'iat',
'email',
'email_verified',
'name',
'preferred_username',
'groups',
'roles',
],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256', 'plain'],
}
}
export async function buildJwks(
app: FastifyInstance,
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const hit = jwksCache.get(app.db)
if (hit && hit.kid === cached?.kid) return hit.jwks
const jwks = computeJwks(app)
jwksCache.set(app.db, { kid: cached?.kid ?? '', jwks })
return jwks
}
/** Мемоизация JWKS: набор ключей меняется только при выпуске нового (меняется kid). */
let jwksCache = new WeakMap<object, { kid: string; jwks: { keys: JWK[] } }>()
function computeJwks(app: FastifyInstance): { keys: JWK[] } {
const keys = listOidcSigningKeys(app.db)
.map((row): JWK | null => {
try {
const jwk = JSON.parse(row.publicJwkJson) as JWK
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
} catch {
return null
}
})
.filter((k): k is JWK => k !== null)
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
keys.unshift(cached.publicJwk)
}
return { keys }
}
/** Принудительный сброс JWKS (после ручных правок ключей в БД). */
export function invalidateJwks(app: FastifyInstance): void {
jwksCache.delete(app.db)
}
export async function signOidcJwt(
app: FastifyInstance,
claims: Record<string, unknown>,
expiresInSeconds: number,
): Promise<string> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
return new SignJWT(claims)
.setProtectedHeader({ alg: 'RS256', kid: key.kid, typ: 'JWT' })
.setIssuer(issuer)
.setIssuedAt()
.setExpirationTime(`${expiresInSeconds}s`)
.sign(key.privateKey)
}
/** Verify OIDC access token (RS256) and return payload. */
export async function verifyOidcAccessToken(
app: FastifyInstance,
token: string,
): Promise<Record<string, unknown>> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
let publicKey: CryptoKey | undefined = importedPublicKeys.get(key.kid)
if (!publicKey) {
// Для RS256 + RSA JWK importJWK всегда возвращает CryptoKey.
publicKey = (await importJWK(key.publicJwk, 'RS256')) as CryptoKey
importedPublicKeys.set(key.kid, publicKey)
}
const { payload } = await jwtVerify(token, publicKey, {
issuer,
algorithms: ['RS256'],
})
return payload as Record<string, unknown>
}
+58
View File
@@ -0,0 +1,58 @@
import { hash, verify } from '@node-rs/argon2'
/**
* argon2 держит ~19 МиБ на вызов и занимает поток libuv; без ограничителя
* шквал логинов раздувает RSS. Семафор ограничивает число одновременных хешей.
*/
let limit = 2
let active = 0
const waiters: Array<() => void> = []
export function setPasswordConcurrency(n: number): void {
limit = Math.max(1, Math.floor(n))
}
export function passwordHashStats(): { active: number; waiting: number } {
return { active, waiting: waiters.length }
}
async function withSlot<T>(fn: () => Promise<T>): Promise<T> {
if (active >= limit) {
await new Promise<void>((resolve) => waiters.push(resolve))
} else {
active += 1
}
try {
return await fn()
} finally {
const next = waiters.shift()
if (next) next() // слот передаётся ожидающему без изменения active
else active -= 1
}
}
export function hashPassword(password: string): Promise<string> {
return withSlot(() => hash(password))
}
export function verifyPassword(
passwordHash: string,
password: string,
): Promise<boolean> {
return withSlot(() => verify(passwordHash, password))
}
let dummyHash: Promise<string> | null = null
/**
* Для неизвестного/отключённого пользователя выполняем настоящую проверку
* по фиктивному хешу: время ответа не выдаёт существование учётки.
*/
export async function verifyDummyPassword(password: string): Promise<void> {
dummyHash ??= hash('dummy-password-for-timing')
try {
await withSlot(async () => verify(await dummyHash!, password))
} catch {
/* результат не важен */
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { FastifyInstance } from 'fastify'
export interface ShutdownOptions {
timeoutMs: number
exit?: (code: number) => void
signals?: NodeJS.Signals[]
proc?: Pick<NodeJS.Process, 'on' | 'off'>
}
/**
* SIGTERM/SIGINT → app.close() (дозавершает запросы, onClose закрывает БД),
* принудительный выход по таймауту, повторный сигнал — немедленный выход.
* Возвращает функцию отписки и ручной триггер (для тестов).
*/
export function registerShutdown(app: FastifyInstance, opts: ShutdownOptions) {
const exit = opts.exit ?? ((code: number) => process.exit(code))
const proc = opts.proc ?? process
const signals = opts.signals ?? ['SIGTERM', 'SIGINT']
let closing: Promise<void> | null = null
const shutdown = (reason: string, code = 0): Promise<void> => {
if (closing) {
app.log.warn(`shutdown: repeated ${reason}, forcing exit`)
exit(1)
return closing
}
app.log.info(`shutdown: ${reason}`)
const timer = setTimeout(() => {
app.log.error('shutdown: timeout, forcing exit')
exit(1)
}, opts.timeoutMs)
timer.unref()
closing = app
.close()
.then(() => {
clearTimeout(timer)
exit(code)
})
.catch((err: unknown) => {
clearTimeout(timer)
app.log.error(err, 'shutdown: close failed')
exit(1)
})
return closing
}
const handlers = signals.map((sig) => {
const h = () => void shutdown(sig)
proc.on(sig, h)
return [sig, h] as const
})
const onFatal = (err: unknown) => {
app.log.error(err, 'fatal error')
void shutdown('fatal', 1)
}
proc.on('unhandledRejection', onFatal)
proc.on('uncaughtException', onFatal)
return {
shutdown,
dispose() {
for (const [sig, h] of handlers) proc.off(sig, h)
proc.off('unhandledRejection', onFatal)
proc.off('uncaughtException', onFatal)
},
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { AuditSourceApp } from '@authportal/shared'
/** Resolve SSO target app from return_to URL host/path (and OIDC authorize unwrap). */
export function targetAppFromReturnTo(
returnTo: string | undefined | null,
): AuditSourceApp {
if (!returnTo) return 'portal'
let u: URL
try {
u = new URL(returnTo)
} catch {
return 'portal'
}
const host = u.hostname.toLowerCase()
const path = u.pathname.toLowerCase()
const search = u.search
// Portal OIDC authorize as return_to → map via client's redirect_uri
if (path === '/oauth/authorize' || path.endsWith('/oauth/authorize')) {
try {
const redirectUri = new URLSearchParams(search).get('redirect_uri')
if (redirectUri) return targetAppFromReturnTo(redirectUri)
} catch {
/* ignore */
}
}
const hay = `${host} ${path}`
if (/\bvps\b/.test(hay) || host.includes('vps')) return 'vps'
if (
/\bcfdm\b/.test(hay) ||
host.includes('cfdm') ||
host.includes('domain')
) {
return 'cfdm'
}
if (/\bbgp\b/.test(hay) || host.includes('bgp')) return 'bgp'
if (
/\bfw\b/.test(hay) ||
host.includes('firewall') ||
host.includes('evofw')
) {
return 'fw'
}
if (
/\bdns\b/.test(hay) ||
host.includes('technitium') ||
path.includes('/sso/')
) {
return 'dns'
}
if (
/\bcdn\b/.test(hay) ||
host.includes('cdnmanager') ||
host.includes('cdn-manager')
) {
return 'cdn'
}
if (
/\bmm\b/.test(hay) ||
host.includes('mikrotik') ||
host.includes('mmapp')
) {
return 'mm'
}
return 'portal'
}
export function clientUserAgent(
headers: Record<string, string | string[] | undefined>,
): string | null {
const ua = headers['user-agent']
if (typeof ua === 'string' && ua.trim()) return ua.trim().slice(0, 512)
return null
}
+70
View File
@@ -0,0 +1,70 @@
import {
getUserApps,
getUserById,
getUserDataGeneration,
getUserPermissions,
type AppDb,
type UserRow,
} from '@authportal/db'
import { allPermissionKeys, normalizePermissionKeys } from '@authportal/shared'
import { TtlCache } from './cache.js'
export type UserProfile = {
row: UserRow
/** Сырые app_id из БД. */
apps: string[]
/** Нормализованные права; у админа — полный каталог. */
permissions: string[]
}
type Entry = { profile: UserProfile; generation: number }
/** Страховочный TTL: основная инвалидация — по счётчику поколения из @authportal/db. */
const PROFILE_TTL_MS = 10_000
const PROFILE_MAX = 500
const caches = new WeakMap<AppDb, TtlCache<string, Entry>>()
function cacheFor(db: AppDb): TtlCache<string, Entry> {
let c = caches.get(db)
if (!c) {
c = new TtlCache({ max: PROFILE_MAX, ttlMs: PROFILE_TTL_MS })
caches.set(db, c)
}
return c
}
export function loadUserProfile(db: AppDb, id: string): UserProfile | undefined {
const row = getUserById(db, id)
if (!row) return undefined
return profileFromRow(db, row)
}
function profileFromRow(db: AppDb, row: UserRow): UserProfile {
const apps = getUserApps(db, row.id)
const permissions = row.isAdmin
? allPermissionKeys()
: normalizePermissionKeys(getUserPermissions(db, row.id))
return { row, apps, permissions }
}
/**
* Профиль пользователя (строка + приложения + права) с кэшем: 3 SELECT на
* запрос → 0 пока данные пользователей не менялись.
*/
export function getUserProfile(
db: AppDb,
id: string,
): UserProfile | undefined {
const cache = cacheFor(db)
const generation = getUserDataGeneration()
const hit = cache.get(id)
if (hit && hit.generation === generation) return hit.profile
const profile = loadUserProfile(db, id)
if (!profile) {
cache.delete(id)
return undefined
}
cache.set(id, { profile, generation })
return profile
}
+35
View File
@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify'
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'
import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'
export function webauthnRelyingParty(app: FastifyInstance): {
rpID: string
rpName: string
origins: string[]
} {
return {
rpID: app.config.webauthnRpID,
rpName: app.config.webauthnRpName,
origins: app.config.webauthnOrigins,
}
}
export function userIdToBytes(userId: string): Uint8Array<ArrayBuffer> {
// Копия через конструктор даёт Uint8Array<ArrayBuffer>, которого требует SimpleWebAuthn v13.
return new Uint8Array(isoUint8Array.fromUTF8String(userId))
}
export function encodePublicKey(publicKey: Uint8Array<ArrayBuffer>): string {
return isoBase64URL.fromBuffer(publicKey)
}
export function decodePublicKey(stored: string): Uint8Array<ArrayBuffer> {
return new Uint8Array(isoBase64URL.toBuffer(stored))
}
export function asTransports(
values: string[],
): AuthenticatorTransportFuture[] | undefined {
if (values.length === 0) return undefined
return values as AuthenticatorTransportFuture[]
}
+112
View File
@@ -0,0 +1,112 @@
import type { FastifyReply, FastifyRequest } from 'fastify'
import type { UserRow } from '@authportal/db'
import type { AppId, MeResponse } from '@authportal/shared'
import { APP_IDS } from '@authportal/shared'
import { getUserProfile, type UserProfile } from '../lib/user-profile.js'
export type AuthUser = {
id: string
email: string
name: string
isAdmin: boolean
apps: AppId[]
permissions: string[]
}
declare module '@fastify/jwt' {
interface FastifyJWT {
payload: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
tenants?: Record<string, string>
bgp_tenant_id?: string
is_admin?: boolean
iss: string
}
user: {
sub: string
email: string
name: string
apps: string[]
permissions: string[]
tenants?: Record<string, string>
bgp_tenant_id?: string
is_admin?: boolean
iss: string
}
}
}
declare module 'fastify' {
interface FastifyRequest {
authUser?: AuthUser
}
}
function asAppIds(apps: string[]): AppId[] {
return apps.filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
}
export function toMe(user: UserRow, apps: string[], permissions: string[]): MeResponse {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
apps: asAppIds(apps),
permissions,
}
}
export function authUserFromProfile(profile: UserProfile): AuthUser {
const { row } = profile
return {
id: row.id,
email: row.email,
name: row.name,
isAdmin: row.isAdmin,
apps: asAppIds(profile.apps),
permissions: profile.permissions,
}
}
export async function requireAuth(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
try {
await request.jwtVerify()
} catch {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
})
}
const sub = request.user.sub
const profile = getUserProfile(request.server.db, sub)
if (!profile || profile.row.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
request.authUser = authUserFromProfile(profile)
}
export async function requireAdmin(
request: FastifyRequest,
reply: FastifyReply,
): Promise<void> {
await requireAuth(request, reply)
if (reply.sent) return
if (!request.authUser?.isAdmin) {
return reply.status(403).send({
error: { code: 'FORBIDDEN', message: 'Только для администраторов' },
})
}
}
+560
View File
@@ -0,0 +1,560 @@
import type { FastifyInstance } from 'fastify'
import { hashPassword } from '../lib/password.js'
import { hashClientSecret } from '../lib/client-secret.js'
import {
countWebauthnCredentials,
countWebauthnCredentialsBatch,
createOidcClient,
createUser,
deleteOidcClient,
deleteUser,
deleteWebauthnCredentialsForUser,
generateOidcClientSecret,
getAppSwitcherConfig,
getOidcClientById,
getUserApps,
getUserByEmail,
getUserById,
getUserPermissions,
listActiveSessions,
listOidcClients,
listUserAppsBatch,
listUserPermissionsBatch,
listUsers,
parseJsonStringArray,
revokeAllSessionsForUser,
revokeSessionById,
setAppSwitcherConfig,
setUserAccess,
updateOidcClient,
updateUser,
withTransaction,
} from '@authportal/db'
import {
APP_IDS,
OIDC_SCOPES,
allPermissionKeys,
appSwitcherConfigSchema,
createOidcClientRequestSchema,
createUserRequestSchema,
normalizePermissionKeys,
patchOidcClientRequestSchema,
patchUserRequestSchema,
putUserAccessRequestSchema,
type AdminUser,
type AppId,
type OidcClientPublic,
} from '@authportal/shared'
import { requireAdmin } from '../plugins/auth-guards.js'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { oidcIssuerFromConfig } from '../config.js'
const allowedPermissions = new Set(allPermissionKeys())
function toAdminUser(
user: NonNullable<ReturnType<typeof getUserById>>,
apps: string[],
permissions: string[],
passkeyCount: number,
): AdminUser {
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps: apps.filter((a): a is AppId => (APP_IDS as readonly string[]).includes(a)),
permissions: normalizePermissionKeys(permissions),
last_login_at: user.lastLoginAt ?? null,
last_login_ip: user.lastLoginIp ?? null,
passkey_count: passkeyCount,
created_at: user.createdAt,
updated_at: user.updatedAt,
}
}
function mapUser(
db: FastifyInstance['db'],
user: NonNullable<ReturnType<typeof getUserById>>,
): AdminUser {
return toAdminUser(
user,
getUserApps(db, user.id),
getUserPermissions(db, user.id),
countWebauthnCredentials(db, user.id),
)
}
function validateAccess(apps: string[], permissions: string[]): string | null {
for (const app of apps) {
if (!(APP_IDS as readonly string[]).includes(app)) {
return `Неизвестное приложение: ${app}`
}
}
for (const p of permissions) {
if (!allowedPermissions.has(p)) {
return `Неизвестное право: ${p}`
}
}
return null
}
function mapOidcClient(
row: NonNullable<ReturnType<typeof getOidcClientById>>,
): OidcClientPublic {
return {
id: row.id,
client_id: row.clientId,
name: row.name,
redirect_uris: parseJsonStringArray(row.redirectUrisJson),
scopes: parseJsonStringArray(row.scopesJson),
enabled: row.enabled,
created_at: row.createdAt,
updated_at: row.updatedAt,
}
}
export async function adminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/users', async () => {
// Батч-запросы вместо 3 SELECT'ов на пользователя (N+1 при N пользователях).
const usersRows = listUsers(app.db)
const appsByUser = listUserAppsBatch(app.db)
const permsByUser = listUserPermissionsBatch(app.db)
const passkeysByUser = countWebauthnCredentialsBatch(app.db)
return usersRows.map((u) =>
toAdminUser(
u,
appsByUser.get(u.id) ?? [],
permsByUser.get(u.id) ?? [],
passkeysByUser.get(u.id) ?? 0,
),
)
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
return mapUser(app.db, user)
},
)
app.post('/api/v1/admin/users', async (request, reply) => {
const parsed = createUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const data = parsed.data
const permissions = normalizePermissionKeys(data.permissions)
if (getUserByEmail(app.db, data.email)) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
const accessError = validateAccess(data.apps, permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
const passwordHash = await hashPassword(data.password)
const user = withTransaction(app.db, () => {
const created = createUser(app.db, {
email: data.email,
name: data.name,
passwordHash,
isAdmin: data.is_admin,
})
setUserAccess(app.db, created.id, data.apps, permissions)
return created
})
const mapped = mapUser(app.db, getUserById(app.db, user.id)!)
safeAudit(app, {
action: 'user.create',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Создан пользователь ${mapped.email}`,
details: {
is_admin: mapped.is_admin,
apps: mapped.apps,
},
ip: clientIp(request),
})
return reply.status(201).send(mapped)
})
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
const parsed = patchUserRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
if (parsed.data.email && parsed.data.email !== existing.email) {
const clash = getUserByEmail(app.db, parsed.data.email)
if (clash) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Email уже занят' },
})
}
}
const passwordHash = parsed.data.password
? await hashPassword(parsed.data.password)
: undefined
const updated = updateUser(app.db, request.params.id, {
email: parsed.data.email,
name: parsed.data.name,
passwordHash,
isAdmin: parsed.data.is_admin,
disabled: parsed.data.disabled,
})
const mapped = mapUser(app.db, updated!)
safeAudit(app, {
action: 'user.update',
severity: parsed.data.disabled ? 'warning' : 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Изменён пользователь ${mapped.email}`,
details: {
...parsed.data,
password: parsed.data.password ? '[changed]' : undefined,
},
ip: clientIp(request),
})
return mapped
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/passkeys',
async (request, reply) => {
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const removed = deleteWebauthnCredentialsForUser(app.db, existing.id)
safeAudit(app, {
action: 'admin.passkey_reset',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Сброшены passkeys: ${existing.email} (${removed})`,
details: { removed },
ip: clientIp(request),
})
return { ok: true, removed, user: mapUser(app.db, existing) }
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/users/:id',
async (request, reply) => {
if (request.authUser?.id === request.params.id) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Нельзя удалить себя',
},
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const ok = deleteUser(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
safeAudit(app, {
action: 'user.delete',
severity: 'critical',
...actorFromRequest(request),
targetType: 'user',
targetId: existing.id,
summary: `Удалён пользователь ${existing.email}`,
details: { email: existing.email, name: existing.name },
ip: clientIp(request),
})
return reply.status(204).send()
},
)
app.put<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/access',
async (request, reply) => {
const parsed = putUserAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const existing = getUserById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const permissions = normalizePermissionKeys(parsed.data.permissions)
const accessError = validateAccess(parsed.data.apps, permissions)
if (accessError) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: accessError },
})
}
setUserAccess(
app.db,
request.params.id,
parsed.data.apps,
permissions,
)
const mapped = mapUser(app.db, getUserById(app.db, request.params.id)!)
safeAudit(app, {
action: 'user.access_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'user',
targetId: mapped.id,
summary: `Обновлены права: ${mapped.email}`,
details: {
apps: mapped.apps,
permissions_count: mapped.permissions.length,
},
ip: clientIp(request),
})
return mapped
},
)
app.get('/api/v1/admin/app-switcher', async () =>
getAppSwitcherConfig(app.db),
)
app.put('/api/v1/admin/app-switcher', async (request, reply) => {
const parsed = appSwitcherConfigSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const result = setAppSwitcherConfig(app.db, parsed.data)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'app_switcher',
summary: 'Обновлены ссылки App Switcher',
details: {
menuLabel: result.menuLabel,
apps: result.apps.map((a) => a.id),
},
ip: clientIp(request),
})
return result
})
app.get('/api/v1/admin/sessions', async (request) => {
const userId = (request.query as { user_id?: string }).user_id
return listActiveSessions(app.db, { userId }).map((s) => ({
id: s.id,
user_id: s.userId,
email: s.email,
name: s.name,
ip: s.ip,
user_agent: s.userAgent,
created_at: s.createdAt,
expires_at: s.expiresAt,
}))
})
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/sessions/:id',
async (request, reply) => {
const ok = revokeSessionById(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Сессия не найдена' },
})
}
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'session',
targetId: request.params.id,
summary: `Админ отозвал сессию ${request.params.id.slice(0, 8)}`,
details: { source: 'admin_revoke' },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/users/:id/sessions/revoke-all',
async (request, reply) => {
const user = getUserById(app.db, request.params.id)
if (!user) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Пользователь не найден' },
})
}
const revoked = revokeAllSessionsForUser(app.db, user.id)
safeAudit(app, {
action: 'auth.logout',
severity: 'warning',
...actorFromRequest(request),
targetType: 'user',
targetId: user.id,
summary: `Отозваны все сессии: ${user.email} (${revoked})`,
details: { revoked, source: 'admin_revoke_all' },
ip: clientIp(request),
})
return { revoked }
},
)
app.get('/api/v1/admin/oidc/meta', async () => {
const issuer = oidcIssuerFromConfig(app.config)
return {
issuer,
discovery_url: `${issuer}/.well-known/openid-configuration`,
jwks_url: `${issuer}/.well-known/jwks.json`,
scopes: [...OIDC_SCOPES],
}
})
app.get('/api/v1/admin/oidc/clients', async () =>
listOidcClients(app.db).map(mapOidcClient),
)
app.post('/api/v1/admin/oidc/clients', async (request, reply) => {
const parsed = createOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const row = createOidcClient(app.db, {
name: parsed.data.name,
clientSecretHash: secretHash,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
const mapped = mapOidcClient(row)
safeAudit(app, {
action: 'app_switcher.update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: row.id,
summary: `OIDC client создан: ${row.name}`,
details: { client_id: row.clientId },
ip: clientIp(request),
})
return { ...mapped, client_secret: secret }
})
app.get<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const row = getOidcClientById(app.db, request.params.id)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const parsed = patchOidcClientRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const row = updateOidcClient(app.db, request.params.id, {
name: parsed.data.name,
redirectUris: parsed.data.redirect_uris,
scopes: parsed.data.scopes,
enabled: parsed.data.enabled,
})
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return mapOidcClient(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id',
async (request, reply) => {
const ok = deleteOidcClient(app.db, request.params.id)
if (!ok) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
return { ok: true }
},
)
app.post<{ Params: { id: string } }>(
'/api/v1/admin/oidc/clients/:id/rotate-secret',
async (request, reply) => {
const existing = getOidcClientById(app.db, request.params.id)
if (!existing) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Клиент не найден' },
})
}
const secret = generateOidcClientSecret()
const secretHash = hashClientSecret(secret)
const row = updateOidcClient(app.db, request.params.id, {
clientSecretHash: secretHash,
})!
return { ...mapOidcClient(row), client_secret: secret }
},
)
}
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
listAudit,
purgeAuditOlderThan,
setAuditRetentionDays,
} from '@authportal/db'
import {
auditListQuerySchema,
putAuditSettingsSchema,
} from '@authportal/shared'
import { actorFromRequest, clientIp, safeAudit } from '../lib/audit.js'
import { requireAdmin } from '../plugins/auth-guards.js'
export async function auditAdminRoutes(app: FastifyInstance): Promise<void> {
app.addHook('onRequest', async (request, reply) => {
if (!request.url.startsWith('/api/v1/admin/audit')) return
await requireAdmin(request, reply)
})
app.get('/api/v1/admin/audit', async (request, reply) => {
const parsed = auditListQuerySchema.safeParse(request.query)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные параметры' },
})
}
const q = parsed.data
return listAudit(app.db, {
action: q.action,
severity: q.severity,
userId: q.user_id,
actorEmail: q.actor_email,
sourceApp: q.source_app,
kind: q.kind,
limit: q.limit,
})
})
app.get('/api/v1/admin/audit/settings', async () => ({
retention_days: getAuditRetentionDays(app.db),
}))
app.put('/api/v1/admin/audit/settings', async (request, reply) => {
const parsed = putAuditSettingsSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'retention_days: от 7 до 3650',
},
})
}
const prev = getAuditRetentionDays(app.db)
const days = setAuditRetentionDays(app.db, parsed.data.retention_days)
safeAudit(app, {
action: 'audit.settings_update',
severity: 'info',
...actorFromRequest(request),
targetType: 'settings',
targetId: 'audit_retention',
summary: `Срок хранения журнала: ${prev} → ${days} дн.`,
details: { previous: prev, retention_days: days },
ip: clientIp(request),
})
return { retention_days: days }
})
app.post('/api/v1/admin/audit/purge', async (request) => {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
safeAudit(app, {
action: 'audit.purge',
severity: 'warning',
...actorFromRequest(request),
targetType: 'system',
summary: `Ручная очистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'manual' },
ip: clientIp(request),
})
return { deleted, retention_days: days }
})
}
+260
View File
@@ -0,0 +1,260 @@
import type { FastifyInstance } from 'fastify'
import {
PERMISSION_CATALOG,
appsMetaFromSwitcher,
loginRequestSchema,
publicAppSwitcherConfig,
ssoAccessRequestSchema,
} from '@authportal/shared'
import {
getAppSwitcherConfig,
getUserByEmail,
getUserById,
searchDirectoryUsers,
revokeRefreshSession,
} from '@authportal/db'
import { verifyDummyPassword, verifyPassword } from '../lib/password.js'
import { parseCookie } from '../lib/cookies.js'
import { requireAuth } from '../plugins/auth-guards.js'
import { issueAccessToken } from '../lib/issue-access-token.js'
import { completeLogin, REFRESH_COOKIE } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=60')
return {
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
webauthn: true,
}
})
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const parsed = loginRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { email, password, return_to: returnTo } = parsed.data
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const targetApp = targetAppFromReturnTo(returnTo)
const user = getUserByEmail(app.db, email)
if (!user || user.disabled) {
await verifyDummyPassword(password)
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorEmail: email.toLowerCase(),
targetType: 'session',
summary: `Неудачный вход: ${email}`,
details: {
reason: !user ? 'unknown_user' : 'disabled',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
const ok = await verifyPassword(user.passwordHash, password)
if (!ok) {
safeAudit(app, {
action: 'auth.login_failed',
severity: 'warning',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `Неудачный вход: ${user.email}`,
details: {
reason: 'bad_password',
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetApp,
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный email или пароль' },
})
}
return completeLogin(app, reply, user, {
method: 'password',
returnTo,
ip,
userAgent,
})
},
})
/**
* Re-mint access JWT from DB (apps/permissions/is_admin/tenants).
* Fixes stale localStorage JWT after role changes or account switch.
*/
app.post(
'/api/v1/auth/reissue',
{
onRequest: requireAuth,
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (request, reply) => {
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
return issueAccessToken(app, user)
},
)
/** Record SSO handoff to a connected app (already authenticated). */
app.post(
'/api/v1/auth/sso-access',
{
onRequest: requireAuth,
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
},
async (request, reply) => {
const parsed = ssoAccessRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const returnTo = parsed.data.return_to
const targetApp = targetAppFromReturnTo(returnTo)
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
safeAudit(app, {
action: 'auth.sso_handoff',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `SSO: ${user.email} → ${targetApp}`,
details: {
return_to: returnTo,
target_app: targetApp,
user_agent: userAgent,
},
ip,
})
return { ok: true, target_app: targetApp }
},
)
app.post(
'/api/v1/auth/logout',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (request, reply) => {
const refresh = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (refresh) {
revokeRefreshSession(app.db, refresh)
}
reply.header(
'Set-Cookie',
`${REFRESH_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`,
)
// Best-effort actor from JWT if present
let actorUserId: string | null = null
let actorEmail: string | null = null
let actorName: string | null = null
try {
await request.jwtVerify()
const sub = request.user.sub
const row = getUserById(app.db, sub)
if (row) {
actorUserId = row.id
actorEmail = row.email
actorName = row.name
}
} catch {
/* anonymous logout */
}
safeAudit(app, {
action: 'auth.logout',
severity: 'info',
actorUserId,
actorEmail,
actorName,
targetType: 'session',
targetId: actorUserId,
summary: actorEmail ? `Выход: ${actorEmail}` : 'Выход',
details: { user_agent: clientUserAgent(request.headers) },
ip: clientIp(request),
})
return { ok: true }
},
)
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
// Без Cache-Control: после сохранения в админке SPA должна сразу видеть новый конфиг.
app.get('/api/v1/app-switcher', async () =>
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
)
app.get(
'/api/v1/auth/me',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return {
id: auth.id,
email: auth.email,
name: auth.name,
is_admin: auth.isAdmin,
apps: auth.apps,
permissions: auth.permissions,
}
},
)
app.get(
'/api/v1/directory/users',
{ onRequest: requireAuth },
async (request) => {
const q = String(
(request.query as { q?: string }).q ?? '',
)
.trim()
.toLowerCase()
return searchDirectoryUsers(app.db, q, 50)
},
)
app.get('/api/v1/catalog', { onRequest: requireAuth }, async () => {
const switcher = getAppSwitcherConfig(app.db)
return {
apps: appsMetaFromSwitcher(switcher),
permissions: PERMISSION_CATALOG,
}
})
}
+73
View File
@@ -0,0 +1,73 @@
import type { FastifyInstance } from 'fastify'
import { appendAuditBatch } from '@authportal/db'
import {
ingestAuditRequestSchema,
type AuditSourceApp,
} from '@authportal/shared'
import { timingSafeEqual } from 'node:crypto'
function secretsEqual(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
export async function auditIngestRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/ingest/audit',
{
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
},
async (request, reply) => {
const secret = app.config.auditIngestSecret
if (!secret) {
return reply.status(503).send({
error: {
code: 'UNAVAILABLE',
message: 'Audit ingest не настроен',
},
})
}
const auth = request.headers.authorization ?? ''
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''
if (!token || !secretsEqual(token, secret)) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Неверный ingest secret' },
})
}
const parsed = ingestAuditRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const { accepted, duplicates, failed } = appendAuditBatch(
app.db,
parsed.data.events.map((event) => ({
eventId: event.event_id,
sourceApp: event.source_app as AuditSourceApp,
action: event.action,
severity: event.severity,
actorUserId: event.actor_user_id,
actorEmail: event.actor_email,
actorName: event.actor_name,
targetType: event.target_type,
targetId: event.target_id,
summary: event.summary,
details: event.details,
ip: event.ip,
createdAt: event.created_at,
})),
)
if (failed > 0) {
app.log.warn({ failed }, 'ingest append failed for some events')
}
return { accepted, duplicates }
},
)
}
+466
View File
@@ -0,0 +1,466 @@
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
import {
updateOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
getOidcClientByClientId,
getUserById,
getUserByRefreshToken,
getUserApps,
getUserPermissions,
parseJsonStringArray,
type OidcClientRow,
type UserRow,
} from '@authportal/db'
import {
oidcGroupsForUser,
normalizePermissionKeys,
} from '@authportal/shared'
import { oidcIssuerFromConfig } from '../config.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { checkClientSecret, hashClientSecret } from '../lib/client-secret.js'
import { parseCookie } from '../lib/cookies.js'
import {
buildJwks,
buildOidcDiscovery,
ensureOidcSigningKey,
signOidcJwt,
verifyOidcAccessToken,
} from '../lib/oidc/keys.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
const REFRESH_COOKIE = 'refresh_token'
const CODE_TTL_MS = 5 * 60 * 1000
const ACCESS_TTL_SEC = 3600
const ID_TOKEN_TTL_SEC = 3600
function parseBasicAuth(
header: string | undefined,
): { clientId: string; clientSecret: string } | null {
if (!header?.startsWith('Basic ')) return null
try {
const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8')
const idx = decoded.indexOf(':')
if (idx < 0) return null
return {
clientId: decoded.slice(0, idx),
clientSecret: decoded.slice(idx + 1),
}
} catch {
return null
}
}
async function authenticateClient(
app: FastifyInstance,
client: OidcClientRow,
secret: string,
): Promise<boolean> {
const res = await checkClientSecret(client.clientSecretHash, secret)
if (res.ok && res.needsRehash) {
// Миграция argon2 → SHA-256 при первом успешном обмене.
try {
updateOidcClient(app.db, client.id, {
clientSecretHash: hashClientSecret(secret),
})
} catch (err) {
app.log.warn({ err }, 'client secret rehash failed')
}
}
return res.ok
}
function redirectError(
reply: FastifyReply,
redirectUri: string | undefined,
error: string,
description: string,
state?: string | null,
) {
if (!redirectUri) {
return reply.status(400).send({
error,
error_description: description,
})
}
const url = new URL(redirectUri)
url.searchParams.set('error', error)
url.searchParams.set('error_description', description)
if (state) url.searchParams.set('state', state)
return reply.redirect(url.toString())
}
function resolveSessionUser(
app: FastifyInstance,
request: FastifyRequest,
): UserRow | undefined {
const raw = parseCookie(request.headers.cookie, REFRESH_COOKIE)
if (!raw) return undefined
return getUserByRefreshToken(app.db, raw)
}
function buildUserInfoClaims(
app: FastifyInstance,
user: UserRow,
scope: string,
) {
const scopes = new Set(scope.split(/\s+/).filter(Boolean))
const permissions = normalizePermissionKeys(
getUserPermissions(app.db, user.id),
)
const groups = oidcGroupsForUser({
isAdmin: user.isAdmin,
permissions,
})
const claims: Record<string, unknown> = {
sub: user.id,
}
if (scopes.has('email') || scopes.has('profile') || scopes.has('openid')) {
claims.email = user.email
claims.email_verified = true
}
if (scopes.has('profile') || scopes.has('openid')) {
claims.name = user.name
claims.preferred_username = user.email
}
if (scopes.has('groups')) {
claims.groups = groups
claims.roles = groups
}
return claims
}
function safeEqualStr(a: string, b: string): boolean {
const ba = Buffer.from(a)
const bb = Buffer.from(b)
if (ba.length !== bb.length) return false
return timingSafeEqual(ba, bb)
}
function verifyPkce(
codeVerifier: string | undefined,
challenge: string | null,
method: string | null,
): boolean {
if (!challenge) return true
if (!codeVerifier) return false
if (method === 'plain' || !method) {
return safeEqualStr(codeVerifier, challenge)
}
if (method === 'S256') {
const hash = createHash('sha256')
.update(codeVerifier)
.digest('base64url')
return hash === challenge
}
return false
}
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await ensureOidcSigningKey(app)
app.get('/.well-known/openid-configuration', async (_request, reply) => {
// Статичный документ — RP не должен перечитывать его на каждый запрос.
reply.header('Cache-Control', 'public, max-age=300')
return buildOidcDiscovery(app.config)
})
app.get('/.well-known/jwks.json', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=300')
return buildJwks(app)
})
app.get('/oauth/authorize', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const q = request.query as Record<string, string | undefined>
const clientId = q.client_id
const redirectUri = q.redirect_uri
const responseType = q.response_type
const scope = q.scope ?? 'openid'
const state = q.state
const nonce = q.nonce
const codeChallenge = q.code_challenge
const codeChallengeMethod = q.code_challenge_method
if (!clientId || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'client_id and redirect_uri are required',
})
}
if (responseType !== 'code') {
return redirectError(
reply,
redirectUri,
'unsupported_response_type',
'Only response_type=code is supported',
state,
)
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(400).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const allowedRedirects = parseJsonStringArray(client.redirectUrisJson)
if (!allowedRedirects.includes(redirectUri)) {
return reply.status(400).send({
error: 'invalid_request',
error_description: 'redirect_uri is not registered',
})
}
const requestedScopes = scope.split(/\s+/).filter(Boolean)
const clientScopes = new Set(parseJsonStringArray(client.scopesJson))
if (!requestedScopes.includes('openid')) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
'openid scope is required',
state,
)
}
for (const s of requestedScopes) {
if (!clientScopes.has(s)) {
return redirectError(
reply,
redirectUri,
'invalid_scope',
`Scope not allowed: ${s}`,
state,
)
}
}
const user = resolveSessionUser(app, request)
if (!user) {
const issuer = oidcIssuerFromConfig(app.config)
const authorizeUrl = new URL(`${issuer}/oauth/authorize`)
for (const [k, v] of Object.entries(q)) {
if (v != null) authorizeUrl.searchParams.set(k, v)
}
const login = new URL(`${issuer}/`)
login.searchParams.set('return_to', authorizeUrl.toString())
return reply.redirect(login.toString())
}
const apps = getUserApps(app.db, user.id)
// Soft check: prefer dns app assignment for Technitium-like clients
if (!user.isAdmin && apps.length === 0) {
return redirectError(
reply,
redirectUri,
'access_denied',
'User has no application access',
state,
)
}
const rawCode = randomBytes(32).toString('base64url')
createOidcAuthCode(app.db, {
rawCode,
clientId: client.clientId,
userId: user.id,
redirectUri,
scope: requestedScopes.join(' '),
nonce: nonce ?? null,
codeChallenge: codeChallenge ?? null,
codeChallengeMethod: codeChallengeMethod ?? null,
expiresAt: new Date(Date.now() + CODE_TTL_MS),
})
const targetApp = targetAppFromReturnTo(redirectUri)
safeAudit(app, {
action: 'auth.sso_handoff',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'session',
targetId: user.id,
summary: `SSO OIDC: ${user.email} → ${targetApp}`,
details: {
return_to: redirectUri,
target_app: targetApp,
user_agent: clientUserAgent(request.headers),
oidc_client_id: client.clientId,
oidc_client_name: client.name,
auth_mode: 'oidc',
},
ip: clientIp(request),
})
const dest = new URL(redirectUri)
dest.searchParams.set('code', rawCode)
if (state) dest.searchParams.set('state', state)
return reply.redirect(dest.toString())
})
app.post('/oauth/token', {
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
handler: async (request, reply) => {
const raw = request.body
const body: Record<string, string | undefined> =
typeof raw === 'string'
? Object.fromEntries(new URLSearchParams(raw))
: raw && typeof raw === 'object'
? (raw as Record<string, string | undefined>)
: {}
const basic = parseBasicAuth(request.headers.authorization)
const clientId = body.client_id ?? basic?.clientId
const clientSecret = body.client_secret ?? basic?.clientSecret
const grantType = body.grant_type
const code = body.code
const redirectUri = body.redirect_uri
const codeVerifier = body.code_verifier
if (grantType !== 'authorization_code') {
return reply.status(400).send({
error: 'unsupported_grant_type',
error_description: 'Only authorization_code is supported',
})
}
if (!clientId || !clientSecret || !code || !redirectUri) {
return reply.status(400).send({
error: 'invalid_request',
error_description:
'client_id, client_secret, code, and redirect_uri are required',
})
}
const client = getOidcClientByClientId(app.db, clientId)
if (!client || !client.enabled) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Unknown or disabled client',
})
}
const secretOk = await authenticateClient(app, client, clientSecret)
if (!secretOk) {
return reply.status(401).send({
error: 'invalid_client',
error_description: 'Invalid client credentials',
})
}
const authCode = consumeOidcAuthCode(app.db, code)
if (!authCode) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Invalid or expired authorization code',
})
}
if (authCode.clientId !== client.clientId) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'Code was not issued to this client',
})
}
if (authCode.redirectUri !== redirectUri) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'redirect_uri mismatch',
})
}
if (
!verifyPkce(
codeVerifier,
authCode.codeChallenge,
authCode.codeChallengeMethod,
)
) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'PKCE verification failed',
})
}
const user = getUserById(app.db, authCode.userId)
if (!user || user.disabled) {
return reply.status(400).send({
error: 'invalid_grant',
error_description: 'User unavailable',
})
}
const info = buildUserInfoClaims(app, user, authCode.scope)
const idClaims: Record<string, unknown> = {
...info,
aud: client.clientId,
}
if (authCode.nonce) idClaims.nonce = authCode.nonce
const accessClaims: Record<string, unknown> = {
sub: user.id,
aud: client.clientId,
scope: authCode.scope,
token_use: 'access',
}
if (info.groups) {
accessClaims.groups = info.groups
accessClaims.roles = info.roles
}
const [idToken, accessToken] = await Promise.all([
signOidcJwt(app, idClaims, ID_TOKEN_TTL_SEC),
signOidcJwt(app, accessClaims, ACCESS_TTL_SEC),
])
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: ACCESS_TTL_SEC,
id_token: idToken,
scope: authCode.scope,
}
},
})
app.get('/oauth/userinfo', {
config: { rateLimit: { max: 120, timeWindow: '1 minute' } },
}, async (request, reply) => {
const auth = request.headers.authorization
if (!auth?.startsWith('Bearer ')) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Bearer token required',
})
}
const token = auth.slice(7)
let payload: Record<string, unknown>
try {
payload = await verifyOidcAccessToken(app, token)
} catch {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Token invalid or expired',
})
}
const sub = typeof payload.sub === 'string' ? payload.sub : null
if (!sub) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'Missing sub',
})
}
const user = getUserById(app.db, sub)
if (!user || user.disabled) {
return reply.status(401).send({
error: 'invalid_token',
error_description: 'User unavailable',
})
}
const scope =
typeof payload.scope === 'string'
? payload.scope
: 'openid profile email groups'
return buildUserInfoClaims(app, user, scope)
})
}
+388
View File
@@ -0,0 +1,388 @@
import type { FastifyInstance } from 'fastify'
import {
generateAuthenticationOptions,
generateRegistrationOptions,
verifyAuthenticationResponse,
verifyRegistrationResponse,
type AuthenticationResponseJSON,
type RegistrationResponseJSON,
} from '@simplewebauthn/server'
import {
consumeWebauthnChallenge,
createWebauthnChallenge,
createWebauthnCredential,
deleteWebauthnCredential,
getUserById,
getWebauthnCredentialByCredentialId,
getWebauthnCredentialById,
listWebauthnCredentials,
parseTransportsJson,
updateWebauthnCredentialName,
touchWebauthnCredential,
type WebauthnCredentialRow,
} from '@authportal/db'
import {
patchPasskeyRequestSchema,
webauthnVerifyRequestSchema,
type PasskeyCredential,
} from '@authportal/shared'
import { requireAuth } from '../plugins/auth-guards.js'
import { completeLogin } from '../lib/complete-login.js'
import { clientIp, safeAudit } from '../lib/audit.js'
import { clientUserAgent, targetAppFromReturnTo } from '../lib/target-app.js'
import {
asTransports,
decodePublicKey,
encodePublicKey,
userIdToBytes,
webauthnRelyingParty,
} from '../lib/webauthn.js'
const LOGIN_RATE = { max: 20, timeWindow: '1 minute' } as const
function toPasskeyDto(row: WebauthnCredentialRow): PasskeyCredential {
return {
id: row.id,
name: row.name,
created_at: row.createdAt,
last_used_at: row.lastUsedAt ?? null,
device_type: row.deviceType ?? null,
}
}
function defaultPasskeyName(userAgent: string | null): string {
const date = new Date().toLocaleDateString('ru-RU')
if (!userAgent) return `Passkey ${date}`
if (/iPhone|iPad|Macintosh/i.test(userAgent)) return `Apple ${date}`
if (/Windows/i.test(userAgent)) return `Windows Hello ${date}`
if (/Android/i.test(userAgent)) return `Android ${date}`
return `Passkey ${date}`
}
function asRegistrationResponse(
raw: unknown,
): RegistrationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as RegistrationResponseJSON
}
function asAuthenticationResponse(
raw: unknown,
): AuthenticationResponseJSON | null {
if (!raw || typeof raw !== 'object') return null
if (!('id' in raw) || typeof (raw as { id: unknown }).id !== 'string') {
return null
}
return raw as AuthenticationResponseJSON
}
export async function webauthnRoutes(app: FastifyInstance): Promise<void> {
app.post(
'/api/v1/webauthn/register/options',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const rp = webauthnRelyingParty(app)
const existing = listWebauthnCredentials(app.db, user.id)
const options = await generateRegistrationOptions({
rpName: rp.rpName,
rpID: rp.rpID,
userName: user.email,
userDisplayName: user.name,
userID: userIdToBytes(user.id),
attestationType: 'none',
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
excludeCredentials: existing.map((cred) => ({
id: cred.credentialId,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
})),
})
const row = createWebauthnChallenge(app.db, {
purpose: 'register',
challenge: options.challenge,
userId: user.id,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/register',
{
onRequest: requireAuth,
config: { rateLimit: LOGIN_RATE },
},
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const user = getUserById(app.db, auth.id)
if (!user || user.disabled) {
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' },
})
}
const response = asRegistrationResponse(parsed.data.response)
if (!response) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректный ответ passkey' },
})
}
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'register',
user.id,
)
if (!challenge) {
return reply.status(400).send({
error: {
code: 'VALIDATION_ERROR',
message: 'Срок действия challenge истёк, повторите регистрацию',
},
})
}
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyRegistrationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
})
} catch (err) {
app.log.warn({ err }, 'webauthn register verify failed')
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Не удалось проверить passkey' },
})
}
if (!verification.verified || !verification.registrationInfo) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Passkey не подтверждён' },
})
}
const info = verification.registrationInfo
const duplicate = getWebauthnCredentialByCredentialId(
app.db,
info.credential.id,
)
if (duplicate) {
return reply.status(409).send({
error: { code: 'CONFLICT', message: 'Этот passkey уже зарегистрирован' },
})
}
const name =
parsed.data.name?.trim() ||
defaultPasskeyName(clientUserAgent(request.headers))
const row = createWebauthnCredential(app.db, {
userId: user.id,
credentialId: info.credential.id,
publicKey: encodePublicKey(info.credential.publicKey),
counter: info.credential.counter,
deviceType: info.credentialDeviceType,
backedUp: info.credentialBackedUp,
transports: info.credential.transports,
name,
})
safeAudit(app, {
action: 'auth.passkey_register',
severity: 'info',
actorUserId: user.id,
actorEmail: user.email,
actorName: user.name,
targetType: 'credential',
targetId: row.id,
summary: `Passkey добавлен: ${user.email}`,
details: { name: row.name, device_type: row.deviceType },
ip: clientIp(request),
})
return toPasskeyDto(row)
},
)
app.get(
'/api/v1/webauthn/credentials',
{ onRequest: requireAuth },
async (request) => {
const auth = request.authUser!
return listWebauthnCredentials(app.db, auth.id).map(toPasskeyDto)
},
)
app.patch<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const parsed = patchPasskeyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const auth = request.authUser!
const row = updateWebauthnCredentialName(
app.db,
request.params.id,
auth.id,
parsed.data.name.trim(),
)
if (!row) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
return toPasskeyDto(row)
},
)
app.delete<{ Params: { id: string } }>(
'/api/v1/webauthn/credentials/:id',
{ onRequest: requireAuth },
async (request, reply) => {
const auth = request.authUser!
const existing = getWebauthnCredentialById(app.db, request.params.id)
if (!existing || existing.userId !== auth.id) {
return reply.status(404).send({
error: { code: 'NOT_FOUND', message: 'Passkey не найден' },
})
}
deleteWebauthnCredential(app.db, request.params.id, auth.id)
const user = getUserById(app.db, auth.id)
safeAudit(app, {
action: 'auth.passkey_delete',
severity: 'warning',
actorUserId: auth.id,
actorEmail: auth.email,
actorName: auth.name,
targetType: 'credential',
targetId: request.params.id,
summary: `Passkey удалён: ${user?.email ?? auth.email}`,
details: { name: existing.name },
ip: clientIp(request),
})
return { ok: true }
},
)
app.post(
'/api/v1/webauthn/login/options',
{ config: { rateLimit: LOGIN_RATE } },
async () => {
const rp = webauthnRelyingParty(app)
const options = await generateAuthenticationOptions({
rpID: rp.rpID,
userVerification: 'preferred',
})
const row = createWebauthnChallenge(app.db, {
purpose: 'authenticate',
challenge: options.challenge,
})
return { challenge_id: row.id, options }
},
)
app.post(
'/api/v1/webauthn/login',
{ config: { rateLimit: LOGIN_RATE } },
async (request, reply) => {
const parsed = webauthnVerifyRequestSchema.safeParse(request.body)
if (!parsed.success) {
return reply.status(400).send({
error: { code: 'VALIDATION_ERROR', message: 'Некорректные данные' },
})
}
const ip = clientIp(request)
const userAgent = clientUserAgent(request.headers)
const returnTo = parsed.data.return_to
const fail = (reason: string) => {
safeAudit(app, {
action: 'auth.passkey_login_failed',
severity: 'warning',
targetType: 'session',
summary: 'Неудачный вход по passkey',
details: {
reason,
user_agent: userAgent,
return_to: returnTo ?? null,
target_app: targetAppFromReturnTo(returnTo),
},
ip,
})
return reply.status(401).send({
error: { code: 'UNAUTHORIZED', message: 'Не удалось войти с passkey' },
})
}
const response = asAuthenticationResponse(parsed.data.response)
if (!response) return fail('bad_response')
const challenge = consumeWebauthnChallenge(
app.db,
parsed.data.challenge_id,
'authenticate',
)
if (!challenge) return fail('expired_challenge')
const cred = getWebauthnCredentialByCredentialId(app.db, response.id)
if (!cred) return fail('unknown_credential')
const user = getUserById(app.db, cred.userId)
if (!user || user.disabled) return fail(user ? 'disabled' : 'unknown_user')
const rp = webauthnRelyingParty(app)
let verification
try {
verification = await verifyAuthenticationResponse({
response,
expectedChallenge: challenge.challenge,
expectedOrigin: rp.origins,
expectedRPID: rp.rpID,
requireUserVerification: true,
credential: {
id: cred.credentialId,
publicKey: decodePublicKey(cred.publicKey),
counter: cred.counter,
transports: asTransports(parseTransportsJson(cred.transportsJson)),
},
})
} catch (err) {
app.log.warn({ err }, 'webauthn login verify failed')
return fail('verify_error')
}
if (!verification.verified) return fail('not_verified')
touchWebauthnCredential(
app.db,
cred.id,
verification.authenticationInfo.newCounter,
)
return completeLogin(app, reply, user, {
method: 'passkey',
returnTo,
ip,
userAgent,
})
},
)
}
+61
View File
@@ -0,0 +1,61 @@
import { readFileSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'
import { buildApp } from './app.js'
import { loadConfig } from './config.js'
import { registerShutdown } from './lib/shutdown.js'
for (const path of [
resolve(import.meta.dirname, '../../../.env'),
'.env',
'../.env',
]) {
if (!existsSync(path)) continue
const content = readFileSync(path, 'utf-8')
for (const line of content.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eq = trimmed.indexOf('=')
if (eq === -1) continue
const key = trimmed.slice(0, eq).trim()
let value = trimmed.slice(eq + 1).trim()
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1)
}
if (!(key in process.env)) process.env[key] = value
}
break
}
const config = loadConfig()
// `node dist/server.js backup` — разовый онлайн-бэкап без запуска сервера
// (для cron / `docker exec auth-portal node dist/server.js backup`).
if (process.argv[2] === 'backup') {
const { createDb, closeDb } = await import('@authportal/db')
const { runBackup } = await import('./services/backup.js')
const { sqlite } = createDb(config.databaseUrl)
try {
const res = await runBackup(sqlite, config)
console.log(`backup ok: ${res.file} (removed ${res.removed.length} old)`)
closeDb(sqlite)
process.exit(0)
} catch (err) {
console.error('backup failed:', err)
process.exit(1)
}
}
const app = await buildApp({ config })
registerShutdown(app, { timeoutMs: config.shutdownTimeoutMs })
try {
await app.listen({ port: config.serverPort, host: '0.0.0.0' })
app.log.info(`auth-portal listening on ${config.serverPort}`)
} catch (err) {
app.log.error(err)
process.exit(1)
}
+41
View File
@@ -0,0 +1,41 @@
import type { FastifyInstance } from 'fastify'
import {
getAuditRetentionDays,
purgeAuditOlderThan,
} from '@authportal/db'
import { safeAudit } from '../lib/audit.js'
const HOUR_MS = 60 * 60 * 1000
export function startAuditRetentionJob(app: FastifyInstance): () => void {
const run = () => {
try {
const days = getAuditRetentionDays(app.db)
const deleted = purgeAuditOlderThan(app.db, days)
if (deleted > 0) {
safeAudit(app, {
action: 'audit.purge',
severity: 'info',
targetType: 'system',
summary: `Автоочистка: удалено ${deleted} записей старше ${days} дн.`,
details: { deleted, retention_days: days, source: 'scheduler' },
})
app.log.info(
{ deleted, retention_days: days },
'audit_log retention purge',
)
}
} catch (err) {
app.log.warn({ err }, 'audit_log retention job failed')
}
}
// Defer first run slightly so bootstrap finishes
const initial = setTimeout(run, 15_000)
const interval = setInterval(run, HOUR_MS)
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
+43
View File
@@ -0,0 +1,43 @@
import { dirname, join, resolve } from 'node:path'
import type { FastifyInstance } from 'fastify'
import { backupDatabase, resolveDatabasePath, type Sqlite } from '@authportal/db'
import type { AppConfig } from '../config.js'
export function backupDirFor(config: AppConfig, databaseUrl = config.databaseUrl) {
if (config.backupDir) return resolve(config.backupDir)
const path = resolveDatabasePath(databaseUrl)
return join(dirname(resolve(path)), 'backups')
}
export async function runBackup(
sqlite: Sqlite,
config: AppConfig,
databaseUrl = config.databaseUrl,
) {
return backupDatabase(sqlite, {
dir: backupDirFor(config, databaseUrl),
keep: config.backupKeep,
})
}
/** Периодический бэкап; выключен при BACKUP_INTERVAL_HOURS=0. */
export function startBackupJob(app: FastifyInstance): () => void {
const hours = app.config.backupIntervalHours
if (hours <= 0 || app.sqlite.memory) return () => {}
let running = false
const run = async () => {
if (running) return
running = true
try {
const res = await runBackup(app.sqlite, app.config)
app.log.info({ file: res.file, removed: res.removed.length }, 'db backup')
} catch (err) {
app.log.error({ err }, 'db backup failed')
} finally {
running = false
}
}
const interval = setInterval(() => void run(), hours * 60 * 60 * 1000)
interval.unref()
return () => clearInterval(interval)
}
+28
View File
@@ -0,0 +1,28 @@
import type { FastifyInstance } from 'fastify'
import { runHousekeeping } from '@authportal/db'
const HOUR_MS = 60 * 60 * 1000
/** Часовая очистка сессий, кодов авторизации и WebAuthn-челленджей. */
export function startHousekeepingJob(app: FastifyInstance): () => void {
const run = () => {
try {
const res = runHousekeeping(app.db, {
retentionDays: app.config.sessionRetentionDays,
})
if (res.sessions + res.authCodes + res.challenges > 0) {
app.log.info(res, 'housekeeping purge')
}
} catch (err) {
app.log.warn({ err }, 'housekeeping job failed')
}
}
const initial = setTimeout(run, 30_000)
const interval = setInterval(run, HOUR_MS)
initial.unref()
interval.unref()
return () => {
clearTimeout(initial)
clearInterval(interval)
}
}
@@ -0,0 +1,82 @@
import { createHmac } from 'node:crypto'
import https from 'node:https'
import type { FastifyInstance } from 'fastify'
import { completePortalRevocation, enablePortalRevocations, nextPortalRevocation } from '@authportal/db'
export function portalRevocationTarget(raw: string): URL {
if (raw.length > 512) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
let url: URL
try { url = new URL(raw) } catch { throw new Error('PORTAL_REVOCATION_TARGET_INVALID') }
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash
|| url.pathname !== '/_internal/mcp/portal/revocations' || url.href !== raw) throw new Error('PORTAL_REVOCATION_TARGET_INVALID')
return url
}
/** One fixed operator-selected HTTPS destination. Core https never follows redirects. */
export function sendPortalRevocation(target: URL, secret: string, body: string, signal: AbortSignal): Promise<boolean> {
if (Buffer.byteLength(body) > 4096) return Promise.resolve(false)
const timestamp = String(Math.floor(Date.now() / 1000))
const signature = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex')
return new Promise(resolve => {
let settled = false
let outcome = false
const finish = (accepted: boolean) => {
if (!settled) { settled = true; outcome = accepted; clearTimeout(deadline); request.destroy() }
}
const request = https.request(target, { method: 'POST', agent: false, rejectUnauthorized: true, signal, headers: {
'content-type': 'application/json', 'content-length': Buffer.byteLength(body),
'x-mm-timestamp': timestamp, 'x-mm-signature': signature,
} }, response => {
const chunks: Buffer[] = []; let size = 0
response.on('data', (chunk: Buffer) => {
size += chunk.length
if (size > 4096) { response.destroy(); request.destroy(); finish(false) }
else chunks.push(chunk)
})
response.once('error', () => finish(false))
response.once('aborted', () => finish(false))
response.once('end', () => {
let accepted = false
try {
const value: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
accepted = response.statusCode === 200 && !!value && typeof value === 'object' && !Array.isArray(value)
&& Object.keys(value).sort().join(',') === 'duplicate,ok' && (value as {ok?: unknown}).ok === true
&& typeof (value as {duplicate?: unknown}).duplicate === 'boolean'
} catch { /* No raw transport/response/error material is logged. */ }
finish(accepted)
})
})
const deadline = setTimeout(() => { request.destroy(); finish(false) }, 3000)
request.once('error', () => finish(false))
request.once('close', () => { clearTimeout(deadline); resolve(outcome) })
request.end(body)
})
}
export function configurePortalRevocationSender(app: FastifyInstance): void {
if (!app.config.portalRevocationsEnabled) return
const target = portalRevocationTarget(app.config.portalRevocationsTarget ?? '')
const secret = app.config.portalRevocationsSecret
if (!secret || secret.length < 32 || Buffer.byteLength(secret) > 1024) throw new Error('PORTAL_REVOCATION_SECRET_REQUIRED')
enablePortalRevocations(app.db, target.href)
let stopped = false, running: Promise<void> | undefined
const abort = new AbortController()
const run = async () => {
// Bounded work per tick, single in-flight request; rows survive all failures/restarts.
for (let i = 0; i < 4 && !stopped; i++) {
const event = nextPortalRevocation(app.db)
if (!event) return
const delivered = await sendPortalRevocation(target, secret, event.body, abort.signal)
completePortalRevocation(app.db, event, delivered)
if (!delivered) return
}
}
const tick = () => {
if (stopped || running) return
running = run().catch(() => { app.log.error({event: 'portal.revocation.delivery-unavailable'}, 'Revocation delivery unavailable') })
.finally(() => { running = undefined })
}
let interval: ReturnType<typeof setInterval> | undefined
app.addHook('onReady', async () => { interval = setInterval(tick, 1000); interval.unref(); tick() })
// preClose executes before onClose closes SQLite. Abort then drain exact in-flight work.
app.addHook('preClose', async () => { stopped = true; clearInterval(interval); abort.abort(); await running })
}
+431
View File
@@ -0,0 +1,431 @@
import { createHash } from 'node:crypto'
import { afterEach, describe, expect, it } from 'vitest'
import { hash } from '@node-rs/argon2'
import { createOidcClient, generateOidcClientSecret } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
type App = Awaited<ReturnType<typeof buildTestApp>>
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
const auth = (token: string) => ({ authorization: `Bearer ${token}` })
async function login(a: App, email = 'admin@test.local', password = 'adminpass') {
const res = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password },
})
return res
}
describe('auth', () => {
it('login: неверный пароль 401, отключённый пользователь 401', async () => {
app = await buildTestApp()
const token = await adminToken(app)
expect((await login(app, 'admin@test.local', 'wrong')).statusCode).toBe(401)
expect((await login(app, 'ghost@test.local', 'whatever')).statusCode).toBe(401)
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: 'off@test.local',
name: 'Off',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const id = (created.json() as { id: string }).id
await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { disabled: true },
})
expect((await login(app, 'off@test.local', 'secret12')).statusCode).toBe(401)
})
it('me / reissue / catalog / logout по cookie', async () => {
app = await buildTestApp()
const res = await login(app)
const token = (res.json() as { access_token: string }).access_token
const refresh = res.cookies.find((c) => c.name === 'refresh_token')!.value
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
expect(me.statusCode).toBe(200)
expect((me.json() as { email: string }).email).toBe('admin@test.local')
const reissue = await app.inject({
method: 'POST',
url: '/api/v1/auth/reissue',
headers: auth(token),
})
expect(reissue.statusCode).toBe(200)
expect((reissue.json() as { access_token: string }).access_token).toBeTruthy()
const catalog = await app.inject({ method: 'GET', url: '/api/v1/catalog', headers: auth(token) })
expect(catalog.statusCode).toBe(200)
expect((catalog.json() as { apps: unknown[] }).apps.length).toBeGreaterThan(0)
const noAuth = await app.inject({ method: 'GET', url: '/api/v1/auth/me' })
expect(noAuth.statusCode).toBe(401)
const logout = await app.inject({
method: 'POST',
url: '/api/v1/auth/logout',
headers: { ...auth(token), cookie: `refresh_token=${refresh}` },
})
expect(logout.statusCode).toBe(200)
const revoked = app.sqlite
.prepare('SELECT revoked_at FROM refresh_sessions')
.get() as { revoked_at: string | null }
expect(revoked.revoked_at).toBeTruthy()
})
})
describe('admin users', () => {
it('создание: конфликт email 409, валидация 400; patch и удаление', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const payload = {
email: 'dup@test.local',
name: 'Dup',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
}
const first = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(first.statusCode).toBe(201)
const id = (first.json() as { id: string }).id
const dup = await app.inject({ method: 'POST', url: '/api/v1/admin/users', headers: auth(token), payload })
expect(dup.statusCode).toBe(409)
const bad = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: { email: 'nope' },
})
expect(bad.statusCode).toBe(400)
const patched = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: auth(token),
payload: { name: 'Renamed', password: 'newsecret12' },
})
expect(patched.statusCode).toBe(200)
expect((patched.json() as { name: string }).name).toBe('Renamed')
expect((await login(app, 'dup@test.local', 'newsecret12')).statusCode).toBe(200)
expect((await login(app, 'dup@test.local', 'secret12')).statusCode).toBe(401)
const del = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(del.statusCode).toBe(204)
const gone = await app.inject({ method: 'GET', url: `/api/v1/admin/users/${id}`, headers: auth(token) })
expect(gone.statusCode).toBe(404)
})
it('нельзя удалить себя; обычный пользователь не админ (403)', async () => {
app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({ method: 'GET', url: '/api/v1/auth/me', headers: auth(token) })
const myId = (me.json() as { id: string }).id
const self = await app.inject({ method: 'DELETE', url: `/api/v1/admin/users/${myId}`, headers: auth(token) })
expect(self.statusCode).toBe(400)
await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: auth(token),
payload: {
email: 'plain@test.local',
name: 'Plain',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
const userTok = ((await login(app, 'plain@test.local', 'secret12')).json() as { access_token: string }).access_token
const forbidden = await app.inject({ method: 'GET', url: '/api/v1/admin/users', headers: auth(userTok) })
expect(forbidden.statusCode).toBe(403)
})
})
describe('OIDC клиенты и обмен кода', () => {
async function setup(a: App) {
const token = await adminToken(a)
const created = await a.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: auth(token),
payload: {
name: 'RP',
redirect_uris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as { id: string; client_id: string; client_secret: string }
const loginRes = await login(a)
const refresh = loginRes.cookies.find((c) => c.name === 'refresh_token')!.value
return { token, client, refresh }
}
async function authorizeCode(
a: App,
refresh: string,
clientId: string,
extra: Record<string, string> = {},
): Promise<string> {
const res = await a.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: clientId,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid profile',
...extra,
},
})
expect(res.statusCode).toBe(302)
return new URL(res.headers.location as string).searchParams.get('code')!
}
const exchange = (a: App, body: Record<string, string>) =>
a.inject({
method: 'POST',
url: '/oauth/token',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
payload: new URLSearchParams({
grant_type: 'authorization_code',
redirect_uri: 'https://rp.test.local/cb',
...body,
}).toString(),
})
it('секрет нового клиента хранится как sha256, обмен работает, replay кода отклоняется', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const stored = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients')
.get() as { h: string }
expect(stored.h.startsWith('sha256:')).toBe(true)
const code = await authorizeCode(app, refresh, client.client_id)
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(ok.statusCode).toBe(200)
expect((ok.json() as { id_token: string }).id_token).toBeTruthy()
const replay = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
expect(replay.statusCode).toBe(400)
expect((replay.json() as { error: string }).error).toBe('invalid_grant')
})
it('PKCE: S256 принимается, неверный verifier и отсутствие verifier отклоняются', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const verifier = 'v'.repeat(50)
const challenge = createHash('sha256').update(verifier).digest('base64url')
const pkce = { code_challenge: challenge, code_challenge_method: 'S256' }
const creds = { client_id: client.client_id, client_secret: client.client_secret }
const good = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: verifier,
})
expect(good.statusCode).toBe(200)
const wrong = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
code_verifier: 'x'.repeat(50),
})
expect(wrong.statusCode).toBe(400)
const missing = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, pkce),
})
expect(missing.statusCode).toBe(400)
const plain = await exchange(app, {
...creds,
code: await authorizeCode(app, refresh, client.client_id, {
code_challenge: 'plain-challenge',
code_challenge_method: 'plain',
}),
code_verifier: 'plain-challenge',
})
expect(plain.statusCode).toBe(200)
})
it('неверный секрет 401; чужой redirect_uri и scope отклоняются; userinfo по access_token', async () => {
app = await buildTestApp()
const { client, refresh } = await setup(app)
const code = await authorizeCode(app, refresh, client.client_id)
const badSecret = await exchange(app, {
client_id: client.client_id,
client_secret: 'nope',
code,
})
expect(badSecret.statusCode).toBe(401)
const badRedirect = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://evil.test/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(badRedirect.statusCode).toBe(400)
const badScope = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh },
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid admin',
},
})
expect(badScope.statusCode).toBe(302)
expect(badScope.headers.location).toContain('error=invalid_scope')
const ok = await exchange(app, {
client_id: client.client_id,
client_secret: client.client_secret,
code,
})
const accessToken = (ok.json() as { access_token: string }).access_token
const info = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: auth(accessToken),
})
expect(info.statusCode).toBe(200)
expect((info.json() as { email: string }).email).toBe('admin@test.local')
const noToken = await app.inject({ method: 'GET', url: '/oauth/userinfo' })
expect(noToken.statusCode).toBe(401)
})
it('без сессии authorize редиректит на логин', async () => {
app = await buildTestApp()
const { client } = await setup(app)
const res = await app.inject({
method: 'GET',
url: '/oauth/authorize',
query: {
client_id: client.client_id,
redirect_uri: 'https://rp.test.local/cb',
response_type: 'code',
scope: 'openid',
},
})
expect(res.statusCode).toBe(302)
expect(res.headers.location).toContain('return_to=')
})
it('старый argon2-секрет работает и мигрирует в sha256', async () => {
app = await buildTestApp()
const { refresh } = await setup(app)
const secret = generateOidcClientSecret()
const legacy = createOidcClient(app.db, {
name: 'Legacy',
redirectUris: ['https://rp.test.local/cb'],
scopes: ['openid', 'profile'],
enabled: true,
clientSecretHash: await hash(secret),
})
const code = await authorizeCode(app, refresh, legacy.clientId)
const res = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code,
})
expect(res.statusCode).toBe(200)
const row = app.sqlite
.prepare('SELECT client_secret_hash h FROM oidc_clients WHERE client_id=?')
.get(legacy.clientId) as { h: string }
expect(row.h.startsWith('sha256:')).toBe(true)
// после миграции секрет всё ещё работает
const code2 = await authorizeCode(app, refresh, legacy.clientId)
const again = await exchange(app, {
client_id: legacy.clientId,
client_secret: secret,
code: code2,
})
expect(again.statusCode).toBe(200)
})
it('CRUD клиента: patch, rotate-secret инвалидирует старый секрет, delete', async () => {
app = await buildTestApp()
const { token, client, refresh } = await setup(app)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
payload: { name: 'Renamed RP' },
})
expect(patch.statusCode).toBe(200)
expect((patch.json() as { name: string }).name).toBe('Renamed RP')
const rotate = await app.inject({
method: 'POST',
url: `/api/v1/admin/oidc/clients/${client.id}/rotate-secret`,
headers: auth(token),
})
expect(rotate.statusCode).toBe(200)
const fresh = (rotate.json() as { client_secret: string }).client_secret
expect(fresh).not.toBe(client.client_secret)
const code = await authorizeCode(app, refresh, client.client_id)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: client.client_secret, code })).statusCode,
).toBe(401)
expect(
(await exchange(app, { client_id: client.client_id, client_secret: fresh, code })).statusCode,
).toBe(200)
const del = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(del.statusCode).toBe(200)
const gone = await app.inject({
method: 'GET',
url: `/api/v1/admin/oidc/clients/${client.id}`,
headers: auth(token),
})
expect(gone.statusCode).toBe(404)
})
})
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest'
import { buildTestApp } from './helpers.js'
describe('app-switcher API', () => {
it('GET /api/v1/app-switcher is public and returns defaults', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(res.statusCode).toBe(200)
const body = res.json() as { menuLabel: string; apps: { id: string }[] }
expect(body.menuLabel).toBeTruthy()
expect(body.apps.map((a) => a.id).sort()).toEqual([
'bgp',
'cdn',
'cfdm',
'dns',
'fw',
'mm',
'vps',
])
expect(body.apps.find((a) => a.id === 'cdn')).toMatchObject({
authMode: 'jwt',
})
expect(body.apps.find((a) => a.id === 'mm')).toMatchObject({
authMode: 'jwt',
})
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
authMode: 'oidc',
})
await app.close()
})
it('PUT /api/v1/admin/app-switcher requires admin and persists', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'PUT',
url: '/api/v1/admin/app-switcher',
payload: { menuLabel: 'Apps', apps: [] },
})
expect(denied.statusCode).toBe(401)
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const getBefore = await app.inject({
method: 'GET',
url: '/api/v1/app-switcher',
})
const before = getBefore.json() as {
menuLabel: string
apps: {
id: string
name: string
url: string
icon: string
enabled: boolean
}[]
}
const updated = {
menuLabel: 'Сервисы',
apps: before.apps.map((a) =>
a.id === 'cfdm'
? { ...a, url: 'https://cfdm.example.test', name: 'CFDM Test' }
: a,
),
}
const put = await app.inject({
method: 'PUT',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${token}` },
payload: updated,
})
expect(put.statusCode).toBe(200)
expect(put.json()).toMatchObject({ menuLabel: 'Сервисы' })
const getAfter = await app.inject({
method: 'GET',
url: '/api/v1/app-switcher',
})
const after = getAfter.json() as typeof before
expect(after.menuLabel).toBe('Сервисы')
expect(after.apps.find((a) => a.id === 'cfdm')?.url).toBe(
'https://cfdm.example.test',
)
await app.close()
})
})
+302
View File
@@ -0,0 +1,302 @@
import { describe, expect, it } from 'vitest'
import { appendAudit, listAudit, purgeAuditOlderThan } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
describe('audit log API', () => {
it('ingests external events with secret and dedupes by event_id', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
payload: { events: [] },
})
expect(denied.statusCode).toBe(401)
const eventId = 'evt-test-1'
const ok = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
actor_email: 'ops@test.local',
},
],
},
})
expect(ok.statusCode).toBe(200)
expect(ok.json()).toMatchObject({ accepted: 1, duplicates: 0 })
const dup = await app.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: {
events: [
{
event_id: eventId,
source_app: 'vps',
action: 'vps.vps.create',
summary: 'Создан VPS',
},
],
},
})
expect(dup.json()).toMatchObject({ accepted: 0, duplicates: 1 })
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?source_app=vps',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { source_app: string; action: string }[]
expect(entries.some((e) => e.action === 'vps.vps.create')).toBe(true)
await app.close()
})
it('records login and lists for admin', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string }[]
expect(entries.some((e) => e.action === 'auth.login')).toBe(true)
const denied = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit',
})
expect(denied.statusCode).toBe(401)
await app.close()
})
it('updates retention and purges old rows', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
appendAudit(app.db, {
action: 'user.create',
summary: 'old event',
})
// Backdate the last inserted row
app.sqlite
.prepare(
`UPDATE audit_log SET created_at = ? WHERE summary = 'old event'`,
)
.run(new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString())
const settings = await app.inject({
method: 'PUT',
url: '/api/v1/admin/audit/settings',
headers: { authorization: `Bearer ${token}` },
payload: { retention_days: 30 },
})
expect(settings.statusCode).toBe(200)
expect(settings.json()).toEqual({ retention_days: 30 })
const before = listAudit(app.db, { limit: 500 })
expect(before.some((e) => e.summary === 'old event')).toBe(true)
const deleted = purgeAuditOlderThan(app.db, 30)
expect(deleted).toBeGreaterThanOrEqual(1)
expect(listAudit(app.db, { limit: 500 }).some((e) => e.summary === 'old event')).toBe(
false,
)
const purge = await app.inject({
method: 'POST',
url: '/api/v1/admin/audit/purge',
headers: { authorization: `Bearer ${token}` },
})
expect(purge.statusCode).toBe(200)
expect((purge.json() as { retention_days: number }).retention_days).toBe(30)
await app.close()
})
it('records user.create from admin mutation', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const create = await app.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
payload: {
email: 'new@test.local',
name: 'New User',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?action=user.create',
headers: { authorization: `Bearer ${token}` },
})
expect(list.statusCode).toBe(200)
const entries = list.json() as { action: string; summary: string }[]
expect(entries.length).toBeGreaterThanOrEqual(1)
expect(entries[0]?.summary).toContain('new@test.local')
await app.close()
})
it('records login with IP, UA and last_login_ip', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: {
'x-forwarded-for': '203.0.113.10',
'user-agent': 'VitestBrowser/1.0',
},
payload: {
email: 'admin@test.local',
password: 'adminpass',
return_to: 'https://vps.example.test/dashboard',
},
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; last_login_ip: string | null }[]
).find((u) => u.email === 'admin@test.local')
expect(admin?.last_login_ip).toBe('203.0.113.10')
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
ip: string | null
details: Record<string, unknown> | null
}[]
const loginEvt = entries.find((e) => e.action === 'auth.login')
expect(loginEvt?.ip).toBe('203.0.113.10')
expect(loginEvt?.details?.user_agent).toBe('VitestBrowser/1.0')
expect(loginEvt?.details?.target_app).toBe('vps')
await app.close()
})
it('records failed login with email and IP', async () => {
const app = await buildTestApp()
const fail = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: 'nobody@test.local', password: 'wrong' },
})
expect(fail.statusCode).toBe(401)
const token = await adminToken(app)
const list = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins&actor_email=nobody@test.local',
headers: { authorization: `Bearer ${token}` },
})
const entries = list.json() as {
action: string
actor_email: string | null
ip: string | null
}[]
expect(entries.some((e) => e.action === 'auth.login_failed')).toBe(true)
expect(entries[0]?.actor_email).toBe('nobody@test.local')
expect(entries[0]?.ip).toBe('198.51.100.7')
await app.close()
})
it('records sso-access and separates kind=changes', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sso = await app.inject({
method: 'POST',
url: '/api/v1/auth/sso-access',
headers: { authorization: `Bearer ${token}` },
payload: { return_to: 'https://bgp.example.test/' },
})
expect(sso.statusCode).toBe(200)
expect((sso.json() as { target_app: string }).target_app).toBe('bgp')
const logins = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins',
headers: { authorization: `Bearer ${token}` },
})
const loginEntries = logins.json() as { action: string }[]
expect(loginEntries.every((e) => e.action.startsWith('auth.'))).toBe(true)
expect(loginEntries.some((e) => e.action === 'auth.sso_handoff')).toBe(true)
const changes = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=changes',
headers: { authorization: `Bearer ${token}` },
})
const changeEntries = changes.json() as { action: string }[]
expect(changeEntries.every((e) => !e.action.startsWith('auth.'))).toBe(true)
await app.close()
})
it('lists and revokes refresh sessions', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const sessions = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(sessions.statusCode).toBe(200)
const rows = sessions.json() as { id: string; user_id: string }[]
expect(rows.length).toBeGreaterThanOrEqual(1)
const sessionId = rows[0]!.id
const revoke = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/sessions/${sessionId}`,
headers: { authorization: `Bearer ${token}` },
})
expect(revoke.statusCode).toBe(200)
const after = await app.inject({
method: 'GET',
url: '/api/v1/admin/sessions',
headers: { authorization: `Bearer ${token}` },
})
expect(
(after.json() as { id: string }[]).some((s) => s.id === sessionId),
).toBe(false)
await app.close()
})
})
+82
View File
@@ -0,0 +1,82 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { listBackups } from '@authportal/db'
import { loadConfig } from '../src/config.js'
import { backupDirFor, runBackup, startBackupJob } from '../src/services/backup.js'
import { buildApp } from '../src/app.js'
import { adminToken } from './helpers.js'
const dirs: string[] = []
afterEach(() => {
vi.useRealTimers()
for (const d of dirs.splice(0)) {
try {
rmSync(d, { recursive: true, force: true })
} catch {
/* Windows может держать файл БД до GC — временный каталог не критичен */
}
}
})
function env(dir: string, extra: NodeJS.ProcessEnv = {}) {
return loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: `sqlite:${join(dir, 'app.db')}`,
NODE_ENV: 'test',
...extra,
})
}
describe('backup', () => {
it('backupDirFor: по умолчанию рядом с БД, BACKUP_DIR приоритетнее', () => {
const cfg = env('/data/x')
expect(backupDirFor(cfg).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/data\/x\/backups$/)
const custom = env('/data/x', { BACKUP_DIR: '/mnt/bk' })
expect(backupDirFor(custom).replaceAll(String.fromCharCode(92), '/')).toMatch(/\/mnt\/bk$/)
})
it('бэкап живого приложения восстанавливается: вход админа работает', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-api-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_KEEP: '2' })
const app = await buildApp({ config })
const res = await runBackup(app.sqlite, config)
expect(listBackups(backupDirFor(config))).toHaveLength(1)
await app.close()
const restored = await buildApp({
config: env(dir),
databaseUrl: `sqlite:${res.file}`,
})
const token = await adminToken(restored as never)
expect(token).toBeTruthy()
await restored.close()
})
it('startBackupJob выключен при 0 часов и работает по таймеру', async () => {
vi.useFakeTimers()
const dir = mkdtempSync(join(tmpdir(), 'ap-job-bk-'))
dirs.push(dir)
const config = env(dir, { BACKUP_INTERVAL_HOURS: '1' })
const app = await buildApp({ config })
const base = vi.getTimerCount()
const off = startBackupJob({ ...app, config: { ...config, backupIntervalHours: 0 } } as never)
expect(vi.getTimerCount()).toBe(base)
off()
const stop = startBackupJob(app)
expect(vi.getTimerCount()).toBe(base + 1)
await vi.advanceTimersByTimeAsync(60 * 60 * 1000 + 10)
vi.useRealTimers()
await vi.waitFor(() =>
expect(listBackups(backupDirFor(config)).length).toBeGreaterThan(0),
)
stop()
await app.close()
})
})
+150
View File
@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Writable } from 'node:stream'
import Fastify from 'fastify'
import { assertBootstrapPassword, loadConfig } from '../src/config.js'
import { buildApp } from '../src/app.js'
const base = { JWT_SECRET: 'a-sufficiently-long-secret-123' }
describe('loadConfig', () => {
it('значения по умолчанию для новых параметров', () => {
const c = loadConfig({ ...base, NODE_ENV: 'test' })
expect(c.shutdownTimeoutMs).toBe(8000)
expect(c.sessionRetentionDays).toBe(7)
expect(c.argon2Concurrency).toBe(2)
expect(c.backupKeep).toBe(7)
expect(c.backupIntervalHours).toBe(0)
expect(c.corsOrigins).toBe('')
expect(c.trustProxy).toContain('10.0.0.0/8')
})
it('читает переопределения из env', () => {
const c = loadConfig({
...base,
NODE_ENV: 'test',
SHUTDOWN_TIMEOUT_MS: '1500',
SESSION_RETENTION_DAYS: '30',
ARGON2_CONCURRENCY: '4',
BACKUP_KEEP: '3',
BACKUP_INTERVAL_HOURS: '12',
CORS_ORIGINS: 'https://a.example',
TRUST_PROXY: 'false',
})
expect(c).toMatchObject({
shutdownTimeoutMs: 1500,
sessionRetentionDays: 30,
argon2Concurrency: 4,
backupKeep: 3,
backupIntervalHours: 12,
corsOrigins: 'https://a.example',
trustProxy: 'false',
})
})
it.each([
['SHUTDOWN_TIMEOUT_MS', '0'],
['ARGON2_CONCURRENCY', '-1'],
['BACKUP_KEEP', 'abc'],
['BACKUP_INTERVAL_HOURS', '-5'],
])('невалидный %s=%s → ошибка', (key, value) => {
expect(() => loadConfig({ ...base, NODE_ENV: 'test', [key]: value })).toThrow()
})
})
describe('production guard', () => {
it.each(['change-me-in-production', 'dev-secret-change-me'])(
'известный дефолт JWT_SECRET "%s" отклоняется',
(secret) => {
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: secret }),
).toThrow(/JWT_SECRET/)
},
)
it('короткий, но уникальный секрет в production не роняет старт (только предупреждение)', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: 'short-secret' }),
).not.toThrow()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JWT_SECRET'))
warn.mockRestore()
})
it('нормальный секрет в production принимается без предупреждений; в test слабый разрешён', () => {
const warn = vi.spyOn(process, 'emitWarning').mockImplementation(() => {})
expect(() =>
loadConfig({ NODE_ENV: 'production', JWT_SECRET: base.JWT_SECRET }),
).not.toThrow()
expect(warn).not.toHaveBeenCalled()
expect(() =>
loadConfig({ NODE_ENV: 'test', JWT_SECRET: 'short-secret' }),
).not.toThrow()
warn.mockRestore()
})
it('пароль первого админа: слабый запрещён только в production', () => {
const prod = (pw?: string) =>
loadConfig({ NODE_ENV: 'production', ...base, ADMIN_PASSWORD: pw })
expect(() => assertBootstrapPassword(prod('admin'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod(''))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('short'))).toThrow(/ADMIN_PASSWORD/)
expect(() => assertBootstrapPassword(prod('long-enough-pw'))).not.toThrow()
expect(() =>
assertBootstrapPassword(loadConfig({ NODE_ENV: 'test', ...base })),
).not.toThrow()
})
it('buildApp в production без нормального пароля падает при создании админа', async () => {
await expect(
buildApp({
config: loadConfig({
NODE_ENV: 'production',
...base,
ADMIN_PASSWORD: 'admin',
DATABASE_URL: 'sqlite::memory:',
}),
databaseUrl: 'sqlite::memory:',
}),
).rejects.toThrow(/ADMIN_PASSWORD/)
})
})
describe('redact в логах', () => {
let app: ReturnType<typeof Fastify> | undefined
afterEach(async () => {
await app?.close()
})
it('authorization и cookie не попадают в лог', async () => {
const lines: string[] = []
const stream = new Writable({
write(chunk, _enc, cb) {
lines.push(String(chunk))
cb()
},
})
// Тот же набор путей, что и в buildApp.
app = Fastify({
logger: {
level: 'info',
stream,
redact: {
paths: ['req.headers.authorization', 'req.headers.cookie'],
censor: '[redacted]',
},
serializers: {
req: (req) => ({ method: req.method, headers: req.headers }),
},
},
})
app.get('/x', async () => ({ ok: true }))
await app.inject({
method: 'GET',
url: '/x',
headers: { authorization: 'Bearer SECRET', cookie: 'refresh_token=SECRET' },
})
const joined = lines.join('')
expect(joined).toContain('[redacted]')
expect(joined).not.toContain('SECRET')
})
})
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { adminToken, buildTestApp } from './helpers.js'
import { startHousekeepingJob } from '../src/services/housekeeping.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.useRealTimers()
await app?.close()
app = undefined
})
describe('directory/users', () => {
it('отдаёт только id/email/name и ограничивает 50', async () => {
app = await buildTestApp()
const token = await adminToken(app)
for (let i = 0; i < 55; i++) {
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES (?,?,?,?,0,0,'t','t')`,
)
.run(`id${i}`, `u${i}@t.io`, `U${i}`, 'hash')
}
const res = await app.inject({
method: 'GET',
url: '/api/v1/directory/users',
headers: { authorization: `Bearer ${token}` },
})
expect(res.statusCode).toBe(200)
const body = res.json() as Array<Record<string, unknown>>
expect(body).toHaveLength(50)
expect(Object.keys(body[0]!).sort()).toEqual(['email', 'id', 'name'])
expect(res.body).not.toContain('hash')
const q = await app.inject({
method: 'GET',
url: '/api/v1/directory/users?q=U54',
headers: { authorization: `Bearer ${token}` },
})
expect((q.json() as unknown[]).length).toBe(1)
})
})
describe('ingest audit', () => {
const ev = (i: number) => ({
event_id: `ev-${i}`,
source_app: 'dns',
action: 'dns.test',
severity: 'info',
summary: 'x',
})
it('принимает пачку одной транзакцией и считает дубликаты', async () => {
app = await buildTestApp()
const post = (events: unknown[]) =>
app!.inject({
method: 'POST',
url: '/api/v1/ingest/audit',
headers: { authorization: 'Bearer dev-audit-ingest-secret' },
payload: { events },
})
const first = await post([ev(1), ev(2), ev(3)])
expect(first.json()).toEqual({ accepted: 3, duplicates: 0 })
const second = await post([ev(3), ev(4)])
expect(second.json()).toEqual({ accepted: 1, duplicates: 1 })
})
})
describe('housekeeping job', () => {
it('удаляет устаревшие сессии по таймеру и останавливается', async () => {
vi.useFakeTimers()
app = await buildTestApp()
app.sqlite
.prepare(
`INSERT INTO users (id,email,name,password_hash,is_admin,disabled,created_at,updated_at)
VALUES ('uh','h@t.io','H','x',0,0,'t','t')`,
)
.run()
app.sqlite
.prepare(
`INSERT INTO refresh_sessions (id,user_id,token_hash,expires_at,created_at)
VALUES ('old','uh','h','2000-01-01T00:00:00.000Z','t')`,
)
.run()
const stop = startHousekeepingJob(app)
await vi.advanceTimersByTimeAsync(31_000)
const left = app.sqlite
.prepare(`SELECT COUNT(*) c FROM refresh_sessions WHERE id='old'`)
.get()
expect(left).toEqual({ c: 0 })
stop()
expect(vi.getTimerCount()).toBe(0)
})
})
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
const root = resolve(import.meta.dirname, '../../..')
const read = (p: string) => readFileSync(resolve(root, p), 'utf-8')
const dockerfile = read('deploy/docker/authportal/Dockerfile')
const composes = {
'docker-compose.yml': read('docker-compose.yml'),
'deploy/docker-compose.traefik.yml': read('deploy/docker-compose.traefik.yml'),
}
describe('Dockerfile', () => {
it('graceful stop и ограничение ресурсов Node', () => {
expect(dockerfile).toMatch(/^STOPSIGNAL SIGTERM$/m)
expect(dockerfile).toMatch(/NODE_OPTIONS=--max-old-space-size=\d+/)
expect(dockerfile).toMatch(/UV_THREADPOOL_SIZE=4/)
})
it('healthcheck без запуска node', () => {
const hc = dockerfile.split('HEALTHCHECK')[1]!.split('\n').slice(0, 2).join(' ')
expect(hc).toContain('wget')
expect(hc).not.toContain('"node"')
})
it('запуск не от root, рантайм без тулчейна и pnpm deploy', () => {
expect(dockerfile).toMatch(/^USER node$/m)
const runtime = dockerfile.split('AS runtime')[1]!
expect(runtime).not.toMatch(/apk add/)
expect(dockerfile).not.toMatch(/pnpm .*deploy/)
})
it('статика сжимается только brotli', () => {
expect(dockerfile).toContain('brotli')
expect(dockerfile).not.toMatch(/gzip -k/)
})
it('preview-маршруты дизайна вырезаются до сборки web', () => {
const rm = dockerfile.indexOf('rm -f apps/web/src/routes/design-preview*.tsx')
const build = dockerfile.indexOf('pnpm turbo build')
expect(rm).toBeGreaterThan(0)
expect(rm).toBeLessThan(build)
})
it('базовый образ закреплён по digest', () => {
expect(dockerfile).toMatch(/ARG BASE_NODE=\S+@sha256:[0-9a-f]{64}/)
expect(read('deploy/docker/docker-bake.hcl')).toMatch(/node:22-alpine@sha256:[0-9a-f]{64}/)
})
it('в образ копируется весь dist API, а tsup не дробит бандл на чанки', () => {
expect(dockerfile).toContain('cp -r apps/api/dist /out/dist')
expect(read('apps/api/tsup.config.ts')).toMatch(/splitting:\s*false/)
})
it('нативные модули проверяются при сборке', () => {
expect(dockerfile).toContain('native modules ok')
})
})
describe.each(Object.entries(composes))('%s', (_name, text) => {
it('init, grace period и лимиты ресурсов', () => {
expect(text).toMatch(/init: true/)
expect(text).toMatch(/stop_grace_period: 15s/)
expect(text).toMatch(/mem_limit: 256m/)
expect(text).toMatch(/pids_limit: \d+/)
expect(text).toMatch(/read_only: true/)
expect(text).toMatch(/cap_drop:\s*\n\s*- ALL/)
expect(text).toMatch(/no-new-privileges:true/)
})
it('healthcheck через wget', () => {
expect(text).toMatch(/"wget", "-q"/)
expect(text).not.toMatch(/fetch\('http:\/\/127\.0\.0\.1:8080\/health'\)/)
})
})
describe('traefik', () => {
it('app ждёт healthy traefik, у traefik есть healthcheck', () => {
const t = composes['deploy/docker-compose.traefik.yml']
expect(t).toMatch(/condition: service_healthy/)
expect(t).toContain('traefik", "healthcheck", "--ping')
expect(t).toContain('--ping=true')
})
it('включено сжатие ответов', () => {
expect(composes['deploy/docker-compose.traefik.yml']).toContain(
'middlewares.auth-portal-compress.compress=true',
)
})
})
+77
View File
@@ -0,0 +1,77 @@
import { mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { z } from 'zod'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
describe('error handler', () => {
it('необработанная ошибка → 500 без деталей', async () => {
app = await buildTestApp()
app.get('/__boom', async () => {
throw new Error('secret internals')
})
const res = await app.inject({ method: 'GET', url: '/__boom' })
expect(res.statusCode).toBe(500)
expect(res.json()).toEqual({
error: { code: 'INTERNAL', message: 'Internal server error' },
})
expect(res.body).not.toContain('secret internals')
})
it('ZodError → 400 BAD_REQUEST', async () => {
app = await buildTestApp()
app.get('/__zod', async () => z.object({ a: z.string() }).parse({}))
const res = await app.inject({ method: 'GET', url: '/__zod' })
expect(res.statusCode).toBe(400)
expect(res.json().error.code).toBe('BAD_REQUEST')
})
it('ошибка с statusCode 4xx сохраняет статус', async () => {
app = await buildTestApp()
app.get('/__teapot', async () => {
throw Object.assign(new Error('nope'), { statusCode: 418 })
})
const res = await app.inject({ method: 'GET', url: '/__teapot' })
expect(res.statusCode).toBe(418)
})
})
describe('not found', () => {
it.each(['/oauth/nope', '/.well-known/nope', '/api/v1/nope'])(
'%s → 404 JSON',
async (url) => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
},
)
it('без STATIC_DIR неизвестный путь → 404 JSON', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(res.statusCode).toBe(404)
})
it('со STATIC_DIR: SPA-путь → index.html, /oauth и /.well-known → 404', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
app = await buildTestApp({ STATIC_DIR: dir })
const spa = await app.inject({ method: 'GET', url: '/some/spa/route' })
expect(spa.statusCode).toBe(200)
expect(spa.body).toContain('spa')
for (const url of ['/oauth/nope', '/.well-known/nope']) {
const res = await app.inject({ method: 'GET', url })
expect(res.statusCode).toBe(404)
expect(res.json().error.code).toBe('NOT_FOUND')
}
})
})
+67
View File
@@ -0,0 +1,67 @@
import { expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
/** Фиксированный тестовый RSA-ключ: importPKCS8 (~мс) вместо generateKeyPair
* (~100–300 мс) при построении приложения в каждом тесте. */
const TEST_OIDC_RSA_PRIVATE_KEY = [
'-----BEGIN PRIVATE KEY-----',
'MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCt/+Vwi6txs4ET',
'/OczfcOX0mVCOfxT3qGejj1xX19A4gKeGGjQ5LSBDGzcztncYMd0J/H1ozUWn0NU',
'ctolNRSTFchAYj1MoC2Pacfhe445nLR4bOssdADO9bwhHmWy8WQFyYc/lqs/xPSd',
'haZFwDRBRRfh9uxzzUPR8FXCovHEJG5HiLuvmALL1W7nkDg+L+vyLYCNje482hS+',
'ZcW3IH17gQpXgpqgXPaGHfZT1teIiYwhelaqPsApMQroOisKYGoQi1z/G/mdvaOF',
'/jravyBqhr5Hm03lFOMBvMptGrXsjopy+BVlqBVcvdwdyFfz94dayhnCmfEIvK7W',
'ynYGtoLnAgMBAAECggEAKqld04DjOb8FzGVLOKRCwyQ6YIkAFX42CT6d0QDNxYZE',
'lwesF1+j5CosPN1YrEBsVs3+dLgd6ZyF8Rwx4UKI92E1VpffqLxzP0Dz2ZSmM/j4',
'hLhL1vvgQN4eUM53p+6olASr7QrflQSQ51hgAqUUCtbKesREiEYRNVWkaVLzTSAr',
'KrwWAF81hzEms0KuHw6/EgThBkWYGAMzZjECFa173yBHklt1ikdB1AOmieL9PZbB',
'anjIequkUE2yoTJM/wOd2GN7sy61PmnMApwl1ke6k4VhrJeQhfahck0D2ynDvWqX',
'fnn1Wr/BRmSi38RNEOySWkrCVjM0XSjO3wEuHFFyuQKBgQDwgOgzgL1W5b13LKXo',
'8F/AJNRmi4vpM1lftMRalx5NU5ugw1iQ2zJzLG3Tl1/nvyt8tGFkXB7Dw9M9H0as',
'jjk7D8ZzL1a7COWQx7wya1l8vMxwv40ERvoWypwpuVo12SvRnHWgGwsbqW4wxYIG',
'9HsdvbxglFiVcSq+b6FDdg2nyQKBgQC5NgKatFPPed/DFb6MCkW+5Wnw2HzdKHxD',
'fEkAJeb3xX+9k7OifRHppurwaDV4jUPJ5W1Whly77cg+xmEYAbUdPS1kgfYJaDn1',
'mPN9dI7RP15v4X46/+5v5EZYAKcdUdMzr9gJ2iQVpoDi5ojp+LJknPLBuo7XTfp3',
'kj9lifONLwKBgHa3SudRXyTr1rBjC6qGLN2N0IKl03RTcoR5mIsXGVAWOzYJ90fZ',
'y3dqsPPHPwIILZErAQAnMNB1c2oybBJ1Se7LhKwK7Ab7B0aGMXG557xY/ZmSFCvO',
'VVi2vosiAVOfVa/sSWu0rbED226br+l6+OLfa4NI9skdyXmjZLkGJwtxAoGBALDp',
'LkOnzAZ6W4p00rXheg2GBVPQ9jQp38h2hyRlD+HL/OCQnR2wadPNZMyOPcUQ6peH',
'VpE3dozr7EJKK6RKvT1YmmtO2DzUGcjGeI51L5Yf+YzUz0Z8AL1QRwD4Xqha6GqO',
'pMWEYMNtb99MZatXqs8AO8Fg+eAe1Agy4euTgdhxAoGBAPAgrLUl7uJMdCsrHFEG',
'K8CH3BZwz2ytSj6429IdX4OCzkkmdsWnjx5/FK2hc+fXMluhybaIziSntgs55vzZ',
'1hd/Xw1T2FSTS6BuWegxVtjO6BQjbVXrxOumA0PPmFYaGMPvvBQrbZy8OSeuaKD/',
'a9jKZApDKnT8vmYvmyMMtsmE',
'-----END PRIVATE KEY-----',
].join('\n')
export async function buildTestApp(env: NodeJS.ProcessEnv = {}) {
resetOidcKeyCache()
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: 'sqlite::memory:',
ISSUER: 'https://auth.test.local',
OIDC_ISSUER: 'https://auth.test.local',
NODE_ENV: 'test',
AUDIT_INGEST_SECRET: 'dev-audit-ingest-secret',
OIDC_RSA_PRIVATE_KEY: TEST_OIDC_RSA_PRIVATE_KEY,
...env,
})
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
}
export async function adminToken(
app: Awaited<ReturnType<typeof buildTestApp>>,
): Promise<string> {
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
return (login.json() as { access_token: string }).access_token
}
+277
View File
@@ -0,0 +1,277 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { hash } from '@node-rs/argon2'
import { TtlCache } from '../src/lib/cache.js'
import { parseCookie } from '../src/lib/cookies.js'
import { checkClientSecret, hashClientSecret } from '../src/lib/client-secret.js'
import {
hashPassword,
passwordHashStats,
setPasswordConcurrency,
verifyPassword,
} from '../src/lib/password.js'
import { parseTrustProxy } from '../src/config.js'
import { adminToken, buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
vi.restoreAllMocks()
await app?.close()
app = undefined
})
describe('TtlCache', () => {
it('истекает по TTL', () => {
let t = 0
const c = new TtlCache<string, number>({ max: 10, ttlMs: 100, now: () => t })
c.set('a', 1)
t = 99
expect(c.get('a')).toBe(1)
t = 100
expect(c.get('a')).toBeUndefined()
expect(c.size).toBe(0)
})
it('вытесняет давно не использованное (LRU)', () => {
const c = new TtlCache<string, number>({ max: 2, ttlMs: 1000 })
c.set('a', 1)
c.set('b', 2)
c.get('a') // освежили a
c.set('c', 3)
expect(c.get('b')).toBeUndefined()
expect(c.get('a')).toBe(1)
expect(c.get('c')).toBe(3)
c.delete('a')
expect(c.get('a')).toBeUndefined()
c.clear()
expect(c.size).toBe(0)
})
})
describe('parseCookie', () => {
it('находит значение и декодирует', () => {
expect(parseCookie('a=1; refresh_token=abc%20d; b=2', 'refresh_token')).toBe(
'abc d',
)
})
it('не путает суффикс имени', () => {
expect(parseCookie('x_refresh_token=bad', 'refresh_token')).toBeNull()
})
it('пусто/нет → null', () => {
expect(parseCookie(undefined, 'a')).toBeNull()
expect(parseCookie('a=; b=1', 'a')).toBeNull()
expect(parseCookie('b=1', 'a')).toBeNull()
})
})
describe('client secret', () => {
it('sha256-формат: верный/неверный, без rehash', async () => {
const stored = hashClientSecret('s3cret')
expect(stored.startsWith('sha256:')).toBe(true)
expect(await checkClientSecret(stored, 's3cret')).toEqual({
ok: true,
needsRehash: false,
})
expect((await checkClientSecret(stored, 'nope')).ok).toBe(false)
})
it('argon2-формат принимается и помечается на rehash', async () => {
const legacy = await hash('old-secret')
expect(await checkClientSecret(legacy, 'old-secret')).toEqual({
ok: true,
needsRehash: true,
})
expect(await checkClientSecret(legacy, 'bad')).toEqual({
ok: false,
needsRehash: false,
})
expect((await checkClientSecret('garbage', 'x')).ok).toBe(false)
})
})
describe('password limiter', () => {
it('не допускает больше N одновременных хешей', async () => {
setPasswordConcurrency(2)
let maxActive = 0
const poll = setInterval(() => {
maxActive = Math.max(maxActive, passwordHashStats().active)
}, 1)
const h = await hashPassword('pw')
await Promise.all(
Array.from({ length: 6 }, () => verifyPassword(h, 'pw')),
)
clearInterval(poll)
expect(maxActive).toBeLessThanOrEqual(2)
expect(passwordHashStats()).toEqual({ active: 0, waiting: 0 })
setPasswordConcurrency(2)
})
})
describe('parseTrustProxy', () => {
it('true/false/список', () => {
expect(parseTrustProxy('true')).toBe(true)
expect(parseTrustProxy('false')).toBe(false)
expect(parseTrustProxy('10.0.0.0/8, ::1')).toEqual(['10.0.0.0/8', '::1'])
})
})
async function createUserAndLogin(
a: NonNullable<typeof app>,
adminTok: string,
email: string,
) {
const create = await a.inject({
method: 'POST',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${adminTok}` },
payload: {
email,
name: 'U',
password: 'secret12',
is_admin: false,
apps: ['cfdm'],
permissions: [],
},
})
expect(create.statusCode).toBe(201)
const id = (create.json() as { id: string }).id
const login = await a.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email, password: 'secret12' },
})
expect(login.statusCode).toBe(200)
return { id, token: (login.json() as { access_token: string }).access_token }
}
describe('кэш профиля пользователя', () => {
it('повторные запросы не обращаются к user_apps/user_permissions', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { token } = await createUserAndLogin(app, admin, 'cache@test.local')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200) // прогрев
const spy = vi.spyOn(app.sqlite, 'prepare')
for (let i = 0; i < 5; i++) expect((await me()).statusCode).toBe(200)
const sqls = spy.mock.calls.map((c) => String(c[0]))
expect(sqls.filter((s) => /user_apps|user_permissions|from "users"/i.test(s)))
.toHaveLength(0)
})
it('блокировка пользователя действует немедленно', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, 'ban@test.local')
const me = () =>
app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect((await me()).statusCode).toBe(200)
const patch = await app.inject({
method: 'PATCH',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
payload: { disabled: true },
})
expect(patch.statusCode).toBe(200)
expect((await me()).statusCode).toBe(401)
})
it('смена доступа отражается сразу, удаление → 401', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const { id, token } = await createUserAndLogin(app, admin, 'acc@test.local')
const me = async () =>
(
await app!.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
).json() as { apps?: string[] }
expect((await me()).apps).toEqual(['cfdm'])
const put = await app.inject({
method: 'PUT',
url: `/api/v1/admin/users/${id}/access`,
headers: { authorization: `Bearer ${admin}` },
payload: { apps: ['dns'], permissions: [] },
})
expect(put.statusCode).toBe(200)
expect((await me()).apps).toEqual(['dns'])
await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${id}`,
headers: { authorization: `Bearer ${admin}` },
})
const gone = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
expect(gone.statusCode).toBe(401)
})
})
describe('app-switcher кэш', () => {
it('PUT сразу виден в GET', async () => {
app = await buildTestApp()
const admin = await adminToken(app)
const before = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
const cfg = await app.inject({
method: 'GET',
url: '/api/v1/admin/app-switcher',
headers: { authorization: `Bearer ${admin}` },
})
expect(before.statusCode).toBe(200)
expect(cfg.statusCode).toBe(200)
})
})
describe('rate-limit', () => {
it('/health не лимитируется', async () => {
app = await buildTestApp()
for (let i = 0; i < 250; i++) {
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
}
})
it('логин ограничен 20/мин', async () => {
app = await buildTestApp()
let last = 0
for (let i = 0; i < 22; i++) {
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'nobody@test.local', password: 'wrongpass' },
})
last = res.statusCode
}
expect(last).toBe(429)
})
it('X-Forwarded-For от недоверенного клиента игнорируется', async () => {
app = await buildTestApp({ TRUST_PROXY: '10.0.0.0/8' })
const res = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
remoteAddress: '203.0.113.9',
headers: { 'x-forwarded-for': '198.51.100.7' },
payload: { email: 'nobody@test.local', password: 'wrongpass' },
})
expect(res.statusCode).toBe(401)
const row = app.sqlite
.prepare(`SELECT ip FROM audit_log WHERE action='auth.login_failed'`)
.get() as { ip: string }
expect(row.ip).toBe('203.0.113.9')
})
})
+232
View File
@@ -0,0 +1,232 @@
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { createOriginChecker } from '../src/lib/cors.js'
import { loadConfig } from '../src/config.js'
import { buildTestApp } from './helpers.js'
let app: Awaited<ReturnType<typeof buildTestApp>> | undefined
afterEach(async () => {
await app?.close()
app = undefined
})
function staticDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'ap-static-'))
mkdirSync(join(dir, 'assets'))
writeFileSync(join(dir, 'index.html'), '<html>spa</html>')
writeFileSync(join(dir, 'assets', 'app-abc123.js'), 'console.log(1)')
return dir
}
describe('статика', () => {
it('hashed-ассеты immutable, index.html no-cache', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const asset = await app.inject({ method: 'GET', url: '/assets/app-abc123.js' })
expect(asset.statusCode).toBe(200)
expect(asset.headers['cache-control']).toBe(
'public, max-age=31536000, immutable',
)
const index = await app.inject({ method: 'GET', url: '/index.html' })
expect(index.headers['cache-control']).toBe('no-cache')
const spa = await app.inject({ method: 'GET', url: '/users/42' })
expect(spa.statusCode).toBe(200)
expect(spa.headers['cache-control']).toBe('no-cache')
})
it('отсутствующий ассет → 404, а не index.html (иначе после деплоя чанк-JS = HTML)', async () => {
app = await buildTestApp({ STATIC_DIR: staticDir() })
const res = await app.inject({ method: 'GET', url: '/assets/missing.js' })
expect(res.statusCode).toBe(404)
expect(res.body).not.toContain('<html>')
})
it('отдаёт предсжатый .br по Accept-Encoding', async () => {
const dir = staticDir()
writeFileSync(join(dir, 'assets', 'app-abc123.js.br'), 'BR-BYTES')
app = await buildTestApp({ STATIC_DIR: dir })
const res = await app.inject({
method: 'GET',
url: '/assets/app-abc123.js',
headers: { 'accept-encoding': 'br' },
})
expect(res.headers['content-encoding']).toBe('br')
expect(res.body).toBe('BR-BYTES')
})
})
describe('кэш-заголовки API', () => {
it('auth/config кэшируется, app-switcher — нет (админка должна видеть правки сразу)', async () => {
app = await buildTestApp()
const sw = await app.inject({ method: 'GET', url: '/api/v1/app-switcher' })
expect(sw.headers['cache-control']).toBeUndefined()
const cfg = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(cfg.headers['cache-control']).toBe('public, max-age=60')
})
})
describe('health / ready', () => {
it('/health не трогает БД и живёт после закрытия БД', async () => {
app = await buildTestApp()
app.sqlite.close()
const res = await app.inject({ method: 'GET', url: '/health' })
expect(res.statusCode).toBe(200)
})
it('/ready 200 в норме и 503 при недоступной БД', async () => {
app = await buildTestApp()
const ok = await app.inject({ method: 'GET', url: '/ready' })
expect(ok.statusCode).toBe(200)
app.sqlite.close()
const bad = await app.inject({ method: 'GET', url: '/ready' })
expect(bad.statusCode).toBe(503)
expect(bad.json()).toEqual({ ok: false, reason: 'db' })
})
})
describe('CORS', () => {
const origin = (url: string) => ({ origin: url })
it('разрешает issuer/allowlist, блокирует чужие', async () => {
app = await buildTestApp({ RETURN_TO_ALLOWLIST: '.test.local,localhost' })
const allowed = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://auth.test.local'),
})
expect(allowed.headers['access-control-allow-origin']).toBe(
'https://auth.test.local',
)
expect(allowed.headers['access-control-allow-credentials']).toBe('true')
const foreign = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://evil.example'),
})
expect(foreign.headers['access-control-allow-origin']).toBeUndefined()
})
it('CORS_ORIGINS добавляет origin; запрос без Origin проходит', async () => {
app = await buildTestApp({ CORS_ORIGINS: 'https://extra.example' })
const res = await app.inject({
method: 'GET',
url: '/api/v1/auth/config',
headers: origin('https://extra.example'),
})
expect(res.headers['access-control-allow-origin']).toBe(
'https://extra.example',
)
const none = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(none.statusCode).toBe(200)
})
it('боевой RETURN_TO_ALLOWLIST из env.traefik.example не теряет клиентов', () => {
const list =
'.shnt.top,https://vps.shnt.top,https://cfdm.shnt.top,https://bgp.shnt.top,https://fw.shnt.top,https://dns.shnt.top,https://cdn.shnt.top'
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.shnt.top',
RETURN_TO_ALLOWLIST: list,
}),
() => [],
)
for (const o of [
'https://vps.shnt.top',
'https://dns.shnt.top',
'https://shnt.top',
'https://anything.shnt.top',
]) {
expect(check(o)).toBe(true)
}
expect(check('https://evil.example')).toBe(false)
})
it('private в allowlist пускает LAN-хосты', () => {
const check = createOriginChecker(
loadConfig({
JWT_SECRET: 'test-secret-at-least-8',
RETURN_TO_ALLOWLIST: '.shnt.top,private',
}),
() => [],
)
expect(check('http://192.168.1.10:8080')).toBe(true)
expect(check('http://10.0.0.5')).toBe(true)
expect(check('https://8.8.8.8')).toBe(false)
})
it('createOriginChecker: суффикс, localhost, switcher, *', () => {
const base = {
JWT_SECRET: 'test-secret-at-least-8',
ISSUER: 'https://auth.test.local',
RETURN_TO_ALLOWLIST: '.shnt.top,localhost,private',
}
const check = createOriginChecker(loadConfig(base), () => [
'https://dns.custom.io/path',
])
expect(check('https://cfdm.shnt.top')).toBe(true)
expect(check('http://localhost:5173')).toBe(true)
expect(check('https://dns.custom.io')).toBe(true)
expect(check('https://evilshnt.top')).toBe(false)
expect(check('not a url')).toBe(false)
const all = createOriginChecker(
loadConfig({ ...base, CORS_ORIGINS: '*' }),
() => [],
)
expect(all('https://anything.example')).toBe(true)
})
})
describe('сжатие в Node (ENABLE_COMPRESS)', () => {
async function withBigRoute(env: NodeJS.ProcessEnv) {
app = await buildTestApp(env)
app.get('/__big', async () => ({ data: 'x'.repeat(5000) }))
app.get('/__small', async () => ({ ok: true }))
return app
}
it('по умолчанию выключено', async () => {
const a = await withBigRoute({})
const res = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(res.headers['content-encoding']).toBeUndefined()
})
it('включено: большой JSON сжимается, маленький нет, без Accept-Encoding нет', async () => {
const a = await withBigRoute({ ENABLE_COMPRESS: 'true' })
const big = await a.inject({
method: 'GET',
url: '/__big',
headers: { 'accept-encoding': 'gzip' },
})
expect(big.headers['content-encoding']).toBe('gzip')
expect(big.rawPayload.length).toBeLessThan(500)
const small = await a.inject({
method: 'GET',
url: '/__small',
headers: { 'accept-encoding': 'gzip' },
})
expect(small.headers['content-encoding']).toBeUndefined()
const none = await a.inject({ method: 'GET', url: '/__big' })
expect(none.headers['content-encoding']).toBeUndefined()
})
});
describe('CSP', () => {
it('отдаётся жёсткая политика без внешних источников', async () => {
app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/health' })
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("script-src 'self'")
expect(csp).toContain("frame-ancestors 'none'")
expect(csp).toContain("object-src 'none'")
expect(csp).not.toMatch(/script-src[^;]*'unsafe-inline'/)
expect(csp).not.toContain('http:')
})
})
+311
View File
@@ -0,0 +1,311 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { jwtVerify, createLocalJWKSet } from 'jose'
import { buildApp } from '../src/app.js'
import { loadConfig } from '../src/config.js'
import { resetOidcKeyCache } from '../src/lib/oidc/keys.js'
import { buildTestApp } from './helpers.js'
describe('OIDC IdP', () => {
it('serves discovery and JWKS', async () => {
const app = await buildTestApp()
const discovery = await app.inject({
method: 'GET',
url: '/.well-known/openid-configuration',
})
expect(discovery.statusCode).toBe(200)
const meta = discovery.json() as {
issuer: string
authorization_endpoint: string
jwks_uri: string
}
expect(meta.issuer).toBe('https://auth.test.local')
expect(meta.authorization_endpoint).toContain('/oauth/authorize')
expect(meta.jwks_uri).toContain('/.well-known/jwks.json')
const jwks = await app.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks.statusCode).toBe(200)
const keys = jwks.json() as { keys: { kid: string; kty: string }[] }
expect(keys.keys.length).toBeGreaterThan(0)
expect(keys.keys[0]?.kty).toBe('RSA')
await app.close()
})
it('records auth.sso_handoff with target_app dns on authorize', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
expect(refresh?.value).toBeTruthy()
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Technitium',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
const client = created.json() as { client_id: string }
const authorize = await app.inject({
method: 'GET',
url: '/oauth/authorize',
cookies: { refresh_token: refresh!.value },
query: {
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid profile email groups',
state: 'xyz',
},
})
expect(authorize.statusCode).toBe(302)
const audit = await app.inject({
method: 'GET',
url: '/api/v1/admin/audit?kind=logins&limit=50',
headers: { authorization: `Bearer ${token}` },
})
expect(audit.statusCode).toBe(200)
const entries = audit.json() as {
action: string
details: Record<string, unknown> | null
}[]
const handoff = entries.find(
(e) =>
e.action === 'auth.sso_handoff' &&
e.details?.target_app === 'dns' &&
e.details?.auth_mode === 'oidc',
)
expect(handoff).toBeTruthy()
await app.close()
})
it('reloads RS256 key from SQLite after restart (extractable import)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'oidc-key-'))
const dbPath = `sqlite:${join(dir, 'app.db')}`
try {
resetOidcKeyCache()
const config = loadConfig({
...process.env,
JWT_SECRET: 'test-secret-at-least-8',
ADMIN_EMAIL: 'admin@test.local',
ADMIN_PASSWORD: 'adminpass',
DATABASE_URL: dbPath,
ISSUER: 'https://auth.test.local',
OIDC_ISSUER: 'https://auth.test.local',
NODE_ENV: 'test',
})
const first = await buildApp({ config, databaseUrl: dbPath })
const jwks1 = await first.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks1.statusCode).toBe(200)
const kid = (jwks1.json() as { keys: { kid: string }[] }).keys[0]?.kid
expect(kid).toBeTruthy()
await first.close()
resetOidcKeyCache()
const second = await buildApp({ config, databaseUrl: dbPath })
const jwks2 = await second.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
expect(jwks2.statusCode).toBe(200)
expect((jwks2.json() as { keys: { kid: string }[] }).keys[0]?.kid).toBe(
kid,
)
await second.close()
} finally {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// Windows may keep better-sqlite3 handle briefly
}
}
})
it('authorization code flow issues id_token with groups', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
expect(login.statusCode).toBe(200)
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')
expect(refresh?.value).toBeTruthy()
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'Technitium',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
expect(created.statusCode).toBe(200)
const client = created.json() as {
client_id: string
client_secret: string
}
const authorize = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid profile email groups',
state: 'xyz',
nonce: 'n1',
}).toString(),
cookies: { refresh_token: refresh!.value },
})
expect(authorize.statusCode).toBe(302)
const location = authorize.headers.location!
expect(location).toContain('https://dns.test.local/sso/callback')
const code = new URL(location).searchParams.get('code')
expect(code).toBeTruthy()
const tokenRes = await app.inject({
method: 'POST',
url: '/oauth/token',
payload: {
grant_type: 'authorization_code',
code: code!,
redirect_uri: 'https://dns.test.local/sso/callback',
client_id: client.client_id,
client_secret: client.client_secret,
},
})
expect(tokenRes.statusCode).toBe(200)
const tokens = tokenRes.json() as {
access_token: string
id_token: string
token_type: string
}
expect(tokens.token_type).toBe('Bearer')
const jwksRes = await app.inject({
method: 'GET',
url: '/.well-known/jwks.json',
})
const jwks = createLocalJWKSet(jwksRes.json() as { keys: never[] })
const { payload } = await jwtVerify(tokens.id_token, jwks, {
issuer: 'https://auth.test.local',
audience: client.client_id,
})
expect(payload.sub).toBeTruthy()
expect(payload.email).toBe('admin@test.local')
expect(payload.nonce).toBe('n1')
const groups = payload.groups as string[]
expect(groups).toContain('technitium_admins')
expect(groups).toContain('technitium_dns_admins')
const userinfo = await app.inject({
method: 'GET',
url: '/oauth/userinfo',
headers: { authorization: `Bearer ${tokens.access_token}` },
})
expect(userinfo.statusCode).toBe(200)
const info = userinfo.json() as { groups: string[]; email: string }
expect(info.email).toBe('admin@test.local')
expect(info.groups).toContain('technitium_admins')
await app.close()
})
it('rejects invalid client secret and redirect_uri mismatch', async () => {
const app = await buildTestApp()
const login = await app.inject({
method: 'POST',
url: '/api/v1/auth/login',
payload: { email: 'admin@test.local', password: 'adminpass' },
})
const token = (login.json() as { access_token: string }).access_token
const refresh = login.cookies.find((c) => c.name === 'refresh_token')!
const created = await app.inject({
method: 'POST',
url: '/api/v1/admin/oidc/clients',
headers: { authorization: `Bearer ${token}` },
payload: {
name: 'DNS',
redirect_uris: ['https://dns.test.local/sso/callback'],
scopes: ['openid', 'profile', 'email', 'groups'],
enabled: true,
},
})
const client = created.json() as {
client_id: string
client_secret: string
}
const badRedirect = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://evil.test/callback',
response_type: 'code',
scope: 'openid',
}).toString(),
cookies: { refresh_token: refresh.value },
})
expect(badRedirect.statusCode).toBe(400)
const authorize = await app.inject({
method: 'GET',
url:
'/oauth/authorize?' +
new URLSearchParams({
client_id: client.client_id,
redirect_uri: 'https://dns.test.local/sso/callback',
response_type: 'code',
scope: 'openid',
}).toString(),
cookies: { refresh_token: refresh.value },
})
const code = new URL(authorize.headers.location!).searchParams.get('code')!
const badSecret = await app.inject({
method: 'POST',
url: '/oauth/token',
payload: {
grant_type: 'authorization_code',
code,
redirect_uri: 'https://dns.test.local/sso/callback',
client_id: client.client_id,
client_secret: 'wrong-secret',
},
})
expect(badSecret.statusCode).toBe(401)
await app.close()
})
})
+120
View File
@@ -0,0 +1,120 @@
import { EventEmitter } from 'node:events'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createDb, closeDb, migrateSchema } from '@authportal/db'
import { registerShutdown } from '../src/lib/shutdown.js'
import { buildTestApp } from './helpers.js'
function fakeApp(close: () => Promise<void>) {
return {
close,
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
} as unknown as Parameters<typeof registerShutdown>[0]
}
describe('registerShutdown', () => {
afterEach(() => {
vi.useRealTimers()
})
it('закрывает приложение один раз и выходит с 0', async () => {
const close = vi.fn(async () => {})
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(0))
expect(close).toHaveBeenCalledTimes(1)
})
it('повторный сигнал форсирует выход, close не вызывается дважды', async () => {
let release!: () => void
const close = vi.fn(
() => new Promise<void>((r) => (release = r)),
)
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(close), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('SIGTERM')
proc.emit('SIGINT')
expect(exit).toHaveBeenCalledWith(1)
expect(close).toHaveBeenCalledTimes(1)
release()
})
it('по таймауту принудительно выходит с кодом 1', async () => {
vi.useFakeTimers()
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(() => new Promise<void>(() => {})),
{ timeoutMs: 500, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.advanceTimersByTimeAsync(501)
expect(exit).toHaveBeenCalledWith(1)
})
it('ошибка close → выход с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(
fakeApp(async () => {
throw new Error('boom')
}),
{ timeoutMs: 1000, exit, proc: proc as never },
)
proc.emit('SIGTERM')
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('unhandledRejection запускает shutdown с кодом 1', async () => {
const exit = vi.fn()
const proc = new EventEmitter()
registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit,
proc: proc as never,
})
proc.emit('unhandledRejection', new Error('x'))
await vi.waitFor(() => expect(exit).toHaveBeenCalledWith(1))
})
it('dispose снимает обработчики', () => {
const proc = new EventEmitter()
const h = registerShutdown(fakeApp(async () => {}), {
timeoutMs: 1000,
exit: vi.fn(),
proc: proc as never,
})
h.dispose()
expect(proc.listenerCount('SIGTERM')).toBe(0)
expect(proc.listenerCount('unhandledRejection')).toBe(0)
})
})
describe('closeDb', () => {
it('идемпотентно закрывает БД', () => {
const { sqlite } = createDb('sqlite::memory:')
migrateSchema(sqlite)
closeDb(sqlite)
expect(sqlite.open).toBe(false)
expect(() => closeDb(sqlite)).not.toThrow()
expect(() => sqlite.prepare('SELECT 1')).toThrow()
})
it('app.close() закрывает БД приложения', async () => {
const app = await buildTestApp()
await app.ready()
expect(app.sqlite.open).toBe(true)
await app.close()
expect(app.sqlite.open).toBe(false)
})
})
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { targetAppFromReturnTo } from '../src/lib/target-app.js'
describe('targetAppFromReturnTo', () => {
it('maps dns host and /sso/ path', () => {
expect(targetAppFromReturnTo('https://dns.shnt.top/sso/callback')).toBe(
'dns',
)
})
it('unwraps portal /oauth/authorize return_to via redirect_uri', () => {
const returnTo =
'https://auth.shnt.top/oauth/authorize?client_id=abc&redirect_uri=' +
encodeURIComponent('https://dns.shnt.top/sso/callback') +
'&response_type=code&scope=openid'
expect(targetAppFromReturnTo(returnTo)).toBe('dns')
})
it('keeps portal for bare issuer authorize without redirect_uri', () => {
expect(
targetAppFromReturnTo('https://auth.shnt.top/oauth/authorize'),
).toBe('portal')
})
it('maps cdn host', () => {
expect(targetAppFromReturnTo('https://cdn.shnt.top/auth/callback')).toBe(
'cdn',
)
})
it('maps mm host', () => {
expect(targetAppFromReturnTo('https://mm.shnt.top/auth/callback')).toBe(
'mm',
)
})
})
+160
View File
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import { createWebauthnCredential } from '@authportal/db'
import { adminToken, buildTestApp } from './helpers.js'
describe('webauthn / passkeys', () => {
it('exposes webauthn flag on auth config', async () => {
const app = await buildTestApp()
const res = await app.inject({ method: 'GET', url: '/api/v1/auth/config' })
expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ webauthn: true })
expect(app.config.webauthnRpID).toBe('auth.test.local')
expect(app.config.webauthnOrigins).toContain('https://auth.test.local')
expect(app.config.webauthnOrigins).toContain('http://localhost:5173')
await app.close()
})
it('requires JWT for register options', async () => {
const app = await buildTestApp()
const denied = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
})
expect(denied.statusCode).toBe(401)
const token = await adminToken(app)
const ok = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/register/options',
headers: { authorization: `Bearer ${token}` },
})
expect(ok.statusCode).toBe(200)
const body = ok.json() as {
challenge_id: string
options: { challenge: string; rp: { id: string } }
}
expect(body.challenge_id).toBeTruthy()
expect(body.options.challenge).toBeTruthy()
expect(body.options.rp.id).toBe('auth.test.local')
await app.close()
})
it('allows public login options and rejects a bogus assertion', async () => {
const app = await buildTestApp()
const options = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login/options',
})
expect(options.statusCode).toBe(200)
const body = options.json() as { challenge_id: string; options: unknown }
expect(body.challenge_id).toBeTruthy()
const login = await app.inject({
method: 'POST',
url: '/api/v1/webauthn/login',
payload: {
challenge_id: body.challenge_id,
response: { id: 'not-a-credential', type: 'public-key' },
},
})
expect(login.statusCode).toBe(401)
await app.close()
})
it('lists, deletes own credentials and reports passkey_count', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'dGVzdC1jcmVkLWlk',
publicKey: 'dGVzdC1wdWJrZXk',
counter: 0,
name: 'Test key',
})
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.statusCode).toBe(200)
const creds = listed.json() as { id: string; name: string }[]
expect(creds).toHaveLength(1)
expect(creds[0]?.name).toBe('Test key')
const users = await app.inject({
method: 'GET',
url: '/api/v1/admin/users',
headers: { authorization: `Bearer ${token}` },
})
const admin = (
users.json() as { email: string; passkey_count: number }[]
).find((u) => u.email === 'admin@test.local')
expect(admin?.passkey_count).toBe(1)
const renamed = await app.inject({
method: 'PATCH',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
payload: { name: 'Laptop' },
})
expect(renamed.statusCode).toBe(200)
expect((renamed.json() as { name: string }).name).toBe('Laptop')
const deleted = await app.inject({
method: 'DELETE',
url: `/api/v1/webauthn/credentials/${creds[0]!.id}`,
headers: { authorization: `Bearer ${token}` },
})
expect(deleted.statusCode).toBe(200)
const empty = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(empty.json()).toEqual([])
await app.close()
})
it('lets admin reset a user passkeys', async () => {
const app = await buildTestApp()
const token = await adminToken(app)
const me = await app.inject({
method: 'GET',
url: '/api/v1/auth/me',
headers: { authorization: `Bearer ${token}` },
})
const userId = (me.json() as { id: string }).id
createWebauthnCredential(app.db, {
userId,
credentialId: 'cmVzZXQta2V5',
publicKey: 'cHVia2V5',
counter: 1,
name: 'To reset',
})
const reset = await app.inject({
method: 'DELETE',
url: `/api/v1/admin/users/${userId}/passkeys`,
headers: { authorization: `Bearer ${token}` },
})
expect(reset.statusCode).toBe(200)
expect(reset.json()).toMatchObject({ ok: true, removed: 1 })
const listed = await app.inject({
method: 'GET',
url: '/api/v1/webauthn/credentials',
headers: { authorization: `Bearer ${token}` },
})
expect(listed.json()).toEqual([])
await app.close()
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src",
"ignoreDeprecations": "6.0",
"types": ["node"]
},
"include": ["src"]
}
+27
View File
@@ -0,0 +1,27 @@
import { defineConfig } from 'tsup'
// Нативные модули остаются внешними (ставятся в образ отдельно),
// всё остальное (fastify, jose, drizzle, workspace-пакеты) бандлится в один файл.
const NATIVE = ['better-sqlite3', '@node-rs/argon2']
export default defineConfig({
entry: ['src/server.ts'],
format: ['esm'],
target: 'node22',
external: NATIVE,
noExternal: [/^(?!better-sqlite3$|@node-rs\/argon2).+/],
// CJS-зависимости внутри ESM-бандла используют require/__dirname.
banner: {
js: [
"import { createRequire as __cr } from 'node:module';",
"import { fileURLToPath as __fu } from 'node:url';",
"import { dirname as __dn } from 'node:path';",
'const require = __cr(import.meta.url);',
'const __filename = __fu(import.meta.url);',
'const __dirname = __dn(__filename);',
].join('\n'),
},
// Один файл: динамические import() (напр. CLI backup) не должны порождать чанки.
splitting: false,
clean: true,
})
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
passWithNoTests: true,
include: ['test/**/*.test.ts'],
coverage: {
provider: 'v8',
include: ['src/**/*.ts'],
reporter: ['text-summary', 'lcov'],
// Стартовые пороги: поднимать по мере роста покрытия, не снижать.
thresholds: { lines: 75 },
},
},
})