Files
paskia/frontend/vite.config.js
T
LeoVasanko 10af29f92d MultiSite: one instance serves authentication across many domains (#4)
- Serve multiple domains (RP IDs) from one instance: host-based dispatch,
  per-domain credentials and sessions, domains managed at runtime in the
  admin UI — previously one RP per instance
- Cross-domain sign-in via Related Origin Requests: per-domain related-origins
  list with a served .well-known/webauthn document
- Explicit per-domain origin lists with shell-glob wildcards (**. for apex +
  any subdomain depth, *. for one level), editable in the admin UI with
  validation and self-lockout guards
- Per-domain auth hosts: the account/admin UI can live on a different host
  per domain, no longer confined to subdomains of a single RP
- CLI: 'paskia init <rp-id [rp-name]' initializes or adds a domain to an
  existing database; 'paskia migrate' converts legacy databases

BREAKING CHANGES (v2.0):
- Database schema: config is now per-domain and credentials/sessions carry
  an rp_id — existing databases must be converted with 'paskia migrate'
- Origins are now explicit: main implicitly allowed every subdomain of the
  RP; configure '**.' origins to reproduce that behavior
- CLI: the flat '--rp-id/--rp-name/--origin/--auth/--save' flags are
  replaced by the 'init' and 'migrate' subcommandsReviewed-on: #4
2026-09-07 22:02:06 +00:00

148 lines
4.9 KiB
JavaScript

import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import { resolve } from 'node:path'
import vue from '@vitejs/plugin-vue'
import { existsSync, renameSync, mkdirSync } from 'node:fs'
import sirv from 'sirv'
import fastapiVue from './vite-plugin-fastapi.js'
// Auth host mode: when set, clients accessing an auth host get /auth/ at / and /auth/admin/ at /admin/
// Comma-separated list of bare hostnames (one per domain with a dedicated auth host)
const authHosts = (process.env.PASKIA_AUTH_HOST || '')
.split(',')
.map(h => h.trim().replace(/^https?:\/\//, '').split(':')[0].split('/')[0])
.filter(Boolean)
export default defineConfig(({ command }) => ({
appType: 'mpa',
publicDir: 'public',
plugins: [
fastapiVue({ paths: [
"/auth/api",
"/auth/ws",
"/.well-known/openid-configuration",
"/.well-known/webauthn",
// Passphrase links: /auth/word1.word2.word3.word4.word5
"^/auth/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
// Passphrase links: /word1.word2.word3.word4.word5
"^/[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+\\.[a-z]+$",
] }),
vue(),
// Auth host routing: rewrite paths when accessing dedicated auth host
// Must run before serve-examples to handle / correctly
authHosts.length && {
name: 'auth-host-routing',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
const host = req.headers.host?.split(':')[0]
// Check if request is coming to the auth host
if (authHosts.includes(host)) {
// Only rewrite specific paths that should map to /auth/*
// Rewrite / and /index.html to /auth/
if (req.url === '/' || req.url === '/index.html') {
req.url = '/auth/'
}
// Rewrite /admin/* to /auth/admin/*
else if (req.url.startsWith('/admin/') || req.url === '/admin') {
req.url = '/auth' + req.url
}
// Everything else (Vite paths, passphrase links, etc.) passes through unchanged
}
next()
})
}
},
{
name: 'restricted-endpoints-rewrite',
configureServer(server) {
server.middlewares.use((req, _res, next) => {
// Rewrite /auth/restricted/iframe and /auth/restricted/oidc to /auth/restricted/
if (req.url === '/auth/restricted/iframe' || req.url === '/auth/restricted/oidc') {
req.url = '/auth/restricted/'
}
next()
})
}
},
{
name: 'serve-examples',
configureServer(server) {
const examplesDir = resolve(__dirname, '../examples')
const serve = sirv(examplesDir, { dev: true })
server.middlewares.use((req, _res, next) => {
// Skip redirect to examples on auth host (handled by auth-host-routing)
const host = req.headers.host?.split(':')[0]
if (authHosts.includes(host)) {
next()
return
}
if (req.url === '/' || req.url === '/index.html') req.url = '/examples/'
next()
})
server.middlewares.use('/examples', serve)
}
},
{
name: 'move-html-files',
closeBundle() {
if (command !== 'build') return
const outDir = resolve(__dirname, '../paskia/frontend-build')
const moves = [
{ from: 'auth.html', to: 'auth/index.html' },
{ from: 'admin.html', to: 'admin/index.html' },
{ from: 'restricted.html', to: 'restricted/index.html' },
{ from: 'reset.html', to: 'reset/index.html' },
{ from: 'forward.html', to: 'forward/index.html' }
]
for (const { from, to } of moves) {
const fromPath = resolve(outDir, from)
const toPath = resolve(outDir, to)
if (existsSync(fromPath)) {
mkdirSync(resolve(outDir, to.split('/')[0]), { recursive: true })
renameSync(fromPath, toPath)
}
}
}
}
].filter(Boolean),
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
}
},
base: '/',
server: {
port: 4403,
allowedHosts: true,
fs: {
allow: ['..']
}
},
build: {
outDir: '../paskia/frontend-build',
emptyOutDir: true,
rollupOptions: {
input: {
auth: resolve(__dirname, 'auth/index.html'),
admin: resolve(__dirname, 'auth/admin/index.html'),
restricted: resolve(__dirname, 'auth/restricted/index.html'),
reset: resolve(__dirname, 'int/reset/index.html'),
forward: resolve(__dirname, 'int/forward/index.html'),
},
output: {
entryFileNames: (chunkInfo) => {
return 'auth/assets/[name]-[hash].js'
},
chunkFileNames: (chunkInfo) => {
return 'auth/assets/[name]-[hash].js'
},
assetFileNames: (assetInfo) => {
return 'auth/assets/[name]-[hash][extname]'
}
}
}
}
}))