import { Hono } from "hono"; import { db as prodDb } from "../../db/index.ts"; import { verifyPassword } from "../services/auth.service.ts"; import { cleanExpiredOAuthData, createAuthorizationCode, exchangeCode, getClient, refreshAccessToken, registerClient, } from "../services/oauth.service.ts"; type Env = { Variables: { db?: any } }; function escapeHtml(str: string): string { return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } function getBaseUrl(c: any): string { if (process.env.GEARBOX_URL) return process.env.GEARBOX_URL.replace(/\/$/, ""); return new URL(c.req.url).origin; } function renderLoginForm(params: { clientName: string; clientId: string; redirectUri: string; codeChallenge: string; codeChallengeMethod: string; state: string; error?: string; }): string { const errorHtml = params.error ? `
${escapeHtml(params.error)}
` : ""; return ` Authorize - GearBox

GearBox

Authorize ${escapeHtml(params.clientName)} to access your data

${errorHtml}
`; } // ── Well-Known Route ──────────────────────────────────────────────── export const wellKnownRoute = new Hono(); wellKnownRoute.get("/oauth-authorization-server", (c) => { const baseUrl = getBaseUrl(c); return c.json({ issuer: baseUrl, authorization_endpoint: `${baseUrl}/oauth/authorize`, token_endpoint: `${baseUrl}/oauth/token`, registration_endpoint: `${baseUrl}/oauth/register`, response_types_supported: ["code"], grant_types_supported: ["authorization_code", "refresh_token"], code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], }); }); // ── OAuth Routes ──────────────────────────────────────────────────── export const oauthRoutes = new Hono(); // POST /register — Dynamic Client Registration (RFC 7591) oauthRoutes.post("/register", async (c) => { const db = c.get("db") ?? prodDb; const body = await c.req.json(); if ( !body.redirect_uris || !Array.isArray(body.redirect_uris) || body.redirect_uris.length === 0 ) { return c.json( { error: "redirect_uris is required and must be a non-empty array" }, 400, ); } const clientName = body.client_name || "Unknown Client"; const { clientId } = registerClient(db, clientName, body.redirect_uris); return c.json( { client_id: clientId, client_name: clientName, redirect_uris: body.redirect_uris, }, 201, ); }); // GET /authorize — Show HTML login form oauthRoutes.get("/authorize", async (c) => { const db = c.get("db") ?? prodDb; const responseType = c.req.query("response_type"); const clientId = c.req.query("client_id"); const redirectUri = c.req.query("redirect_uri"); const codeChallenge = c.req.query("code_challenge"); const codeChallengeMethod = c.req.query("code_challenge_method"); const state = c.req.query("state") ?? ""; if (responseType !== "code") { return c.json({ error: "response_type must be 'code'" }, 400); } if (!clientId || !redirectUri || !codeChallenge || !codeChallengeMethod) { return c.json({ error: "Missing required parameters" }, 400); } const client = getClient(db, clientId); if (!client) { return c.json({ error: "Unknown client_id" }, 400); } const allowedUris: string[] = JSON.parse(client.redirectUris); if (!allowedUris.includes(redirectUri)) { return c.json({ error: "redirect_uri not allowed" }, 400); } return c.html( renderLoginForm({ clientName: client.clientName, clientId, redirectUri, codeChallenge, codeChallengeMethod, state, }), ); }); // POST /authorize — Process login form oauthRoutes.post("/authorize", async (c) => { const db = c.get("db") ?? prodDb; const body = await c.req.parseBody(); const username = body.username as string; const password = body.password as string; const clientId = body.client_id as string; const redirectUri = body.redirect_uri as string; const codeChallenge = body.code_challenge as string; const codeChallengeMethod = body.code_challenge_method as string; const state = (body.state as string) ?? ""; const user = await verifyPassword(db, username, password); if (!user) { const client = getClient(db, clientId); return c.html( renderLoginForm({ clientName: client?.clientName ?? "Unknown", clientId, redirectUri, codeChallenge, codeChallengeMethod, state, error: "Invalid username or password", }), ); } const { code } = createAuthorizationCode( db, clientId, codeChallenge, codeChallengeMethod, redirectUri, ); const url = new URL(redirectUri); url.searchParams.set("code", code); if (state) url.searchParams.set("state", state); return c.redirect(url.toString(), 302); }); // POST /token — Token exchange oauthRoutes.post("/token", async (c) => { const db = c.get("db") ?? prodDb; const body = await c.req.parseBody(); const grantType = body.grant_type as string; // Opportunistic cleanup cleanExpiredOAuthData(db); if (grantType === "authorization_code") { const code = body.code as string; const codeVerifier = body.code_verifier as string; const clientId = body.client_id as string; const redirectUri = body.redirect_uri as string; const result = await exchangeCode( db, code, codeVerifier, clientId, redirectUri, ); if (!result) { return c.json({ error: "invalid_grant" }, 400); } return c.json({ access_token: result.accessToken, refresh_token: result.refreshToken, token_type: "Bearer", expires_in: result.expiresIn, }); } if (grantType === "refresh_token") { const refreshToken = body.refresh_token as string; const clientId = body.client_id as string; const result = await refreshAccessToken(db, refreshToken, clientId); if (!result) { return c.json({ error: "invalid_grant" }, 400); } return c.json({ access_token: result.accessToken, refresh_token: result.refreshToken, token_type: "Bearer", expires_in: result.expiresIn, }); } return c.json({ error: "unsupported_grant_type" }, 400); });