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

Co-authored-by: Cursor <cursoragent@cursor.com>
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_JWT_SECRET: secret,
AUTH_ISSUER: issuer,
AUTH_PORTAL_URL: 'http://192.168.100.67:8080',
})
expect(cfg.required).toBe(true)
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 () => {
+12
View File
@@ -11,6 +11,7 @@ export type AuthConfig = {
required: boolean
jwtSecret: string
issuer: string
portalUrl: string
}
declare module 'fastify' {
@@ -61,12 +62,18 @@ export function loadAuthConfig(
env.JWT_SECRET ??
(isProd ? '' : 'dev-secret-change-me'),
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 {
const path = url.split('?')[0] ?? url
if (path === '/health' || path === '/ready') return true
if (path === '/api/auth/config') return true
if (path.startsWith('/api/integrations/cfdm')) return true
return false
}
@@ -75,6 +82,11 @@ export const authPlugin = fp(async (app) => {
const config = loadAuthConfig()
app.decorate('authConfig', config)
app.get('/api/auth/config', async () => ({
required: config.required,
portal_url: config.portalUrl,
}))
if (!config.required) {
app.log.info('AUTH_REQUIRED=false — portal JWT middleware disabled')
return
+46 -23
View File
@@ -8,7 +8,7 @@ import type {
Payment,
BalanceLedgerRow,
} 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 ?? ''
@@ -27,20 +27,22 @@ async function fetchApi<T>(path: string, options: RequestInit = {}): Promise<T>
if (options.body != null && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (isAuthEnabled()) {
const token = getToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
// Always attach token if present (API may require it even without VITE_AUTH_ENABLED)
const token = getToken()
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`)
}
const res = await fetch(url, {
...options,
headers,
})
if (!res.ok) {
if (isAuthEnabled() && res.status === 401) {
if (res.status === 401) {
clearToken()
redirectToPortalLogin()
const cfg = await ensureAuthConfig()
if (cfg.required || isAuthEnabled()) {
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
}
}
let message = res.statusText || 'API error'
try {
@@ -153,23 +155,37 @@ export const api = {
downloadBackupJson: async (): Promise<Blob> => {
const headers = new Headers()
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
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()
},
downloadBackupDatabase: async (): Promise<Blob> => {
const headers = new Headers()
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
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()
},
@@ -178,16 +194,23 @@ export const api = {
importBackupDatabase: async (buffer: ArrayBuffer) => {
const headers = new Headers({ 'Content-Type': 'application/octet-stream' })
if (isAuthEnabled()) {
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
}
const token = getToken()
if (token) headers.set('Authorization', `Bearer ${token}`)
const res = await fetch(`${API_BASE}/api/backup/database`, {
method: 'POST',
headers,
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()
},
+66 -9
View File
@@ -1,6 +1,7 @@
/** Portal JWT storage + claims helpers for VPS Tracker UI. */
const TOKEN_KEY = 'vps_auth_token'
const API_BASE = import.meta.env.VITE_API_URL ?? ''
export type AccessClaims = {
sub: string
@@ -13,6 +14,66 @@ export type AccessClaims = {
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 {
return localStorage.getItem(TOKEN_KEY)
}
@@ -26,17 +87,13 @@ export function clearToken() {
}
export function isAuthEnabled(): boolean {
return (
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
import.meta.env.VITE_AUTH_ENABLED === '1'
)
if (runtimeConfig) return runtimeConfig.required
return viteAuthEnabled()
}
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) {
@@ -44,7 +101,7 @@ export function redirectToPortalLogin(returnTo?: string) {
returnTo ?? `${window.location.origin}/auth/callback`
const url = new URL(authPortalUrl())
url.searchParams.set('return_to', callback)
window.location.href = url.toString()
window.location.assign(url.toString())
}
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 {
can,
ensureAuthConfig,
firstAllowedPath,
getClaims,
getToken,
isAuthEnabled,
permissionForPath,
redirectToPortalLogin,
} from '@/lib/auth'
export const Route = createFileRoute('/_auth')({
beforeLoad: ({ location }) => {
if (!isAuthEnabled()) return
beforeLoad: async ({ location }) => {
const cfg = await ensureAuthConfig()
if (!cfg.required) return
const token = getToken()
const claims = getClaims()
if (!token || !claims) {
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')) {
throw redirect({ to: '/' })
+7 -6
View File
@@ -1,19 +1,20 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import {
ensureAuthConfig,
firstAllowedPath,
isAuthEnabled,
parseHashToken,
redirectToPortalLogin,
setToken,
} from '@/lib/auth'
export const Route = createFileRoute('/auth/callback')({
beforeLoad: () => {
if (!isAuthEnabled()) {
throw redirect({ to: '/dashboard' })
}
beforeLoad: async () => {
await ensureAuthConfig()
const { accessToken } = parseHashToken(window.location.hash)
if (!accessToken) {
throw redirect({ to: '/' })
redirectToPortalLogin(`${window.location.origin}/auth/callback`)
await new Promise(() => {})
return
}
setToken(accessToken)
window.history.replaceState(null, '', '/auth/callback')
+4
View File
@@ -8,6 +8,10 @@ services:
environment:
PORT: "3001"
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:
- ./data:/app/data