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 { 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"));