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>
|
</div>
|
||||||
|
|
||||||
<script type="module">
|
<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 params = new URLSearchParams(window.location.search);
|
||||||
const inviteToken = params.get('token');
|
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 () => {
|
window.acceptInvite = async () => {
|
||||||
const btn = document.getElementById('accept-btn');
|
const btn = document.getElementById('accept-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Signing in...';
|
btn.textContent = 'Signing in...';
|
||||||
try {
|
try {
|
||||||
// Try to authenticate first
|
let token;
|
||||||
let authResult;
|
|
||||||
try {
|
try {
|
||||||
authResult = await authenticatePasskey();
|
// Try to authenticate with existing passkey
|
||||||
|
const authData = await serverAuth();
|
||||||
|
token = authData.token;
|
||||||
} catch {
|
} catch {
|
||||||
// If no passkey, prompt to register
|
// If no passkey, prompt to register
|
||||||
const username = prompt('No passkey found. Create an account?\\nChoose a username:');
|
const username = prompt('No passkey found. Create an account?\\nChoose a username:');
|
||||||
if (!username) { btn.disabled = false; btn.textContent = 'Accept & Sign In'; return; }
|
if (!username) { btn.disabled = false; btn.textContent = 'Accept & Sign In'; return; }
|
||||||
authResult = await registerPasskey(username, username);
|
const regData = await serverRegister(username);
|
||||||
// Complete registration
|
token = regData.token;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
localStorage.setItem('encryptid_token', token);
|
||||||
// Complete authentication
|
await acceptWithToken(token);
|
||||||
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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
statusEl.className = 'status error';
|
statusEl.className = 'status error';
|
||||||
statusEl.textContent = 'Failed: ' + err.message;
|
statusEl.textContent = 'Failed: ' + err.message;
|
||||||
|
|
@ -2903,8 +2951,8 @@ app.get('/', (c) => {
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import {
|
import {
|
||||||
registerPasskey,
|
bufferToBase64url,
|
||||||
authenticatePasskey,
|
base64urlToBuffer,
|
||||||
getKeyManager,
|
getKeyManager,
|
||||||
getSessionManager,
|
getSessionManager,
|
||||||
detectCapabilities,
|
detectCapabilities,
|
||||||
|
|
@ -2984,13 +3032,44 @@ app.get('/', (c) => {
|
||||||
const email = document.getElementById('reg-email').value.trim();
|
const email = document.getElementById('reg-email').value.trim();
|
||||||
|
|
||||||
btn.textContent = 'Creating passkey...';
|
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 = {
|
const regBody = {
|
||||||
challenge: credential.challenge,
|
challenge: serverOptions.challenge,
|
||||||
credential: credential.credential,
|
credential: credentialData,
|
||||||
userId: credential.userId,
|
userId,
|
||||||
username,
|
username,
|
||||||
};
|
};
|
||||||
if (email) regBody.email = email;
|
if (email) regBody.email = email;
|
||||||
|
|
@ -3006,15 +3085,32 @@ app.get('/', (c) => {
|
||||||
showProfile(data.token, username, data.did);
|
showProfile(data.token, username, data.did);
|
||||||
} else {
|
} else {
|
||||||
btn.textContent = 'Waiting for passkey...';
|
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', {
|
const res = await fetch('/api/auth/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
challenge: result.challenge,
|
challenge: serverOptions.challenge,
|
||||||
credential: result.credential,
|
credential: { credentialId: bufferToBase64url(credential.rawId) },
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue