fix(auth): редирект на portal по runtime /api/auth/config при 401
Docker / build (push) Failing after 20s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 13:49:36 +07:00
co-authored by Cursor
parent 84cc800d54
commit 812aa614df
7 changed files with 144 additions and 42 deletions
+2
View File
@@ -23,9 +23,11 @@ describe('auth plugin (AUTH_REQUIRED)', () => {
AUTH_REQUIRED: 'true', AUTH_REQUIRED: 'true',
AUTH_JWT_SECRET: secret, AUTH_JWT_SECRET: secret,
AUTH_ISSUER: issuer, AUTH_ISSUER: issuer,
AUTH_PORTAL_URL: 'http://192.168.100.67:8080',
}) })
expect(cfg.required).toBe(true) expect(cfg.required).toBe(true)
expect(cfg.jwtSecret).toBe(secret) expect(cfg.jwtSecret).toBe(secret)
expect(cfg.portalUrl).toBe('http://192.168.100.67:8080')
}) })
it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => { it('401 without token; 403 without vps app; 403 without permission; 200 with rights', async () => {
+12
View File
@@ -11,6 +11,7 @@ export type AuthConfig = {
required: boolean required: boolean
jwtSecret: string jwtSecret: string
issuer: string issuer: string
portalUrl: string
} }
declare module 'fastify' { declare module 'fastify' {
@@ -61,12 +62,18 @@ export function loadAuthConfig(
env.JWT_SECRET ?? env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me'), (isProd ? '' : 'dev-secret-change-me'),
issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top', issuer: env.AUTH_ISSUER ?? env.ISSUER ?? 'https://auth.shnt.top',
portalUrl: (
env.AUTH_PORTAL_URL ??
env.VITE_AUTH_PORTAL_URL ??
'http://localhost:5175'
).replace(/\/$/, ''),
} }
} }
function isPublicPath(url: string): boolean { function isPublicPath(url: string): boolean {
const path = url.split('?')[0] ?? url const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path.startsWith('/api/integrations/cfdm')) return true if (path.startsWith('/api/integrations/cfdm')) return true
return false return false
} }
@@ -75,6 +82,11 @@ export const authPlugin = fp(async (app) => {
const config = loadAuthConfig() const config = loadAuthConfig()
app.decorate('authConfig', config) app.decorate('authConfig', config)
app.get('/api/auth/config', async () => ({
required: config.required,
portal_url: config.portalUrl,
}))
if (!config.required) { if (!config.required) {
app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled') app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled')
return return
+46 -23
View File
@@ -8,7 +8,7 @@ import type {
Payment, Payment,
BalanceLedgerRow, BalanceLedgerRow,
} from '@/types/entities' } from '@/types/entities'
import { clearToken, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth' import { clearToken, ensureAuthConfig, getToken, isAuthEnabled, redirectToPortalLogin } from '@/lib/auth'
const API_BASE = import.meta.env.VITE_API_URL ?? '' const API_BASE = import.meta.env.VITE_API_URL ?? ''
@@ -27,20 +27,22 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (options.body != null && !headers.has('Content-Type')) { if (options.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json') headers.set('Content-Type', 'application/json')
} }
if (isAuthEnabled()) { // Always attach token if present (API may require it even without VITE_AUTH_ENABLED)
const token = getToken() const token = getToken()
if (token && !headers.has('Authorization')) { if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`) headers.set('Authorization', `Bearer ${token}`)
}
} }
const res = await fetch(url, { const res = await fetch(url, {
...options, ...options,
headers, headers,
}) })
if (!res.ok) { if (!res.ok) {
if (isAuthEnabled() && res.status === 401) { if (res.status === 401) {
clearToken() clearToken()
redirectToPortalLogin() const cfg = await ensureAuthConfig()
if (cfg.required || isAuthEnabled()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
} }
let message = res.statusText || 'API error' let message = res.statusText || 'API error'
try { try {
@@ -153,23 +155,37 @@ export const api = {
downloadBackupJson: async (): Promise<Blob> => { downloadBackupJson: async (): Promise<Blob> => {
const headers = new Headers() const headers = new Headers()
if (isAuthEnabled()) { const token = getToken()
const token = getToken() if (token) headers.set('Authorization', `Bearer ${token}`)
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/json`, { headers }) const res = await fetch(`${API_BASE}/api/backup/json`, { headers })
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) if (!res.ok) {
if (res.status === 401) {
clearToken()
const cfg = await ensureAuthConfig()
if (cfg.required || isAuthEnabled()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
}
throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
}
return res.blob() return res.blob()
}, },
downloadBackupDatabase: async (): Promise<Blob> => { downloadBackupDatabase: async (): Promise<Blob> => {
const headers = new Headers() const headers = new Headers()
if (isAuthEnabled()) { const token = getToken()
const token = getToken() if (token) headers.set('Authorization', `Bearer ${token}`)
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/database`, { headers }) const res = await fetch(`${API_BASE}/api/backup/database`, { headers })
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status) if (!res.ok) {
if (res.status === 401) {
clearToken()
const cfg = await ensureAuthConfig()
if (cfg.required || isAuthEnabled()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
}
throw new ApiError(res.statusText || 'Ошибка выгрузки', res.status)
}
return res.blob() return res.blob()
}, },
@@ -178,16 +194,23 @@ export const api = {
importBackupDatabase: async (buffer: ArrayBuffer) => { importBackupDatabase: async (buffer: ArrayBuffer) => {
const headers = new Headers({ 'Content-Type': 'application/octet-stream' }) const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
if (isAuthEnabled()) { const token = getToken()
const token = getToken() if (token) headers.set('Authorization', `Bearer ${token}`)
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(`${API_BASE}/api/backup/database`, { const res = await fetch(`${API_BASE}/api/backup/database`, {
method: 'POST', method: 'POST',
headers, headers,
body: buffer, body: buffer,
}) })
if (!res.ok) throw new ApiError(res.statusText || 'Ошибка восстановления', res.status) if (!res.ok) {
if (res.status === 401) {
clearToken()
const cfg = await ensureAuthConfig()
if (cfg.required || isAuthEnabled()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
}
throw new ApiError(res.statusText || 'Ошибка восстановления', res.status)
}
return res.json() return res.json()
}, },
+66 -9
View File
@@ -1,6 +1,7 @@
/** Portal JWT storage + claims helpers for VPS Tracker UI. */ /** Portal JWT storage + claims helpers for VPS Tracker UI. */
const TOKEN_KEY = 'vps_auth_token' const TOKEN_KEY = 'vps_auth_token'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export type AccessClaims = { export type AccessClaims = {
sub: string sub: string
@@ -13,6 +14,66 @@ export type AccessClaims = {
exp?: number exp?: number
} }
export type RuntimeAuthConfig = {
required: boolean
portalUrl: string
}
let runtimeConfig: RuntimeAuthConfig | null = null
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
function viteAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
}
function vitePortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
/\/$/,
'',
)
}
/** Load auth mode from API (Docker-friendly). Falls back to VITE_* flags. */
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
if (runtimeConfig) return runtimeConfig
if (runtimeConfigPromise) return runtimeConfigPromise
runtimeConfigPromise = (async () => {
try {
const res = await fetch(`${API_BASE}/api/auth/config`)
if (res.ok) {
const data = (await res.json()) as {
required?: boolean
portal_url?: string
}
runtimeConfig = {
required: Boolean(data.required) || viteAuthEnabled(),
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
}
return runtimeConfig
}
} catch {
/* ignore — use vite defaults */
}
runtimeConfig = {
required: viteAuthEnabled(),
portalUrl: vitePortalUrl(),
}
return runtimeConfig
})().finally(() => {
runtimeConfigPromise = null
})
return runtimeConfigPromise
}
export function getAuthConfigSync(): RuntimeAuthConfig | null {
return runtimeConfig
}
export function getToken(): string | null { export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY) return localStorage.getItem(TOKEN_KEY)
} }
@@ -26,17 +87,13 @@ export function clearToken() {
} }
export function isAuthEnabled(): boolean { export function isAuthEnabled(): boolean {
return ( if (runtimeConfig) return runtimeConfig.required
import.meta.env.VITE_AUTH_ENABLED === 'true' || return viteAuthEnabled()
import.meta.env.VITE_AUTH_ENABLED === '1'
)
} }
export function authPortalUrl(): string { export function authPortalUrl(): string {
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace( if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
/\/$/, return vitePortalUrl()
'',
)
} }
export function redirectToPortalLogin(returnTo?: string) { export function redirectToPortalLogin(returnTo?: string) {
@@ -44,7 +101,7 @@ export function redirectToPortalLogin(returnTo?: string) {
returnTo ?? `${window.location.origin}/auth/callback` returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl()) const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback) url.searchParams.set('return_to', callback)
window.location.href = url.toString() window.location.assign(url.toString())
} }
export function parseHashToken(hash: string): { export function parseHashToken(hash: string): {
+7 -4
View File
@@ -2,23 +2,26 @@ import { Outlet, createFileRoute, redirect } from '@tanstack/react-router'
import { snapshotQueryOptions } from '@/queries/snapshot' import { snapshotQueryOptions } from '@/queries/snapshot'
import { import {
can, can,
ensureAuthConfig,
firstAllowedPath, firstAllowedPath,
getClaims, getClaims,
getToken, getToken,
isAuthEnabled,
permissionForPath, permissionForPath,
redirectToPortalLogin, redirectToPortalLogin,
} from '@/lib/auth' } from '@/lib/auth'
export const Route = createFileRoute('/_auth')({ export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => { beforeLoad: async ({ location }) => {
if (!isAuthEnabled()) return const cfg = await ensureAuthConfig()
if (!cfg.required) return
const token = getToken() const token = getToken()
const claims = getClaims() const claims = getClaims()
if (!token || !claims) { if (!token || !claims) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`) redirectToPortalLogin(`${window.location.origin}/auth/callback`)
throw new Error('Redirecting to auth portal') // Abort route load while browser navigates away
await new Promise(() => {})
return
} }
if (!claims.apps.includes('vps')) { if (!claims.apps.includes('vps')) {
throw redirect({ to: '/' }) throw redirect({ to: '/' })
+7 -6
View File
@@ -1,19 +1,20 @@
import { createFileRoute, redirect } from '@tanstack/react-router' import { createFileRoute, redirect } from '@tanstack/react-router'
import { import {
ensureAuthConfig,
firstAllowedPath, firstAllowedPath,
isAuthEnabled,
parseHashToken, parseHashToken,
redirectToPortalLogin,
setToken, setToken,
} from '@/lib/auth' } from '@/lib/auth'
export const Route = createFileRoute('/auth/callback')({ export const Route = createFileRoute('/auth/callback')({
beforeLoad: () => { beforeLoad: async () => {
if (!isAuthEnabled()) { await ensureAuthConfig()
throw redirect({ to: '/dashboard' })
}
const { accessToken } = parseHashToken(window.location.hash) const { accessToken } = parseHashToken(window.location.hash)
if (!accessToken) { if (!accessToken) {
throw redirect({ to: '/' }) redirectToPortalLogin(`${window.location.origin}/auth/callback`)
await new Promise(() => {})
return
} }
setToken(accessToken) setToken(accessToken)
window.history.replaceState(null, '', '/auth/callback') window.history.replaceState(null, '', '/auth/callback')
+4
View File
@@ -8,6 +8,10 @@ services:
environment: environment:
PORT: "3001" PORT: "3001"
RUNTIME: "fastify" RUNTIME: "fastify"
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-dev-secret-change-me}
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-http://localhost:8080}
volumes: volumes:
- ./data:/app/data - ./data:/app/data