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