@stelstone/server 0.28.0 → 0.29.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": "@stelstone/server",
3
- "version": "0.28.0",
3
+ "version": "0.29.0",
4
4
  "description": "Runtime-agnostic CMS server built on the Web Fetch API, with pluggable adapters for content, media, auth, and build.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * HTTP Basic auth + HMAC-SHA256 JWT for media tokens.
@@ -24,6 +25,8 @@ export function createBasicAuth({
24
25
  users,
25
26
  jwtSecret,
26
27
  jwtTtl,
28
+ mediaTokenTtl,
29
+ mediaKeyVersion,
27
30
  realm = "Admin",
28
31
  }) {
29
32
  // Normalise: prefer `users` array, fall back to single user/pass pair.
@@ -51,23 +54,17 @@ export function createBasicAuth({
51
54
  }
52
55
 
53
56
  function issueMediaToken(tenantId) {
54
- if (!jwtSecret) throw new Error("JWT_SECRET not set");
55
- const header = Buffer.from(
56
- JSON.stringify({ alg: "HS256", typ: "JWT" }),
57
- ).toString("base64url");
58
- const payload = Buffer.from(
59
- JSON.stringify({
60
- sub: user,
61
- tenant_id: tenantId,
62
- iat: Math.floor(Date.now() / 1000),
63
- exp: Math.floor(Date.now() / 1000) + jwtTtl,
64
- }),
65
- ).toString("base64url");
66
- const sig = crypto
67
- .createHmac("sha256", jwtSecret)
68
- .update(`${header}.${payload}`)
69
- .digest("base64url");
70
- return `${header}.${payload}.${sig}`;
57
+ // Derived per tenant, and short-lived — see adapters/media-token.mjs.
58
+ // It used to be signed with the root secret and live as long as an admin
59
+ // session (8h in some configs), which is both wider and longer than an
60
+ // upload needs.
61
+ return mintMediaToken({
62
+ root: jwtSecret,
63
+ tenantId,
64
+ sub: user,
65
+ ttl: mediaTokenTtl,
66
+ keyVersion: mediaKeyVersion,
67
+ });
71
68
  }
72
69
 
73
70
  /**
@@ -1,4 +1,5 @@
1
1
  import crypto from "crypto";
2
+ import { issueMediaToken as mintMediaToken } from "./media-token.mjs";
2
3
 
3
4
  /**
4
5
  * GitHub OAuth adapter — drop-in replacement for createBasicAuth.
@@ -37,6 +38,8 @@ export function createGitHubOAuth({
37
38
  roles = {},
38
39
  jwtSecret,
39
40
  jwtTtl = 8 * 60 * 60,
41
+ mediaTokenTtl,
42
+ mediaKeyVersion,
40
43
  defaultRole = "editor",
41
44
  realm = "Admin",
42
45
  }) {
@@ -143,7 +146,14 @@ export function createGitHubOAuth({
143
146
  /** @param {string} capability */
144
147
  supports: (capability) => ["mediaToken", "session", "oauth"].includes(capability),
145
148
  issueMediaToken(tenantId) {
146
- return issueToken({ sub: "media", tenant_id: tenantId, type: "media" });
149
+ // Not issueToken(): that signs a session with the root secret and the
150
+ // session TTL. A media token is derived per tenant and short-lived.
151
+ return mintMediaToken({
152
+ root: jwtSecret,
153
+ tenantId,
154
+ ttl: mediaTokenTtl,
155
+ keyVersion: mediaKeyVersion,
156
+ });
147
157
  },
148
158
  verify,
149
159
  issueSessionToken,
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The token the admin sends to the media CDN when it uploads.
3
+ *
4
+ * It is signed with a key DERIVED from the CDN root secret, not with the root
5
+ * itself:
6
+ *
7
+ * key = HMAC-SHA256(root, "jwt:v1:<tenant_id>:<keyVersion>")
8
+ *
9
+ * The CDN derives the same key from its own copy of the root and compares. Two
10
+ * things follow, and both are the point:
11
+ *
12
+ * - The tenant id is bound into the key. A token minted for tenant A cannot
13
+ * be replayed as tenant B: changing the claim selects a key the holder
14
+ * cannot produce.
15
+ * - Rotating one tenant is a counter bump on that tenant's row, with a grace
16
+ * window in which the previous version still verifies. Nothing else has to
17
+ * be redeployed.
18
+ *
19
+ * Signing with the raw root — which is what this used to do — worked only
20
+ * because the CDN still accepts it (LEGACY_JWT). That path treats the root as
21
+ * a master key for every tenant at once, so it is being retired.
22
+ *
23
+ * `keyVersion` has to match what the CDN holds for the tenant. It defaults to
24
+ * 1, which is where a tenant starts; after a `tenants.mjs rotate-keys`, raise
25
+ * it here inside the grace window.
26
+ */
27
+ import crypto from "node:crypto";
28
+
29
+ /** Must match KEY_PURPOSE_JWT in the CDN's lambda/auth.mjs. */
30
+ const KEY_PURPOSE_JWT = "jwt";
31
+
32
+ /** Ten minutes. An upload takes seconds; the token has no reason to outlive it. */
33
+ export const DEFAULT_MEDIA_TOKEN_TTL = 600;
34
+
35
+ /**
36
+ * @param {string} root the CDN root secret
37
+ * @param {string} tenantId
38
+ * @param {number} keyVersion
39
+ */
40
+ export function deriveMediaKey(root, tenantId, keyVersion) {
41
+ return crypto
42
+ .createHmac("sha256", root)
43
+ .update(`${KEY_PURPOSE_JWT}:v1:${tenantId}:${keyVersion}`)
44
+ .digest();
45
+ }
46
+
47
+ /**
48
+ * @param {Object} opts
49
+ * @param {string} opts.root the CDN root secret (JWT_SECRET)
50
+ * @param {string} opts.tenantId
51
+ * @param {string} [opts.sub] who is uploading, for the CDN's logs
52
+ * @param {number} [opts.ttl] seconds; kept short on purpose
53
+ * @param {number} [opts.keyVersion]
54
+ * @returns {string} a compact JWS
55
+ */
56
+ export function issueMediaToken({ root, tenantId, sub = "media", ttl, keyVersion = 1 }) {
57
+ if (!root) throw new Error("JWT_SECRET not set");
58
+ if (!tenantId) throw new Error("media.tenantId not set — a media token names one tenant");
59
+
60
+ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
61
+ const now = Math.floor(Date.now() / 1000);
62
+
63
+ // `kid` is not what the CDN selects on — it tries the tenant's active
64
+ // versions — but it makes a rejected token readable in a log.
65
+ const header = b64({ alg: "HS256", typ: "JWT", kid: `${tenantId}.v${keyVersion}` });
66
+ const payload = b64({
67
+ sub,
68
+ tenant_id: tenantId,
69
+ type: "media",
70
+ iat: now,
71
+ exp: now + (ttl ?? DEFAULT_MEDIA_TOKEN_TTL),
72
+ });
73
+
74
+ const key = deriveMediaKey(root, tenantId, keyVersion);
75
+ const sig = crypto.createHmac("sha256", key).update(`${header}.${payload}`).digest("base64url");
76
+ return `${header}.${payload}.${sig}`;
77
+ }
@@ -93,6 +93,18 @@ export function githubTemplatesOptions(config, secrets) {
93
93
  * @param {SecretLookup} secrets
94
94
  * @returns {{ provider: "basic"|"github-oauth"|"cloudflare-access", options: Object }}
95
95
  */
96
+ /**
97
+ * How the admin's media tokens are signed. These live under `media` rather
98
+ * than `auth` because they describe the CDN tenant, not the login: the key is
99
+ * derived from the tenant id and the version the CDN holds for it.
100
+ */
101
+ function mediaTokenOptions(config) {
102
+ return {
103
+ mediaTokenTtl: config.media?.tokenTtl,
104
+ mediaKeyVersion: config.media?.keyVersion ?? 1,
105
+ };
106
+ }
107
+
96
108
  export function authOptions(config, secrets) {
97
109
  const auth = config.auth ?? {};
98
110
  const provider = auth.provider ?? "basic";
@@ -108,6 +120,7 @@ export function authOptions(config, secrets) {
108
120
  defaultRole: auth.defaultRole,
109
121
  jwtSecret: secrets(auth.jwtSecretEnv),
110
122
  jwtTtl: auth.jwtTtl,
123
+ ...mediaTokenOptions(config),
111
124
  },
112
125
  };
113
126
  }
@@ -135,6 +148,7 @@ export function authOptions(config, secrets) {
135
148
  })),
