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]'),
|
adminEmail: z.string().email().default('[email protected]'),
|
||||||
adminPassword: z.string().default('admin'),
|
adminPassword: z.string().default('admin'),
|
||||||
adminName: 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),
|
serverPort: z.coerce.number().int().positive().default(8080),
|
||||||
staticDir: z.string().optional(),
|
staticDir: z.string().optional(),
|
||||||
logLevel: z.string().default('info'),
|
logLevel: z.string().default('info'),
|
||||||
@@ -36,7 +36,8 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
|
|||||||
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
|
adminEmail: env.ADMIN_EMAIL ?? '[email protected]',
|
||||||
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
|
adminPassword: env.ADMIN_PASSWORD ?? 'admin',
|
||||||
adminName: env.ADMIN_NAME ?? '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,
|
serverPort: env.SERVER_PORT ?? 8080,
|
||||||
staticDir: env.STATIC_DIR || undefined,
|
staticDir: env.STATIC_DIR || undefined,
|
||||||
logLevel: env.LOG_LEVEL ?? 'info',
|
logLevel: env.LOG_LEVEL ?? 'info',
|
||||||
|
|||||||
@@ -19,6 +19,12 @@ import { requireAuth, toMe } from '../plugins/auth-guards.js'
|
|||||||
const REFRESH_COOKIE = 'refresh_token'
|
const REFRESH_COOKIE = 'refresh_token'
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
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', {
|
app.post('/api/v1/auth/login', {
|
||||||
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
|
config: { rateLimit: { max: 20, timeWindow: '1 minute' } },
|
||||||
handler: async (request, reply) => {
|
handler: async (request, reply) => {
|
||||||
|
|||||||
@@ -17,16 +17,11 @@ import {
|
|||||||
AlertDescription,
|
AlertDescription,
|
||||||
AlertTitle,
|
AlertTitle,
|
||||||
} from '@/components/reui/alert'
|
} from '@/components/reui/alert'
|
||||||
import { setToken } from '@/lib/auth'
|
import { ensureReturnToAllowlist, setToken } from '@/lib/auth'
|
||||||
import { ApiError } from '@/lib/api-client'
|
import { ApiError } from '@/lib/api-client'
|
||||||
import { login, meQueryKey } from '@/queries/auth'
|
import { login, meQueryKey } from '@/queries/auth'
|
||||||
import { AuthLogo } from '@/components/blocks/auth-18/components/auth-logo'
|
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() {
|
export function PortalLoginForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -48,10 +43,8 @@ export function PortalLoginForm() {
|
|||||||
setToken(res.access_token)
|
setToken(res.access_token)
|
||||||
queryClient.setQueryData(meQueryKey, res.user)
|
queryClient.setQueryData(meQueryKey, res.user)
|
||||||
|
|
||||||
if (
|
const allowlist = await ensureReturnToAllowlist()
|
||||||
returnTo &&
|
if (returnTo && isReturnToAllowed(returnTo, allowlist)) {
|
||||||
isReturnToAllowed(returnTo, RETURN_TO_ALLOWLIST)
|
|
||||||
) {
|
|
||||||
window.location.href = buildSsoRedirectUrl(
|
window.location.href = buildSsoRedirectUrl(
|
||||||
returnTo,
|
returnTo,
|
||||||
res.access_token,
|
res.access_token,
|
||||||
|
|||||||
@@ -1,5 +1,41 @@
|
|||||||
const TOKEN_KEY = 'authportal_token'
|
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 {
|
export function getToken(): string | null {
|
||||||
return localStorage.getItem(TOKEN_KEY)
|
return localStorage.getItem(TOKEN_KEY)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
isReturnToAllowed,
|
isReturnToAllowed,
|
||||||
type MeResponse,
|
type MeResponse,
|
||||||
} from '@authportal/shared'
|
} from '@authportal/shared'
|
||||||
import { getToken } from '@/lib/auth'
|
import { ensureReturnToAllowlist, getToken } from '@/lib/auth'
|
||||||
import { PortalLoginForm } from '@/components/portal-login-form'
|
import { PortalLoginForm } from '@/components/portal-login-form'
|
||||||
import { api } from '@/lib/api-client'
|
import { api } from '@/lib/api-client'
|
||||||
|
|
||||||
@@ -13,10 +13,6 @@ const searchSchema = z.object({
|
|||||||
return_to: z.string().url().optional(),
|
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('/')({
|
export const Route = createFileRoute('/')({
|
||||||
validateSearch: (search) => searchSchema.parse(search),
|
validateSearch: (search) => searchSchema.parse(search),
|
||||||
beforeLoad: async ({ search }) => {
|
beforeLoad: async ({ search }) => {
|
||||||
@@ -24,9 +20,10 @@ export const Route = createFileRoute('/')({
|
|||||||
if (!token) return
|
if (!token) return
|
||||||
try {
|
try {
|
||||||
const me = await api.get<MeResponse>('/api/v1/auth/me')
|
const me = await api.get<MeResponse>('/api/v1/auth/me')
|
||||||
|
const allowlist = await ensureReturnToAllowlist()
|
||||||
if (
|
if (
|
||||||
search.return_to &&
|
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()
|
const exp = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||||
window.location.href = buildSsoRedirectUrl(
|
window.location.href = buildSsoRedirectUrl(
|
||||||
|
|||||||
Reference in New Issue
Block a user