From 5d0e714789a7486979a77296f8ec5c7bc885b9d5 Mon Sep 17 00:00:00 2001 From: Connor Pearson Date: Mon, 15 Jul 2024 18:47:17 +0200 Subject: [PATCH] feat: add withBasicAuth utility for authentication --- docs/2.utils/98.advanced.md | 18 +++++++ src/utils/auth.ts | 49 +++++++++++++++++++ src/utils/index.ts | 1 + test/auth.test.ts | 95 +++++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/utils/auth.ts create mode 100644 test/auth.test.ts diff --git a/docs/2.utils/98.advanced.md b/docs/2.utils/98.advanced.md index 7db3d840..36573fc8 100644 --- a/docs/2.utils/98.advanced.md +++ b/docs/2.utils/98.advanced.md @@ -216,3 +216,21 @@ eventHandler((event) => { ``` + +## Authentication + + + +### `withBasicAuth(auth: { username, password }, handler)` + +Protect an event handler with basic authentication + +**Example:** + +```ts +export default withBasicAuth({ username: 'test', password: 'abc123!' }, defineEventHandler(async (event) => { + return 'Hello, world!'; +})); +``` + + diff --git a/src/utils/auth.ts b/src/utils/auth.ts new file mode 100644 index 00000000..b2c9f987 --- /dev/null +++ b/src/utils/auth.ts @@ -0,0 +1,49 @@ +import { EventHandler } from "../types"; +import { eventHandler, type H3Event } from "../event"; +import { getRequestHeaders } from "./request"; +import { setResponseHeader, setResponseStatus } from "./response"; + +const authenticationFailed = (event: H3Event) => { + setResponseHeader( + event, + "WWW-Authenticate", + 'Basic realm="Authentication required"', + ); + setResponseStatus(event, 401); + return "Authentication required"; +}; + +/** + * Protect an event handler with basic authentication + * + * @example + * export default withBasicAuth({ username: 'test', password: 'abc123!' }, defineEventHandler(async (event) => { + * return 'Hello, world!'; + * })); + * + * @param auth The username and password to use for authentication. + * @param handler The event handler to wrap. + */ +export function withBasicAuth( + auth: { username: string; password: string } | string, + handler: EventHandler, +): EventHandler { + const authString = + typeof auth === "string" ? auth : `${auth.username}:${auth.password}`; + return eventHandler(async (event) => { + const headers = getRequestHeaders(event); + + if (!headers.authorization) { + return authenticationFailed(event); + } + + const b64auth = headers.authorization.split(" ")[1] || ""; + const decodedAuthHeader = Buffer.from(b64auth, "base64").toString(); + + if (decodedAuthHeader !== authString) { + return authenticationFailed(event); + } + + return await handler(event); + }); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 9e788463..6da47060 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./route"; +export * from "./auth"; export * from "./body"; export * from "./cache"; export * from "./consts"; diff --git a/test/auth.test.ts b/test/auth.test.ts new file mode 100644 index 00000000..c5cdc5a5 --- /dev/null +++ b/test/auth.test.ts @@ -0,0 +1,95 @@ +import { Server } from "node:http"; +import getPort from "get-port"; +import { Client } from "undici"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { + createApp, + toNodeListener, + App, + eventHandler, + withBasicAuth, +} from "../src"; + +describe("auth", () => { + let app: App; + let server: Server; + let client: Client; + + beforeEach(async () => { + app = createApp({ debug: true }); + server = new Server(toNodeListener(app)); + const port = await getPort(); + server.listen(port); + client = new Client(`http://localhost:${port}`); + }); + + afterEach(() => { + client.close(); + server.close(); + }); + + describe("withBasicAuth", () => { + it("responds 401 for a missing authorization header", async () => { + app.use( + "/test", + withBasicAuth( + { username: "test", password: "123!" }, + eventHandler(async () => { + return "Hello, world!"; + }), + ), + ); + const result = await client.request({ + path: "/test", + method: "GET", + }); + + expect(await result.body.text()).toBe("Authentication required"); + expect(result.statusCode).toBe(401); + }); + + it("responds 401 for an incorrect authorization header", async () => { + app.use( + "/test", + withBasicAuth( + { username: "test", password: "123!" }, + eventHandler(async () => { + return "Hello, world!"; + }), + ), + ); + const result = await client.request({ + path: "/test", + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("test:wrongpass").toString("base64")}`, + }, + }); + + expect(await result.body.text()).toBe("Authentication required"); + expect(result.statusCode).toBe(401); + }); + + it("responds 200 for a correct authorization header", async () => { + app.use( + "/test", + withBasicAuth( + "test:123!", + eventHandler(async () => { + return "Hello, world!"; + }), + ), + ); + const result = await client.request({ + path: "/test", + method: "GET", + headers: { + Authorization: `Basic ${Buffer.from("test:123!").toString("base64")}`, + }, + }); + + expect(await result.body.text()).toBe("Hello, world!"); + expect(result.statusCode).toBe(200); + }); + }); +});