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