feat(encryptid): fix DID consistency, add PRF key derivation, stepper signup, and magic link login
- Fix DID mismatch: server now stores and reads proper did🔑z6Mk... DIDs from database instead of deriving truncated did🔑${slice(0,32)} - Add PRF extension to WebAuthn create/get flows for client-side key derivation - Derive DID, signing keys, encryption keys, and EOA wallet from passkey PRF - Auto-upgrade truncated DIDs to proper format on sign-in - Add POST /api/account/upgrade-did endpoint for DID migration - Add 5-step educational registration wizard (identity, passkey, DID, wallet, security) - Add email/username field to sign-in for scoped passkey selection - Add magic link email login for external devices without passkeys - Add POST /api/auth/magic-link and GET /magic-login verification page - Add mintWelcomeBalance() for 5 fUSDC to new users - Store EOA wallet address during registration when PRF available Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
db61e54d7b
commit
8bd8348529
|
|
@ -150,3 +150,45 @@ export async function seedCUSDC() {
|
||||||
console.error('[TokenService] Failed to mint cUSDC to jeff');
|
console.error('[TokenService] Failed to mint cUSDC to jeff');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mint $5 fUSDC welcome balance for a new user. */
|
||||||
|
export function mintWelcomeBalance(did: string, username: string): boolean {
|
||||||
|
if (!_syncServer) {
|
||||||
|
console.warn('[TokenService] SyncServer not initialized, skipping welcome balance');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenId = 'fusdc';
|
||||||
|
const docId = tokenDocId(tokenId);
|
||||||
|
let doc = _syncServer.getDoc<TokenLedgerDoc>(docId);
|
||||||
|
|
||||||
|
// Create fUSDC token if it doesn't exist
|
||||||
|
if (!doc) {
|
||||||
|
doc = Automerge.change(Automerge.init<TokenLedgerDoc>(), 'init fUSDC ledger', (d) => {
|
||||||
|
const init = tokenLedgerSchema.init();
|
||||||
|
Object.assign(d, init);
|
||||||
|
});
|
||||||
|
_syncServer.setDoc(docId, doc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!doc.token.name) {
|
||||||
|
_syncServer.changeDoc<TokenLedgerDoc>(docId, 'define fUSDC token', (d) => {
|
||||||
|
d.token.id = 'fusdc';
|
||||||
|
d.token.name = 'Fake USDC';
|
||||||
|
d.token.symbol = 'fUSDC';
|
||||||
|
d.token.decimals = 6;
|
||||||
|
d.token.description = 'Test stablecoin for new users — $5 welcome balance';
|
||||||
|
d.token.icon = '💵';
|
||||||
|
d.token.color = '#2775ca';
|
||||||
|
d.token.createdAt = Date.now();
|
||||||
|
d.token.createdBy = 'system';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint 5 fUSDC (5 × 10^6 base units at 6 decimals)
|
||||||
|
const success = mintTokens(tokenId, did, username, 5_000_000, 'Welcome balance', 'system');
|
||||||
|
if (success) {
|
||||||
|
console.log(`[TokenService] Welcome balance: 5 fUSDC minted to ${username} (${did})`);
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -255,7 +255,7 @@ export async function migrateSpaceMemberDid(oldDid: string, newDid: string): Pro
|
||||||
export interface StoredRecoveryToken {
|
export interface StoredRecoveryToken {
|
||||||
token: string;
|
token: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
type: 'email_verify' | 'account_recovery';
|
type: 'email_verify' | 'account_recovery' | 'magic_login';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
used: boolean;
|
used: boolean;
|
||||||
|
|
@ -282,7 +282,7 @@ export async function getRecoveryToken(token: string): Promise<StoredRecoveryTok
|
||||||
return {
|
return {
|
||||||
token: row.token,
|
token: row.token,
|
||||||
userId: row.user_id,
|
userId: row.user_id,
|
||||||
type: row.type as 'email_verify' | 'account_recovery',
|
type: row.type as 'email_verify' | 'account_recovery' | 'magic_login',
|
||||||
createdAt: new Date(row.created_at).getTime(),
|
createdAt: new Date(row.created_at).getTime(),
|
||||||
expiresAt: new Date(row.expires_at).getTime(),
|
expiresAt: new Date(row.expires_at).getTime(),
|
||||||
used: row.used,
|
used: row.used,
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ CREATE INDEX IF NOT EXISTS idx_challenges_expires_at ON challenges(expires_at);
|
||||||
CREATE TABLE IF NOT EXISTS recovery_tokens (
|
CREATE TABLE IF NOT EXISTS recovery_tokens (
|
||||||
token TEXT PRIMARY KEY,
|
token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery')),
|
type TEXT NOT NULL CHECK (type IN ('email_verify', 'account_recovery', 'magic_login')),
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
expires_at TIMESTAMPTZ NOT NULL,
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
used BOOLEAN DEFAULT FALSE
|
used BOOLEAN DEFAULT FALSE
|
||||||
|
|
@ -65,6 +65,15 @@ CREATE TABLE IF NOT EXISTS recovery_tokens (
|
||||||
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id);
|
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_user_id ON recovery_tokens(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at);
|
CREATE INDEX IF NOT EXISTS idx_recovery_tokens_expires_at ON recovery_tokens(expires_at);
|
||||||
|
|
||||||
|
-- Migration: add magic_login type to existing recovery_tokens CHECK constraint
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE recovery_tokens DROP CONSTRAINT IF EXISTS recovery_tokens_type_check;
|
||||||
|
ALTER TABLE recovery_tokens ADD CONSTRAINT recovery_tokens_type_check
|
||||||
|
CHECK (type IN ('email_verify', 'account_recovery', 'magic_login'));
|
||||||
|
EXCEPTION WHEN others THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
-- Space membership: source of truth for user roles across the r*.online ecosystem
|
-- Space membership: source of truth for user roles across the r*.online ecosystem
|
||||||
CREATE TABLE IF NOT EXISTS space_members (
|
CREATE TABLE IF NOT EXISTS space_members (
|
||||||
space_slug TEXT NOT NULL,
|
space_slug TEXT NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,8 @@ import {
|
||||||
getUserUPAddress,
|
getUserUPAddress,
|
||||||
setUserUPAddress,
|
setUserUPAddress,
|
||||||
getUserByUPAddress,
|
getUserByUPAddress,
|
||||||
|
updateUserDid,
|
||||||
|
migrateSpaceMemberDid,
|
||||||
} from './db.js';
|
} from './db.js';
|
||||||
import {
|
import {
|
||||||
isMailcowConfigured,
|
isMailcowConfigured,
|
||||||
|
|
@ -555,7 +557,13 @@ app.post('/api/register/start', async (c) => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json({ options, userId });
|
// Compute PRF salt: SHA-256("encryptid-prf-salt-master-key-v1")
|
||||||
|
// This matches the client-side generatePRFSalt('master-key') in webauthn.ts
|
||||||
|
const prfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1');
|
||||||
|
const prfSaltHash = await crypto.subtle.digest('SHA-256', prfSaltData);
|
||||||
|
const prfSalt = Buffer.from(prfSaltHash).toString('base64url');
|
||||||
|
|
||||||
|
return c.json({ options, userId, prfSalt });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -645,6 +653,14 @@ app.post('/api/register/complete', async (c) => {
|
||||||
console.error('EncryptID: Failed to auto-provision user space:', e);
|
console.error('EncryptID: Failed to auto-provision user space:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mint welcome balance (non-fatal)
|
||||||
|
try {
|
||||||
|
const { mintWelcomeBalance } = await import('../../server/token-service');
|
||||||
|
mintWelcomeBalance(did, username);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('EncryptID: Failed to mint welcome balance:', e);
|
||||||
|
}
|
||||||
|
|
||||||
// Generate initial session token
|
// Generate initial session token
|
||||||
const token = await generateSessionToken(userId, username);
|
const token = await generateSessionToken(userId, username);
|
||||||
|
|
||||||
|
|
@ -666,7 +682,7 @@ app.post('/api/register/complete', async (c) => {
|
||||||
*/
|
*/
|
||||||
app.post('/api/auth/start', async (c) => {
|
app.post('/api/auth/start', async (c) => {
|
||||||
const body = await c.req.json().catch(() => ({}));
|
const body = await c.req.json().catch(() => ({}));
|
||||||
const { credentialId } = body;
|
const { credentialId, email, username: loginUsername } = body;
|
||||||
|
|
||||||
// Generate challenge
|
// Generate challenge
|
||||||
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
const challenge = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
||||||
|
|
@ -680,8 +696,9 @@ app.post('/api/auth/start', async (c) => {
|
||||||
};
|
};
|
||||||
await storeChallenge(challengeRecord);
|
await storeChallenge(challengeRecord);
|
||||||
|
|
||||||
// Build allowed credentials if specified
|
// Build allowed credentials — scope to user if email/username provided
|
||||||
let allowCredentials;
|
let allowCredentials;
|
||||||
|
let userFound = false;
|
||||||
if (credentialId) {
|
if (credentialId) {
|
||||||
const cred = await getCredential(credentialId);
|
const cred = await getCredential(credentialId);
|
||||||
if (cred) {
|
if (cred) {
|
||||||
|
|
@ -690,6 +707,23 @@ app.post('/api/auth/start', async (c) => {
|
||||||
id: credentialId,
|
id: credentialId,
|
||||||
transports: cred.transports,
|
transports: cred.transports,
|
||||||
}];
|
}];
|
||||||
|
userFound = true;
|
||||||
|
}
|
||||||
|
} else if (email || loginUsername) {
|
||||||
|
// Look up user by email or username, then get their credentials
|
||||||
|
const user = email
|
||||||
|
? await getUserByEmail(email.trim().toLowerCase())
|
||||||
|
: await getUserByUsername(loginUsername.trim());
|
||||||
|
if (user) {
|
||||||
|
userFound = true;
|
||||||
|
const creds = await getUserCredentials(user.id);
|
||||||
|
if (creds.length > 0) {
|
||||||
|
allowCredentials = creds.map(c => ({
|
||||||
|
type: 'public-key' as const,
|
||||||
|
id: c.credentialId,
|
||||||
|
transports: c.transports,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -703,7 +737,12 @@ app.post('/api/auth/start', async (c) => {
|
||||||
allowCredentials,
|
allowCredentials,
|
||||||
};
|
};
|
||||||
|
|
||||||
return c.json({ options });
|
// Include PRF salt for key derivation during sign-in
|
||||||
|
const authPrfSaltData = new TextEncoder().encode('encryptid-prf-salt-master-key-v1');
|
||||||
|
const authPrfSaltHash = await crypto.subtle.digest('SHA-256', authPrfSaltData);
|
||||||
|
const prfSalt = Buffer.from(authPrfSaltHash).toString('base64url');
|
||||||
|
|
||||||
|
return c.json({ options, prfSalt, userFound });
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -875,6 +914,64 @@ app.get('/api/user/credentials', async (c) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// DID MIGRATION ENDPOINT
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upgrade a user's DID from truncated format to proper did:key:z6Mk...
|
||||||
|
* Called automatically during sign-in when client detects a DID mismatch.
|
||||||
|
*/
|
||||||
|
app.post('/api/account/upgrade-did', async (c) => {
|
||||||
|
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||||
|
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||||
|
|
||||||
|
const { clientDid, eoaAddress } = await c.req.json();
|
||||||
|
if (!clientDid || typeof clientDid !== 'string' || !clientDid.startsWith('did:key:z')) {
|
||||||
|
return c.json({ error: 'Invalid DID format — must start with did:key:z' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = claims.sub as string;
|
||||||
|
const oldDid = claims.did as string;
|
||||||
|
|
||||||
|
// Don't upgrade if already proper format
|
||||||
|
if (oldDid === clientDid) {
|
||||||
|
return c.json({ success: true, did: oldDid, message: 'DID already up to date' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user's DID in database
|
||||||
|
await updateUserDid(userId, clientDid);
|
||||||
|
|
||||||
|
// Migrate space memberships from old DID to new DID
|
||||||
|
let migratedSpaces = 0;
|
||||||
|
if (oldDid) {
|
||||||
|
migratedSpaces = await migrateSpaceMemberDid(oldDid, clientDid);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store wallet address if provided
|
||||||
|
if (eoaAddress && typeof eoaAddress === 'string' && eoaAddress.startsWith('0x')) {
|
||||||
|
await updateUserProfile(userId, { walletAddress: eoaAddress });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`EncryptID: DID upgraded for user ${userId}`, {
|
||||||
|
oldDid: oldDid?.slice(0, 30) + '...',
|
||||||
|
newDid: clientDid.slice(0, 30) + '...',
|
||||||
|
migratedSpaces,
|
||||||
|
hasWallet: !!eoaAddress,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate new JWT with updated DID
|
||||||
|
const username = claims.username as string;
|
||||||
|
const token = await generateSessionToken(userId, username);
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
success: true,
|
||||||
|
did: clientDid,
|
||||||
|
token,
|
||||||
|
migratedSpaces,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// USER PROFILE ENDPOINTS
|
// USER PROFILE ENDPOINTS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -1580,6 +1677,169 @@ app.post('/api/recovery/email/verify', async (c) => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// MAGIC LINK LOGIN
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request a magic login link — sends an email with a one-time login URL.
|
||||||
|
* For users signing in from a device that doesn't have their passkey.
|
||||||
|
* Session issued has wallet capabilities disabled (no PRF keys available).
|
||||||
|
*/
|
||||||
|
app.post('/api/auth/magic-link', async (c) => {
|
||||||
|
const { email } = await c.req.json();
|
||||||
|
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return c.json({ error: 'Valid email required' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await getUserByEmail(email.trim().toLowerCase());
|
||||||
|
// Always return success to avoid email enumeration
|
||||||
|
if (!user) {
|
||||||
|
return c.json({ success: true, message: 'If an account exists with this email, a login link has been sent.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate magic login token
|
||||||
|
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
|
||||||
|
const rt: StoredRecoveryToken = {
|
||||||
|
token,
|
||||||
|
userId: user.id,
|
||||||
|
type: 'magic_login',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 15 * 60 * 1000, // 15 minutes
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
await storeRecoveryToken(rt);
|
||||||
|
|
||||||
|
// Send magic login email
|
||||||
|
const loginLink = `https://auth.rspace.online/magic-login?token=${encodeURIComponent(token)}`;
|
||||||
|
if (smtpTransport) {
|
||||||
|
try {
|
||||||
|
await smtpTransport.sendMail({
|
||||||
|
from: CONFIG.smtp.from,
|
||||||
|
to: email.trim().toLowerCase(),
|
||||||
|
subject: 'rStack — Sign In Link',
|
||||||
|
text: [
|
||||||
|
`Hi ${user.username},`,
|
||||||
|
'',
|
||||||
|
'Use the link below to sign in to your rStack account:',
|
||||||
|
'',
|
||||||
|
loginLink,
|
||||||
|
'',
|
||||||
|
'This link expires in 15 minutes and can only be used once.',
|
||||||
|
'Note: Wallet features are unavailable when signing in via email link. Use a device with your passkey for full functionality.',
|
||||||
|
'',
|
||||||
|
'If you did not request this, you can safely ignore this email.',
|
||||||
|
'',
|
||||||
|
'— rStack Identity',
|
||||||
|
].join('\n'),
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="margin:0;padding:0;background:#1a1a2e;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
|
||||||
|
<table width="100%" cellpadding="0" cellspacing="0" style="background:#1a1a2e;padding:40px 20px;">
|
||||||
|
<tr><td align="center">
|
||||||
|
<table width="480" cellpadding="0" cellspacing="0" style="background:#16213e;border-radius:12px;border:1px solid rgba(255,255,255,0.1);">
|
||||||
|
<tr><td style="padding:32px 32px 24px;text-align:center;">
|
||||||
|
<div style="font-size:36px;margin-bottom:8px;">✉</div>
|
||||||
|
<h1 style="margin:0;font-size:24px;background:linear-gradient(90deg,#00d4ff,#7c3aed);-webkit-background-clip:text;-webkit-text-fill-color:transparent;">rStack Sign In</h1>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
|
||||||
|
<p>Hi <strong>${user.username}</strong>,</p>
|
||||||
|
<p>Click below to sign in to your rStack account:</p>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 32px 24px;text-align:center;">
|
||||||
|
<a href="${loginLink}" style="display:inline-block;padding:12px 32px;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;border-radius:8px;font-weight:600;font-size:15px;">Sign In</a>
|
||||||
|
</td></tr>
|
||||||
|
<tr><td style="padding:0 32px 32px;color:#94a3b8;font-size:13px;line-height:1.5;">
|
||||||
|
<p>This link expires in <strong>15 minutes</strong> and can only be used once.</p>
|
||||||
|
<p style="background:rgba(234,179,8,0.1);border:1px solid rgba(234,179,8,0.2);border-radius:8px;padding:8px 12px;margin-top:8px;color:#eab308;font-size:12px;">
|
||||||
|
Wallet and signing features are unavailable via email login. Use a device with your passkey for full functionality.
|
||||||
|
</p>
|
||||||
|
<p style="margin-top:16px;padding-top:16px;border-top:1px solid rgba(255,255,255,0.1);font-size:12px;color:#64748b;">
|
||||||
|
Can't click the button? Copy this link:<br>
|
||||||
|
<span style="color:#94a3b8;word-break:break-all;">${loginLink}</span>
|
||||||
|
</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('EncryptID: Failed to send magic login email:', err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`EncryptID: [NO SMTP] Magic login link for ${email}: ${loginLink}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ success: true, message: 'If an account exists with this email, a login link has been sent.' });
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Magic login page — verifies token and issues session
|
||||||
|
*/
|
||||||
|
app.get('/magic-login', async (c) => {
|
||||||
|
const token = c.req.query('token');
|
||||||
|
if (!token) {
|
||||||
|
return c.redirect('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = await getRecoveryToken(token);
|
||||||
|
const valid = rt && !rt.used && rt.type === 'magic_login' && Date.now() <= rt.expiresAt;
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
await markRecoveryTokenUsed(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = valid ? await getUserById(rt.userId) : null;
|
||||||
|
const sessionToken = user ? await generateSessionToken(rt.userId, user.username) : null;
|
||||||
|
|
||||||
|
// Return a page that stores the token and redirects
|
||||||
|
return c.html(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>rStack Identity — Sign In</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; color: #fff; }
|
||||||
|
.card { background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 16px; padding: 2.5rem; max-width: 440px; width: 100%; text-align: center; }
|
||||||
|
.icon { font-size: 3rem; margin-bottom: 0.75rem; }
|
||||||
|
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
||||||
|
p { color: #94a3b8; font-size: 0.9rem; line-height: 1.5; margin-bottom: 1rem; }
|
||||||
|
.warning { background: rgba(234,179,8,0.1); border: 1px solid rgba(234,179,8,0.2); border-radius: 8px; padding: 0.75rem; color: #eab308; font-size: 0.8rem; margin-bottom: 1rem; }
|
||||||
|
.error { color: #fca5a5; }
|
||||||
|
.btn { display: inline-block; padding: 0.75rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 8px; font-weight: 600; font-size: 0.9rem; border: none; cursor: pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
${valid && sessionToken ? `
|
||||||
|
<div class="icon">✓</div>
|
||||||
|
<h1>Signed In</h1>
|
||||||
|
<p>Welcome back, <strong>${user!.username}</strong>!</p>
|
||||||
|
<div class="warning">You're signed in via email link. Wallet and signing features require your passkey device.</div>
|
||||||
|
<p>Redirecting...</p>
|
||||||
|
<script>
|
||||||
|
localStorage.setItem('encryptid_token', ${JSON.stringify(sessionToken)});
|
||||||
|
setTimeout(() => { window.location.href = '/'; }, 1500);
|
||||||
|
</script>
|
||||||
|
` : `
|
||||||
|
<div class="icon">❌</div>
|
||||||
|
<h1>Link Expired</h1>
|
||||||
|
<p class="error">This login link is invalid or has already been used.</p>
|
||||||
|
<a href="/" class="btn">Back to Sign In</a>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// HELPER FUNCTIONS
|
// HELPER FUNCTIONS
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
@ -3468,7 +3728,7 @@ app.post('/api/spaces/:slug/members', async (c) => {
|
||||||
return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
|
return c.json({ error: `Invalid role. Must be one of: ${VALID_ROLES.join(', ')}` }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const grantedByDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const grantedByDid = claims.did as string;
|
||||||
const member = await upsertSpaceMember(slug, body.userDID, body.role, grantedByDid);
|
const member = await upsertSpaceMember(slug, body.userDID, body.role, grantedByDid);
|
||||||
return c.json({
|
return c.json({
|
||||||
userDID: member.userDID,
|
userDID: member.userDID,
|
||||||
|
|
@ -3621,7 +3881,7 @@ app.post('/api/invites/:token/accept', async (c) => {
|
||||||
if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);
|
if (!accepted) return c.json({ error: 'Failed to accept invite' }, 500);
|
||||||
|
|
||||||
// Add to space_members with the invite's role (use DID, not raw userId)
|
// Add to space_members with the invite's role (use DID, not raw userId)
|
||||||
const userDid = (claims.did as string) || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const userDid = claims.did as string;
|
||||||
await upsertSpaceMember(accepted.spaceSlug, userDid, accepted.role, accepted.invitedBy);
|
await upsertSpaceMember(accepted.spaceSlug, userDid, accepted.role, accepted.invitedBy);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|
@ -4783,8 +5043,11 @@ app.post('/api/invites/identity/:token/claim', async (c) => {
|
||||||
|
|
||||||
// Auto-join space if specified
|
// Auto-join space if specified
|
||||||
if (invite.spaceSlug) {
|
if (invite.spaceSlug) {
|
||||||
const did = `did:key:${payload.sub.slice(0, 32)}`;
|
const inviteeUser = await getUserById(payload.sub);
|
||||||
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
|
const did = inviteeUser?.did || `did:key:${payload.sub.slice(0, 32)}`;
|
||||||
|
const inviterUser = await getUserById(invite.invitedByUserId);
|
||||||
|
const inviterDid = inviterUser?.did || `did:key:${invite.invitedByUserId.slice(0, 32)}`;
|
||||||
|
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, inviterDid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-add email to OIDC client allowlist if this is a client invite
|
// Auto-add email to OIDC client allowlist if this is a client invite
|
||||||
|
|
@ -6328,6 +6591,39 @@ app.get('/', (c) => {
|
||||||
.link-row a { color: #7c3aed; text-decoration: none; }
|
.link-row a { color: #7c3aed; text-decoration: none; }
|
||||||
.link-row a:hover { text-decoration: underline; }
|
.link-row a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* Registration stepper */
|
||||||
|
.stepper { display: none; }
|
||||||
|
.stepper.active { display: block; }
|
||||||
|
.stepper-progress { display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 1.5rem; padding: 0 1rem; }
|
||||||
|
.stepper-dot { width: 10px; height: 10px; border-radius: 50%; background: rgba(255,255,255,0.15); transition: all 0.3s; flex-shrink: 0; }
|
||||||
|
.stepper-dot.active { background: #7c3aed; box-shadow: 0 0 8px rgba(124,58,237,0.4); }
|
||||||
|
.stepper-dot.done { background: #22c55e; }
|
||||||
|
.stepper-line { height: 2px; flex: 1; background: rgba(255,255,255,0.1); transition: background 0.3s; }
|
||||||
|
.stepper-line.done { background: #22c55e; }
|
||||||
|
|
||||||
|
.step-card { animation: stepIn 0.3s ease; }
|
||||||
|
@keyframes stepIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
|
||||||
|
.step-icon { font-size: 2rem; text-align: center; margin-bottom: 0.75rem; }
|
||||||
|
.step-title { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
.step-desc { font-size: 0.85rem; color: #94a3b8; line-height: 1.5; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
.key-status { background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); border-radius: 0.5rem; padding: 0.75rem; margin-bottom: 1rem; }
|
||||||
|
.key-row { display: flex; justify-content: space-between; align-items: center; padding: 0.3rem 0; font-size: 0.8rem; }
|
||||||
|
.key-label { color: #94a3b8; }
|
||||||
|
.key-value { color: #22c55e; font-family: monospace; font-size: 0.75rem; }
|
||||||
|
.key-value.pending { color: #64748b; }
|
||||||
|
|
||||||
|
.did-display { background: rgba(124,58,237,0.1); border: 1px solid rgba(124,58,237,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.7rem; color: #c4b5fd; word-break: break-all; cursor: pointer; position: relative; }
|
||||||
|
.did-display:hover { border-color: #7c3aed; }
|
||||||
|
.did-display .copy-toast { position: absolute; right: 0.5rem; top: 50%; transform: translateY(-50%); font-size: 0.7rem; color: #7c3aed; font-family: sans-serif; }
|
||||||
|
|
||||||
|
.eoa-display { background: rgba(34,197,94,0.08); border: 1px solid rgba(34,197,94,0.2); border-radius: 0.5rem; padding: 0.6rem 0.75rem; font-family: monospace; font-size: 0.75rem; color: #86efac; word-break: break-all; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
.step-note { font-size: 0.8rem; color: #64748b; font-style: italic; padding: 0.5rem 0.75rem; background: rgba(255,255,255,0.03); border-radius: 0.5rem; margin-bottom: 1rem; }
|
||||||
|
.btn-secondary { display: block; width: 100%; padding: 0.75rem; border: 1px solid rgba(255,255,255,0.15); background: transparent; color: #e2e8f0; border-radius: 0.5rem; font-size: 0.9rem; cursor: pointer; transition: all 0.2s; margin-top: 0.5rem; }
|
||||||
|
.btn-secondary:hover { border-color: #7c3aed; color: #fff; }
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
.features { grid-template-columns: 1fr; }
|
.features { grid-template-columns: 1fr; }
|
||||||
.header h1 { font-size: 2rem; }
|
.header h1 { font-size: 2rem; }
|
||||||
|
|
@ -6356,8 +6652,42 @@ app.get('/', (c) => {
|
||||||
<div id="error-msg" class="error"></div>
|
<div id="error-msg" class="error"></div>
|
||||||
<div id="success-msg" class="success"></div>
|
<div id="success-msg" class="success"></div>
|
||||||
|
|
||||||
<!-- Register fields (hidden in signin mode) -->
|
<!-- Sign-in mode (default) -->
|
||||||
<div id="register-fields" style="display:none">
|
<div id="signin-fields">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="signin-email">Email or Username</label>
|
||||||
|
<input id="signin-email" type="text" placeholder="you@example.com or username" autocomplete="email" />
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
|
||||||
|
<div id="magic-link-section" style="display:none;margin-top:0.75rem">
|
||||||
|
<div style="text-align:center;color:#64748b;font-size:0.8rem;margin-bottom:0.5rem">or</div>
|
||||||
|
<button class="btn-secondary" id="magic-link-btn" onclick="sendMagicLink()">Email me a login link</button>
|
||||||
|
<div id="magic-link-msg" style="font-size:0.8rem;margin-top:0.5rem;display:none"></div>
|
||||||
|
</div>
|
||||||
|
<div style="text-align:center;margin-top:0.75rem;font-size:0.8rem;color:#64748b" id="signin-hint">
|
||||||
|
Enter your email to find your account, or leave blank to use any passkey on this device
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Registration stepper (hidden until register tab) -->
|
||||||
|
<div id="register-stepper" style="display:none">
|
||||||
|
<div class="stepper-progress" id="stepper-progress">
|
||||||
|
<div class="stepper-dot active" data-step="1"></div>
|
||||||
|
<div class="stepper-line" data-line="1"></div>
|
||||||
|
<div class="stepper-dot" data-step="2"></div>
|
||||||
|
<div class="stepper-line" data-line="2"></div>
|
||||||
|
<div class="stepper-dot" data-step="3"></div>
|
||||||
|
<div class="stepper-line" data-line="3"></div>
|
||||||
|
<div class="stepper-dot" data-step="4"></div>
|
||||||
|
<div class="stepper-line" data-line="4"></div>
|
||||||
|
<div class="stepper-dot" data-step="5"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Choose Your Identity -->
|
||||||
|
<div class="stepper step-card active" id="reg-step-1">
|
||||||
|
<div class="step-icon">👤</div>
|
||||||
|
<div class="step-title">Choose Your Identity</div>
|
||||||
|
<div class="step-desc">Your username is your identity across rStack. No passwords — your device IS your key.</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">Username</label>
|
<label for="username">Username</label>
|
||||||
<input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
|
<input id="username" type="text" placeholder="Choose a username" autocomplete="username" />
|
||||||
|
|
@ -6366,9 +6696,71 @@ app.get('/', (c) => {
|
||||||
<label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional — for account recovery)</span></label>
|
<label for="reg-email">Email <span style="color:#475569;font-weight:400">(optional — for account recovery)</span></label>
|
||||||
<input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
|
<input id="reg-email" type="email" placeholder="you@example.com" autocomplete="email" />
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-primary" id="step1-btn" onclick="handleStep1()">Continue</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-primary" id="auth-btn" onclick="handleAuth()">Sign In with Passkey</button>
|
<!-- Step 2: Create Your Passkey -->
|
||||||
|
<div class="stepper step-card" id="reg-step-2">
|
||||||
|
<div class="step-icon">🔒</div>
|
||||||
|
<div class="step-title">Create Your Passkey</div>
|
||||||
|
<div class="step-desc">A passkey is a cryptographic credential stored on your device. It replaces passwords with biometrics (fingerprint, face) or your device PIN. Passkeys are phishing-resistant and never leave your device.</div>
|
||||||
|
<button class="btn-primary" id="step2-btn" onclick="handleStep2()">Create Passkey</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Your Universal Identity (DID) -->
|
||||||
|
<div class="stepper step-card" id="reg-step-3">
|
||||||
|
<div class="step-icon">🔑</div>
|
||||||
|
<div class="step-title">Your Universal Identity</div>
|
||||||
|
<div id="step3-prf-content">
|
||||||
|
<div class="step-desc">Your passkey derived a unique Decentralized Identifier (DID) — a cryptographic identity that works across every app. Encryption and signing keys were also derived. All keys stay on your device.</div>
|
||||||
|
<div class="did-display" id="step3-did" onclick="copyDid(this)">
|
||||||
|
<span id="step3-did-text">Loading...</span>
|
||||||
|
<span class="copy-toast">click to copy</span>
|
||||||
|
</div>
|
||||||
|
<div class="key-status" style="margin-top:0.75rem">
|
||||||
|
<div class="key-row"><span class="key-label">Encryption</span><span class="key-value" id="key-encrypt">✓ Derived</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">Signing</span><span class="key-value" id="key-sign">✓ Derived</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">DID</span><span class="key-value" id="key-did">✓ Derived</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">EOA Wallet</span><span class="key-value" id="key-eoa">✓ Derived</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="step3-no-prf" style="display:none">
|
||||||
|
<div class="step-note">Full key derivation requires a compatible authenticator with PRF support. You can upgrade your identity later from a supported device.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="goToStep(4)">Continue</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 4: Your Wallet -->
|
||||||
|
<div class="stepper step-card" id="reg-step-4">
|
||||||
|
<div class="step-icon">💰</div>
|
||||||
|
<div class="step-title">Your Wallet</div>
|
||||||
|
<div id="step4-prf-content">
|
||||||
|
<div class="step-desc">Your passkey derived an Ethereum wallet address. This wallet can hold tokens and sign transactions — no seed phrases or browser extensions needed.</div>
|
||||||
|
<div class="eoa-display" id="step4-eoa">Loading...</div>
|
||||||
|
<div class="step-desc" style="margin-top:0.75rem;font-size:0.8rem">A CRDT-based local wallet is also active for offline-first token management.</div>
|
||||||
|
</div>
|
||||||
|
<div id="step4-no-prf" style="display:none">
|
||||||
|
<div class="step-note">Wallet derivation requires PRF-capable authenticator. You can add a wallet later from settings.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" onclick="goToStep(5)">Continue</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 5: Secure Your Account -->
|
||||||
|
<div class="stepper step-card" id="reg-step-5">
|
||||||
|
<div class="step-icon">🛡</div>
|
||||||
|
<div class="step-title">Secure Your Account</div>
|
||||||
|
<div class="step-desc">Social recovery replaces seed phrases. Choose trusted people who can help you recover your account if you lose all devices.</div>
|
||||||
|
<div class="key-status">
|
||||||
|
<div class="key-row"><span class="key-label">Passkey</span><span class="key-value">✓ Created</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">Recovery email</span><span class="key-value" id="step5-email">○ Not set</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">Second device</span><span class="key-value pending">○ Not added</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">Guardians</span><span class="key-value pending">○ 0 of 3</span></div>
|
||||||
|
<div class="key-row"><span class="key-label">Vault backup</span><span class="key-value pending">○ Not configured</span></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-primary" id="step5-btn" onclick="finishRegistration()">Go to Dashboard</button>
|
||||||
|
<button class="btn-secondary" onclick="finishRegistration()">Set up later</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="link-row" id="recovery-link-row">
|
<div class="link-row" id="recovery-link-row">
|
||||||
<a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
|
<a href="#" onclick="showRecoveryForm(); return false;">Lost your device? Recover account</a>
|
||||||
|
|
@ -6544,14 +6936,206 @@ app.get('/', (c) => {
|
||||||
const TOKEN_KEY = 'encryptid_token';
|
const TOKEN_KEY = 'encryptid_token';
|
||||||
let currentMode = 'signin';
|
let currentMode = 'signin';
|
||||||
|
|
||||||
|
// Registration stepper state
|
||||||
|
let regStep = 1;
|
||||||
|
let regData = {}; // stores username, email, userId, serverOptions, prfSalt, clientDid, eoaAddress, token, did
|
||||||
|
|
||||||
// Expose to inline onclick handlers
|
// Expose to inline onclick handlers
|
||||||
window.switchTab = (mode) => {
|
window.switchTab = (mode) => {
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
document.getElementById('tab-signin').classList.toggle('active', mode === 'signin');
|
document.getElementById('tab-signin').classList.toggle('active', mode === 'signin');
|
||||||
document.getElementById('tab-register').classList.toggle('active', mode === 'register');
|
document.getElementById('tab-register').classList.toggle('active', mode === 'register');
|
||||||
document.getElementById('register-fields').style.display = mode === 'register' ? 'block' : 'none';
|
document.getElementById('register-stepper').style.display = mode === 'register' ? 'block' : 'none';
|
||||||
document.getElementById('auth-btn').textContent = mode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
|
document.getElementById('signin-fields').style.display = mode === 'signin' ? 'block' : 'none';
|
||||||
|
document.getElementById('recovery-link-row').style.display = mode === 'signin' ? 'block' : 'none';
|
||||||
hideMessages();
|
hideMessages();
|
||||||
|
if (mode === 'register') {
|
||||||
|
regStep = 1;
|
||||||
|
updateStepper();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateStepper() {
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const stepEl = document.getElementById('reg-step-' + i);
|
||||||
|
const dotEl = document.querySelector('.stepper-dot[data-step="' + i + '"]');
|
||||||
|
if (stepEl) stepEl.classList.toggle('active', i === regStep);
|
||||||
|
if (dotEl) {
|
||||||
|
dotEl.classList.toggle('active', i === regStep);
|
||||||
|
dotEl.classList.toggle('done', i < regStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const lineEl = document.querySelector('.stepper-line[data-line="' + i + '"]');
|
||||||
|
if (lineEl) lineEl.classList.toggle('done', i < regStep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.goToStep = (step) => {
|
||||||
|
regStep = step;
|
||||||
|
updateStepper();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.copyDid = (el) => {
|
||||||
|
const text = el.querySelector('span')?.textContent || el.textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const toast = el.querySelector('.copy-toast');
|
||||||
|
if (toast) { toast.textContent = 'copied!'; setTimeout(() => toast.textContent = 'click to copy', 1500); }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 1: Validate username
|
||||||
|
window.handleStep1 = async () => {
|
||||||
|
const username = document.getElementById('username').value.trim();
|
||||||
|
if (!username) { showError('Username is required'); return; }
|
||||||
|
if (username.length < 2) { showError('Username must be at least 2 characters'); return; }
|
||||||
|
const email = document.getElementById('reg-email').value.trim();
|
||||||
|
const btn = document.getElementById('step1-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Checking...';
|
||||||
|
hideMessages();
|
||||||
|
try {
|
||||||
|
// Check username availability
|
||||||
|
const checkRes = await fetch('/api/register/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, displayName: username }),
|
||||||
|
});
|
||||||
|
if (!checkRes.ok) {
|
||||||
|
const err = await checkRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.error || 'Username may be taken');
|
||||||
|
}
|
||||||
|
const { options, userId, prfSalt } = await checkRes.json();
|
||||||
|
regData = { username, email, userId, serverOptions: options, prfSalt };
|
||||||
|
goToStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Continue';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Create passkey with PRF
|
||||||
|
window.handleStep2 = async () => {
|
||||||
|
const btn = document.getElementById('step2-btn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Waiting for authenticator...';
|
||||||
|
hideMessages();
|
||||||
|
try {
|
||||||
|
const { serverOptions, prfSalt, username, email, userId } = regData;
|
||||||
|
|
||||||
|
// Build PRF extension
|
||||||
|
const prfExtension = prfSalt ? {
|
||||||
|
prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
|
||||||
|
} : {};
|
||||||
|
|
||||||
|
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,
|
||||||
|
extensions: { credProps: true, ...prfExtension },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
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?.() || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check PRF results
|
||||||
|
let clientDid = null, eoaAddress = null, prfSupported = false;
|
||||||
|
const extResults = credential.getClientExtensionResults?.();
|
||||||
|
const prfResults = extResults?.prf?.results;
|
||||||
|
if (prfResults?.first) {
|
||||||
|
prfSupported = true;
|
||||||
|
try {
|
||||||
|
const km = getKeyManager();
|
||||||
|
await km.initFromPRF(prfResults.first);
|
||||||
|
const keys = await km.getKeys();
|
||||||
|
clientDid = keys.did;
|
||||||
|
eoaAddress = keys.eoaAddress;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('EncryptID: PRF key derivation failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete registration with server
|
||||||
|
const regBody = {
|
||||||
|
challenge: serverOptions.challenge,
|
||||||
|
credential: credentialData,
|
||||||
|
userId, username,
|
||||||
|
};
|
||||||
|
if (email) regBody.email = email;
|
||||||
|
if (clientDid) regBody.clientDid = clientDid;
|
||||||
|
if (eoaAddress) regBody.eoaAddress = eoaAddress;
|
||||||
|
|
||||||
|
const res = await fetch('/api/register/complete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(regBody),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Registration failed');
|
||||||
|
|
||||||
|
// Save to regData for subsequent steps
|
||||||
|
regData.token = data.token;
|
||||||
|
regData.did = data.did;
|
||||||
|
regData.clientDid = clientDid;
|
||||||
|
regData.eoaAddress = eoaAddress;
|
||||||
|
regData.prfSupported = prfSupported;
|
||||||
|
localStorage.setItem(TOKEN_KEY, data.token);
|
||||||
|
|
||||||
|
// Populate step 3
|
||||||
|
if (prfSupported && clientDid) {
|
||||||
|
document.getElementById('step3-did-text').textContent = clientDid;
|
||||||
|
document.getElementById('step3-prf-content').style.display = 'block';
|
||||||
|
document.getElementById('step3-no-prf').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
document.getElementById('step3-prf-content').style.display = 'none';
|
||||||
|
document.getElementById('step3-no-prf').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate step 4
|
||||||
|
if (prfSupported && eoaAddress) {
|
||||||
|
document.getElementById('step4-eoa').textContent = eoaAddress;
|
||||||
|
document.getElementById('step4-prf-content').style.display = 'block';
|
||||||
|
document.getElementById('step4-no-prf').style.display = 'none';
|
||||||
|
} else {
|
||||||
|
document.getElementById('step4-prf-content').style.display = 'none';
|
||||||
|
document.getElementById('step4-no-prf').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate step 5
|
||||||
|
if (email) {
|
||||||
|
document.getElementById('step5-email').textContent = '✓ ' + email;
|
||||||
|
document.getElementById('step5-email').classList.remove('pending');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip steps 3-4 if no PRF
|
||||||
|
goToStep(prfSupported ? 3 : 5);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err.message);
|
||||||
|
btn.textContent = 'Create Passkey';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.finishRegistration = () => {
|
||||||
|
const { token, username, did, prfSupported, clientDid, eoaAddress } = regData;
|
||||||
|
showProfile(token, username, did, { prfSupported, clientDid, eoaAddress });
|
||||||
};
|
};
|
||||||
|
|
||||||
function showError(msg) {
|
function showError(msg) {
|
||||||
|
|
@ -6604,79 +7188,52 @@ app.get('/', (c) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// handleAuth is now sign-in only (registration uses stepper)
|
||||||
window.handleAuth = async () => {
|
window.handleAuth = async () => {
|
||||||
const btn = document.getElementById('auth-btn');
|
const btn = document.getElementById('auth-btn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
hideMessages();
|
|
||||||
try {
|
|
||||||
if (currentMode === 'register') {
|
|
||||||
const username = document.getElementById('username').value.trim();
|
|
||||||
if (!username) { showError('Username is required'); btn.disabled = false; return; }
|
|
||||||
const email = document.getElementById('reg-email').value.trim();
|
|
||||||
|
|
||||||
btn.textContent = 'Creating passkey...';
|
|
||||||
|
|
||||||
// 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: serverOptions.challenge,
|
|
||||||
credential: credentialData,
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
};
|
|
||||||
if (email) regBody.email = email;
|
|
||||||
const res = await fetch('/api/register/complete', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(regBody),
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
if (!res.ok) throw new Error(data.error || 'Registration failed');
|
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, data.token);
|
|
||||||
showProfile(data.token, username, data.did);
|
|
||||||
} else {
|
|
||||||
btn.textContent = 'Waiting for passkey...';
|
btn.textContent = 'Waiting for passkey...';
|
||||||
|
hideMessages();
|
||||||
|
|
||||||
// Server-initiated auth flow
|
// Get email/username if provided to scope the passkey picker
|
||||||
|
const signinInput = document.getElementById('signin-email').value.trim();
|
||||||
|
const isEmail = signinInput.includes('@');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Server-initiated auth flow — pass email/username to scope credentials
|
||||||
|
const authBody = {};
|
||||||
|
if (signinInput) {
|
||||||
|
if (isEmail) authBody.email = signinInput;
|
||||||
|
else authBody.username = signinInput;
|
||||||
|
}
|
||||||
const startRes = await fetch('/api/auth/start', {
|
const startRes = await fetch('/api/auth/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: '{}',
|
body: JSON.stringify(authBody),
|
||||||
});
|
});
|
||||||
if (!startRes.ok) throw new Error('Failed to start authentication');
|
if (!startRes.ok) throw new Error('Failed to start authentication');
|
||||||
const { options: serverOptions } = await startRes.json();
|
const { options: serverOptions, prfSalt, userFound } = await startRes.json();
|
||||||
|
|
||||||
|
// If user provided email/username but no account found, show magic link option
|
||||||
|
if (signinInput && !userFound) {
|
||||||
|
showError('No account found. Check your email/username or register a new account.');
|
||||||
|
if (isEmail) {
|
||||||
|
document.getElementById('magic-link-section').style.display = 'block';
|
||||||
|
}
|
||||||
|
btn.textContent = 'Sign In with Passkey';
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show magic link option when email is provided (in case passkey isn't on this device)
|
||||||
|
if (isEmail) {
|
||||||
|
document.getElementById('magic-link-section').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build PRF extension for sign-in
|
||||||
|
const prfExtension = prfSalt ? {
|
||||||
|
prf: { eval: { first: new Uint8Array(base64urlToBuffer(prfSalt)) } },
|
||||||
|
} : {};
|
||||||
|
|
||||||
const credential = await navigator.credentials.get({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: {
|
publicKey: {
|
||||||
|
|
@ -6684,9 +7241,11 @@ app.get('/', (c) => {
|
||||||
rpId: serverOptions.rpId || 'rspace.online',
|
rpId: serverOptions.rpId || 'rspace.online',
|
||||||
userVerification: 'required',
|
userVerification: 'required',
|
||||||
timeout: 60000,
|
timeout: 60000,
|
||||||
|
allowCredentials: serverOptions.allowCredentials,
|
||||||
|
extensions: { ...prfExtension },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!credential) throw new Error('Authentication failed');
|
if (!credential) throw new Error('Authentication cancelled');
|
||||||
|
|
||||||
const res = await fetch('/api/auth/complete', {
|
const res = await fetch('/api/auth/complete', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -6700,16 +7259,98 @@ app.get('/', (c) => {
|
||||||
if (!res.ok) throw new Error(data.error || 'Authentication failed');
|
if (!res.ok) throw new Error(data.error || 'Authentication failed');
|
||||||
|
|
||||||
localStorage.setItem(TOKEN_KEY, data.token);
|
localStorage.setItem(TOKEN_KEY, data.token);
|
||||||
showProfile(data.token, data.username, data.did);
|
|
||||||
|
// Check for PRF output and auto-upgrade DID if needed
|
||||||
|
const extResults = credential.getClientExtensionResults?.();
|
||||||
|
const prfResults = extResults?.prf?.results;
|
||||||
|
if (prfResults?.first) {
|
||||||
|
try {
|
||||||
|
const km = getKeyManager();
|
||||||
|
await km.initFromPRF(prfResults.first);
|
||||||
|
const keys = await km.getKeys();
|
||||||
|
// Check if server DID is truncated (doesn't start with z6Mk)
|
||||||
|
const serverDid = data.did || '';
|
||||||
|
const didPart = serverDid.replace('did:key:', '');
|
||||||
|
if (keys.did && !didPart.startsWith('z6Mk') && !didPart.startsWith('z')) {
|
||||||
|
// Auto-upgrade DID
|
||||||
|
try {
|
||||||
|
const upgradeRes = await fetch('/api/account/upgrade-did', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + data.token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
clientDid: keys.did,
|
||||||
|
eoaAddress: keys.eoaAddress,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (upgradeRes.ok) {
|
||||||
|
const upgradeData = await upgradeRes.json();
|
||||||
|
if (upgradeData.token) {
|
||||||
|
localStorage.setItem(TOKEN_KEY, upgradeData.token);
|
||||||
|
data.token = upgradeData.token;
|
||||||
|
data.did = keys.did;
|
||||||
|
console.log('EncryptID: DID auto-upgraded to', keys.did);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('EncryptID: DID upgrade failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('EncryptID: PRF key derivation during sign-in failed', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showProfile(data.token, data.username, data.did);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// If passkey auth was cancelled/failed but email was provided, suggest magic link
|
||||||
|
if (isEmail && err.name === 'NotAllowedError') {
|
||||||
|
showError('Passkey not found on this device. Use the email link below to sign in.');
|
||||||
|
document.getElementById('magic-link-section').style.display = 'block';
|
||||||
|
} else {
|
||||||
showError(err.message || 'Authentication failed');
|
showError(err.message || 'Authentication failed');
|
||||||
btn.textContent = currentMode === 'signin' ? 'Sign In with Passkey' : 'Register with Passkey';
|
}
|
||||||
|
btn.textContent = 'Sign In with Passkey';
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
async function showProfile(token, username, did) {
|
// Magic link: send login email
|
||||||
|
window.sendMagicLink = async () => {
|
||||||
|
const email = document.getElementById('signin-email').value.trim();
|
||||||
|
const msgEl = document.getElementById('magic-link-msg');
|
||||||
|
const btn = document.getElementById('magic-link-btn');
|
||||||
|
if (!email || !email.includes('@')) {
|
||||||
|
msgEl.textContent = 'Enter your email address above first';
|
||||||
|
msgEl.style.color = '#fca5a5';
|
||||||
|
msgEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Sending...';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/auth/magic-link', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
msgEl.textContent = data.message || 'Check your email for a login link.';
|
||||||
|
msgEl.style.color = '#86efac';
|
||||||
|
msgEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Link sent!';
|
||||||
|
} catch (err) {
|
||||||
|
msgEl.textContent = 'Failed to send: ' + err.message;
|
||||||
|
msgEl.style.color = '#fca5a5';
|
||||||
|
msgEl.style.display = 'block';
|
||||||
|
btn.textContent = 'Email me a login link';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function showProfile(token, username, did, extra = {}) {
|
||||||
// If there's a redirect param, navigate there after login
|
// If there's a redirect param, navigate there after login
|
||||||
const redirectUrl = new URLSearchParams(location.search).get('redirect');
|
const redirectUrl = new URLSearchParams(location.search).get('redirect');
|
||||||
if (redirectUrl && redirectUrl.startsWith('/')) {
|
if (redirectUrl && redirectUrl.startsWith('/')) {
|
||||||
|
|
@ -7258,7 +7899,7 @@ app.post('/api/delegations', async (c) => {
|
||||||
return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400);
|
return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const delegatorDid = claims.did as string;
|
||||||
|
|
||||||
if (delegateDid === delegatorDid) {
|
if (delegateDid === delegatorDid) {
|
||||||
return c.json({ error: 'Cannot delegate to yourself' }, 400);
|
return c.json({ error: 'Cannot delegate to yourself' }, 400);
|
||||||
|
|
@ -7328,7 +7969,7 @@ app.get('/api/delegations/from', async (c) => {
|
||||||
const spaceSlug = c.req.query('space');
|
const spaceSlug = c.req.query('space');
|
||||||
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
||||||
|
|
||||||
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const delegatorDid = claims.did as string;
|
||||||
const delegations = await listDelegationsFrom(delegatorDid, spaceSlug);
|
const delegations = await listDelegationsFrom(delegatorDid, spaceSlug);
|
||||||
return c.json({ delegations });
|
return c.json({ delegations });
|
||||||
});
|
});
|
||||||
|
|
@ -7341,7 +7982,7 @@ app.get('/api/delegations/to', async (c) => {
|
||||||
const spaceSlug = c.req.query('space');
|
const spaceSlug = c.req.query('space');
|
||||||
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400);
|
||||||
|
|
||||||
const delegateDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const delegateDid = claims.did as string;
|
||||||
const delegations = await listDelegationsTo(delegateDid, spaceSlug);
|
const delegations = await listDelegationsTo(delegateDid, spaceSlug);
|
||||||
return c.json({ delegations });
|
return c.json({ delegations });
|
||||||
});
|
});
|
||||||
|
|
@ -7355,7 +7996,7 @@ app.patch('/api/delegations/:id', async (c) => {
|
||||||
const existing = await getDelegation(id);
|
const existing = await getDelegation(id);
|
||||||
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
|
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
|
||||||
|
|
||||||
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const delegatorDid = claims.did as string;
|
||||||
if (existing.delegatorDid !== delegatorDid) {
|
if (existing.delegatorDid !== delegatorDid) {
|
||||||
return c.json({ error: 'Only the delegator can modify a delegation' }, 403);
|
return c.json({ error: 'Only the delegator can modify a delegation' }, 403);
|
||||||
}
|
}
|
||||||
|
|
@ -7416,7 +8057,7 @@ app.delete('/api/delegations/:id', async (c) => {
|
||||||
const existing = await getDelegation(id);
|
const existing = await getDelegation(id);
|
||||||
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
|
if (!existing) return c.json({ error: 'Delegation not found' }, 404);
|
||||||
|
|
||||||
const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`;
|
const delegatorDid = claims.did as string;
|
||||||
if (existing.delegatorDid !== delegatorDid) {
|
if (existing.delegatorDid !== delegatorDid) {
|
||||||
return c.json({ error: 'Only the delegator can revoke a delegation' }, 403);
|
return c.json({ error: 'Only the delegator can revoke a delegation' }, 403);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue