Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
fc45822
add Permission & Permissible
zefir-git Mar 18, 2025
a5cbf95
add permission group class
zefir-git Mar 18, 2025
d82fdff
Authorisation class
zefir-git Mar 18, 2025
a96fbc3
authenticator interface
zefir-git Mar 18, 2025
3f1394e
add class for authenticated request
zefir-git Mar 18, 2025
ff39fc6
add authenticators to Server class
zefir-git Mar 18, 2025
e218914
add Server to request
zefir-git Mar 18, 2025
ba3c4b9
get authorisation and authenticate request
zefir-git Mar 18, 2025
7a9bf3d
add generics for authorisation data
zefir-git Mar 18, 2025
49cb0e6
authenticator docs
zefir-git Mar 18, 2025
a09e108
chore: update indices
cloudnode-bot Mar 18, 2025
a67b93b
implement Permissible on AuthenticatedRequest
zefir-git Mar 18, 2025
ceca680
add typedoc comments on AuthenticatedRequest
zefir-git Mar 18, 2025
20952c0
Merge remote-tracking branch 'origin/auth' into auth
zefir-git Mar 18, 2025
cb02574
Merge branch 'main' into auth
zefir-git Mar 22, 2025
77f1a36
Merge branch 'main' into auth
zefir-git Mar 22, 2025
2a0c4ea
Merge branch 'main' into auth
zefir-git Mar 24, 2025
18c20d4
fix error from merge conflict resolution
zefir-git Mar 24, 2025
f4a6815
Merge branch 'main' into auth
zefir-git Mar 26, 2025
e47ead4
move authenticated request to `auth`
zefir-git Mar 27, 2025
e974e1b
Merge branch '22-handle-errors-thrown-by-a-routes-handle' into auth
zefir-git Mar 27, 2025
d9a7fd6
add method on auth. req. to require permissions
zefir-git Mar 27, 2025
4e4f9ef
Merge branch 'main' into auth
zefir-git Mar 30, 2025
097bccb
Merge branch 'main' into auth
zefir-git Mar 31, 2025
77023e4
Merge branch 'main' into auth
zefir-git Mar 31, 2025
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
58 changes: 49 additions & 9 deletions src/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,15 @@ import {IPAddress, IPv4, IPv6} from "@cldn/ip";
import {Multipart} from "multipart-ts";
import http, {OutgoingHttpHeader} from "node:http";
import stream from "node:stream";
import {Authenticator} from "./auth/Authenticator.js";
import {Authorisation} from "./auth/Authorisation.js";
import {AuthenticatedRequest} from "./auth/AuthenticatedRequest.js";
import {Server} from "./Server.js";

