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 phase two of tokens

+409 -18
+162 -12
src/html/docs.html
··· 585 585 <li><a href="#authorization" style="font-size: 0.9rem;">discovery</a></li> 586 586 </ul> 587 587 </li> 588 + <li><a href="#tokens">token management</a> 589 + <ul style="margin-top: 0.5rem; margin-left: 1.5rem;"> 590 + <li><a href="#tokens-refresh" style="font-size: 0.9rem;">refresh tokens</a></li> 591 + <li><a href="#tokens-introspect" style="font-size: 0.9rem;">introspection</a></li> 592 + <li><a href="#tokens-revoke" style="font-size: 0.9rem;">revocation</a></li> 593 + <li><a href="#tokens-userinfo" style="font-size: 0.9rem;">userinfo</a></li> 594 + </ul> 595 + </li> 588 596 <li><a href="#scopes">scopes</a></li> 589 597 <li><a href="#roles">roles</a></li> 590 598 <li><a href="#clients">client types</a></li> ··· 604 612 <ul> 605 613 <li>Passwordless authentication via WebAuthn passkeys</li> 606 614 <li>Full IndieAuth and OAuth 2.0 support with PKCE</li> 615 + <li>Access tokens and refresh tokens for API access</li> 616 + <li>Token introspection and revocation endpoints</li> 617 + <li>UserInfo endpoint for profile data</li> 607 618 <li>Auto-registration of OAuth clients</li> 608 619 <li>Pre-registered clients with secrets and role management</li> 609 620 <li>Session-based SSO (authenticate once, authorize many apps)</li> ··· 719 730 <tr> 720 731 <td><code>/auth/token</code></td> 721 732 <td>POST</td> 722 - <td>Exchange code for access token</td> 733 + <td>Exchange code for access token and refresh token</td> 734 + </tr> 735 + <tr> 736 + <td><code>/auth/token/introspect</code></td> 737 + <td>POST</td> 738 + <td>Verify access token validity</td> 739 + </tr> 740 + <tr> 741 + <td><code>/auth/token/revoke</code></td> 742 + <td>POST</td> 743 + <td>Revoke access or refresh token</td> 744 + </tr> 745 + <tr> 746 + <td><code>/userinfo</code></td> 747 + <td>GET</td> 748 + <td>Get user profile data with bearer token</td> 723 749 </tr> 724 750 <tr> 725 751 <td><code>/u/:username</code></td> ··· 849 875 All clients MUST use PKCE (code_verifier) per the IndieAuth specification. Pre-registered confidential clients should also include <code>client_secret</code> in the token request for additional security. 850 876 </div> 851 877 852 - <h3>5. receive user profile</h3> 878 + <h3>5. receive tokens and user profile</h3> 853 879 <pre><code>{ 854 - <span class="json-key">"me"</span>: <span class="json-string" id="profileMeUrl">"http://localhost:3000/u/username"</span>, 855 - <span class="json-key">"profile"</span>: { 856 - <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>, 857 - <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>, 858 - <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>, 859 - <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span> 860 - }, 861 - <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>, 862 - <span class="json-key">"role"</span>: <span class="json-string">"admin"</span> 863 - }</code></pre> 880 + <span class="json-key">"access_token"</span>: <span class="json-string">"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."</span>, 881 + <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>, 882 + <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>, 883 + <span class="json-key">"refresh_token"</span>: <span class="json-string">"RT_abc123xyz..."</span>, 884 + <span class="json-key">"me"</span>: <span class="json-string" id="profileMeUrl">"http://localhost:3000/u/username"</span>, 885 + <span class="json-key">"profile"</span>: { 886 + <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>, 887 + <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span>, 888 + <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>, 889 + <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span> 890 + }, 891 + <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>, 892 + <span class="json-key">"iss"</span>: <span class="json-string" id="issuerUrl2">"http://localhost:3000"</span>, 893 + <span class="json-key">"role"</span>: <span class="json-string">"admin"</span> 894 + }</code></pre> 895 + 896 + <div class="info-box"> 897 + <strong>Token types:</strong> 898 + <ul style="margin-top: 0.5rem; margin-bottom: 0;"> 899 + <li><code>access_token</code> - Short-lived token (1 hour) for API access</li> 900 + <li><code>refresh_token</code> - Long-lived token (30 days) for getting new access tokens</li> 901 + </ul> 902 + </div> 864 903 865 904 <div class="info-box"> 866 905 <strong>Roles:</strong> 867 906 If an admin has assigned a role to this user for your app, it will be included in the response. Roles are 868 907 arbitrary strings that you can use for role-based access control (RBAC) in your application. 908 + </div> 909 + </section> 910 + 911 + <section id="tokens" class="section"> 912 + <h2>token management</h2> 913 + <p> 914 + Indiko provides a complete OAuth 2.0 token management system with access tokens, refresh tokens, introspection, and revocation. 915 + </p> 916 + 917 + <h3 id="tokens-refresh">refresh tokens</h3> 918 + <p> 919 + Access tokens expire after 1 hour. Use the refresh token to get a new access token without requiring user re-authentication: 920 + </p> 921 + 922 + <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRefreshUrl">http://localhost:3000/auth/token</span> 923 + <span class="http-header">Content-Type</span>: application/x-www-form-urlencoded 924 + 925 + <span class="http-param">grant_type</span>=refresh_token 926 + &<span class="http-param">refresh_token</span>=RT_abc123xyz... 927 + &<span class="http-param">client_id</span>=https://myapp.example.com</code></pre> 928 + 929 + <p>Response:</p> 930 + <pre><code>{ 931 + <span class="json-key">"access_token"</span>: <span class="json-string">"new_access_token..."</span>, 932 + <span class="json-key">"token_type"</span>: <span class="json-string">"Bearer"</span>, 933 + <span class="json-key">"expires_in"</span>: <span class="json-number">3600</span>, 934 + <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>, 935 + <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>, 936 + <span class="json-key">"iss"</span>: <span class="json-string">"http://localhost:3000"</span> 937 + }</code></pre> 938 + 939 + <div class="info-box"> 940 + <strong>Important:</strong> 941 + <ul style="margin-top: 0.5rem; margin-bottom: 0;"> 942 + <li>Refresh tokens are valid for 30 days</li> 943 + <li>Each refresh request generates a new access token</li> 944 + <li>The refresh token itself remains valid (no rotation)</li> 945 + <li>Store refresh tokens securely - they provide long-term access</li> 946 + </ul> 947 + </div> 948 + 949 + <h3 id="tokens-introspect">token introspection</h3> 950 + <p> 951 + Resource servers can verify access tokens by calling the introspection endpoint: 952 + </p> 953 + 954 + <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenIntrospectUrl">http://localhost:3000/auth/token/introspect</span> 955 + <span class="http-header">Content-Type</span>: application/json 956 + 957 + { 958 + <span class="json-key">"token"</span>: <span class="json-string">"access_token_here"</span> 959 + }</code></pre> 960 + 961 + <p>Response (valid token):</p> 962 + <pre><code>{ 963 + <span class="json-key">"active"</span>: <span class="json-boolean">true</span>, 964 + <span class="json-key">"me"</span>: <span class="json-string">"http://localhost:3000/u/username"</span>, 965 + <span class="json-key">"client_id"</span>: <span class="json-string">"https://myapp.example.com"</span>, 966 + <span class="json-key">"scope"</span>: <span class="json-string">"profile email"</span>, 967 + <span class="json-key">"exp"</span>: <span class="json-number">1640000000</span>, 968 + <span class="json-key">"iat"</span>: <span class="json-number">1639996400</span> 969 + }</code></pre> 970 + 971 + <p>Response (invalid token):</p> 972 + <pre><code>{ 973 + <span class="json-key">"active"</span>: <span class="json-boolean">false</span> 974 + }</code></pre> 975 + 976 + <div class="info-box"> 977 + <strong>Use case:</strong> 978 + Introspection is useful for resource servers (like Micropub endpoints) that need to verify tokens issued by Indiko. 979 + </div> 980 + 981 + <h3 id="tokens-revoke">token revocation</h3> 982 + <p> 983 + Apps can revoke access or refresh tokens when users log out: 984 + </p> 985 + 986 + <pre><code><span class="http-method">POST</span> <span class="http-url" id="tokenRevokeUrl">http://localhost:3000/auth/token/revoke</span> 987 + <span class="http-header">Content-Type</span>: application/json 988 + 989 + { 990 + <span class="json-key">"token"</span>: <span class="json-string">"access_or_refresh_token_here"</span> 991 + }</code></pre> 992 + 993 + <p>Response: HTTP 200 (always returns success, even if token doesn't exist)</p> 994 + 995 + <div class="info-box"> 996 + <strong>Best practice:</strong> 997 + Always revoke tokens when users explicitly log out to prevent unauthorized access. 998 + </div> 999 + 1000 + <h3 id="tokens-userinfo">userinfo endpoint</h3> 1001 + <p> 1002 + Fetch updated user profile information using an access token: 1003 + </p> 1004 + 1005 + <pre><code><span class="http-method">GET</span> <span class="http-url" id="userinfoUrl">http://localhost:3000/userinfo</span> 1006 + <span class="http-header">Authorization</span>: Bearer access_token_here</code></pre> 1007 + 1008 + <p>Response (with <code>profile</code> and <code>email</code> scopes):</p> 1009 + <pre><code>{ 1010 + <span class="json-key">"name"</span>: <span class="json-string">"Jane Doe"</span>, 1011 + <span class="json-key">"photo"</span>: <span class="json-string">"https://example.com/photo.jpg"</span>, 1012 + <span class="json-key">"url"</span>: <span class="json-string">"https://jane.example.com"</span>, 1013 + <span class="json-key">"email"</span>: <span class="json-string">"jane@example.com"</span> 1014 + }</code></pre> 1015 + 1016 + <div class="info-box"> 1017 + <strong>Note:</strong> 1018 + The response only includes data for scopes granted to the token. A token with only <code>profile</code> scope will not include email. 869 1019 </div> 870 1020 </section> 871 1021
+6
src/index.ts
··· 51 51 tokenRevoke, 52 52 updateInvite, 53 53 userProfile, 54 + userinfo, 54 55 } from "./routes/indieauth"; 55 56 56 57 (() => { ··· 144 145 ); 145 146 }, 146 147 "/.well-known/oauth-authorization-server": indieauthMetadata, 148 + // OAuth/IndieAuth endpoints 149 + "/userinfo": (req: Request) => { 150 + if (req.method === "GET") return userinfo(req); 151 + return new Response("Method not allowed", { status: 405 }); 152 + }, 147 153 // API endpoints 148 154 "/api/hello": hello, 149 155 "/api/users": listUsers,
+5
src/migrations/004_add_refresh_tokens.sql
··· 1 + -- Add refresh token support 2 + ALTER TABLE tokens ADD COLUMN refresh_token TEXT UNIQUE; 3 + ALTER TABLE tokens ADD COLUMN refresh_expires_at INTEGER; 4 + 5 + CREATE INDEX idx_tokens_refresh_token ON tokens(refresh_token);
+236 -6
src/routes/indieauth.ts
··· 1461 1461 code_verifier, 1462 1462 } = body; 1463 1463 1464 - if (grant_type !== "authorization_code") { 1464 + if (grant_type !== "authorization_code" && grant_type !== "refresh_token") { 1465 1465 return Response.json( 1466 1466 { 1467 1467 error: "unsupported_grant_type", 1468 - error_description: "Only authorization_code grant type is supported", 1468 + error_description: "Only authorization_code and refresh_token grant types are supported", 1469 1469 }, 1470 1470 { status: 400 }, 1471 1471 ); 1472 1472 } 1473 1473 1474 + // Handle refresh token grant 1475 + if (grant_type === "refresh_token") { 1476 + const { refresh_token } = body; 1477 + 1478 + if (!refresh_token) { 1479 + return Response.json( 1480 + { 1481 + error: "invalid_request", 1482 + error_description: "refresh_token parameter is required", 1483 + }, 1484 + { status: 400 }, 1485 + ); 1486 + } 1487 + 1488 + // Look up refresh token 1489 + const tokenData = db 1490 + .query( 1491 + "SELECT id, user_id, client_id, scope, refresh_expires_at, revoked FROM tokens WHERE refresh_token = ?", 1492 + ) 1493 + .get(refresh_token) as 1494 + | { 1495 + id: number; 1496 + user_id: number; 1497 + client_id: string; 1498 + scope: string; 1499 + refresh_expires_at: number; 1500 + revoked: number; 1501 + } 1502 + | undefined; 1503 + 1504 + if (!tokenData || tokenData.revoked === 1) { 1505 + return Response.json( 1506 + { 1507 + error: "invalid_grant", 1508 + error_description: "Invalid refresh token", 1509 + }, 1510 + { status: 400 }, 1511 + ); 1512 + } 1513 + 1514 + // Check if refresh token expired 1515 + const now = Math.floor(Date.now() / 1000); 1516 + if (tokenData.refresh_expires_at < now) { 1517 + return Response.json( 1518 + { 1519 + error: "invalid_grant", 1520 + error_description: "Refresh token expired", 1521 + }, 1522 + { status: 400 }, 1523 + ); 1524 + } 1525 + 1526 + // Verify client_id matches 1527 + if (tokenData.client_id !== client_id) { 1528 + return Response.json( 1529 + { 1530 + error: "invalid_grant", 1531 + error_description: "client_id mismatch", 1532 + }, 1533 + { status: 400 }, 1534 + ); 1535 + } 1536 + 1537 + // Generate new access token 1538 + const newAccessToken = crypto.randomBytes(32).toString("base64url"); 1539 + const expiresIn = 3600; // 1 hour 1540 + const expiresAt = now + expiresIn; 1541 + 1542 + // Update token (rotate access token, keep refresh token) 1543 + db.query( 1544 + "UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?", 1545 + ).run(newAccessToken, expiresAt, tokenData.id); 1546 + 1547 + // Get user profile for me value 1548 + const user = db 1549 + .query("SELECT username, url FROM users WHERE id = ?") 1550 + .get(tokenData.user_id) as 1551 + | { username: string; url: string | null } 1552 + | undefined; 1553 + 1554 + if (!user) { 1555 + return Response.json( 1556 + { 1557 + error: "server_error", 1558 + error_description: "User not found", 1559 + }, 1560 + { status: 500 }, 1561 + ); 1562 + } 1563 + 1564 + const origin = process.env.ORIGIN || "http://localhost:3000"; 1565 + const meValue = user.url || `${origin}/u/${user.username}`; 1566 + 1567 + return Response.json( 1568 + { 1569 + access_token: newAccessToken, 1570 + token_type: "Bearer", 1571 + expires_in: expiresIn, 1572 + me: meValue, 1573 + scope: tokenData.scope, 1574 + iss: origin, 1575 + }, 1576 + { 1577 + headers: { 1578 + "Content-Type": "application/json", 1579 + "Cache-Control": "no-store", 1580 + "Pragma": "no-cache", 1581 + }, 1582 + }, 1583 + ); 1584 + } 1585 + 1586 + // Handle authorization_code grant (existing flow) 1474 1587 // Check if client is pre-registered and requires secret 1475 1588 const app = db 1476 1589 .query( ··· 1699 1812 const expiresIn = 3600; // 1 hour 1700 1813 const expiresAt = now + expiresIn; 1701 1814 1702 - // Store token in database 1815 + // Generate refresh token (30 days) 1816 + const refreshToken = crypto.randomBytes(32).toString("base64url"); 1817 + const refreshExpiresIn = 2592000; // 30 days in seconds 1818 + const refreshExpiresAt = now + refreshExpiresIn; 1819 + 1820 + // Store token in database with refresh token 1703 1821 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); 1822 + "INSERT INTO tokens (token, user_id, client_id, scope, expires_at, refresh_token, refresh_expires_at) VALUES (?, ?, ?, ?, ?, ?, ?)", 1823 + ).run(accessToken, authcode.user_id, client_id, scopes.join(" "), expiresAt, refreshToken, refreshExpiresAt); 1706 1824 1707 1825 const response: Record<string, unknown> = { 1708 1826 access_token: accessToken, 1709 1827 token_type: "Bearer", 1710 1828 expires_in: expiresIn, 1829 + refresh_token: refreshToken, 1711 1830 me: meValue, 1712 1831 profile, 1713 1832 scope: scopes.join(" "), ··· 1874 1993 return new Response(null, { status: 200 }); 1875 1994 } catch (error) { 1876 1995 console.error("Token revocation error:", error); 1996 + return Response.json( 1997 + { 1998 + error: "server_error", 1999 + error_description: "Internal server error", 2000 + }, 2001 + { status: 500 }, 2002 + ); 2003 + } 2004 + } 2005 + 2006 + // GET /userinfo - Get user profile from access token 2007 + export function userinfo(req: Request): Response { 2008 + try { 2009 + // Get access token from Authorization header 2010 + const authHeader = req.headers.get("Authorization"); 2011 + 2012 + if (!authHeader || !authHeader.startsWith("Bearer ")) { 2013 + return Response.json( 2014 + { 2015 + error: "invalid_request", 2016 + error_description: "Missing or invalid Authorization header", 2017 + }, 2018 + { status: 401 }, 2019 + ); 2020 + } 2021 + 2022 + const token = authHeader.substring(7); 2023 + 2024 + // Look up token 2025 + const tokenData = db 2026 + .query( 2027 + "SELECT t.user_id, t.scope, t.expires_at, t.revoked, u.name, u.email, u.photo, u.url, u.username FROM tokens t JOIN users u ON t.user_id = u.id WHERE t.token = ?", 2028 + ) 2029 + .get(token) as 2030 + | { 2031 + user_id: number; 2032 + scope: string; 2033 + expires_at: number; 2034 + revoked: number; 2035 + name: string; 2036 + email: string | null; 2037 + photo: string | null; 2038 + url: string | null; 2039 + username: string; 2040 + } 2041 + | undefined; 2042 + 2043 + // Token not found or revoked 2044 + if (!tokenData || tokenData.revoked === 1) { 2045 + return Response.json( 2046 + { 2047 + error: "invalid_token", 2048 + error_description: "Invalid or revoked access token", 2049 + }, 2050 + { status: 401 }, 2051 + ); 2052 + } 2053 + 2054 + // Check if expired 2055 + const now = Math.floor(Date.now() / 1000); 2056 + if (tokenData.expires_at < now) { 2057 + return Response.json( 2058 + { 2059 + error: "invalid_token", 2060 + error_description: "Access token expired", 2061 + }, 2062 + { status: 401 }, 2063 + ); 2064 + } 2065 + 2066 + // Parse scopes 2067 + const scopes = tokenData.scope.split(" "); 2068 + 2069 + // Build response based on scopes 2070 + const response: Record<string, string> = {}; 2071 + 2072 + if (scopes.includes("profile")) { 2073 + response.name = tokenData.name; 2074 + if (tokenData.photo) response.photo = tokenData.photo; 2075 + if (tokenData.url) { 2076 + response.url = tokenData.url; 2077 + } else { 2078 + const origin = process.env.ORIGIN || "http://localhost:3000"; 2079 + response.url = `${origin}/u/${tokenData.username}`; 2080 + } 2081 + } 2082 + 2083 + if (scopes.includes("email") && tokenData.email) { 2084 + response.email = tokenData.email; 2085 + } 2086 + 2087 + // Return empty object if no profile/email scopes 2088 + if (Object.keys(response).length === 0) { 2089 + return Response.json( 2090 + { 2091 + error: "insufficient_scope", 2092 + error_description: "Token does not have profile or email scope", 2093 + }, 2094 + { status: 403 }, 2095 + ); 2096 + } 2097 + 2098 + return Response.json(response, { 2099 + headers: { 2100 + "Content-Type": "application/json", 2101 + "Cache-Control": "no-store", 2102 + }, 2103 + }); 2104 + } catch (error) { 2105 + console.error("Userinfo error:", error); 1877 2106 return Response.json( 1878 2107 { 1879 2108 error: "server_error", ··· 2371 2600 introspection_endpoint_auth_methods_supported: ["none"], 2372 2601 revocation_endpoint: `${origin}/auth/token/revoke`, 2373 2602 revocation_endpoint_auth_methods_supported: ["none"], 2603 + userinfo_endpoint: `${origin}/userinfo`, 2374 2604 code_challenge_methods_supported: ["S256"], 2375 2605 scopes_supported: ["profile", "email"], 2376 2606 response_types_supported: ["code"], 2377 - grant_types_supported: ["authorization_code"], 2607 + grant_types_supported: ["authorization_code", "refresh_token"], 2378 2608 service_documentation: `${origin}/docs`, 2379 2609 }; 2380 2610