Add CORS testing documentation and HTML test page for Passkey Cross-Device Authentication
Some checks failed
Code Analysis (JS/Vue) / analyze (push) Failing after 55s

Introduce a comprehensive CORS testing guide in CORS_TEST_ANLEITUNG.md, detailing steps for testing OPTIONS and POST requests, along with expected responses. Additionally, add a new HTML test page (test-cors.html) to facilitate interactive testing of CORS headers and responses for the Passkey registration API. Update the server API to ensure proper CORS headers are set for Cross-Device Authentication, enhancing the overall testing and debugging process.
This commit is contained in:
Torsten Schulz (local)
2026-01-08 11:14:22 +01:00
parent 010e89212f
commit 34968742f0
6 changed files with 592 additions and 30 deletions

124
CORS_TEST_ANLEITUNG.md Normal file
View File

@@ -0,0 +1,124 @@
# CORS-Test für Passkey Cross-Device Authentication
## Test-Datei verwenden
1. **Öffne die Test-Seite:**
```
https://harheimertc.tsschulz.de/test-cors.html
```
2. **Führe die Tests aus:**
- Klicke auf "Test OPTIONS Request" - sollte Status 204 zurückgeben
- Klicke auf "Test POST Request" - sollte Status 200 zurückgeben
- Klicke auf "Test /api/auth/register-passkey-options" - sollte Options zurückgeben
3. **Prüfe die Browser-Entwicklertools:**
- Öffne F12 → Network Tab
- Führe die Tests aus
- Klicke auf jeden Request und prüfe die **Response Headers**:
- `Access-Control-Allow-Origin` sollte `https://harheimertc.tsschulz.de` sein
- `Access-Control-Allow-Credentials` sollte `true` sein
- `Access-Control-Allow-Methods` sollte `GET, POST, OPTIONS` enthalten
## Manueller Test mit curl
### OPTIONS Preflight Test:
```bash
curl -X OPTIONS \
-H "Origin: https://harheimertc.tsschulz.de" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-v \
https://harheimertc.tsschulz.de/api/auth/register-passkey-options
```
**Erwartete Response:**
- Status: 204 No Content
- Header: `Access-Control-Allow-Origin: https://harheimertc.tsschulz.de`
- Header: `Access-Control-Allow-Credentials: true`
### POST Request Test:
```bash
curl -X POST \
-H "Origin: https://harheimertc.tsschulz.de" \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"test@example.com"}' \
-v \
https://harheimertc.tsschulz.de/api/auth/register-passkey-options
```
**Erwartete Response:**
- Status: 200 OK
- Header: `Access-Control-Allow-Origin: https://harheimertc.tsschulz.de`
- Body: JSON mit `success: true` und `options` Objekt
## Browser Console Test
Öffne die Browser-Konsole (F12) und führe aus:
```javascript
// Test 1: OPTIONS Preflight
fetch('https://harheimertc.tsschulz.de/api/auth/register-passkey-options', {
method: 'OPTIONS',
headers: {
'Origin': window.location.origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type'
}
}).then(r => {
console.log('OPTIONS Status:', r.status);
console.log('CORS Headers:', {
origin: r.headers.get('Access-Control-Allow-Origin'),
credentials: r.headers.get('Access-Control-Allow-Credentials'),
methods: r.headers.get('Access-Control-Allow-Methods')
});
});
// Test 2: POST Request
fetch('https://harheimertc.tsschulz.de/api/auth/register-passkey-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': window.location.origin
},
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com'
})
}).then(r => r.json()).then(data => {
console.log('POST Response:', data);
console.log('Has Options:', !!data.options);
console.log('Has Challenge:', !!data.options?.challenge);
});
```
## Was zu prüfen ist:
1. **OPTIONS Request:**
- Status sollte 204 sein (nicht 200 oder 404)
- CORS-Header müssen vorhanden sein
2. **POST Request:**
- Status sollte 200 sein
- CORS-Header müssen vorhanden sein
- Response sollte `success: true` und `options` enthalten
3. **Cross-Device Problem:**
- Wenn CORS korrekt ist, aber Cross-Device trotzdem nicht funktioniert:
- Prüfe, ob der QR-Code die richtige Origin enthält
- Prüfe, ob das Smartphone die gleiche Origin erreichen kann
- Prüfe die Browser-Logs auf dem Smartphone (falls möglich)
## Häufige Probleme:
1. **Apache überschreibt CORS-Header:**
- Lösung: In Apache-Config sicherstellen, dass CORS-Header nicht überschrieben werden
2. **OPTIONS gibt 404 zurück:**
- Lösung: Endpoint muss OPTIONS-Requests explizit behandeln
3. **CORS-Header fehlen:**
- Lösung: Server-Endpoint muss CORS-Header setzen
4. **Origin-Mismatch:**
- Lösung: `WEBAUTHN_ORIGIN` muss exakt mit der Browser-Origin übereinstimmen

