anbaric-cloud-hosting 1.33.0 → 1.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/auth/HttpMembershipService.ts +44 -0
- package/src/auth/InMemoryMembershipService.ts +38 -0
- package/src/auth/InMemoryUserDirectory.ts +29 -0
- package/src/auth/MembershipService.ts +35 -0
- package/src/auth/SessionSigner.ts +4 -2
- package/src/auth/UserDirectory.ts +25 -0
- package/src/data-store/PostgresUserDirectory.ts +39 -0
- package/src/data-store/Schema.ts +10 -0
- package/src/hosting/HostingServer.ts +10 -2
- package/src/hosting/handlers/auth/InvitationsHandler.ts +34 -0
- package/src/hosting/handlers/auth/UsersHandler.ts +16 -0
- package/src/hosting/middleware/AuthenticationMiddleware.ts +28 -2
- package/src/hosting/pages/platform-ui.html +3 -3
- package/src/index.ts +8 -0
- package/src/main.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.35.0",
|
|
4
4
|
"description": "Anbaric Cloud hosting service: Postgres-backed job persistence and queuing exposed over an HTTP API",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "chris@anbaric.ai",
|
|
@@ -18,18 +18,18 @@
|
|
|
18
18
|
"@aws-sdk/client-s3": "^3.1110.0",
|
|
19
19
|
"@aws-sdk/client-secrets-manager": "^3.700.0",
|
|
20
20
|
"@aws-sdk/client-servicediscovery": "^3.1110.0",
|
|
21
|
-
"anbaric-data-store": "^1.
|
|
22
|
-
"anbaric-plugins": "^1.
|
|
23
|
-
"anbaric-tsapi": "^1.
|
|
24
|
-
"anbaric-web": "^1.
|
|
21
|
+
"anbaric-data-store": "^1.35.0",
|
|
22
|
+
"anbaric-plugins": "^1.35.0",
|
|
23
|
+
"anbaric-tsapi": "^1.35.0",
|
|
24
|
+
"anbaric-web": "^1.35.0",
|
|
25
25
|
"esbuild": "^0.28.2",
|
|
26
26
|
"pg": "^8.16.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^26.2.0",
|
|
30
30
|
"@types/pg": "^8.15.0",
|
|
31
|
-
"anbaric-impl-cloud": "^1.
|
|
32
|
-
"anbaric-state-machine": "^1.
|
|
31
|
+
"anbaric-impl-cloud": "^1.35.0",
|
|
32
|
+
"anbaric-state-machine": "^1.35.0",
|
|
33
33
|
"tsx": "^4.20.0",
|
|
34
34
|
"typescript": "^7.0.2"
|
|
35
35
|
},
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
2
|
+
|
|
3
|
+
type FetchFn = (url : string, init : RequestInit) => Promise<Response>;
|
|
4
|
+
|
|
5
|
+
/* Talks to the central login's invitation endpoints for one tenant, with the
|
|
6
|
+
same central api key the platform already holds for CLI key lookups. */
|
|
7
|
+
class HttpMembershipService implements MembershipService {
|
|
8
|
+
|
|
9
|
+
constructor(private centralUrl : string, private secret : string, private tenantSlug : string,
|
|
10
|
+
private fetchFn : FetchFn = (url, init) => fetch(url, init)) {}
|
|
11
|
+
|
|
12
|
+
async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
13
|
+
const response = await this.call("POST", "", { email, invitedBy });
|
|
14
|
+
return await response.json() as PendingInvitation;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async pending() : Promise<Array<PendingInvitation>> {
|
|
18
|
+
const response = await this.call("GET", "");
|
|
19
|
+
return await response.json() as Array<PendingInvitation>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async revoke(token : string) : Promise<boolean> {
|
|
23
|
+
const response = await this.call("DELETE", `/${encodeURIComponent(token)}`, undefined, [404]);
|
|
24
|
+
return response.status === 204;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async call(method : string, path : string, body? : unknown, tolerated : Array<number> = []) : Promise<Response> {
|
|
28
|
+
const headers : Record<string, string> = { "x-anbaric-central-key": this.secret };
|
|
29
|
+
if (body !== undefined) headers["content-type"] = "application/json";
|
|
30
|
+
|
|
31
|
+
const response = await this.fetchFn(
|
|
32
|
+
`${this.centralUrl.replace(/\/$/, "")}/tenants/${encodeURIComponent(this.tenantSlug)}/invitations${path}`,
|
|
33
|
+
{ method, headers, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
34
|
+
|
|
35
|
+
if (! response.ok && ! tolerated.includes(response.status)) {
|
|
36
|
+
const problem = await response.json().catch(() => ({})) as { error? : string };
|
|
37
|
+
throw new Error(problem.error ?? `The invitation request failed with status ${response.status}`);
|
|
38
|
+
}
|
|
39
|
+
return response;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export { HttpMembershipService }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {randomUUID} from "node:crypto";
|
|
2
|
+
import {Inviter, MembershipService, PendingInvitation} from "./MembershipService";
|
|
3
|
+
|
|
4
|
+
class InMemoryMembershipService implements MembershipService {
|
|
5
|
+
|
|
6
|
+
private invitations = new Map<string, PendingInvitation>();
|
|
7
|
+
|
|
8
|
+
constructor(private linkBase : string = "http://localhost/invitations") {}
|
|
9
|
+
|
|
10
|
+
async invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation> {
|
|
11
|
+
const token = randomUUID();
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const invitation : PendingInvitation = {
|
|
14
|
+
token,
|
|
15
|
+
email: email.trim().toLowerCase(),
|
|
16
|
+
role: "member",
|
|
17
|
+
invitedBy: invitedBy.id,
|
|
18
|
+
invitedByName: invitedBy.name,
|
|
19
|
+
link: `${this.linkBase}/${token}`,
|
|
20
|
+
createdAt: now.toISOString(),
|
|
21
|
+
expiresAt: new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
22
|
+
emailed: false,
|
|
23
|
+
};
|
|
24
|
+
this.invitations.set(token, invitation);
|
|
25
|
+
return { ...invitation };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async pending() : Promise<Array<PendingInvitation>> {
|
|
29
|
+
return Array.from(this.invitations.values()).map(invitation => ({ ...invitation })).reverse();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async revoke(token : string) : Promise<boolean> {
|
|
33
|
+
return this.invitations.delete(token);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { InMemoryMembershipService }
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {User} from "./User";
|
|
2
|
+
import {DirectoryUser, UserDirectory} from "./UserDirectory";
|
|
3
|
+
|
|
4
|
+
class InMemoryUserDirectory implements UserDirectory {
|
|
5
|
+
|
|
6
|
+
private users = new Map<string, DirectoryUser>();
|
|
7
|
+
|
|
8
|
+
async record(user : User) : Promise<void> {
|
|
9
|
+
const now = new Date().toISOString();
|
|
10
|
+
const existing = this.users.get(user.id);
|
|
11
|
+
this.users.set(user.id, {
|
|
12
|
+
id: user.id,
|
|
13
|
+
name: user.name ?? "",
|
|
14
|
+
email: user.email ?? "",
|
|
15
|
+
picture: user.picture,
|
|
16
|
+
firstSeenAt: existing?.firstSeenAt ?? now,
|
|
17
|
+
lastSeenAt: now,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async list() : Promise<Array<DirectoryUser>> {
|
|
22
|
+
return Array.from(this.users.values())
|
|
23
|
+
.map(user => ({ ...user }))
|
|
24
|
+
.sort((a, b) => (a.name || a.id).localeCompare(b.name || b.id));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export { InMemoryUserDirectory }
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type PendingInvitation = {
|
|
2
|
+
|
|
3
|
+
token : string,
|
|
4
|
+
email : string,
|
|
5
|
+
role : string,
|
|
6
|
+
invitedBy : string,
|
|
7
|
+
invitedByName? : string,
|
|
8
|
+
link : string,
|
|
9
|
+
createdAt : string,
|
|
10
|
+
expiresAt : string,
|
|
11
|
+
emailed? : boolean,
|
|
12
|
+
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
type Inviter = {
|
|
16
|
+
|
|
17
|
+
id : string,
|
|
18
|
+
name? : string,
|
|
19
|
+
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/* Invitations into this tenant. Membership itself is decided wherever people
|
|
23
|
+
sign in (Anbaric Cloud's central login), so a platform only asks that
|
|
24
|
+
service to invite, list and revoke on the tenant's behalf. */
|
|
25
|
+
interface MembershipService {
|
|
26
|
+
|
|
27
|
+
invite(email : string, invitedBy : Inviter) : Promise<PendingInvitation>;
|
|
28
|
+
|
|
29
|
+
pending() : Promise<Array<PendingInvitation>>;
|
|
30
|
+
|
|
31
|
+
revoke(token : string) : Promise<boolean>;
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type { MembershipService, PendingInvitation, Inviter }
|
|
@@ -42,6 +42,7 @@ class SessionSigner {
|
|
|
42
42
|
roles: user.roles.map(role => role.id),
|
|
43
43
|
name: user.name,
|
|
44
44
|
picture: user.picture,
|
|
45
|
+
email: user.email,
|
|
45
46
|
exp: nowSeconds() + this.ttlSeconds,
|
|
46
47
|
};
|
|
47
48
|
const payload = base64url(JSON.stringify(claims));
|
|
@@ -54,7 +55,7 @@ class SessionSigner {
|
|
|
54
55
|
const [payload, signature] = token.split(".");
|
|
55
56
|
if (!payload || !signature || !this.signatureMatches(payload, signature)) return undefined;
|
|
56
57
|
|
|
57
|
-
let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, exp? : unknown };
|
|
58
|
+
let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, name? : unknown, picture? : unknown, email? : unknown, exp? : unknown };
|
|
58
59
|
try {
|
|
59
60
|
claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
60
61
|
} catch {
|
|
@@ -68,7 +69,8 @@ class SessionSigner {
|
|
|
68
69
|
const tenant = typeof claims.tenant === "string" ? new Tenant(claims.tenant) : undefined;
|
|
69
70
|
const name = typeof claims.name === "string" ? claims.name : undefined;
|
|
70
71
|
const picture = typeof claims.picture === "string" ? claims.picture : undefined;
|
|
71
|
-
|
|
72
|
+
const email = typeof claims.email === "string" ? claims.email : undefined;
|
|
73
|
+
return [new User(claims.sub, roles, [], name, picture, email), tenant];
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
// Sets (or, for a still-valid session, refreshes) the session cookie.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import {User} from "./User";
|
|
2
|
+
|
|
3
|
+
type DirectoryUser = {
|
|
4
|
+
|
|
5
|
+
id : string,
|
|
6
|
+
name : string,
|
|
7
|
+
email : string,
|
|
8
|
+
picture? : string,
|
|
9
|
+
firstSeenAt : string,
|
|
10
|
+
lastSeenAt : string,
|
|
11
|
+
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/* Everyone who has signed in to this tenant, as the platform saw them at
|
|
15
|
+
their last sign-in. A tenant-local directory: it needs no identity-provider
|
|
16
|
+
organisation, so it works for a single-owner tenant as much as a team. */
|
|
17
|
+
interface UserDirectory {
|
|
18
|
+
|
|
19
|
+
record(user : User) : Promise<void>;
|
|
20
|
+
|
|
21
|
+
list() : Promise<Array<DirectoryUser>>;
|
|
22
|
+
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type { UserDirectory, DirectoryUser }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {Pool} from "pg";
|
|
2
|
+
import {User} from "../auth/User";
|
|
3
|
+
import {DirectoryUser, UserDirectory} from "../auth/UserDirectory";
|
|
4
|
+
|
|
5
|
+
class PostgresUserDirectory implements UserDirectory {
|
|
6
|
+
|
|
7
|
+
constructor(private pool : Pool) {}
|
|
8
|
+
|
|
9
|
+
async record(user : User) : Promise<void> {
|
|
10
|
+
await this.pool.query(
|
|
11
|
+
`INSERT INTO anbaric_system.users (id, name, email, picture)
|
|
12
|
+
VALUES ($1, $2, $3, $4)
|
|
13
|
+
ON CONFLICT (id) DO UPDATE
|
|
14
|
+
SET name = EXCLUDED.name, email = EXCLUDED.email, picture = EXCLUDED.picture, last_seen = now()`,
|
|
15
|
+
[user.id, user.name ?? "", user.email ?? "", user.picture ?? null]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async list() : Promise<Array<DirectoryUser>> {
|
|
19
|
+
const result = await this.pool.query(
|
|
20
|
+
`SELECT id, name, email, picture, first_seen, last_seen
|
|
21
|
+
FROM anbaric_system.users
|
|
22
|
+
ORDER BY NULLIF(name, '') NULLS LAST, id`);
|
|
23
|
+
return result.rows.map(row => ({
|
|
24
|
+
id: row.id,
|
|
25
|
+
name: row.name,
|
|
26
|
+
email: row.email,
|
|
27
|
+
picture: row.picture ?? undefined,
|
|
28
|
+
firstSeenAt: this.iso(row.first_seen),
|
|
29
|
+
lastSeenAt: this.iso(row.last_seen),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private iso(value : unknown) : string {
|
|
34
|
+
return value instanceof Date ? value.toISOString() : String(value ?? "");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { PostgresUserDirectory }
|
package/src/data-store/Schema.ts
CHANGED
|
@@ -156,6 +156,16 @@ const ensureSchema = async (pool : Pool) : Promise<void> => {
|
|
|
156
156
|
)
|
|
157
157
|
`);
|
|
158
158
|
await pool.query("ALTER TABLE anbaric_system.cli_keys ADD COLUMN IF NOT EXISTS tenant TEXT");
|
|
159
|
+
await pool.query(`
|
|
160
|
+
CREATE TABLE IF NOT EXISTS anbaric_system.users (
|
|
161
|
+
id TEXT PRIMARY KEY,
|
|
162
|
+
name TEXT NOT NULL DEFAULT '',
|
|
163
|
+
email TEXT NOT NULL DEFAULT '',
|
|
164
|
+
picture TEXT,
|
|
165
|
+
first_seen TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
166
|
+
last_seen TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
167
|
+
)
|
|
168
|
+
`);
|
|
159
169
|
await pool.query(`
|
|
160
170
|
DO $$ BEGIN
|
|
161
171
|
CREATE TYPE anbaric_system.audit_interaction AS ENUM
|
|
@@ -13,7 +13,11 @@ import {AuditsHandler} from "./handlers/AuditsHandler";
|
|
|
13
13
|
import {AuthorizeCliHandler} from "./handlers/auth/AuthorizeCliHandler";
|
|
14
14
|
import {KeysHandler} from "./handlers/auth/KeysHandler";
|
|
15
15
|
import {SessionsHandler} from "./handlers/auth/SessionsHandler";
|
|
16
|
+
import {UsersHandler} from "./handlers/auth/UsersHandler";
|
|
17
|
+
import {InvitationsHandler} from "./handlers/auth/InvitationsHandler";
|
|
18
|
+
import {MembershipService} from "../auth/MembershipService";
|
|
16
19
|
import {WhoamiHandler} from "./handlers/auth/WhoamiHandler";
|
|
20
|
+
import {UserDirectory} from "../auth/UserDirectory";
|
|
17
21
|
import {ConsumersHandler} from "./handlers/ConsumersHandler";
|
|
18
22
|
import {DocumentsHandler} from "./handlers/DocumentsHandler";
|
|
19
23
|
import {EntitlementsAdminHandler} from "./handlers/EntitlementsAdminHandler";
|
|
@@ -62,7 +66,9 @@ class HostingServer {
|
|
|
62
66
|
plugins : Array<LoadedPlugin> = [],
|
|
63
67
|
jobRunSchedules? : JobRunSchedulePersistence,
|
|
64
68
|
notifier? : Notifier,
|
|
65
|
-
entitlements? : EntitlementStore
|
|
69
|
+
entitlements? : EntitlementStore,
|
|
70
|
+
userDirectory? : UserDirectory,
|
|
71
|
+
memberships? : MembershipService) {
|
|
66
72
|
const pages = new PagesHandler();
|
|
67
73
|
const ping = new PingHandler(tenant);
|
|
68
74
|
const jobs = new JobsHandler(persistence);
|
|
@@ -92,6 +98,8 @@ class HostingServer {
|
|
|
92
98
|
if (jobRunSchedules) publicRouter.registerApi("job-run-schedules", new JobRunSchedulesHandler(jobRunSchedules));
|
|
93
99
|
if (notifier) publicRouter.registerApi("notifications", new NotificationsHandler(notifier));
|
|
94
100
|
if (entitlements) publicRouter.registerApi("entitlements", new EntitlementsAdminHandler(entitlements));
|
|
101
|
+
if (userDirectory) publicRouter.registerApi("users", new UsersHandler(userDirectory));
|
|
102
|
+
if (memberships) publicRouter.registerApi("invitations", new InvitationsHandler(memberships));
|
|
95
103
|
if (cliAuthorizer) {
|
|
96
104
|
publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
|
|
97
105
|
publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
|
|
@@ -128,7 +136,7 @@ class HostingServer {
|
|
|
128
136
|
|
|
129
137
|
this.publicServer = new Server(publicRouter, [
|
|
130
138
|
new SessionMiddleware(),
|
|
131
|
-
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests),
|
|
139
|
+
new AuthenticationMiddleware(authenticator, tokenAuthenticator, openRequests, undefined, userDirectory),
|
|
132
140
|
]);
|
|
133
141
|
this.internalServer = new Server(internalRouter);
|
|
134
142
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {MembershipService} from "../../../auth/MembershipService";
|
|
2
|
+
import {Request} from "../../Request";
|
|
3
|
+
import {RequestHandler} from "../../RequestHandler";
|
|
4
|
+
|
|
5
|
+
const EMAIL_SHAPE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
6
|
+
|
|
7
|
+
/* The console's side of inviting people into this tenant: list what is
|
|
8
|
+
pending, invite by email (attributed to the signed-in user), revoke. */
|
|
9
|
+
class InvitationsHandler implements RequestHandler {
|
|
10
|
+
|
|
11
|
+
constructor(private memberships : MembershipService) {}
|
|
12
|
+
|
|
13
|
+
async handle(request : Request) : Promise<void> {
|
|
14
|
+
if (request.method === "GET" && ! request.id) return request.reply(200, await this.memberships.pending());
|
|
15
|
+
if (request.method === "POST" && ! request.id) return this.invite(request);
|
|
16
|
+
if (request.method === "DELETE" && request.id && ! request.subresource) {
|
|
17
|
+
const revoked = await this.memberships.revoke(request.id);
|
|
18
|
+
return revoked ? request.reply(204) : request.notFound();
|
|
19
|
+
}
|
|
20
|
+
request.notFound();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private async invite(request : Request) : Promise<void> {
|
|
24
|
+
const body = await request.body() as { email? : string } | undefined;
|
|
25
|
+
const email = String(body?.email ?? "").trim();
|
|
26
|
+
if (! EMAIL_SHAPE.test(email)) return request.reply(400, { error: "A valid email address is required" });
|
|
27
|
+
|
|
28
|
+
const inviter = request.user ? { id: request.user.id, name: request.user.name } : { id: "system" };
|
|
29
|
+
request.reply(201, await this.memberships.invite(email, inviter));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export { InvitationsHandler }
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {UserDirectory} from "../../../auth/UserDirectory";
|
|
2
|
+
import {Request} from "../../Request";
|
|
3
|
+
import {RequestHandler} from "../../RequestHandler";
|
|
4
|
+
|
|
5
|
+
class UsersHandler implements RequestHandler {
|
|
6
|
+
|
|
7
|
+
constructor(private directory : UserDirectory) {}
|
|
8
|
+
|
|
9
|
+
async handle(request : Request) : Promise<void> {
|
|
10
|
+
if (request.method === "GET" && ! request.id) return request.reply(200, await this.directory.list());
|
|
11
|
+
request.notFound();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export { UsersHandler }
|
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import {Authenticator} from "../../auth/Authenticator";
|
|
2
2
|
import {SessionSigner} from "../../auth/SessionSigner";
|
|
3
3
|
import {TokenAuthenticator} from "../../auth/TokenAuthenticator";
|
|
4
|
+
import {User} from "../../auth/User";
|
|
5
|
+
import {UserDirectory} from "../../auth/UserDirectory";
|
|
4
6
|
import {Middleware} from "../Middleware";
|
|
5
7
|
import {Request} from "../Request";
|
|
6
8
|
|
|
7
9
|
type OpenRequestPredicate = (request : Request) => boolean;
|
|
8
10
|
|
|
11
|
+
const DIRECTORY_REFRESH_MS = 60 * 60 * 1000;
|
|
12
|
+
|
|
9
13
|
/* Decorates the request with its authenticated user and tenant - bearer
|
|
10
14
|
tokens first, then a valid platform session cookie, then the authenticator -
|
|
11
15
|
and enforces authorization. A valid platform session short-circuits the
|
|
12
16
|
authenticator entirely (no identity-provider round-trip) and slides its
|
|
13
17
|
expiry; a fresh authenticator login mints one. Requests matching the open
|
|
14
18
|
predicate (ping, the one-time CLI key poll) pass through untouched, as does
|
|
15
|
-
everything when no authenticator is configured.
|
|
19
|
+
everything when no authenticator is configured. Browser-session users are
|
|
20
|
+
recorded in the tenant's user directory, at most once an hour each. */
|
|
16
21
|
class AuthenticationMiddleware implements Middleware {
|
|
17
22
|
|
|
23
|
+
private recorded = new Map<string, number>();
|
|
24
|
+
|
|
18
25
|
constructor(private authenticator? : Authenticator,
|
|
19
26
|
private tokenAuthenticator? : TokenAuthenticator,
|
|
20
27
|
private isOpen : OpenRequestPredicate = () => false,
|
|
21
|
-
private sessionSigner : SessionSigner = new SessionSigner()
|
|
28
|
+
private sessionSigner : SessionSigner = new SessionSigner(),
|
|
29
|
+
private userDirectory? : UserDirectory) {}
|
|
22
30
|
|
|
23
31
|
async apply(request : Request) : Promise<boolean> {
|
|
24
32
|
if (this.isOpen(request)) return true;
|
|
@@ -35,6 +43,7 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
35
43
|
if (session) {
|
|
36
44
|
[request.user, request.tenant] = session;
|
|
37
45
|
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
46
|
+
await this.remember(request.user!);
|
|
38
47
|
return this.authorized(request);
|
|
39
48
|
}
|
|
40
49
|
}
|
|
@@ -57,9 +66,26 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
57
66
|
if (this.sessionSigner.configured && !request.handled) {
|
|
58
67
|
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
59
68
|
}
|
|
69
|
+
await this.remember(request.user!);
|
|
60
70
|
return this.authorized(request);
|
|
61
71
|
}
|
|
62
72
|
|
|
73
|
+
private async remember(user : User) : Promise<void> {
|
|
74
|
+
if (! this.userDirectory) return;
|
|
75
|
+
|
|
76
|
+
const now = Date.now();
|
|
77
|
+
const last = this.recorded.get(user.id) ?? 0;
|
|
78
|
+
if (now - last < DIRECTORY_REFRESH_MS) return;
|
|
79
|
+
|
|
80
|
+
this.recorded.set(user.id, now);
|
|
81
|
+
try {
|
|
82
|
+
await this.userDirectory.record(user);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
this.recorded.delete(user.id);
|
|
85
|
+
console.error(`Could not record user "${user.id}" in the directory:`, error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
private isStateChanging(method : string) : boolean {
|
|
64
90
|
return method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
65
91
|
}
|