@adobe/helix-config 2.8.0 → 2.9.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [2.9.0](https://github.com/adobe/helix-config/compare/v2.8.0...v2.9.0) (2024-04-25)
2
+
3
+
4
+ ### Features
5
+
6
+ * add token support ([d8e25f5](https://github.com/adobe/helix-config/commit/d8e25f58ae8b083f9fa2970d84e5ab6f03c26372)), closes [#42](https://github.com/adobe/helix-config/issues/42)
7
+
1
8
  # [2.8.0](https://github.com/adobe/helix-config/compare/v2.7.1...v2.8.0) (2024-04-24)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "2.8.0",
3
+ "version": "2.9.0",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -72,12 +72,26 @@ export function canonicalArrayString(root, partition, prop) {
72
72
  /**
73
73
  * Returns the normalized access configuration for the give partition.
74
74
  */
75
- export function getAccessConfig(access, partition) {
76
- return {
75
+ export function getAccessConfig(config, partition) {
76
+ const { access, tokens = {} } = config;
77
+ const apiKeyId = toArray(access[partition]?.apiKeyId ?? access.apiKeyId);
78
+ const cfg = {
77
79
  allow: toArray(access[partition]?.allow ?? access.allow),
78
- apiKeyId: toArray(access[partition]?.apiKeyId ?? access.apiKeyId),
80
+ apiKeyId,
81
+ tokenHash: apiKeyId.map((id) => tokens[id]?.hash).filter((hash) => !!hash),
79
82
  clientCertDN: toArray(access[partition]?.clientCertDN ?? access.clientCertDN),
80
83
  };
84
+ // if an allow is defined but no apiKeyId, create a fake one so that auth is still
85
+ // enforced. later we can remove the allow and the apiKeyId in favor of the tokenHash
86
+ if (cfg.allow.length && !cfg.apiKeyId.length) {
87
+ cfg.apiKeyId.push('fake');
88
+ }
89
+ // if an apiKeyId is defined but no tokenHash, create a fake one so that auth is still
90
+ // enforced.
91
+ if (cfg.apiKeyId.length && !cfg.tokenHash.length) {
92
+ cfg.tokenHash.push('n/a');
93
+ }
94
+ return cfg;
81
95
  }
82
96
 
83
97
  /**
@@ -211,8 +225,8 @@ export async function getConfigResponse(ctx, opts) {
211
225
  // normalize access config
212
226
  const { admin = {} } = config.access;
213
227
  config.access = {
214
- preview: getAccessConfig(config.access, 'preview'),
215
- live: getAccessConfig(config.access, 'live'),
228
+ preview: getAccessConfig(config, 'preview'),
229
+ live: getAccessConfig(config, 'live'),
216
230
  // access.require.repository ?
217
231
  };
218
232
  if (opts.scope === SCOPE_ADMIN || opts.scope === SCOPE_RAW) {
@@ -241,13 +255,20 @@ export async function getConfigResponse(ctx, opts) {
241
255
  'x-hlx-auth-allow-preview': canonicalArrayString(config.access, 'preview', 'allow'),
242
256
  'x-hlx-auth-apikey-preview': canonicalArrayString(config.access, 'preview', 'apiKeyId'),
243
257
  'x-hlx-auth-clientdn-preview': canonicalArrayString(config.access, 'preview', 'clientCertDN'),
258
+ 'x-hlx-auth-hash-preview': canonicalArrayString(config.access, 'preview', 'tokenHash'),
244
259
  'x-hlx-auth-allow-live': canonicalArrayString(config.access, 'live', 'allow'),
245
260
  'x-hlx-auth-apikey-live': canonicalArrayString(config.access, 'live', 'apiKeyId'),
246
261
  'x-hlx-auth-clientdn-live': canonicalArrayString(config.access, 'live', 'clientCertDN'),
262
+ 'x-hlx-auth-hash-live': canonicalArrayString(config.access, 'live', 'tokenHash'),
247
263
  },
248
264
  });
249
265
  }
250
266
 
267
+ // delete token hashes
268
+ delete config.tokens;
269
+ delete config.access?.preview?.tokenHash;
270
+ delete config.access?.live?.tokenHash;
271
+
251
272
  if (opts.scope === SCOPE_ADMIN) {
252
273
  const adminConfig = {
253
274
  ...rso,
@@ -288,7 +309,6 @@ export async function getConfigResponse(ctx, opts) {
288
309
  repo: config.code.repo,
289
310
  ...rso,
290
311
  contentBusId: config.content.contentBusId,
291
- access: config.access,
292
312
  headers: config.headers,
293
313
  head: config.head,
294
314
  metadata: config.metadata,
@@ -15,15 +15,8 @@
15
15
  "title": "Site Access Config",
16
16
  "type": "object",
17
17
  "properties": {
18
- "allow": {
19
- "description": "The email glob of the users that are allowed.",
20
- "type": "array",
21
- "items": {
22
- "type": "string"
23
- }
24
- },
25
18
  "apiKeyId": {
26
- "description": "IDs of the api keys that are allowed.",
19
+ "description": "IDs of the api keys (tokens) that are allowed.",
27
20
  "type": "array",
28
21
  "items": {
29
22
  "type": "string"
@@ -49,6 +49,9 @@
49
49
  },
50
50
  "robots": {
51
51
  "$ref": "https://ns.adobe.com/helix/config/robots"
52
+ },
53
+ "tokens": {
54
+ "$ref": "https://ns.adobe.com/helix/config/tokens"
52
55
  }
53
56
  },
54
57
  "required": [
@@ -54,6 +54,9 @@
54
54
  },
55
55
  "robots": {
56
56
  "$ref": "https://ns.adobe.com/helix/config/robots"
57
+ },
58
+ "tokens": {
59
+ "$ref": "https://ns.adobe.com/helix/config/tokens"
57
60
  }
58
61
  },
59
62
  "required": [
@@ -0,0 +1,12 @@
1
+ /*
2
+ * Copyright 2024 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ module.exports = require('./tokens.schema.json');
@@ -0,0 +1,38 @@
1
+ {
2
+ "meta:license": [
3
+ "Copyright 2024 Adobe. All rights reserved.",
4
+ "This file is licensed to you under the Apache License, Version 2.0 (the \"License\");",
5
+ "you may not use this file except in compliance with the License. You may obtain a copy",
6
+ "of the License at http://www.apache.org/licenses/LICENSE-2.0",
7
+ "",
8
+ "Unless required by applicable law or agreed to in writing, software distributed under",
9
+ "the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS",
10
+ "OF ANY KIND, either express or implied. See the License for the specific language",
11
+ "governing permissions and limitations under the License."
12
+ ],
13
+ "$schema": "http://json-schema.org/draft-07/schema#",
14
+ "$id": "https://ns.adobe.com/helix/config/tokens",
15
+ "title": "tokens",
16
+ "type": "object",
17
+ "patternProperties": {
18
+ "^[a-zA-Z0-9-_=]+$": {
19
+ "type": "object",
20
+ "properties": {
21
+ "id": {
22
+ "type": "string",
23
+ "pattern": "^[a-zA-Z0-9-_=]+$"
24
+ },
25
+ "hash": {
26
+ "type": "string",
27
+ "pattern": "^[a-zA-Z0-9-_=]+$"
28
+ },
29
+ "created": {
30
+ "type": "string",
31
+ "format": "date-time"
32
+ }
33
+ },
34
+ "additionalProperties": false
35
+ }
36
+ },
37
+ "additionalProperties": false
38
+ }
@@ -9,9 +9,11 @@
9
9
  * OF ANY KIND, either express or implied. See the License for the specific language
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
+ /* eslint-disable no-param-reassign */
12
13
  import { HelixStorage } from './storage.js';
13
14
  import { StatusCodeError } from './status-code-error.js';
14
15
  import {
16
+ createToken,
15
17
  jsonGet,
16
18
  jsonPut,
17
19
  updateCodeSource,
@@ -19,27 +21,111 @@ import {
19
21
  } from './utils.js';
20
22
  import { validate } from './config-validator.js';
21
23
 
22
- const FRAGMENTS = {
23
- sites: {
24
- content: 'object',
25
- code: 'object',
26
- folders: 'object',
27
- headers: 'object',
28
- metadata: 'object',
29
- sidekick: 'object',
30
- cdn: 'object',
31
- 'cdn/prod': 'object',
32
- 'cdn/preview': 'object',
33
- 'cdn/live': 'object',
34
- access: 'object',
35
- 'access/admin': 'object',
36
- 'access/preview': 'object',
37
- 'access/live': 'object',
38
- public: 'object',
39
- robots: 'object',
24
+ const FRAGMENTS_COMMON = {
25
+ content: 'object',
26
+ code: 'object',
27
+ folders: 'object',
28
+ headers: 'object',
29
+ metadata: 'object',
30
+ sidekick: 'object',
31
+ cdn: {
32
+ '.': 'object',
33
+ prod: 'object',
34
+ preview: 'object',
35
+ live: 'object',
36
+ },
37
+ access: {
38
+ '.': 'object',
39
+ admin: 'object',
40
+ preview: 'object',
41
+ live: 'object',
42
+ },
43
+ public: 'object',
44
+ robots: 'object',
45
+ tokens: {
46
+ '.': 'tokens',
47
+ '.param': {
48
+ name: 'id',
49
+ '.': 'token',
50
+ },
40
51
  },
41
52
  };
42
53
 
54
+ const FRAGMENTS = {
55
+ sites: FRAGMENTS_COMMON,
56
+ profiles: FRAGMENTS_COMMON,
57
+ };
58
+
59
+ export function getFragmentInfo(type, relPath) {
60
+ if (!relPath) {
61
+ return null;
62
+ }
63
+ const parts = relPath.split('/');
64
+ let fragment = FRAGMENTS[type];
65
+ const info = {
66
+ relPath,
67
+ };
68
+ for (const part of parts) {
69
+ let next = fragment[part];
70
+ if (!next) {
71
+ next = fragment['.param'];
72
+ if (!next) {
73
+ throw new StatusCodeError(400, 'invalid object path.');
74
+ }
75
+ info[next.name] = part;
76
+ }
77
+ fragment = next;
78
+ }
79
+ if (typeof fragment === 'string') {
80
+ info.type = fragment;
81
+ } else {
82
+ info.type = fragment['.'];
83
+ }
84
+ return info;
85
+ }
86
+
87
+ /**
88
+ * Redact / transform the token config
89
+ * @param token
90
+ */
91
+ function redactToken(token) {
92
+ return {
93
+ id: token.id,
94
+ created: token.created,
95
+ };
96
+ }
97
+
98
+ /**
99
+ * Redact / transform the tokens config
100
+ * @param tokens
101
+ */
102
+ function redactTokens(tokens) {
103
+ return Object.values(tokens).map(redactToken);
104
+ }
105
+
106
+ /**
107
+ * redact information from the config
108
+ * @param {object} config
109
+ * @param {object} frag
110
+ */
111
+ function redact(config, frag) {
112
+ if (!config) {
113
+ return config;
114
+ }
115
+ let ret = config;
116
+ if (frag?.type === 'tokens') {
117
+ ret = redactTokens(config);
118
+ }
119
+ if (frag?.type === 'token') {
120
+ ret = redactToken(config);
121
+ }
122
+ if (ret.tokens) {
123
+ // eslint-disable-next-line no-param-reassign
124
+ ret.tokens = redactTokens(ret.tokens);
125
+ }
126
+ return ret;
127
+ }
128
+
43
129
  /**
44
130
  * General purpose config store.
45
131
  */
@@ -86,42 +172,54 @@ export class ConfigStore {
86
172
  return null;
87
173
  }
88
174
  let obj = JSON.parse(buf);
89
- if (relPath) {
90
- const fragment = FRAGMENTS[this.type][relPath];
91
- if (!fragment) {
92
- throw new StatusCodeError(400, 'invalid object path.');
93
- }
94
- obj = jsonGet(obj, relPath);
175
+ const frag = getFragmentInfo(this.type, relPath);
176
+ if (frag) {
177
+ obj = jsonGet(obj, frag.relPath);
95
178
  }
96
- return obj;
179
+ return redact(obj, frag);
97
180
  }
98
181
 
99
182
  async update(ctx, data, relPath = '') {
100
183
  const storage = HelixStorage.fromContext(ctx).configBus();
101
184
  const buf = await storage.get(this.key);
102
185
  const old = buf ? JSON.parse(buf) : null;
103
- if (relPath) {
186
+ const frag = getFragmentInfo(this.type, relPath);
187
+ let config = data;
188
+ let ret = null;
189
+ if (frag) {
104
190
  if (!old) {
105
191
  throw new StatusCodeError(404, 'config not found.');
106
192
  }
107
- const fragment = FRAGMENTS[this.type][relPath];
108
- if (!fragment) {
109
- throw new StatusCodeError(400, 'invalid object path.');
110
- }
111
193
  if (relPath === 'code') {
112
194
  updateCodeSource(ctx, data);
113
195
  } else if (relPath === 'content') {
114
196
  updateContentSource(ctx, data);
197
+ } else if (frag.type === 'token') {
198
+ // don't allow to update individual token
199
+ throw new StatusCodeError(400, 'invalid object path.');
200
+ } else if (frag.type === 'tokens') {
201
+ // create new token
202
+ const token = createToken(this.org);
203
+ data = {
204
+ ...token,
205
+ };
206
+ // don't store token value in the config
207
+ delete data.value;
208
+ relPath = `tokens/${token.id}`;
209
+ frag.type = 'token';
210
+ ret = token;
211
+ // don't expose hash in return value
212
+ delete ret.hash;
115
213
  }
116
- // eslint-disable-next-line no-param-reassign
117
- data = jsonPut(JSON.parse(buf), relPath, data);
214
+ config = jsonPut(old, relPath, data);
118
215
  } else {
119
- updateContentSource(ctx, data.content);
120
- updateCodeSource(ctx, data.code);
216
+ updateContentSource(ctx, config.content);
217
+ updateCodeSource(ctx, config.code);
121
218
  }
122
- await validate(data, this.type);
123
- await storage.put(this.key, JSON.stringify(data), 'application/json');
124
- await this.purge(ctx, old, data);
219
+ await validate(config, this.type);
220
+ await storage.put(this.key, JSON.stringify(config), 'application/json');
221
+ await this.purge(ctx, buf ? JSON.parse(buf) : null, config);
222
+ return ret ?? redact(data, frag);
125
223
  }
126
224
 
127
225
  async remove(ctx, relPath) {
@@ -130,11 +228,8 @@ export class ConfigStore {
130
228
  if (!buf) {
131
229
  throw new StatusCodeError(404, 'config not found.');
132
230
  }
133
- if (relPath) {
134
- const fragment = FRAGMENTS[this.type][relPath];
135
- if (!fragment) {
136
- throw new StatusCodeError(400, 'invalid object path.');
137
- }
231
+ const frag = getFragmentInfo(this.type, relPath);
232
+ if (frag) {
138
233
  const data = jsonPut(JSON.parse(buf), relPath, null);
139
234
  await validate(data, this.type);
140
235
  await storage.put(this.key, JSON.stringify(data), 'application/json');
@@ -29,6 +29,7 @@ import profileSchema from '../schemas/profile.schema.cjs';
29
29
  import robotsSchema from '../schemas/robots.schema.cjs';
30
30
  import sidekickSchema from '../schemas/sidekick.schema.cjs';
31
31
  import siteSchema from '../schemas/site.schema.cjs';
32
+ import tokensSchema from '../schemas/tokens.schema.cjs';
32
33
 
33
34
  const SCHEMAS = [
34
35
  accessSchema,
@@ -47,6 +48,7 @@ const SCHEMAS = [
47
48
  robotsSchema,
48
49
  sidekickSchema,
49
50
  siteSchema,
51
+ tokensSchema,
50
52
  ];
51
53
 
52
54
  const SCHEMA_TYPES = {
@@ -111,3 +111,26 @@ export function updateCodeSource(ctx, code) {
111
111
  }
112
112
  return modified;
113
113
  }
114
+
115
+ /**
116
+ * Creates a random token and hashes it with the given key
117
+ * @param {string} key
118
+ * @returns {object} the token
119
+ */
120
+ export function createToken(key) {
121
+ const secret = crypto.randomBytes(32).toString('base64url');
122
+ const value = `hlx_${secret}`;
123
+ const hash = crypto
124
+ .createHmac('sha512', key)
125
+ .update(value, 'utf-8')
126
+ .digest()
127
+ .toString('base64url');
128
+ const id = crypto.createHash('sha256').update(hash).digest().toString('base64url');
129
+ const created = new Date().toISOString();
130
+ return {
131
+ id,
132
+ value,
133
+ hash,
134
+ created,
135
+ };
136
+ }
@@ -32,6 +32,7 @@ export interface HelixProfileConfig {
32
32
  sidekick?: SidekickConfig;
33
33
  metadata?: Metadata;
34
34
  robots?: Robots;
35
+ tokens?: Tokens;
35
36
  }
36
37
  /**
37
38
  * Defines the content bus location and source.
@@ -223,11 +224,7 @@ export interface Role {
223
224
  }
224
225
  export interface SiteAccessConfig {
225
226
  /**
226
- * The email glob of the users that are allowed.
227
- */
228
- allow?: string[];
229
- /**
230
- * IDs of the api keys that are allowed.
227
+ * IDs of the api keys (tokens) that are allowed.
231
228
  */
232
229
  apiKeyId?: string[];
233
230
  /**
@@ -251,3 +248,14 @@ export interface Metadata {
251
248
  export interface Robots {
252
249
  txt?: string;
253
250
  }
251
+ export interface Tokens {
252
+ /**
253
+ * This interface was referenced by `Tokens`'s JSON-Schema definition
254
+ * via the `patternProperty` "^[a-zA-Z0-9-_=]+$".
255
+ */
256
+ [k: string]: {
257
+ id?: string;
258
+ hash?: string;
259
+ created?: string;
260
+ };
261
+ }
@@ -35,6 +35,7 @@ export interface HelixSiteConfig {
35
35
  sidekick?: SidekickConfig;
36
36
  metadata?: Metadata;
37
37
  robots?: Robots;
38
+ tokens?: Tokens;
38
39
  }
39
40
  /**
40
41
  * Defines the content bus location and source.
@@ -226,11 +227,7 @@ export interface Role {
226
227
  }
227
228
  export interface SiteAccessConfig {
228
229
  /**
229
- * The email glob of the users that are allowed.
230
- */
231
- allow?: string[];
232
- /**
233
- * IDs of the api keys that are allowed.
230
+ * IDs of the api keys (tokens) that are allowed.
234
231
  */
235
232
  apiKeyId?: string[];
236
233
  /**
@@ -254,3 +251,14 @@ export interface Metadata {
254
251
  export interface Robots {
255
252
  txt?: string;
256
253
  }
254
+ export interface Tokens {
255
+ /**
256
+ * This interface was referenced by `Tokens`'s JSON-Schema definition
257
+ * via the `patternProperty` "^[a-zA-Z0-9-_=]+$".
258
+ */
259
+ [k: string]: {
260
+ id?: string;
261
+ hash?: string;
262
+ created?: string;
263
+ };
264
+ }