diff --git a/manual-tests/cognito-client.ts b/manual-tests/cognito-client.ts new file mode 100644 index 0000000..e818deb --- /dev/null +++ b/manual-tests/cognito-client.ts @@ -0,0 +1,65 @@ +import axios from 'axios'; + +interface TokenResponse { + access_token: string; + expires_in: number; + token_type: string; +} + +export default class CognitoClient { + tokenEndpont: string; + clientId: string; + clientSecret: string; + scope: string; + expiresAt: number; + accessToken: string; + + constructor( + tokenEndpoint: string, + clientId: string, + clientSecret: string, + scope: string, + ) { + this.tokenEndpont = `${tokenEndpoint}/oauth2/token`; + this.clientId = clientId; + this.clientSecret = clientSecret; + this.scope = scope; + this.accessToken = ''; + this.expiresAt = 0; + } + + private getPostData(): URLSearchParams { + const params = new URLSearchParams(); + params.append('grant_type', 'client_credentials'); + params.append('client_id', this.clientId); + params.append('scope', this.scope); + return params; + } + + public async getToken(): Promise { + const now = Date.now(); + if (this.expiresAt < now) { + await this.getNewToken(); + } + console.log('Getting cached token'); + return this.accessToken; + } + + private async getNewToken(): Promise { + console.log('Getting new token'); + const authString = + 'Basic ' + + Buffer.from(this.clientId + ':' + this.clientSecret).toString('base64'); + const res: TokenResponse = await axios + .post(this.tokenEndpont, this.getPostData(), { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: authString, + }, + }) + .then((r) => r.data); + + this.accessToken = res.access_token; + this.expiresAt = Date.now() + res.expires_in * 1e3; + } +} diff --git a/manual-tests/test-scopes.ts b/manual-tests/test-scopes.ts new file mode 100644 index 0000000..909e7bb --- /dev/null +++ b/manual-tests/test-scopes.ts @@ -0,0 +1,95 @@ +import axios from 'axios'; +import CognitoClient from './cognito-client'; + +const config = { + PW_GRAPHQL_URL: 'http://docker.for.mac.localhost:4000', + API_TOKEN_ENDPOINT: + 'https://photowall-test-admin.auth.eu-west-1.amazoncognito.com', + API_CLIENT_ID: '', + API_CLIENT_SECRET: '', +}; + +async function doQuery( + query: string, + variables: any, + scopes: string[], +): Promise { + const cognitoClient = new CognitoClient( + config.API_TOKEN_ENDPOINT, + config.API_CLIENT_ID, + config.API_CLIENT_SECRET, + scopes.join(' '), + ); + let token = ''; + token = await cognitoClient.getToken(); + + const url = config.PW_GRAPHQL_URL; + const res = await axios({ + url: url, + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }, + data: JSON.stringify({ + query, + variables, + }), + }); + return res; +} + +async function testGetOrder() { + const query = ` + query GetOrder($id: ID!) { + order(id:$id){ + id + klarnaOrderId + # paypalTransactionId + customerType + countryCode + } + } + `; + const variables = { + id: '414246', + }; + const res = await doQuery(query, variables, [ + 'https://graphql.photowall.com/orders.read', + ]); + console.log(JSON.stringify(res.data, null, 2)); +} + +async function testGetProduct() { + const query = ` + query GetProduct($id: Int!) { + product(id:$id){ + id + fields { + artNo + name + } + } + } + `; + const variables = { + id: 52924, + }; + const res = await doQuery(query, variables, [ + 'https://graphql.photowall.com/products.read', + ]); + console.log(JSON.stringify(res.data, null, 2)); +} + +async function main() { + console.log('Starting'); + try { + await testGetOrder(); + await testGetProduct(); + } catch (e) { + console.log(e?.response.data.errors); + } +} + +main(); diff --git a/package.json b/package.json index 121efd9..67ec5e5 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,9 @@ "scripts": { "exportSQL": "cd src/__test__/db_generator/ && npm install && node index.js && cd ../../../", "test": "npm run exportSQL && jest --detectOpenHandles", - "test:dev": "npm run exportSQL && jest --watch" + "test:dev": "npm run exportSQL && jest --watch", + "unit:dev": "jest --watch src/__test__/unit.test.ts", + "test:manual": "cd manual-tests && ts-node test-scopes.ts" }, "author": "", "license": "ISC", diff --git a/src/__test__/unit.test.ts b/src/__test__/unit.test.ts new file mode 100644 index 0000000..0f45478 --- /dev/null +++ b/src/__test__/unit.test.ts @@ -0,0 +1,98 @@ +import { checkAccess } from '../cognito/access-control'; +import { + AccessTokenClaim, + ClaimVerifyResult, + IdTokenClaim, +} from '../cognito/cognito-client'; + +const idTokenAuth = { + userName: 'niklas.fondberg@photowall.se', + isValid: true, + idToken: { + 'custom:adgroups': + '[Domain Users, Images_Write, VPN_Users, WebAdmin, WebAdminSuperUser]', + 'cognito:username': 'photowalladfs_photowall\\niklas.fondberg', + token_use: 'id', + email_verified: false, + nonce: 'Qc9kHCXrN----4st19sM', + at_hash: '9x_RMAO3_2NW1cQYkhWEtg', + email: 'niklas.fondberg@photowall.se', + aud: '53og32ofjne7e6i4uf10r3bg2b', + sub: '2646dd8b-f914-40eb-895b-c652976a65e1', + identities: [null], + } as IdTokenClaim, + accessToken: null, +} as ClaimVerifyResult; + +const accessTokenAuth = { + isValid: true, + idToken: null, + accessToken: { + token_use: 'access', + username: '', + scope: + 'https://graphql.photowall.com/orders.write https://graphql.photowall.com/orders.read', + client_id: '5vftcl43n2jkcdvd9imc270mtu', + } as AccessTokenClaim, +} as ClaimVerifyResult; + +describe('GraphQL Apollo unit tests', () => { + describe('Test auth', () => { + it('user test is accepted', () => { + const auth: ClaimVerifyResult = { + userName: 'testuser', + idToken: null, + accessToken: null, + isValid: true, + }; + expect(() => checkAccess(['orders.read'], auth)).not.toThrow(); + }); + }); + describe('Test auth not ok for test', () => { + it('user bla is accepted', () => { + const auth: ClaimVerifyResult = { + userName: 'bla', + idToken: null, + accessToken: null, + isValid: true, + }; + expect(() => checkAccess(['orders.read'], auth)).toThrow(); + }); + }); + + describe('Test access auth', () => { + it('user test is accepted', () => { + const auth = accessTokenAuth; + expect(() => + checkAccess(['orders.read', 'orders.write'], auth), + ).not.toThrow(); + }); + }); + + describe('Test access auth not enough scope', () => { + it('user test is accepted', () => { + const auth = accessTokenAuth; + expect(() => + checkAccess(['products.read', 'orders.read'], auth), + ).toThrow(); + }); + }); + + describe('Test id auth', () => { + it('user test is accepted', () => { + const auth = idTokenAuth; + expect(() => + checkAccess(['orders.read', 'orders.write'], auth), + ).not.toThrow(); + }); + }); + describe('Test id auth no enough', () => { + it('user test is accepted', () => { + const auth = JSON.parse(JSON.stringify(idTokenAuth)) as ClaimVerifyResult; + auth.idToken['custom:adgroups'] = '[]'; + expect(() => + checkAccess(['orders.read', 'orders.write'], auth), + ).toThrow(); + }); + }); +}); diff --git a/src/cognito/access-control.ts b/src/cognito/access-control.ts new file mode 100644 index 0000000..49e0098 --- /dev/null +++ b/src/cognito/access-control.ts @@ -0,0 +1,35 @@ +import { AuthenticationError } from 'apollo-server'; +import { ClaimVerifyResult } from './cognito-client'; + +export const RESOURCE = 'https://graphql.photowall.com'; + +export enum SCOPES { + ORDERS_READ = 'orders.read', + ORDERS_WRITE = 'orders.write', + DESIGNERS_READ = 'designers.read', + DESIGNERS_WRITE = 'designers.write', + PRODUCTS_READ = 'products.read', + PRODUCTS_WRITE = 'products.write', +} + +export function checkAccess(scopes: string[], auth: ClaimVerifyResult): void { + if (auth.userName === 'testuser') { + return; + } + if (auth.idToken) { + if (auth.idToken['custom:adgroups'].includes('WebAdmin')) { + return; + } + } + if (auth.accessToken) { + const authScopes = auth.accessToken.scope.split(' '); + + const requiredScoped = scopes.map((s) => `${RESOURCE}/${s}`); + const checker = (arr: string[], target: string[]) => + target.every((v) => arr.includes(v)); + if (checker(authScopes, requiredScoped)) { + return; + } + } + throw new AuthenticationError('Not authorized for this endpoint'); +} diff --git a/src/cognito/cognito-client.ts b/src/cognito/cognito-client.ts index e009e7e..5370db6 100644 --- a/src/cognito/cognito-client.ts +++ b/src/cognito/cognito-client.ts @@ -49,7 +49,7 @@ interface Claim { iss: string; } -interface AccessTokenClaim extends Claim { +export interface AccessTokenClaim extends Claim { token_use: string; username: string; client_id: string; @@ -57,7 +57,7 @@ interface AccessTokenClaim extends Claim { scope: string; } -interface IdTokenClaim extends Claim { +export interface IdTokenClaim extends Claim { 'custom:adgroups': string; // 'custom:adgroups': '[Domain Users, Images_Write, VPN_Users, WebAdmin, WebAdminSuperUser]', 'cognito:username': string; token_use: string; diff --git a/src/index.ts b/src/index.ts index 2e7dd91..1e2a4a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,7 +15,7 @@ import { MarketLocaleAPI } from './datasources/market-locale-api'; import { ImageServerApi } from './datasources/imageserver-api'; import { InteriorAPI } from './datasources/interior-api'; -import { verifyToken } from './cognito/cognito-client'; +import { ClaimVerifyResult, verifyToken } from './cognito/cognito-client'; import { readFileSync } from 'fs'; import { InteriorsLambdaAPI } from './datasources/interiors-lambda-api'; const path = require('path'); @@ -51,7 +51,7 @@ const dataSources = () => ({ const context = async ({ req }) => { if (process.env.NODE_ENV == 'test') { - return { auth: 'testuser' }; + return { auth: { userName: 'testuser' } }; } const authHeader = req.headers.authorization || ''; @@ -65,7 +65,14 @@ const context = async ({ req }) => { process.env.NODE_ENV === 'development' && authHeader == 'Basic ZGV2OnB2N1VmYSFiZVAzeGk=' ) { - return { auth: 'during development' }; + return { + auth: { + userName: 'testuser', + isValid: true, + accessToken: null, + idToken: null, + }, + }; } throw new AuthenticationError('Not authorized'); }; diff --git a/src/resolvers/categories-resolver.ts b/src/resolvers/categories-resolver.ts index bd459aa..2d4a091 100644 --- a/src/resolvers/categories-resolver.ts +++ b/src/resolvers/categories-resolver.ts @@ -1,8 +1,10 @@ +import { checkAccess } from '../cognito/access-control'; import { CategoryAPI } from '../datasources/category-api'; import { ProductAPI } from '../datasources/product-api'; const Category = { - async products({ id }, _args, { dataSources }) { + async products({ id }, _args, { dataSources, auth }) { + checkAccess(['products.read'], auth); return (dataSources.productApi).getCategoryProducts(id); }, }; diff --git a/src/resolvers/designers-resolver.ts b/src/resolvers/designers-resolver.ts index 294ef34..fc1a008 100644 --- a/src/resolvers/designers-resolver.ts +++ b/src/resolvers/designers-resolver.ts @@ -1,22 +1,27 @@ +import { checkAccess } from '../cognito/access-control'; import { DesignerAPI } from '../datasources/designer-api'; import { OrderAPI } from '../datasources/order-api'; import { ProductAPI } from '../datasources/product-api'; import { GeneralInput } from '../types/types'; const Designer = { - async orderRows({ id }, input: GeneralInput, { dataSources }) { + async orderRows({ id }, input: GeneralInput, { dataSources, auth }) { + checkAccess(['orders.read'], auth); return (dataSources.orderApi).getOrderRowsByDesignerId(id, input); }, - async products({ id }, _args, { dataSources }) { + async products({ id }, _args, { dataSources, auth }) { + checkAccess(['products.read'], auth); return (dataSources.productApi).getDesignerProducts(id); }, }; -async function getDesigners(_, { limit }, { dataSources }) { +async function getDesigners(_, { limit }, { dataSources, auth }) { + checkAccess(['designers.read'], auth); return (dataSources.designerApi).getDesigners(limit); } -async function getDesigner(parent, { id }, { dataSources }) { +async function getDesigner(_, { id }, { dataSources, auth }) { + checkAccess(['designers.read'], auth); return (dataSources.designerApi).getDesignerById(id); } diff --git a/src/resolvers/keywords-resolver.ts b/src/resolvers/keywords-resolver.ts index 82f2e69..15dcfef 100644 --- a/src/resolvers/keywords-resolver.ts +++ b/src/resolvers/keywords-resolver.ts @@ -1,8 +1,10 @@ +import { checkAccess } from '../cognito/access-control'; import { KeywordAPI } from '../datasources/keyword-api'; import { ProductAPI } from '../datasources/product-api'; const Keyword = { - async products({ id }, _args, { dataSources }) { + async products({ id }, _args, { dataSources, auth }) { + checkAccess(['products.read'], auth); return (dataSources.productApi).getKeywordProducts(id); }, }; diff --git a/src/resolvers/orders-resolver.ts b/src/resolvers/orders-resolver.ts index d204df4..f369232 100644 --- a/src/resolvers/orders-resolver.ts +++ b/src/resolvers/orders-resolver.ts @@ -1,3 +1,4 @@ +import { checkAccess } from '../cognito/access-control'; import { MarketLocaleAPI } from '../datasources/market-locale-api'; import { OrderAPI } from '../datasources/order-api'; import { OrdersResult } from '../types/order-types'; @@ -27,7 +28,8 @@ const Order = { }; const OrderRow = { - async order(parent, _args, { dataSources }) { + async order(parent, _args, { dataSources, auth }) { + checkAccess(['orders.read'], auth); return (dataSources.orderApi).getOrderById(parent.orderId); }, }; @@ -35,8 +37,9 @@ const OrderRow = { async function getOrdersResult( _, input: GeneralInput, - { dataSources }, + { dataSources, auth }, ): Promise { + checkAccess(['orders.read'], auth); const total = (dataSources.orderApi).getOrdersTotal(input); const items = (dataSources.orderApi).getOrders(input); return { @@ -49,7 +52,8 @@ async function getOrdersResult( }; } -async function getOrder(_, { id }, { dataSources }) { +async function getOrder(_, { id }, { dataSources, auth }) { + checkAccess(['orders.read'], auth); return (dataSources.orderApi).getOrderById(id); } diff --git a/src/resolvers/products-resolver.ts b/src/resolvers/products-resolver.ts index ae27bd5..f6ca8d5 100644 --- a/src/resolvers/products-resolver.ts +++ b/src/resolvers/products-resolver.ts @@ -16,6 +16,7 @@ import { InteriorsLambdaAPI } from '../datasources/interiors-lambda-api'; import { IdNumberResult } from '../types/types'; import { moveS3File } from '../s3'; import { DesignerAPI } from '../datasources/designer-api'; +import { checkAccess } from '../cognito/access-control'; const ProductBlacklist = { async market(parent, _args, { dataSources }) { @@ -30,7 +31,8 @@ const Product = { async categories({ id }, _args, { dataSources }): Promise> { return (dataSources.categoryApi).getProductCategories(id); }, - async designer({ designerId }, _args, { dataSources }) { + async designer({ designerId }, _args, { dataSources, auth }) { + checkAccess(['designers.read'], auth); if (!designerId) { return null; } @@ -53,8 +55,9 @@ const PrintProduct = { async function getProductsResult( _, input: ProductsFilterInput, - { dataSources }, + { dataSources, auth }, ): Promise { + checkAccess(['products.read'], auth); const total = (dataSources.productApi).getProductsTotal(input); const items = (dataSources.productApi).getProducts(input); @@ -71,8 +74,9 @@ async function getProductsResult( async function getProductsSearchResult( _, { q }, - { dataSources }, + { dataSources, auth }, ): Promise { + checkAccess(['products.read', 'designers.read'], auth); const prodApi = dataSources.productApi; const catApi = dataSources.categoryApi; const keywApi = dataSources.keywordApi; @@ -92,7 +96,7 @@ async function getProductsSearchResult( }; } -async function getProduct(_, { id }, { dataSources }): Promise { +async function getProduct(_, { id }, { dataSources, auth }): Promise { return (dataSources.productApi).getProduct(id); } @@ -111,14 +115,16 @@ export const productQueryTypeDefs = { /////////////////// // Mutations below export const productMutationTypeDefs = { - async addProduct(_, { name, batch }, { dataSources }) { + async addProduct(_, { name, batch }, { dataSources, auth }) { + checkAccess(['products.write'], auth); const id = await (dataSources.productApi).addProduct( name, batch, ); return (dataSources.productApi).getProduct(id); }, - async productInfo(_, { productId, info }, { dataSources }) { + async productInfo(_, { productId, info }, { dataSources, auth }) { + checkAccess(['products.write'], auth); await (dataSources.productApi).updateProductInfo( productId, info, @@ -128,8 +134,9 @@ export const productMutationTypeDefs = { async productFocusPoint( _, { productId, focusXpoint2, focusYpoint2 }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).updateFocusPoint( productId, focusXpoint2, @@ -145,14 +152,16 @@ export const productMutationTypeDefs = { )).generateNewInteriors(product); return (dataSources.productApi).getProduct(productId); }, - async productBlacklisting(_, { productId, markets }, { dataSources }) { + async productBlacklisting(_, { productId, markets }, { dataSources, auth }) { + checkAccess(['products.write'], auth); await (dataSources.productApi).updateBlacklisting( productId, markets, ); return (dataSources.productApi).getProduct(productId); }, - async productGroup(_, { productId, groupIds }, { dataSources }) { + async productGroup(_, { productId, groupIds }, { dataSources, auth }) { + checkAccess(['products.write'], auth); await (dataSources.productApi).updateGroups( productId, groupIds, @@ -168,8 +177,9 @@ export const productMutationTypeDefs = { async productGroupInteriors( _, { productId, groupId, uris }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).addInteriorsToProductGroup( productId, groupId, @@ -177,7 +187,8 @@ export const productMutationTypeDefs = { ); return (dataSources.productApi).getProduct(productId); }, - async productKeywords(_, { productId, keywordIds }, { dataSources }) { + async productKeywords(_, { productId, keywordIds }, { dataSources, auth }) { + checkAccess(['products.write'], auth); await (dataSources.keywordApi).setProductKeywords( productId, keywordIds, @@ -187,8 +198,9 @@ export const productMutationTypeDefs = { async productProportionsWarning( _, { productId, proportions }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).setProportionsWarning( productId, proportions, @@ -198,8 +210,9 @@ export const productMutationTypeDefs = { async addOwnInteriorToPrintProduct( _, { printId, uploadedS3Key }, - { dataSources }, + { dataSources, auth }, ): Promise { + checkAccess(['products.write'], auth); return (dataSources.interiorApi).addOwnUploadToPrintId( printId, uploadedS3Key, @@ -208,8 +221,9 @@ export const productMutationTypeDefs = { async productWallpaperType( _, { productId, wallpaperTypes }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).setWallpaperTypes( productId, wallpaperTypes, @@ -219,8 +233,9 @@ export const productMutationTypeDefs = { async updateProductImage( _, { productId, uploadedS3Key, width, height }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await moveS3File( CONFIG.uploadBucket, uploadedS3Key, @@ -246,7 +261,12 @@ export const productMutationTypeDefs = { )).generateNewInteriors(product); return 'success'; }, - async addRelatedProducts(_, { productId, articleNumbers }, { dataSources }) { + async addRelatedProducts( + _, + { productId, articleNumbers }, + { dataSources, auth }, + ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).addRelatedProducts( productId, articleNumbers, @@ -256,8 +276,9 @@ export const productMutationTypeDefs = { async removeRelatedProducts( _, { productId, relatedProductIds }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).removeRelatedProducts( productId, relatedProductIds, @@ -265,7 +286,12 @@ export const productMutationTypeDefs = { return (dataSources.productApi).getProduct(productId); }, - async addCategoriesToProduct(_, { productId, categoryIds }, { dataSources }) { + async addCategoriesToProduct( + _, + { productId, categoryIds }, + { dataSources, auth }, + ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).addCategoriesToProduct( productId, categoryIds, @@ -276,15 +302,21 @@ export const productMutationTypeDefs = { async removeCategoriesFromProduct( _, { productId, categoryIds }, - { dataSources }, + { dataSources, auth }, ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).removeCategoriesFromProduct( productId, categoryIds, ); return (dataSources.productApi).getProduct(productId); }, - async updateProductComments(_, { productId, comments }, { dataSources }) { + async updateProductComments( + _, + { productId, comments }, + { dataSources, auth }, + ) { + checkAccess(['products.write'], auth); await (dataSources.productApi).updateComments( productId, comments,