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()
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
AppWindowIcon,
|
||||
HistoryIcon,
|
||||
KeyRoundIcon,
|
||||
LayoutGridIcon,
|
||||
LogInIcon,
|
||||
UsersIcon,
|
||||
@@ -68,7 +69,8 @@ export function AppSidebar() {
|
||||
isActive(pathname, '/admin', false) &&
|
||||
!pathname.startsWith('/admin/apps') &&
|
||||
!pathname.startsWith('/admin/audit') &&
|
||||
!pathname.startsWith('/admin/logins')
|
||||
!pathname.startsWith('/admin/logins') &&
|
||||
!pathname.startsWith('/admin/oidc')
|
||||
}
|
||||
render={<Link to="/admin" />}
|
||||
>
|
||||
@@ -106,6 +108,16 @@ export function AppSidebar() {
|
||||
<span>Ссылки приложений</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
tooltip="OIDC"
|
||||
isActive={isActive(pathname, '/admin/oidc', false)}
|
||||
render={<Link to="/admin/oidc" />}
|
||||
>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
<span>OIDC-клиенты</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
@@ -91,7 +91,7 @@ export function AppSwitcher() {
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
onClick={() => {
|
||||
void ssoOpenApp(app.url).catch(() => {
|
||||
void ssoOpenApp(app.url, app.authMode ?? 'jwt').catch(() => {
|
||||
window.location.href = app.url.replace(/\/$/, '')
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ServerIcon,
|
||||
NetworkIcon,
|
||||
ShieldIcon,
|
||||
GlobeIcon,
|
||||
} from 'lucide-react'
|
||||
import { APPS, type AppId } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
@@ -26,6 +27,7 @@ const APP_ICONS: Record<
|
||||
vps: ServerIcon,
|
||||
bgp: NetworkIcon,
|
||||
fw: ShieldIcon,
|
||||
dns: GlobeIcon,
|
||||
}
|
||||
|
||||
export function AppsMenu() {
|
||||
|
||||
@@ -16,6 +16,9 @@ function breadcrumbs(pathname: string) {
|
||||
if (pathname.startsWith('/admin/apps')) {
|
||||
return [{ label: 'Ссылки приложений', href: '/admin/apps' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/oidc')) {
|
||||
return [{ label: 'OIDC-клиенты', href: '/admin/oidc' }]
|
||||
}
|
||||
if (pathname.startsWith('/admin/logins')) {
|
||||
return [{ label: 'Журнал входов', href: '/admin/logins' }]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate, useSearch } from '@tanstack/react-router'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { EyeIcon, EyeOffIcon } from 'lucide-react'
|
||||
import { buildSsoRedirectUrl, isReturnToAllowed } from '@authportal/shared'
|
||||
import { buildSsoRedirectUrl, isPortalOidcAuthorizeUrl, isReturnToAllowed } from '@authportal/shared'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
AlertDescription,
|
||||
AlertTitle,
|
||||
} from '@/components/reui/alert'
|
||||
import { ensureReturnToAllowlist, setToken } from '@/lib/auth'
|
||||
import { ensureAuthConfig, setToken } from '@/lib/auth'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import { login, meQueryKey } from '@/queries/auth'
|
||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
||||
@@ -43,8 +43,12 @@ export function PortalLoginForm() {
|
||||
setToken(res.access_token)
|
||||
queryClient.setQueryData(meQueryKey, res.user)
|
||||
|
||||
const allowlist = await ensureReturnToAllowlist()
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return
|
||||
}
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
returnTo,
|
||||
res.access_token,
|
||||
|
||||
@@ -42,6 +42,7 @@ export const SOURCE_APP_OPTIONS: {
|
||||
{ value: 'cfdm', label: 'CFDM' },
|
||||
{ value: 'bgp', label: 'EvoBGP' },
|
||||
{ value: 'fw', label: 'EvoFirewall' },
|
||||
{ value: 'dns', label: 'Technitium DNS' },
|
||||
]
|
||||
|
||||
export const severityVariant: Record<AuditSeverity, BadgeProps['variant']> = {
|
||||
|
||||
+54
-11
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
buildSsoRedirectUrl,
|
||||
isJwtExpired,
|
||||
isPortalOidcAuthorizeUrl,
|
||||
isReturnToAllowed,
|
||||
readJwtPayload,
|
||||
type AppAuthMode,
|
||||
type LoginResponse,
|
||||
type MeResponse,
|
||||
} from '@authportal/shared'
|
||||
@@ -14,11 +16,23 @@ export const DEFAULT_RETURN_TO_ALLOWLIST =
|
||||
'.shnt.top,localhost,private,http://localhost:5173'
|
||||
|
||||
let returnToAllowlist: string | null = null
|
||||
let issuerUrl: string | null = null
|
||||
let returnToAllowlistPromise: Promise<string> | null = null
|
||||
|
||||
export async function ensureReturnToAllowlist(): Promise<string> {
|
||||
if (returnToAllowlist) return returnToAllowlist
|
||||
if (returnToAllowlistPromise) return returnToAllowlistPromise
|
||||
export async function ensureAuthConfig(): Promise<{
|
||||
returnToAllowlist: string
|
||||
issuer: string
|
||||
}> {
|
||||
if (returnToAllowlist && issuerUrl) {
|
||||
return { returnToAllowlist, issuer: issuerUrl }
|
||||
}
|
||||
if (returnToAllowlistPromise) {
|
||||
await returnToAllowlistPromise
|
||||
return {
|
||||
returnToAllowlist: returnToAllowlist ?? DEFAULT_RETURN_TO_ALLOWLIST,
|
||||
issuer: issuerUrl ?? 'https://auth.shnt.top',
|
||||
}
|
||||
}
|
||||
|
||||
returnToAllowlistPromise = (async () => {
|
||||
const fromVite = import.meta.env.VITE_RETURN_TO_ALLOWLIST as
|
||||
@@ -27,22 +41,38 @@ export async function ensureReturnToAllowlist(): Promise<string> {
|
||||
try {
|
||||
const res = await fetch('/api/v1/auth/config')
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { return_to_allowlist?: string }
|
||||
const data = (await res.json()) as {
|
||||
return_to_allowlist?: string
|
||||
issuer?: string
|
||||
}
|
||||
if (data.return_to_allowlist) {
|
||||
returnToAllowlist = data.return_to_allowlist
|
||||
return returnToAllowlist
|
||||
}
|
||||
if (data.issuer) {
|
||||
issuerUrl = data.issuer
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
returnToAllowlist = fromVite || DEFAULT_RETURN_TO_ALLOWLIST
|
||||
returnToAllowlist =
|
||||
returnToAllowlist || fromVite || DEFAULT_RETURN_TO_ALLOWLIST
|
||||
issuerUrl = issuerUrl || 'https://auth.shnt.top'
|
||||
return returnToAllowlist
|
||||
})().finally(() => {
|
||||
returnToAllowlistPromise = null
|
||||
})
|
||||
|
||||
return returnToAllowlistPromise
|
||||
await returnToAllowlistPromise
|
||||
return {
|
||||
returnToAllowlist: returnToAllowlist!,
|
||||
issuer: issuerUrl!,
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureReturnToAllowlist(): Promise<string> {
|
||||
const cfg = await ensureAuthConfig()
|
||||
return cfg.returnToAllowlist
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
@@ -106,9 +136,16 @@ export async function reissueAccessToken(): Promise<LoginResponse> {
|
||||
return body
|
||||
}
|
||||
|
||||
/** SSO open: fresh JWT → app /auth/callback. */
|
||||
export async function ssoOpenApp(appBaseUrl: string): Promise<void> {
|
||||
/** SSO open: JWT fragment → /auth/callback, or plain URL for OIDC apps. */
|
||||
export async function ssoOpenApp(
|
||||
appBaseUrl: string,
|
||||
authMode: AppAuthMode = 'jwt',
|
||||
): Promise<void> {
|
||||
const base = appBaseUrl.replace(/\/$/, '')
|
||||
if (authMode === 'oidc') {
|
||||
window.location.href = base
|
||||
return
|
||||
}
|
||||
const issued = await reissueAccessToken()
|
||||
const callback = `${base}/auth/callback`
|
||||
window.location.href = buildSsoRedirectUrl(
|
||||
@@ -118,10 +155,16 @@ export async function ssoOpenApp(appBaseUrl: string): Promise<void> {
|
||||
)
|
||||
}
|
||||
|
||||
/** SSO return_to handoff with fresh JWT. */
|
||||
/** SSO return_to handoff with fresh JWT (or clean redirect for OIDC authorize). */
|
||||
export async function ssoHandoffReturnTo(returnTo: string): Promise<boolean> {
|
||||
const allowlist = await ensureReturnToAllowlist()
|
||||
const { returnToAllowlist: allowlist, issuer } = await ensureAuthConfig()
|
||||
if (!isReturnToAllowed(returnTo, allowlist)) return false
|
||||
|
||||
if (isPortalOidcAuthorizeUrl(returnTo, issuer)) {
|
||||
window.location.href = returnTo
|
||||
return true
|
||||
}
|
||||
|
||||
const issued = await reissueAccessToken()
|
||||
try {
|
||||
const token = getToken()
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { queryOptions } from '@tanstack/react-query'
|
||||
import type {
|
||||
CreateOidcClientRequest,
|
||||
OidcClientCreated,
|
||||
OidcClientPublic,
|
||||
PatchOidcClientRequest,
|
||||
} from '@authportal/shared'
|
||||
import { api } from '@/lib/api-client'
|
||||
|
||||
export const oidcClientsQueryKey = ['admin', 'oidc', 'clients'] as const
|
||||
export const oidcMetaQueryKey = ['admin', 'oidc', 'meta'] as const
|
||||
|
||||
export const oidcClientsQueryOptions = queryOptions({
|
||||
queryKey: oidcClientsQueryKey,
|
||||
queryFn: () => api.get<OidcClientPublic[]>('/api/v1/admin/oidc/clients'),
|
||||
})
|
||||
|
||||
export const oidcMetaQueryOptions = queryOptions({
|
||||
queryKey: oidcMetaQueryKey,
|
||||
queryFn: () =>
|
||||
api.get<{
|
||||
issuer: string
|
||||
discovery_url: string
|
||||
jwks_url: string
|
||||
scopes: string[]
|
||||
}>('/api/v1/admin/oidc/meta'),
|
||||
})
|
||||
|
||||
export function createOidcClient(body: CreateOidcClientRequest) {
|
||||
return api.post<OidcClientCreated>('/api/v1/admin/oidc/clients', body)
|
||||
}
|
||||
|
||||
export function patchOidcClient(id: string, body: PatchOidcClientRequest) {
|
||||
return api.patch<OidcClientPublic>(`/api/v1/admin/oidc/clients/${id}`, body)
|
||||
}
|
||||
|
||||
export function deleteOidcClient(id: string) {
|
||||
return api.delete(`/api/v1/admin/oidc/clients/${id}`)
|
||||
}
|
||||
|
||||
export function rotateOidcClientSecret(id: string) {
|
||||
return api.post<OidcClientCreated>(
|
||||
`/api/v1/admin/oidc/clients/${id}/rotate-secret`,
|
||||
)
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { Route as AuthAdminIndexRouteImport } from './routes/_auth.admin.index'
|
||||
import { Route as AuthAdminAppsRouteImport } from './routes/_auth.admin.apps'
|
||||
import { Route as AuthAdminAuditRouteImport } from './routes/_auth.admin.audit'
|
||||
import { Route as AuthAdminLoginsRouteImport } from './routes/_auth.admin.logins'
|
||||
import { Route as AuthAdminOidcRouteImport } from './routes/_auth.admin.oidc'
|
||||
import { Route as AuthAdminUsersUserIdRouteImport } from './routes/_auth.admin.users.$userId'
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
@@ -64,6 +65,11 @@ const AuthAdminLoginsRoute = AuthAdminLoginsRouteImport.update({
|
||||
path: '/logins',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminOidcRoute = AuthAdminOidcRouteImport.update({
|
||||
id: '/oidc',
|
||||
path: '/oidc',
|
||||
getParentRoute: () => AuthAdminRoute,
|
||||
} as any)
|
||||
const AuthAdminUsersUserIdRoute = AuthAdminUsersUserIdRouteImport.update({
|
||||
id: '/users/$userId',
|
||||
path: '/users/$userId',
|
||||
@@ -78,6 +84,7 @@ export interface FileRoutesByFullPath {
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/admin/oidc': typeof AuthAdminOidcRoute
|
||||
'/admin/': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -88,6 +95,7 @@ export interface FileRoutesByTo {
|
||||
'/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/admin/oidc': typeof AuthAdminOidcRoute
|
||||
'/admin': typeof AuthAdminIndexRoute
|
||||
'/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -101,6 +109,7 @@ export interface FileRoutesById {
|
||||
'/_auth/admin/apps': typeof AuthAdminAppsRoute
|
||||
'/_auth/admin/audit': typeof AuthAdminAuditRoute
|
||||
'/_auth/admin/logins': typeof AuthAdminLoginsRoute
|
||||
'/_auth/admin/oidc': typeof AuthAdminOidcRoute
|
||||
'/_auth/admin/': typeof AuthAdminIndexRoute
|
||||
'/_auth/admin/users/$userId': typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -114,6 +123,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/logins'
|
||||
| '/admin/oidc'
|
||||
| '/admin/'
|
||||
| '/admin/users/$userId'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -124,6 +134,7 @@ export interface FileRouteTypes {
|
||||
| '/admin/apps'
|
||||
| '/admin/audit'
|
||||
| '/admin/logins'
|
||||
| '/admin/oidc'
|
||||
| '/admin'
|
||||
| '/admin/users/$userId'
|
||||
id:
|
||||
@@ -136,6 +147,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/admin/apps'
|
||||
| '/_auth/admin/audit'
|
||||
| '/_auth/admin/logins'
|
||||
| '/_auth/admin/oidc'
|
||||
| '/_auth/admin/'
|
||||
| '/_auth/admin/users/$userId'
|
||||
fileRoutesById: FileRoutesById
|
||||
@@ -211,6 +223,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthAdminLoginsRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/oidc': {
|
||||
id: '/_auth/admin/oidc'
|
||||
path: '/oidc'
|
||||
fullPath: '/admin/oidc'
|
||||
preLoaderRoute: typeof AuthAdminOidcRouteImport
|
||||
parentRoute: typeof AuthAdminRoute
|
||||
}
|
||||
'/_auth/admin/users/$userId': {
|
||||
id: '/_auth/admin/users/$userId'
|
||||
path: '/users/$userId'
|
||||
@@ -225,6 +244,7 @@ interface AuthAdminRouteChildren {
|
||||
AuthAdminAppsRoute: typeof AuthAdminAppsRoute
|
||||
AuthAdminAuditRoute: typeof AuthAdminAuditRoute
|
||||
AuthAdminLoginsRoute: typeof AuthAdminLoginsRoute
|
||||
AuthAdminOidcRoute: typeof AuthAdminOidcRoute
|
||||
AuthAdminIndexRoute: typeof AuthAdminIndexRoute
|
||||
AuthAdminUsersUserIdRoute: typeof AuthAdminUsersUserIdRoute
|
||||
}
|
||||
@@ -233,6 +253,7 @@ const AuthAdminRouteChildren: AuthAdminRouteChildren = {
|
||||
AuthAdminAppsRoute: AuthAdminAppsRoute,
|
||||
AuthAdminAuditRoute: AuthAdminAuditRoute,
|
||||
AuthAdminLoginsRoute: AuthAdminLoginsRoute,
|
||||
AuthAdminOidcRoute: AuthAdminOidcRoute,
|
||||
AuthAdminIndexRoute: AuthAdminIndexRoute,
|
||||
AuthAdminUsersUserIdRoute: AuthAdminUsersUserIdRoute,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
/**
|
||||
* Admin OIDC clients — Frame surface.
|
||||
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/data-grid-filtering-2
|
||||
*/
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { PlusIcon, KeyRoundIcon, Trash2Icon, CopyIcon } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import type { OidcClientCreated, OidcClientPublic } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from '@/components/reui/frame'
|
||||
import { Button } from '@authportal/ui/components/button'
|
||||
import { Field, FieldGroup, FieldLabel } from '@authportal/ui/components/field'
|
||||
import { Input } from '@authportal/ui/components/input'
|
||||
import { Switch } from '@authportal/ui/components/switch'
|
||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@authportal/ui/components/sheet'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@authportal/ui/components/alert-dialog'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { ApiError } from '@/lib/api-client'
|
||||
import {
|
||||
createOidcClient,
|
||||
deleteOidcClient,
|
||||
oidcClientsQueryOptions,
|
||||
oidcMetaQueryOptions,
|
||||
rotateOidcClientSecret,
|
||||
} from '@/queries/oidc'
|
||||
|
||||
export const Route = createFileRoute('/_auth/admin/oidc')({
|
||||
component: AdminOidcPage,
|
||||
})
|
||||
|
||||
function AdminOidcPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const { data: clients, isLoading, isError, error } = useQuery(
|
||||
oidcClientsQueryOptions,
|
||||
)
|
||||
const { data: meta } = useQuery(oidcMetaQueryOptions)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [secretOnce, setSecretOnce] = useState<OidcClientCreated | null>(null)
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null)
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: createOidcClient,
|
||||
onSuccess: (created) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
||||
setCreateOpen(false)
|
||||
setSecretOnce(created)
|
||||
toast.success('OIDC-клиент создан')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось создать клиента',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOidcClient,
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'oidc'] })
|
||||
setDeleteId(null)
|
||||
toast.success('Клиент удалён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось удалить',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const rotateMutation = useMutation({
|
||||
mutationFn: rotateOidcClientSecret,
|
||||
onSuccess: (created) => {
|
||||
setSecretOnce(created)
|
||||
toast.success('Секрет обновлён')
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(
|
||||
err instanceof ApiError ? err.message : 'Не удалось обновить секрет',
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div className="flex flex-col gap-px">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">OIDC-клиенты</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Внешние сервисы (Technitium DNS и др.) через OpenID Connect
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={() => setCreateOpen(true)}>
|
||||
<PlusIcon className="size-4" />
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{meta ? (
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Issuer</FrameTitle>
|
||||
<FrameDescription>
|
||||
Metadata для Relying Party
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel className="flex flex-col gap-2 text-sm">
|
||||
<CopyRow label="Issuer" value={meta.issuer} />
|
||||
<CopyRow label="Discovery" value={meta.discovery_url} />
|
||||
<CopyRow label="JWKS" value={meta.jwks_url} />
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
) : null}
|
||||
|
||||
<Frame dense className="w-full">
|
||||
<FrameHeader>
|
||||
<FrameTitle>Клиенты</FrameTitle>
|
||||
<FrameDescription>
|
||||
Confidential clients (Authorization Code)
|
||||
</FrameDescription>
|
||||
</FrameHeader>
|
||||
<FramePanel>
|
||||
{isLoading ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-16 w-full" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<p className="text-destructive text-sm">
|
||||
{error instanceof ApiError
|
||||
? error.message
|
||||
: 'Не удалось загрузить'}
|
||||
</p>
|
||||
) : !clients?.length ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Пока нет OIDC-клиентов. Создайте клиент для Technitium DNS.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{clients.map((c) => (
|
||||
<OidcClientRow
|
||||
key={c.id}
|
||||
client={c}
|
||||
onRotate={() => rotateMutation.mutate(c.id)}
|
||||
onDelete={() => setDeleteId(c.id)}
|
||||
rotating={rotateMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
|
||||
<CreateOidcClientSheet
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
pending={createMutation.isPending}
|
||||
onSubmit={(values) => createMutation.mutate(values)}
|
||||
/>
|
||||
|
||||
<SecretRevealDialog
|
||||
client={secretOnce}
|
||||
onClose={() => setSecretOnce(null)}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteId != null}
|
||||
onOpenChange={(o: boolean) => {
|
||||
if (!o) setDeleteId(null)
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent size="sm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Удалить OIDC-клиент?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Relying Party перестанет получать токены с этим client_id.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant="destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (deleteId) deleteMutation.mutate(deleteId)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
|
||||
function CopyRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-muted-foreground w-24 shrink-0">{label}</span>
|
||||
<code className="bg-muted truncate rounded px-2 py-1 text-xs">
|
||||
{value}
|
||||
</code>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
aria-label={`Копировать ${label}`}
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(value)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OidcClientRow({
|
||||
client,
|
||||
onRotate,
|
||||
onDelete,
|
||||
rotating,
|
||||
}: {
|
||||
client: OidcClientPublic
|
||||
onRotate: () => void
|
||||
onDelete: () => void
|
||||
rotating: boolean
|
||||
}) {
|
||||
return (
|
||||
<li className="border-border flex flex-col gap-2 rounded-lg border p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{client.name}</span>
|
||||
{client.enabled ? (
|
||||
<Badge variant="success-light" size="sm">
|
||||
enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="warning-light" size="sm">
|
||||
disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<code className="text-muted-foreground text-xs">
|
||||
{client.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={rotating}
|
||||
onClick={onRotate}
|
||||
>
|
||||
<KeyRoundIcon className="size-4" />
|
||||
Секрет
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
aria-label="Удалить"
|
||||
>
|
||||
<Trash2Icon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex flex-col gap-1 text-xs">
|
||||
{client.redirect_uris.map((u) => (
|
||||
<span key={u}>{u}</span>
|
||||
))}
|
||||
<span>scopes: {client.scopes.join(' ')}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function CreateOidcClientSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
pending,
|
||||
onSubmit,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange: (o: boolean) => void
|
||||
pending: boolean
|
||||
onSubmit: (v: {
|
||||
name: string
|
||||
redirect_uris: string[]
|
||||
scopes: Array<'openid' | 'profile' | 'email' | 'groups'>
|
||||
enabled: boolean
|
||||
}) => void
|
||||
}) {
|
||||
const [name, setName] = useState('Technitium DNS')
|
||||
const [redirectUris, setRedirectUris] = useState(
|
||||
'https://dns.shnt.top/sso/callback',
|
||||
)
|
||||
const [enabled, setEnabled] = useState(true)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex flex-col gap-4 sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Новый OIDC-клиент</SheetTitle>
|
||||
<SheetDescription>
|
||||
Redirect URI для Technitium:{' '}
|
||||
<code className="text-xs">https://<host>/sso/callback</code>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<FieldGroup className="gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="oidc-name">Название</FieldLabel>
|
||||
<Input
|
||||
id="oidc-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="oidc-redirects">
|
||||
Redirect URIs (по одному на строку)
|
||||
</FieldLabel>
|
||||
<textarea
|
||||
id="oidc-redirects"
|
||||
className="border-input bg-background min-h-24 w-full rounded-md border px-3 py-2 text-sm"
|
||||
value={redirectUris}
|
||||
onChange={(e) => setRedirectUris(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field className="flex flex-row items-center justify-between gap-3">
|
||||
<FieldLabel htmlFor="oidc-enabled">Включён</FieldLabel>
|
||||
<Switch
|
||||
id="oidc-enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={(v: boolean) => setEnabled(v === true)}
|
||||
/>
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
<SheetFooter>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={pending || !name.trim()}
|
||||
onClick={() => {
|
||||
const uris = redirectUris
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
onSubmit({
|
||||
name: name.trim(),
|
||||
redirect_uris: uris,
|
||||
scopes: ['openid', 'profile', 'email', 'groups'],
|
||||
enabled,
|
||||
})
|
||||
}}
|
||||
>
|
||||
{pending ? 'Создание…' : 'Создать'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SecretRevealDialog({
|
||||
client,
|
||||
onClose,
|
||||
}: {
|
||||
client: OidcClientCreated | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const open = client != null
|
||||
const text = useMemo(() => {
|
||||
if (!client) return ''
|
||||
return `client_id: ${client.client_id}\nclient_secret: ${client.client_secret}`
|
||||
}, [client])
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(o: boolean) => {
|
||||
if (!o) onClose()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Сохраните client_secret</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Секрет показывается только один раз. Вставьте его в Technitium SSO.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{client ? (
|
||||
<pre className="bg-muted max-h-40 overflow-auto rounded-md p-3 text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Закрыть</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
void navigator.clipboard.writeText(text)
|
||||
toast.success('Скопировано')
|
||||
}}
|
||||
>
|
||||
Копировать
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { LayoutGridIcon } from 'lucide-react'
|
||||
import type { AppId } from '@authportal/shared'
|
||||
import type { AppAuthMode, AppId } from '@authportal/shared'
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
@@ -21,9 +21,13 @@ export const Route = createFileRoute('/_auth/apps')({
|
||||
component: AppsPage,
|
||||
})
|
||||
|
||||
async function openApp(_appId: AppId, baseUrl: string) {
|
||||
async function openApp(
|
||||
_appId: AppId,
|
||||
baseUrl: string,
|
||||
authMode: AppAuthMode = 'jwt',
|
||||
) {
|
||||
try {
|
||||
await ssoOpenApp(baseUrl)
|
||||
await ssoOpenApp(baseUrl, authMode)
|
||||
} catch {
|
||||
window.location.href = baseUrl.replace(/\/$/, '')
|
||||
}
|
||||
@@ -95,7 +99,14 @@ function AppsPage() {
|
||||
<FrameFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void openApp(app.id, app.url)}
|
||||
onClick={() =>
|
||||
void openApp(
|
||||
app.id,
|
||||
app.url,
|
||||
app.authMode ??
|
||||
(app.id === 'dns' ? 'oidc' : 'jwt'),
|
||||
)
|
||||
}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
|
||||
@@ -28,6 +28,8 @@ export default defineConfig({
|
||||
'/api': 'http://localhost:8080',
|
||||
'/health': 'http://localhost:8080',
|
||||
'/ready': 'http://localhost:8080',
|
||||
'/.well-known': 'http://localhost:8080',
|
||||
'/oauth': 'http://localhost:8080',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user