···4747 listInvites,
4848 logout,
4949 token,
5050+ tokenIntrospect,
5151+ tokenRevoke,
5052 updateInvite,
5153 userProfile,
5254} from "./routes/indieauth";
···211213 if (req.method === "POST") return await token(req);
212214 return new Response("Method not allowed", { status: 405 });
213215 },
216216+ "/auth/token/introspect": async (req: Request) => {
217217+ if (req.method === "POST") return await tokenIntrospect(req);
218218+ return new Response("Method not allowed", { status: 405 });
219219+ },
220220+ "/auth/token/revoke": async (req: Request) => {
221221+ if (req.method === "POST") return await tokenRevoke(req);
222222+ return new Response("Method not allowed", { status: 405 });
223223+ },
214224 "/auth/logout": (req: Request) => {
215225 if (req.method === "POST") return logout(req);
216226 return new Response("Method not allowed", { status: 405 });
···272282 const authcodesDeleted = db
273283 .query("DELETE FROM authcodes WHERE expires_at < ?")
274284 .run(now);
285285+ const tokensDeleted = db
286286+ .query("DELETE FROM tokens WHERE expires_at < ? OR revoked = 1")
287287+ .run(now);
275288276289 const total =
277290 sessionsDeleted.changes +
278291 challengesDeleted.changes +
279279- authcodesDeleted.changes;
292292+ authcodesDeleted.changes +
293293+ tokensDeleted.changes;
280294281295 if (total > 0) {
282296 console.log(
283283- `[Cleanup] Removed ${total} expired records (sessions: ${sessionsDeleted.changes}, challenges: ${challengesDeleted.changes}, authcodes: ${authcodesDeleted.changes})`,
297297+ `[Cleanup] Removed ${total} expired records (sessions: ${sessionsDeleted.changes}, challenges: ${challengesDeleted.changes}, authcodes: ${authcodesDeleted.changes}, tokens: ${tokensDeleted.changes})`,
284298 );
285299 }
286300}, 3600000); // 1 hour in milliseconds
+16
src/migrations/003_add_tokens_table.sql
···11+-- Add tokens table for IndieAuth access tokens
22+CREATE TABLE tokens (
33+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44+ token TEXT NOT NULL UNIQUE,
55+ user_id INTEGER NOT NULL,
66+ client_id TEXT NOT NULL,
77+ scope TEXT NOT NULL,
88+ created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
99+ expires_at INTEGER NOT NULL,
1010+ revoked INTEGER NOT NULL DEFAULT 0,
1111+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
1212+);
1313+1414+CREATE INDEX idx_tokens_token ON tokens(token);
1515+CREATE INDEX idx_tokens_user_id ON tokens(user_id);
1616+CREATE INDEX idx_tokens_expires_at ON tokens(expires_at);
+161
src/routes/indieauth.ts
···1694169416951695 const origin = process.env.ORIGIN || "http://localhost:3000";
1696169616971697+ // Generate access token
16981698+ const accessToken = crypto.randomBytes(32).toString("base64url");
16991699+ const expiresIn = 3600; // 1 hour
17001700+ const expiresAt = now + expiresIn;
17011701+17021702+ // Store token in database
17031703+ db.query(
17041704+ "INSERT INTO tokens (token, user_id, client_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)",
17051705+ ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt);
17061706+16971707 const response: Record<string, unknown> = {
17081708+ access_token: accessToken,
17091709+ token_type: "Bearer",
17101710+ expires_in: expiresIn,
16981711 me: meValue,
16991712 profile,
17001713 scope: scopes.join(" "),
···17171730 });
17181731 } catch (error) {
17191732 console.error("Token exchange error:", error);
17331733+ return Response.json(
17341734+ {
17351735+ error: "server_error",
17361736+ error_description: "Internal server error",
17371737+ },
17381738+ { status: 500 },
17391739+ );
17401740+ }
17411741+}
17421742+17431743+// POST /auth/token/introspect - Introspect access token
17441744+export async function tokenIntrospect(req: Request): Promise<Response> {
17451745+ try {
17461746+ const contentType = req.headers.get("Content-Type");
17471747+ let body: Record<string, string>;
17481748+17491749+ // Support both JSON and form-encoded requests
17501750+ if (contentType?.includes("application/json")) {
17511751+ body = await req.json();
17521752+ } else if (contentType?.includes("application/x-www-form-urlencoded")) {
17531753+ const formData = await req.formData();
17541754+ body = Object.fromEntries(formData.entries()) as Record<string, string>;
17551755+ } else {
17561756+ return Response.json(
17571757+ {
17581758+ error: "invalid_request",
17591759+ error_description:
17601760+ "Content-Type must be application/json or application/x-www-form-urlencoded",
17611761+ },
17621762+ { status: 400 },
17631763+ );
17641764+ }
17651765+17661766+ const { token } = body;
17671767+17681768+ if (!token) {
17691769+ return Response.json(
17701770+ {
17711771+ error: "invalid_request",
17721772+ error_description: "token parameter is required",
17731773+ },
17741774+ { status: 400 },
17751775+ );
17761776+ }
17771777+17781778+ // Look up token
17791779+ const tokenData = db
17801780+ .query(
17811781+ "SELECT t.user_id, t.client_id, t.scope, t.expires_at, t.revoked, t.created_at, u.username FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token = ?",
17821782+ )
17831783+ .get(token) as
17841784+ | {
17851785+ user_id: number;
17861786+ client_id: string;
17871787+ scope: string;
17881788+ expires_at: number;
17891789+ revoked: number;
17901790+ created_at: number;
17911791+ username: string;
17921792+ }
17931793+ | undefined;
17941794+17951795+ // Token not found or revoked
17961796+ if (!tokenData || tokenData.revoked === 1) {
17971797+ return Response.json({ active: false });
17981798+ }
17991799+18001800+ // Check if expired
18011801+ const now = Math.floor(Date.now() / 1000);
18021802+ if (tokenData.expires_at < now) {
18031803+ return Response.json({ active: false });
18041804+ }
18051805+18061806+ // Get user's verified domain or use indiko profile
18071807+ const user = db
18081808+ .query("SELECT url FROM users WHERE id = ?")
18091809+ .get(tokenData.user_id) as { url: string | null } | undefined;
18101810+18111811+ const origin = process.env.ORIGIN || "http://localhost:3000";
18121812+ const meValue = user?.url || `${origin}/u/${tokenData.username}`;
18131813+18141814+ // Token is active - return metadata
18151815+ return Response.json({
18161816+ active: true,
18171817+ me: meValue,
18181818+ client_id: tokenData.client_id,
18191819+ scope: tokenData.scope,
18201820+ exp: tokenData.expires_at,
18211821+ iat: tokenData.created_at,
18221822+ });
18231823+ } catch (error) {
18241824+ console.error("Token introspection error:", error);
18251825+ return Response.json(
18261826+ {
18271827+ error: "server_error",
18281828+ error_description: "Internal server error",
18291829+ },
18301830+ { status: 500 },
18311831+ );
18321832+ }
18331833+}
18341834+18351835+// POST /auth/token/revoke - Revoke access token
18361836+export async function tokenRevoke(req: Request): Promise<Response> {
18371837+ try {
18381838+ const contentType = req.headers.get("Content-Type");
18391839+ let body: Record<string, string>;
18401840+18411841+ // Support both JSON and form-encoded requests
18421842+ if (contentType?.includes("application/json")) {
18431843+ body = await req.json();
18441844+ } else if (contentType?.includes("application/x-www-form-urlencoded")) {
18451845+ const formData = await req.formData();
18461846+ body = Object.fromEntries(formData.entries()) as Record<string, string>;
18471847+ } else {
18481848+ return Response.json(
18491849+ {
18501850+ error: "invalid_request",
18511851+ error_description:
18521852+ "Content-Type must be application/json or application/x-www-form-urlencoded",
18531853+ },
18541854+ { status: 400 },
18551855+ );
18561856+ }
18571857+18581858+ const { token } = body;
18591859+18601860+ if (!token) {
18611861+ return Response.json(
18621862+ {
18631863+ error: "invalid_request",
18641864+ error_description: "token parameter is required",
18651865+ },
18661866+ { status: 400 },
18671867+ );
18681868+ }
18691869+18701870+ // Mark token as revoked (per spec, return 200 even if token doesn't exist)
18711871+ db.query("UPDATE tokens SET revoked = 1 WHERE token = ?").run(token);
18721872+18731873+ // Return 200 with empty body per RFC 7009
18741874+ return new Response(null, { status: 200 });
18751875+ } catch (error) {
18761876+ console.error("Token revocation error:", error);
17201877 return Response.json(
17211878 {
17221879 error: "server_error",
···22102367 issuer: origin,
22112368 authorization_endpoint: `${origin}/auth/authorize`,
22122369 token_endpoint: `${origin}/auth/token`,
23702370+ introspection_endpoint: `${origin}/auth/token/introspect`,
23712371+ introspection_endpoint_auth_methods_supported: ["none"],
23722372+ revocation_endpoint: `${origin}/auth/token/revoke`,
23732373+ revocation_endpoint_auth_methods_supported: ["none"],
22132374 code_challenge_methods_supported: ["S256"],
22142375 scopes_supported: ["profile", "email"],
22152376 response_types_supported: ["code"],