View File

@@ -505,8 +505,7 @@ const handleRegisterWithPasskey = async () => {
const webauthnStart = Date.now()
const mod = await import('@simplewebauthn/browser')
// startRegistration erwartet die Options direkt
// @simplewebauthn/browser v13+ erwartet die Options direkt
// startRegistration erwartet die Options direkt (wie in anderen Dateien auch)
let credential
try {
// Timeout-Warnung nach 2 Minuten
@@ -517,29 +516,11 @@ const handleRegisterWithPasskey = async () => {
console.warn('[DEBUG] Challenge:', pre.options?.challenge)
}, 120000)
// Stelle sicher, dass die Options korrekt formatiert sind
// @simplewebauthn/browser v13+ erwartet die Options direkt als Objekt
const registrationOptions = {
challenge: pre.options.challenge,
rp: pre.options.rp,
user: pre.options.user,
pubKeyCredParams: pre.options.pubKeyCredParams,
timeout: pre.options.timeout,
attestation: pre.options.attestation || 'none',
excludeCredentials: pre.options.excludeCredentials || [],
authenticatorSelection: pre.options.authenticatorSelection,
extensions: pre.options.extensions || {}
}
console.log('[DEBUG] startRegistration called - QR-Code should appear now (if Cross-Device)')
console.log('[DEBUG] Registration options structure:', {
hasChallenge: !!registrationOptions.challenge,
hasRp: !!registrationOptions.rp,
hasUser: !!registrationOptions.user,
timeout: registrationOptions.timeout
})
console.log('[DEBUG] Passing options directly to startRegistration (same as in profil.vue)')
credential = await mod.startRegistration(registrationOptions)
// Direkt die Options übergeben (wie in profil.vue und passkey-wiederherstellen.vue)
credential = await mod.startRegistration(pre.options)
clearTimeout(timeoutWarning)

214
public/test-cors.html Normal file
View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html>
<head>
<title>CORS Test für Passkey Cross-Device</title>
<style>
body { font-family: monospace; padding: 20px; }
.test { margin: 20px 0; padding: 10px; border: 1px solid #ccc; }
.success { background: #d4edda; }
.error { background: #f8d7da; }
.info { background: #d1ecf1; }
button { padding: 10px 20px; margin: 5px; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>CORS Test für Passkey Cross-Device Authentication</h1>
<div class="test info">
<h3>1. Origin-Info</h3>
<p><strong>Current Origin:</strong> <span id="currentOrigin"></span></p>
<p><strong>Is Secure Context:</strong> <span id="isSecure"></span></p>
</div>
<div class="test">
<h3>2. OPTIONS Preflight Test</h3>
<button onclick="testOptions()">Test OPTIONS Request</button>
<pre id="optionsResult"></pre>
</div>
<div class="test">
<h3>3. POST Request Test (mit Origin-Header)</h3>
<button onclick="testPost()">Test POST Request</button>
<pre id="postResult"></pre>
</div>
<div class="test">
<h3>4. Registration Options Test</h3>
<button onclick="testRegistrationOptions()">Test /api/auth/register-passkey-options</button>
<pre id="registrationResult"></pre>
</div>
<div class="test">
<h3>5. CORS Headers Check (Network Tab)</h3>
<p>Öffne die Browser-Entwicklertools (F12) → Network Tab</p>
<p>Führe die Tests oben aus und prüfe:</p>
<ul>
<li><strong>OPTIONS Request:</strong> Sollte Status 204 haben</li>
<li><strong>Response Headers:</strong> Sollten enthalten:
<ul>
<li>Access-Control-Allow-Origin: <span id="currentOrigin2"></span></li>
<li>Access-Control-Allow-Credentials: true</li>
<li>Access-Control-Allow-Methods: GET, POST, OPTIONS</li>
</ul>
</li>
</ul>
</div>
<script>
const origin = window.location.origin;
document.getElementById('currentOrigin').textContent = origin;
document.getElementById('currentOrigin2').textContent = origin;
document.getElementById('isSecure').textContent = window.isSecureContext ? 'JA ✓' : 'NEIN ✗';
async function testOptions() {
const resultEl = document.getElementById('optionsResult');
resultEl.textContent = 'Testing OPTIONS request...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'OPTIONS',
headers: {
'Origin': origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type'
}
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
resultEl.innerHTML = `
Status: ${response.status} ${response.status === 204 ? '✓' : '✗'}
Status Text: ${response.statusText}
Response Headers:
${JSON.stringify(headers, null, 2)}
CORS Headers Check:
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
- Access-Control-Allow-Methods: ${headers['access-control-allow-methods'] || 'FEHLT ✗'}
- Access-Control-Allow-Headers: ${headers['access-control-allow-headers'] || 'FEHLT ✗'}
`;
if (response.status === 204 && headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
async function testPost() {
const resultEl = document.getElementById('postResult');
resultEl.textContent = 'Testing POST request...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': origin
},
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com'
})
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const data = await response.json().catch(() => ({ error: 'Could not parse JSON' }));
resultEl.innerHTML = `
Status: ${response.status}
Status Text: ${response.statusText}
Response Headers:
${JSON.stringify(headers, null, 2)}
Response Body:
${JSON.stringify(data, null, 2)}
CORS Headers Check:
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
`;
if (headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
async function testRegistrationOptions() {
const resultEl = document.getElementById('registrationResult');
resultEl.textContent = 'Testing registration options endpoint...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com',
phone: ''
})
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const data = await response.json();
resultEl.innerHTML = `
Status: ${response.status}
Status Text: ${response.statusText}
Response Headers (CORS):
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
- Access-Control-Allow-Methods: ${headers['access-control-allow-methods'] || 'FEHLT ✗'}
Response Body:
${JSON.stringify(data, null, 2)}
Options Structure:
- hasChallenge: ${data.options?.challenge ? 'JA ✓' : 'NEIN ✗'}
- hasRp: ${data.options?.rp ? 'JA ✓' : 'NEIN ✗'}
- hasUser: ${data.options?.user ? 'JA ✓' : 'NEIN ✗'}
- rpId: ${data.options?.rp?.id || 'FEHLT'}
- timeout: ${data.options?.timeout || 'FEHLT'}
`;
if (data.success && data.options && headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
</script>
</body>
</html>

View File

@@ -125,17 +125,28 @@ export default defineEventHandler(async (event) => {
await writeAuditLog('auth.passkey.prereg.options', { email })
// CORS-Header für Cross-Device Authentication
if (requestOrigin) {
setHeader(event, 'Access-Control-Allow-Origin', requestOrigin)
// WICHTIG: Für Cross-Device muss CORS korrekt konfiguriert sein
const allowedOrigin = requestOrigin || webauthnOrigin
if (allowedOrigin) {
setHeader(event, 'Access-Control-Allow-Origin', allowedOrigin)
setHeader(event, 'Access-Control-Allow-Credentials', 'true')
setHeader(event, 'Access-Control-Allow-Methods', 'POST, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization')
console.log('[DEBUG] CORS headers set', { origin: requestOrigin })
setHeader(event, 'Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization, Origin, X-Requested-With')
setHeader(event, 'Access-Control-Max-Age', '86400') // 24 Stunden Cache für Preflight
console.log('[DEBUG] CORS headers set', {
origin: allowedOrigin,
requestOrigin,
webauthnOrigin,
method: getMethod(event)
})
}
// OPTIONS Preflight-Request für Cross-Device
if (getMethod(event) === 'OPTIONS') {
console.log('[DEBUG] OPTIONS request, returning early')
return { success: true }
console.log('[DEBUG] OPTIONS preflight request, returning 204')
setResponseStatus(event, 204)
return null
}
// Stelle sicher, dass die Options korrekt serialisiert werden

View File

@@ -11,12 +11,30 @@ import { assertPasswordNotPwned } from '../../utils/hibp.js'
export default defineEventHandler(async (event) => {
const requestStart = Date.now()
const requestOrigin = getHeader(event, 'origin')
const { origin: webauthnOrigin } = getWebAuthnConfig()
console.log('[DEBUG] register-passkey request received', {
origin: requestOrigin,
webauthnOrigin,
timestamp: new Date().toISOString()
})
// CORS-Header für Cross-Device Authentication
const allowedOrigin = requestOrigin || webauthnOrigin
if (allowedOrigin) {
setHeader(event, 'Access-Control-Allow-Origin', allowedOrigin)
setHeader(event, 'Access-Control-Allow-Credentials', 'true')
setHeader(event, 'Access-Control-Allow-Methods', 'POST, OPTIONS')
setHeader(event, 'Access-Control-Allow-Headers', 'Content-Type, Authorization, Origin, X-Requested-With')
}
// OPTIONS Preflight-Request
if (getMethod(event) === 'OPTIONS') {
console.log('[DEBUG] OPTIONS preflight request, returning 204')
setResponseStatus(event, 204)
return null
}
const body = await readBody(event)
const registrationId = String(body?.registrationId || '')
const response = body?.credential

214
test-cors.html Normal file
View File

@@ -0,0 +1,214 @@
<!DOCTYPE html>
<html>
<head>
<title>CORS Test für Passkey Cross-Device</title>
<style>
body { font-family: monospace; padding: 20px; }
.test { margin: 20px 0; padding: 10px; border: 1px solid #ccc; }
.success { background: #d4edda; }
.error { background: #f8d7da; }
.info { background: #d1ecf1; }
button { padding: 10px 20px; margin: 5px; }
pre { background: #f5f5f5; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>CORS Test für Passkey Cross-Device Authentication</h1>
<div class="test info">
<h3>1. Origin-Info</h3>
<p><strong>Current Origin:</strong> <span id="currentOrigin"></span></p>
<p><strong>Is Secure Context:</strong> <span id="isSecure"></span></p>
</div>
<div class="test">
<h3>2. OPTIONS Preflight Test</h3>
<button onclick="testOptions()">Test OPTIONS Request</button>
<pre id="optionsResult"></pre>
</div>
<div class="test">
<h3>3. POST Request Test (mit Origin-Header)</h3>
<button onclick="testPost()">Test POST Request</button>
<pre id="postResult"></pre>
</div>
<div class="test">
<h3>4. Registration Options Test</h3>
<button onclick="testRegistrationOptions()">Test /api/auth/register-passkey-options</button>
<pre id="registrationResult"></pre>
</div>
<div class="test">
<h3>5. CORS Headers Check (Network Tab)</h3>
<p>Öffne die Browser-Entwicklertools (F12) → Network Tab</p>
<p>Führe die Tests oben aus und prüfe:</p>
<ul>
<li><strong>OPTIONS Request:</strong> Sollte Status 204 haben</li>
<li><strong>Response Headers:</strong> Sollten enthalten:
<ul>
<li>Access-Control-Allow-Origin: <span id="currentOrigin2"></span></li>
<li>Access-Control-Allow-Credentials: true</li>
<li>Access-Control-Allow-Methods: GET, POST, OPTIONS</li>
</ul>
</li>
</ul>
</div>
<script>
const origin = window.location.origin;
document.getElementById('currentOrigin').textContent = origin;
document.getElementById('currentOrigin2').textContent = origin;
document.getElementById('isSecure').textContent = window.isSecureContext ? 'JA ✓' : 'NEIN ✗';
async function testOptions() {
const resultEl = document.getElementById('optionsResult');
resultEl.textContent = 'Testing OPTIONS request...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'OPTIONS',
headers: {
'Origin': origin,
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'Content-Type'
}
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
resultEl.innerHTML = `
Status: ${response.status} ${response.status === 204 ? '✓' : '✗'}
Status Text: ${response.statusText}
Response Headers:
${JSON.stringify(headers, null, 2)}
CORS Headers Check:
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
- Access-Control-Allow-Methods: ${headers['access-control-allow-methods'] || 'FEHLT ✗'}
- Access-Control-Allow-Headers: ${headers['access-control-allow-headers'] || 'FEHLT ✗'}
`;
if (response.status === 204 && headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
async function testPost() {
const resultEl = document.getElementById('postResult');
resultEl.textContent = 'Testing POST request...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Origin': origin
},
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com'
})
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const data = await response.json().catch(() => ({ error: 'Could not parse JSON' }));
resultEl.innerHTML = `
Status: ${response.status}
Status Text: ${response.statusText}
Response Headers:
${JSON.stringify(headers, null, 2)}
Response Body:
${JSON.stringify(data, null, 2)}
CORS Headers Check:
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
`;
if (headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
async function testRegistrationOptions() {
const resultEl = document.getElementById('registrationResult');
resultEl.textContent = 'Testing registration options endpoint...';
try {
const response = await fetch('/api/auth/register-passkey-options', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: 'Test User',
email: 'test@example.com',
phone: ''
})
});
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
const data = await response.json();
resultEl.innerHTML = `
Status: ${response.status}
Status Text: ${response.statusText}
Response Headers (CORS):
- Access-Control-Allow-Origin: ${headers['access-control-allow-origin'] || 'FEHLT ✗'}
- Access-Control-Allow-Credentials: ${headers['access-control-allow-credentials'] || 'FEHLT ✗'}
- Access-Control-Allow-Methods: ${headers['access-control-allow-methods'] || 'FEHLT ✗'}
Response Body:
${JSON.stringify(data, null, 2)}
Options Structure:
- hasChallenge: ${data.options?.challenge ? 'JA ✓' : 'NEIN ✗'}
- hasRp: ${data.options?.rp ? 'JA ✓' : 'NEIN ✗'}
- hasUser: ${data.options?.user ? 'JA ✓' : 'NEIN ✗'}
- rpId: ${data.options?.rp?.id || 'FEHLT'}
- timeout: ${data.options?.timeout || 'FEHLT'}
`;
if (data.success && data.options && headers['access-control-allow-origin']) {
resultEl.parentElement.className = 'test success';
} else {
resultEl.parentElement.className = 'test error';
}
} catch (error) {
resultEl.textContent = `ERROR: ${error.message}\n${error.stack}`;
resultEl.parentElement.className = 'test error';
}
}
</script>
</body>
</html>