@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,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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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