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:
parent
9443178d1c
commit
88118cd157
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue