anbaric-cloud-hosting 1.17.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "anbaric-cloud-hosting",
3
- "version": "1.17.0",
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.17.0",
22
- "anbaric-plugins": "^1.17.0",
23
- "anbaric-tsapi": "^1.17.0",
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.17.0",
31
- "anbaric-state-machine": "^1.17.0",
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 }
@@ -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, sessions otherwise - and enforces authorization. Requests
10
- matching the open predicate (ping, the one-time CLI key poll) pass
11
- through untouched, as does everything when no authenticator is
12
- configured. */
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
 
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export * from "./auth/InMemoryCliKeyStore";
11
11
  export * from "./auth/AuthenticatorLoader";
12
12
  export * from "./auth/KeyPair";
13
13
  export * from "./auth/Role";
14
+ export * from "./auth/SessionSigner";
14
15
  export * from "./auth/StubAuthenticator";
15
16
  export * from "./auth/Tenant";
16
17
  export * from "./auth/TokenAuthenticator";