my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add basic token support

+193 -2
+16 -2
src/index.ts
··· 47 47 listInvites, 48 48 logout, 49 49 token, 50 + tokenIntrospect, 51 + tokenRevoke, 50 52 updateInvite, 51 53 userProfile, 52 54 } from "./routes/indieauth"; ··· 211 213 if (req.method === "POST") return await token(req); 212 214 return new Response("Method not allowed", { status: 405 }); 213 215 }, 216 + "/auth/token/introspect": async (req: Request) => { 217 + if (req.method === "POST") return await tokenIntrospect(req); 218 + return new Response("Method not allowed", { status: 405 }); 219 + }, 220 + "/auth/token/revoke": async (req: Request) => { 221 + if (req.method === "POST") return await tokenRevoke(req); 222 + return new Response("Method not allowed", { status: 405 }); 223 + }, 214 224 "/auth/logout": (req: Request) => { 215 225 if (req.method === "POST") return logout(req); 216 226 return new Response("Method not allowed", { status: 405 }); ··· 272 282 const authcodesDeleted = db 273 283 .query("DELETE FROM authcodes WHERE expires_at < ?") 274 284 .run(now); 285 + const tokensDeleted = db 286 + .query("DELETE FROM tokens WHERE expires_at < ? OR revoked = 1") 287 + .run(now); 275 288 276 289 const total = 277 290 sessionsDeleted.changes + 278 291 challengesDeleted.changes + 279 - authcodesDeleted.changes; 292 + authcodesDeleted.changes + 293 + tokensDeleted.changes; 280 294 281 295 if (total > 0) { 282 296 console.log( 283 - `[Cleanup] Removed ${total} expired records (sessions: ${sessionsDeleted.changes}, challenges: ${challengesDeleted.changes}, authcodes: ${authcodesDeleted.changes})`, 297 + `[Cleanup] Removed ${total} expired records (sessions: ${sessionsDeleted.changes}, challenges: ${challengesDeleted.changes}, authcodes: ${authcodesDeleted.changes}, tokens: ${tokensDeleted.changes})`, 284 298 ); 285 299 } 286 300 }, 3600000); // 1 hour in milliseconds
+16
src/migrations/003_add_tokens_table.sql
··· 1 + -- Add tokens table for IndieAuth access tokens 2 + CREATE TABLE tokens ( 3 + id INTEGER PRIMARY KEY AUTOINCREMENT, 4 + token TEXT NOT NULL UNIQUE, 5 + user_id INTEGER NOT NULL, 6 + client_id TEXT NOT NULL, 7 + scope TEXT NOT NULL, 8 + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), 9 + expires_at INTEGER NOT NULL, 10 + revoked INTEGER NOT NULL DEFAULT 0, 11 + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE 12 + ); 13 + 14 + CREATE INDEX idx_tokens_token ON tokens(token); 15 + CREATE INDEX idx_tokens_user_id ON tokens(user_id); 16 + CREATE INDEX idx_tokens_expires_at ON tokens(expires_at);
+161
src/routes/indieauth.ts
··· 1694 1694 1695 1695 const origin = process.env.ORIGIN || "http://localhost:3000"; 1696 1696 1697 + // Generate access token 1698 + const accessToken = crypto.randomBytes(32).toString("base64url"); 1699 + const expiresIn = 3600; // 1 hour 1700 + const expiresAt = now + expiresIn; 1701 + 1702 + // Store token in database 1703 + db.query( 1704 + "INSERT INTO tokens (token, user_id, client_id, scope, expires_at) VALUES (?, ?, ?, ?, ?)", 1705 + ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt); 1706 + 1697 1707 const response: Record<string, unknown> = { 1708 + access_token: accessToken, 1709 + token_type: "Bearer", 1710 + expires_in: expiresIn, 1698 1711 me: meValue, 1699 1712 profile, 1700 1713 scope: scopes.join(" "), ··· 1717 1730 }); 1718 1731 } catch (error) { 1719 1732 console.error("Token exchange error:", error); 1733 + return Response.json( 1734 + { 1735 + error: "server_error", 1736 + error_description: "Internal server error", 1737 + }, 1738 + { status: 500 }, 1739 + ); 1740 + } 1741 + } 1742 + 1743 + // POST /auth/token/introspect - Introspect access token 1744 + export async function tokenIntrospect(req: Request): Promise<Response> { 1745 + try { 1746 + const contentType = req.headers.get("Content-Type"); 1747 + let body: Record<string, string>; 1748 + 1749 + // Support both JSON and form-encoded requests 1750 + if (contentType?.includes("application/json")) { 1751 + body = await req.json(); 1752 + } else if (contentType?.includes("application/x-www-form-urlencoded")) { 1753 + const formData = await req.formData(); 1754 + body = Object.fromEntries(formData.entries()) as Record<string, string>; 1755 + } else { 1756 + return Response.json( 1757 + { 1758 + error: "invalid_request", 1759 + error_description: 1760 + "Content-Type must be application/json or application/x-www-form-urlencoded", 1761 + }, 1762 + { status: 400 }, 1763 + ); 1764 + } 1765 + 1766 + const { token } = body; 1767 + 1768 + if (!token) { 1769 + return Response.json( 1770 + { 1771 + error: "invalid_request", 1772 + error_description: "token parameter is required", 1773 + }, 1774 + { status: 400 }, 1775 + ); 1776 + } 1777 + 1778 + // Look up token 1779 + const tokenData = db 1780 + .query( 1781 + "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 = ?", 1782 + ) 1783 + .get(token) as 1784 + | { 1785 + user_id: number; 1786 + client_id: string; 1787 + scope: string; 1788 + expires_at: number; 1789 + revoked: number; 1790 + created_at: number; 1791 + username: string; 1792 + } 1793 + | undefined; 1794 + 1795 + // Token not found or revoked 1796 + if (!tokenData || tokenData.revoked === 1) { 1797 + return Response.json({ active: false }); 1798 + } 1799 + 1800 + // Check if expired 1801 + const now = Math.floor(Date.now() / 1000); 1802 + if (tokenData.expires_at < now) { 1803 + return Response.json({ active: false }); 1804 + } 1805 + 1806 + // Get user's verified domain or use indiko profile 1807 + const user = db 1808 + .query("SELECT url FROM users WHERE id = ?") 1809 + .get(tokenData.user_id) as { url: string | null } | undefined; 1810 + 1811 + const origin = process.env.ORIGIN || "http://localhost:3000"; 1812 + const meValue = user?.url || `${origin}/u/${tokenData.username}`; 1813 + 1814 + // Token is active - return metadata 1815 + return Response.json({ 1816 + active: true, 1817 + me: meValue, 1818 + client_id: tokenData.client_id, 1819 + scope: tokenData.scope, 1820 + exp: tokenData.expires_at, 1821 + iat: tokenData.created_at, 1822 + }); 1823 + } catch (error) { 1824 + console.error("Token introspection error:", error); 1825 + return Response.json( 1826 + { 1827 + error: "server_error", 1828 + error_description: "Internal server error", 1829 + }, 1830 + { status: 500 }, 1831 + ); 1832 + } 1833 + } 1834 + 1835 + // POST /auth/token/revoke - Revoke access token 1836 + export async function tokenRevoke(req: Request): Promise<Response> { 1837 + try { 1838 + const contentType = req.headers.get("Content-Type"); 1839 + let body: Record<string, string>; 1840 + 1841 + // Support both JSON and form-encoded requests 1842 + if (contentType?.includes("application/json")) { 1843 + body = await req.json(); 1844 + } else if (contentType?.includes("application/x-www-form-urlencoded")) { 1845 + const formData = await req.formData(); 1846 + body = Object.fromEntries(formData.entries()) as Record<string, string>; 1847 + } else { 1848 + return Response.json( 1849 + { 1850 + error: "invalid_request", 1851 + error_description: 1852 + "Content-Type must be application/json or application/x-www-form-urlencoded", 1853 + }, 1854 + { status: 400 }, 1855 + ); 1856 + } 1857 + 1858 + const { token } = body; 1859 + 1860 + if (!token) { 1861 + return Response.json( 1862 + { 1863 + error: "invalid_request", 1864 + error_description: "token parameter is required", 1865 + }, 1866 + { status: 400 }, 1867 + ); 1868 + } 1869 + 1870 + // Mark token as revoked (per spec, return 200 even if token doesn't exist) 1871 + db.query("UPDATE tokens SET revoked = 1 WHERE token = ?").run(token); 1872 + 1873 + // Return 200 with empty body per RFC 7009 1874 + return new Response(null, { status: 200 }); 1875 + } catch (error) { 1876 + console.error("Token revocation error:", error); 1720 1877 return Response.json( 1721 1878 { 1722 1879 error: "server_error", ··· 2210 2367 issuer: origin, 2211 2368 authorization_endpoint: `${origin}/auth/authorize`, 2212 2369 token_endpoint: `${origin}/auth/token`, 2370 + introspection_endpoint: `${origin}/auth/token/introspect`, 2371 + introspection_endpoint_auth_methods_supported: ["none"], 2372 + revocation_endpoint: `${origin}/auth/token/revoke`, 2373 + revocation_endpoint_auth_methods_supported: ["none"], 2213 2374 code_challenge_methods_supported: ["S256"], 2214 2375 scopes_supported: ["profile", "email"], 2215 2376 response_types_supported: ["code"],