feat: add OIDC provider + identity invite flow to EncryptID

Add full OIDC Authorization Code flow (discovery, authorize, token,
userinfo) so external apps like Postiz can authenticate via EncryptID
passkeys through auth.ridentity.online.

Add "Claim your rSpace" identity invite system — authenticated users
can invite friends by email, who register a passkey and optionally
auto-join a space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-09 15:53:37 -07:00
parent 93b6b2eb2c
commit f19a5b9904
4 changed files with 1194 additions and 1 deletions

View File

@ -25,6 +25,8 @@ services:
- MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080} - MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080}
- MAILCOW_API_KEY=${MAILCOW_API_KEY:-} - MAILCOW_API_KEY=${MAILCOW_API_KEY:-}
- ADMIN_DIDS=${ADMIN_DIDS} - ADMIN_DIDS=${ADMIN_DIDS}
- OIDC_ISSUER=${OIDC_ISSUER:-https://auth.ridentity.online}
- OIDC_CLIENTS=${OIDC_CLIENTS:-}
labels: labels:
# Traefik auto-discovery # Traefik auto-discovery
- "traefik.enable=true" - "traefik.enable=true"

View File

@ -1309,4 +1309,193 @@ export async function cleanExpiredFundClaims(): Promise<number> {
return result.count; return result.count;
} }
// ============================================================================
// OIDC PROVIDER
// ============================================================================
export interface StoredOidcClient {
clientId: string;
clientSecret: string;
name: string;
redirectUris: string[];
allowedEmails: string[];
createdAt: number;
}
export async function getOidcClient(clientId: string): Promise<StoredOidcClient | null> {
const rows = await sql`SELECT * FROM oidc_clients WHERE client_id = ${clientId}`;
if (!rows.length) return null;
const r = rows[0];
return {
clientId: r.client_id,
clientSecret: r.client_secret,
name: r.name,
redirectUris: r.redirect_uris,
allowedEmails: r.allowed_emails || [],
createdAt: new Date(r.created_at).getTime(),
};
}
export async function createOidcAuthCode(
code: string,
clientId: string,
userId: string,
redirectUri: string,
scope: string,
): Promise<void> {
const expiresAt = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes
await sql`
INSERT INTO oidc_auth_codes (code, client_id, user_id, redirect_uri, scope, expires_at)
VALUES (${code}, ${clientId}, ${userId}, ${redirectUri}, ${scope}, ${expiresAt})
`;
}
export async function consumeOidcAuthCode(code: string): Promise<{
clientId: string;
userId: string;
redirectUri: string;
scope: string;
} | null> {
const rows = await sql`
UPDATE oidc_auth_codes
SET used = TRUE
WHERE code = ${code} AND used = FALSE AND expires_at > NOW()
RETURNING client_id, user_id, redirect_uri, scope
`;
if (!rows.length) return null;
const r = rows[0];
return {
clientId: r.client_id,
userId: r.user_id,
redirectUri: r.redirect_uri,
scope: r.scope,
};
}
export async function cleanExpiredOidcCodes(): Promise<number> {
const result = await sql`DELETE FROM oidc_auth_codes WHERE expires_at < NOW() OR used = TRUE`;
return result.count;
}
export async function seedOidcClients(clients: Array<{
clientId: string;
clientSecret: string;
name: string;
redirectUris: string[];
allowedEmails?: string[];
}>): Promise<void> {
for (const c of clients) {
await sql`
INSERT INTO oidc_clients (client_id, client_secret, name, redirect_uris, allowed_emails)
VALUES (${c.clientId}, ${c.clientSecret}, ${c.name}, ${c.redirectUris}, ${c.allowedEmails || []})
ON CONFLICT (client_id) DO UPDATE SET
client_secret = EXCLUDED.client_secret,
name = EXCLUDED.name,
redirect_uris = EXCLUDED.redirect_uris,
allowed_emails = EXCLUDED.allowed_emails
`;
}
}
// ============================================================================
// IDENTITY INVITES
// ============================================================================
export interface StoredIdentityInvite {
id: string;
token: string;
email: string;
invitedByUserId: string;
invitedByUsername: string;
message: string | null;
spaceSlug: string | null;
spaceRole: string;
status: string;
claimedByUserId: string | null;
createdAt: number;
expiresAt: number;
claimedAt: number | null;
}
function mapInviteRow(r: any): StoredIdentityInvite {
return {
id: r.id,
token: r.token,
email: r.email,
invitedByUserId: r.invited_by_user_id,
invitedByUsername: r.invited_by_username,
message: r.message,
spaceSlug: r.space_slug,
spaceRole: r.space_role,
status: r.status,
claimedByUserId: r.claimed_by_user_id,
createdAt: new Date(r.created_at).getTime(),
expiresAt: new Date(r.expires_at).getTime(),
claimedAt: r.claimed_at ? new Date(r.claimed_at).getTime() : null,
};
}
export async function createIdentityInvite(invite: {
id: string;
token: string;
email: string;
invitedByUserId: string;
invitedByUsername: string;
message?: string;
spaceSlug?: string;
spaceRole?: string;
expiresAt: number;
}): Promise<StoredIdentityInvite> {
const rows = await sql`
INSERT INTO identity_invites (id, token, email, invited_by_user_id, invited_by_username, message, space_slug, space_role, expires_at)
VALUES (${invite.id}, ${invite.token}, ${invite.email}, ${invite.invitedByUserId},
${invite.invitedByUsername}, ${invite.message || null},
${invite.spaceSlug || null}, ${invite.spaceRole || 'member'},
${new Date(invite.expiresAt).toISOString()})
RETURNING *
`;
return mapInviteRow(rows[0]);
}
export async function getIdentityInviteByToken(token: string): Promise<StoredIdentityInvite | null> {
const rows = await sql`SELECT * FROM identity_invites WHERE token = ${token}`;
return rows.length ? mapInviteRow(rows[0]) : null;
}
export async function getIdentityInvitesByEmail(email: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE email = ${email} ORDER BY created_at DESC`;
return rows.map(mapInviteRow);
}
export async function getIdentityInvitesByInviter(userId: string): Promise<StoredIdentityInvite[]> {
const rows = await sql`SELECT * FROM identity_invites WHERE invited_by_user_id = ${userId} ORDER BY created_at DESC`;
return rows.map(mapInviteRow);
}
export async function claimIdentityInvite(token: string, claimedByUserId: string): Promise<StoredIdentityInvite | null> {
const rows = await sql`
UPDATE identity_invites
SET status = 'claimed', claimed_by_user_id = ${claimedByUserId}, claimed_at = NOW()
WHERE token = ${token} AND status = 'pending' AND expires_at > NOW()
RETURNING *
`;
return rows.length ? mapInviteRow(rows[0]) : null;
}
export async function revokeIdentityInvite(id: string, userId: string): Promise<boolean> {
const result = await sql`
UPDATE identity_invites SET status = 'revoked'
WHERE id = ${id} AND invited_by_user_id = ${userId} AND status = 'pending'
`;
return result.count > 0;
}
export async function cleanExpiredIdentityInvites(): Promise<number> {
const result = await sql`
UPDATE identity_invites SET status = 'expired'
WHERE status = 'pending' AND expires_at < NOW()
`;
return result.count;
}
export { sql }; export { sql };

View File

@ -255,3 +255,56 @@ CREATE TABLE IF NOT EXISTS fund_claims (
CREATE INDEX IF NOT EXISTS idx_fund_claims_token ON fund_claims(token); CREATE INDEX IF NOT EXISTS idx_fund_claims_token ON fund_claims(token);
CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hash ON fund_claims(email_hash); CREATE INDEX IF NOT EXISTS idx_fund_claims_email_hash ON fund_claims(email_hash);
CREATE INDEX IF NOT EXISTS idx_fund_claims_expires ON fund_claims(expires_at); CREATE INDEX IF NOT EXISTS idx_fund_claims_expires ON fund_claims(expires_at);
-- ============================================================================
-- OIDC PROVIDER (Authorization Code flow for Postiz, etc.)
-- ============================================================================
CREATE TABLE IF NOT EXISTS oidc_clients (
client_id TEXT PRIMARY KEY,
client_secret TEXT NOT NULL,
name TEXT NOT NULL,
redirect_uris TEXT[] NOT NULL,
allowed_emails TEXT[] DEFAULT '{}', -- empty = unrestricted
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS oidc_auth_codes (
code TEXT PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oidc_clients(client_id),
user_id TEXT NOT NULL REFERENCES users(id),
redirect_uri TEXT NOT NULL,
scope TEXT DEFAULT 'openid profile email',
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used BOOLEAN DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_oidc_auth_codes_expires ON oidc_auth_codes(expires_at);
-- ============================================================================
-- IDENTITY INVITES ("Claim your rSpace" flow)
-- ============================================================================
CREATE TABLE IF NOT EXISTS identity_invites (
id TEXT PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
email TEXT NOT NULL,
invited_by_user_id TEXT NOT NULL REFERENCES users(id),
invited_by_username TEXT NOT NULL,
message TEXT, -- optional personal message
space_slug TEXT, -- auto-join this space on claim
space_role TEXT DEFAULT 'member'
CHECK (space_role IN ('viewer', 'member', 'moderator', 'admin')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'claimed', 'expired', 'revoked')),
claimed_by_user_id TEXT REFERENCES users(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
claimed_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_identity_invites_token ON identity_invites(token);
CREATE INDEX IF NOT EXISTS idx_identity_invites_email ON identity_invites(email);
CREATE INDEX IF NOT EXISTS idx_identity_invites_invited_by ON identity_invites(invited_by_user_id);
CREATE INDEX IF NOT EXISTS idx_identity_invites_expires ON identity_invites(expires_at);

View File

@ -80,6 +80,17 @@ import {
acceptFundClaim, acceptFundClaim,
accumulateFundClaim, accumulateFundClaim,
cleanExpiredFundClaims, cleanExpiredFundClaims,
getOidcClient,
createOidcAuthCode,
consumeOidcAuthCode,
cleanExpiredOidcCodes,
seedOidcClients,
createIdentityInvite,
getIdentityInviteByToken,
getIdentityInvitesByInviter,
claimIdentityInvite,
revokeIdentityInvite,
cleanExpiredIdentityInvites,
sql, sql,
} from './db.js'; } from './db.js';
import { import {
@ -146,6 +157,9 @@ const CONFIG = {
'https://rinbox.online', 'https://rinbox.online',
'https://rmail.online', 'https://rmail.online',
'https://rsocials.online', 'https://rsocials.online',
'https://demo.rsocials.online',
'https://socials.crypto-commons.org',
'https://socials.p2pfoundation.net',
'https://rwork.online', 'https://rwork.online',
'https://rforum.online', 'https://rforum.online',
'https://rchoices.online', 'https://rchoices.online',
@ -3275,6 +3289,925 @@ app.get('/claim', (c) => {
`); `);
}); });
// ============================================================================
// IDENTITY INVITES ("Claim your rSpace" flow)
// ============================================================================
// Send an invite (authenticated user invites a friend by email)
app.post('/api/invites/identity', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const body = await c.req.json();
const { email, message, spaceSlug, spaceRole } = body;
if (!email || typeof email !== 'string' || !email.includes('@')) {
return c.json({ error: 'Valid email is required' }, 400);
}
// Check if this email already has an account
const existingUser = await getUserByEmail(email.toLowerCase().trim());
if (existingUser) {
return c.json({ error: 'This person already has an EncryptID account' }, 409);
}
const id = crypto.randomUUID();
const token = Buffer.from(crypto.getRandomValues(new Uint8Array(24))).toString('base64url');
const invite = await createIdentityInvite({
id,
token,
email: email.toLowerCase().trim(),
invitedByUserId: payload.sub,
invitedByUsername: payload.username,
message: message || undefined,
spaceSlug: spaceSlug || undefined,
spaceRole: spaceRole || 'member',
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7 days
});
// Send invite email
const joinLink = `https://auth.rspace.online/join?token=${encodeURIComponent(token)}`;
if (smtpTransport) {
try {
await smtpTransport.sendMail({
from: CONFIG.smtp.from,
to: email,
subject: `${payload.username} invited you to join rSpace`,
html: `
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 520px; margin: 0 auto; padding: 2rem;">
<h2 style="color: #1a1a2e;">You've been invited to rSpace</h2>
<p><strong>${escapeHtml(payload.username)}</strong> wants you to join the rSpace ecosystem a suite of privacy-first tools powered by passkey authentication.</p>
${message ? `<blockquote style="border-left: 3px solid #7c3aed; padding: 0.5rem 1rem; margin: 1rem 0; color: #475569; background: #f8fafc; border-radius: 0 0.5rem 0.5rem 0;">"${escapeHtml(message)}"</blockquote>` : ''}
<p>Click below to claim your identity and set up your passkey:</p>
<p style="text-align: center; margin: 2rem 0;">
<a href="${joinLink}" style="display: inline-block; padding: 0.85rem 2rem; background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff; text-decoration: none; border-radius: 0.5rem; font-weight: 600; font-size: 1rem;">Claim your rSpace</a>
</p>
<p style="color: #94a3b8; font-size: 0.85rem;">This invite expires in 7 days. If you didn't expect this, you can safely ignore it.</p>
</div>
`,
});
} catch (err) {
console.error('EncryptID: Failed to send invite email:', (err as Error).message);
}
} else {
console.log(`EncryptID: [NO SMTP] Invite link for ${email}: ${joinLink}`);
}
return c.json({ id: invite.id, token: invite.token, email: invite.email });
});
// List invites sent by the authenticated user
app.get('/api/invites/identity', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const invites = await getIdentityInvitesByInviter(payload.sub);
return c.json({ invites });
});
// Revoke a pending invite
app.delete('/api/invites/identity/:id', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'Unauthorized' }, 401);
}
let payload: any;
try {
payload = await verify(authHeader.slice(7), CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid token' }, 401);
}
const revoked = await revokeIdentityInvite(c.req.param('id'), payload.sub);
return revoked ? c.json({ success: true }) : c.json({ error: 'Invite not found or already claimed' }, 404);
});
// Get invite info (public — for the claim page)
app.get('/api/invites/identity/:token/info', async (c) => {
const invite = await getIdentityInviteByToken(c.req.param('token'));
if (!invite || invite.status !== 'pending') {
return c.json({ error: 'Invite not found or expired' }, 404);
}
if (Date.now() > invite.expiresAt) {
return c.json({ error: 'Invite expired' }, 410);
}
return c.json({
invitedBy: invite.invitedByUsername,
message: invite.message,
email: invite.email,
spaceSlug: invite.spaceSlug,
});
});
// Claim an invite (after passkey registration)
app.post('/api/invites/identity/:token/claim', async (c) => {
const token = c.req.param('token');
const body = await c.req.json();
const { sessionToken } = body;
// Verify the new user's session token (from registration)
let payload: any;
try {
payload = await verify(sessionToken, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid session' }, 401);
}
const invite = await getIdentityInviteByToken(token);
if (!invite || invite.status !== 'pending') {
return c.json({ error: 'Invite not found or already claimed' }, 404);
}
if (Date.now() > invite.expiresAt) {
return c.json({ error: 'Invite expired' }, 410);
}
// Link the email to the new user's account
await setUserEmail(payload.sub, invite.email);
// Claim the invite
const claimed = await claimIdentityInvite(token, payload.sub);
if (!claimed) {
return c.json({ error: 'Failed to claim invite' }, 500);
}
// Auto-join space if specified
if (invite.spaceSlug) {
const did = `did:key:${payload.sub.slice(0, 32)}`;
await upsertSpaceMember(invite.spaceSlug, did, invite.spaceRole, `did:key:${invite.invitedByUserId.slice(0, 32)}`);
}
return c.json({ success: true, email: invite.email, spaceSlug: invite.spaceSlug });
});
// Join page — the invite claim UI
app.get('/join', async (c) => {
const token = c.req.query('token');
return c.html(joinPage(token || ''));
});
function joinPage(token: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claim your rSpace 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: 440px;
width: 90%;
text-align: center;
}
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.5rem; }
.inviter { color: #7c3aed; font-weight: 600; }
.message-box {
border-left: 3px solid #7c3aed;
padding: 0.6rem 1rem;
margin: 1rem 0 1.5rem;
text-align: left;
color: #cbd5e1;
background: rgba(124,58,237,0.08);
border-radius: 0 0.5rem 0.5rem 0;
font-size: 0.9rem;
font-style: italic;
display: none;
}
.form-group { margin-bottom: 1rem; text-align: left; }
.form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; }
.form-group input {
width: 100%; padding: 0.7rem 1rem; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.05); color: #fff; font-size: 0.95rem; outline: none;
}
.form-group input:focus { border-color: #7c3aed; }
.form-group input::placeholder { color: #475569; }
.form-group input:read-only { opacity: 0.6; cursor: not-allowed; }
.btn-primary {
width: 100%; padding: 0.85rem; border-radius: 0.5rem; border: none;
background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
margin-top: 0.5rem;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.error {
background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
color: #fca5a5; margin-bottom: 1rem; display: none;
}
.success {
background: rgba(34,197,94,0.15); border: 1px solid rgba(34,197,94,0.3);
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
color: #86efac; display: none;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
.step-indicator {
display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem;
}
.step {
width: 8px; height: 8px; border-radius: 50%;
background: rgba(255,255,255,0.15); transition: background 0.3s;
}
.step.active { background: #7c3aed; }
.step.done { background: #22c55e; }
.features {
margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8;
}
.features li { margin: 0.4rem 0; list-style: none; }
.features li::before { content: '✦ '; color: #00d4ff; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Claim your rSpace</h1>
<p id="subtitle" class="sub">Create your passkey-protected identity</p>
<div class="step-indicator">
<div id="step1" class="step active"></div>
<div id="step2" class="step"></div>
<div id="step3" class="step"></div>
</div>
<div id="messageBox" class="message-box"></div>
<div id="error" class="error"></div>
<div id="success" class="success"></div>
<div id="registerForm">
<div class="form-group">
<label>Username</label>
<input type="text" id="username" placeholder="Choose a username" autocomplete="username" />
</div>
<div class="form-group">
<label>Email</label>
<input type="email" id="email" placeholder="your@email.com" readonly />
</div>
<button id="registerBtn" class="btn-primary" onclick="register()" disabled>
Create Passkey &amp; Claim
</button>
</div>
<p id="status" class="status"></p>
<ul class="features">
<li>No passwords your device is the key</li>
<li>Works across the entire r* ecosystem</li>
<li>Privacy-first, encrypted by default</li>
</ul>
</div>
<script>
const TOKEN = ${JSON.stringify(token)};
let inviteData = null;
const errorEl = document.getElementById('error');
const successEl = document.getElementById('success');
const statusEl = document.getElementById('status');
const registerBtn = document.getElementById('registerBtn');
const usernameInput = document.getElementById('username');
const emailInput = document.getElementById('email');
const messageBox = document.getElementById('messageBox');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
successEl.style.display = 'none';
statusEl.style.display = 'none';
}
function showStatus(msg) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
function setStep(n) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById('step' + i);
el.className = 'step' + (i < n ? ' done' : i === n ? ' active' : '');
}
}
// Load invite info
(async () => {
if (!TOKEN) {
showError('No invite token provided. You need an invitation link to join.');
return;
}
try {
const res = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/info');
if (!res.ok) {
const data = await res.json();
showError(data.error || 'This invite is no longer valid.');
return;
}
inviteData = await res.json();
document.getElementById('subtitle').innerHTML =
'<span class="inviter">' + inviteData.invitedBy + '</span> invited you to join rSpace';
emailInput.value = inviteData.email;
if (inviteData.message) {
messageBox.textContent = '"' + inviteData.message + '"';
messageBox.style.display = 'block';
}
registerBtn.disabled = false;
} catch (err) {
showError('Failed to load invite. Please try again.');
}
})();
async function register() {
const username = usernameInput.value.trim();
if (!username || username.length < 2) {
showError('Username must be at least 2 characters');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
showError('Username can only contain letters, numbers, hyphens, and underscores');
return;
}
registerBtn.disabled = true;
errorEl.style.display = 'none';
setStep(2);
showStatus('Starting passkey registration...');
try {
// Step 1: Start registration
const startRes = await fetch('/api/register/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username }),
});
const startData = await startRes.json();
if (startData.error) {
showError(startData.error);
registerBtn.disabled = false;
setStep(1);
return;
}
// Step 2: Create credential
showStatus('Follow your browser prompt to create a passkey...');
const { options } = startData;
const challengeBytes = Uint8Array.from(
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const userIdBytes = Uint8Array.from(
atob(options.user.id.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const publicKeyOptions = {
challenge: challengeBytes,
rp: options.rp,
user: { ...options.user, id: userIdBytes },
pubKeyCredParams: options.pubKeyCredParams,
timeout: options.timeout,
authenticatorSelection: options.authenticatorSelection,
attestation: options.attestation,
};
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions });
// Step 3: Complete registration
setStep(3);
showStatus('Completing registration...');
const credentialId = btoa(String.fromCharCode(...new Uint8Array(credential.rawId)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const attestationObject = btoa(String.fromCharCode(...new Uint8Array(credential.response.attestationObject)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const clientDataJSON = btoa(String.fromCharCode(...new Uint8Array(credential.response.clientDataJSON)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const completeRes = await fetch('/api/register/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
challenge: options.challenge,
credential: {
credentialId,
attestationObject,
clientDataJSON,
transports: credential.response.getTransports ? credential.response.getTransports() : [],
},
}),
});
const completeData = await completeRes.json();
if (!completeData.success) {
showError(completeData.error || 'Registration failed');
registerBtn.disabled = false;
setStep(1);
return;
}
// Step 4: Claim the invite
showStatus('Claiming your invite...');
const claimRes = await fetch('/api/invites/identity/' + encodeURIComponent(TOKEN) + '/claim', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionToken: completeData.token }),
});
const claimData = await claimRes.json();
if (claimData.error) {
// Registration succeeded but claim failed — still show partial success
showError('Account created but invite claim failed: ' + claimData.error);
return;
}
// Success!
document.getElementById('registerForm').style.display = 'none';
statusEl.style.display = 'none';
successEl.innerHTML = '<strong>Welcome to rSpace!</strong><br>Your identity has been created and your passkey is set up.' +
(claimData.spaceSlug ? '<br>You\\'ve been added to <strong>' + claimData.spaceSlug + '</strong>.' : '') +
'<br><br><a href="https://rspace.online" style="color: #7c3aed;">Go to rSpace →</a>';
successEl.style.display = 'block';
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey creation was cancelled. Please try again.');
} else {
showError(err.message || 'Registration failed');
}
registerBtn.disabled = false;
setStep(1);
}
}
</script>
</body>
</html>`;
}
// ============================================================================
// OIDC PROVIDER (Authorization Code Flow)
// ============================================================================
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://auth.ridentity.online';
// Discovery document
app.get('/.well-known/openid-configuration', (c) => {
return c.json({
issuer: OIDC_ISSUER,
authorization_endpoint: `${OIDC_ISSUER}/oidc/authorize`,
token_endpoint: `${OIDC_ISSUER}/oidc/token`,
userinfo_endpoint: `${OIDC_ISSUER}/oidc/userinfo`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['HS256'],
scopes_supported: ['openid', 'profile', 'email'],
claims_supported: ['sub', 'email', 'name', 'preferred_username'],
grant_types_supported: ['authorization_code'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
});
});
// Authorization endpoint
app.get('/oidc/authorize', async (c) => {
const clientId = c.req.query('client_id');
const redirectUri = c.req.query('redirect_uri');
const responseType = c.req.query('response_type');
const scope = c.req.query('scope') || 'openid profile email';
const state = c.req.query('state') || '';
if (responseType !== 'code') {
return c.text('Unsupported response_type', 400);
}
if (!clientId || !redirectUri) {
return c.text('Missing client_id or redirect_uri', 400);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.text('Unknown client_id', 400);
}
if (!client.redirectUris.includes(redirectUri)) {
return c.text('Invalid redirect_uri', 400);
}
// Serve the authorize page — it handles login + consent in one flow
return c.html(oidcAuthorizePage(client.name, clientId, redirectUri, scope, state));
});
// Authorization callback (POST from the authorize page after passkey login)
app.post('/oidc/authorize', async (c) => {
const body = await c.req.json();
const { clientId, redirectUri, scope, state, token } = body;
// Verify the session token from passkey login
let payload: any;
try {
payload = await verify(token, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'Invalid session' }, 401);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.json({ error: 'Unknown client' }, 400);
}
if (!client.redirectUris.includes(redirectUri)) {
return c.json({ error: 'Invalid redirect_uri' }, 400);
}
// Check email allowlist
const user = await getUserById(payload.sub);
if (!user) {
return c.json({ error: 'User not found' }, 404);
}
const userEmail = user.email || user.profile_email;
if (client.allowedEmails.length > 0) {
if (!userEmail || !client.allowedEmails.includes(userEmail)) {
return c.json({ error: 'access_denied', message: 'You do not have access to this application.' }, 403);
}
}
// Generate auth code
const code = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
await createOidcAuthCode(code, clientId, payload.sub, redirectUri, scope || 'openid profile email');
const url = new URL(redirectUri);
url.searchParams.set('code', code);
if (state) url.searchParams.set('state', state);
return c.json({ redirectUrl: url.toString() });
});
// Token endpoint
app.post('/oidc/token', async (c) => {
const contentType = c.req.header('content-type') || '';
let grantType: string, code: string, redirectUri: string, clientId: string, clientSecret: string;
if (contentType.includes('application/json')) {
const body = await c.req.json();
grantType = body.grant_type;
code = body.code;
redirectUri = body.redirect_uri;
clientId = body.client_id;
clientSecret = body.client_secret;
} else {
// application/x-www-form-urlencoded (standard OIDC)
const body = await c.req.parseBody();
grantType = body.grant_type as string;
code = body.code as string;
redirectUri = body.redirect_uri as string;
clientId = body.client_id as string;
clientSecret = body.client_secret as string;
}
// Support Basic auth for client credentials
if (!clientId || !clientSecret) {
const authHeader = c.req.header('Authorization');
if (authHeader?.startsWith('Basic ')) {
const decoded = Buffer.from(authHeader.slice(6), 'base64').toString();
const [id, secret] = decoded.split(':');
clientId = clientId || id;
clientSecret = clientSecret || secret;
}
}
if (grantType !== 'authorization_code') {
return c.json({ error: 'unsupported_grant_type' }, 400);
}
const client = await getOidcClient(clientId);
if (!client) {
return c.json({ error: 'invalid_client' }, 401);
}
// Constant-time comparison for client secret
const expected = new TextEncoder().encode(client.clientSecret);
const received = new TextEncoder().encode(clientSecret || '');
if (expected.length !== received.length) {
return c.json({ error: 'invalid_client' }, 401);
}
let mismatch = 0;
for (let i = 0; i < expected.length; i++) {
mismatch |= expected[i] ^ received[i];
}
if (mismatch !== 0) {
return c.json({ error: 'invalid_client' }, 401);
}
// Consume auth code (atomic — marks used)
const authCode = await consumeOidcAuthCode(code);
if (!authCode) {
return c.json({ error: 'invalid_grant' }, 400);
}
if (authCode.clientId !== clientId) {
return c.json({ error: 'invalid_grant' }, 400);
}
if (authCode.redirectUri !== redirectUri) {
return c.json({ error: 'invalid_grant' }, 400);
}
// Look up user for claims
const user = await getUserById(authCode.userId);
if (!user) {
return c.json({ error: 'invalid_grant' }, 400);
}
const now = Math.floor(Date.now() / 1000);
const idTokenPayload = {
iss: OIDC_ISSUER,
sub: authCode.userId,
aud: clientId,
iat: now,
exp: now + 3600,
email: user.email || user.profile_email || '',
name: user.display_name || user.username,
preferred_username: user.username,
};
const idToken = await sign(idTokenPayload, CONFIG.jwtSecret);
// Access token is also a JWT with same claims (used by userinfo)
const accessTokenPayload = {
iss: OIDC_ISSUER,
sub: authCode.userId,
aud: clientId,
iat: now,
exp: now + 3600,
scope: authCode.scope,
};
const accessToken = await sign(accessTokenPayload, CONFIG.jwtSecret);
return c.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
id_token: idToken,
});
});
// UserInfo endpoint
app.get('/oidc/userinfo', async (c) => {
const authHeader = c.req.header('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return c.json({ error: 'invalid_token' }, 401);
}
const token = authHeader.slice(7);
let payload: any;
try {
payload = await verify(token, CONFIG.jwtSecret, 'HS256');
} catch {
return c.json({ error: 'invalid_token' }, 401);
}
const user = await getUserById(payload.sub);
if (!user) {
return c.json({ error: 'invalid_token' }, 401);
}
return c.json({
sub: payload.sub,
email: user.email || user.profile_email || '',
name: user.display_name || user.username,
preferred_username: user.username,
});
});
// Authorize page HTML — embeds passkey login, then posts back to complete authorization
function oidcAuthorizePage(appName: string, clientId: string, redirectUri: string, scope: string, state: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in 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;
}
.logo { font-size: 2.5rem; margin-bottom: 0.5rem; }
h1 { font-size: 1.3rem; margin-bottom: 0.25rem; }
.sub { color: #94a3b8; font-size: 0.9rem; margin-bottom: 2rem; }
.app-name { color: #7c3aed; font-weight: 600; }
.btn-primary {
width: 100%; padding: 0.85rem; border-radius: 0.5rem; border: none;
background: linear-gradient(90deg, #00d4ff, #7c3aed); color: #fff;
font-size: 1rem; font-weight: 600; cursor: pointer;
transition: transform 0.15s, opacity 0.15s;
}
.btn-primary:hover { transform: translateY(-1px); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.error {
background: rgba(239,68,68,0.15); border: 1px solid rgba(239,68,68,0.3);
border-radius: 0.5rem; padding: 0.6rem 1rem; font-size: 0.85rem;
color: #fca5a5; margin-bottom: 1rem; display: none;
}
.status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; }
.scope-list {
text-align: left; margin: 1rem 0 1.5rem; padding: 0.75rem 1rem;
background: rgba(255,255,255,0.04); border-radius: 0.5rem;
font-size: 0.85rem; color: #cbd5e1;
}
.scope-list li { margin: 0.3rem 0; list-style: none; }
.scope-list li::before { content: '✓ '; color: #22c55e; }
</style>
</head>
<body>
<div class="card">
<div class="logo">🔐</div>
<h1>Sign in with EncryptID</h1>
<p class="sub"><span class="app-name">${escapeHtml(appName)}</span> wants to access your account</p>
<ul class="scope-list">
<li>Your name and username</li>
<li>Your email address</li>
</ul>
<div id="error" class="error"></div>
<button id="loginBtn" class="btn-primary" onclick="startLogin()">
Sign in with Passkey
</button>
<p id="status" class="status"></p>
</div>
<script>
const CLIENT_ID = ${JSON.stringify(clientId)};
const REDIRECT_URI = ${JSON.stringify(redirectUri)};
const SCOPE = ${JSON.stringify(scope)};
const STATE = ${JSON.stringify(state)};
const errorEl = document.getElementById('error');
const statusEl = document.getElementById('status');
const loginBtn = document.getElementById('loginBtn');
function showError(msg) {
errorEl.textContent = msg;
errorEl.style.display = 'block';
statusEl.style.display = 'none';
}
function showStatus(msg) {
statusEl.textContent = msg;
statusEl.style.display = 'block';
errorEl.style.display = 'none';
}
async function startLogin() {
loginBtn.disabled = true;
errorEl.style.display = 'none';
showStatus('Starting passkey authentication...');
try {
// Step 1: Get authentication challenge
const startRes = await fetch('/api/auth/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
// Step 2: WebAuthn get assertion
const challengeBytes = Uint8Array.from(
atob(options.challenge.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
const publicKeyOptions = {
challenge: challengeBytes,
rpId: options.rpId,
userVerification: options.userVerification,
timeout: options.timeout,
};
if (options.allowCredentials) {
publicKeyOptions.allowCredentials = options.allowCredentials.map(c => ({
type: c.type,
id: Uint8Array.from(atob(c.id.replace(/-/g, '+').replace(/_/g, '/')), ch => ch.charCodeAt(0)),
transports: c.transports,
}));
}
showStatus('Touch your passkey...');
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions });
// Step 3: Complete authentication
const credentialId = btoa(String.fromCharCode(...new Uint8Array(assertion.rawId)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');
const completeRes = await fetch('/api/auth/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId,
authenticatorData: btoa(String.fromCharCode(...new Uint8Array(assertion.response.authenticatorData)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
clientDataJSON: btoa(String.fromCharCode(...new Uint8Array(assertion.response.clientDataJSON)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
signature: btoa(String.fromCharCode(...new Uint8Array(assertion.response.signature)))
.replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, ''),
},
}),
});
const authResult = await completeRes.json();
if (!authResult.success) {
showError(authResult.error || 'Authentication failed');
loginBtn.disabled = false;
return;
}
// Step 4: Exchange session token for OIDC auth code
showStatus('Authorizing...');
const authorizeRes = await fetch('/oidc/authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
scope: SCOPE,
state: STATE,
token: authResult.token,
}),
});
const authorizeResult = await authorizeRes.json();
if (authorizeResult.error) {
showError(authorizeResult.message || authorizeResult.error);
loginBtn.disabled = false;
return;
}
// Step 5: Redirect back to the app with the auth code
showStatus('Redirecting...');
window.location.href = authorizeResult.redirectUrl;
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Passkey authentication was cancelled.');
} else {
showError(err.message || 'Authentication failed');
}
loginBtn.disabled = false;
}
}
</script>
</body>
</html>`;
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ============================================================================ // ============================================================================
// SERVE STATIC FILES // SERVE STATIC FILES
// ============================================================================ // ============================================================================
@ -4368,13 +5301,29 @@ app.get('/', (c) => {
} }
})(); })();
// Clean expired challenges, recovery tokens, and fund claims every 10 minutes // Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes
setInterval(() => { setInterval(() => {
cleanExpiredChallenges().catch(() => {}); cleanExpiredChallenges().catch(() => {});
cleanExpiredRecoveryTokens().catch(() => {}); cleanExpiredRecoveryTokens().catch(() => {});
cleanExpiredFundClaims().catch(() => {}); cleanExpiredFundClaims().catch(() => {});
cleanExpiredOidcCodes().catch(() => {});
cleanExpiredIdentityInvites().catch(() => {});
}, 10 * 60 * 1000); }, 10 * 60 * 1000);
// Seed OIDC clients from environment (OIDC_CLIENTS=json array or individual env vars)
(async () => {
try {
const clientsJson = process.env.OIDC_CLIENTS;
if (clientsJson) {
const clients = JSON.parse(clientsJson);
await seedOidcClients(clients);
console.log(`EncryptID: Seeded ${clients.length} OIDC client(s)`);
}
} catch (err) {
console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message);
}
})();
console.log(` console.log(`