anbaric-cloud-hosting 1.16.4 → 1.18.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 +6 -6
- package/src/auth/SessionSigner.ts +83 -0
- package/src/hosting/HostingServer.ts +19 -27
- package/src/hosting/Request.ts +7 -1
- package/src/hosting/Router.ts +16 -6
- package/src/hosting/handlers/AppProxyHandler.ts +11 -9
- package/src/hosting/handlers/PluginsHandler.ts +1 -1
- package/src/hosting/middleware/AuthenticationMiddleware.ts +21 -5
- package/src/hosting/pages/platform-ui.html +1 -1
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anbaric-cloud-hosting",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.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,17 +18,17 @@
|
|
|
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.
|
|
21
|
+
"anbaric-data-store": "^1.18.0",
|
|
22
|
+
"anbaric-plugins": "^1.18.0",
|
|
23
|
+
"anbaric-tsapi": "^1.18.0",
|
|
24
24
|
"esbuild": "^0.28.2",
|
|
25
25
|
"pg": "^8.16.0"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
28
|
"@types/node": "^26.2.0",
|
|
29
29
|
"@types/pg": "^8.15.0",
|
|
30
|
-
"anbaric-impl-cloud": "^1.
|
|
31
|
-
"anbaric-state-machine": "^1.
|
|
30
|
+
"anbaric-impl-cloud": "^1.18.0",
|
|
31
|
+
"anbaric-state-machine": "^1.18.0",
|
|
32
32
|
"tsx": "^4.20.0",
|
|
33
33
|
"typescript": "^7.0.2"
|
|
34
34
|
},
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {createHmac, timingSafeEqual} from "node:crypto";
|
|
2
|
+
import {ServerResponse} from "node:http";
|
|
3
|
+
import {SESSION_COOKIE} from "./Authenticator";
|
|
4
|
+
import {Role} from "./Role";
|
|
5
|
+
import {Tenant} from "./Tenant";
|
|
6
|
+
import {User} from "./User";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
9
|
+
|
|
10
|
+
const nowSeconds = () => Math.floor(Date.now() / 1000);
|
|
11
|
+
|
|
12
|
+
const base64url = (input : Buffer | string) => Buffer.from(input).toString("base64url");
|
|
13
|
+
|
|
14
|
+
/* A stateless, platform-signed session. Once an identity provider has proved
|
|
15
|
+
who the user is (once, at login), the browser holds a signed cookie carrying
|
|
16
|
+
the user, tenant and roles; every later request is authenticated by verifying
|
|
17
|
+
the signature - no per-request identity-provider round-trip and no session
|
|
18
|
+
store. The signing secret lives in the environment
|
|
19
|
+
(ANBARIC_SESSION_SIGNING_SECRET, sourced from a secret manager); with none
|
|
20
|
+
set the signer is inert and the platform falls back to the authenticator.
|
|
21
|
+
Sessions slide: a valid one is re-issued with a fresh expiry on each request. */
|
|
22
|
+
class SessionSigner {
|
|
23
|
+
|
|
24
|
+
constructor(private secret : string = process.env.ANBARIC_SESSION_SIGNING_SECRET ?? "",
|
|
25
|
+
private ttlSeconds : number = DEFAULT_TTL_SECONDS) {}
|
|
26
|
+
|
|
27
|
+
get configured() : boolean {
|
|
28
|
+
return this.secret.length > 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
mint(user : User, tenant? : Tenant) : string {
|
|
32
|
+
const claims = {
|
|
33
|
+
sub: user.id,
|
|
34
|
+
tenant: tenant?.id,
|
|
35
|
+
roles: user.roles.map(role => role.id),
|
|
36
|
+
exp: nowSeconds() + this.ttlSeconds,
|
|
37
|
+
};
|
|
38
|
+
const payload = base64url(JSON.stringify(claims));
|
|
39
|
+
return `${payload}.${this.sign(payload)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
verify(token : string | undefined) : [User, Tenant | undefined] | undefined {
|
|
43
|
+
if (!token || !this.configured) return undefined;
|
|
44
|
+
|
|
45
|
+
const [payload, signature] = token.split(".");
|
|
46
|
+
if (!payload || !signature || !this.signatureMatches(payload, signature)) return undefined;
|
|
47
|
+
|
|
48
|
+
let claims : { sub? : unknown, tenant? : unknown, roles? : unknown, exp? : unknown };
|
|
49
|
+
try {
|
|
50
|
+
claims = JSON.parse(Buffer.from(payload, "base64url").toString());
|
|
51
|
+
} catch {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
if (typeof claims.sub !== "string" || typeof claims.exp !== "number" || claims.exp < nowSeconds()) {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const roles = Array.isArray(claims.roles) ? claims.roles.map(id => new Role(String(id))) : [];
|
|
59
|
+
const tenant = typeof claims.tenant === "string" ? new Tenant(claims.tenant) : undefined;
|
|
60
|
+
return [new User(claims.sub, roles), tenant];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Sets (or, for a still-valid session, refreshes) the session cookie.
|
|
64
|
+
issue(response : ServerResponse, user : User, tenant? : Tenant) : void {
|
|
65
|
+
const cookie = `${SESSION_COOKIE}=${this.mint(user, tenant)}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${this.ttlSeconds}`;
|
|
66
|
+
const existing = response.getHeader("Set-Cookie");
|
|
67
|
+
if (existing === undefined) response.setHeader("Set-Cookie", cookie);
|
|
68
|
+
else response.setHeader("Set-Cookie", Array.isArray(existing) ? [...existing, cookie] : [String(existing), cookie]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private sign(payload : string) : string {
|
|
72
|
+
return createHmac("sha256", this.secret).update(payload).digest("base64url");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private signatureMatches(payload : string, signature : string) : boolean {
|
|
76
|
+
const expected = Buffer.from(this.sign(payload));
|
|
77
|
+
const actual = Buffer.from(signature);
|
|
78
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export { SessionSigner }
|
|
@@ -23,7 +23,6 @@ import {StateMachinesHandler} from "./handlers/StateMachinesHandler";
|
|
|
23
23
|
import {PluginsHandler} from "./handlers/PluginsHandler";
|
|
24
24
|
import {AuthenticationMiddleware} from "./middleware/AuthenticationMiddleware";
|
|
25
25
|
import {SessionMiddleware} from "./middleware/SessionMiddleware";
|
|
26
|
-
import {PageDirectory} from "../plugins/PageDirectory";
|
|
27
26
|
import {LoadedPlugin} from "../plugins/Plugin";
|
|
28
27
|
import {Request} from "./Request";
|
|
29
28
|
import {Router} from "./Router";
|
|
@@ -64,41 +63,34 @@ class HostingServer {
|
|
|
64
63
|
|
|
65
64
|
const publicRouter = new Router();
|
|
66
65
|
publicRouter.registerRoot(pages);
|
|
67
|
-
if (plugins.length > 0) {
|
|
68
|
-
for (const segment of new PageDirectory(plugins).topLevelSegments) {
|
|
69
|
-
publicRouter.register(segment, pages);
|
|
70
|
-
}
|
|
71
|
-
publicRouter.register("plugins", new PluginsHandler(plugins));
|
|
72
|
-
}
|
|
73
66
|
publicRouter.register("ping", ping);
|
|
74
|
-
publicRouter.
|
|
75
|
-
publicRouter.
|
|
76
|
-
publicRouter.
|
|
77
|
-
publicRouter.
|
|
78
|
-
publicRouter.
|
|
79
|
-
publicRouter.
|
|
80
|
-
if (documents) publicRouter.
|
|
81
|
-
if (secrets) publicRouter.
|
|
82
|
-
if (audits) publicRouter.
|
|
67
|
+
if (plugins.length > 0) publicRouter.registerApi("plugins", new PluginsHandler(plugins));
|
|
68
|
+
publicRouter.registerApi("whoami", new WhoamiHandler());
|
|
69
|
+
publicRouter.registerApi("jobs", jobs);
|
|
70
|
+
publicRouter.registerApi("queue", queueHandler);
|
|
71
|
+
publicRouter.registerApi("consumers", consumers);
|
|
72
|
+
publicRouter.registerApi("state-machines", stateMachines);
|
|
73
|
+
if (documents) publicRouter.registerApi("documents", documents);
|
|
74
|
+
if (secrets) publicRouter.registerApi("secrets", secrets);
|
|
75
|
+
if (audits) publicRouter.registerApi("audits", audits);
|
|
83
76
|
if (cliAuthorizer) {
|
|
84
77
|
publicRouter.register("authorize-cli", new AuthorizeCliHandler(cliAuthorizer, pages, tenant));
|
|
85
|
-
publicRouter.
|
|
86
|
-
publicRouter.register("manage-keys", pages);
|
|
78
|
+
publicRouter.registerApi("keys", new KeysHandler(cliAuthorizer));
|
|
87
79
|
}
|
|
88
80
|
if (buildLayer) {
|
|
89
|
-
publicRouter.
|
|
90
|
-
publicRouter.
|
|
81
|
+
publicRouter.registerApi("apps", new AppsHandler(buildLayer));
|
|
82
|
+
publicRouter.register("app", new AppProxyHandler(buildLayer));
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
const internalRouter = new Router();
|
|
94
86
|
internalRouter.register("ping", ping);
|
|
95
|
-
internalRouter.
|
|
96
|
-
internalRouter.
|
|
97
|
-
internalRouter.
|
|
98
|
-
internalRouter.
|
|
99
|
-
if (documents) internalRouter.
|
|
100
|
-
if (secrets) internalRouter.
|
|
101
|
-
if (audits) internalRouter.
|
|
87
|
+
internalRouter.registerApi("jobs", jobs);
|
|
88
|
+
internalRouter.registerApi("queue", queueHandler);
|
|
89
|
+
internalRouter.registerApi("consumers", consumers);
|
|
90
|
+
internalRouter.registerApi("state-machines", stateMachines);
|
|
91
|
+
if (documents) internalRouter.registerApi("documents", documents);
|
|
92
|
+
if (secrets) internalRouter.registerApi("secrets", secrets);
|
|
93
|
+
if (audits) internalRouter.registerApi("audits", audits);
|
|
102
94
|
|
|
103
95
|
const openRequests = (request : Request) =>
|
|
104
96
|
request.url.pathname === "/ping" ||
|
package/src/hosting/Request.ts
CHANGED
|
@@ -14,6 +14,8 @@ class Request {
|
|
|
14
14
|
user? : User;
|
|
15
15
|
tenant? : Tenant;
|
|
16
16
|
|
|
17
|
+
readonly api : boolean;
|
|
18
|
+
|
|
17
19
|
private parsed : URL;
|
|
18
20
|
private segments : Array<string>;
|
|
19
21
|
|
|
@@ -21,7 +23,11 @@ class Request {
|
|
|
21
23
|
rayId : string = randomUUID()) {
|
|
22
24
|
this.rayId = rayId;
|
|
23
25
|
this.parsed = new URL(incoming.url ?? "/", "http://localhost");
|
|
24
|
-
|
|
26
|
+
const raw = this.parsed.pathname.split("/").filter(Boolean);
|
|
27
|
+
// The API lives under /api/v2; strip that prefix so resource/id/subresource
|
|
28
|
+
// address the resource, and flag it so the router serves API handlers only there.
|
|
29
|
+
this.api = raw[0] === "api" && raw[1] === "v2";
|
|
30
|
+
this.segments = this.api ? raw.slice(2) : raw;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
get method() : string {
|
package/src/hosting/Router.ts
CHANGED
|
@@ -1,17 +1,22 @@
|
|
|
1
1
|
import {Request} from "./Request";
|
|
2
2
|
import {RequestHandler} from "./RequestHandler";
|
|
3
3
|
|
|
4
|
-
/* Deliberately hollow: other systems register
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/* Deliberately hollow: other systems register API handlers (served only under
|
|
5
|
+
/api/v2), site handlers (top-level pages/proxies), one root handler, and one
|
|
6
|
+
fallback. All business logic lives in the handlers. */
|
|
7
7
|
class Router {
|
|
8
8
|
|
|
9
|
-
private
|
|
9
|
+
private apiHandlers = new Map<string, RequestHandler>();
|
|
10
|
+
private siteHandlers = new Map<string, RequestHandler>();
|
|
10
11
|
private rootHandler? : RequestHandler;
|
|
11
12
|
private fallbackHandler? : RequestHandler;
|
|
12
13
|
|
|
14
|
+
registerApi(resource : string, handler : RequestHandler) : void {
|
|
15
|
+
this.apiHandlers.set(resource, handler);
|
|
16
|
+
}
|
|
17
|
+
|
|
13
18
|
register(resource : string, handler : RequestHandler) : void {
|
|
14
|
-
this.
|
|
19
|
+
this.siteHandlers.set(resource, handler);
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
registerRoot(handler : RequestHandler) : void {
|
|
@@ -23,12 +28,17 @@ class Router {
|
|
|
23
28
|
}
|
|
24
29
|
|
|
25
30
|
async route(request : Request) : Promise<void> {
|
|
31
|
+
if (request.api) {
|
|
32
|
+
const handler = request.resource === undefined ? undefined : this.apiHandlers.get(request.resource);
|
|
33
|
+
return handler ? handler.handle(request) : request.notFound();
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
if (request.resource === undefined) {
|
|
27
37
|
if (this.rootHandler) return this.rootHandler.handle(request);
|
|
28
38
|
return request.notFound();
|
|
29
39
|
}
|
|
30
40
|
|
|
31
|
-
const handler = this.
|
|
41
|
+
const handler = this.siteHandlers.get(request.resource);
|
|
32
42
|
if (handler) return handler.handle(request);
|
|
33
43
|
if (this.fallbackHandler) return this.fallbackHandler.handle(request);
|
|
34
44
|
|
|
@@ -10,22 +10,24 @@ const HOP_BY_HOP = new Set([
|
|
|
10
10
|
"te", "trailer", "transfer-encoding", "upgrade",
|
|
11
11
|
]);
|
|
12
12
|
|
|
13
|
-
/*
|
|
14
|
-
is
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
/* Serves /app/<name>: the named running app is reverse-proxied to. The
|
|
14
|
+
/app/<name> prefix is stripped before forwarding (the app sees the sub-path)
|
|
15
|
+
and surfaced as X-Forwarded-Prefix so the app can rebuild public URLs.
|
|
16
|
+
Request and response headers pass through both ways - notably cookies,
|
|
17
|
+
Set-Cookie and Location - so sessions and redirects work from app-served
|
|
18
|
+
HTML. */
|
|
19
19
|
class AppProxyHandler implements RequestHandler {
|
|
20
20
|
|
|
21
21
|
constructor(private buildLayer : BuildLayer) {}
|
|
22
22
|
|
|
23
23
|
async handle(request : Request) : Promise<void> {
|
|
24
|
-
|
|
24
|
+
await this.buildLayer.ensureHydrated();
|
|
25
|
+
const appName = request.id;
|
|
26
|
+
if (!appName) return request.notFound();
|
|
25
27
|
const app = this.buildLayer.status(appName);
|
|
26
28
|
if (!app || app.status !== "running") return request.notFound();
|
|
27
29
|
|
|
28
|
-
const appPath = request.url.pathname.slice(
|
|
30
|
+
const appPath = request.url.pathname.slice(`/app/${appName}`.length) || "/";
|
|
29
31
|
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.rawBody();
|
|
30
32
|
|
|
31
33
|
await new Promise<void>((resolve, reject) => {
|
|
@@ -50,7 +52,7 @@ class AppProxyHandler implements RequestHandler {
|
|
|
50
52
|
private forwardHeaders(request : Request, appName : string) : OutgoingHttpHeaders {
|
|
51
53
|
const headers = this.passThrough(request.raw.headers);
|
|
52
54
|
delete headers.host;
|
|
53
|
-
headers["x-forwarded-prefix"] =
|
|
55
|
+
headers["x-forwarded-prefix"] = `/app/${appName}`;
|
|
54
56
|
headers["x-forwarded-host"] = request.raw.headers.host;
|
|
55
57
|
headers["x-forwarded-proto"] = "https";
|
|
56
58
|
return headers;
|
|
@@ -18,7 +18,7 @@ class PluginsHandler implements RequestHandler {
|
|
|
18
18
|
private manifest(request : Request) : void {
|
|
19
19
|
request.reply(200, this.plugins.map(({ name, plugin }) => ({
|
|
20
20
|
name,
|
|
21
|
-
bundle: `/plugins/${name}.js`,
|
|
21
|
+
bundle: `/api/v2/plugins/${name}.js`,
|
|
22
22
|
pages: plugin.pages,
|
|
23
23
|
widgets: plugin.widgets.map(({ page, id, title, position, data }) =>
|
|
24
24
|
({ page, id, title, position, hasData: data !== undefined })),
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {Authenticator} from "../../auth/Authenticator";
|
|
2
|
+
import {SessionSigner} from "../../auth/SessionSigner";
|
|
2
3
|
import {TokenAuthenticator} from "../../auth/TokenAuthenticator";
|
|
3
4
|
import {Middleware} from "../Middleware";
|
|
4
5
|
import {Request} from "../Request";
|
|
@@ -6,15 +7,18 @@ import {Request} from "../Request";
|
|
|
6
7
|
type OpenRequestPredicate = (request : Request) => boolean;
|
|
7
8
|
|
|
8
9
|
/* Decorates the request with its authenticated user and tenant - bearer
|
|
9
|
-
tokens first,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
tokens first, then a valid platform session cookie, then the authenticator -
|
|
11
|
+
and enforces authorization. A valid platform session short-circuits the
|
|
12
|
+
authenticator entirely (no identity-provider round-trip) and slides its
|
|
13
|
+
expiry; a fresh authenticator login mints one. Requests matching the open
|
|
14
|
+
predicate (ping, the one-time CLI key poll) pass through untouched, as does
|
|
15
|
+
everything when no authenticator is configured. */
|
|
13
16
|
class AuthenticationMiddleware implements Middleware {
|
|
14
17
|
|
|
15
18
|
constructor(private authenticator? : Authenticator,
|
|
16
19
|
private tokenAuthenticator? : TokenAuthenticator,
|
|
17
|
-
private isOpen : OpenRequestPredicate = () => false
|
|
20
|
+
private isOpen : OpenRequestPredicate = () => false,
|
|
21
|
+
private sessionSigner : SessionSigner = new SessionSigner()) {}
|
|
18
22
|
|
|
19
23
|
async apply(request : Request) : Promise<boolean> {
|
|
20
24
|
if (this.isOpen(request)) return true;
|
|
@@ -26,12 +30,24 @@ class AuthenticationMiddleware implements Middleware {
|
|
|
26
30
|
return this.authorized(request);
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
if (this.sessionSigner.configured) {
|
|
34
|
+
const session = this.sessionSigner.verify(request.session);
|
|
35
|
+
if (session) {
|
|
36
|
+
[request.user, request.tenant] = session;
|
|
37
|
+
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
38
|
+
return this.authorized(request);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
29
42
|
if (!this.authenticator) return true;
|
|
30
43
|
|
|
31
44
|
const authenticated = await this.authenticator.authenticate(request.session, request.raw, request.rawResponse);
|
|
32
45
|
if (!authenticated) return false;
|
|
33
46
|
|
|
34
47
|
[request.user, request.tenant] = authenticated;
|
|
48
|
+
if (this.sessionSigner.configured && !request.handled) {
|
|
49
|
+
this.sessionSigner.issue(request.rawResponse, request.user!, request.tenant);
|
|
50
|
+
}
|
|
35
51
|
return this.authorized(request);
|
|
36
52
|
}
|
|
37
53
|
|