Skip to content

Commit

Permalink
Add ability to encrypt session data
Browse files Browse the repository at this point in the history
  • Loading branch information
paulomarg committed Apr 25, 2024
1 parent 981811e commit a425cfe
Show file tree
Hide file tree
Showing 9 changed files with 353 additions and 15 deletions.
5 changes: 5 additions & 0 deletions .changeset/olive-eggs-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/shopify-api": minor
---

Added the ability to encrypt Session access tokens using AES-GCM with a 128-bit tag and a 12-byte random IV.
159 changes: 154 additions & 5 deletions packages/apps/shopify-api/lib/session/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {Session} from '../session';
import {testConfig} from '../../__tests__/test-config';
import {shopifyApi} from '../..';
import {AuthScopes} from '../../auth';
import {getCryptoLib} from '../../../runtime';

describe('session', () => {
it('can create a session from another session', () => {
Expand Down Expand Up @@ -239,8 +240,8 @@ const testSessions = [
['state', 'offline-session-state'],
['isOnline', false],
['scope', 'offline-session-scope'],
['accessToken', 'offline-session-token'],
['expires', expiresNumber],
['accessToken', 'offline-session-token'],
],
returnUserData: false,
},
Expand Down Expand Up @@ -340,8 +341,8 @@ const testSessions = [
['state', 'online-session-state'],
['isOnline', true],
['scope', 'online-session-scope'],
['accessToken', 'online-session-token'],
['expires', expiresNumber],
['accessToken', 'online-session-token'],
['onlineAccessInfo', 1],
],
returnUserData: false,
Expand Down Expand Up @@ -392,8 +393,8 @@ const testSessions = [
['state', 'offline-session-state'],
['isOnline', false],
['scope', 'offline-session-scope'],
['accessToken', 'offline-session-token'],
['expires', expiresNumber],
['accessToken', 'offline-session-token'],
],
returnUserData: true,
},
Expand Down Expand Up @@ -427,8 +428,8 @@ const testSessions = [
['state', 'online-session-state'],
['isOnline', true],
['scope', 'online-session-scope'],
['accessToken', 'online-session-token'],
['expires', expiresNumber],
['accessToken', 'online-session-token'],
['userId', 1],
['firstName', 'online-session-first-name'],
['lastName', 'online-session-last-name'],
Expand Down Expand Up @@ -463,8 +464,8 @@ const testSessions = [
['state', 'online-session-state'],
['isOnline', true],
['scope', 'online-session-scope'],
['accessToken', 'online-session-token'],
['expires', expiresNumber],
['accessToken', 'online-session-token'],
['userId', 1],
],
returnUserData: true,
Expand Down Expand Up @@ -636,3 +637,151 @@ describe('toPropertyArray and fromPropertyArray', () => {
});
});
});

