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);
});