Skip to content

Commit 33e7732

Browse files
committed
Merge branch 'auth' into dev/zefir
2 parents 06287eb + 20952c0 commit 33e7732

19 files changed

+274
-58
lines changed

src/AuthenticatedRequest.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import {Authorisation} from "./auth/Authorisation.js";
2+
import {Permissible} from "./auth/Permissible.js";
3+
import {Permission} from "./auth/Permission.js";
4+
import {Request} from "./Request.js";
5+
6+
/**
7+
* A request with available {@link Authorisation}.
8+
*/
9+
export class AuthenticatedRequest<A> extends Request<A> implements Permissible {
10+
/**
11+
* This request’s authorisation.
12+
*/
13+
public readonly authorisation: Authorisation<A>;
14+
15+
/**
16+
* Create a new authenticated request.
17+
* @param authorisation
18+
* @param args The arguments to pass to the {@link Request} constructor.
19+
*/
20+
public constructor(
21+
authorisation: Authorisation<A>,
22+
...args: ConstructorParameters<typeof Request<A>>
23+
) {
24+
super(...args);
25+
this.authorisation = authorisation;
26+
}
27+
28+
/**
29+
* Check if the request has the specified permission.
30+
* @param permission
31+
*/
32+
public has(permission: Permission): boolean {
33+
return this.authorisation.has(permission);
34+
}
35+
}

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 "./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,34 +36,42 @@ 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
* Construct a new Request.
3746
* @param method See {@link Request#method}.
3847
* @param url See {@link Request#url}.
3948
* @param headers See {@link Request#headers}.
4049
* @param bodyStream See {@link Request#bodyStream}.
4150
* @param ip See {@link Request#ip}.
51+
* @param server See {@link Request#server}.
4252
*/
43-
protected constructor(
44-
method: Request["method"],
45-
url: Request["url"],
46-
headers: Request["headers"],
47-
bodyStream: Request["bodyStream"],
48-
ip: Request["ip"],
53+
public constructor(
54+
method: Request<A>["method"],
55+
url: Request<A>["url"],
56+
headers: Request<A>["headers"],
57+
bodyStream: Request<A>["bodyStream"],
58+
ip: Request<A>["ip"],
59+
server: Request<A>["server"]
4960
) {
5061
this.method = method;
5162
this.url = url;
5263
this.headers = headers;
5364
this.bodyStream = bodyStream;
5465
this.ip = ip;
66+
this.server = server;
5567
}
5668

5769
/**
5870
* Create a new Request from a Node.js incoming HTTP request.
5971
* @throws {@link Request.BadUrlError} If the request URL is invalid.
6072
* @throws {@link Request.SocketClosedError} If the request socket was closed before the request could be handled.
6173
*/
62-
public static incomingMessage(incomingMessage: http.IncomingMessage) {
74+
public static incomingMessage<A>(incomingMessage: http.IncomingMessage, server: Server<A>) {
6375
const auth =
6476
incomingMessage.headers.authorization
6577
?.toLowerCase()
@@ -80,7 +92,7 @@ export class Request {
8092
if (remoteAddress === undefined)
8193
throw new Request.SocketClosedError();
8294

83-
return new Request(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress));
95+
return new Request<A>(incomingMessage.method as Request.Method, new URL(url), headers, incomingMessage, IPAddress.fromString(remoteAddress), server);
8496
}
8597

8698
/**
@@ -97,6 +109,34 @@ export class Request {
97109
);
98110
}
99111

112+
/**
113+
* Attempt to obtain authorisation for this request with one of the {@link Server}’s {@link Authenticator}s.
114+
* @returns `null` if the request lacks authorisation information.
115+
*/
116+
public async getAuthorisation(): Promise<Authorisation<A> | null> {
117+
const authenticator = this.server._authenticators.find(a => a.canAuthenticate(this));
118+
if (authenticator === undefined) return null;
119+
return await authenticator.authenticate(this);
120+
}
121+
122+
/**
123+
* Attempt to authenticate this request with one of the {@link Server}’s {@link Authenticator}s.
124+
* @returns `null` if the request lacks authorisation information.
125+
*/
126+
public async authenticate(): Promise<AuthenticatedRequest<A> | null> {
127+
const authorisation = await this.getAuthorisation();
128+
if (authorisation === null) return null;
129+
return new AuthenticatedRequest<A>(
130+
authorisation,
131+
this.method,
132+
this.url,
133+
this.headers,
134+
this.bodyStream,
135+
this.ip,
136+
this.server,
137+
);
138+
}
139+
100140
/**
101141
* Returns a boolean value that declares whether the body has been read yet.
102142
*/

src/Server.ts

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,41 @@
11
import http from "node:http";
22
import packageJson from "../package.json" with {type: "json"};
3+
import {Authenticator} from "./auth/Authenticator.js";
34
import {Request} from "./Request.js";
45
import {Response} from "./response/Response.js";
56
import {RouteRegistry} from "./routing/RouteRegistry.js";
67
import {ServerErrorRegistry} from "./ServerErrorRegistry.js";
78

8-
class Server {
9+
class Server<A> {
910
/**
1011
* Headers sent with every response.
1112
*/
1213
public readonly globalHeaders: Headers;
1314
/**
1415
* This server's route registry.
1516
*/
16-
public readonly routes = new RouteRegistry();
17+
public readonly routes = new RouteRegistry<A>();
1718
private readonly server: http.Server;
1819
private readonly copyOrigin: boolean;
19-
private readonly errors = new ServerErrorRegistry();
20+
private readonly errors = new ServerErrorRegistry<A>();
21+
/** @internal */
22+
public readonly _authenticators: Authenticator<A>[];
2023

2124
/**
2225
* Create a new HTTP server.
2326
* @param options Server options.
2427
*/
25-
public constructor(options: Server.Options) {
28+
public constructor(options: Server.Options<A>) {
2629
this.server = http.createServer({
2730
joinDuplicateHeaders: true,
2831
}, this.listener.bind(this));
2932

3033
this.globalHeaders = new Headers(options.globalHeaders);
3134
if (!this.globalHeaders.has("server"))
32-
this.globalHeaders.set("Server", `cldn/${packageJson.version}`);
35+
this.globalHeaders.set("Server", `${packageJson.name}/${packageJson.version}`);
3336

3437
this.copyOrigin = options.copyOrigin ?? false;
38+
this._authenticators = options.authenticators ?? [];
3539

3640
this.server.listen(options.port);
3741
}
@@ -42,13 +46,13 @@ class Server {
4246
}
4347

4448
private async listener(req: http.IncomingMessage, res: http.ServerResponse) {
45-
let apiRequest: Request;
49+
let apiRequest: Request<A>;
4650
try {
47-
apiRequest = Request.incomingMessage(req);
51+
apiRequest = Request.incomingMessage(req, this);
4852
}
4953
catch (e) {
5054
if (e instanceof Request.BadUrlError) {
51-
this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res, this);
55+
await this.errors._get(ServerErrorRegistry.ErrorCodes.BAD_URL, null)._send(res, this);
5256
return;
5357
}
5458
if (e instanceof Request.SocketClosedError)
@@ -64,7 +68,7 @@ class Server {
6468
apiRequest._responseHeaders.set("vary", "origin");
6569
}
6670

67-
let response: Response;
71+
let response: Response<A>;
6872
try {
6973
response = await this.routes.handle(apiRequest);
7074
}
@@ -76,7 +80,7 @@ class Server {
7680
response = this.errors._get(ServerErrorRegistry.ErrorCodes.INTERNAL, apiRequest);
7781
}
7882
}
79-
response._send(res, this, apiRequest);
83+
await response._send(res, apiRequest);
8084
}
8185

8286
public close(): Promise<void> {
@@ -96,7 +100,7 @@ namespace Server {
96100
/**
97101
* Server options
98102
*/
99-
export interface Options {
103+
export interface Options<A> {
100104
/**
101105
* The HTTP listener port. From 1 to 65535. Ports 1–1023 require
102106
* privileges.
@@ -115,6 +119,11 @@ namespace Server {
115119
* @default false
116120
*/
117121
readonly copyOrigin?: boolean;
122+
123+
/**
124+
* Authenticators for handling request authentication.
125+
*/
126+
readonly authenticators?: Authenticator<A>[];
118127
}
119128
}
120129

src/ServerErrorRegistry.ts

Lines changed: 4 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.
@@ -29,12 +29,12 @@ class ServerErrorRegistry {
2929
* @param code The server error code.
3030
* @param response The response to send.
3131
*/
32-
public register(code: ServerErrorRegistry.ErrorCodes, response: Response | ((req?: Request) => Response)) {
32+
public register(code: ServerErrorRegistry.ErrorCodes, response: Response<A> | ((req?: Request<A>) => Response<A>)) {
3333
this.responses[code] = response;
3434
}
3535

3636
/** @internal */
37-
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request | null): Response {
37+
public _get(code: ServerErrorRegistry.ErrorCodes, req: Request<A> | null): Response<A> {
3838
const r = this.responses[code];
3939
if (typeof r === "function") return r(req ?? void 0);
4040
return r;

src/auth/Authenticator.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Request} from "../Request.js";
2+
import {Authorisation} from "./Authorisation.js";
3+
4+
/**
5+
* Handles authentication for requests.
6+
*/
7+
export interface Authenticator<A extends any> {
8+
/**
9+
* Check whether this can handle authentication for the given request. The authenticator should return `false` if
10+
* the request lacks the information required to begin authentication.
11+
* @param request
12+
*/
13+
canAuthenticate(request: Request<A>): boolean;
14+
15+
/**
16+
* Authenticate the given request. If authenticate fails, e.g. due to missing or invalid information, such as
17+
* credentials, the authenticator should return `null`, which can be communicated to the client by implementing
18+
* applications using a 401 status response.
19+
* @param request
20+
*/
21+
authenticate(request: Request<A>): Promise<Authorisation<A> | null>;
22+
}

src/auth/Authorisation.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {Permission} from "./Permission.js";
2+
import {PermissionGroup} from "./PermissionGroup.js";
3+
4+
/**
5+
* A permission group with additional data.
6+
*/
7+
export class Authorisation<T> extends PermissionGroup {
8+
/**
9+
* Additional authentication data.
10+
*/
11+
public readonly data: T;
12+
13+
/**
14+
* Create a new authorisation.
15+
* @param permissions The permissions of the authorisation.
16+
* @param data Additional authentication data.
17+
*/
18+
public constructor(permissions: Iterable<Permission>, data: T) {
19+
super(permissions);
20+
this.data = data;
21+
}
22+
}

src/auth/Permissible.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import {Permission} from "./Permission.js";
2+
3+
/**
4+
* Represents an entity that can be checked for permissions.
5+
*/
6+
export interface Permissible {
7+
/**
8+
* Check whether this entity has the specified permission.
9+
* @param permission The permission to check.
10+
*/
11+
has(permission: Permission): boolean;
12+
}

src/auth/Permission.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import {Permissible} from "./Permissible.js";
2+
3+
/**
4+
* Represents a permission with a unique name.
5+
*/
6+
export class Permission implements Permissible {
7+
private readonly name: string;
8+
9+
/**
10+
* Create a new permission with the specified name.
11+
* @param name The name of the permission.
12+
*/
13+
public constructor(name: string) {
14+
this.name = name;
15+
}
16+
17+
/**
18+
* Get the name of this permission.
19+
*/
20+
public getName(): string {
21+
return this.name;
22+
}
23+
24+
/**
25+
* Checks if this permission matches another.
26+
* @param permission The permission to compare with.
27+
*/
28+
public has(permission: Permission): boolean {
29+
return this.name === permission.name;
30+
}
31+
}

0 commit comments

Comments
 (0)