Skip to content

Commit 440a29f

Browse files
authored
feat: auth route (#80)
* feat: refactor * chore: fix tests * chore: fix postdeploy test * fix: tweak catalog handler * chore: fix test * chore: fix test * chore: fix test * fix: various fixes * feat: auth route * fix: assert perms for catalog routes * chore: fix tests * chore: add tests * chore: fix test * chore: fix postdeploy test * chore: fix postdeploy test
1 parent 04b242f commit 440a29f

20 files changed

+455
-24
lines changed

.github/workflows/main.yaml

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ jobs:
4848
run: npm run deploy:ci
4949
- name: Post-Deployment Integration Test
5050
run: npm run test-postdeploy
51+
env:
52+
SUPERUSER_KEY: ${{ secrets.SUPERUSER_KEY }}
5153
- name: Semantic Release (Dry Run)
5254
run: npm run semantic-release-dry
5355
env:

src/index.js

+12-5
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,20 @@ async function parseData(req) {
2323
}
2424
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
2525
const text = await req.text();
26-
try {
27-
return JSON.parse(text);
28-
} catch {
29-
return text;
26+
if (text.trim().length) {
27+
try {
28+
return JSON.parse(text);
29+
} catch {
30+
return text;
31+
}
32+
}
33+
34+
const params = new URL(req.url).searchParams;
35+
if (params.size) {
36+
return Object.fromEntries(params.entries());
3037
}
3138
}
32-
return null;
39+
return {};
3340
}
3441

3542
/**

src/routes/auth/fetch.js

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2025 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+
13+
import { assertAuthorization } from '../../utils/auth.js';
14+
import { errorResponse } from '../../utils/http.js';
15+
16+
/**
17+
* @param {Context} ctx
18+
*/
19+
export default async function fetch(ctx) {
20+
const { config } = ctx;
21+
22+
await assertAuthorization(ctx);
23+
24+
const token = await ctx.env.KEYS.get(config.siteKey);
25+
if (!token) {
26+
return errorResponse(404);
27+
}
28+
29+
return new Response(JSON.stringify({ token }), {
30+
headers: {
31+
'Content-Type': 'application/json',
32+
},
33+
});
34+
}

src/routes/auth/handler.js

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/*
2+
* Copyright 2025 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+
13+
import { errorResponse } from '../../utils/http.js';
14+
import fetch from './fetch.js';
15+
import update from './update.js';
16+
import rotate from './rotate.js';
17+
18+
/**
19+
* @type {Record<string, Record<string, (ctx: Context, req: Request) => Promise<Response>>>}
20+
*/
21+
const handlers = {
22+
token: {
23+
GET: fetch,
24+
PUT: update,
25+
POST: rotate,
26+
},
27+
};
28+
29+
/**
30+
* @param {Context} ctx
31+
* @param {Request} req
32+
* @returns {Promise<Response>}
33+
*/
34+
export default async function handler(ctx, req) {
35+
const {
36+
info: { method },
37+
url: { pathname },
38+
} = ctx;
39+
const [subRoute] = pathname.split('/').filter(Boolean).slice(['org', 'site', 'route'].length);
40+
41+
const fn = handlers[subRoute]?.[method];
42+
if (!fn) {
43+
return errorResponse(404);
44+
}
45+
return fn(ctx, req);
46+
}

src/routes/auth/rotate.js

+34
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2025 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+
13+
import { assertAuthorization } from '../../utils/auth.js';
14+
import { errorResponse } from '../../utils/http.js';
15+
import { updateToken } from './update.js';
16+
17+
/**
18+
* @param {Context} ctx
19+
*/
20+
export default async function rotate(ctx) {
21+
const { data } = ctx;
22+
if (data.token) {
23+
return errorResponse(400, 'token can not be provided on rotate');
24+
}
25+
26+
await assertAuthorization(ctx);
27+
28+
const token = await updateToken(ctx);
29+
return new Response(JSON.stringify({ token }), {
30+
headers: {
31+
'Content-Type': 'application/json',
32+
},
33+
});
34+
}

