From c9555aa33447a365640d6253a0e73d3cb1f64c14 Mon Sep 17 00:00:00 2001 From: Adam Elmore Date: Wed, 2 Feb 2022 16:24:01 -0600 Subject: [PATCH] feat(dynamodb): configurable dynamodb keys (#380) --- packages/dynamodb/README.md | 19 ++- packages/dynamodb/jest-dynamodb-config.js | 30 ++++ packages/dynamodb/src/index.ts | 178 ++++++++++++++-------- packages/dynamodb/src/utils.ts | 9 +- packages/dynamodb/tests/custom.test.ts | 105 +++++++++++++ 5 files changed, 273 insertions(+), 68 deletions(-) create mode 100644 packages/dynamodb/tests/custom.test.ts diff --git a/packages/dynamodb/README.md b/packages/dynamodb/README.md index 2fe8617f..0dce2097 100644 --- a/packages/dynamodb/README.md +++ b/packages/dynamodb/README.md @@ -16,7 +16,7 @@ This is the AWS DynamoDB Adapter for next-auth. This package can only be used in conjunction with the primary next-auth package. It is not a standalone package. -You need a table with a partition key `pk` and a sort key `sk`. Your table also needs a global secondary index named `GSI1` with `GSI1PK` as partition key and `GSI1SK` as sorting key. You can set whatever you want as the table name and the billing method. +By default, the adapter expects a table with a partition key `pk` and a sort key `sk`, as well as a global secondary index named `GSI1` with `GSI1PK` as partition key and `GSI1SK` as sorting key. You can set whatever you want as the table name and the billing method. If you want sessions and verification tokens to get automatically removed from your table you need to [activate TTL](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html) on your table with the TTL attribute name set to `expires` @@ -88,12 +88,27 @@ The table respects the single table design pattern. This has many advantages: - Querying relations is faster than with multi-table schemas (for eg. retreiving all sessions for a user). - Only one table needs to be replicated, if you want to go multi-region. -Here is a schema of the table : +Here is the default schema of the table:

+## Customize table structure + +You can configure your custom table structure by passing the `options` key to the adapter constructor: + +``` +const adapter = DynamoDBAdapter(client, { + tableName: "custom-table-name", + partitionKey: "custom-pk", + sortKey: "custom-sk", + indexName: "custom-index-name", + indexPartitionKey: "custom-index-pk", + indexSortKey: "custom-index-sk", +}) +``` + ## Contributing We're open to all community contributions! If you'd like to contribute in any way, please read our [Contributing Guide](https://github.com/nextauthjs/adapters/blob/main/CONTRIBUTING.md). diff --git a/packages/dynamodb/jest-dynamodb-config.js b/packages/dynamodb/jest-dynamodb-config.js index beb6c8e3..98473dd9 100644 --- a/packages/dynamodb/jest-dynamodb-config.js +++ b/packages/dynamodb/jest-dynamodb-config.js @@ -30,6 +30,36 @@ module.exports = { ], ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, }, + { + TableName: `next-auth-custom`, + KeySchema: [ + { AttributeName: "PK", KeyType: "HASH" }, + { AttributeName: "SK", KeyType: "RANGE" }, + ], + AttributeDefinitions: [ + { AttributeName: "PK", AttributeType: "S" }, + { AttributeName: "SK", AttributeType: "S" }, + { AttributeName: "gsi1pk", AttributeType: "S" }, + { AttributeName: "gsi1sk", AttributeType: "S" }, + ], + GlobalSecondaryIndexes: [ + { + IndexName: "gsi1", + KeySchema: [ + { AttributeName: "gsi1pk", KeyType: "HASH" }, + { AttributeName: "gsi1sk", KeyType: "RANGE" }, + ], + Projection: { + ProjectionType: "ALL", + }, + ProvisionedThroughput: { + ReadCapacityUnits: 1, + WriteCapacityUnits: 1, + }, + }, + ], + ProvisionedThroughput: { ReadCapacityUnits: 1, WriteCapacityUnits: 1 }, + }, // etc ], port: 8000, diff --git a/packages/dynamodb/src/index.ts b/packages/dynamodb/src/index.ts index e84d81a5..8aeb1ce1 100644 --- a/packages/dynamodb/src/index.ts +++ b/packages/dynamodb/src/index.ts @@ -16,11 +16,62 @@ import { format, generateUpdateExpression } from "./utils" export { format, generateUpdateExpression } +export interface DynamoDBAdapterOptions { + /** + * The name of the DynamoDB table. + * + * @default next-auth + */ + tableName?: string + + /** + * The name of the global secondary index (GSI). + * + * @default GSI1 + */ + indexName?: string + + /** + * The partition key of the DynamoDB table. + * + * @default pk + */ + partitionKey?: string + + /** + * The sort key of the DynamoDB table. + * + * @default sk + */ + sortKey?: string + + /** + * The partition key of the global secondary index (GSI). + * + * @default GSI1PK + */ + indexPartitionKey?: string + + /** + * The sort key of the global secondary index (GSI). + * + * @default GSI1SK + */ + indexSortKey?: string +} + export function DynamoDBAdapter( client: DynamoDBDocument, - options?: { tableName: string } + options?: DynamoDBAdapterOptions ): Adapter { const TableName = options?.tableName ?? "next-auth" + const IndexName = options?.indexName ?? "GSI1" + + const partitionKey = options?.partitionKey ?? "pk" + const sortKey = options?.sortKey ?? "sk" + const indexPartitionKey = options?.indexPartitionKey ?? "GSI1PK" + const indexSortKey = options?.indexSortKey ?? "GSI1SK" + const keys = [partitionKey, sortKey, indexPartitionKey, indexSortKey] return { async createUser(data) { @@ -33,11 +84,11 @@ export function DynamoDBAdapter( TableName, Item: format.to({ ...user, - pk: `USER#${user.id}`, - sk: `USER#${user.id}`, + [partitionKey]: `USER#${user.id}`, + [sortKey]: `USER#${user.id}`, type: "USER", - GSI1PK: `USER#${user.email as string}`, - GSI1SK: `USER#${user.email as string}`, + [indexPartitionKey]: `USER#${user.email as string}`, + [indexSortKey]: `USER#${user.email as string}`, }), }) @@ -47,20 +98,20 @@ export function DynamoDBAdapter( const data = await client.get({ TableName, Key: { - pk: `USER#${userId}`, - sk: `USER#${userId}`, + [partitionKey]: `USER#${userId}`, + [sortKey]: `USER#${userId}`, }, }) - return format.from(data.Item) + return format.from(data.Item, keys) }, async getUserByEmail(email) { const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `USER#${email}`, @@ -68,16 +119,16 @@ export function DynamoDBAdapter( }, }) - return format.from(data.Items?.[0]) + return format.from(data.Items?.[0], keys) }, async getUserByAccount({ provider, providerAccountId }) { const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `ACCOUNT#${provider}`, @@ -90,11 +141,11 @@ export function DynamoDBAdapter( const res = await client.get({ TableName, Key: { - pk: `USER#${accounts.userId}`, - sk: `USER#${accounts.userId}`, + [partitionKey]: `USER#${accounts.userId}`, + [sortKey]: `USER#${accounts.userId}`, }, }) - return format.from(res.Item) + return format.from(res.Item, keys) }, async updateUser(user) { const { @@ -106,8 +157,8 @@ export function DynamoDBAdapter( TableName, Key: { // next-auth type is incorrect it should be Partial & {id: string} instead of just Partial - pk: `USER#${user.id as string}`, - sk: `USER#${user.id as string}`, + [partitionKey]: `USER#${user.id as string}`, + [sortKey]: `USER#${user.id as string}`, }, UpdateExpression, ExpressionAttributeNames, @@ -116,14 +167,14 @@ export function DynamoDBAdapter( }) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - return format.from(data.Attributes)! + return format.from(data.Attributes, keys)! }, async deleteUser(userId) { // query all the items related to the user to delete const res = await client.query({ TableName, KeyConditionExpression: "#pk = :pk", - ExpressionAttributeNames: { "#pk": "pk" }, + ExpressionAttributeNames: { "#pk": partitionKey }, ExpressionAttributeValues: { ":pk": `USER#${userId}` }, }) if (!res.Items) return null @@ -134,8 +185,8 @@ export function DynamoDBAdapter( return { DeleteRequest: { Key: { - sk: item.sk, - pk: item.pk, + [sortKey]: item[sortKey], + [partitionKey]: item[partitionKey], }, }, } @@ -146,16 +197,16 @@ export function DynamoDBAdapter( RequestItems: { [TableName]: itemsToDeleteMax }, } await client.batchWrite(param) - return format.from(user) + return format.from(user, keys) }, async linkAccount(data) { const item = { ...data, id: randomBytes(16).toString("hex"), - pk: `USER#${data.userId}`, - sk: `ACCOUNT#${data.provider}#${data.providerAccountId}`, - GSI1PK: `ACCOUNT#${data.provider}`, - GSI1SK: `ACCOUNT#${data.providerAccountId}`, + [partitionKey]: `USER#${data.userId}`, + [sortKey]: `ACCOUNT#${data.provider}#${data.providerAccountId}`, + [indexPartitionKey]: `ACCOUNT#${data.provider}`, + [indexSortKey]: `ACCOUNT#${data.providerAccountId}`, } await client.put({ TableName, Item: format.to(item) }) return data @@ -163,24 +214,24 @@ export function DynamoDBAdapter( async unlinkAccount({ provider, providerAccountId }) { const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `ACCOUNT#${provider}`, ":gsi1sk": `ACCOUNT#${providerAccountId}`, }, }) - const account = format.from(data.Items?.[0]) + const account = format.from(data.Items?.[0], keys) if (!account) return await client.delete({ TableName, Key: { - pk: `USER#${account.userId}`, - sk: `ACCOUNT#${provider}#${providerAccountId}`, + [partitionKey]: `USER#${account.userId}`, + [sortKey]: `ACCOUNT#${provider}#${providerAccountId}`, }, ReturnValues: "ALL_OLD", }) @@ -189,27 +240,27 @@ export function DynamoDBAdapter( async getSessionAndUser(sessionToken) { const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, ":gsi1sk": `SESSION#${sessionToken}`, }, }) - const session = format.from(data.Items?.[0]) + const session = format.from(data.Items?.[0], keys) if (!session) return null const res = await client.get({ TableName, Key: { - pk: `USER#${session.userId}`, - sk: `USER#${session.userId}`, + [partitionKey]: `USER#${session.userId}`, + [sortKey]: `USER#${session.userId}`, }, }) - const user = format.from(res.Item) + const user = format.from(res.Item, keys) if (!user) return null return { user, session } }, @@ -221,10 +272,10 @@ export function DynamoDBAdapter( await client.put({ TableName, Item: format.to({ - pk: `USER#${data.userId}`, - sk: `SESSION#${data.sessionToken}`, - GSI1SK: `SESSION#${data.sessionToken}`, - GSI1PK: `SESSION#${data.sessionToken}`, + [partitionKey]: `USER#${data.userId}`, + [sortKey]: `SESSION#${data.sessionToken}`, + [indexSortKey]: `SESSION#${data.sessionToken}`, + [indexPartitionKey]: `SESSION#${data.sessionToken}`, type: "SESSION", ...data, }), @@ -235,11 +286,11 @@ export function DynamoDBAdapter( const { sessionToken } = session const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, @@ -247,7 +298,8 @@ export function DynamoDBAdapter( }, }) if (!data.Items?.length) return null - const { pk, sk } = data.Items[0] as any + const item = data.Items[0] as any + const { UpdateExpression, ExpressionAttributeNames, @@ -255,22 +307,22 @@ export function DynamoDBAdapter( } = generateUpdateExpression(session) const res = await client.update({ TableName, - Key: { pk, sk }, + Key: { [partitionKey]: item[partitionKey], [sortKey]: item[sortKey] }, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, ReturnValues: "ALL_NEW", }) - return format.from(res.Attributes) + return format.from(res.Attributes, keys) }, async deleteSession(sessionToken) { const data = await client.query({ TableName, - IndexName: "GSI1", + IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { - "#gsi1pk": "GSI1PK", - "#gsi1sk": "GSI1SK", + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, @@ -279,21 +331,21 @@ export function DynamoDBAdapter( }) if (!data?.Items?.length) return null - const { pk, sk } = data.Items[0] + const item = data.Items[0] as any const res = await client.delete({ TableName, - Key: { pk, sk }, + Key: { [partitionKey]: item[partitionKey], [sortKey]: item[sortKey] }, ReturnValues: "ALL_OLD", }) - return format.from(res.Attributes) + return format.from(res.Attributes, keys) }, async createVerificationToken(data) { await client.put({ TableName, Item: format.to({ - pk: `VT#${data.identifier}`, - sk: `VT#${data.token}`, + [partitionKey]: `VT#${data.identifier}`, + [sortKey]: `VT#${data.token}`, type: "VT", ...data, }), @@ -304,12 +356,12 @@ export function DynamoDBAdapter( const data = await client.delete({ TableName, Key: { - pk: `VT#${identifier}`, - sk: `VT#${token}`, + [partitionKey]: `VT#${identifier}`, + [sortKey]: `VT#${token}`, }, ReturnValues: "ALL_OLD", }) - return format.from(data.Attributes) + return format.from(data.Attributes, keys) }, } } diff --git a/packages/dynamodb/src/utils.ts b/packages/dynamodb/src/utils.ts index 0d076b02..1b85b3cf 100644 --- a/packages/dynamodb/src/utils.ts +++ b/packages/dynamodb/src/utils.ts @@ -20,13 +20,16 @@ export const format = { return newObject }, /** Takes a Dynamo object and returns a plain old JavaScript object */ - from>(object?: Record): T | null { + from>( + object: Record | undefined, + keys: string[] = ["pk", "sk", "GSI1PK", "GSI1SK"] + ): T | null { if (!object) return null const newObject: Record = {} for (const key in object) { - // Filter DynamoDB specific attributes so it doesn't get passed to core, + // Filter DynamoDB table and GSI keys so it doesn't get passed to core, // to avoid revealing the type of database - if (["pk", "sk", "GSI1PK", "GSI1SK"].includes(key)) continue + if (keys.includes(key)) continue const value = object[key] diff --git a/packages/dynamodb/tests/custom.test.ts b/packages/dynamodb/tests/custom.test.ts new file mode 100644 index 00000000..349094be --- /dev/null +++ b/packages/dynamodb/tests/custom.test.ts @@ -0,0 +1,105 @@ +import type { DynamoDBAdapterOptions } from "../src" +import { DynamoDBAdapter, format } from "../src" +import { runBasicTests } from "../../../basic-tests" +import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb" +import { DynamoDB } from "@aws-sdk/client-dynamodb" + +const config = { + endpoint: "http://127.0.0.1:8000", + region: "eu-central-1", + tls: false, + credentials: { + accessKeyId: "foo", + secretAccessKey: "bar", + }, +} + +export const client = DynamoDBDocument.from(new DynamoDB(config), { + marshallOptions: { + convertEmptyValues: true, + removeUndefinedValues: true, + convertClassInstanceToMap: true, + }, +}) + +const tableName = "next-auth-custom" +const indexName = "gsi1" +const partitionKey = "PK" +const sortKey = "SK" +const indexPartitionKey = "gsi1pk" +const indexSortKey = "gsi1sk" +const keys = [partitionKey, sortKey, indexPartitionKey, indexSortKey] + +const options: DynamoDBAdapterOptions = { + tableName, + partitionKey, + sortKey, + indexName, + indexPartitionKey, + indexSortKey, +} + +const adapter = DynamoDBAdapter(client, options) +const TableName = tableName +const IndexName = indexName + +runBasicTests({ + adapter, + db: { + async user(id) { + const user = await client.get({ + TableName, + Key: { + [partitionKey]: `USER#${id}`, + [sortKey]: `USER#${id}`, + }, + }) + + return format.from(user.Item, keys) + }, + async session(token) { + const session = await client.query({ + TableName, + IndexName, + KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", + ExpressionAttributeNames: { + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, + }, + ExpressionAttributeValues: { + ":gsi1pk": `SESSION#${token}`, + ":gsi1sk": `SESSION#${token}`, + }, + }) + + return format.from(session.Items?.[0], keys) + }, + async account({ provider, providerAccountId }) { + const account = await client.query({ + TableName, + IndexName, + KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", + ExpressionAttributeNames: { + "#gsi1pk": indexPartitionKey, + "#gsi1sk": indexSortKey, + }, + ExpressionAttributeValues: { + ":gsi1pk": `ACCOUNT#${provider}`, + ":gsi1sk": `ACCOUNT#${providerAccountId}`, + }, + }) + + return format.from(account.Items?.[0], keys) + }, + async verificationToken({ token, identifier }) { + const vt = await client.get({ + TableName, + Key: { + [partitionKey]: `VT#${identifier}`, + [sortKey]: `VT#${token}`, + }, + }) + return format.from(vt.Item, keys) + }, + }, +})