From cf7fab25f36b93128182837aa74ae2bc67b2da19 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 10 Mar 2026 12:33:20 -0700 Subject: [PATCH] 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 --- src/encryptid/server.ts | 298 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 296 insertions(+), 2 deletions(-) diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 0bba64f..4195cc6 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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 { + 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: ` + + + + + + +
+ + + + + +
+
+

rStack Identity

+
+

Hi ${username},

+

Verify your email address to sign in to ${appName}:

+
+ Verify Email +
+

This link expires in 30 minutes.

+

If you didn't request this, you can safely ignore this email.

+

+ Can't click the button? Copy this link:
+ ${verifyLink} +

+
+
+ +`, + }); + + return true; +} + async function sendClaimEmail(to: string, token: string, amount?: string, currency?: string): Promise { 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; } @@ -5327,6 +5504,18 @@ function oidcAuthorizePage(appName: string, clientId: string, redirectUri: strin
+
+

Your passkey is valid, but your account doesn't have a verified email for this app. Enter your email to verify access.

+ + + +
+ +
+
+

Check your inbox!
A verification link has been sent to . Click the link to verify your email and continue signing in.

+
+ @@ -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; + } + } + `; } +function verifyEmailResultPage(success: boolean, errorMessage: string = '', retryUrl: string = ''): string { + const content = success + ? `
+

Email Verified

+

Your email address has been verified. You can now sign in.

+ Continue to Sign In` + : `
+

Verification Failed

+

${escapeHtml(errorMessage)}

`; + + return ` + + + + + ${success ? 'Email Verified' : 'Verification Failed'} — EncryptID + + + +
+ ${content} +
+ +`; +} + function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); }