Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions src/utils/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getEventContext, HTTPError } from "../index.ts";

import type { H3EventContext, HTTPEvent, Middleware } from "../index.ts";
import { randomJitter, timingSafeEqual } from "./internal/auth.ts";

type _BasicAuthOptions = {
/**
Expand Down Expand Up @@ -47,32 +48,40 @@ export async function requireBasicAuth(
opts: BasicAuthOptions,
): Promise<true> {
if (!opts.validate && !opts.password) {
throw new Error(
"You must provide either a validate function or a password for basic auth.",
);
throw new HTTPError({
message: "Either 'password' or 'validate' option must be provided",
status: 500,
});
}

const authHeader = event.req.headers.get("authorization");
if (!authHeader) {
throw autheFailed(event);
throw authFailed(event);
}
const [authType, b64auth] = authHeader.split(" ");
if (authType !== "Basic" || !b64auth) {
throw autheFailed(event, opts?.realm);
if (!b64auth || authType.toLowerCase() !== "basic") {
throw authFailed(event, opts?.realm);
}
const [username, password] = atob(b64auth).split(":");
let authDecoded: string;
try {
authDecoded = atob(b64auth);
} catch {
throw authFailed(event, opts?.realm);
}
const colonIndex = authDecoded.indexOf(":");
const username = authDecoded.slice(0, colonIndex);
const password = authDecoded.slice(colonIndex + 1);
if (!username || !password) {
throw autheFailed(event, opts?.realm);
throw authFailed(event, opts?.realm);
}

if (opts.username && username !== opts.username) {
throw autheFailed(event, opts?.realm);
}
if (opts.password && password !== opts.password) {
throw autheFailed(event, opts?.realm);
}
if (opts.validate && !(await opts.validate(username, password))) {
throw autheFailed(event, opts?.realm);
if (
(opts.username && !timingSafeEqual(username, opts.username)) ||
(opts.password && !timingSafeEqual(password, opts.password)) ||
(opts.validate && !(await opts.validate(username, password)))
) {
await randomJitter();
throw authFailed(event, opts?.realm);
}

const context = getEventContext<H3EventContext>(event);
Expand All @@ -97,7 +106,7 @@ export function basicAuth(opts: BasicAuthOptions): Middleware {
};
}

function autheFailed(event: HTTPEvent, realm: string = "") {
function authFailed(event: HTTPEvent, realm: string = "") {
return new HTTPError({
status: 401,
statusText: "Authentication required",
Expand Down
29 changes: 29 additions & 0 deletions src/utils/internal/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
const _textEncoder = new TextEncoder();

/**
* Constant-time string comparison to prevent timing attacks.
* Uses UTF-8 byte comparison for proper Unicode handling.
* Always compares all bytes regardless of where differences occur.
*/
export function timingSafeEqual(a: string, b: string): boolean {
const aBuf = _textEncoder.encode(a);
const bBuf = _textEncoder.encode(b);
const aLen = aBuf.length;
const bLen = bBuf.length;
// Always compare against the longer buffer length to avoid length-based timing leaks
const len = Math.max(aLen, bLen);
let result = aLen === bLen ? 0 : 1;
for (let i = 0; i < len; i++) {
// Use bitwise XOR to compare bytes; accumulate differences with OR
result |= (aBuf[i % aLen] ?? 0) ^ (bBuf[i % bLen] ?? 0);
}
return result === 0;
}

/**
* Add random delay (0-100ms) to prevent timing-based credential inference.
*/
export function randomJitter(): Promise<void> {
const jitter = Math.floor(Math.random() * 100);
return new Promise((resolve) => setTimeout(resolve, jitter));
}
214 changes: 214 additions & 0 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,218 @@ describeMatrix("auth", (t, { it, expect }) => {
expect(await result.text()).toBe("Hello, world!");
expect(result.status).toBe(200);
});

it("handles password containing colons", async () => {
const authWithColon = basicAuth({
username: "admin",
password: "pass:word:with:colons",
});
t.app.get("/colon-test", () => "Success!", { middleware: [authWithColon] });

const result = await t.fetch("/colon-test", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("admin:pass:word:with:colons").toString("base64")}`,
},
});

expect(await result.text()).toBe("Success!");
expect(result.status).toBe(200);
});

it("rejects wrong password when password contains colons", async () => {
const authWithColon = basicAuth({
username: "admin",
password: "pass:word:with:colons",
});
t.app.get("/colon-reject", () => "Success!", {
middleware: [authWithColon],
});

const result = await t.fetch("/colon-reject", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("admin:pass:word").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("responds 401 for invalid base64", async () => {
t.app.get("/invalid-base64", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/invalid-base64", {
method: "GET",
headers: {
Authorization: "Basic !!!invalid-base64!!!",
},
});

expect(result.status).toBe(401);
});

it("responds 401 when base64 value has no colon separator", async () => {
t.app.get("/no-colon", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/no-colon", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("usernameonly").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("responds 401 when username is empty", async () => {
t.app.get("/empty-username", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/empty-username", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(":password").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("responds 401 when password is empty", async () => {
t.app.get("/empty-password", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/empty-password", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("username:").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("responds 401 when both username and password are empty", async () => {
t.app.get("/empty-both", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/empty-both", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from(":").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("responds 401 when auth type is not Basic", async () => {
t.app.get("/wrong-type", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/wrong-type", {
method: "GET",
headers: {
Authorization: "Bearer some-token",
},
});

expect(result.status).toBe(401);
});

it("responds 401 when auth header has no credentials part", async () => {
t.app.get("/no-credentials", () => "Hello, world!", {
middleware: [auth],
});
const result = await t.fetch("/no-credentials", {
method: "GET",
headers: {
Authorization: "Basic",
},
});

expect(result.status).toBe(401);
});

it("responds 401 when auth header is empty", async () => {
t.app.get("/empty-header", () => "Hello, world!", { middleware: [auth] });
const result = await t.fetch("/empty-header", {
method: "GET",
headers: {
Authorization: "",
},
});

expect(result.status).toBe(401);
});

it("supports custom validate function", async () => {
const customAuth = basicAuth({
validate: (username, password) => {
return username === "custom" && password === "secret";
},
});
t.app.get("/custom-validate", () => "Custom validated!", {
middleware: [customAuth],
});

const result = await t.fetch("/custom-validate", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("custom:secret").toString("base64")}`,
},
});

