Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: auth route #80

Merged
merged 16 commits into from
Feb 21, 2025
2 changes: 2 additions & 0 deletions .github/workflows/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ jobs:
run: npm run deploy:ci
- name: Post-Deployment Integration Test
run: npm run test-postdeploy
env:
SUPERUSER_KEY: ${{ secrets.SUPERUSER_KEY }}
- name: Semantic Release (Dry Run)
run: npm run semantic-release-dry
env:
Expand Down
17 changes: 12 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,20 @@ async function parseData(req) {
}
if (['POST', 'PUT', 'PATCH'].includes(req.method)) {
const text = await req.text();
try {
return JSON.parse(text);
} catch {
return text;
if (text.trim().length) {
try {
return JSON.parse(text);
} catch {
return text;
}
}

const params = new URL(req.url).searchParams;
if (params.size) {
return Object.fromEntries(params.entries());
}
}
return null;
return {};
}

/**
Expand Down
34 changes: 34 additions & 0 deletions src/routes/auth/fetch.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { assertAuthorization } from '../../utils/auth.js';
import { errorResponse } from '../../utils/http.js';

/**
* @param {Context} ctx
*/
export default async function fetch(ctx) {
const { config } = ctx;

await assertAuthorization(ctx);

const token = await ctx.env.KEYS.get(config.siteKey);
if (!token) {
return errorResponse(404);
}

return new Response(JSON.stringify({ token }), {
headers: {
'Content-Type': 'application/json',
},
});
}
46 changes: 46 additions & 0 deletions src/routes/auth/handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { errorResponse } from '../../utils/http.js';
import fetch from './fetch.js';
import update from './update.js';
import rotate from './rotate.js';

/**
* @type {Record<string, Record<string, (ctx: Context, req: Request) => Promise<Response>>>}
*/
const handlers = {
token: {
GET: fetch,
PUT: update,
POST: rotate,
},
};

/**
* @param {Context} ctx
* @param {Request} req
* @returns {Promise<Response>}
*/
export default async function handler(ctx, req) {
const {
info: { method },
url: { pathname },
} = ctx;
const [subRoute] = pathname.split('/').filter(Boolean).slice(['org', 'site', 'route'].length);

const fn = handlers[subRoute]?.[method];
if (!fn) {
return errorResponse(404);
}
return fn(ctx, req);
}
34 changes: 34 additions & 0 deletions src/routes/auth/rotate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { assertAuthorization } from '../../utils/auth.js';
import { errorResponse } from '../../utils/http.js';
import { updateToken } from './update.js';

/**
* @param {Context} ctx
*/
export default async function rotate(ctx) {
const { data } = ctx;
if (data.token) {
return errorResponse(400, 'token can not be provided on rotate');
}

await assertAuthorization(ctx);

const token = await updateToken(ctx);
return new Response(JSON.stringify({ token }), {
headers: {
'Content-Type': 'application/json',
},
});
}
52 changes: 52 additions & 0 deletions src/routes/auth/update.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import { assertAuthorization } from '../../utils/auth.js';
import { errorResponse, errorWithResponse } from '../../utils/http.js';

const generateToken = () => crypto.randomUUID().toUpperCase();

/**
*
* @param {Context} ctx
* @param {string} [token]
* @returns {Promise<string>}
*/
export async function updateToken(ctx, token = generateToken()) {
const { config } = ctx;
try {
await ctx.env.KEYS.put(config.siteKey, token);
} catch (e) {
ctx.log.error('failed to update token', e);
throw errorWithResponse(503, 'failed to update token');
}
return token;
}

/**
* @param {Context} ctx
*/
export default async function update(ctx) {
const { data } = ctx;
if (!data.token || typeof data.token !== 'string') {
return errorResponse(400, 'missing or invalid token');
}

await assertAuthorization(ctx);

const token = await updateToken(ctx, data.token);
return new Response(JSON.stringify({ token }), {
headers: {
'Content-Type': 'application/json',
},
});
}
3 changes: 3 additions & 0 deletions src/routes/catalog/remove.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

import { assertAuthorization } from '../../utils/auth.js';
import { errorResponse, errorWithResponse } from '../../utils/http.js';
import StorageClient from './StorageClient.js';

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

await assertAuthorization(ctx);

const storage = StorageClient.fromContext(ctx);
const deleteResults = await storage.deleteProducts([sku]);

Expand Down
3 changes: 3 additions & 0 deletions src/routes/catalog/update.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { assertValidProduct, hasUppercase } from '../../utils/product.js';
import { errorResponse } from '../../utils/http.js';
import StorageClient from './StorageClient.js';
import { assertAuthorization } from '../../utils/auth.js';

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

assertValidProduct(product);

await assertAuthorization(ctx);

const storage = StorageClient.fromContext(ctx);
const saveResults = await storage.saveProducts([product]);

Expand Down
8 changes: 8 additions & 0 deletions src/routes/config/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { errorResponse } from '../../utils/http.js';
import { assertAuthorization } from '../../utils/auth.js';
import { validate } from '../../utils/validation.js';
import { updateToken } from '../auth/update.js';
import ConfigSchema from '../../schemas/Config.js';

/**
Expand Down Expand Up @@ -46,7 +47,14 @@ export default async function configHandler(ctx) {
}

// valid, persist it
const exists = (await ctx.env.CONFIGS.list({ prefix: ctx.config.siteKey })).keys.length > 0;
await ctx.env.CONFIGS.put(ctx.config.siteKey, JSON.stringify(json));

// add key
if (!exists) {
await updateToken(ctx);
}

return new Response(JSON.stringify(json), {
status: 200,
headers: {
Expand Down
5 changes: 2 additions & 3 deletions src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import content from './content/handler.js';
import catalog from './catalog/handler.js';
import config from './config/handler.js';
import { errorResponse } from '../utils/http.js';
import auth from './auth/handler.js';

/**
* @type {Record<string, (ctx: Context, request: Request) => Promise<Response>>}
Expand All @@ -22,6 +22,5 @@ export default {
content,
catalog,
config,
// eslint-disable-next-line no-unused-vars
graphql: async (ctx) => errorResponse(501, 'not implemented'),
auth,
};
1 change: 1 addition & 0 deletions src/schemas/Config.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const ConfigEntry = {
offerVariantURLTemplate: { type: 'string' },
liveSearchEnabled: { type: 'boolean' },
attributeOverrides: AttributeOverrides,
catalogSource: { type: 'string', enum: ['helix', 'magento'] },
imageRoleOrder: {
type: 'array',
items: { type: 'string' },
Expand Down
3 changes: 2 additions & 1 deletion src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ declare global {
/**
* Override "escape hatch" for json-ld
*/
jsonld?: any;
jsonld?: string;

/**
* Additional data that can be retrieved via .json API
Expand Down Expand Up @@ -204,6 +204,7 @@ declare global {
export interface Env {
VERSION: string;
ENVIRONMENT: string;
SUPERUSER_KEY: string;

// KV namespaces
CONFIGS: KVNamespace<string>;
Expand Down
12 changes: 6 additions & 6 deletions src/utils/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,20 +16,20 @@ import { errorWithResponse } from './http.js';
* @param {Context} ctx
*/
export async function assertAuthorization(ctx) {
let actual;
let actual = ctx.attributes.key;
if (typeof ctx.attributes.key === 'undefined') {
ctx.attributes.key = ctx.info.headers.authorization?.slice('Bearer '.length);
actual = ctx.attributes.key;
}
if (!actual) {
throw errorWithResponse(403, 'invalid key');
}

if (actual === ctx.env.SUPERUSER_KEY) {
ctx.log.info('acting as superuser');
ctx.log.debug('acting as superuser');
return;
}

if (!actual) {
throw errorWithResponse(403, 'invalid key');
}

const expected = await ctx.env.KEYS.get(ctx.config.siteKey);
if (!expected) {
throw errorWithResponse(403, 'no key found for site');
Expand Down
2 changes: 1 addition & 1 deletion src/utils/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export class ResponseError extends Error {

/**
* @param {number} status - The HTTP status code.
* @param {string} xError - The error message.
* @param {string} [xError] - The error message.
* @param {string|Record<string,unknown>} [body=''] - The response body.
* @returns {Response} - A response object.
*/
Expand Down
21 changes: 21 additions & 0 deletions test/fixtures/context.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,21 @@ export const DEFAULT_CONTEXT = (
) => ({
url: new URL(`${baseUrl}${path}`),
log: console,
// @ts-ignore
config: {
siteKey: 'org--site',
},
...overrides,
attributes: {
key: 'test-key',
...(overrides.attributes ?? {}),
},
env: {
SUPERUSER_KEY: 'su-test-key',
KEYS: {
// @ts-ignore
get: async () => 'test-key',
},
CONFIGS: {
// @ts-ignore
get: async (id) => configMap[id],
Expand All @@ -45,6 +55,17 @@ export const DEFAULT_CONTEXT = (
headers: {},
...(overrides.info ?? {}),
},
data: typeof overrides.data === 'string' ? overrides.data : {
...(overrides.data ?? {}),
},
});

export const SUPERUSER_CONTEXT = (overrides = {}) => DEFAULT_CONTEXT({
...overrides,
attributes: {
key: 'su-test-key',
...(overrides.attributes ?? {}),
},
});

/**
Expand Down
Loading