136
149
  jwtSecret: secrets(auth.jwtSecretEnv),
137
150
  jwtTtl: auth.jwtTtl,
151
+ ...mediaTokenOptions(config),
138
152
  },
139
153
  };
140
154
  }
@@ -379,6 +379,21 @@ function checkMisc(config, report) {
379
379
  if (config.media !== undefined) {
380
380
  if (!isPlainObject(config.media)) report.error("media", "must be an object");
381
381
  else if (!config.media.cdnBase) report.error("media.cdnBase", "is required when media is configured");
382
+ else {
383
+ const { tokenTtl, keyVersion } = config.media;
384
+ if (tokenTtl !== undefined && (!Number.isInteger(tokenTtl) || tokenTtl < 1)) {
385
+ report.error("media.tokenTtl", "must be a positive integer (seconds)");
386
+ }
387
+ // An upload takes seconds. A long-lived media token is a credential
388
+ // sitting in a browser for no reason, and the CDN may refuse it outright
389
+ // when MAX_TOKEN_TTL is set below this.
390
+ if (Number.isInteger(tokenTtl) && tokenTtl > 3600) {
391
+ report.warn("media.tokenTtl", `${tokenTtl}s is long for an upload — the CDN may cap it`);
392
+ }
393
+ if (keyVersion !== undefined && (!Number.isInteger(keyVersion) || keyVersion < 1)) {
394
+ report.error("media.keyVersion", "must be a positive integer — the CDN's key version for this tenant");
395
+ }
396
+ }
382
397
  }
383
398
 
384
399
  if (config.previewUrl !== undefined && typeof config.previewUrl !== "function") {
package/src/version.mjs CHANGED
@@ -5,4 +5,4 @@
5
5
  * require() and no import.meta.url, so reading the manifest at runtime yields
6
6
  * "unknown" there.
7
7
  */
8
- export const SERVER_VERSION = "0.28.0";
8
+ export const SERVER_VERSION = "0.29.0";