feat(auth): enhance JWT claims and app switcher configuration
- Added support for optional tenant IDs in JWT claims for user permissions. - Updated auth routes to include tenant information in the JWT payload. - Enhanced app switcher configuration to handle tenant IDs without exposing them publicly. - Improved documentation for EvoBGP tenant ID integration and its usage in JWT.
This commit is contained in:
@@ -25,6 +25,8 @@ declare module '@fastify/jwt' {
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
@@ -34,6 +36,8 @@ declare module '@fastify/jwt' {
|
||||
name: string
|
||||
apps: string[]
|
||||
permissions: string[]
|
||||
tenants?: Record<string, string>
|
||||
bgp_tenant_id?: string
|
||||
is_admin?: boolean
|
||||
iss: string
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import { hash, verify } from '@node-rs/argon2'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
PERMISSION_CATALOG,
|
||||
allPermissionKeys,
|
||||
appsMetaFromSwitcher,
|
||||
loginRequestSchema,
|
||||
normalizePermissionKeys,
|
||||
publicAppSwitcherConfig,
|
||||
tenantsClaimForUser,
|
||||
type LoginResponse,
|
||||
} from '@authportal/shared'
|
||||
import {
|
||||
@@ -53,9 +56,16 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
const apps = getUserApps(app.db, user.id)
|
||||
const permissions = normalizePermissionKeys(
|
||||
let permissions = normalizePermissionKeys(
|
||||
getUserPermissions(app.db, user.id),
|
||||
)
|
||||
// Portal admin gets full catalog in JWT so apps can rely on permissions
|
||||
// even when UI also checks is_admin.
|
||||
if (user.isAdmin) {
|
||||
permissions = allPermissionKeys()
|
||||
}
|
||||
const switcher = getAppSwitcherConfig(app.db)
|
||||
const tenants = tenantsClaimForUser(switcher, apps)
|
||||
const me = toMe(user, apps, permissions)
|
||||
|
||||
const expiresAt = new Date(
|
||||
@@ -68,6 +78,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
name: user.name,
|
||||
apps,
|
||||
permissions,
|
||||
tenants,
|
||||
bgp_tenant_id: tenants.bgp,
|
||||
is_admin: user.isAdmin,
|
||||
iss: app.config.issuer,
|
||||
},
|
||||
@@ -109,7 +121,9 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
})
|
||||
|
||||
/** Public — apps chrome (CFDM/VPS) fetch switcher URLs without portal JWT. */
|
||||
app.get('/api/v1/app-switcher', async () => getAppSwitcherConfig(app.db))
|
||||
app.get('/api/v1/app-switcher', async () =>
|
||||
publicAppSwitcherConfig(getAppSwitcherConfig(app.db)),
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/api/v1/auth/me',
|
||||
|
||||
@@ -92,6 +92,22 @@ export function AppSwitcherAdminEditor({
|
||||
placeholder="https://…"
|
||||
/>
|
||||
</Field>
|
||||
{appId === 'bgp' ? (
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`tenant-${appId}`}>
|
||||
EvoBGP tenant ID
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id={`tenant-${appId}`}
|
||||
{...form.register(`apps.${appIndex}.tenantId`)}
|
||||
placeholder="UUID тенанта из EvoBGP"
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Попадает в JWT (`bgp_tenant_id` / `tenants.bgp`). Публичный
|
||||
switcher его не отдаёт.
|
||||
</p>
|
||||
</Field>
|
||||
) : null}
|
||||
<Field className="sm:col-span-2">
|
||||
<FieldLabel htmlFor={`subtitle-${appId}`}>
|
||||
Описание
|
||||
|
||||
@@ -69,7 +69,8 @@ AUTH_REQUIRED=true
|
||||
AUTH_JWT_SECRET=dev-secret-change-me
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=http://localhost:5175
|
||||
EVOBGP_PORTAL_TENANT_ID=<uuid tenant>
|
||||
# Опционально, если tenant не задан в portal /admin/apps для bgp:
|
||||
# EVOBGP_PORTAL_TENANT_ID=<uuid tenant>
|
||||
```
|
||||
|
||||
```env
|
||||
@@ -78,10 +79,12 @@ VITE_AUTH_ENABLED=true
|
||||
VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
```
|
||||
|
||||
В portal **Админ → Приложения → BGP** укажите **EvoBGP tenant ID** (UUID из БД / лога `DemoIDs` / API-ключа). Он попадёт в JWT как `bgp_tenant_id`. Portal `is_admin` получает полный каталог `bgp:*` в JWT.
|
||||
|
||||
## App Switcher
|
||||
|
||||
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher`.
|
||||
`CURRENT_APP_ID = bgp`. Редактор ссылок — portal `/admin/apps`.
|
||||
Публичный конфиг: `GET {AUTH_PORTAL_URL}/api/v1/app-switcher` (без `tenantId`).
|
||||
`CURRENT_APP_ID = bgp`. Редактор ссылок и tenant — portal `/admin/apps`.
|
||||
|
||||
## Logout (SSO)
|
||||
|
||||
@@ -93,6 +96,6 @@ VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||
|---------|---------|
|
||||
| 401 на API | Нет/битый Bearer; разные `JWT_SECRET` |
|
||||
| 403 нет доступа к приложению | В portal не выдан app `bgp` |
|
||||
| 403 на раздел | Нет `bgp:<section>:…` |
|
||||
| JWT без tenant | Не задан `EVOBGP_PORTAL_TENANT_ID` |
|
||||
| 403 на раздел | Нет `bgp:<section>:…` (у admin после обновления портала — полный каталог; перелогиньтесь) |
|
||||
| portal tenant not configured | Нет tenant в JWT и нет `EVOBGP_PORTAL_TENANT_ID` |
|
||||
| return_to rejected | origin EvoBGP не в `RETURN_TO_ALLOWLIST` |
|
||||
|
||||
@@ -18,6 +18,8 @@ export const appSwitcherEntrySchema = z.object({
|
||||
shortcut: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
sort: z.number().int().optional(),
|
||||
/** App-scoped tenant (e.g. EvoBGP UUID) — goes into JWT, not public switcher. */
|
||||
tenantId: z.string().optional(),
|
||||
})
|
||||
|
||||
export const appSwitcherConfigSchema = z.object({
|
||||
@@ -73,6 +75,7 @@ export function normalizeAppSwitcherConfig(
|
||||
sort: existing?.sort ?? index,
|
||||
enabled: existing?.enabled ?? true,
|
||||
icon: existing?.icon ?? fallback.icon,
|
||||
tenantId: existing?.tenantId?.trim() || undefined,
|
||||
}
|
||||
}).sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))
|
||||
|
||||
@@ -82,6 +85,32 @@ export function normalizeAppSwitcherConfig(
|
||||
}
|
||||
}
|
||||
|
||||
/** Public GET must not expose tenant IDs. */
|
||||
export function publicAppSwitcherConfig(
|
||||
config: AppSwitcherConfig,
|
||||
): AppSwitcherConfig {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
return {
|
||||
menuLabel: normalized.menuLabel,
|
||||
apps: normalized.apps.map(({ tenantId: _tid, ...rest }) => rest),
|
||||
}
|
||||
}
|
||||
|
||||
/** Map appId → tenantId for JWT (only apps the user may access). */
|
||||
export function tenantsClaimForUser(
|
||||
config: AppSwitcherConfig,
|
||||
userApps: readonly string[],
|
||||
): Record<string, string> {
|
||||
const allowed = new Set(userApps)
|
||||
const out: Record<string, string> = {}
|
||||
for (const app of normalizeAppSwitcherConfig(config).apps) {
|
||||
if (!allowed.has(app.id)) continue
|
||||
const tid = app.tenantId?.trim()
|
||||
if (tid) out[app.id] = tid
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** AppMeta list with URLs from switcher store (for /apps + catalog). */
|
||||
export function appsMetaFromSwitcher(config: AppSwitcherConfig): AppMeta[] {
|
||||
const normalized = normalizeAppSwitcherConfig(config)
|
||||
|
||||
Reference in New Issue
Block a user