/** *
*

Official DynamoDB adapter for Auth.js / NextAuth.js.

* * * *
* * ## Installation * * ```bash npm2yarn * npm install next-auth @auth/dynamodb-adapter * ``` * * @module @auth/dynamodb-adapter */ import type { BatchWriteCommandInput, DynamoDBDocument, } from "@aws-sdk/lib-dynamodb" import { type Adapter, type AdapterSession, type AdapterAccount, type AdapterUser, type VerificationToken, isDate, } from "@auth/core/adapters" export interface DynamoDBAdapterOptions { tableName?: string partitionKey?: string sortKey?: string indexName?: string indexPartitionKey?: string indexSortKey?: string } export function DynamoDBAdapter( client: DynamoDBDocument, options?: DynamoDBAdapterOptions ): Adapter { const TableName = options?.tableName ?? "next-auth" const pk = options?.partitionKey ?? "pk" const sk = options?.sortKey ?? "sk" const IndexName = options?.indexName ?? "GSI1" const GSI1PK = options?.indexPartitionKey ?? "GSI1PK" const GSI1SK = options?.indexSortKey ?? "GSI1SK" return { async createUser(data) { const user: AdapterUser = { ...(data as any), id: crypto.randomUUID(), } await client.put({ TableName, Item: format.to({ ...user, [pk]: `USER#${user.id}`, [sk]: `USER#${user.id}`, type: "USER", [GSI1PK]: `USER#${user.email}`, [GSI1SK]: `USER#${user.email}`, }), }) return user }, async getUser(userId) { const data = await client.get({ TableName, Key: { [pk]: `USER#${userId}`, [sk]: `USER#${userId}`, }, }) return format.from(data.Item) }, async getUserByEmail(email) { const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `USER#${email}`, ":gsi1sk": `USER#${email}`, }, }) return format.from(data.Items?.[0]) }, async getUserByAccount({ provider, providerAccountId }) { const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `ACCOUNT#${provider}`, ":gsi1sk": `ACCOUNT#${providerAccountId}`, }, }) if (!data.Items?.length) return null const accounts = data.Items[0] as AdapterAccount const res = await client.get({ TableName, Key: { [pk]: `USER#${accounts.userId}`, [sk]: `USER#${accounts.userId}`, }, }) return format.from(res.Item) }, async updateUser(user) { const { UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, } = generateUpdateExpression(user) const data = await client.update({ TableName, Key: { [pk]: `USER#${user.id}`, [sk]: `USER#${user.id}`, }, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, ReturnValues: "ALL_NEW", }) return format.from(data.Attributes)! }, 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 }, ExpressionAttributeValues: { ":pk": `USER#${userId}` }, }) if (!res.Items) return null const items = res.Items // find the user we want to delete to return at the end of the function call const user = items.find((item) => item.type === "USER") const itemsToDelete = items.map((item) => { return { DeleteRequest: { Key: { [sk]: item.sk, [pk]: item.pk, }, }, } }) // batch write commands cannot handle more than 25 requests at once const itemsToDeleteMax = itemsToDelete.slice(0, 25) const param: BatchWriteCommandInput = { RequestItems: { [TableName]: itemsToDeleteMax }, } await client.batchWrite(param) return format.from(user) }, async linkAccount(data) { const item = { ...data, id: crypto.randomUUID(), [pk]: `USER#${data.userId}`, [sk]: `ACCOUNT#${data.provider}#${data.providerAccountId}`, [GSI1PK]: `ACCOUNT#${data.provider}`, [GSI1SK]: `ACCOUNT#${data.providerAccountId}`, } await client.put({ TableName, Item: format.to(item) }) return data }, async unlinkAccount({ provider, providerAccountId }) { const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `ACCOUNT#${provider}`, ":gsi1sk": `ACCOUNT#${providerAccountId}`, }, }) const account = format.from(data.Items?.[0]) if (!account) return await client.delete({ TableName, Key: { [pk]: `USER#${account.userId}`, [sk]: `ACCOUNT#${provider}#${providerAccountId}`, }, ReturnValues: "ALL_OLD", }) return account }, async getSessionAndUser(sessionToken) { const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, ":gsi1sk": `SESSION#${sessionToken}`, }, }) const session = format.from(data.Items?.[0]) if (!session) return null const res = await client.get({ TableName, Key: { [pk]: `USER#${session.userId}`, [sk]: `USER#${session.userId}`, }, }) const user = format.from(res.Item) if (!user) return null return { user, session } }, async createSession(data) { const session = { id: crypto.randomUUID(), ...data, } await client.put({ TableName, Item: format.to({ [pk]: `USER#${data.userId}`, [sk]: `SESSION#${data.sessionToken}`, [GSI1SK]: `SESSION#${data.sessionToken}`, [GSI1PK]: `SESSION#${data.sessionToken}`, type: "SESSION", ...data, }), }) return session }, async updateSession(session) { const { sessionToken } = session const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, ":gsi1sk": `SESSION#${sessionToken}`, }, }) if (!data.Items?.length) return null const sessionRecord = data.Items[0] const { UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, } = generateUpdateExpression(session) const res = await client.update({ TableName, Key: { [pk]: sessionRecord[pk], [sk]: sessionRecord[sk], }, UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, ReturnValues: "ALL_NEW", }) return format.from(res.Attributes) }, async deleteSession(sessionToken) { const data = await client.query({ TableName, IndexName, KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk", ExpressionAttributeNames: { "#gsi1pk": GSI1PK, "#gsi1sk": GSI1SK, }, ExpressionAttributeValues: { ":gsi1pk": `SESSION#${sessionToken}`, ":gsi1sk": `SESSION#${sessionToken}`, }, }) if (!data?.Items?.length) return null const sessionRecord = data.Items[0] const res = await client.delete({ TableName, Key: { [pk]: sessionRecord[pk], [sk]: sessionRecord[sk], }, ReturnValues: "ALL_OLD", }) return format.from(res.Attributes) }, async createVerificationToken(data) { await client.put({ TableName, Item: format.to({ [pk]: `VT#${data.identifier}`, [sk]: `VT#${data.token}`, type: "VT", ...data, }), }) return data }, async useVerificationToken({ identifier, token }) { const data = await client.delete({ TableName, Key: { [pk]: `VT#${identifier}`, [sk]: `VT#${token}`, }, ReturnValues: "ALL_OLD", }) return format.from(data.Attributes) }, } } const format = { /** Takes a plain old JavaScript object and turns it into a DynamoDB object */ to(object: Record) { const newObject: Record = {} for (const key in object) { const value = object[key] if (value instanceof Date) { // DynamoDB requires the TTL attribute be a UNIX timestamp (in secs). if (key === "expires") newObject[key] = value.getTime() / 1000 else newObject[key] = value.toISOString() } else newObject[key] = value } return newObject }, /** Takes a Dynamo object and returns a plain old JavaScript object */ from>(object?: Record): 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, // to avoid revealing the type of database if (["pk", "sk", "GSI1PK", "GSI1SK"].includes(key)) continue const value = object[key] if (isDate(value)) newObject[key] = new Date(value) // hack to keep type property in account else if (key === "type" && ["SESSION", "VT", "USER"].includes(value)) continue // The expires property is stored as a UNIX timestamp in seconds, but // JavaScript needs it in milliseconds, so multiply by 1000. else if (key === "expires" && typeof value === "number") newObject[key] = new Date(value * 1000) else newObject[key] = value } return newObject as T }, } function generateUpdateExpression(object: Record): { UpdateExpression: string ExpressionAttributeNames: Record ExpressionAttributeValues: Record } { const formattedSession = format.to(object) let UpdateExpression = "set" const ExpressionAttributeNames: Record = {} const ExpressionAttributeValues: Record = {} for (const property in formattedSession) { UpdateExpression += ` #${property} = :${property},` ExpressionAttributeNames["#" + property] = property ExpressionAttributeValues[":" + property] = formattedSession[property] } UpdateExpression = UpdateExpression.slice(0, -1) return { UpdateExpression, ExpressionAttributeNames, ExpressionAttributeValues, } } export { format, generateUpdateExpression }