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