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:
@@ -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 (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
nativeButton={false}
|
||||
render={<a href={app.url} target="_blank" rel="noreferrer" />}
|
||||
onClick={() => {
|
||||
void ssoOpenApp(app.url).catch(() => {
|
||||
window.location.href = app.url.replace(/\/$/, '')
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon />
|
||||
{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'
|
||||
|
||||
@@ -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<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 { 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() {
|
||||
<FrameFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => openApp(app.id, app.url)}
|
||||
onClick={() => void openApp(app.id, app.url)}
|
||||
>
|
||||
Открыть
|
||||
</Button>
|
||||
|
||||
@@ -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<MeResponse>('/api/v1/auth/me')
|
||||
if (jwtClaimsStaleVsMe(token, me)) {
|
||||
await reissueAccessToken()
|
||||
}
|
||||
if (location.pathname.startsWith('/admin') && !me.is_admin) {
|
||||
throw redirect({ to: '/apps' })
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user