fix: server-backed auth + show username instead of DID key
Rewrote auth flow to go through EncryptID server instead of client-side unsigned JWTs. Fixes "Invalid or expired authentication token" on space creation, and shows username in header. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9f39e2393b
commit
33f0ef4077
|
|
@ -11,8 +11,10 @@ const ENCRYPTID_URL = 'https://auth.rspace.online';
|
||||||
interface SessionState {
|
interface SessionState {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
claims: {
|
claims: {
|
||||||
sub: string; // DID
|
sub: string; // user ID
|
||||||
exp: number;
|
exp: number;
|
||||||
|
username?: string;
|
||||||
|
did?: string;
|
||||||
eid: {
|
eid: {
|
||||||
authLevel: number;
|
authLevel: number;
|
||||||
capabilities: {
|
capabilities: {
|
||||||
|
|
@ -36,6 +38,7 @@ function getSession(): SessionState | null {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
if (now >= session.claims.exp) {
|
if (now >= session.claims.exp) {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
localStorage.removeItem('rspace-username');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return session;
|
return session;
|
||||||
|
|
@ -46,6 +49,7 @@ function getSession(): SessionState | null {
|
||||||
|
|
||||||
function clearSession(): void {
|
function clearSession(): void {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
localStorage.removeItem('rspace-username');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAuthenticated(): boolean {
|
export function isAuthenticated(): boolean {
|
||||||
|
|
@ -57,7 +61,68 @@ export function getAccessToken(): string | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUserDID(): string | null {
|
export function getUserDID(): string | null {
|
||||||
return getSession()?.claims.sub ?? null;
|
return getSession()?.claims.did ?? getSession()?.claims.sub ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUsername(): string | null {
|
||||||
|
return getSession()?.claims.username ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// BASE64URL / JWT HELPERS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function base64urlToBuffer(base64url: string): ArrayBuffer {
|
||||||
|
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
|
||||||
|
const binary = atob(base64 + padding);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferToBase64url(buffer: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.byteLength; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseJWT(token: string): Record<string, any> {
|
||||||
|
const parts = token.split('.');
|
||||||
|
if (parts.length < 2) return {};
|
||||||
|
try {
|
||||||
|
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padding = '='.repeat((4 - (b64.length % 4)) % 4);
|
||||||
|
return JSON.parse(atob(b64 + padding));
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeSession(token: string, username: string, did: string): void {
|
||||||
|
const payload = parseJWT(token);
|
||||||
|
const session: SessionState = {
|
||||||
|
accessToken: token,
|
||||||
|
claims: {
|
||||||
|
sub: payload.sub || '',
|
||||||
|
exp: payload.exp || 0,
|
||||||
|
username,
|
||||||
|
did,
|
||||||
|
eid: payload.eid || {
|
||||||
|
authLevel: 3,
|
||||||
|
capabilities: { encrypt: true, sign: true, wallet: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
|
||||||
|
if (username) {
|
||||||
|
localStorage.setItem('rspace-username', username);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -511,23 +576,44 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
|
||||||
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Authenticating...';
|
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Authenticating...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Dynamic import to avoid loading WebAuthn unless needed
|
// 1. Get server challenge
|
||||||
const { authenticatePasskey } = await import('@encryptid/webauthn');
|
const startRes = await fetch(`${ENCRYPTID_URL}/api/auth/start`, {
|
||||||
const { getKeyManager } = await import('@encryptid/key-derivation');
|
method: 'POST',
|
||||||
const { getSessionManager } = await import('@encryptid/session');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({}),
|
||||||
const result = await authenticatePasskey();
|
|
||||||
const keyManager = getKeyManager();
|
|
||||||
if (result.prfOutput) {
|
|
||||||
await keyManager.initFromPRF(result.prfOutput);
|
|
||||||
}
|
|
||||||
const keys = await keyManager.getKeys();
|
|
||||||
const sessionManager = getSessionManager();
|
|
||||||
await sessionManager.createSession(result, keys.did, {
|
|
||||||
encrypt: true,
|
|
||||||
sign: true,
|
|
||||||
wallet: false,
|
|
||||||
});
|
});
|
||||||
|
if (!startRes.ok) throw new Error('Failed to start authentication');
|
||||||
|
const { options: serverOptions } = await startRes.json();
|
||||||
|
|
||||||
|
// 2. WebAuthn ceremony with server challenge
|
||||||
|
const credential = await navigator.credentials.get({
|
||||||
|
publicKey: {
|
||||||
|
challenge: new Uint8Array(base64urlToBuffer(serverOptions.challenge)),
|
||||||
|
rpId: serverOptions.rpId || 'rspace.online',
|
||||||
|
userVerification: 'required',
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
if (!credential) throw new Error('Authentication failed');
|
||||||
|
|
||||||
|
// 3. Complete auth on EncryptID server → get signed JWT + username
|
||||||
|
const completeRes = await fetch(`${ENCRYPTID_URL}/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');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store server-signed token with username
|
||||||
|
storeSession(data.token, data.username || '', data.did || '');
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
callbacks?.onSuccess?.();
|
callbacks?.onSuccess?.();
|
||||||
|
|
@ -559,25 +645,68 @@ export function showAuthModal(callbacks?: Partial<AuthModalCallbacks>): void {
|
||||||
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Creating passkey...';
|
btn.innerHTML = '<span class="rspace-auth-modal__spinner"></span> Creating passkey...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { registerPasskey, authenticatePasskey } = await import('@encryptid/webauthn');
|
// 1. Get registration options from server
|
||||||
const { getKeyManager } = await import('@encryptid/key-derivation');
|
const startRes = await fetch(`${ENCRYPTID_URL}/api/register/start`, {
|
||||||
const { getSessionManager } = await import('@encryptid/session');
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
await registerPasskey(username, username);
|
body: JSON.stringify({ username, displayName: username }),
|
||||||
|
|
||||||
// Auto sign-in after registration
|
|
||||||
const result = await authenticatePasskey();
|
|
||||||
const keyManager = getKeyManager();
|
|
||||||
if (result.prfOutput) {
|
|
||||||
await keyManager.initFromPRF(result.prfOutput);
|
|
||||||
}
|
|
||||||
const keys = await keyManager.getKeys();
|
|
||||||
const sessionManager = getSessionManager();
|
|
||||||
await sessionManager.createSession(result, keys.did, {
|
|
||||||
encrypt: true,
|
|
||||||
sign: true,
|
|
||||||
wallet: false,
|
|
||||||
});
|
});
|
||||||
|
if (!startRes.ok) throw new Error('Failed to start registration');
|
||||||
|
const { options: serverOptions, userId } = await startRes.json();
|
||||||
|
|
||||||
|
// 2. WebAuthn ceremony with server challenge
|
||||||
|
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' as const },
|
||||||
|
{ alg: -257, type: 'public-key' as const },
|
||||||
|
],
|
||||||
|
authenticatorSelection: {
|
||||||
|
residentKey: 'required',
|
||||||
|
requireResidentKey: true,
|
||||||
|
userVerification: 'required',
|
||||||
|
},
|
||||||
|
attestation: 'none',
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
}) as PublicKeyCredential;
|
||||||
|
if (!credential) throw new Error('Failed to create credential');
|
||||||
|
|
||||||
|
const response = credential.response as AuthenticatorAttestationResponse;
|
||||||
|
const publicKey = response.getPublicKey?.();
|
||||||
|
|
||||||
|
// 3. Complete registration on EncryptID server
|
||||||
|
const completeRes = await fetch(`${ENCRYPTID_URL}/api/register/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
challenge: serverOptions.challenge,
|
||||||
|
credential: {
|
||||||
|
credentialId: bufferToBase64url(credential.rawId),
|
||||||
|
publicKey: publicKey ? bufferToBase64url(publicKey) : '',
|
||||||
|
transports: response.getTransports?.() || [],
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await completeRes.json();
|
||||||
|
if (!completeRes.ok || !data.success) {
|
||||||
|
throw new Error(data.error || 'Registration failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Store server-signed token with username
|
||||||
|
storeSession(data.token, username, data.did || '');
|
||||||
|
|
||||||
closeModal();
|
closeModal();
|
||||||
callbacks?.onSuccess?.();
|
callbacks?.onSuccess?.();
|
||||||
|
|
@ -676,16 +805,17 @@ export function mountHeader(options: HeaderOptions): void {
|
||||||
: '<div></div>';
|
: '<div></div>';
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
const did = session!.claims.sub;
|
const username = session!.claims.username || '';
|
||||||
const shortDID = did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did;
|
const did = session!.claims.did || session!.claims.sub;
|
||||||
const initial = did.slice(8, 10).toUpperCase();
|
const displayName = username || (did.length > 24 ? did.slice(0, 16) + '...' + did.slice(-6) : did);
|
||||||
|
const initial = username ? username[0].toUpperCase() : did.slice(8, 10).toUpperCase();
|
||||||
|
|
||||||
header.innerHTML = `
|
header.innerHTML = `
|
||||||
${brandHTML}
|
${brandHTML}
|
||||||
<div class="rspace-header__right">
|
<div class="rspace-header__right">
|
||||||
<div class="rspace-header__user" id="header-user-toggle">
|
<div class="rspace-header__user" id="header-user-toggle">
|
||||||
<div class="rspace-header__avatar">${initial}</div>
|
<div class="rspace-header__avatar">${initial}</div>
|
||||||
<span class="rspace-header__user-did">${shortDID}</span>
|
<span class="rspace-header__user-did">${displayName}</span>
|
||||||
<div class="rspace-header__dropdown" id="header-dropdown">
|
<div class="rspace-header__dropdown" id="header-dropdown">
|
||||||
<button class="rspace-header__dropdown-item" data-action="profile">
|
<button class="rspace-header__dropdown-item" data-action="profile">
|
||||||
👤 Profile
|
👤 Profile
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue