diff --git a/src/server/index.ts b/src/server/index.ts index 510c073..84aad15 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -11,6 +11,7 @@ import { itemRoutes } from "./routes/items.ts"; import { settingsRoutes } from "./routes/settings.ts"; import { setupRoutes } from "./routes/setups.ts"; import { threadRoutes } from "./routes/threads.ts"; +import { oauthRoutes, wellKnownRoute } from "./routes/oauth.ts"; import { totalRoutes } from "./routes/totals.ts"; // Seed default data on startup @@ -33,6 +34,14 @@ app.get("/api/health", (c) => { return c.json({ status: "ok" }); }); +// OAuth routes (must be before /api/* middleware) +app.use("/oauth/*", async (c, next) => { + c.set("db", prodDb); + return next(); +}); +app.route("/.well-known", wellKnownRoute); +app.route("/oauth", oauthRoutes); + // Inject production database into request context app.use("/api/*", async (c, next) => { c.set("db", prodDb); diff --git a/src/server/routes/oauth.ts b/src/server/routes/oauth.ts new file mode 100644 index 0000000..66f5192 --- /dev/null +++ b/src/server/routes/oauth.ts @@ -0,0 +1,261 @@ +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); +}); diff --git a/tests/routes/oauth.test.ts b/tests/routes/oauth.test.ts new file mode 100644 index 0000000..b9d6efd --- /dev/null +++ b/tests/routes/oauth.test.ts @@ -0,0 +1,303 @@ +import { createHash, randomBytes } from "node:crypto"; +import { beforeEach, describe, expect, it } from "bun:test"; +import { Hono } from "hono"; +import { createUser } from "../../src/server/services/auth.service.ts"; +import { oauthRoutes, wellKnownRoute } from "../../src/server/routes/oauth.ts"; +import { createTestDb } from "../helpers/db.ts"; + +function createTestApp() { + const db = createTestDb(); + const app = new Hono<{ Variables: { db?: any } }>(); + app.use("*", async (c, next) => { + c.set("db", db); + await next(); + }); + app.route("/.well-known", wellKnownRoute); + app.route("/oauth", oauthRoutes); + return { app, db }; +} + +function generatePkce() { + const verifier = randomBytes(32).toString("hex"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +describe("OAuth Routes", () => { + let app: Hono; + let db: ReturnType; + + beforeEach(async () => { + const testApp = createTestApp(); + app = testApp.app; + db = testApp.db; + await createUser(db, "admin", "secret123"); + }); + + describe("GET /.well-known/oauth-authorization-server", () => { + it("returns 200 with correct metadata fields", async () => { + const res = await app.request("/.well-known/oauth-authorization-server"); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.issuer).toBeDefined(); + expect(body.authorization_endpoint).toContain("/oauth/authorize"); + expect(body.token_endpoint).toContain("/oauth/token"); + expect(body.registration_endpoint).toContain("/oauth/register"); + expect(body.response_types_supported).toEqual(["code"]); + expect(body.grant_types_supported).toEqual(["authorization_code", "refresh_token"]); + expect(body.code_challenge_methods_supported).toEqual(["S256"]); + expect(body.token_endpoint_auth_methods_supported).toEqual(["none"]); + }); + }); + + describe("POST /oauth/register", () => { + it("returns 201 with client_id, client_name, redirect_uris", async () => { + const res = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }), + }); + + expect(res.status).toBe(201); + const body = await res.json(); + expect(body.client_id).toBeDefined(); + expect(body.client_name).toBe("Test Client"); + expect(body.redirect_uris).toEqual(["http://localhost:3000/callback"]); + }); + + it("returns 400 without redirect_uris", async () => { + const res = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_name: "Test Client" }), + }); + + expect(res.status).toBe(400); + }); + }); + + describe("GET /oauth/authorize", () => { + it("returns 200 HTML with form when params are valid", async () => { + // Register a client first + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }), + }); + const { client_id } = await regRes.json(); + const { challenge } = generatePkce(); + + const params = new URLSearchParams({ + response_type: "code", + client_id, + redirect_uri: "http://localhost:3000/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "abc123", + }); + + const res = await app.request(`/oauth/authorize?${params}`); + expect(res.status).toBe(200); + + const html = await res.text(); + expect(html).toContain("GearBox"); + expect(html).toContain("password"); + expect(html).toContain("Test Client"); + }); + + it("returns 400 with invalid client_id", async () => { + const { challenge } = generatePkce(); + const params = new URLSearchParams({ + response_type: "code", + client_id: "nonexistent", + redirect_uri: "http://localhost:3000/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "abc123", + }); + + const res = await app.request(`/oauth/authorize?${params}`); + expect(res.status).toBe(400); + }); + }); + + describe("POST /oauth/authorize", () => { + it("returns 200 HTML with error on wrong password", async () => { + // Register a client + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }), + }); + const { client_id } = await regRes.json(); + const { challenge } = generatePkce(); + + const formBody = new URLSearchParams({ + username: "admin", + password: "wrongpassword", + client_id, + redirect_uri: "http://localhost:3000/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "abc123", + }); + + const res = await app.request("/oauth/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formBody.toString(), + }); + + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("Invalid"); + }); + }); + + describe("Full OAuth flow", () => { + it("register → authorize → token exchange", async () => { + // 1. Register client + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }), + }); + const { client_id } = await regRes.json(); + const { verifier, challenge } = generatePkce(); + + // 2. POST /oauth/authorize with correct credentials + const formBody = new URLSearchParams({ + username: "admin", + password: "secret123", + client_id, + redirect_uri: "http://localhost:3000/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "mystate", + }); + + const authRes = await app.request("/oauth/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formBody.toString(), + redirect: "manual", + }); + + expect(authRes.status).toBe(302); + const location = authRes.headers.get("location")!; + expect(location).toContain("code="); + expect(location).toContain("state=mystate"); + + const redirectUrl = new URL(location); + const code = redirectUrl.searchParams.get("code")!; + + // 3. Exchange code for tokens + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id, + redirect_uri: "http://localhost:3000/callback", + }); + + const tokenRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody.toString(), + }); + + expect(tokenRes.status).toBe(200); + const tokens = await tokenRes.json(); + expect(tokens.access_token).toBeDefined(); + expect(tokens.refresh_token).toBeDefined(); + expect(tokens.token_type).toBe("Bearer"); + expect(tokens.expires_in).toBe(3600); + }); + }); + + describe("Token refresh", () => { + it("exchanges refresh token for new tokens", async () => { + // Full flow to get initial tokens + const regRes = await app.request("/oauth/register", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + client_name: "Test Client", + redirect_uris: ["http://localhost:3000/callback"], + }), + }); + const { client_id } = await regRes.json(); + const { verifier, challenge } = generatePkce(); + + const formBody = new URLSearchParams({ + username: "admin", + password: "secret123", + client_id, + redirect_uri: "http://localhost:3000/callback", + code_challenge: challenge, + code_challenge_method: "S256", + state: "s", + }); + + const authRes = await app.request("/oauth/authorize", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: formBody.toString(), + redirect: "manual", + }); + + const location = authRes.headers.get("location")!; + const code = new URL(location).searchParams.get("code")!; + + const tokenBody = new URLSearchParams({ + grant_type: "authorization_code", + code, + code_verifier: verifier, + client_id, + redirect_uri: "http://localhost:3000/callback", + }); + + const tokenRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: tokenBody.toString(), + }); + const tokens = await tokenRes.json(); + + // Now refresh + const refreshBody = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: tokens.refresh_token, + client_id, + }); + + const refreshRes = await app.request("/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: refreshBody.toString(), + }); + + expect(refreshRes.status).toBe(200); + const newTokens = await refreshRes.json(); + expect(newTokens.access_token).toBeDefined(); + expect(newTokens.refresh_token).toBeDefined(); + expect(newTokens.token_type).toBe("Bearer"); + expect(newTokens.expires_in).toBe(3600); + // Should be different tokens + expect(newTokens.access_token).not.toBe(tokens.access_token); + }); + }); +});