feat(reui): update ReUI components and documentation
- 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]>
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
"@authportal/db": "workspace:*",
|
||||
"@authportal/shared": "workspace:*",
|
||||
"@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",
|
||||
@@ -21,6 +22,7 @@
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"fastify": "^5.4.0",
|
||||
"fastify-plugin": "^5.0.1",
|
||||
"jose": "^6.2.8",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -4,6 +4,7 @@ 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'
|
||||
@@ -29,7 +30,9 @@ 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 {
|
||||
@@ -100,6 +103,7 @@ export async function buildApp(opts: {
|
||||
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' })
|
||||
@@ -117,11 +121,14 @@ export async function buildApp(opts: {
|
||||
|
||||
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)
|
||||
|
||||
+12
-1
@@ -11,6 +11,10 @@ export const configSchema = z.object({
|
||||
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('[email protected]'),
|
||||
adminPassword: z.string().default('admin'),
|
||||
adminName: z.string().default('Admin'),
|
||||
@@ -30,13 +34,16 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
const auditIngestSecret =
|
||||
env.AUDIT_INGEST_SECRET ??
|
||||
(isProd ? undefined : 'dev-audit-ingest-secret')
|
||||
const issuer = env.ISSUER ?? 'https://auth.shnt.top'
|
||||
|
||||
return configSchema.parse({
|
||||
databaseUrl: env.DATABASE_URL ?? 'sqlite:data/app.db',
|
||||
jwtSecret,
|
||||
jwtTtlHours: env.JWT_TTL_HOURS ?? 1,
|
||||
refreshTtlDays: env.REFRESH_TTL_DAYS ?? 14,
|
||||
issuer: env.ISSUER ?? 'https://auth.shnt.top',
|
||||
issuer,
|
||||
oidcIssuer: env.OIDC_ISSUER || issuer,
|
||||
oidcRsaPrivateKey: env.OIDC_RSA_PRIVATE_KEY || undefined,
|
||||
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
|
||||
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
|
||||
adminName: env.ADMIN_NAME ?? 'Admin',
|
||||
@@ -51,3 +58,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
||||
auditIngestSecret,
|
||||
})
|
||||
}
|
||||
|
||||
export function oidcIssuerFromConfig(config: AppConfig): string {
|
||||
return (config.oidcIssuer ?? config.issuer).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import {
|
||||
exportJWK,
|
||||
exportPKCS8,
|
||||
generateKeyPair,
|
||||
importJWK,
|
||||
importPKCS8,
|
||||
jwtVerify,
|
||||
SignJWT,
|
||||
type JWK,
|
||||
type KeyLike,
|
||||
} 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: KeyLike
|
||||
publicJwk: JWK
|
||||
}
|
||||
|
||||
let cached: OidcKeyMaterial | null = null
|
||||
|
||||
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,
|
||||
): Promise<OidcKeyMaterial> {
|
||||
const privateKey = await importPKCS8(privatePem, 'RS256')
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
/** Reset cache (tests). */
|
||||
export function resetOidcKeyCache(): void {
|
||||
cached = null
|
||||
}
|
||||
|
||||
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 keys = listOidcSigningKeys(app.db)
|
||||
.map((row) => {
|
||||
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 }
|
||||
}
|
||||
|
||||
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)
|
||||
const publicKey = await importJWK(key.publicJwk, 'RS256')
|
||||
const { payload } = await jwtVerify(token, publicKey, {
|
||||
issuer,
|
||||
algorithms: ['RS256'],
|
||||
})
|
||||
return payload as Record<string, unknown>
|
||||
}
|
||||
@@ -31,6 +31,13 @@ export function targetAppFromReturnTo(
|
||||
) {
|
||||
return 'fw'
|
||||
}
|
||||
if (
|
||||
/\bdns\b/.test(hay) ||
|
||||
host.includes('technitium') ||
|
||||
path.includes('/sso/')
|
||||
) {
|
||||
return 'dns'
|
||||
}
|
||||
return 'portal'
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,32 @@ import {
|
||||
setAppSwitcherConfig,
|
||||
setUserAccess,
|
||||
updateUser,
|
||||
createOidcClient,
|
||||
deleteOidcClient,
|
||||
generateOidcClientSecret,
|
||||
getOidcClientById,
|
||||
listOidcClients,
|
||||
parseJsonStringArray,
|
||||
updateOidcClient,
|
||||
} 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())
|
||||
|
||||
@@ -69,6 +81,21 @@ function validateAccess(apps: string[], permissions: string[]): string | null {
|
||||
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
|
||||
@@ -361,4 +388,116 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
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 = await hash(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 = await hash(secret)
|
||||
const row = updateOidcClient(app.db, request.params.id, {
|
||||
clientSecretHash: secretHash,
|
||||
})!
|
||||
return { ...mapOidcClient(row), client_secret: secret }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { verify } from '@node-rs/argon2'
|
||||
import {
|
||||
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 {
|
||||
buildJwks,
|
||||
buildOidcDiscovery,
|
||||
ensureOidcSigningKey,
|
||||
signOidcJwt,
|
||||
verifyOidcAccessToken,
|
||||
} from '../lib/oidc/keys.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 cookieValue(
|
||||
cookieHeader: string | undefined,
|
||||
name: string,
|
||||
): string | null {
|
||||
if (!cookieHeader) return null
|
||||
const match = cookieHeader.match(new RegExp(`${name}=([^;]+)`))
|
||||
return match?.[1] ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
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(
|
||||
client: OidcClientRow,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
return await verify(client.clientSecretHash, secret)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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 = cookieValue(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 () =>
|
||||
buildOidcDiscovery(app.config),
|
||||
)
|
||||
|
||||
app.get('/.well-known/jwks.json', async () => buildJwks(app))
|
||||
|
||||
app.get('/oauth/authorize', 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 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(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', 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)
|
||||
})
|
||||
}
|
||||
@@ -19,9 +19,13 @@ describe('app-switcher API', () => {
|
||||
expect(body.apps.map((a) => a.id).sort()).toEqual([
|
||||
'bgp',
|
||||
'cfdm',
|
||||
'dns',
|
||||
'fw',
|
||||
'vps',
|
||||
])
|
||||
expect(body.apps.find((a) => a.id === 'dns')).toMatchObject({
|
||||
authMode: 'oidc',
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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'
|
||||
|
||||
async function buildTestApp() {
|
||||
resetOidcKeyCache()
|
||||
const config = loadConfig({
|
||||
...process.env,
|
||||
JWT_SECRET: 'test-secret-at-least-8',
|
||||
ADMIN_EMAIL: '[email protected]',
|
||||
ADMIN_PASSWORD: 'adminpass',
|
||||
DATABASE_URL: 'sqlite::memory:',
|
||||
ISSUER: 'https://auth.test.local',
|
||||
OIDC_ISSUER: 'https://auth.test.local',
|
||||
NODE_ENV: 'test',
|
||||
})
|
||||
return buildApp({ config, databaseUrl: 'sqlite::memory:' })
|
||||
}
|
||||
|
||||
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('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: '[email protected]', 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('[email protected]')
|
||||
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('[email protected]')
|
||||
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: '[email protected]', 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user