describe('toEncryptedPropertyArray and fromEncryptedPropertyArray', () => {
let key: CryptoKey;

beforeEach(async () => {
const cryptoLib = getCryptoLib();

key = await cryptoLib.subtle.generateKey(
{name: 'AES-GCM', length: 256},
true,
['encrypt', 'decrypt'],
);
});

testSessions.forEach((test) => {
const onlineOrOffline = test.session.isOnline ? 'online' : 'offline';
const userData = test.returnUserData ? 'with' : 'without';

it(`returns a property array of an ${onlineOrOffline} session ${userData} user data`, async () => {
// GIVEN
const getPropIndex = (object: any, prop: string, check = true) => {
const index = object.findIndex((property: any) => property[0] === prop);

if (check) expect(index).toBeGreaterThan(-1);

return index;
};

const session = new Session(test.session);
const testProps = [...test.propertyArray];

// WHEN
const actualProps = await session.toEncryptedPropertyArray(
key,
test.returnUserData,
);

// THEN

// The token is encrypted, so the values will be different
const tokenIndex = getPropIndex(testProps, 'accessToken', false);
const actualTokenIndex = getPropIndex(actualProps, 'accessToken', false);

if (actualTokenIndex > -1 && tokenIndex > -1) {
expect(
actualProps[actualTokenIndex][1].toString().startsWith('encrypted#'),
).toBeTruthy();

actualProps.splice(actualTokenIndex, 1);
testProps.splice(tokenIndex, 1);
}

expect(actualProps).toStrictEqual(testProps);
});

it(`recreates a Session from a property array of an ${onlineOrOffline} session ${userData} user data`, async () => {
// GIVEN
const session = new Session(test.session);

// WHEN
const actualSession = await Session.fromEncryptedPropertyArray(
await session.toEncryptedPropertyArray(key, test.returnUserData),
key,
test.returnUserData,
);

// THEN
expect(actualSession.id).toStrictEqual(session.id);
expect(actualSession.shop).toStrictEqual(session.shop);
expect(actualSession.state).toStrictEqual(session.state);
expect(actualSession.isOnline).toStrictEqual(session.isOnline);
expect(actualSession.scope).toStrictEqual(session.scope);
expect(actualSession.accessToken).toStrictEqual(session.accessToken);
expect(actualSession.expires).toStrictEqual(session.expires);

const user = session.onlineAccessInfo?.associated_user;
const actualUser = actualSession.onlineAccessInfo?.associated_user;
expect(actualUser?.id).toStrictEqual(user?.id);

if (test.returnUserData) {
if (user && actualUser) {
expect(actualUser).toMatchObject(user);
} else {
expect(actualUser).toBeUndefined();
expect(user).toBeUndefined();
}
} else {
expect(actualUser?.first_name).toBeUndefined();
expect(actualUser?.last_name).toBeUndefined();
expect(actualUser?.email).toBeUndefined();
expect(actualUser?.locale).toBeUndefined();
expect(actualUser?.email_verified).toBeUndefined();
expect(actualUser?.account_owner).toBeUndefined();
expect(actualUser?.collaborator).toBeUndefined();
}
});

const describe = test.session.isOnline ? 'Does' : 'Does not';
const isOnline = test.session.isOnline ? 'online' : 'offline';

it(`${describe} have online access info when the token is ${isOnline}`, async () => {
// GIVEN
const session = new Session(test.session);

// WHEN
const actualSession = await Session.fromEncryptedPropertyArray(
await session.toEncryptedPropertyArray(key, test.returnUserData),
key,
test.returnUserData,
);

// THEN
if (test.session.isOnline) {
expect(actualSession.onlineAccessInfo).toBeDefined();
} else {
expect(actualSession.onlineAccessInfo).toBeUndefined();
}
});
});

it('fails to decrypt an invalid token', async () => {
// GIVEN
const session = new Session({
id: 'offline_session_id',
shop: 'offline-session-shop',
state: 'offline-session-state',
isOnline: false,
scope: 'offline-session-scope',
accessToken: 'offline-session-token',
expires: expiresDate,
});

const props = await session.toEncryptedPropertyArray(key, false);

// WHEN
const tamperedProps = props.map((derp) => {
return [
derp[0],
derp[0] === 'accessToken' ? 'encrypted#invalid token' : derp[1],
] as [string, string | number | boolean];
});

// THEN
await expect(
Session.fromEncryptedPropertyArray(tamperedProps, key, false),
).rejects.toThrow(DOMException);
});
});
89 changes: 83 additions & 6 deletions packages/apps/shopify-api/lib/session/session.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
/* eslint-disable no-fallthrough */

import {InvalidSession} from '../error';
import {OnlineAccessInfo} from '../auth/oauth/types';
import {AuthScopes} from '../auth/scopes';
import {
decryptString,
encryptString,
generateIV,
asBase64,
fromBase64,
} from '../../runtime/crypto';

import {SessionParams} from './types';

type SessionParamsArray = [string, string | number | boolean][];

