my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 438 lines 14 kB view raw
1import { env } from "bun"; 2import { db } from "./db"; 3import adminHTML from "./html/admin.html"; 4import adminClientsHTML from "./html/admin-clients.html"; 5import adminInvitesHTML from "./html/admin-invites.html"; 6import appsHTML from "./html/apps.html"; 7import docsHTML from "./html/docs.html"; 8import indexHTML from "./html/index.html"; 9import loginHTML from "./html/login.html"; 10import { getLdapAccounts, updateOrphanedAccounts } from "./ldap-cleanup"; 11import { getDiscoveryDocument, getJWKS } from "./oidc"; 12import { 13 deleteSelfAccount, 14 deleteUser, 15 disableUser, 16 enableUser, 17 getAppDetails, 18 getAuthorizedApps, 19 getProfile, 20 hello, 21 listAllApps, 22 listUsers, 23 revokeApp, 24 revokeAppForUser, 25 updateProfile, 26 updateUserTier, 27} from "./routes/api"; 28import { 29 canRegister, 30 ldapVerify, 31 loginOptions, 32 loginVerify, 33 registerOptions, 34 registerVerify, 35} from "./routes/auth"; 36import { 37 createClient, 38 deleteClient, 39 getClient, 40 listClients, 41 regenerateClientSecret, 42 setUserRole, 43 updateClient, 44} from "./routes/clients"; 45import { 46 authorizeGet, 47 authorizePost, 48 clientMetadata, 49 createInvite, 50 deleteInvite, 51 indieauthMetadata, 52 listInvites, 53 logout, 54 token, 55 tokenIntrospect, 56 tokenRevoke, 57 updateInvite, 58 userinfo, 59 userProfile, 60} from "./routes/indieauth"; 61import { 62 addPasskeyOptions, 63 addPasskeyVerify, 64 deletePasskey, 65 listPasskeys, 66 renamePasskey, 67} from "./routes/passkeys"; 68 69(() => { 70 const required = ["ORIGIN", "RP_ID"]; 71 72 const missing = required.filter((key) => !process.env[key]); 73 74 if (missing.length > 0) { 75 console.warn( 76 `[Startup] Missing required environment variables: ${missing.join(", ")}`, 77 ); 78 process.exit(1); 79 } 80 81 // Validate ORIGIN is HTTPS in production 82 const origin = process.env.ORIGIN as string; 83 const rpId = process.env.RP_ID as string; 84 const nodeEnv = process.env.NODE_ENV || "development"; 85 86 if (nodeEnv === "production" && !origin.startsWith("https://")) { 87 console.error( 88 `[Startup] ORIGIN must use HTTPS in production (got: ${origin})`, 89 ); 90 process.exit(1); 91 } 92 93 // Validate RP_ID matches ORIGIN domain 94 try { 95 const originUrl = new URL(origin); 96 if (originUrl.hostname !== rpId) { 97 console.error( 98 `[Startup] RP_ID must match ORIGIN domain (ORIGIN: ${originUrl.hostname}, RP_ID: ${rpId})`, 99 ); 100 process.exit(1); 101 } 102 } catch { 103 console.error(`[Startup] Invalid ORIGIN URL format: ${origin}`); 104 process.exit(1); 105 } 106 107 console.log(`[Startup] Environment validated (${nodeEnv} mode)`); 108})(); 109 110const server = Bun.serve({ 111 port: env.PORT ? Number.parseInt(env.PORT, 10) : 3000, 112 routes: { 113 "/favicon.svg": Bun.file("./public/favicon.svg"), 114 "/": indexHTML, 115 "/health": () => { 116 try { 117 // Verify database is accessible 118 db.query("SELECT 1").get(); 119 return Response.json({ 120 status: "ok", 121 timestamp: new Date().toISOString(), 122 }); 123 } catch { 124 return Response.json( 125 { status: "error", error: "Database unavailable" }, 126 { status: 503 }, 127 ); 128 } 129 }, 130 "/admin": adminHTML, 131 "/admin/invites": adminInvitesHTML, 132 "/admin/apps": () => Response.redirect("/admin/clients", 302), 133 "/admin/clients": adminClientsHTML, 134 "/login": loginHTML, 135 "/docs": docsHTML, 136 "/apps": appsHTML, 137 // Well-known endpoints 138 "/.well-known/security.txt": () => { 139 const expiryDate = new Date(); 140 expiryDate.setMonth(expiryDate.getMonth() + 3); 141 expiryDate.setSeconds(0, 0); 142 const expires = expiryDate.toISOString(); 143 return new Response( 144 `# Security Contact Information for Indiko 145# See: https://securitytxt.org/ 146Contact: mailto:security@dunkirk.sh 147Expires: ${expires} 148Preferred-Languages: en 149Canonical: ${env.ORIGIN}/.well-known/security.txt 150Policy: https://tangled.org/dunkirk.sh/indiko/blob/main/SECURITY.md 151`, 152 { 153 headers: { 154 "Content-Type": "text/plain; charset=utf-8", 155 }, 156 }, 157 ); 158 }, 159 "/.well-known/oauth-authorization-server": indieauthMetadata, 160 "/.well-known/oauth-client": (req: Request) => { 161 if (req.method === "GET") return clientMetadata(req); 162 if (req.method === "OPTIONS") 163 return new Response(null, { 164 status: 204, 165 headers: { 166 "Access-Control-Allow-Origin": "*", 167 "Access-Control-Allow-Methods": "GET, OPTIONS", 168 }, 169 }); 170 return new Response("Method not allowed", { status: 405 }); 171 }, 172 "/.well-known/openid-configuration": () => { 173 const origin = process.env.ORIGIN as string; 174 return Response.json(getDiscoveryDocument(origin)); 175 }, 176 "/jwks": async () => { 177 const jwks = await getJWKS(); 178 return Response.json(jwks); 179 }, 180 // OAuth/IndieAuth endpoints 181 "/userinfo": (req: Request) => { 182 if (req.method === "GET") return userinfo(req); 183 return new Response("Method not allowed", { status: 405 }); 184 }, 185 // API endpoints 186 "/api/hello": hello, 187 "/api/users": listUsers, 188 "/api/profile": (req: Request) => { 189 if (req.method === "GET") return getProfile(req); 190 if (req.method === "PUT") return updateProfile(req); 191 if (req.method === "DELETE") return deleteSelfAccount(req); 192 return new Response("Method not allowed", { status: 405 }); 193 }, 194 "/api/apps": (req: Request) => { 195 if (req.method === "GET") return getAuthorizedApps(req); 196 return new Response("Method not allowed", { status: 405 }); 197 }, 198 "/api/admin/apps": (req: Request) => { 199 if (req.method === "GET") return listAllApps(req); 200 return new Response("Method not allowed", { status: 405 }); 201 }, 202 "/api/admin/clients": (req: Request) => { 203 if (req.method === "GET") return listClients(req); 204 if (req.method === "POST") return createClient(req); 205 return new Response("Method not allowed", { status: 405 }); 206 }, 207 "/api/invites/create": (req: Request) => { 208 if (req.method === "POST") return createInvite(req); 209 return new Response("Method not allowed", { status: 405 }); 210 }, 211 "/api/invites": (req: Request) => { 212 if (req.method === "GET") return listInvites(req); 213 return new Response("Method not allowed", { status: 405 }); 214 }, 215 "/api/invites/:id": (req: Request) => { 216 if (req.method === "PATCH") return updateInvite(req); 217 if (req.method === "DELETE") return deleteInvite(req); 218 return new Response("Method not allowed", { status: 405 }); 219 }, 220 "/api/admin/users/:id/disable": (req: Request) => { 221 if (req.method === "POST") { 222 const url = new URL(req.url); 223 const userId = url.pathname.split("/")[4]; 224 return disableUser(req, userId); 225 } 226 return new Response("Method not allowed", { status: 405 }); 227 }, 228 "/api/admin/users/:id/enable": (req: Request) => { 229 if (req.method === "POST") { 230 const url = new URL(req.url); 231 const userId = url.pathname.split("/")[4]; 232 return enableUser(req, userId); 233 } 234 return new Response("Method not allowed", { status: 405 }); 235 }, 236 "/api/admin/users/:id/tier": (req: Request) => { 237 if (req.method === "PUT") { 238 const url = new URL(req.url); 239 const userId = url.pathname.split("/")[4]; 240 return updateUserTier(req, userId); 241 } 242 return new Response("Method not allowed", { status: 405 }); 243 }, 244 "/api/admin/users/:id/delete": (req: Request) => { 245 if (req.method === "DELETE") { 246 const url = new URL(req.url); 247 const userId = url.pathname.split("/")[4]; 248 return deleteUser(req, userId); 249 } 250 return new Response("Method not allowed", { status: 405 }); 251 }, 252 // IndieAuth/OAuth 2.0 endpoints 253 "/auth/authorize": async (req: Request) => { 254 if (req.method === "GET") return authorizeGet(req); 255 if (req.method === "POST") return await authorizePost(req); 256 return new Response("Method not allowed", { status: 405 }); 257 }, 258 "/auth/token": async (req: Request) => { 259 if (req.method === "POST") return await token(req); 260 return new Response("Method not allowed", { status: 405 }); 261 }, 262 "/auth/token/introspect": async (req: Request) => { 263 if (req.method === "POST") return await tokenIntrospect(req); 264 return new Response("Method not allowed", { status: 405 }); 265 }, 266 "/auth/token/revoke": async (req: Request) => { 267 if (req.method === "POST") return await tokenRevoke(req); 268 return new Response("Method not allowed", { status: 405 }); 269 }, 270 "/auth/logout": (req: Request) => { 271 if (req.method === "POST") return logout(req); 272 return new Response("Method not allowed", { status: 405 }); 273 }, 274 // Passkey auth endpoints 275 "/auth/can-register": canRegister, 276 "/auth/register/options": registerOptions, 277 "/auth/register/verify": registerVerify, 278 "/auth/login/options": loginOptions, 279 "/auth/login/verify": loginVerify, 280 // LDAP verification endpoint 281 "/api/ldap-verify": (req: Request) => { 282 if (req.method === "POST") return ldapVerify(req); 283 return new Response("Method not allowed", { status: 405 }); 284 }, 285 // Passkey management endpoints 286 "/api/passkeys": (req: Request) => { 287 if (req.method === "GET") return listPasskeys(req); 288 return new Response("Method not allowed", { status: 405 }); 289 }, 290 "/api/passkeys/add/options": (req: Request) => { 291 if (req.method === "POST") return addPasskeyOptions(req); 292 return new Response("Method not allowed", { status: 405 }); 293 }, 294 "/api/passkeys/add/verify": (req: Request) => { 295 if (req.method === "POST") return addPasskeyVerify(req); 296 return new Response("Method not allowed", { status: 405 }); 297 }, 298 "/api/passkeys/:id": (req: Request) => { 299 if (req.method === "DELETE") return deletePasskey(req); 300 if (req.method === "PATCH") return renamePasskey(req); 301 return new Response("Method not allowed", { status: 405 }); 302 }, 303 // Dynamic routes with Bun's :param syntax 304 "/u/:username": userProfile, 305 "/api/apps/:clientId": (req) => { 306 if (req.method === "DELETE") return revokeApp(req, req.params.clientId); 307 return new Response("Method not allowed", { status: 405 }); 308 }, 309 "/api/admin/apps/:clientId": (req) => { 310 if (req.method === "GET") return getAppDetails(req, req.params.clientId); 311 return new Response("Method not allowed", { status: 405 }); 312 }, 313 "/api/admin/apps/:clientId/users/:username": (req) => { 314 if (req.method === "DELETE") 315 return revokeAppForUser(req, req.params.clientId, req.params.username); 316 return new Response("Method not allowed", { status: 405 }); 317 }, 318 "/api/admin/clients/:clientId": (req) => { 319 if (req.method === "GET") return getClient(req, req.params.clientId); 320 if (req.method === "PUT") return updateClient(req, req.params.clientId); 321 if (req.method === "DELETE") 322 return deleteClient(req, req.params.clientId); 323 return new Response("Method not allowed", { status: 405 }); 324 }, 325 "/api/admin/clients/:clientId/users/:username/role": (req) => { 326 if (req.method === "POST") 327 return setUserRole(req, req.params.clientId, req.params.username); 328 return new Response("Method not allowed", { status: 405 }); 329 }, 330 "/api/admin/clients/:clientId/secret": (req) => { 331 if (req.method === "POST") 332 return regenerateClientSecret(req, req.params.clientId); 333 return new Response("Method not allowed", { status: 405 }); 334 }, 335 }, 336 development: process.env.NODE_ENV === "dev", 337}); 338 339console.log("[Indiko] running on", env.ORIGIN); 340 341// Cleanup job: runs every hour to remove expired data 342const cleanupJob = setInterval(() => { 343 const now = Math.floor(Date.now() / 1000); 344 345 const sessionsDeleted = db 346 .query("DELETE FROM sessions WHERE expires_at < ?") 347 .run(now); 348 const challengesDeleted = db 349 .query("DELETE FROM challenges WHERE expires_at < ?") 350 .run(now); 351 const authcodesDeleted = db 352 .query("DELETE FROM authcodes WHERE expires_at < ?") 353 .run(now); 354 const tokensDeleted = db 355 .query("DELETE FROM tokens WHERE expires_at < ? OR revoked = 1") 356 .run(now); 357 358 const total = 359 sessionsDeleted.changes + 360 challengesDeleted.changes + 361 authcodesDeleted.changes + 362 tokensDeleted.changes; 363 364 if (total > 0) { 365 console.log( 366 `[Cleanup] Removed ${total} expired records (sessions: ${sessionsDeleted.changes}, challenges: ${challengesDeleted.changes}, authcodes: ${authcodesDeleted.changes}, tokens: ${tokensDeleted.changes})`, 367 ); 368 } 369}, 3600000); // 1 hour in milliseconds 370 371const ldapCleanupJob = 372 process.env.LDAP_ADMIN_DN && process.env.LDAP_ADMIN_PASSWORD 373 ? setInterval(async () => { 374 const result = await getLdapAccounts(); 375 const action = process.env.LDAP_ORPHAN_ACTION || "deactivate"; 376 const gracePeriod = Number.parseInt( 377 process.env.LDAP_ORPHAN_GRACE_PERIOD || "604800", 378 10, 379 ); // 7 days default 380 const now = Math.floor(Date.now() / 1000); 381 382 // Only take action on accounts orphaned longer than grace period 383 if (result.orphaned > 0) { 384 const expiredOrphans = result.orphanedUsers.filter( 385 (user) => now - user.createdAt > gracePeriod, 386 ); 387 388 if (expiredOrphans.length > 0) { 389 if (action === "suspend") { 390 await updateOrphanedAccounts( 391 { ...result, orphanedUsers: expiredOrphans }, 392 "suspend", 393 ); 394 } else if (action === "deactivate") { 395 await updateOrphanedAccounts( 396 { ...result, orphanedUsers: expiredOrphans }, 397 "deactivate", 398 ); 399 } else if (action === "remove") { 400 await updateOrphanedAccounts( 401 { ...result, orphanedUsers: expiredOrphans }, 402 "remove", 403 ); 404 } 405 console.log( 406 `[LDAP Cleanup] ${action === "remove" ? "Removed" : action === "suspend" ? "Suspended" : "Deactivated"} ${expiredOrphans.length} LDAP orphan accounts (grace period: ${gracePeriod}s)`, 407 ); 408 } 409 } 410 411 console.log( 412 `[LDAP Cleanup] Check completed: ${result.total} total, ${result.active} active, ${result.orphaned} orphaned, ${result.errors} errors.`, 413 ); 414 }, 3600000) 415 : null; // 1 hour in milliseconds 416 417let is_shutting_down = false; 418function shutdown(sig: string) { 419 if (is_shutting_down) return; 420 is_shutting_down = true; 421 422 console.log(`[Shutdown] triggering shutdown due to ${sig}`); 423 424 clearInterval(cleanupJob); 425 if (ldapCleanupJob) clearInterval(ldapCleanupJob); 426 console.log("[Shutdown] stopped cleanup job"); 427 428 server.stop(); 429 console.log("[Shutdown] stopped server"); 430 431 db.close(); 432 console.log("[Shutdown] closed db"); 433 434 process.exit(0); 435} 436 437process.on("SIGTERM", () => shutdown("SIGTERM")); 438process.on("SIGINT", () => shutdown("SIGINT"));