refactor: move auth.ts into a shared location rather than importing across workspace boundaries (#11)

This commit is contained in:
2026-04-25 14:37:28 +02:00
committed by GitHub
parent 6d53729b70
commit 298feb574e
8 changed files with 7 additions and 17 deletions
-86
View File
@@ -1,86 +0,0 @@
import { describe, it, expect } from 'vitest';
import { checkAuth, type AuthEnv } from './auth.js';
function makeRequest(authHeader?: string): Request {
const headers = new Headers();
if (authHeader) {
headers.set('Authorization', authHeader);
}
return new Request('https://example.com/', { headers });
}
function encodeBasic(username: string, password: string): string {
return 'Basic ' + btoa(`${username}:${password}`);
}
describe('checkAuth', () => {
it('allows access when STATUS_PUBLIC is true', async () => {
const env: AuthEnv = { STATUS_PUBLIC: 'true' };
const result = await checkAuth(makeRequest(), env);
expect(result).toBeUndefined();
});
it('returns 403 when no credentials are configured', async () => {
const env: AuthEnv = {};
const result = await checkAuth(makeRequest(), env);
expect(result).toBeInstanceOf(Response);
expect(result!.status).toBe(403);
});
it('returns 403 when only username is configured', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin' };
const result = await checkAuth(makeRequest(), env);
expect(result!.status).toBe(403);
});
it('returns 401 when no Authorization header is sent', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest(), env);
expect(result!.status).toBe(401);
expect(result!.headers.get('WWW-Authenticate')).toBe('Basic realm="Status Page"');
});
it('returns 401 for non-Basic auth header', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest('Bearer token123'), env);
expect(result!.status).toBe(401);
});
it('returns 401 for invalid base64 encoding', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest('Basic !!!invalid!!!'), env);
expect(result!.status).toBe(401);
});
it('returns 401 for credentials without colon separator', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest('Basic ' + btoa('nocolon')), env);
expect(result!.status).toBe(401);
});
it('returns 401 for wrong username', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest(encodeBasic('wrong', 'secret')), env);
expect(result!.status).toBe(401);
});
it('returns 401 for wrong password', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest(encodeBasic('admin', 'wrong')), env);
expect(result!.status).toBe(401);
});
it('allows access with valid credentials', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'secret' };
const result = await checkAuth(makeRequest(encodeBasic('admin', 'secret')), env);
expect(result).toBeUndefined();
});
it('handles passwords containing colons', async () => {
const env: AuthEnv = { STATUS_USERNAME: 'admin', STATUS_PASSWORD: 'pass:with:colons' };
const result = await checkAuth(makeRequest(encodeBasic('admin', 'pass:with:colons')), env);
expect(result).toBeUndefined();
});
});
-77
View File
@@ -1,77 +0,0 @@
export type AuthEnv = {
STATUS_PUBLIC?: string;
STATUS_USERNAME?: string;
STATUS_PASSWORD?: string;
};
const unauthorizedResponse = (): Response =>
new Response('Unauthorized', {
status: 401,
headers: { 'WWW-Authenticate': 'Basic realm="Status Page"' },
});
/**
* Timing-safe string comparison using SHA-256 hashing.
* Hashing both values to a fixed size prevents leaking length information.
* Uses constant-time byte comparison to prevent timing side-channel attacks.
*/
async function timingSafeCompare(a: string, b: string): Promise<boolean> {
const encoder = new TextEncoder();
const [hashA, hashB] = await Promise.all([
crypto.subtle.digest('SHA-256', encoder.encode(a)),
crypto.subtle.digest('SHA-256', encoder.encode(b)),
]);
const viewA = new Uint8Array(hashA);
const viewB = new Uint8Array(hashB);
// Constant-time comparison: always check every byte
let mismatch = 0;
for (let i = 0; i < viewA.length; i++) {
mismatch |= viewA[i] ^ viewB[i];
}
return mismatch === 0;
}
export async function checkAuth(request: Request, env: AuthEnv): Promise<Response | undefined> {
if (env.STATUS_PUBLIC === 'true') {
return undefined;
}
if (!env.STATUS_USERNAME || !env.STATUS_PASSWORD) {
return new Response('Forbidden', { status: 403 });
}
const authHeader = request.headers.get('Authorization');
const basicAuthPrefix = 'Basic ';
if (!authHeader?.startsWith(basicAuthPrefix)) {
return unauthorizedResponse();
}
const base64Credentials = authHeader.slice(basicAuthPrefix.length);
let credentials: string;
try {
credentials = atob(base64Credentials);
} catch {
return unauthorizedResponse();
}
const colonIndex = credentials.indexOf(':');
if (colonIndex === -1) {
return unauthorizedResponse();
}
const username = credentials.slice(0, colonIndex);
const password = credentials.slice(colonIndex + 1);
const [usernameMatch, passwordMatch] = await Promise.all([
timingSafeCompare(username, env.STATUS_USERNAME),
timingSafeCompare(password, env.STATUS_PASSWORD),
]);
if (!usernameMatch || !passwordMatch) {
return unauthorizedResponse();
}
return undefined;
}