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

Add ability to encrypt session data #818

Closed
wants to merge 2 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
61 changes: 43 additions & 18 deletions packages/apps/shopify-api/docs/guides/session-storage.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,16 @@
# Storing sessions

As of v6 of the library, there are no `SessionStorage` implementations included and the responsibility for implementing session storage is now delegated to the application.
In order to be able to load user data without using cookies, your app will need to store its access tokens and other relevant data.

The previous implementations of `SessionStorage` are now available in their own packages, the source of which is available in the [respective directory](../../../session-storage#readme).
This package provides a `SessionStorage` interface that makes it easy to plug in a new storage strategy to your app.
You can use of one the [packages we provide](../../../session-storage/README.md), or implement your own following the instructions in this page.

| Package | Session storage object | Notes |
| :-----------------------------------------------: | :----------------------: | ---------------------------------------- |
| `@shopify/shopify-app-session-storage-memory` | MemorySessionStorage | |
| `@shopify/shopify-app-session-storage-mongodb` | MongoDBSessionStorage | |
| `@shopify/shopify-app-session-storage-mysql` | MySQLSessionStorage | |
| `@shopify/shopify-app-session-storage-postgresql` | PostgreSQLSessionStorage | |
| `@shopify/shopify-app-session-storage-redis` | RedisSessionStorage | |
| `@shopify/shopify-app-session-storage-sqlite` | SQLiteSessionStorage | |
| `@shopify/shopify-app-session-storage-dynamodb` | DynamoDBSessionStorage | |
| `@shopify/shopify-app-session-storage-kv` | KVSessionStorage | |
| `@shopify/shopify-app-session-storage` | SessionStorage | Abstract class used by the classes above |
In this page, you'll file:

## Basics
- [What data is in a `Session` object?](#what-data-is-in-a-session-object)
- [Save a session to storage](#save-a-session-to-storage)
- [Load a session from storage](#load-a-session-from-storage)
- [Encrypting data for storage](#encrypting-data-for-storage)

### What data is in a `Session` object?

Expand Down Expand Up @@ -61,11 +55,9 @@ const sessionCopy = new Session(callbackResponse.session.toObject());
// sessionCopy is an identical copy of the callbackResponse.session instance
```

Now that the app has a JavaScript object containing the data of a `Session`, it can convert the data into whatever means necessary to store it in the apps preferred storage mechanism. Various implementations of session storage can be found in the [`session-storages` folder](../../../session-storage#readme).
When converting the data for storage, the `Session` class also includes an instance method called `.toPropertyArray` that returns an array of key-value pairs constructed from the result of `toObject`. `toPropertyArray` has an optional parameter `returnUserData`, defaulted to false, when set to true it will return the associated user data as part of the property array object.

The `Session` class also includes an instance method called `.toPropertyArray` that returns an array of key-value pairs, e.g.,

`toPropertyArray` has an optional parameter `returnUserData`, defaulted to false, when set to true it will return the associated user data as part of the property array object.
With that object containing the data of a `Session`, the app can convert the data into whatever it needs to store using its preferred mechanism. Various implementations of session storage can be found in the [`session-storages` folder](../../../session-storage#readme).

```ts
const {session, headers} = shopify.auth.callback({
Expand Down Expand Up @@ -294,4 +286,37 @@ const session = Session.fromPropertyArray(sessionProperties);
>
> The existing [SQL-based implementations](../../../session-storage#readme), i.e., MySQL, PostgreSQL and SQLite, convert it from seconds from storage. The remaining implementations do not change the retrieved `expires` property.

### Encrypting data for storage

If you want to encrypt your sessions before storing them, the `Session` class provides two methods: `fromEncryptedPropertyArray` and `toEncryptedPropertyArray`.

These behave the same as their non-encrypted counterparts, but are `async` and take in a `CryptoKey` object.
If a session is currently not encrypted in storage, these methods will still load it normally, and will encrypt them when saving them.

`fromEncryptedPropertyArray` will return ciphers for the encrypted fields, prefixed with `encrypted#`.
That way, storage providers can progressively update sessions as they're loaded and saved, or run a migration script that simply loads and saves every session.

By default, only the access token is encrypted, but you can pass in any fields (including custom ones not from the `Session` class) you want to encrypt.
Note that some fields are not allowed for encryption: `id`, `expires`, and any of the boolean fields.

You can use the static `Session.DEFAULT_ENCRYPTED_PROPERTIES` to encrypt the default properties, in addition to your own.
That enables apps to continue using the default behaviour while customizing it.

```ts
// Get encrypted data array
const sessionProperties = await session.toEncryptedPropertyArray({
cryptoKey,
// Use the default encrypted columns
propertiesToEncrypt: [...Session.DEFAULT_ENCRYPTED_PROPERTIES, 'customField'],
});

// Store and load data
// ...

// Create session from encrypted data
const newSession = Session.fromEncryptedPropertyArray(sessionProperties, {
cryptoKey,
});
```

[Back to guide index](../../README.md#guides)
277 changes: 277 additions & 0 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 @@ -636,3 +637,279 @@ describe('toPropertyArray and fromPropertyArray', () => {
});
});
});

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

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

cryptoKey = 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({
cryptoKey,
returnUserData: 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({
cryptoKey,
returnUserData: test.returnUserData,
}),
{cryptoKey, returnUserData: 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({
cryptoKey,
returnUserData: test.returnUserData,
}),
{cryptoKey, returnUserData: 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({
cryptoKey,
returnUserData: 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(async () =>
Session.fromEncryptedPropertyArray(tamperedProps, {
cryptoKey,
returnUserData: false,
}),
).rejects.toThrow('The provided data is too small.');
});

describe('encrypting multiple fields', () => {
let session: Session;

beforeEach(() => {
session = new Session({
id: 'offline_session_id',
shop: 'example.myshopify.io',
state: 'offline-session-state',
isOnline: true,
scope: 'test_scope',
accessToken: 'offline-session-token',
expires: expiresDate,
onlineAccessInfo: {
expires_in: 1,
associated_user_scope: 'user_scope',
associated_user: {
id: 1,
first_name: 'first-name',
last_name: 'last-name',
email: 'email',
locale: 'locale',
email_verified: true,
account_owner: true,
collaborator: false,
},
},
});
});

it('can encrypt and decrypt all fields', async () => {
// GIVEN
const encryptFields = [
'shop',
'state',
'scope',
'accessToken',
'userId',
'firstName',
'lastName',
'email',
'locale',
];

// WHEN
const encryptedProps = await session.toEncryptedPropertyArray({
cryptoKey,
propertiesToEncrypt: encryptFields,
returnUserData: true,
});
const newSession = await Session.fromEncryptedPropertyArray(
encryptedProps,
{cryptoKey, returnUserData: true},
);

// THEN
expect(encryptedProps).toMatchObject([
['id', 'offline_session_id'],
['shop', expect.stringMatching(/^encrypted#/)],
['state', expect.stringMatching(/^encrypted#/)],
['isOnline', true],
['scope', expect.stringMatching(/^encrypted#/)],
['accessToken', expect.stringMatching(/^encrypted#/)],
['expires', expect.any(Number)],
['userId', expect.stringMatching(/^encrypted#/)],
['firstName', expect.stringMatching(/^encrypted#/)],
['lastName', expect.stringMatching(/^encrypted#/)],
['email', expect.stringMatching(/^encrypted#/)],
['locale', expect.stringMatching(/^encrypted#/)],
['emailVerified', true],
['accountOwner', true],
['collaborator', false],
]);
expect(newSession.equals(session)).toBeTruthy();
});

it('can encrypt and decrypt custom fields', async () => {
// GIVEN
const sessionWithCustomFields = new Session({
...session.toObject(),
customField: 'custom',
});

// WHEN
const encryptedProps =
await sessionWithCustomFields.toEncryptedPropertyArray({
cryptoKey,
propertiesToEncrypt: ['customField'],
returnUserData: true,
});
const newSession = await Session.fromEncryptedPropertyArray(
encryptedProps,
{cryptoKey, returnUserData: true},
);

// THEN
const index = encryptedProps.findIndex(([key]) => key === 'customField');
expect(index).toBeGreaterThan(-1);
expect(encryptedProps[index][1]).toMatch(/^encrypted#/);
expect((newSession as any).customField).toEqual(
(sessionWithCustomFields as any).customField,
);
});

it.each(['id', 'expires', 'emailVerified', 'accountOwner', 'collaborator'])(
"can't encrypt '%s' field",
async (field) => {
// WHEN
await expect(
session.toEncryptedPropertyArray({
cryptoKey,
propertiesToEncrypt: [field],
returnUserData: true,
}),
).rejects.toThrow(`Can't encrypt fields: [${field}]`);
},
);
});
});
Loading
Loading