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>