feat(encryptid): OIDC email verification flow for access_denied
When passkey auth succeeds but user's email doesn't match the OIDC client's allowedEmails, show an inline email verification form instead of a dead-end error. Sends a branded verification email with a single-use 30-minute token, then updates users.email on callback and lets the user retry sign-in. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
49f55dffc8
commit
cf7fab25f3
|
|
@ -275,6 +275,68 @@ async function sendRecoveryEmail(to: string, token: string, username: string): P
|
|||
return true;
|
||||
}
|
||||
|
||||
async function sendVerificationEmail(to: string, token: string, username: string, appName: string): Promise<boolean> {
|
||||
const verifyLink = `https://auth.rspace.online/oidc/verify-email-callback?token=${encodeURIComponent(token)}`;
|
||||
|
||||
if (!smtpTransport) {
|
||||
console.log(`EncryptID: [NO SMTP] Verification link for ${to}: ${verifyLink}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
await smtpTransport.sendMail({
|
||||
from: CONFIG.smtp.from,
|
||||
to,
|
||||
subject: 'rStack — Verify your email address',
|
||||
text: [
|
||||
`Hi ${username},`,
|
||||
'',
|
||||
`Please verify your email address to sign in to ${appName}.`,
|
||||
'Click the link below to verify:',
|
||||
'',
|
||||
verifyLink,
|
||||
'',
|
||||
'This link expires in 30 minutes.',
|
||||
'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 Identity</h1>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 24px;color:#e2e8f0;font-size:15px;line-height:1.6;">
|
||||
<p>Hi <strong>${username}</strong>,</p>
|
||||
<p>Verify your email address to sign in to <strong>${appName}</strong>:</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 32px 32px;text-align:center;">
|
||||
<a href="${verifyLink}" 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;">Verify Email</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>30 minutes</strong>.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</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;">${verifyLink}</span>
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendClaimEmail(to: string, token: string, amount?: string, currency?: string): Promise<boolean> {
|
||||
const claimLink = `https://auth.rspace.online/claim?token=${encodeURIComponent(token)}`;
|
||||
const amountStr = amount && currency ? `$${amount} ${currency}` : 'Your funds';
|
||||
|
|
@ -5124,6 +5186,109 @@ app.post('/oidc/authorize', async (c) => {
|
|||
return c.json({ redirectUrl: url.toString() });
|
||||
});
|
||||
|
||||
// Email verification for OIDC access — send verification email
|
||||
app.post('/oidc/verify-email', async (c) => {
|
||||
const body = await c.req.json();
|
||||
const { token, email, clientId, redirectUri, scope, state } = body;
|
||||
|
||||
if (!token || !email || !clientId) {
|
||||
return c.json({ error: 'Missing required fields' }, 400);
|
||||
}
|
||||
|
||||
// Verify session token
|
||||
let payload: any;
|
||||
try {
|
||||
payload = await verify(token, CONFIG.jwtSecret, 'HS256');
|
||||
} catch {
|
||||
return c.json({ error: 'Invalid session' }, 401);
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.sub);
|
||||
if (!user) {
|
||||
return c.json({ error: 'User not found' }, 404);
|
||||
}
|
||||
|
||||
const client = await getOidcClient(clientId);
|
||||
if (!client) {
|
||||
return c.json({ error: 'Unknown client' }, 400);
|
||||
}
|
||||
|
||||
// Security: only allow emails that are in the client's allowedEmails list
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
if (client.allowedEmails.length === 0 || !client.allowedEmails.includes(normalizedEmail)) {
|
||||
return c.json({ error: 'This email is not authorized for this application.' }, 403);
|
||||
}
|
||||
|
||||
// Encode OIDC context into the token key so the callback can reconstruct the flow
|
||||
const oidcContext = Buffer.from(JSON.stringify({
|
||||
email: normalizedEmail,
|
||||
userId: payload.sub,
|
||||
clientId,
|
||||
redirectUri: redirectUri || '',
|
||||
scope: scope || 'openid profile email',
|
||||
state: state || '',
|
||||
})).toString('base64url');
|
||||
|
||||
const random = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url');
|
||||
const verifyToken = `oidcverify_${oidcContext}_${random}`;
|
||||
|
||||
const now = Date.now();
|
||||
await storeRecoveryToken({
|
||||
token: verifyToken,
|
||||
userId: payload.sub,
|
||||
type: 'email_verify',
|
||||
createdAt: now,
|
||||
expiresAt: now + 30 * 60 * 1000,
|
||||
used: false,
|
||||
});
|
||||
|
||||
const sent = await sendVerificationEmail(normalizedEmail, verifyToken, user.username, client.name);
|
||||
|
||||
return c.json({ success: true, emailSent: sent });
|
||||
});
|
||||
|
||||
// Email verification callback — clicked from the verification email
|
||||
app.get('/oidc/verify-email-callback', async (c) => {
|
||||
const token = c.req.query('token');
|
||||
if (!token) {
|
||||
return c.html(verifyEmailResultPage(false, 'Missing verification token.'));
|
||||
}
|
||||
|
||||
// Validate token
|
||||
const record = await getRecoveryToken(token);
|
||||
if (!record) {
|
||||
return c.html(verifyEmailResultPage(false, 'Invalid or expired verification link.'));
|
||||
}
|
||||
if (record.used) {
|
||||
return c.html(verifyEmailResultPage(false, 'This verification link has already been used.'));
|
||||
}
|
||||
if (new Date(record.expiresAt) < new Date()) {
|
||||
return c.html(verifyEmailResultPage(false, 'This verification link has expired.'));
|
||||
}
|
||||
|
||||
// Parse OIDC context from token
|
||||
const parts = token.split('_');
|
||||
if (parts.length < 3 || parts[0] !== 'oidcverify') {
|
||||
return c.html(verifyEmailResultPage(false, 'Invalid verification token format.'));
|
||||
}
|
||||
|
||||
let context: { email: string; userId: string; clientId: string; redirectUri: string; scope: string; state: string };
|
||||
try {
|
||||
context = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
||||
} catch {
|
||||
return c.html(verifyEmailResultPage(false, 'Could not parse verification token.'));
|
||||
}
|
||||
|
||||
// Update user email and mark token used
|
||||
await setUserEmail(context.userId, context.email);
|
||||
await markRecoveryTokenUsed(token);
|
||||
|
||||
// Build retry URL
|
||||
const retryUrl = `/oidc/authorize?response_type=code&client_id=${encodeURIComponent(context.clientId)}&redirect_uri=${encodeURIComponent(context.redirectUri)}&scope=${encodeURIComponent(context.scope)}&state=${encodeURIComponent(context.state)}`;
|
||||
|
||||
return c.html(verifyEmailResultPage(true, '', retryUrl));
|
||||
});
|
||||
|
||||
// Token endpoint
|
||||
app.post('/oidc/token', async (c) => {
|
||||
const contentType = c.req.header('content-type') || '';
|
||||
|
|
@ -5312,6 +5477,18 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
}
|
||||
.scope-list li { margin: 0.3rem 0; list-style: none; }
|
||||
.scope-list li::before { content: '✓ '; color: #22c55e; }
|
||||
.verify-section { display: none; text-align: left; margin-top: 1rem; }
|
||||
.verify-section label { display: block; color: #cbd5e1; font-size: 0.85rem; margin-bottom: 0.4rem; }
|
||||
.verify-section input {
|
||||
width: 100%; padding: 0.7rem 0.75rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(255,255,255,0.06); color: #fff; font-size: 0.95rem; margin-bottom: 0.75rem;
|
||||
outline: none;
|
||||
}
|
||||
.verify-section input:focus { border-color: #7c3aed; }
|
||||
.verify-section p { color: #94a3b8; font-size: 0.82rem; line-height: 1.5; margin-bottom: 1rem; text-align: center; }
|
||||
.verify-sent { display: none; }
|
||||
.verify-sent .sent-icon { font-size: 2rem; margin-bottom: 0.5rem; }
|
||||
.verify-sent p { color: #94a3b8; font-size: 0.85rem; line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
|
@ -5327,6 +5504,18 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
|
||||
<div id="error" class="error"></div>
|
||||
|
||||
<div id="verify-section" class="verify-section">
|
||||
<p>Your passkey is valid, but your account doesn't have a verified email for this app. Enter your email to verify access.</p>
|
||||
<label for="verify-email">Email address</label>
|
||||
<input type="email" id="verify-email" placeholder="you@example.com" autocomplete="email">
|
||||
<button class="btn-primary" onclick="sendVerifyEmail()">Send Verification Email</button>
|
||||
</div>
|
||||
|
||||
<div id="verify-sent" class="verify-sent">
|
||||
<div class="sent-icon">✉</div>
|
||||
<p><strong>Check your inbox!</strong><br>A verification link has been sent to <strong id="sent-email"></strong>. Click the link to verify your email and continue signing in.</p>
|
||||
</div>
|
||||
|
||||
<button id="loginBtn" class="btn-primary" onclick="startLogin()">
|
||||
Sign in with Passkey
|
||||
</button>
|
||||
|
|
@ -5343,6 +5532,9 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
const errorEl = document.getElementById('error');
|
||||
const statusEl = document.getElementById('status');
|
||||
const loginBtn = document.getElementById('loginBtn');
|
||||
const verifySection = document.getElementById('verify-section');
|
||||
const verifySent = document.getElementById('verify-sent');
|
||||
let sessionToken = null;
|
||||
|
||||
function showError(msg) {
|
||||
errorEl.textContent = msg;
|
||||
|
|
@ -5422,6 +5614,9 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
return;
|
||||
}
|
||||
|
||||
// Save session token for email verification flow
|
||||
sessionToken = authResult.token;
|
||||
|
||||
// Step 4: Exchange session token for OIDC auth code
|
||||
showStatus('Authorizing...');
|
||||
const authorizeRes = await fetch('/oidc/authorize', {
|
||||
|
|
@ -5438,8 +5633,15 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
const authorizeResult = await authorizeRes.json();
|
||||
|
||||
if (authorizeResult.error) {
|
||||
showError(authorizeResult.message || authorizeResult.error);
|
||||
loginBtn.disabled = false;
|
||||
if (authorizeResult.error === 'access_denied') {
|
||||
// Show email verification flow instead of dead-end error
|
||||
loginBtn.style.display = 'none';
|
||||
statusEl.style.display = 'none';
|
||||
verifySection.style.display = 'block';
|
||||
} else {
|
||||
showError(authorizeResult.message || authorizeResult.error);
|
||||
loginBtn.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -5457,11 +5659,103 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
|
|||
}
|
||||
}
|
||||
|
||||
async function sendVerifyEmail() {
|
||||
const emailInput = document.getElementById('verify-email');
|
||||
const email = emailInput.value.trim().toLowerCase();
|
||||
if (!email || !email.includes('@')) {
|
||||
showError('Please enter a valid email address.');
|
||||
return;
|
||||
}
|
||||
if (!sessionToken) {
|
||||
showError('Session expired. Please refresh and try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
errorEl.style.display = 'none';
|
||||
verifySection.querySelector('.btn-primary').disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch('/oidc/verify-email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: sessionToken,
|
||||
email,
|
||||
clientId: CLIENT_ID,
|
||||
redirectUri: REDIRECT_URI,
|
||||
scope: SCOPE,
|
||||
state: STATE,
|
||||
}),
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.error) {
|
||||
showError(result.error);
|
||||
verifySection.querySelector('.btn-primary').disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Show success message
|
||||
verifySection.style.display = 'none';
|
||||
verifySent.style.display = 'block';
|
||||
document.getElementById('sent-email').textContent = email;
|
||||
} catch (err) {
|
||||
showError(err.message || 'Failed to send verification email.');
|
||||
verifySection.querySelector('.btn-primary').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function verifyEmailResultPage(success: boolean, errorMessage: string = '', retryUrl: string = ''): string {
|
||||
const content = success
|
||||
? `<div style="font-size:3rem;margin-bottom:1rem;">✓</div>
|
||||
<h1 style="font-size:1.3rem;margin-bottom:0.5rem;">Email Verified</h1>
|
||||
<p style="color:#94a3b8;font-size:0.9rem;margin-bottom:2rem;">Your email address has been verified. You can now sign in.</p>
|
||||
<a href="${escapeHtml(retryUrl)}" style="display:inline-block;width:100%;padding:0.85rem;border-radius:0.5rem;background:linear-gradient(90deg,#00d4ff,#7c3aed);color:#fff;text-decoration:none;font-size:1rem;font-weight:600;text-align:center;box-sizing:border-box;">Continue to Sign In</a>`
|
||||
: `<div style="font-size:3rem;margin-bottom:1rem;">✗</div>
|
||||
<h1 style="font-size:1.3rem;margin-bottom:0.5rem;">Verification Failed</h1>
|
||||
<p style="color:#fca5a5;font-size:0.9rem;margin-bottom:2rem;">${escapeHtml(errorMessage)}</p>`;
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${success ? 'Email Verified' : 'Verification Failed'} — EncryptID</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.06);
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
max-width: 400px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
${content}
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue