- Added new OIDC configuration options in `.env.example`. - Expanded documentation in `AGENTS.md` to include OIDC endpoints and admin UI. - Updated ReUI skill version and component count from 17 to 20 across various documentation files. - Enhanced `README.md` and other related files to reflect the new component structure and usage guidelines. Co-authored-by: Cursor <[email protected]>
157 lines
4.4 KiB
TypeScript
157 lines
4.4 KiB
TypeScript
import type { FastifyInstance } from 'fastify'
|
|
import Fastify from 'fastify'
|
|
import cors from '@fastify/cors'
|
|
import helmet from '@fastify/helmet'
|
|
import rateLimit from '@fastify/rate-limit'
|
|
import sensible from '@fastify/sensible'
|
|
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 {
|
|
createDb,
|
|
getUserApps,
|
|
healthCheck,
|
|
migrateSchema,
|
|
setUserAccess,
|
|
users,
|
|
type AppDb,
|
|
type Sqlite,
|
|
} from '@authportal/db'
|
|
import { hash } from '@node-rs/argon2'
|
|
import { randomUUID } from 'node:crypto'
|
|
import 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 { startAuditRetentionJob } from './services/audit-retention.js'
|
|
import { ensureOidcSigningKey, resetOidcKeyCache } from './lib/oidc/keys.js'
|
|
|
|
declare module 'fastify' {
|
|
interface FastifyInstance {
|
|
config: AppConfig
|
|
db: AppDb
|
|
sqlite: Sqlite
|
|
}
|
|
}
|
|
|
|
async function ensureBootstrapAdmin(app: FastifyInstance): Promise<void> {
|
|
const existing = app.db.select().from(users).all()
|
|
if (existing.length === 0) {
|
|
const now = new Date().toISOString()
|
|
const passwordHash = await hash(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 existing) {
|
|
if (!user.isAdmin) continue
|
|
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 })
|
|
}
|
|
|
|
const { db, sqlite } = createDb(dbUrl)
|
|
migrateSchema(sqlite)
|
|
|
|
const app = Fastify({
|
|
logger: { level: config.logLevel },
|
|
trustProxy: true,
|
|
})
|
|
|
|
app.decorate('config', config)
|
|
app.decorate('db', db)
|
|
app.decorate('sqlite', sqlite)
|
|
|
|
await app.register(sensible)
|
|
await app.register(formbody)
|
|
await app.register(cors, { origin: true, credentials: true })
|
|
await app.register(helmet, { contentSecurityPolicy: false })
|
|
await app.register(rateLimit, { max: 200, timeWindow: '1 minute' })
|
|
await app.register(fjwt, { secret: config.jwtSecret })
|
|
|
|
app.get('/health', async () => {
|
|
healthCheck(sqlite)
|
|
return { ok: true }
|
|
})
|
|
|
|
app.get('/ready', async () => {
|
|
healthCheck(sqlite)
|
|
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)
|
|
|
|
if (process.env.NODE_ENV !== 'test') {
|
|
const stopRetention = startAuditRetentionJob(app)
|
|
app.addHook('onClose', async () => {
|
|
stopRetention()
|
|
})
|
|
}
|
|
|
|
if (config.staticDir && existsSync(config.staticDir)) {
|
|
await app.register(fastifyStatic, {
|
|
root: resolve(config.staticDir),
|
|
wildcard: false,
|
|
})
|
|
app.setNotFoundHandler((req, reply) => {
|
|
if (req.method === 'GET' && !req.url.startsWith('/api')) {
|
|
return reply.sendFile('index.html')
|
|
}
|
|
return reply
|
|
.status(404)
|
|
.send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
|
})
|
|
}
|
|
|
|
return app
|
|
}
|