@adobe/helix-config 2.8.0 → 2.10.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.
@@ -0,0 +1 @@
1
+ npx lint-staged
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [2.10.0](https://github.com/adobe/helix-config/compare/v2.9.0...v2.10.0) (2024-04-26)
2
+
3
+
4
+ ### Features
5
+
6
+ * add support to migrate jwt api keys ([#59](https://github.com/adobe/helix-config/issues/59)) ([04adf04](https://github.com/adobe/helix-config/commit/04adf04c975ee38840a56f81aac8a1609959298a))
7
+
8
+ # [2.9.0](https://github.com/adobe/helix-config/compare/v2.8.0...v2.9.0) (2024-04-25)
9
+
10
+
11
+ ### Features
12
+
13
+ * add token support ([d8e25f5](https://github.com/adobe/helix-config/commit/d8e25f58ae8b083f9fa2970d84e5ab6f03c26372)), closes [#42](https://github.com/adobe/helix-config/issues/42)
14
+
1
15
  # [2.8.0](https://github.com/adobe/helix-config/compare/v2.7.1...v2.8.0) (2024-04-24)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-config",
3
- "version": "2.8.0",
3
+ "version": "2.10.0",
4
4
  "description": "Helix Config",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -15,7 +15,7 @@
15
15
  "docs:types": "node ./test/dev/generate-types.js",
16
16
  "semantic-release": "semantic-release",
17
17
  "semantic-release-dry": "semantic-release --dry-run --branches $CI_BRANCH",
18
- "prepare": "husky install"
18
+ "prepare": "husky"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
@@ -68,6 +68,7 @@
68
68
  "@smithy/node-http-handler": "2.5.0",
69
69
  "ajv": "8.12.0",
70
70
  "ajv-formats": "3.0.1",
71
+ "jose": "5.2.4",
71
72
  "mime": "4.0.1"
72
73
  }
73
74
  }
@@ -72,12 +72,30 @@ 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
82
+ // token ids are always stored in base64url format, but legacy apiKeyIds are not
83
+ .map((jti) => jti.replaceAll('/', '_').replaceAll('+', '-'))
84
+ .map((id) => tokens[id]?.hash)
85
+ .filter((hash) => !!hash),
79
86
  clientCertDN: toArray(access[partition]?.clientCertDN ?? access.clientCertDN),
80
87
  };
88
+ // if an allow is defined but no apiKeyId, create a fake one so that auth is still
89
+ // enforced. later we can remove the allow and the apiKeyId in favor of the tokenHash
90
+ if (cfg.allow.length && !cfg.apiKeyId.length) {
91
+ cfg.apiKeyId.push('fake');
92
+ }
93
+ // if an apiKeyId is defined but no tokenHash, create a fake one so that auth is still
94
+ // enforced.
95
+ if (cfg.apiKeyId.length && !cfg.tokenHash.length) {
96
+ cfg.tokenHash.push('n/a');
97
+ }
98
+ return cfg;
81
99
  }
82
100
 
83
101
  /**
@@ -211,8 +229,8 @@ export async function getConfigResponse(ctx, opts) {
211
229
  // normalize access config
212
230
  const { admin = {} } = config.access;
213
231
  config.access = {
214
- preview: getAccessConfig(config.access, 'preview'),
215
- live: getAccessConfig(config.access, 'live'),
232
+ preview: getAccessConfig(config, 'preview'),
233
+ live: getAccessConfig(config, 'live'),
216
234
  // access.require.repository ?
217
235
  };
218
236
  if (opts.scope === SCOPE_ADMIN || opts.scope === SCOPE_RAW) {
@@ -241,13 +259,20 @@ export async function getConfigResponse(ctx, opts) {
241
259
  'x-hlx-auth-allow-preview': canonicalArrayString(config.access, 'preview', 'allow'),
242
260
  'x-hlx-auth-apikey-preview': canonicalArrayString(config.access, 'preview', 'apiKeyId'),
243
261
  'x-hlx-auth-clientdn-preview': canonicalArrayString(config.access, 'preview', 'clientCertDN'),
262
+ 'x-hlx-auth-hash-preview': canonicalArrayString(config.access, 'preview', 'tokenHash'),
244
263
  'x-hlx-auth-allow-live': canonicalArrayString(config.access, 'live', 'allow'),
245
264
  'x-hlx-auth-apikey-live': canonicalArrayString(config.access, 'live', 'apiKeyId'),
246
265
  'x-hlx-auth-clientdn-live': canonicalArrayString(config.access, 'live', 'clientCertDN'),
266
+ 'x-hlx-auth-hash-live': canonicalArrayString(config.access, 'live', 'tokenHash'),
247
267
  },
248
268
  });
249
269
  }
250
270
 
271
+ // delete token hashes
272
+ delete config.tokens;
273
+ delete config.access?.preview?.tokenHash;
274
+ delete config.access?.live?.tokenHash;
275
+
251
276
  if (opts.scope === SCOPE_ADMIN) {
252
277
  const adminConfig = {
253
278
  ...rso,
@@ -288,7 +313,6 @@ export async function getConfigResponse(ctx, opts) {
288
313
  repo: config.code.repo,
289
314
  ...rso,
290
315
  contentBusId: config.content.contentBusId,
291
- access: config.access,
292
316
  headers: config.headers,
293
317
  head: config.head,
294
318
  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,37 +9,124 @@
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,
19
+ migrateToken,
17
20
  updateCodeSource,
18
21
  updateContentSource,
19
22
  } from './utils.js';
20
23
  import { validate } from './config-validator.js';
21
24
 
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',
25
+ const FRAGMENTS_COMMON = {
26
+ content: 'object',
27
+ code: 'object',
28
+ folders: 'object',
29
+ headers: 'object',
30
+ metadata: 'object',
31
+ sidekick: 'object',
32
+ cdn: {
33
+ '.': 'object',
34
+ prod: 'object',
35
+ preview: 'object',
36
+ live: 'object',
37
+ },
38
+ access: {
39
+ '.': 'object',
40
+ admin: 'object',
41
+ preview: 'object',
42
+ live: 'object',
43
+ },
44
+ public: 'object',
45
+ robots: 'object',
46
+ tokens: {
47
+ '.': 'tokens',
48
+ '.param': {
49
+ name: 'id',
50
+ '.': 'token',
51
+ },
40
52
  },
41
53
  };
42
54
 
55
+ const FRAGMENTS = {
56
+ sites: FRAGMENTS_COMMON,
57
+ profiles: FRAGMENTS_COMMON,
58
+ };
59
+
60
+ export function getFragmentInfo(type, relPath) {
61
+ if (!relPath) {
62
+ return null;
63
+ }
64
+ const parts = relPath.split('/');
65
+ let fragment = FRAGMENTS[type];
66
+ const info = {
67
+ relPath,
68
+ };
69
+ for (const part of parts) {
70
+ let next = fragment[part];
71
+ if (!next) {
72
+ next = fragment['.param'];
73
+ if (!next) {
74
+ throw new StatusCodeError(400, 'invalid object path.');
75
+ }
76
+ info[next.name] = part;
77
+ }
78
+ fragment = next;
79
+ }
80
+ if (typeof fragment === 'string') {
81
+ info.type = fragment;
82
+ } else {
83
+ info.type = fragment['.'];
84
+ }
85
+ return info;
86
+ }
87
+
88
+ /**
89
+ * Redact / transform the token config
90
+ * @param token
91
+ */
92
+ function redactToken(token) {
93
+ return {
94
+ id: token.id,
95
+ created: token.created,
96
+ };
97
+ }
98
+
99
+ /**
100
+ * Redact / transform the tokens config
101
+ * @param tokens
102
+ */
103
+ function redactTokens(tokens) {
104
+ return Object.values(tokens).map(redactToken);
105
+ }
106
+
107
+ /**
108
+ * redact information from the config
109
+ * @param {object} config
110
+ * @param {object} frag
111
+ */
112
+ function redact(config, frag) {
113
+ if (!config) {
114
+ return config;
115
+ }
116
+ let ret = config;
117
+ if (frag?.type === 'tokens') {
118
+ ret = redactTokens(config);
119
+ }
120
+ if (frag?.type === 'token') {
121
+ ret = redactToken(config);
122
+ }
123
+ if (ret.tokens) {
124
+ // eslint-disable-next-line no-param-reassign
125
+ ret.tokens = redactTokens(ret.tokens);
126
+ }
127
+ return ret;
128
+ }
129
+
43
130
  /**
44
131
  * General purpose config store.
45
132
  */
@@ -86,42 +173,61 @@ export class ConfigStore {
86
173
  return null;
87
174
  }
88
175
  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);
