diff --git a/docker-compose.encryptid.yml b/docker-compose.encryptid.yml index b0a55b0..c5b7d9a 100644 --- a/docker-compose.encryptid.yml +++ b/docker-compose.encryptid.yml @@ -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" diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index 0b93baf..cd7771d 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -1309,4 +1309,193 @@ export async function cleanExpiredFundClaims(): Promise { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + const result = await sql` + UPDATE identity_invites SET status = 'expired' + WHERE status = 'pending' AND expires_at < NOW() + `; + return result.count; +} + export { sql }; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index 453213d..c32004e 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.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); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index 93af80a..db3f6d9 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -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: ` +
+

You've been invited to rSpace

+

${escapeHtml(payload.username)} wants you to join the rSpace ecosystem — a suite of privacy-first tools powered by passkey authentication.

+ ${message ? `
"${escapeHtml(message)}"
` : ''} +

Click below to claim your identity and set up your passkey:

+

+ Claim your rSpace +

+

This invite expires in 7 days. If you didn't expect this, you can safely ignore it.

+
+ `, + }); + } 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 ` + + + + + Claim your rSpace — EncryptID + + + +
+ +

Claim your rSpace

+

Create your passkey-protected identity

+ +
+
+
+
+
+ +
+
+
+ +
+
+ + +
+
+ + +
+ +
+ +

+ +
    +
  • No passwords — your device is the key
  • +
  • Works across the entire r* ecosystem
  • +
  • Privacy-first, encrypted by default
  • +
+
+ + + +`; +} + +// ============================================================================ +// 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 ` + + + + + Sign in — EncryptID + + + +
+ +

Sign in with EncryptID

+

${escapeHtml(appName)} wants to access your account

+ +
    +
  • Your name and username
  • +
  • Your email address
  • +
+ +
+ + + +

+
+ + + +`; +} + +function escapeHtml(s: string): string { + return s.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(` ╔═══════════════════════════════════════════════════════════╗ ║ ║