/**
* An incoming HTTP request from a connected client.
*/
export class Request {
export class Request<A> {
/**
* The request method.
*/
Expand All @@ -32,6 +36,11 @@ export class Request {
*/
public readonly ip: IPv4 | IPv6;

/**
* The {@link Server} from which this request was received.
*/
public readonly server: Server<A>;

/**
* The parsed request cookies from the {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie|Cookie} request header.
*/
Expand All @@ -44,19 +53,22 @@ export class Request {
* @param headers See {@link Request#headers}.
* @param bodyStream See {@link Request#bodyStream}.
* @param ip See {@link Request#ip}.
* @param server See {@link Request#server}.
*/
protected constructor(
method: Request["method"],
url: Request["url"],
headers: Request["headers"],
bodyStream: Request["bodyStream"],
ip: Request["ip"],
public constructor(
method: Request<A>["method"],
url: Request<A>["url"],
headers: Request<A>["headers"],
bodyStream: Request<A>["bodyStream"],
ip: Request<A>["ip"],
server: Request<A>["server"]
) {
this.method = method;
this.url = url;
this.headers = headers;
this.bodyStream = bodyStream;
this.ip = ip;
this.server = server;

this.cookies = new Map(
this.headers.get("cookie")
Expand All @@ -80,7 +92,7 @@ export class Request {
* @throws {@link Request.BadUrlError} If the request URL is invalid.
* @throws {@link Request.SocketClosedError} If the request socket was closed before the request could be handled.
*/
public static incomingMessage(incomingMessage: http.IncomingMessage) {
public static incomingMessage<A>(incomingMessage: http.IncomingMessage, server: Server<A>) {
const auth =
incomingMessage.headers.authorization
?.toLowerCase()
Expand All @@ -101,7 +113,7 @@ export class Request {
if (remoteAddress === undefined)
throw new Request.SocketClosedError();

return new Request(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress));
return new Request<A>(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress), server);
}

/**
Expand All @@ -118,6 +130,34 @@ export class Request {
);
}

/**
* Attempt to obtain authorisation for this request with one of the {@link Server}’s {@link Authenticator}s.
* @returns `null` if the request lacks authorisation information.
*/
public async getAuthorisation(): Promise<Authorisation<A> | null> {
const authenticator = this.server._authenticators.find(a => a.canAuthenticate(this));
if (authenticator === undefined) return null;
return await authenticator.authenticate(this);
}

/**
* Attempt to authenticate this request with one of the {@link Server}’s {@link Authenticator}s.
* @returns `null` if the request lacks authorisation information.
*/
public async authenticate(): Promise<AuthenticatedRequest<A> | null> {
const authorisation = await this.getAuthorisation();
if (authorisation === null) return null;
return new AuthenticatedRequest<A>(
authorisation,
this.method,
this.url,
this.headers,
this.bodyStream,
this.ip,
this.server,
);
}

/**
* Returns a boolean value that declares whether the body has been read yet.
*/
Expand Down
48 changes: 30 additions & 18 deletions src/Server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import EventEmitter from "node:events";
import http from "node:http";
import packageJson from "../package.json" with {type: "json"};
import {Authenticator} from "./auth/Authenticator.js";
import {Request} from "./Request.js";
import {EmptyResponse} from "./response/index.js";
import {Response} from "./response/Response.js";
Expand All @@ -12,19 +13,24 @@ import {ServerErrorRegistry} from "./ServerErrorRegistry.js";
* An HTTP server.
* @see {@link Server.Events} for events.
*/
class Server extends EventEmitter<Server.Events> {
class Server<A> extends EventEmitter<Server.Events> {
/**
* Headers sent with every response.
*/
public readonly globalHeaders: Headers;

/**
* This server's route registry.
*/
public readonly routes = new RouteRegistry();
public readonly routes = new RouteRegistry<A>();

/** @internal */
public readonly _authenticators: Authenticator<A>[];

/**
* This server's error registry.
*/
public readonly errors = new ServerErrorRegistry();
public readonly errors = new ServerErrorRegistry<A>();
private readonly server: http.Server;
private readonly port?: number;
private readonly copyOrigin: boolean;
Expand All @@ -34,19 +40,20 @@ class Server extends EventEmitter<Server.Events> {
* Create a new HTTP server.
* @param options Server options.
*/
public constructor(options?: Server.Options) {
public constructor(options?: Server.Options<A>) {
super();
this.server = http.createServer({
joinDuplicateHeaders: true,
}, this.listener.bind(this));

this.globalHeaders = new Headers(options?.globalHeaders);
if (!this.globalHeaders.has("server"))
this.globalHeaders.set("Server", `cldn/${packageJson.version}`);
this.globalHeaders.set("Server", `${packageJson.name}/${packageJson.version}`);

this.port = options?.port;
this.copyOrigin = options?.copyOrigin ?? false;
this.handleConditionalRequests = options?.handleConditionalRequests ?? true;
this._authenticators = options?.authenticators ?? [];

if (this.port !== undefined) this.listen(this.port).then();

Expand Down Expand Up @@ -101,21 +108,21 @@ class Server extends EventEmitter<Server.Events> {
}

private async listener(req: http.IncomingMessage, res: http.ServerResponse) {
let apiRequest: Request;
let apiRequest: Request<A>;
try {
apiRequest = Request.incomingMessage(req);
apiRequest = Request.incomingMessage(req, this);
}
catch (e) {
if (e instanceof Request.BadUrlError) {
this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res, this);
await this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res);
return;
}

if (e instanceof Request.SocketClosedError)
return;

this.emit("error", e as any);
this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, null)._send(res, this);
await this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, null)._send(res);
return;
}

Expand All @@ -127,7 +134,7 @@ class Server extends EventEmitter<Server.Events> {
apiRequest._responseHeaders.set("vary", "origin");
}

let response: Response;
let response: Response<A>;
try {
response = await this.routes.handle(apiRequest);
}
Expand All @@ -148,13 +155,13 @@ class Server extends EventEmitter<Server.Events> {
await this.sendResponse(response, res, apiRequest);
}

private async sendResponse(response: Response, res: http.ServerResponse, req: Request): Promise<void> {
private async sendResponse(response: Response<A>, res: http.ServerResponse, req: Request<A>): Promise<void> {
conditional: if (
this.handleConditionalRequests
&& response.statusCode === 200
&& [Request.Method.GET, Request.Method.HEAD].includes(req.method)
) {
const responseHeaders = response.allHeaders(res, this, req);
const responseHeaders = response.allHeaders(res, req);
const etag = responseHeaders.get("etag");
const lastModified = responseHeaders.has("last-modified")
? new Date(responseHeaders.get("last-modified")!)
Expand All @@ -166,26 +173,26 @@ class Server extends EventEmitter<Server.Events> {
if (!this.getETags(req.headers.get("if-match")!)
.filter(t => !t.startsWith("W/"))
.includes(etag!))
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, req);
}
else if (req.headers.has("if-unmodified-since")) {
if (lastModified === null
|| lastModified.getTime() > new Date(req.headers.get("if-unmodified-since")!).getTime())
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, req);
}

if (req.headers.has("if-none-match")) {
if (this.getETags(req.headers.get("if-none-match")!)
.includes(etag!))
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
return new EmptyResponse<A>(responseHeaders, 304)._send(res, req);
}
else if (req.headers.has("if-modified-since")) {
if (lastModified !== null
&& lastModified.getTime() <= new Date(req.headers.get("if-modified-since")!).getTime())
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
return new EmptyResponse<A>(responseHeaders, 304)._send(res, req);
}
}
response._send(res, this, req);
await response._send(res, req);
}

private getETags(header: string) {
Expand All @@ -199,7 +206,7 @@ namespace Server {
/**
* Server options
*/
export interface Options {
export interface Options<A> {
/**
* The HTTP listener port. From 1 to 65535. Ports 1–1023 require
* privileges. If not set, {@link Server#listen|Server.listen()} must be called manually.
Expand All @@ -225,6 +232,11 @@ namespace Server {
* @default true
*/
readonly handleConditionalRequests?: boolean;

/**
* Authenticators for handling request authentication.
*/
readonly authenticators?: Authenticator<A>[];
}

/**
Expand Down
12 changes: 8 additions & 4 deletions src/ServerErrorRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import {TextResponse} from "./response/TextResponse.js";
/**
* A registry for server errors.
*/
class ServerErrorRegistry {
private readonly responses: Record<ServerErrorRegistry.ErrorCodes, Response | ((req?: Request) => Response)>;
class ServerErrorRegistry<A> {
private readonly responses: Record<ServerErrorRegistry.ErrorCodes, Response<A> | ((req?: Request<A>) => Response<A>)>;

/**
* Create a new server error registry initialised with default responses.
Expand All @@ -24,6 +24,9 @@ class ServerErrorRegistry {

[ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED]:
new TextResponse("Precondition failed.", 412),

[ServerErrorRegistry.ErrorCodes.NO_PERMISSION]:
new TextResponse("You do not have permission to perform this action.", 403),
};
}

Expand All @@ -32,12 +35,12 @@ class ServerErrorRegistry {
* @param code The server error code.
* @param response The response to send.
*/
public register(code: ServerErrorRegistry.ErrorCodes, response: Response | ((req?: Request) => Response)) {
public register(code: ServerErrorRegistry.ErrorCodes, response: Response<A> | ((req?: Request<A>) => Response<A>)) {
this.responses[code] = response;
}

/** @internal */
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request | null): Response {
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request<A> | null): Response<A> {
const r = this.responses[code];
if (typeof r === "function") return r(req ?? void 0);
return r;
Expand All @@ -53,6 +56,7 @@ namespace ServerErrorRegistry {
NO_ROUTE,
INTERNAL,
PRECONDITION_FAILED,
NO_PERMISSION,
}
}

Expand Down
69 changes: 69 additions & 0 deletions src/auth/AuthenticatedRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {Request} from "../Request.js";
import {Response, ThrowableResponse} from "../response/index.js";
import {ServerErrorRegistry} from "../ServerErrorRegistry.js";
import {Authorisation} from "./Authorisation.js";
import {Permissible} from "./Permissible.js";
import {Permission} from "./Permission.js";

/**
* A request with available {@link Authorisation}.
*/
export class AuthenticatedRequest<A> extends Request<A> implements Permissible {
/**
* This request’s authorisation.
*/
public readonly authorisation: Authorisation<A>;

/**
* Create a new authenticated request.
* @param authorisation
* @param args The arguments to pass to the {@link Request} constructor.
*/
public constructor(
authorisation: Authorisation<A>,
...args: ConstructorParameters<typeof Request<A>>
) {
super(...args);
this.authorisation = authorisation;
}

/**
* Check if the request has the specified permission.
* @param permission
*/
public has(permission: Permission): boolean {
return this.authorisation.has(permission);
}

/**
* Require the request to have all the specified permissions.
* @param permissions The required permission.
* @param [response] Throw this response if the request does not have the permission. Defaults to 403 from
* {@link ServerErrorRegistry}.
* @throws {@link ThrowableResponse} If the request does not have the permission.
*/
public require(permissions: Iterable<Permission>, response?: Response<A>): void;

/**
* Require the request to have the specified permission.
* @param permission The required permission.
* @param [response] Throw this response if the request does not have the permission. Defaults to 403 from
* {@link ServerErrorRegistry}.
* @throws {@link ThrowableResponse} If the request does not have the permission.
*/
public require(permission: Permission, response?: Response<A>): void;
public require(required: Permission | Iterable<Permission>, response?: Response<A>): void {
if (required instanceof Permission) {
if (!this.has(required))
throw new ThrowableResponse(
response ?? this.server.errors._get(ServerErrorRegistry.ErrorCodes.NO_PERMISSION, this)
);
}
else for (const permission of required) {
if (!this.has(permission))
throw new ThrowableResponse(
response ?? this.server.errors._get(ServerErrorRegistry.ErrorCodes.NO_PERMISSION, this)
);
}
}
}
Loading