diff --git a/scripts/reset-passkey.ts b/scripts/reset-passkey.ts
index 6efaf3f..4aa4677 100644
--- a/scripts/reset-passkey.ts
+++ b/scripts/reset-passkey.ts
@@ -52,7 +52,9 @@ interface Credential {
function getUser(username: string): User | null {
return db
- .query("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?")
+ .query(
+ "SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?",
+ )
.get(username) as User | null;
}
@@ -70,26 +72,35 @@ function deleteCredentials(userId: number): number {
}
function deleteSessions(userId: number): number {
- const result = db
- .query("DELETE FROM sessions WHERE user_id = ?")
- .run(userId);
+ const result = db.query("DELETE FROM sessions WHERE user_id = ?").run(userId);
return result.changes;
}
-function createResetInvite(adminUserId: number, targetUsername: string): string {
+function createResetInvite(
+ adminUserId: number,
+ targetUsername: string,
+): string {
const code = crypto.randomBytes(16).toString("base64url");
const now = Math.floor(Date.now() / 1000);
const expiresAt = now + 86400; // 24 hours
// Check if there's a reset_username column, if not we'll use the note field
const hasResetColumn = db
- .query("SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'")
+ .query(
+ "SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'",
+ )
.get();
if (hasResetColumn) {
db.query(
"INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, reset_username) VALUES (?, ?, 1, 0, ?, ?, ?)",
- ).run(code, adminUserId, expiresAt, `Passkey reset for ${targetUsername}`, targetUsername);
+ ).run(
+ code,
+ adminUserId,
+ expiresAt,
+ `Passkey reset for ${targetUsername}`,
+ targetUsername,
+ );
} else {
// Use a special note format to indicate this is a reset invite
// Format: PASSKEY_RESET:username
@@ -109,7 +120,9 @@ function createResetInvite(adminUserId: number, targetUsername: string): string
function getAdminUser(): User | null {
return db
- .query("SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1")
+ .query(
+ "SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1",
+ )
.get() as User | null;
}
@@ -169,7 +182,9 @@ Example:
});
if (credentials.length === 0) {
- console.log("\n⚠️ User has no passkeys registered. Creating reset link anyway...");
+ console.log(
+ "\n⚠️ User has no passkeys registered. Creating reset link anyway...",
+ );
}
if (dryRun) {
@@ -184,7 +199,9 @@ Example:
// Confirmation prompt (unless --force)
if (!force) {
console.log("\n⚠️ This will:");
- console.log(` • Delete ALL ${credentials.length} passkey(s) for this user`);
+ console.log(
+ ` • Delete ALL ${credentials.length} passkey(s) for this user`,
+ );
console.log(" • Log them out of all sessions");
console.log(" • Generate a 24-hour reset link\n");
diff --git a/src/routes/clients.ts b/src/routes/clients.ts
index e151484..32557f4 100644
--- a/src/routes/clients.ts
+++ b/src/routes/clients.ts
@@ -1,4 +1,4 @@
-import crypto from "crypto";
+import crypto from "node:crypto";
import { nanoid } from "nanoid";
import { db } from "../db";
@@ -121,7 +121,7 @@ export function listClients(req: Request): Response {
if (!rolesByApp.has(app_id)) {
rolesByApp.set(app_id, []);
}
- rolesByApp.get(app_id)!.push(role);
+ rolesByApp.get(app_id)?.push(role);
}
return Response.json({
diff --git a/src/routes/indieauth.ts b/src/routes/indieauth.ts
index 6219e88..1456909 100644
--- a/src/routes/indieauth.ts
+++ b/src/routes/indieauth.ts
@@ -1,7 +1,7 @@
-import crypto from "crypto";
+import crypto from "node:crypto";
import { db } from "../db";
-import { signIDToken } from "../oidc";
import { safeFetch, validateExternalURL } from "../lib/ssrf-safe-fetch";
+import { signIDToken } from "../oidc";
interface SessionUser {
username: string;
@@ -10,6 +10,18 @@ interface SessionUser {
tier: string;
}
+function unauthorizedResponse(error: string, description: string): Response {
+ return Response.json(
+ { error, error_description: description },
+ {
+ status: 401,
+ headers: {
+ "WWW-Authenticate": `Bearer realm="indiko", error="${error}", error_description="${description}"`,
+ },
+ },
+ );
+}
+
// Helper to get authenticated user from session token
function getSessionUser(req: Request): SessionUser | Response {
const authHeader = req.headers.get("Authorization");
@@ -415,7 +427,10 @@ export async function verifyDomain(
// Validate URL is safe to fetch (prevents SSRF attacks)
const urlValidation = validateExternalURL(domainUrl);
if (!urlValidation.safe) {
- return { success: false, error: urlValidation.error || "Invalid domain URL" };
+ return {
+ success: false,
+ error: urlValidation.error || "Invalid domain URL",
+ };
}
// Use SSRF-safe fetch
@@ -428,8 +443,13 @@ export async function verifyDomain(
});
if (!fetchResult.success) {
- console.error(`[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`);
- return { success: false, error: `Failed to fetch domain: ${fetchResult.error}` };
+ console.error(
+ `[verifyDomain] Failed to fetch ${domainUrl}: ${fetchResult.error}`,
+ );
+ return {
+ success: false,
+ error: `Failed to fetch domain: ${fetchResult.error}`,
+ };
}
const response = fetchResult.data;
@@ -452,67 +472,67 @@ export async function verifyDomain(
const html = await response.text();
- // Extract rel="me" links using regex
- // Matches both and tags with rel attribute containing "me"
- const relMeLinks: string[] = [];
+ // Extract rel="me" links using regex
+ // Matches both and tags with rel attribute containing "me"
+ const relMeLinks: string[] = [];
- // Simpler approach: find all link and a tags, then check if they have rel="me" and href
- const linkRegex = /]*>/gi;
- const aRegex = /]*>/gi;
+ // Simpler approach: find all link and a tags, then check if they have rel="me" and href
+ const linkRegex = /]*>/gi;
+ const aRegex = /]*>/gi;
- const processTag = (tagHtml: string) => {
- // Check if has rel containing "me" (handle quoted and unquoted attributes)
- const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i);
- if (!relMatch) return null;
+ const processTag = (tagHtml: string) => {
+ // Check if has rel containing "me" (handle quoted and unquoted attributes)
+ const relMatch = tagHtml.match(/rel=["']?([^"'\s>]+)["']?/i);
+ if (!relMatch) return null;
- const relValue = relMatch[1];
- // Check if "me" is a separate word in the rel attribute
- if (!relValue.split(/\s+/).includes("me")) return null;
+ const relValue = relMatch[1];
+ // Check if "me" is a separate word in the rel attribute
+ if (!relValue.split(/\s+/).includes("me")) return null;
- // Extract href (handle quoted and unquoted attributes)
- const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
- if (!hrefMatch) return null;
+ // Extract href (handle quoted and unquoted attributes)
+ const hrefMatch = tagHtml.match(/href=["']?([^"'\s>]+)["']?/i);
+ if (!hrefMatch) return null;
- return hrefMatch[1];
- };
+ return hrefMatch[1];
+ };
- // Process all link tags
- let linkMatch;
- while ((linkMatch = linkRegex.exec(html)) !== null) {
- const href = processTag(linkMatch[0]);
- if (href && !relMeLinks.includes(href)) {
- relMeLinks.push(href);
- }
+ // Process all link tags
+ let linkMatch;
+ while ((linkMatch = linkRegex.exec(html)) !== null) {
+ const href = processTag(linkMatch[0]);
+ if (href && !relMeLinks.includes(href)) {
+ relMeLinks.push(href);
}
+ }
- // Process all a tags
- let aMatch;
- while ((aMatch = aRegex.exec(html)) !== null) {
- const href = processTag(aMatch[0]);
- if (href && !relMeLinks.includes(href)) {
- relMeLinks.push(href);
- }
+ // Process all a tags
+ let aMatch;
+ while ((aMatch = aRegex.exec(html)) !== null) {
+ const href = processTag(aMatch[0]);
+ if (href && !relMeLinks.includes(href)) {
+ relMeLinks.push(href);
}
+ }
- // Check if any rel="me" link matches the indiko profile URL
- const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
- const hasRelMe = relMeLinks.some((link) => {
- try {
- const normalizedLink = canonicalizeURL(link);
- return normalizedLink === normalizedIndikoUrl;
- } catch {
- return false;
- }
- });
+ // Check if any rel="me" link matches the indiko profile URL
+ const normalizedIndikoUrl = canonicalizeURL(indikoProfileUrl);
+ const hasRelMe = relMeLinks.some((link) => {
+ try {
+ const normalizedLink = canonicalizeURL(link);
+ return normalizedLink === normalizedIndikoUrl;
+ } catch {
+ return false;
+ }
+ });
- if (!hasRelMe) {
- console.error(
- `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
- {
- foundLinks: relMeLinks,
- normalizedTarget: normalizedIndikoUrl,
- },
- );
+ if (!hasRelMe) {
+ console.error(
+ `[verifyDomain] No rel="me" link found on ${domainUrl} pointing to ${indikoProfileUrl}`,
+ {
+ foundLinks: relMeLinks,
+ normalizedTarget: normalizedIndikoUrl,
+ },
+ );
return {
success: false,
error: `Domain must have or ... to verify ownership`,
@@ -662,15 +682,15 @@ export async function authorizeGet(req: Request): Promise {
const me = params.get("me");
const nonce = params.get("nonce"); // OIDC nonce parameter
- if (responseType !== "code") {
- return new Response("Unsupported response_type", { status: 400 });
- }
-
- if (!rawClientId || !rawRedirectUri || !state || !codeChallenge) {
- return new Response("Missing required parameters", { status: 400 });
+ // Step 1: Validate client_id and redirect_uri exist (can't redirect without them)
+ if (!rawClientId || !rawRedirectUri) {
+ return new Response(
+ "Missing required parameters: client_id and redirect_uri",
+ { status: 400 },
+ );
}
- // Validate and canonicalize URLs for consistent storage and comparison
+ // Step 2: Canonicalize URLs (if they're malformed, can't trust redirect_uri)
let clientId: string;
let redirectUri: string;
try {
@@ -772,13 +792,7 @@ export async function authorizeGet(req: Request): Promise {
);
}
- if (codeChallengeMethod && codeChallengeMethod !== "S256") {
- return new Response("Only S256 code_challenge_method supported", {
- status: 400,
- });
- }
-
- // Verify app is registered
+ // Step 3: Verify app is registered (can't trust redirect_uri if client is invalid)
const appResult = await ensureApp(clientId, redirectUri);
if (appResult.error) {
@@ -982,6 +996,50 @@ export async function authorizeGet(req: Request): Promise {
);
}
+ // Step 5: redirect_uri is now trusted — validate remaining params via redirect (RFC 6749 §4.1.2.1)
+ if (responseType !== "code") {
+ const errorUrl = new URL(redirectUri);
+ errorUrl.searchParams.set("error", "unsupported_response_type");
+ errorUrl.searchParams.set(
+ "error_description",
+ "Only response_type=code is supported",
+ );
+ if (state) errorUrl.searchParams.set("state", state);
+ return Response.redirect(errorUrl.toString(), 302);
+ }
+
+ if (!codeChallenge) {
+ const errorUrl = new URL(redirectUri);
+ errorUrl.searchParams.set("error", "invalid_request");
+ errorUrl.searchParams.set(
+ "error_description",
+ "Missing required parameter: code_challenge",
+ );
+ if (state) errorUrl.searchParams.set("state", state);
+ return Response.redirect(errorUrl.toString(), 302);
+ }
+
+ if (codeChallengeMethod && codeChallengeMethod !== "S256") {
+ const errorUrl = new URL(redirectUri);
+ errorUrl.searchParams.set("error", "invalid_request");
+ errorUrl.searchParams.set(
+ "error_description",
+ "Only S256 code_challenge_method is supported",
+ );
+ if (state) errorUrl.searchParams.set("state", state);
+ return Response.redirect(errorUrl.toString(), 302);
+ }
+
+ if (!state) {
+ const errorUrl = new URL(redirectUri);
+ errorUrl.searchParams.set("error", "invalid_request");
+ errorUrl.searchParams.set(
+ "error_description",
+ "Missing required parameter: state",
+ );
+ return Response.redirect(errorUrl.toString(), 302);
+ }
+
// Check if user is logged in
const user = getUserFromCookie(req);
@@ -1675,10 +1733,18 @@ export async function token(req: Request): Promise {
const expiresIn = 3600; // 1 hour
const expiresAt = now + expiresIn;
- // Update token (rotate access token, keep refresh token)
- db.query("UPDATE tokens SET token = ?, expires_at = ? WHERE id = ?").run(
+ // Rotate refresh token to prevent replay attacks
+ const newRefreshToken = crypto.randomBytes(32).toString("base64url");
+ const refreshExpiresAt = now + 2592000; // 30 days
+
+ // Update token (rotate both access and refresh tokens)
+ db.query(
+ "UPDATE tokens SET token = ?, expires_at = ?, refresh_token = ?, refresh_expires_at = ? WHERE id = ?",
+ ).run(
newAccessToken,
expiresAt,
+ newRefreshToken,
+ refreshExpiresAt,
tokenData.id,
);
@@ -1707,6 +1773,7 @@ export async function token(req: Request): Promise {
access_token: newAccessToken,
token_type: "Bearer",
expires_in: expiresIn,
+ refresh_token: newRefreshToken,
me: meValue,
scope: tokenData.scope,
iss: origin,
@@ -1731,16 +1798,20 @@ export async function token(req: Request): Promise {
| { is_preregistered: number; client_secret_hash: string | null }
| undefined;
+ // If client_secret provided but client not found - unknown client (400, not 401)
+ if (client_secret && !app) {
+ return Response.json(
+ { error: "invalid_client", error_description: "Unknown client" },
+ { status: 400 },
+ );
+ }
+
// If client is pre-registered, verify client secret
if (app && app.is_preregistered === 1) {
if (!client_secret) {
- return Response.json(
- {
- error: "invalid_client",
- error_description:
- "client_secret is required for pre-registered clients",
- },
- { status: 401 },
+ return unauthorizedResponse(
+ "invalid_client",
+ "client_secret is required for pre-registered clients",
);
}
@@ -1761,13 +1832,7 @@ export async function token(req: Request): Promise {
.digest("hex");
if (providedSecretHash !== app.client_secret_hash) {
- return Response.json(
- {
- error: "invalid_client",
- error_description: "Invalid client_secret",
- },
- { status: 401 },
- );
+ return unauthorizedResponse("invalid_client", "Invalid client_secret");
}
}
@@ -1874,9 +1939,19 @@ export async function token(req: Request): Promise {
);
}
- // Verify redirect_uri matches if provided (per OAuth 2.0 RFC 6749 section 4.1.3)
- // redirect_uri is REQUIRED if it was included in the authorization request
- if (redirect_uri && authcode.redirect_uri !== redirect_uri) {
+ // redirect_uri is REQUIRED since it's always included in the authorization request
+ // (per OAuth 2.0 RFC 6749 §4.1.3)
+ if (!redirect_uri) {
+ return Response.json(
+ {
+ error: "invalid_request",
+ error_description: "redirect_uri is required",
+ },
+ { status: 400 },
+ );
+ }
+
+ if (authcode.redirect_uri !== redirect_uri) {
console.error("Token endpoint: redirect_uri mismatch", {
stored: authcode.redirect_uri,
received: redirect_uri,
@@ -2156,11 +2231,13 @@ export async function tokenIntrospect(req: Request): Promise {
// Token is active - return metadata
return Response.json({
active: true,
+ sub: meValue,
me: meValue,
client_id: tokenData.client_id,
scope: tokenData.scope,
exp: tokenData.expires_at,
iat: tokenData.created_at,
+ username: tokenData.username,
});
} catch (error) {
console.error("Token introspection error:", error);
@@ -2209,10 +2286,31 @@ export async function tokenRevoke(req: Request): Promise {
);
}
- // Mark token as revoked (per spec, return 200 even if token doesn't exist)
- db.query("UPDATE tokens SET revoked = 1 WHERE token = ?").run(token);
+ // Check if it's a refresh token first (RFC 7009 §2.1: revoking a refresh token
+ // must also invalidate associated access tokens, and vice versa)
+ const refreshTokenData = db
+ .query("SELECT id FROM tokens WHERE refresh_token = ?")
+ .get(token) as { id: number } | undefined;
+
+ if (refreshTokenData) {
+ // Revoking refresh token — revoke the entire token record (access + refresh)
+ db.query("UPDATE tokens SET revoked = 1 WHERE id = ?").run(
+ refreshTokenData.id,
+ );
+ } else {
+ // Check if it's an access token — also invalidates the associated refresh token
+ const accessTokenData = db
+ .query("SELECT id FROM tokens WHERE token = ?")
+ .get(token) as { id: number } | undefined;
+
+ if (accessTokenData) {
+ db.query("UPDATE tokens SET revoked = 1 WHERE id = ?").run(
+ accessTokenData.id,
+ );
+ }
+ }
- // Return 200 with empty body per RFC 7009
+ // Per RFC 7009, return 200 even if token doesn't exist
return new Response(null, { status: 200 });
} catch (error) {
console.error("Token revocation error:", error);
@@ -2233,12 +2331,9 @@ export function userinfo(req: Request): Response {
const authHeader = req.headers.get("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
- return Response.json(
- {
- error: "invalid_request",
- error_description: "Missing or invalid Authorization header",
- },
- { status: 401 },
+ return unauthorizedResponse(
+ "invalid_request",
+ "Missing or invalid Authorization header",
);
}
@@ -2265,25 +2360,16 @@ export function userinfo(req: Request): Response {
// Token not found or revoked
if (!tokenData || tokenData.revoked === 1) {
- return Response.json(
- {
- error: "invalid_token",
- error_description: "Invalid or revoked access token",
- },
- { status: 401 },
+ return unauthorizedResponse(
+ "invalid_token",
+ "Invalid or revoked access token",
);
}
// Check if expired
const now = Math.floor(Date.now() / 1000);
if (tokenData.expires_at < now) {
- return Response.json(
- {
- error: "invalid_token",
- error_description: "Access token expired",
- },
- { status: 401 },
- );
+ return unauthorizedResponse("invalid_token", "Access token expired");
}
// Parse scopes
@@ -2293,12 +2379,8 @@ export function userinfo(req: Request): Response {
const origin = process.env.ORIGIN || "http://localhost:3000";
const response: Record = {};
- // sub claim is always required for OIDC userinfo
- if (tokenData.url) {
- response.sub = tokenData.url;
- } else {
- response.sub = `${origin}/u/${tokenData.username}`;
- }
+ // sub claim - use stable canonical profile URL (OIDC Core §2)
+ response.sub = `${origin}/u/${tokenData.username}`;
if (scopes.includes("profile")) {
response.name = tokenData.name;