48 lines
1.5 KiB
TypeScript
48 lines
1.5 KiB
TypeScript
|
|
import { UnauthorizedError, ConfigurationError } from '@shade/core';
|
||
|
|
import type { Context, Next } from 'hono';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Bearer token middleware for the observer.
|
||
|
|
*
|
||
|
|
* Reads token from `Authorization: Bearer <token>` header.
|
||
|
|
* For SSE endpoints (where browsers can't set headers), also accepts
|
||
|
|
* `?token=<token>` query parameter.
|
||
|
|
*
|
||
|
|
* Throws ConfigurationError if SHADE_OBSERVER_TOKEN is empty (refuses to start).
|
||
|
|
*/
|
||
|
|
export function createAuthMiddleware(token: string) {
|
||
|
|
if (!token || token.length < 16) {
|
||
|
|
throw new ConfigurationError(
|
||
|
|
'SHADE_OBSERVER_TOKEN must be set and at least 16 characters. Refusing to start.',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return async (c: Context, next: Next) => {
|
||
|
|
const header = c.req.header('Authorization');
|
||
|
|
let provided: string | null = null;
|
||
|
|
|
||
|
|
if (header && header.startsWith('Bearer ')) {
|
||
|
|
provided = header.slice(7);
|
||
|
|
} else {
|
||
|
|
// Allow query string for SSE (EventSource can't set headers)
|
||
|
|
provided = c.req.query('token') ?? null;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!provided || !constantTimeStringEqual(provided, token)) {
|
||
|
|
throw new UnauthorizedError('Invalid or missing observer token');
|
||
|
|
}
|
||
|
|
|
||
|
|
await next();
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Constant-time string comparison (avoids timing attacks on token check) */
|
||
|
|
function constantTimeStringEqual(a: string, b: string): boolean {
|
||
|
|
if (a.length !== b.length) return false;
|
||
|
|
let diff = 0;
|
||
|
|
for (let i = 0; i < a.length; i++) {
|
||
|
|
diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
||
|
|
}
|
||
|
|
return diff === 0;
|
||
|
|
}
|