fix(auth): runtime allowlist return_to с private для LAN SSO
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user