expect(await result.text()).toBe("Custom validated!");
expect(result.status).toBe(200);
});

it("rejects invalid credentials with custom validate function", async () => {
const customAuth = basicAuth({
validate: (username, password) => {
return username === "custom" && password === "secret";
},
});
t.app.get("/custom-validate-reject", () => "Custom validated!", {
middleware: [customAuth],
});

const result = await t.fetch("/custom-validate-reject", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("custom:wrong").toString("base64")}`,
},
});

expect(result.status).toBe(401);
});

it("supports async custom validate function", async () => {
const asyncAuth = basicAuth({
validate: async (username, password) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return username === "async" && password === "pass";
},
});
t.app.get("/async-validate", () => "Async validated!", {
middleware: [asyncAuth],
});

const result = await t.fetch("/async-validate", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("async:pass").toString("base64")}`,
},
});

expect(await result.text()).toBe("Async validated!");
expect(result.status).toBe(200);
});

it("throws error when neither password nor validate is provided", async () => {
const invalidAuth = basicAuth({} as any);
t.app.get("/no-auth-config", () => "Should not reach!", {
middleware: [invalidAuth],
});

const result = await t.fetch("/no-auth-config", {
method: "GET",
headers: {
Authorization: `Basic ${Buffer.from("user:pass").toString("base64")}`,
},
});

expect(result.status).toBe(500);
});
});
Loading