fix(auth): runtime allowlist return_to с private для LAN SSO
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 3m15s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-07-18 14:00:05 +07:00
co-authored by Cursor
parent 1cc58977fd
commit 961242506b
5 changed files with 51 additions and 18 deletions
+3 -2
View File
@@ -14,7 +14,7 @@ export const configSchema = z.object({
adminEmail: z.string().email().default('[email protected]'),
adminPassword: z.string().default('admin'),
adminName: z.string().default('Admin'),
returnToAllowlist: z.string().default('.shnt.top,localhost'),
returnToAllowlist: z.string().default('.shnt.top,localhost,private'),
serverPort: z.coerce.number().int().positive().default(8080),
staticDir: z.string().optional(),
logLevel: z.string().default('info'),
@@ -36,7 +36,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
adminName: env.ADMIN_NAME ?? 'Admin',
returnToAllowlist: env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost',
returnToAllowlist:
env.RETURN_TO_ALLOWLIST ?? '.shnt.top,localhost,private',
serverPort: env.SERVER_PORT ?? 8080,
staticDir: env.STATIC_DIR || undefined,
logLevel: env.LOG_LEVEL ?? 'info',
+6
View File
@@ -19,6 +19,12 @@ import { requireAuth, toMe } from '../plugins/auth-guards.js'
const REFRESH_COOKIE = 'refresh_token'
export async function authRoutes(app: FastifyInstance): Promise<void> {
/** Public — SPA reads allowlist at runtime (Docker-friendly). */
app.get('/api/v1/auth/config', async () => ({
return_to_allowlist: app.config.returnToAllowlist,
issuer: app.config.issuer,
}))
app.post('/api/v1/auth/login', {
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
handler: async (request, reply) => {
+3 -10
View File
@@ -17,16 +17,11 @@ import {
AlertDescription,
AlertTitle,
} from '@/components/reui/alert'
import { setToken } from '@/lib/auth'
import { ensureReturnToAllowlist, setToken } from '@/lib/auth'
import { ApiError } from '@/lib/api-client'
import { login, meQueryKey } from '@/queries/auth'
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
/** Dev default matches auth-portal .env.example; prod should pass via Vite if needed. */
const RETURN_TO_ALLOWLIST =
import.meta.env.VITE_RETURN_TO_ALLOWLIST ??
'.shnt.top,localhost,http://localhost:5173'
export function PortalLoginForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
@@ -48,10 +43,8 @@ export function PortalLoginForm() {
setToken(res.access_token)
queryClient.setQueryData(meQueryKey, res.user)
if (
returnTo &&
isReturnToAllowed(returnTo, RETURN_TO_ALLOWLIST)
) {
const allowlist = await ensureReturnToAllowlist()
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
window.location.href = buildSsoRedirectUrl(
returnTo,
res.access_token,
+36
View File
@@ -1,5 +1,41 @@
const TOKEN_KEY = 'authportal_token'
/** Fallback if /api/v1/auth/config unavailable (dev). Includes `private` for LAN SSO. */
export const DEFAULT_RETURN_TO_ALLOWLIST =
'.shnt.top,localhost,private,http://localhost:5173'
let returnToAllowlist: string | null = null
let returnToAllowlistPromise: Promise<string> | null = null
export async function ensureReturnToAllowlist(): Promise<string> {
if (returnToAllowlist) return returnToAllowlist
if (returnToAllowlistPromise) return returnToAllowlistPromise
returnToAllowlistPromise = (async () => {
const fromVite = import.meta.env.VITE_RETURN_TO_ALLOWLIST as
| string
| undefined
try {
const res = await fetch('/api/v1/auth/config')
if (res.ok) {
const data = (await res.json()) as { return_to_allowlist?: string }
if (data.return_to_allowlist) {
returnToAllowlist = data.return_to_allowlist
return returnToAllowlist
}
}
} catch {
/* ignore */
}
returnToAllowlist = fromVite || DEFAULT_RETURN_TO_ALLOWLIST
return returnToAllowlist
})().finally(() => {
returnToAllowlistPromise = null
})
return returnToAllowlistPromise
}
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY)
}
+3 -6
View File
@@ -5,7 +5,7 @@ import {
isReturnToAllowed,
type MeResponse,
} from '@authportal/shared'
import { getToken } from '@/lib/auth'
import { ensureReturnToAllowlist, getToken } from '@/lib/auth'
import { PortalLoginForm } from '@/components/portal-login-form'
import { api } from '@/lib/api-client'
@@ -13,10 +13,6 @@ const searchSchema = z.object({
return_to: z.string().url().optional(),
})
const RETURN_TO_ALLOWLIST =
import.meta.env.VITE_RETURN_TO_ALLOWLIST ??
'.shnt.top,localhost,http://localhost:5173'
export const Route = createFileRoute('/')({
validateSearch: (search) => searchSchema.parse(search),
beforeLoad: async ({ search }) => {
@@ -24,9 +20,10 @@ export const Route = createFileRoute('/')({
if (!token) return
try {
const me = await api.get<MeResponse>('/api/v1/auth/me')
const allowlist = await ensureReturnToAllowlist()
if (
search.return_to &&
isReturnToAllowed(search.return_to, RETURN_TO_ALLOWLIST)
isReturnToAllowed(search.return_to, allowlist)
) {
const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString()
window.location.href = buildSsoRedirectUrl(