Skip to content

Commit a83ef31

Browse files
authored
Request authentication and authorisation (#30)
2 parents f2b68af + 77023e4 commit a83ef31

20 files changed

+325
-68
lines changed

src/Request.ts

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@ import {IPAddress, IPv4, IPv6} from "@cldn/ip";
22
import {Multipart} from "multipart-ts";
33
import http, {OutgoingHttpHeader} from "node:http";
44
import stream from "node:stream";
5+
import {Authenticator} from "./auth/Authenticator.js";
6+
import {Authorisation} from "./auth/Authorisation.js";
7+
import {AuthenticatedRequest} from "./auth/AuthenticatedRequest.js";
8+
import {Server} from "./Server.js";
59

610
/**
711
* An incoming HTTP request from a connected client.
812
*/
9-
export class Request {
13+
export class Request<A> {
1014
/**
1115
* The request method.
1216
*/
@@ -32,6 +36,11 @@ export class Request {
3236
*/
3337
public readonly ip: IPv4 | IPv6;
3438

39+
/**
40+
* The {@link Server} from which this request was received.
41+
*/
42+
public readonly server: Server<A>;
43+
3544
/**
3645
* The parsed request cookies from the {@link https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cookie|Cookie} request header.
3746
*/
@@ -44,19 +53,22 @@ export class Request {
4453
* @param headers See {@link Request#headers}.
4554
* @param bodyStream See {@link Request#bodyStream}.
4655
* @param ip See {@link Request#ip}.
56+
* @param server See {@link Request#server}.
4757
*/
48-
protected constructor(
49-
method: Request["method"],
50-
url: Request["url"],
51-
headers: Request["headers"],
52-
bodyStream: Request["bodyStream"],
53-
ip: Request["ip"],
58+
public constructor(
59+
method: Request<A>["method"],
60+
url: Request<A>["url"],
61+
headers: Request<A>["headers"],
62+
bodyStream: Request<A>["bodyStream"],
63+
ip: Request<A>["ip"],
64+
server: Request<A>["server"]
5465
) {
5566
this.method = method;
5667
this.url = url;
5768
this.headers = headers;
5869
this.bodyStream = bodyStream;
5970
this.ip = ip;
71+
this.server = server;
6072

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

104-
return new Request(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress));
116+
return new Request<A>(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress), server);
105117
}
106118

107119
/**
@@ -118,6 +130,34 @@ export class Request {
118130
);
119131
}
120132

133+
/**
134+
* Attempt to obtain authorisation for this request with one of the {@link Server}’s {@link Authenticator}s.
135+
* @returns `null` if the request lacks authorisation information.
136+
*/
137+
public async getAuthorisation(): Promise<Authorisation<A> | null> {
138+
const authenticator = this.server._authenticators.find(a => a.canAuthenticate(this));
139+
if (authenticator === undefined) return null;
140+
return await authenticator.authenticate(this);
141+
}
142+
143+
/**
144+
* Attempt to authenticate this request with one of the {@link Server}’s {@link Authenticator}s.
145+
* @returns `null` if the request lacks authorisation information.
146+
*/
147+
public async authenticate(): Promise<AuthenticatedRequest<A> | null> {
148+
const authorisation = await this.getAuthorisation();
149+
if (authorisation === null) return null;
150+
return new AuthenticatedRequest<A>(
151+
authorisation,
152+
this.method,
153+
this.url,
154+
this.headers,
155+
this.bodyStream,
156+
this.ip,
157+
this.server,
158+
);
159+
}
160+
121161
/**
122162
* Returns a boolean value that declares whether the body has been read yet.
123163
*/

src/Server.ts

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import EventEmitter from "node:events";
22
import http from "node:http";
33
import packageJson from "../package.json" with {type: "json"};
4+
import {Authenticator} from "./auth/Authenticator.js";
45
import {Request} from "./Request.js";
56
import {EmptyResponse} from "./response/index.js";
67
import {Response} from "./response/Response.js";
@@ -12,19 +13,24 @@ import {ServerErrorRegistry} from "./ServerErrorRegistry.js";
1213
* An HTTP server.
1314
* @see {@link Server.Events} for events.
1415
*/
15-
class Server extends EventEmitter<Server.Events> {
16+
class Server<A> extends EventEmitter<Server.Events> {
1617
/**
1718
* Headers sent with every response.
1819
*/
1920
public readonly globalHeaders: Headers;
21+
2022
/**
2123
* This server's route registry.
2224
*/
23-
public readonly routes = new RouteRegistry();
25+
public readonly routes = new RouteRegistry<A>();
26+
27+
/** @internal */
28+
public readonly _authenticators: Authenticator<A>[];
29+
2430
/**
2531
* This server's error registry.
2632
*/
27-
public readonly errors = new ServerErrorRegistry();
33+
public readonly errors = new ServerErrorRegistry<A>();
2834
private readonly server: http.Server;
2935
private readonly port?: number;
3036
private readonly copyOrigin: boolean;
@@ -34,19 +40,20 @@ class Server extends EventEmitter<Server.Events> {
3440
* Create a new HTTP server.
3541
* @param options Server options.
3642
*/
37-
public constructor(options?: Server.Options) {
43+
public constructor(options?: Server.Options<A>) {
3844
super();
3945
this.server = http.createServer({
4046
joinDuplicateHeaders: true,
4147
}, this.listener.bind(this));
4248

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

4753
this.port = options?.port;
4854
this.copyOrigin = options?.copyOrigin ?? false;
4955
this.handleConditionalRequests = options?.handleConditionalRequests ?? true;
56+
this._authenticators = options?.authenticators ?? [];
5057

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

@@ -101,21 +108,21 @@ class Server extends EventEmitter<Server.Events> {
101108
}
102109

103110
private async listener(req: http.IncomingMessage, res: http.ServerResponse) {
104-
let apiRequest: Request;
111+
let apiRequest: Request<A>;
105112
try {
106-
apiRequest = Request.incomingMessage(req);
113+
apiRequest = Request.incomingMessage(req, this);
107114
}
108115
catch (e) {
109116
if (e instanceof Request.BadUrlError) {
110-
this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res, this);
117+
await this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res);
111118
return;
112119
}
113120

114121
if (e instanceof Request.SocketClosedError)
115122
return;
116123

117124
this.emit("error", e as any);
118-
this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, null)._send(res, this);
125+
await this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, null)._send(res);
119126
return;
120127
}
121128

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

130-
let response: Response;
137+
let response: Response<A>;
131138
try {
132139
response = await this.routes.handle(apiRequest);
133140
}
@@ -148,13 +155,13 @@ class Server extends EventEmitter<Server.Events> {
148155
await this.sendResponse(response, res, apiRequest);
149156
}
150157

151-
private async sendResponse(response: Response, res: http.ServerResponse, req: Request): Promise<void> {
158+
private async sendResponse(response: Response<A>, res: http.ServerResponse, req: Request<A>): Promise<void> {
152159
conditional: if (
153160
this.handleConditionalRequests
154161
&& response.statusCode === 200
155162
&& [Request.Method.GET, Request.Method.HEAD].includes(req.method)
156163
) {
157-
const responseHeaders = response.allHeaders(res, this, req);
164+
const responseHeaders = response.allHeaders(res, req);
158165
const etag = responseHeaders.get("etag");
159166
const lastModified = responseHeaders.has("last-modified")
160167
? new Date(responseHeaders.get("last-modified")!)
@@ -166,26 +173,26 @@ class Server extends EventEmitter<Server.Events> {
166173
if (!this.getETags(req.headers.get("if-match")!)
167174
.filter(t => !t.startsWith("W/"))
168175
.includes(etag!))
169-
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
176+
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, req);
170177
}
171178
else if (req.headers.has("if-unmodified-since")) {
172179
if (lastModified === null
173180
|| lastModified.getTime() > new Date(req.headers.get("if-unmodified-since")!).getTime())
174-
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, this, req);
181+
return this.errors._get(ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED, req)._send(res, req);
175182
}
176183

177184
if (req.headers.has("if-none-match")) {
178185
if (this.getETags(req.headers.get("if-none-match")!)
179186
.includes(etag!))
180-
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
187+
return new EmptyResponse<A>(responseHeaders, 304)._send(res, req);
181188
}
182189
else if (req.headers.has("if-modified-since")) {
183190
if (lastModified !== null
184191
&& lastModified.getTime() <= new Date(req.headers.get("if-modified-since")!).getTime())
185-
return new EmptyResponse(responseHeaders, 304)._send(res, this, req);
192+
return new EmptyResponse<A>(responseHeaders, 304)._send(res, req);
186193
}
187194
}
188-
response._send(res, this, req);
195+
await response._send(res, req);
189196
}
190197

191198
private getETags(header: string) {
@@ -199,7 +206,7 @@ namespace Server {
199206
/**
200207
* Server options
201208
*/
202-
export interface Options {
209+
export interface Options<A> {
203210
/**
204211
* The HTTP listener port. From 1 to 65535. Ports 1–1023 require
205212
* privileges. If not set, {@link Server#listen|Server.listen()} must be called manually.
@@ -225,6 +232,11 @@ namespace Server {
225232
* @default true
226233
*/
227234
readonly handleConditionalRequests?: boolean;
235+
236+
/**
237+
* Authenticators for handling request authentication.
238+
*/
239+
readonly authenticators?: Authenticator<A>[];
228240
}
229241

230242
/**

src/ServerErrorRegistry.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import {TextResponse} from "./response/TextResponse.js";
55
/**
66
* A registry for server errors.
77
*/
8-
class ServerErrorRegistry {
9-
private readonly responses: Record<ServerErrorRegistry.ErrorCodes, Response | ((req?: Request) => Response)>;
8+
class ServerErrorRegistry<A> {
9+
private readonly responses: Record<ServerErrorRegistry.ErrorCodes, Response<A> | ((req?: Request<A>) => Response<A>)>;
1010

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

2525
[ServerErrorRegistry.ErrorCodes.PRECONDITION_FAILED]:
2626
new TextResponse("Precondition failed.", 412),
27+
28+
[ServerErrorRegistry.ErrorCodes.NO_PERMISSION]:
29+
new TextResponse("You do not have permission to perform this action.", 403),
2730
};
2831
}
2932

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

3942
/** @internal */
40-
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request | null): Response {
43+
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request<A> | null): Response<A> {
4144
const r = this.responses[code];
4245
if (typeof r === "function") return r(req ?? void 0);
4346
return r;
@@ -53,6 +56,7 @@ namespace ServerErrorRegistry {
5356
NO_ROUTE,
5457
INTERNAL,
5558
PRECONDITION_FAILED,
59+
NO_PERMISSION,
5660
}
5761
}
5862

src/auth/AuthenticatedRequest.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {Request} from "../Request.js";
2+
import {Response, ThrowableResponse} from "../response/index.js";
3+
import {ServerErrorRegistry} from "../ServerErrorRegistry.js";
4+
import {Authorisation} from "./Authorisation.js";
5+
import {Permissible} from "./Permissible.js";
6+
import {Permission} from "./Permission.js";
7+
8+
/**
9+
* A request with available {@link Authorisation}.
10+
*/
11+
export class AuthenticatedRequest<A> extends Request<A> implements Permissible {
12+
/**
13+
* This request’s authorisation.
14+
*/
15+
public readonly authorisation: Authorisation<A>;
16+
17+
/**
18+
* Create a new authenticated request.
19+
* @param authorisation
20+
* @param args The arguments to pass to the {@link Request} constructor.
21+
*/
22+
public constructor(
23+
authorisation: Authorisation<A>,
24+
...args: ConstructorParameters<typeof Request<A>>
25+
) {
26+
super(...args);
27+
this.authorisation = authorisation;
28+
}
29+
30+
/**
31+
* Check if the request has the specified permission.
32+
* @param permission
33+
*/
34+
public has(permission: Permission): boolean {
35+
return this.authorisation.has(permission);
36+
}
37+
38+
/**
39+
* Require the request to have all the specified permissions.
40+
* @param permissions The required permission.
41+
* @param [response] Throw this response if the request does not have the permission. Defaults to 403 from
42+
* {@link ServerErrorRegistry}.
43+
* @throws {@link ThrowableResponse} If the request does not have the permission.
44+
*/
45+
public require(permissions: Iterable<Permission>, response?: Response<A>): void;
46+
47+
/**
48+
* Require the request to have the specified permission.
49+
* @param permission The required permission.
50+
* @param [response] Throw this response if the request does not have the permission. Defaults to 403 from
51+
* {@link ServerErrorRegistry}.
52+
* @throws {@link ThrowableResponse} If the request does not have the permission.
53+
*/
54+
public require(permission: Permission, response?: Response<A>): void;
55+
public require(required: Permission | Iterable<Permission>, response?: Response<A>): void {
56+
if (required instanceof Permission) {
57+
if (!this.has(required))
58+
throw new ThrowableResponse(
59+
response ?? this.server.errors._get(ServerErrorRegistry.ErrorCodes.NO_PERMISSION, this)
60+
);
61+
}
62+
else for (const permission of required) {
63+
if (!this.has(permission))
64+
throw new ThrowableResponse(
65+
response ?? this.server.errors._get(ServerErrorRegistry.ErrorCodes.NO_PERMISSION, this)
66+
);
67+
}
68+
}
69+
}

0 commit comments

Comments
 (0)