Update forward API to return in JSON iframe srcdoc with options injected. (currently broken in dev mode).

This commit is contained in:
2025-12-04 01:58:18 +00:00
parent 4482a601f3
commit b9b1c995f9
10 changed files with 111 additions and 63 deletions
+31 -6
View File
@@ -103,7 +103,32 @@
} }
}); });
function showLoginMode() { // Cache for auth iframe HTML by mode
const authIframeHtmlCache = {};
async function getAuthIframeHtml(mode = 'login') {
if (authIframeHtmlCache[mode]) {
return authIframeHtmlCache[mode];
}
// Fetch from forward endpoint - it returns HTML in auth.iframe on 401/403
const response = await fetch('/auth/api/forward', { credentials: 'include' });
if (response.status === 401 || response.status === 403) {
const data = await response.json();
if (data.auth?.iframe) {
let html = data.auth.iframe;
if (mode !== data.auth.mode) {
// Replace data-mode attribute value
html = html.replace(/data-mode="[^"]*"/, `data-mode="${mode}"`);
}
authIframeHtmlCache[mode] = html;
return html;
}
}
throw new Error('Unable to fetch auth iframe HTML');
}
async function showLoginMode() {
let iframe = document.getElementById('auth-iframe'); let iframe = document.getElementById('auth-iframe');
if (!iframe) { if (!iframe) {
iframe = document.createElement('iframe'); iframe = document.createElement('iframe');
@@ -111,11 +136,11 @@
iframe.title = 'Authentication'; iframe.title = 'Authentication';
document.body.appendChild(iframe); document.body.appendChild(iframe);
} }
iframe.src = '/auth/restricted/?mode=login'; iframe.srcdoc = await getAuthIframeHtml('login');
showStatus('Login mode loaded - for users who are not authenticated', 'info'); showStatus('Login mode loaded - for users who are not authenticated', 'info');
} }
function showReauthMode() { async function showReauthMode() {
let iframe = document.getElementById('auth-iframe'); let iframe = document.getElementById('auth-iframe');
if (!iframe) { if (!iframe) {
iframe = document.createElement('iframe'); iframe = document.createElement('iframe');
@@ -123,17 +148,17 @@
iframe.title = 'Authentication'; iframe.title = 'Authentication';
document.body.appendChild(iframe); document.body.appendChild(iframe);
} }
iframe.src = '/auth/restricted/?mode=reauth'; iframe.srcdoc = await getAuthIframeHtml('reauth');
showStatus('Reauth mode loaded - for additional verification of authenticated users', 'info'); showStatus('Reauth mode loaded - for additional verification of authenticated users', 'info');
} }
function showAuthIframe() { async function showAuthIframe() {
let iframe = document.getElementById('auth-iframe'); let iframe = document.getElementById('auth-iframe');
if (!iframe) { if (!iframe) {
iframe = document.createElement('iframe'); iframe = document.createElement('iframe');
iframe.id = 'auth-iframe'; iframe.id = 'auth-iframe';
iframe.title = 'Authentication'; iframe.title = 'Authentication';
iframe.src = '/auth/restricted/'; iframe.srcdoc = await getAuthIframeHtml('login');
document.body.appendChild(iframe); document.body.appendChild(iframe);
iframeInitialized = true; iframeInitialized = true;
} }
+5 -4
View File
@@ -12,7 +12,7 @@
<script setup> <script setup>
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, onUnmounted, ref } from 'vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { apiJson } from '@/utils/api' import { apiJson, getAuthIframeHtml } from '@/utils/api'
import StatusMessage from '@/components/StatusMessage.vue' import StatusMessage from '@/components/StatusMessage.vue'
import ProfileView from '@/components/ProfileView.vue' import ProfileView from '@/components/ProfileView.vue'
import LoadingView from '@/components/LoadingView.vue' import LoadingView from '@/components/LoadingView.vue'
@@ -39,15 +39,16 @@ async function tryLoadUserInfo() {
} }
} }
function showAuthIframe() { async function showAuthIframe() {
// Remove existing iframe if any // Remove existing iframe if any
hideAuthIframe() hideAuthIframe()
// Create new iframe for authentication // Create new iframe for authentication using srcdoc
const html = await getAuthIframeHtml('login')
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.src = '/auth/restricted/?mode=login' authIframe.srcdoc = html
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...' loadingMessage.value = 'Authentication required...'
} }
+4 -3
View File
@@ -13,7 +13,7 @@ import AdminUserDetail from '@/admin/AdminUserDetail.vue'
import AdminDialogs from '@/admin/AdminDialogs.vue' import AdminDialogs from '@/admin/AdminDialogs.vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings'
import { apiJson } from '@/utils/api' import { apiJson, getAuthIframeHtml } from '@/utils/api'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
@@ -288,12 +288,13 @@ function deletePermission(p) {
} }) } })
} }
function showAuthIframe() { async function showAuthIframe() {
hideAuthIframe() hideAuthIframe()
const html = await getAuthIframeHtml('login')
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.src = '/auth/restricted/?mode=login' authIframe.srcdoc = html
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...' loadingMessage.value = 'Authentication required...'
} }
+6 -2
View File
@@ -10,9 +10,13 @@
import { computed, onMounted } from 'vue' import { computed, onMounted } from 'vue'
import RestrictedAuth from '@/components/RestrictedAuth.vue' import RestrictedAuth from '@/components/RestrictedAuth.vue'
// Detect mode from data attribute on html tag (injected by server)
const authMode = computed(() => { const authMode = computed(() => {
const params = new URLSearchParams(window.location.search) const htmlElement = document.documentElement
return params.get('mode') === 'reauth' ? 'reauth' : 'login' const dataMode = htmlElement.getAttribute('data-mode')
if (dataMode === 'reauth') return 'reauth'
if (dataMode === 'forbidden') return 'forbidden'
return 'login'
}) })
function postToParent(message) { function postToParent(message) {
+38 -4
View File
@@ -49,6 +49,40 @@ let authPromise = null
let authResolve = null let authResolve = null
let authReject = null let authReject = null
// Cache for auth iframe HTML by mode
const authIframeHtmlCache = {}
/**
* Get the auth iframe HTML for a given mode.
* Fetches from /auth/api/forward which returns HTML in the auth.iframe field.
* Results are cached per mode.
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
* @returns {Promise<string>} - The HTML content for the iframe
*/
export async function getAuthIframeHtml(mode = 'login') {
if (authIframeHtmlCache[mode]) {
return authIframeHtmlCache[mode]
}
// Fetch from forward endpoint - it returns HTML in auth.iframe on 401/403
const response = await fetch('/auth/api/forward', { credentials: 'include' })
if (response.status === 401 || response.status === 403) {
const data = await response.json()
if (data.auth?.iframe) {
// Cache the HTML - it's the same regardless of mode (mode is in data attrs)
// But we need to patch the mode in the HTML if different from returned
let html = data.auth.iframe
if (mode !== data.auth.mode) {
// Replace data-mode attribute value
html = html.replace(/data-mode="[^"]*"/, `data-mode="${mode}"`)
}
authIframeHtmlCache[mode] = html
return html
}
}
throw new Error('Unable to fetch auth iframe HTML')
}
/** /**
* Check if an auth iframe is already open (from any source). * Check if an auth iframe is already open (from any source).
* @returns {boolean} * @returns {boolean}
@@ -60,11 +94,11 @@ export function isAuthIframeOpen() {
/** /**
* Show the authentication iframe and return a promise that resolves on success. * Show the authentication iframe and return a promise that resolves on success.
* If an auth iframe is already open (from any source), hooks into its completion. * If an auth iframe is already open (from any source), hooks into its completion.
* @param {string} iframeSrc - The URL for the iframe src * @param {string} iframeHtml - The HTML content for the iframe srcdoc
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {AuthCancelledError} - If authentication is cancelled by user * @throws {AuthCancelledError} - If authentication is cancelled by user
*/ */
export function showAuthIframe(iframeSrc) { export function showAuthIframe(iframeHtml) {
// If we already have a promise (from us), return it // If we already have a promise (from us), return it
if (authPromise) return authPromise if (authPromise) return authPromise
@@ -86,11 +120,11 @@ export function showAuthIframe(iframeSrc) {
// Remove existing iframe if any // Remove existing iframe if any
hideAuthIframe() hideAuthIframe()
// Create new iframe for authentication // Create new iframe for authentication using srcdoc
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.src = iframeSrc authIframe.srcdoc = iframeHtml
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
return authPromise return authPromise
+1 -8
View File
@@ -33,14 +33,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI.""" """Handle AuthException with auth info for UI."""
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content=authz.auth_error_content(exc),
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
) )
+5 -21
View File
@@ -62,14 +62,7 @@ async def auth_exception_handler(_request: Request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI.""" """Handle AuthException with auth info for UI."""
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content=authz.auth_error_content(exc),
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
) )
@@ -184,27 +177,18 @@ async def forward_authentication(
wants_html = "text/html" in accept wants_html = "text/html" in accept
if wants_html: if wants_html:
# Browser request - return HTML with metadata # Browser request - return full-page HTML with metadata
html = frontend.file("int", "forward", "index.html").read_bytes()
# Inject mode and any additional metadata
data_attrs = {"mode": e.mode, **e.metadata} data_attrs = {"mode": e.mode, **e.metadata}
html = frontend.file("int", "forward", "index.html").read_bytes()
html = htmlutil.patch_html_data_attrs(html, **data_attrs) html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response( return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8" html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
) )
else: else:
# API request - return JSON with iframe src link # API request - return JSON with iframe srcdoc HTML
iframe_url = f"/auth/restricted/?mode={e.mode}"
return JSONResponse( return JSONResponse(
status_code=e.status_code, status_code=e.status_code,
content={ content=authz.auth_error_content(e),
"detail": e.detail,
"auth": {
"mode": e.mode,
"iframe": iframe_url,
**e.metadata,
},
},
) )
+19 -1
View File
@@ -2,7 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import permutil, sessionutil from ..util import frontend, htmlutil, permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -32,6 +32,24 @@ class AuthException(HTTPException):
self.metadata = metadata self.metadata = metadata
def auth_error_content(exc: AuthException) -> dict:
"""Generate JSON response content for an AuthException.
Returns a dict with detail, mode, and iframe HTML for srcdoc embedding.
"""
data_attrs = {"mode": exc.mode, **exc.metadata}
iframe_html = frontend.file("auth", "restricted", "index.html").read_bytes()
iframe_html = htmlutil.patch_html_data_attrs(iframe_html, **data_attrs)
return {
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": iframe_html.decode("utf-8"),
**exc.metadata,
},
}
async def verify( async def verify(
auth: str | None, auth: str | None,
perm: list[str], perm: list[str],
+1 -8
View File
@@ -29,14 +29,7 @@ async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI.""" """Handle AuthException with auth info for UI."""
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content={ content=authz.auth_error_content(exc),
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
) )
+1 -6
View File
@@ -26,12 +26,7 @@ def websocket_error_handler(func):
await ws.send_json( await ws.send_json(
{ {
"status": e.status_code, "status": e.status_code,
"detail": e.detail, **authz.auth_error_content(e),
"auth": {
"mode": e.mode,
"iframe": f"/auth/restricted/?mode={e.mode}",
**e.metadata,
},
} }
) )
except (ValueError, InvalidAuthenticationResponse) as e: except (ValueError, InvalidAuthenticationResponse) as e: