diff --git a/apps/api/src/lib/issue-access-token.ts b/apps/api/src/lib/issue-access-token.ts new file mode 100644 index 0000000..581c4fc --- /dev/null +++ b/apps/api/src/lib/issue-access-token.ts @@ -0,0 +1,52 @@ +import type { FastifyInstance } from 'fastify' +import { + allPermissionKeys, + normalizePermissionKeys, + tenantsClaimForUser, + type LoginResponse, +} from '@authportal/shared' +import { + getAppSwitcherConfig, + getUserApps, + getUserPermissions, + type UserRow, +} from '@authportal/db' +import { toMe } from '../plugins/auth-guards.js' + +/** Mint access JWT + Me from current DB state (login / reissue). */ +export function issueAccessToken( + app: FastifyInstance, + user: UserRow, +): LoginResponse { + const apps = getUserApps(app.db, user.id) + let permissions = normalizePermissionKeys(getUserPermissions(app.db, user.id)) + 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( + Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000, + ) + const accessToken = app.jwt.sign( + { + sub: user.id, + email: user.email, + name: user.name, + apps, + permissions, + tenants, + bgp_tenant_id: tenants.bgp, + is_admin: user.isAdmin, + iss: app.config.issuer, + }, + { expiresIn: `${app.config.jwtTtlHours}h` }, + ) + return { + access_token: accessToken, + expires_at: expiresAt.toISOString(), + token_type: 'Bearer', + user: me, + } +} diff --git a/apps/api/src/plugins/auth-guards.ts b/apps/api/src/plugins/auth-guards.ts index d1f52d5..c155a6c 100644 --- a/apps/api/src/plugins/auth-guards.ts +++ b/apps/api/src/plugins/auth-guards.ts @@ -6,7 +6,7 @@ import { type UserRow, } from '@authportal/db' import type { AppId, MeResponse } from '@authportal/shared' -import { APP_IDS, normalizePermissionKeys } from '@authportal/shared' +import { APP_IDS, allPermissionKeys, normalizePermissionKeys } from '@authportal/shared' export type AuthUser = { id: string @@ -72,9 +72,12 @@ export function loadAuthUser( user: UserRow, ): AuthUser { const apps = getUserApps(request.server.db, user.id) - const permissions = normalizePermissionKeys( + let permissions = normalizePermissionKeys( getUserPermissions(request.server.db, user.id), ) + if (user.isAdmin) { + permissions = allPermissionKeys() + } return { id: user.id, email: user.email, diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 1af6c58..3d5eb30 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,25 +1,22 @@ import type { FastifyInstance } from 'fastify' -import { hash, verify } from '@node-rs/argon2' +import { 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 { createRefreshSession, getAppSwitcherConfig, - getUserApps, getUserByEmail, - getUserPermissions, + getUserById, revokeRefreshSession, } from '@authportal/db' -import { requireAuth, toMe } from '../plugins/auth-guards.js' +import { requireAuth } from '../plugins/auth-guards.js' +import { issueAccessToken } from '../lib/issue-access-token.js' const REFRESH_COOKIE = 'refresh_token' @@ -55,36 +52,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }) } - const apps = getUserApps(app.db, user.id) - 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( - Date.now() + app.config.jwtTtlHours * 60 * 60 * 1000, - ) - const accessToken = app.jwt.sign( - { - sub: user.id, - email: user.email, - name: user.name, - apps, - permissions, - tenants, - bgp_tenant_id: tenants.bgp, - is_admin: user.isAdmin, - iss: app.config.issuer, - }, - { expiresIn: `${app.config.jwtTtlHours}h` }, - ) + const body = issueAccessToken(app, user) const refreshRaw = randomBytes(32).toString('hex') const refreshExpires = new Date( @@ -97,16 +65,32 @@ export async function authRoutes(app: FastifyInstance): Promise { `${REFRESH_COOKIE}=${refreshRaw}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${app.config.refreshTtlDays * 86400}${app.config.isProd ? '; Secure' : ''}`, ) - const body: LoginResponse = { - access_token: accessToken, - expires_at: expiresAt.toISOString(), - token_type: 'Bearer', - user: me, - } return body }, }) + /** + * Re-mint access JWT from DB (apps/permissions/is_admin/tenants). + * Fixes stale localStorage JWT after role changes or account switch. + */ + app.post( + '/api/v1/auth/reissue', + { + onRequest: requireAuth, + config: { rateLimit: { max: 60, timeWindow: '1 minute' } }, + }, + async (request, reply) => { + const auth = request.authUser! + const user = getUserById(app.db, auth.id) + if (!user || user.disabled) { + return reply.status(401).send({ + error: { code: 'UNAUTHORIZED', message: 'Пользователь недоступен' }, + }) + } + return issueAccessToken(app, user) + }, + ) + app.post('/api/v1/auth/logout', async (request, reply) => { const cookie = request.headers.cookie ?? '' const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`)) diff --git a/apps/web/src/components/app-switcher.tsx b/apps/web/src/components/app-switcher.tsx index 0d9ab43..785cc01 100644 --- a/apps/web/src/components/app-switcher.tsx +++ b/apps/web/src/components/app-switcher.tsx @@ -11,6 +11,7 @@ import { } from 'lucide-react' import type { AppSwitcherIconName } from '@authportal/shared' import { appSwitcherQueryOptions } from '@/queries/app-switcher' +import { ssoOpenApp } from '@/lib/auth' import { DropdownMenu, DropdownMenuContent, @@ -89,8 +90,11 @@ export function AppSwitcher() { return ( } + onClick={() => { + void ssoOpenApp(app.url).catch(() => { + window.location.href = app.url.replace(/\/$/, '') + }) + }} > {app.name} diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index c6f4289..bbcd42a 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -1,4 +1,11 @@ -import { isJwtExpired } from '@authportal/shared' +import { + buildSsoRedirectUrl, + isJwtExpired, + isReturnToAllowed, + readJwtPayload, + type LoginResponse, + type MeResponse, +} from '@authportal/shared' const TOKEN_KEY = 'authportal_token' @@ -55,3 +62,71 @@ export function setToken(token: string) { export function clearToken() { localStorage.removeItem(TOKEN_KEY) } + +/** True when stored JWT claims disagree with live /me (DB). */ +export function jwtClaimsStaleVsMe(token: string, me: MeResponse): boolean { + const payload = readJwtPayload(token) + if (!payload) return true + if (Boolean(payload.is_admin) !== Boolean(me.is_admin)) return true + const claimApps = Array.isArray(payload.apps) + ? payload.apps.map(String).sort().join(',') + : '' + const meApps = [...me.apps].map(String).sort().join(',') + if (claimApps !== meApps) return true + const claimPerms = Array.isArray(payload.permissions) + ? payload.permissions.map(String).sort().join(',') + : '' + const mePerms = [...me.permissions].map(String).sort().join(',') + if (claimPerms !== mePerms) return true + return false +} + +/** + * Re-mint access token from API (current DB rights) and store it. + * Always use before SSO handoff so apps never get a stale user JWT. + */ +export async function reissueAccessToken(): Promise { + const token = getToken() + if (!token) { + throw new Error('Нет сессии') + } + const res = await fetch('/api/v1/auth/reissue', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + }, + }) + if (!res.ok) { + clearToken() + throw new Error('Не удалось обновить сессию') + } + const body = (await res.json()) as LoginResponse + setToken(body.access_token) + return body +} + +/** SSO open: fresh JWT → app /auth/callback. */ +export async function ssoOpenApp(appBaseUrl: string): Promise { + const base = appBaseUrl.replace(/\/$/, '') + const issued = await reissueAccessToken() + const callback = `${base}/auth/callback` + window.location.href = buildSsoRedirectUrl( + callback, + issued.access_token, + issued.expires_at, + ) +} + +/** SSO return_to handoff with fresh JWT. */ +export async function ssoHandoffReturnTo(returnTo: string): Promise { + const allowlist = await ensureReturnToAllowlist() + if (!isReturnToAllowed(returnTo, allowlist)) return false + const issued = await reissueAccessToken() + window.location.href = buildSsoRedirectUrl( + returnTo, + issued.access_token, + issued.expires_at, + ) + return true +} diff --git a/apps/web/src/routes/_auth.apps.tsx b/apps/web/src/routes/_auth.apps.tsx index 45ea4ed..4a1b27b 100644 --- a/apps/web/src/routes/_auth.apps.tsx +++ b/apps/web/src/routes/_auth.apps.tsx @@ -1,7 +1,7 @@ import { createFileRoute, Link } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { LayoutGridIcon } from 'lucide-react' -import { buildSsoRedirectUrl, type AppId } from '@authportal/shared' +import type { AppId } from '@authportal/shared' import { PageShell } from '@/components/page-shell' import { Badge } from '@/components/reui/badge' import { @@ -14,23 +14,19 @@ import { } from '@/components/reui/frame' import { Button } from '@authportal/ui/components/button' import { Skeleton } from '@authportal/ui/components/skeleton' -import { getToken } from '@/lib/auth' +import { ssoOpenApp } from '@/lib/auth' import { catalogQueryOptions, meQueryOptions } from '@/queries/auth' export const Route = createFileRoute('/_auth/apps')({ component: AppsPage, }) -function openApp(_appId: AppId, baseUrl: string) { - const base = baseUrl.replace(/\/$/, '') - const token = getToken() - if (!token) { - window.open(base, '_blank', 'noreferrer') - return +async function openApp(_appId: AppId, baseUrl: string) { + try { + await ssoOpenApp(baseUrl) + } catch { + window.location.href = baseUrl.replace(/\/$/, '') } - const callback = `${base}/auth/callback` - const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString() - window.location.href = buildSsoRedirectUrl(callback, token, expiresAt) } function AppsPage() { @@ -99,7 +95,7 @@ function AppsPage() { diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx index ef13af4..0336801 100644 --- a/apps/web/src/routes/_auth.tsx +++ b/apps/web/src/routes/_auth.tsx @@ -1,16 +1,25 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router' import { AppShell } from '@/components/layout/app-shell' -import { clearToken, getToken } from '@/lib/auth' +import { + clearToken, + getToken, + jwtClaimsStaleVsMe, + reissueAccessToken, +} from '@/lib/auth' import { api } from '@/lib/api-client' import type { MeResponse } from '@authportal/shared' export const Route = createFileRoute('/_auth')({ beforeLoad: async ({ location }) => { - if (!getToken()) { + const token = getToken() + if (!token) { throw redirect({ to: '/' }) } try { const me = await api.get('/api/v1/auth/me') + if (jwtClaimsStaleVsMe(token, me)) { + await reissueAccessToken() + } if (location.pathname.startsWith('/admin') && !me.is_admin) { throw redirect({ to: '/apps' }) } diff --git a/apps/web/src/routes/index.tsx b/apps/web/src/routes/index.tsx index 0a29223..71ce9e9 100644 --- a/apps/web/src/routes/index.tsx +++ b/apps/web/src/routes/index.tsx @@ -1,11 +1,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router' import { z } from 'zod' -import { - buildSsoRedirectUrl, - isReturnToAllowed, - type MeResponse, -} from '@authportal/shared' -import { ensureReturnToAllowlist, getToken } from '@/lib/auth' +import { type MeResponse } from '@authportal/shared' +import { getToken, ssoHandoffReturnTo } from '@/lib/auth' import { PortalLoginForm } from '@/components/portal-login-form' import { api } from '@/lib/api-client' @@ -19,20 +15,17 @@ export const Route = createFileRoute('/')({ const token = getToken() if (!token) return - // SSO handoff first — avoid /me round-trip (rate-limit loops under redirect storms) + // SSO handoff — always reissue so apps get current DB rights (not stale JWT) if (search.return_to) { - const allowlist = await ensureReturnToAllowlist() - if (isReturnToAllowed(search.return_to, allowlist)) { - const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString() - window.location.href = buildSsoRedirectUrl( - search.return_to, - token, - exp, - ) - await new Promise(() => {}) - return + try { + const ok = await ssoHandoffReturnTo(search.return_to) + if (ok) { + await new Promise(() => {}) + return + } + } catch { + /* reissue failed — fall through to login */ } - // return_to present but not allowlisted — stay on login, do not hammer /me return } diff --git a/packages/shared/src/contracts/auth.ts b/packages/shared/src/contracts/auth.ts index 47778cb..1a3d0a1 100644 --- a/packages/shared/src/contracts/auth.ts +++ b/packages/shared/src/contracts/auth.ts @@ -199,19 +199,25 @@ function isPrivateHostname(hostname: string): boolean { return false } -/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */ -export function readJwtExp(token: string): number | null { +/** Decode JWT payload without verifying signature (claims sync only). */ +export function readJwtPayload(token: string): Record | null { try { const parts = token.split('.') if (parts.length < 2) return null const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/')) - const payload = JSON.parse(json) as { exp?: unknown } - return typeof payload.exp === 'number' ? payload.exp : null + return JSON.parse(json) as Record } catch { return null } } +/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */ +export function readJwtExp(token: string): number | null { + const payload = readJwtPayload(token) + if (!payload) return null + return typeof payload.exp === 'number' ? payload.exp : null +} + /** True when token is missing exp or exp is in the past (30s clock-skew grace). */ export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean { const exp = readJwtExp(token)