feat(auth): refactor authentication routes and enhance JWT handling
- 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:
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type UserRow,
|
type UserRow,
|
||||||
} from '@authportal/db'
|
} from '@authportal/db'
|
||||||
import type { AppId, MeResponse } from '@authportal/shared'
|
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 = {
|
export type AuthUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -72,9 +72,12 @@ export function loadAuthUser(
|
|||||||
user: UserRow,
|
user: UserRow,
|
||||||
): AuthUser {
|
): AuthUser {
|
||||||
const apps = getUserApps(request.server.db, user.id)
|
const apps = getUserApps(request.server.db, user.id)
|
||||||
const permissions = normalizePermissionKeys(
|
let permissions = normalizePermissionKeys(
|
||||||
getUserPermissions(request.server.db, user.id),
|
getUserPermissions(request.server.db, user.id),
|
||||||
)
|
)
|
||||||
|
if (user.isAdmin) {
|
||||||
|
permissions = allPermissionKeys()
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
+27
-43
@@ -1,25 +1,22 @@
|
|||||||
import type { FastifyInstance } from 'fastify'
|
import type { FastifyInstance } from 'fastify'
|
||||||
import { hash, verify } from '@node-rs/argon2'
|
import { verify } from '@node-rs/argon2'
|
||||||
import { randomBytes } from 'node:crypto'
|
import { randomBytes } from 'node:crypto'
|
||||||
import {
|
import {
|
||||||
PERMISSION_CATALOG,
|
PERMISSION_CATALOG,
|
||||||
allPermissionKeys,
|
|
||||||
appsMetaFromSwitcher,
|
appsMetaFromSwitcher,
|
||||||
loginRequestSchema,
|
loginRequestSchema,
|
||||||
normalizePermissionKeys,
|
|
||||||
publicAppSwitcherConfig,
|
publicAppSwitcherConfig,
|
||||||
tenantsClaimForUser,
|
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import {
|
import {
|
||||||
createRefreshSession,
|
createRefreshSession,
|
||||||
getAppSwitcherConfig,
|
getAppSwitcherConfig,
|
||||||
getUserApps,
|
|
||||||
getUserByEmail,
|
getUserByEmail,
|
||||||
getUserPermissions,
|
getUserById,
|
||||||
revokeRefreshSession,
|
revokeRefreshSession,
|
||||||
} from '@authportal/db'
|
} 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'
|
const REFRESH_COOKIE = 'refresh_token'
|
||||||
|
|
||||||
@@ -55,36 +52,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const apps = getUserApps(app.db, user.id)
|
const body = issueAccessToken(app, user)
|
||||||
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 refreshRaw = randomBytes(32).toString('hex')
|
const refreshRaw = randomBytes(32).toString('hex')
|
||||||
const refreshExpires = new Date(
|
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' : ''}`,
|
`${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
|
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) => {
|
app.post('/api/v1/auth/logout', async (request, reply) => {
|
||||||
const cookie = request.headers.cookie ?? ''
|
const cookie = request.headers.cookie ?? ''
|
||||||
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
|
const match = cookie.match(new RegExp(`${REFRESH_COOKIE}=([^;]+)`))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { AppSwitcherIconName } from '@authportal/shared'
|
import type { AppSwitcherIconName } from '@authportal/shared'
|
||||||
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||||
|
import { ssoOpenApp } from '@/lib/auth'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -89,8 +90,11 @@ export function AppSwitcher() {
|
|||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={app.id}
|
key={app.id}
|
||||||
nativeButton={false}
|
onClick={() => {
|
||||||
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
void ssoOpenApp(app.url).catch(() => {
|
||||||
|
window.location.href = app.url.replace(/\/$/, '')
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Icon />
|
<Icon />
|
||||||
{app.name}
|
{app.name}
|
||||||
|
|||||||
@@ -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'
|
const TOKEN_KEY = 'authportal_token'
|
||||||
|
|
||||||
@@ -55,3 +62,71 @@ export function setToken(token: string) {
|
|||||||
export function clearToken() {
|
export function clearToken() {
|
||||||
localStorage.removeItem(TOKEN_KEY)
|
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<LoginResponse> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { LayoutGridIcon } from 'lucide-react'
|
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 { PageShell } from '@/components/page-shell'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import {
|
import {
|
||||||
@@ -14,23 +14,19 @@ import {
|
|||||||
} from '@/components/reui/frame'
|
} from '@/components/reui/frame'
|
||||||
import { Button } from '@authportal/ui/components/button'
|
import { Button } from '@authportal/ui/components/button'
|
||||||
import { Skeleton } from '@authportal/ui/components/skeleton'
|
import { Skeleton } from '@authportal/ui/components/skeleton'
|
||||||
import { getToken } from '@/lib/auth'
|
import { ssoOpenApp } from '@/lib/auth'
|
||||||
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
|
import { catalogQueryOptions, meQueryOptions } from '@/queries/auth'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/apps')({
|
export const Route = createFileRoute('/_auth/apps')({
|
||||||
component: AppsPage,
|
component: AppsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
function openApp(_appId: AppId, baseUrl: string) {
|
async function openApp(_appId: AppId, baseUrl: string) {
|
||||||
const base = baseUrl.replace(/\/$/, '')
|
try {
|
||||||
const token = getToken()
|
await ssoOpenApp(baseUrl)
|
||||||
if (!token) {
|
} catch {
|
||||||
window.open(base, '_blank', 'noreferrer')
|
window.location.href = baseUrl.replace(/\/$/, '')
|
||||||
return
|
|
||||||
}
|
}
|
||||||
const callback = `${base}/auth/callback`
|
|
||||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
|
||||||
window.location.href = buildSsoRedirectUrl(callback, token, expiresAt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function AppsPage() {
|
function AppsPage() {
|
||||||
@@ -99,7 +95,7 @@ function AppsPage() {
|
|||||||
<FrameFooter>
|
<FrameFooter>
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
onClick={() => openApp(app.id, app.url)}
|
onClick={() => void openApp(app.id, app.url)}
|
||||||
>
|
>
|
||||||
Открыть
|
Открыть
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { AppShell } from '@/components/layout/app-shell'
|
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 { api } from '@/lib/api-client'
|
||||||
import type { MeResponse } from '@authportal/shared'
|
import type { MeResponse } from '@authportal/shared'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: async ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
if (!getToken()) {
|
const token = getToken()
|
||||||
|
if (!token) {
|
||||||
throw redirect({ to: '/' })
|
throw redirect({ to: '/' })
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const me = await api.get<MeResponse>('/api/v1/auth/me')
|
const me = await api.get<MeResponse>('/api/v1/auth/me')
|
||||||
|
if (jwtClaimsStaleVsMe(token, me)) {
|
||||||
|
await reissueAccessToken()
|
||||||
|
}
|
||||||
if (location.pathname.startsWith('/admin') && !me.is_admin) {
|
if (location.pathname.startsWith('/admin') && !me.is_admin) {
|
||||||
throw redirect({ to: '/apps' })
|
throw redirect({ to: '/apps' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import {
|
import { type MeResponse } from '@authportal/shared'
|
||||||
buildSsoRedirectUrl,
|
import { getToken, ssoHandoffReturnTo } from '@/lib/auth'
|
||||||
isReturnToAllowed,
|
|
||||||
type MeResponse,
|
|
||||||
} from '@authportal/shared'
|
|
||||||
import { ensureReturnToAllowlist, getToken } from '@/lib/auth'
|
|
||||||
import { PortalLoginForm } from '@/components/portal-login-form'
|
import { PortalLoginForm } from '@/components/portal-login-form'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
@@ -19,20 +15,17 @@ export const Route = createFileRoute('/')({
|
|||||||
const token = getToken()
|
const token = getToken()
|
||||||
if (!token) return
|
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) {
|
if (search.return_to) {
|
||||||
const allowlist = await ensureReturnToAllowlist()
|
try {
|
||||||
if (isReturnToAllowed(search.return_to, allowlist)) {
|
const ok = await ssoHandoffReturnTo(search.return_to)
|
||||||
const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
if (ok) {
|
||||||
window.location.href = buildSsoRedirectUrl(
|
await new Promise(() => {})
|
||||||
search.return_to,
|
return
|
||||||
token,
|
}
|
||||||
exp,
|
} catch {
|
||||||
)
|
/* reissue failed — fall through to login */
|
||||||
await new Promise(() => {})
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// return_to present but not allowlisted — stay on login, do not hammer /me
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -199,19 +199,25 @@ function isPrivateHostname(hostname: string): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Decode JWT `exp` without verifying signature. Returns null if missing/invalid. */
|
/** Decode JWT payload without verifying signature (claims sync only). */
|
||||||
export function readJwtExp(token: string): number | null {
|
export function readJwtPayload(token: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parts = token.split('.')
|
const parts = token.split('.')
|
||||||
if (parts.length < 2) return null
|
if (parts.length < 2) return null
|
||||||
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
const payload = JSON.parse(json) as { exp?: unknown }
|
return JSON.parse(json) as Record<string, unknown>
|
||||||
return typeof payload.exp === 'number' ? payload.exp : null
|
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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). */
|
/** 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 {
|
export function isJwtExpired(token: string, nowMs: number = Date.now()): boolean {
|
||||||
const exp = readJwtExp(token)
|
const exp = readJwtExp(token)
|
||||||
|
|||||||
Reference in New Issue
Block a user