const propertiesToSave = [
'id',
'shop',
Expand All @@ -20,8 +30,10 @@ const propertiesToSave = [
* Stores App information from logged in merchants so they can make authenticated requests to the Admin API.
*/
export class Session {
private static CIPHER_PREFIX = 'encrypted#';

public static fromPropertyArray(
entries: [string, string | number | boolean][],
entries: SessionParamsArray,
returnUserData = false,
): Session {
if (!Array.isArray(entries)) {
Expand Down Expand Up @@ -134,6 +146,48 @@ export class Session {
return session;
}

public static async fromEncryptedPropertyArray(
entries: SessionParamsArray,
cryptoKey: CryptoKey,
returnUserData = false,
) {
const decryptedEntries: SessionParamsArray = [];
for (const [key, value] of entries) {
switch (key) {
case 'accessToken':
decryptedEntries.push([
key,
await this.decryptValue(value as string, cryptoKey),
]);
break;
default:
decryptedEntries.push([key, value]);
break;
}
}

return this.fromPropertyArray(decryptedEntries, returnUserData);
}

private static async encryptValue(value: string, key: CryptoKey) {
const iv = generateIV();
const cipher = await encryptString(value, {key, iv});

return `${Session.CIPHER_PREFIX}${asBase64(iv)}${cipher}`;
}

private static async decryptValue(value: string, key: CryptoKey) {
if (!value.startsWith(Session.CIPHER_PREFIX)) {
return value;
}

const keyString = value.slice(Session.CIPHER_PREFIX.length);
const iv = new Uint8Array(fromBase64(keyString.slice(0, 16)));
const cipher = keyString.slice(16);

return decryptString(cipher, {key, iv});
}

/**
* The unique identifier for the session.
*/
Expand Down Expand Up @@ -208,7 +262,7 @@ export class Session {
}

/**
* Converts an object with data into a Session.
* Converts a Session into an object with its data, that can be used to construct another Session.
*/
public toObject(): SessionParams {
const object: SessionParams = {
Expand Down Expand Up @@ -259,19 +313,42 @@ export class Session {
/**
* Converts the session into an array of key-value pairs.
*/
public toPropertyArray(
public toPropertyArray(returnUserData = false): SessionParamsArray {
return this.flattenProperties(this.toObject(), returnUserData);
}

/**
* Converts the session into an array of key-value pairs, encrypting sensitive data.
*
* The encrypted string will contain both the IV and the encrypted value.
*/
public async toEncryptedPropertyArray(
key: CryptoKey,
returnUserData = false,
): [string, string | number | boolean][] {
): Promise<SessionParamsArray> {
const object = this.toObject();

if (object.accessToken) {
object.accessToken = await Session.encryptValue(object.accessToken, key);
}

return this.flattenProperties(object, returnUserData);
}

private flattenProperties(
params: SessionParams,
returnUserData: boolean,
): SessionParamsArray {
return (
Object.entries(this)
Object.entries(params)
.filter(
([key, value]) =>
propertiesToSave.includes(key) &&
value !== undefined &&
value !== null,
)
// Prepare values for db storage
.flatMap(([key, value]): [string, string | number | boolean][] => {
.flatMap(([key, value]): SessionParamsArray => {
switch (key) {
case 'expires':
return [[key, value ? value.getTime() : undefined]];
Expand Down
1 change: 1 addition & 0 deletions packages/apps/shopify-api/runtime/__tests__/all.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import '../crypto/__tests__/encrypt.test';
import '../crypto/__tests__/hmac.test';
import '../http/__tests__/http.test';
import '../platform/__tests__/platform.test';
22 changes: 22 additions & 0 deletions packages/apps/shopify-api/runtime/crypto/__tests__/encrypt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {decryptString, encryptString} from '../encrypt';
import {getCryptoLib} from '../utils';

it('can encrypt and decrypt a string with a random key and IV', async () => {
// GIVEN
const cryptoLib = getCryptoLib();

const iv = cryptoLib.getRandomValues(new Uint8Array(12));
const key = await cryptoLib.subtle.generateKey(
{name: 'AES-GCM', length: 256},
true,
['encrypt', 'decrypt'],
);

// WHEN
const encryptedValue = await encryptString('Test encrypted value', {key, iv});
const result = await decryptString(encryptedValue, {key, iv});

// THEN
expect(encryptedValue).not.toEqual('Test encrypted value');
expect(result).toEqual('Test encrypted value');
});
Loading

0 comments on commit a425cfe

Please sign in to comment.