fix: use server-initiated WebAuthn flow for guardian acceptance and login

The guardian page and auth.rspace.online login page were using the
client-side authenticatePasskey()/registerPasskey() SDK functions which
generate their own challenge and return AuthenticationResult — but then
tried to send result.challenge and result.credential (both undefined)
to the server. This caused postgres to throw "UNDEFINED_VALUE" resulting
in a 500 "Internal Server Error" that the client couldn't parse as JSON.

Fix: use the proper server-initiated flow matching rstack-identity.ts:
1. POST /api/auth/start (or /register/start) to get server challenge
2. navigator.credentials.get/create with that challenge
3. POST /api/auth/complete (or /register/complete) with challenge + credential

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-02 16:34:43 -08:00
parent 9443178d1c
commit 88118cd157
1 changed files with 160 additions and 64 deletions

View File

@ -1695,7 +1695,7 @@ app.get('/guardian', (c) => {
</div>
<script type="module">
import { authenticatePasskey, registerPasskey } from '/dist/index.js';
import { bufferToBase64url, base64urlToBuffer } from '/dist/index.js';
const params = new URLSearchParams(window.location.search);
const inviteToken = params.get('token');
@ -1728,69 +1728,117 @@ app.get('/guardian', (c) => {
}
}
// Server-initiated auth: /api/auth/start → WebAuthn get → /api/auth/complete
async function serverAuth() {
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
},
});
if (!credential) throw new Error('Authentication failed');
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || 'Authentication failed');
return data;
}
// Server-initiated registration: /api/register/start → WebAuthn create → /api/register/complete
async function serverRegister(username) {
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: serverOptions.challenge, credential: credentialData, userId, username }),
});
const data = await completeRes.json();
if (!completeRes.ok || !data.success) throw new Error(data.error || 'Registration failed');
return data;
}
async function acceptWithToken(token) {
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ inviteToken }),
});
const acceptData = await acceptRes.json();
if (acceptData.error) throw new Error(acceptData.error);
statusEl.className = 'status success';
statusEl.textContent = 'You are now a guardian!';
document.getElementById('info').classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
document.getElementById('done-owner').textContent = ownerUsername;
}
window.acceptInvite = async () => {
const btn = document.getElementById('accept-btn');
btn.disabled = true;
btn.textContent = 'Signing in...';
try {
// Try to authenticate first
let authResult;
let token;
try {
authResult = await authenticatePasskey();
// Try to authenticate with existing passkey
const authData = await serverAuth();
token = authData.token;
} catch {
// If no passkey, prompt to register
const username = prompt('No passkey found. Create an account?\\nChoose a username:');
if (!username) { btn.disabled = false; btn.textContent = 'Accept & Sign In'; return; }
authResult = await registerPasskey(username, username);
// Complete registration
const regRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: authResult.challenge, credential: authResult.credential, userId: authResult.userId, username }),
});
const regData = await regRes.json();
if (!regData.success) throw new Error(regData.error || 'Registration failed');
localStorage.setItem('encryptid_token', regData.token);
// Now accept
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + regData.token },
body: JSON.stringify({ inviteToken }),
});
const acceptData = await acceptRes.json();
if (acceptData.error) throw new Error(acceptData.error);
statusEl.className = 'status success';
statusEl.textContent = 'You are now a guardian!';
document.getElementById('info').classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
document.getElementById('done-owner').textContent = ownerUsername;
return;
const regData = await serverRegister(username);
token = regData.token;
}
// Complete authentication
const authRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ challenge: authResult.challenge, credential: authResult.credential }),
});
const authData = await authRes.json();
if (!authData.success) throw new Error(authData.error || 'Auth failed');
localStorage.setItem('encryptid_token', authData.token);
// Accept the guardian invite
const acceptRes = await fetch('/api/guardian/accept', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + authData.token },
body: JSON.stringify({ inviteToken }),
});
const acceptData = await acceptRes.json();
if (acceptData.error) throw new Error(acceptData.error);
statusEl.className = 'status success';
statusEl.textContent = 'You are now a guardian!';
document.getElementById('info').classList.add('hidden');
document.getElementById('done').classList.remove('hidden');
document.getElementById('done-owner').textContent = ownerUsername;
localStorage.setItem('encryptid_token', token);
await acceptWithToken(token);
} catch (err) {
statusEl.className = 'status error';
statusEl.textContent = 'Failed: ' + err.message;
@ -2903,8 +2951,8 @@ app.get('/', (c) => {
<script type="module">
import {
registerPasskey,
authenticatePasskey,
bufferToBase64url,
base64urlToBuffer,
getKeyManager,
getSessionManager,
detectCapabilities,
@ -2984,13 +3032,44 @@ app.get('/', (c) => {
const email = document.getElementById('reg-email').value.trim();
btn.textContent = 'Creating passkey...';
const credential = await registerPasskey(username, username);
// Complete registration with server
// Server-initiated registration flow
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName: username }),
});
if (!startRes.ok) throw new Error('Failed to start registration');
const { options: serverOptions, userId } = await startRes.json();
const credential = await navigator.credentials.create({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rp: { id: serverOptions.rp?.id || 'rspace.online', name: serverOptions.rp?.name || 'EncryptID' },
user: { id: new Uint8Array(base64urlToBuffer(serverOptions.user.id)), name: username, displayName: username },
pubKeyCredParams: serverOptions.pubKeyCredParams || [
{ alg: -7, type: 'public-key' },
{ alg: -257, type: 'public-key' },
],
authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required' },
attestation: 'none',
timeout: 60000,
},
});
if (!credential) throw new Error('Failed to create credential');
const response = credential.response;
const publicKey = response.getPublicKey?.();
const credentialData = {
credentialId: bufferToBase64url(credential.rawId),
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
transports: response.getTransports?.() || [],
};
const regBody = {
challenge: credential.challenge,
credential: credential.credential,
userId: credential.userId,
challenge: serverOptions.challenge,
credential: credentialData,
userId,
username,
};
if (email) regBody.email = email;
@ -3006,15 +3085,32 @@ app.get('/', (c) => {
showProfile(data.token, username, data.did);
} else {
btn.textContent = 'Waiting for passkey...';
const result = await authenticatePasskey();
// Complete auth with server
// Server-initiated auth flow
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
if (!startRes.ok) throw new Error('Failed to start authentication');
const { options: serverOptions } = await startRes.json();
const credential = await navigator.credentials.get({
publicKey: {
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
rpId: serverOptions.rpId || 'rspace.online',
userVerification: 'required',
timeout: 60000,
},
});
if (!credential) throw new Error('Authentication failed');
const res = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: result.challenge,
credential: result.credential,
challenge: serverOptions.challenge,
credential: { credentialId: bufferToBase64url(credential.rawId) },
}),
});
const data = await res.json();