mirror of
https://github.com/cpvalente/ontime.git
synced 2026-09-01 20:39:18 +00:00
187 lines
6.0 KiB
TypeScript
187 lines
6.0 KiB
TypeScript
import type { IncomingMessage } from 'node:http';
|
|
|
|
import type { NextFunction, Request, Response } from 'express';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../api-data/session/session.service.js', () => ({
|
|
hasPassword: true,
|
|
hashedPassword: 'valid-token',
|
|
}));
|
|
|
|
import { authenticateSocket, isPublicAssetRequest, makeAuthenticateMiddleware } from '../authenticate.js';
|
|
|
|
function makeResponse() {
|
|
return {
|
|
redirect: vi.fn(),
|
|
send: vi.fn(),
|
|
status: vi.fn().mockReturnThis(),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
function makeHeadersWithFailingAuthorization(cookie?: string) {
|
|
return {
|
|
cookie,
|
|
get authorization(): never {
|
|
throw new Error('Authorization header should not be read');
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('isPublicAssetRequest()', () => {
|
|
it('allows root public assets without a prefix', () => {
|
|
expect(isPublicAssetRequest('/site.webmanifest', '')).toBe(true);
|
|
expect(isPublicAssetRequest('/manifest.json', '')).toBe(true);
|
|
});
|
|
|
|
it('allows prefixed public assets in cloud deployments', () => {
|
|
expect(isPublicAssetRequest('/stage-hash/site.webmanifest', '/stage-hash')).toBe(true);
|
|
expect(isPublicAssetRequest('/stage-hash/ontime-logo.png?cache=1', '/stage-hash')).toBe(true);
|
|
});
|
|
|
|
it('keeps non-public paths protected', () => {
|
|
expect(isPublicAssetRequest('/stage-hash/data', '/stage-hash')).toBe(false);
|
|
expect(isPublicAssetRequest('/backstage', '')).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('bearer authentication', () => {
|
|
const next = vi.fn() as NextFunction;
|
|
|
|
beforeEach(() => {
|
|
next.mockClear();
|
|
});
|
|
|
|
it('prioritises cookie authentication for API requests', () => {
|
|
const { authenticate } = makeAuthenticateMiddleware('');
|
|
const req = {
|
|
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
headers: makeHeadersWithFailingAuthorization(),
|
|
query: {},
|
|
} as unknown as Request;
|
|
|
|
expect(() => authenticate(req, makeResponse(), next)).not.toThrow();
|
|
expect(next).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('prioritises cookie authentication for redirecting routes', () => {
|
|
const { authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
|
const req = {
|
|
cookies: { token: JSON.stringify({ token: 'valid-token' }) },
|
|
headers: makeHeadersWithFailingAuthorization(),
|
|
originalUrl: '/external/image.png',
|
|
query: {},
|
|
} as unknown as Request;
|
|
|
|
expect(() => authenticateAndRedirect(req, makeResponse(), next)).not.toThrow();
|
|
expect(next).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('prioritises cookie authentication for WebSocket handshakes', () => {
|
|
const cookie = `token=${encodeURIComponent(JSON.stringify({ token: 'valid-token' }))}`;
|
|
const req = { headers: makeHeadersWithFailingAuthorization(cookie) } as IncomingMessage;
|
|
|
|
expect(() => authenticateSocket({} as never, req, next)).not.toThrow();
|
|
expect(next).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('authenticates API requests with a bearer token', () => {
|
|
const { authenticate } = makeAuthenticateMiddleware('');
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: 'Bearer valid-token' },
|
|
query: {},
|
|
} as unknown as Request;
|
|
const res = makeResponse();
|
|
|
|
authenticate(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(res.status).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('accepts case-insensitive bearer schemes and extra whitespace', () => {
|
|
const { authenticate } = makeAuthenticateMiddleware('');
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: 'bearer valid-token ' },
|
|
query: {},
|
|
} as unknown as Request;
|
|
|
|
authenticate(req, makeResponse(), next);
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('authenticates redirecting routes with a bearer token', () => {
|
|
const { authenticateAndRedirect } = makeAuthenticateMiddleware('/stage');
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: 'Bearer valid-token' },
|
|
originalUrl: '/stage/external/image.png',
|
|
query: {},
|
|
} as unknown as Request;
|
|
const res = makeResponse();
|
|
|
|
authenticateAndRedirect(req, res, next);
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(res.redirect).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('authenticates WebSocket handshakes with a bearer token', () => {
|
|
const req = {
|
|
headers: { authorization: 'Bearer valid-token' },
|
|
} as IncomingMessage;
|
|
|
|
authenticateSocket({} as never, req, next);
|
|
|
|
expect(next).toHaveBeenCalledOnce();
|
|
expect(next).toHaveBeenCalledWith();
|
|
});
|
|
|
|
it('rejects an invalid bearer token', () => {
|
|
const { authenticate, authenticateAndRedirect } = makeAuthenticateMiddleware('');
|
|
const req = {
|
|
cookies: {},
|
|
headers: { authorization: 'Bearer invalid-token' },
|
|
query: {},
|
|
} as unknown as Request;
|
|
const res = makeResponse();
|
|
|
|
authenticate(req, res, next);
|
|
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(res.status).toHaveBeenCalledWith(401);
|
|
expect(res.send).toHaveBeenCalledWith('Unauthorized');
|
|
|
|
const redirectReq = { ...req, originalUrl: '/external/image.png' } as Request;
|
|
const redirectRes = makeResponse();
|
|
authenticateAndRedirect(redirectReq, redirectRes, next);
|
|
|
|
expect(next).not.toHaveBeenCalled();
|
|
expect(redirectRes.redirect).toHaveBeenCalledWith('/login?redirect=/external/image.png');
|
|
|
|
const socketNext = vi.fn();
|
|
authenticateSocket(
|
|
{} as never,
|
|
{ headers: { authorization: 'Bearer invalid-token' } } as IncomingMessage,
|
|
socketNext,
|
|
);
|
|
|
|
expect(socketNext).toHaveBeenCalledOnce();
|
|
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
|
});
|
|
|
|
it.each(['/socket?not_token=valid-token', '/socket?token=valid-token-suffix'])(
|
|
'rejects lookalike WebSocket query tokens in %s',
|
|
(url) => {
|
|
const socketNext = vi.fn();
|
|
|
|
authenticateSocket({} as never, { headers: { host: 'localhost' }, url } as IncomingMessage, socketNext);
|
|
|
|
expect(socketNext).toHaveBeenCalledOnce();
|
|
expect(socketNext.mock.calls[0][0]).toEqual(new Error('Unauthorized'));
|
|
},
|
|
);
|
|
});
|