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