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:
parent
93b6b2eb2c
commit
f19a5b9904
|
|
@ -25,6 +25,8 @@ services:
|
|||
- MAILCOW_API_URL=${MAILCOW_API_URL:-http://nginx-mailcow:8080}
|
||||
- MAILCOW_API_KEY=${MAILCOW_API_KEY:-}
|
||||
- ADMIN_DIDS=${ADMIN_DIDS}
|
||||
- OIDC_ISSUER=${OIDC_ISSUER:-https://auth.ridentity.online}
|
||||
- OIDC_CLIENTS=${OIDC_CLIENTS:-}
|
||||
labels:
|
||||
# Traefik auto-discovery
|
||||
- "traefik.enable=true"
|
||||
|
|
|
|||
|
|
@ -1309,4 +1309,193 @@ export async function cleanExpiredFundClaims(): Promise<number> {
|
|||
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 };
|
||||
|
|
|
|||
|
|
@ -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_email_hash ON fund_claims(email_hash);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -80,6 +80,17 @@ import {
|
|||
acceptFundClaim,
|
||||
accumulateFundClaim,
|
||||
cleanExpiredFundClaims,
|
||||
getOidcClient,
|
||||
createOidcAuthCode,
|
||||
consumeOidcAuthCode,
|
||||
cleanExpiredOidcCodes,
|
||||
seedOidcClients,
|
||||
createIdentityInvite,
|
||||
getIdentityInviteByToken,
|
||||
getIdentityInvitesByInviter,
|
||||
claimIdentityInvite,
|
||||
revokeIdentityInvite,
|
||||
cleanExpiredIdentityInvites,
|
||||
sql,
|
||||
} from './db.js';
|
||||
import {
|
||||
|
|
@ -146,6 +157,9 @@ const CONFIG = {
|
|||
'https://rinbox.online',
|
||||
'https://rmail.online',
|
||||
'https://rsocials.online',
|
||||
'https://demo.rsocials.online',
|
||||
'https://socials.crypto-commons.org',
|
||||
'https://socials.p2pfoundation.net',
|
||||
'https://rwork.online',
|
||||
'https://rforum.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 & 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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(() => {
|
||||
cleanExpiredChallenges().catch(() => {});
|
||||
cleanExpiredRecoveryTokens().catch(() => {});
|
||||
cleanExpiredFundClaims().catch(() => {});
|
||||
cleanExpiredOidcCodes().catch(() => {});
|
||||
cleanExpiredIdentityInvites().catch(() => {});
|
||||
}, 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(`
|
||||
╔═══════════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
|
|
|
|||
Loading…
Reference in New Issue