176
+ const frag = getFragmentInfo(this.type, relPath);
177
+ if (frag) {
178
+ obj = jsonGet(obj, frag.relPath);
95
179
  }
96
- return obj;
180
+ return redact(obj, frag);
97
181
  }
98
182
 
99
183
  async update(ctx, data, relPath = '') {
100
184
  const storage = HelixStorage.fromContext(ctx).configBus();
101
185
  const buf = await storage.get(this.key);
102
186
  const old = buf ? JSON.parse(buf) : null;
103
- if (relPath) {
187
+ const frag = getFragmentInfo(this.type, relPath);
188
+ let config = data;
189
+ let ret = null;
190
+ if (frag) {
104
191
  if (!old) {
105
192
  throw new StatusCodeError(404, 'config not found.');
106
193
  }
107
- const fragment = FRAGMENTS[this.type][relPath];
108
- if (!fragment) {
109
- throw new StatusCodeError(400, 'invalid object path.');
110
- }
111
194
  if (relPath === 'code') {
112
195
  updateCodeSource(ctx, data);
113
196
  } else if (relPath === 'content') {
114
197
  updateContentSource(ctx, data);
198
+ } else if (frag.type === 'token') {
199
+ // don't allow to update individual token
200
+ throw new StatusCodeError(400, 'invalid object path.');
201
+ } else if (frag.type === 'tokens') {
202
+ // TODO: remove support after all helix4 projects are migrated
203
+ let token;
204
+ if (data.jwt) {
205
+ token = await migrateToken(this.org, data.jwt);
206
+ } else {
207
+ // create new token
208
+ token = createToken(this.org);
209
+ }
210
+
211
+ data = {
212
+ ...token,
213
+ };
214
+ // don't store token value in the config
215
+ delete data.value;
216
+ relPath = ['tokens', token.id];
217
+ frag.type = 'token';
218
+ ret = token;
219
+ // don't expose hash in return value
220
+ delete ret.hash;
115
221
  }
116
- // eslint-disable-next-line no-param-reassign
117
- data = jsonPut(JSON.parse(buf), relPath, data);
222
+ config = jsonPut(old, relPath, data);
118
223
  } else {
119
- updateContentSource(ctx, data.content);
120
- updateCodeSource(ctx, data.code);
224
+ updateContentSource(ctx, config.content);
225
+ updateCodeSource(ctx, config.code);
121
226
  }
122
- await validate(data, this.type);
123
- await storage.put(this.key, JSON.stringify(data), 'application/json');
124
- await this.purge(ctx, old, data);
227
+ await validate(config, this.type);
228
+ await storage.put(this.key, JSON.stringify(config), 'application/json');
229
+ await this.purge(ctx, buf ? JSON.parse(buf) : null, config);
230
+ return ret ?? redact(data, frag);
125
231
  }
126
232
 
127
233
  async remove(ctx, relPath) {
@@ -130,11 +236,8 @@ export class ConfigStore {
130
236
  if (!buf) {
131
237
  throw new StatusCodeError(404, 'config not found.');
132
238
  }
133
- if (relPath) {
134
- const fragment = FRAGMENTS[this.type][relPath];
135
- if (!fragment) {
136
- throw new StatusCodeError(400, 'invalid object path.');
137
- }
239
+ const frag = getFragmentInfo(this.type, relPath);
240
+ if (frag) {
138
241
  const data = jsonPut(JSON.parse(buf), relPath, null);
139
242
  await validate(data, this.type);
140
243
  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 = {
@@ -12,13 +12,15 @@
12
12
  /* eslint-disable no-param-reassign */
13
13
  import crypto from 'crypto';
14
14
  import { GitUrl } from '@adobe/helix-shared-git';
15
+ import { decodeJwt } from 'jose';
16
+ import { StatusCodeError } from './status-code-error.js';
15
17
 
16
18
  export function jsonGet(obj, path) {
17
19
  return path.split('/').reduce((o, p) => o[p], obj);
18
20
  }
19
21
 
20
22
  export function jsonPut(obj, path, value) {
21
- const parts = path.split('/');
23
+ const parts = Array.isArray(path) ? path : path.split('/');
22
24
  const last = parts.pop();
23
25
  const parent = parts.reduce((o, p) => {
24
26
  if (!(p in o)) {
@@ -111,3 +113,60 @@ export function updateCodeSource(ctx, code) {
111
113
  }
112
114
  return modified;
113
115
  }
116
+
117
+ /**
118
+ * Creates a random token and hashes it with the given key
119
+ * @param {string} key
120
+ * @returns {object} the token
121
+ */
122
+ export function createToken(key) {
123
+ const secret = crypto.randomBytes(32).toString('base64url');
124
+ const value = `hlx_${secret}`;
125
+ const hash = crypto
126
+ .createHmac('sha512', key)
127
+ .update(value, 'utf-8')
128
+ .digest()
129
+ .toString('base64url');
130
+ const id = crypto.createHash('sha256').update(hash).digest().toString('base64url');
131
+ const created = new Date().toISOString();
132
+ return {
133
+ id,
134
+ value,
135
+ hash,
136
+ created,
137
+ };
138
+ }
139
+
140
+ /**
141
+ * migrates an existing jwt token
142
+ * @param key
143
+ * @param jwt
144
+ * @returns {Promise<object>}
145
+ */
146
+ export async function migrateToken(key, jwt) {
147
+ let payload;
148
+ try {
149
+ payload = await decodeJwt(jwt);
150
+ } catch (e) {
151
+ throw new StatusCodeError(400, `unable to migrate jwt: ${e.message}`);
152
+ }
153
+ const { jti } = payload;
154
+ if (!jti) {
155
+ throw new StatusCodeError(400, 'unable to migrate jwt: missing jti claim.');
156
+ }
157
+ const id = jti
158
+ .replaceAll('/', '_')
159
+ .replaceAll('+', '-');
160
+ const hash = crypto
161
+ .createHmac('sha512', key)
162
+ .update(jwt, 'utf-8')
163
+ .digest()
164
+ .toString('base64url');
165
+ const created = new Date().toISOString();
166
+ return {
167
+ id,
168
+ value: jwt,
169
+ hash,
170
+ created,
171
+ };
172
+ }
@@ -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
+ }