my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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"));