src/routes/auth/update.js

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
* Copyright 2025 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+
13+
import { assertAuthorization } from '../../utils/auth.js';
14+
import { errorResponse, errorWithResponse } from '../../utils/http.js';
15+
16+
const generateToken = () => crypto.randomUUID().toUpperCase();
17+
18+
/**
19+
*
20+
* @param {Context} ctx
21+
* @param {string} [token]
22+
* @returns {Promise<string>}
23+
*/
24+
export async function updateToken(ctx, token = generateToken()) {
25+
const { config } = ctx;
26+
try {
27+
await ctx.env.KEYS.put(config.siteKey, token);
28+
} catch (e) {
29+
ctx.log.error('failed to update token', e);
30+
throw errorWithResponse(503, 'failed to update token');
31+
}
32+
return token;
33+
}
34+
35+
/**
36+
* @param {Context} ctx
37+
*/
38+
export default async function update(ctx) {
39+
const { data } = ctx;
40+
if (!data.token || typeof data.token !== 'string') {
41+
return errorResponse(400, 'missing or invalid token');
42+
}
43+
44+
await assertAuthorization(ctx);
45+
46+
const token = await updateToken(ctx, data.token);
47+
return new Response(JSON.stringify({ token }), {
48+
headers: {
49+
'Content-Type': 'application/json',
50+
},
51+
});
52+
}

src/routes/catalog/remove.js

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import { assertAuthorization } from '../../utils/auth.js';
1314
import { errorResponse, errorWithResponse } from '../../utils/http.js';
1415
import StorageClient from './StorageClient.js';
1516

@@ -30,6 +31,8 @@ export default async function remove(ctx) {
3031
throw errorWithResponse(400, 'Helix API key is required to delete or unpublish products.');
3132
}
3233

34+
await assertAuthorization(ctx);
35+
3336
const storage = StorageClient.fromContext(ctx);
3437
const deleteResults = await storage.deleteProducts([sku]);
3538

src/routes/catalog/update.js

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { assertValidProduct, hasUppercase } from '../../utils/product.js';
1414
import { errorResponse } from '../../utils/http.js';
1515
import StorageClient from './StorageClient.js';
16+
import { assertAuthorization } from '../../utils/auth.js';
1617

1718
/**
1819
* Handles a PUT request to update a product.
@@ -40,6 +41,8 @@ export default async function update(ctx) {
4041

4142
assertValidProduct(product);
4243

44+
await assertAuthorization(ctx);
45+
4346
const storage = StorageClient.fromContext(ctx);
4447
const saveResults = await storage.saveProducts([product]);
4548

src/routes/config/handler.js

+8
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import { errorResponse } from '../../utils/http.js';
1414
import { assertAuthorization } from '../../utils/auth.js';
1515
import { validate } from '../../utils/validation.js';
16+
import { updateToken } from '../auth/update.js';
1617
import ConfigSchema from '../../schemas/Config.js';
1718

1819
/**
@@ -46,7 +47,14 @@ export default async function configHandler(ctx) {
4647
}
4748

4849
// valid, persist it
50+
const exists = (await ctx.env.CONFIGS.list({ prefix: ctx.config.siteKey })).keys.length > 0;
4951
await ctx.env.CONFIGS.put(ctx.config.siteKey, JSON.stringify(json));
52+
53+
// add key
54+
if (!exists) {
55+
await updateToken(ctx);
56+
}
57+
5058
return new Response(JSON.stringify(json), {
5159
status: 200,
5260
headers: {

src/routes/index.js

+2-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import content from './content/handler.js';
1414
import catalog from './catalog/handler.js';
1515
import config from './config/handler.js';
16-
import { errorResponse } from '../utils/http.js';
16+
import auth from './auth/handler.js';
1717

1818
/**
1919
* @type {Record<string, (ctx: Context, request: Request) => Promise<Response>>}
@@ -22,6 +22,5 @@ export default {
2222
content,
2323
catalog,
2424
config,
25-
// eslint-disable-next-line no-unused-vars
26-
graphql: async (ctx) => errorResponse(501, 'not implemented'),
25+
auth,
2726
};

src/schemas/Config.js

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const ConfigEntry = {
4444
offerVariantURLTemplate: { type: 'string' },
4545
liveSearchEnabled: { type: 'boolean' },
4646
attributeOverrides: AttributeOverrides,
47+
catalogSource: { type: 'string', enum: ['helix', 'magento'] },
4748
imageRoleOrder: {
4849
type: 'array',
4950
items: { type: 'string' },

src/types.d.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ declare global {
3434
/**
3535
* Override "escape hatch" for json-ld
3636
*/
37-
jsonld?: any;
37+
jsonld?: string;
3838

