mirror of
https://github.com/dcarrillo/atalaya.git
synced 2026-05-18 06:24:12 +00:00
refactor: move auth.ts into a shared location rather than importing across workspace boundaries (#11)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user