feat(auth): refactor authentication routes and enhance JWT handling
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 1m49s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

- Removed unused permission and app retrieval logic from the login process.
- Introduced a new endpoint for reissuing access tokens to handle role changes and account switches.
- Updated JWT payload handling to improve clarity and maintainability.
- Enhanced the readJwtPayload function for better decoding of JWT claims.
This commit is contained in:
Denozordec
2026-07-19 02:22:23 +07:00
parent ed1699503a
commit 09ed7f4dc4
9 changed files with 206 additions and 84 deletions
+52
View File
@@ -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,
}
}
+5 -2
View File
@@ -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,
+27 -43
View File
@@ -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<void> {
})
}
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<void> {
`${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}=([^;]+)`))