3939
/**
4040
* Additional data that can be retrieved via .json API
@@ -204,6 +204,7 @@ declare global {
204204
export interface Env {
205205
VERSION: string;
206206
ENVIRONMENT: string;
207+
SUPERUSER_KEY: string;
207208

208209
// KV namespaces
209210
CONFIGS: KVNamespace<string>;

src/utils/auth.js

+6-6
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,20 @@ import { errorWithResponse } from './http.js';
1616
* @param {Context} ctx
1717
*/
1818
export async function assertAuthorization(ctx) {
19-
let actual;
19+
let actual = ctx.attributes.key;
2020
if (typeof ctx.attributes.key === 'undefined') {
2121
ctx.attributes.key = ctx.info.headers.authorization?.slice('Bearer '.length);
2222
actual = ctx.attributes.key;
2323
}
24-
if (!actual) {
25-
throw errorWithResponse(403, 'invalid key');
26-
}
27-
2824
if (actual === ctx.env.SUPERUSER_KEY) {
29-
ctx.log.info('acting as superuser');
25+
ctx.log.debug('acting as superuser');
3026
return;
3127
}
3228

29+
if (!actual) {
30+
throw errorWithResponse(403, 'invalid key');
31+
}
32+
3333
const expected = await ctx.env.KEYS.get(ctx.config.siteKey);
3434
if (!expected) {
3535
throw errorWithResponse(403, 'no key found for site');

src/utils/http.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export class ResponseError extends Error {
4848

4949
/**
5050
* @param {number} status - The HTTP status code.
51-
* @param {string} xError - The error message.
51+
* @param {string} [xError] - The error message.
5252
* @param {string|Record<string,unknown>} [body=''] - The response body.
5353
* @returns {Response} - A response object.
5454
*/

test/fixtures/context.js

+21
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,21 @@ export const DEFAULT_CONTEXT = (
2929
) => ({
3030
url: new URL(`${baseUrl}${path}`),
3131
log: console,
32+
// @ts-ignore
33+
config: {
34+
siteKey: 'org--site',
35+
},
3236
...overrides,
3337
attributes: {
38+
key: 'test-key',
3439
...(overrides.attributes ?? {}),
3540
},
3641
env: {
42+
SUPERUSER_KEY: 'su-test-key',
43+
KEYS: {
44+
// @ts-ignore
45+
get: async () => 'test-key',
46+
},
3747
CONFIGS: {
3848
// @ts-ignore
3949
get: async (id) => configMap[id],
@@ -45,6 +55,17 @@ export const DEFAULT_CONTEXT = (
4555
headers: {},
4656
...(overrides.info ?? {}),
4757
},
58+
data: typeof overrides.data === 'string' ? overrides.data : {
59+
...(overrides.data ?? {}),
60+
},
61+
});
62+
63+
export const SUPERUSER_CONTEXT = (overrides = {}) => DEFAULT_CONTEXT({
64+
...overrides,
65+
attributes: {
66+
key: 'su-test-key',
67+
...(overrides.attributes ?? {}),
68+
},
4869
});
4970

5071
/**

0 commit comments

Comments
 (0)