fix(auth): редирект на portal по runtime /api/auth/config при 401
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
|
||||
@@ -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): {
|
||||
|
||||
@@ -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: '/' })
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user