perf(api): горячие пути OIDC и батч-запросы

- кэш importJWK: не импортировать RSA-ключ на каждый /oauth/userinfo
- Cache-Control для /.well-known/openid-configuration и jwks.json
- индекс refresh_sessions(token_hash) — было full scan на каждом authorize/logout
- GET /admin/users: 3 SELECT на пользователя → батч-запросы (N+1)
- revokeAllSessionsForUser: цикл UPDATE → один UPDATE
- preCompressed статики (файлы готовит Dockerfile)
This commit is contained in:
Denozordec
2026-09-23 14:51:14 +07:00
parent fd7d9fd13c
commit 87f35631b4
7 changed files with 121 additions and 38 deletions
+3
View File
@@ -143,6 +143,9 @@ export async function buildApp(opts: {
await app.register(fastifyStatic, {
root: resolve(config.staticDir),
wildcard: false,
// Traefik перед приложением не сжимает; .gz/.br для статики готовит
// Dockerfile на этапе сборки (find … gzip/brotli), здесь только отдаём.
preCompressed: true,
})
app.setNotFoundHandler((req, reply) => {
if (req.method === 'GET' && !req.url.startsWith('/api')) {
+12 -5
View File
@@ -7,7 +7,6 @@ import {
jwtVerify,
SignJWT,
type JWK,
type KeyLike,
} from 'jose'
import { createHash, randomUUID } from 'node:crypto'
import type { FastifyInstance } from 'fastify'
@@ -20,11 +19,13 @@ import { oidcIssuerFromConfig, type AppConfig } from '../../config.js'
export type OidcKeyMaterial = {
kid: string
privateKey: KeyLike
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 {
@@ -115,6 +116,7 @@ export async function ensureOidcSigningKey(
/** Reset cache (tests). */
export function resetOidcKeyCache(): void {
cached = null
importedPublicKeys.clear()
}
export function buildOidcDiscovery(config: AppConfig) {
@@ -156,7 +158,7 @@ export async function buildJwks(
): Promise<{ keys: JWK[] }> {
await ensureOidcSigningKey(app)
const keys = listOidcSigningKeys(app.db)
.map((row) => {
.map((row): JWK | null => {
try {
const jwk = JSON.parse(row.publicJwkJson) as JWK
return { ...jwk, kid: row.kid, alg: 'RS256', use: 'sig' }
@@ -164,7 +166,7 @@ export async function buildJwks(
return null
}
})
.filter((k): k is JWK => k != null)
.filter((k): k is JWK => k !== null)
if (cached && !keys.some((k) => k.kid === cached!.kid)) {
keys.unshift(cached.publicJwk)
@@ -194,7 +196,12 @@ export async function verifyOidcAccessToken(
): Promise<Record<string, unknown>> {
const key = await ensureOidcSigningKey(app)
const issuer = oidcIssuerFromConfig(app.config)
const publicKey = await importJWK(key.publicJwk, 'RS256')
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'],
+44 -19
View File
@@ -1,29 +1,32 @@
import type { FastifyInstance } from 'fastify'
import { hash } from '@node-rs/argon2'
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,
updateUser,
createOidcClient,
deleteOidcClient,
generateOidcClientSecret,
getOidcClientById,
listOidcClients,
parseJsonStringArray,
updateOidcClient,
countWebauthnCredentials,
deleteWebauthnCredentialsForUser,
updateUser,
} from '@authportal/db'
import {
APP_IDS,
@@ -46,30 +49,40 @@ import { oidcIssuerFromConfig } from '../config.js'
const allowedPermissions = new Set(allPermissionKeys())
function mapUser(
db: FastifyInstance['db'],
function toAdminUser(
user: NonNullable<ReturnType<typeof getUserById>>,
apps: string[],
permissions: string[],
passkeyCount: number,
): AdminUser {
const apps = getUserApps(db, user.id).filter((a): a is AppId =>
(APP_IDS as readonly string[]).includes(a),
)
const permissions = normalizePermissionKeys(getUserPermissions(db, user.id))
return {
id: user.id,
email: user.email,
name: user.name,
is_admin: user.isAdmin,
disabled: user.disabled,
apps,
permissions,
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: countWebauthnCredentials(db, user.id),
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)) {
@@ -106,7 +119,19 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
})
app.get('/api/v1/admin/users', async () => {
return listUsers(app.db).map((u) => mapUser(app.db, u))
// Батч-запросы вместо 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 } }>(
+9 -4
View File
@@ -160,11 +160,16 @@ function verifyPkce(
export async function oidcRoutes(app: FastifyInstance): Promise<void> {
await ensureOidcSigningKey(app)
app.get('/.well-known/openid-configuration', async () =>
buildOidcDiscovery(app.config),
)
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 () => buildJwks(app))
app.get('/.well-known/jwks.json', async (_request, reply) => {
reply.header('Cache-Control', 'public, max-age=300')
return buildJwks(app)
})
app.get('/oauth/authorize', async (request, reply) => {
const q = request.query as Record<string, string | undefined>
+1
View File
@@ -91,6 +91,7 @@ export function migrateSchema(sqlite: Sqlite): void {
CREATE INDEX IF NOT EXISTS idx_user_apps_user ON user_apps(user_id);
CREATE INDEX IF NOT EXISTS idx_user_permissions_user ON user_permissions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_user ON refresh_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_sessions_token ON refresh_sessions(token_hash);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_user_id, created_at);
+38 -9
View File
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm'
import { and, eq, gt, isNull } from 'drizzle-orm'
import { createHash, randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import {
@@ -51,6 +51,30 @@ export function getUserPermissions(db: AppDb, userId: string): string[] {
.map((r) => r.permission)
}
/** Apps всех пользователей одним запросом — для списков без N+1. */
export function listUserAppsBatch(db: AppDb): Map<string, string[]> {
const rows = db.select().from(userApps).all()
const byUser = new Map<string, string[]>()
for (const row of rows) {
const list = byUser.get(row.userId)
if (list) list.push(row.appId)
else byUser.set(row.userId, [row.appId])
}
return byUser
}
/** Permissions всех пользователей одним запросом — для списков без N+1. */
export function listUserPermissionsBatch(db: AppDb): Map<string, string[]> {
const rows = db.select().from(userPermissions).all()
const byUser = new Map<string, string[]>()
for (const row of rows) {
const list = byUser.get(row.userId)
if (list) list.push(row.permission)
else byUser.set(row.userId, [row.permission])
}
return byUser
}
export function createUser(
db: AppDb,
input: {
@@ -263,12 +287,17 @@ export function revokeSessionById(db: AppDb, sessionId: string): boolean {
export function revokeAllSessionsForUser(db: AppDb, userId: string): number {
const now = new Date().toISOString()
const active = listActiveSessions(db, { userId })
for (const s of active) {
db.update(refreshSessions)
.set({ revokedAt: now })
.where(eq(refreshSessions.id, s.id))
.run()
}
return active.length
// Активная сессия = не отозвана и не истекла; один UPDATE вместо цикла.
const result = db
.update(refreshSessions)
.set({ revokedAt: now })
.where(
and(
eq(refreshSessions.userId, userId),
isNull(refreshSessions.revokedAt),
gt(refreshSessions.expiresAt, now),
),
)
.run()
return result.changes
}
+14 -1
View File
@@ -1,4 +1,4 @@
import { and, eq, lt } from 'drizzle-orm'
import { and, eq, lt, sql } from 'drizzle-orm'
import { randomUUID } from 'node:crypto'
import type { AppDb } from './index.js'
import { webauthnChallenges, webauthnCredentials } from './schema/index.js'
@@ -85,6 +85,19 @@ export function countWebauthnCredentials(db: AppDb, userId: string): number {
return listWebauthnCredentials(db, userId).length
}
/** Число passkey-ов по всем пользователям одним GROUP BY — для списков без N+1. */
export function countWebauthnCredentialsBatch(db: AppDb): Map<string, number> {
const rows = db
.select({
userId: webauthnCredentials.userId,
total: sql<number>`count(*)`,
})
.from(webauthnCredentials)
.groupBy(webauthnCredentials.userId)
.all()
return new Map(rows.map((r) => [r.userId, Number(r.total)]))
}
export function getWebauthnCredentialById(
db: AppDb,
id: string,