Revert earlier change to iframe srcdoc, using src instead, because srcdoc was not compatible with all passkey implementations (BitWarden).

This commit is contained in:
Leo Vasanko
2025-12-03 18:01:47 -06:00
parent 9b73684082
commit afbd9606db
8 changed files with 47 additions and 44 deletions
+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, getAuthIframeHtml } from '@/utils/api' import { apiJson, getAuthIframeUrl } 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'
@@ -42,12 +42,13 @@ async function showAuthIframe() {
// Remove existing iframe if any // Remove existing iframe if any
hideAuthIframe() hideAuthIframe()
// Create new iframe for authentication using srcdoc // Create new iframe for authentication using src URL
const html = await getAuthIframeHtml('login') const url = await getAuthIframeUrl('login')
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.srcdoc = html authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = url
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, getAuthIframeHtml } from '@/utils/api' import { apiJson, getAuthIframeUrl } from '@/utils/api'
const info = ref(null) const info = ref(null)
const loading = ref(true) const loading = ref(true)
@@ -299,11 +299,12 @@ function deletePermission(p) {
async function showAuthIframe() { async function showAuthIframe() {
hideAuthIframe() hideAuthIframe()
const html = await getAuthIframeHtml('login') const url = await getAuthIframeUrl('login')
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.srcdoc = html authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = url
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
loadingMessage.value = 'Authentication required...' loadingMessage.value = 'Authentication required...'
} }
+5 -5
View File
@@ -10,12 +10,12 @@
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) // Detect mode from URL hash fragment
const authMode = computed(() => { const authMode = computed(() => {
const htmlElement = document.documentElement const params = new URLSearchParams(window.location.hash.slice(1))
const dataMode = htmlElement.getAttribute('data-mode') const mode = params.get('mode')
if (dataMode === 'reauth') return 'reauth' if (mode === 'reauth') return 'reauth'
if (dataMode === 'forbidden') return 'forbidden' if (mode === 'forbidden') return 'forbidden'
return 'login' return 'login'
}) })
+21 -21
View File
@@ -49,38 +49,37 @@ let authPromise = null
let authResolve = null let authResolve = null
let authReject = null let authReject = null
// Cache for auth iframe HTML by mode // Cache for auth iframe URL by mode
const authIframeHtmlCache = {} const authIframeUrlCache = {}
/** /**
* Get the auth iframe HTML for a given mode. * Get the auth iframe URL for a given mode.
* Fetches from /auth/api/forward which returns HTML in the auth.iframe field. * Fetches from /auth/api/forward which returns URL in the auth.iframe field.
* Results are cached per mode. * Results are cached per mode.
* @param {string} mode - The auth mode ('login', 'reauth', 'forbidden') * @param {string} mode - The auth mode ('login', 'reauth', 'forbidden')
* @returns {Promise<string>} - The HTML content for the iframe * @returns {Promise<string>} - The URL for the iframe
*/ */
export async function getAuthIframeHtml(mode = 'login') { export async function getAuthIframeUrl(mode = 'login') {
if (authIframeHtmlCache[mode]) { if (authIframeUrlCache[mode]) {
return authIframeHtmlCache[mode] return authIframeUrlCache[mode]
} }
// Fetch from forward endpoint - it returns HTML in auth.iframe on 401/403 // Fetch from forward endpoint - it returns URL in auth.iframe on 401/403
const response = await fetch('/auth/api/forward', { credentials: 'include' }) const response = await fetch('/auth/api/forward', { credentials: 'include' })
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
const data = await response.json() const data = await response.json()
if (data.auth?.iframe) { if (data.auth?.iframe) {
// Cache the HTML - it's the same regardless of mode (mode is in data attrs) // The iframe field now contains a URL with hash fragment
// But we need to patch the mode in the HTML if different from returned // If mode differs, update the hash param
let html = data.auth.iframe let url = data.auth.iframe
if (mode !== data.auth.mode) { if (mode !== data.auth.mode) {
// Replace data-mode attribute value url = url.replace(/mode=[^&]*/, `mode=${mode}`)
html = html.replace(/data-mode="[^"]*"/, `data-mode="${mode}"`)
} }
authIframeHtmlCache[mode] = html authIframeUrlCache[mode] = url
return html return url
} }
} }
throw new Error('Unable to fetch auth iframe HTML') throw new Error('Unable to fetch auth iframe URL')
} }
/** /**
@@ -94,11 +93,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} iframeHtml - The HTML content for the iframe srcdoc * @param {string} iframeUrl - The URL for the iframe src
* @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(iframeHtml) { export function showAuthIframe(iframeUrl) {
// 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
@@ -120,11 +119,12 @@ export function showAuthIframe(iframeHtml) {
// Remove existing iframe if any // Remove existing iframe if any
hideAuthIframe() hideAuthIframe()
// Create new iframe for authentication using srcdoc // Create new iframe for authentication using src URL
authIframe = document.createElement('iframe') authIframe = document.createElement('iframe')
authIframe.id = 'auth-iframe' authIframe.id = 'auth-iframe'
authIframe.title = 'Authentication' authIframe.title = 'Authentication'
authIframe.srcdoc = iframeHtml authIframe.allow = 'publickey-credentials-get; publickey-credentials-create'
authIframe.src = iframeUrl
document.body.appendChild(authIframe) document.body.appendChild(authIframe)
return authPromise return authPromise
+2 -2
View File
@@ -37,7 +37,7 @@ export async function register(resetToken = null, displayName = null, onstartreg
// Notify caller that we're about to show the browser prompt // Notify caller that we're about to show the browser prompt
if (onstartreg) onstartreg() if (onstartreg) onstartreg()
const registrationResponse = await startRegistration({ optionsJSON: res }) const registrationResponse = await startRegistration(res)
ws.send_json(registrationResponse) ws.send_json(registrationResponse)
const result = await ws.receive_json() const result = await ws.receive_json()
@@ -64,7 +64,7 @@ export async function authenticate() {
throw new Error(res.detail || `Authentication failed: ${res.status}`) throw new Error(res.detail || `Authentication failed: ${res.status}`)
} }
const authResponse = await startAuthentication({ optionsJSON: res }) const authResponse = await startAuthentication(res)
ws.send_json(authResponse) ws.send_json(authResponse)
const result = await ws.receive_json() const result = await ws.receive_json()
+7 -6
View File
@@ -2,7 +2,7 @@ import logging
from fastapi import HTTPException from fastapi import HTTPException
from ..util import frontend, htmlutil, permutil, sessionutil from ..util import permutil, sessionutil
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -35,16 +35,17 @@ class AuthException(HTTPException):
async def auth_error_content(exc: AuthException) -> dict: async def auth_error_content(exc: AuthException) -> dict:
"""Generate JSON response content for an AuthException. """Generate JSON response content for an AuthException.
Returns a dict with detail, mode, and iframe HTML for srcdoc embedding. Returns a dict with detail, mode, and iframe URL for src embedding.
""" """
data_attrs = {"mode": exc.mode, **exc.metadata} # Build hash fragment from mode and metadata
iframe_html = (await frontend.read("/auth/restricted/index.html"))[0] params = {"mode": exc.mode, **exc.metadata}
iframe_html = htmlutil.patch_html_data_attrs(iframe_html, **data_attrs) fragment = "&".join(f"{k}={v}" for k, v in params.items() if v is not None)
iframe_url = f"/auth/restricted/#{fragment}"
return { return {
"detail": exc.detail, "detail": exc.detail,
"auth": { "auth": {
"mode": exc.mode, "mode": exc.mode,
"iframe": iframe_html.decode("utf-8"), "iframe": iframe_url,
**exc.metadata, **exc.metadata,
}, },
} }
+2 -2
View File
@@ -56,7 +56,7 @@ async def register_chat(
credential_ids=credential_ids, credential_ids=credential_ids,
origin=origin, origin=origin,
) )
await ws.send_json(options) await ws.send_json({"optionsJSON": options})
response = await ws.receive_json() response = await ws.receive_json()
return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin) return passkey.instance.reg_verify(response, challenge, user_uuid, origin=origin)
@@ -150,7 +150,7 @@ async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
options, challenge = passkey.instance.auth_generate_options( options, challenge = passkey.instance.auth_generate_options(
credential_ids=credential_ids credential_ids=credential_ids
) )
await ws.send_json(options) await ws.send_json({"optionsJSON": options})
# Wait for the client to use his authenticator to authenticate # Wait for the client to use his authenticator to authenticate
credential = passkey.instance.auth_parse(await ws.receive_json()) credential = passkey.instance.auth_parse(await ws.receive_json())
# Fetch from the database by credential ID # Fetch from the database by credential ID
+1 -1
View File
@@ -184,7 +184,7 @@ class Passkey:
authopts: Additional arguments to generate_authentication_options. authopts: Additional arguments to generate_authentication_options.
Returns: Returns:
Tuple of (JSON to be sent to client, challenge bytes to store) Tuple of (JSON dict to be sent to client, challenge bytes to store)
""" """
options = generate_authentication_options( options = generate_authentication_options(
rp_id=self.rp_id, rp_id=self.rp_id,