@auth/dynamodb-adapter 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +15 -0
- package/README.md +28 -0
- package/index.d.ts +175 -0
- package/index.d.ts.map +1 -0
- package/index.js +508 -0
- package/jest-dynamodb-config.js +41 -0
- package/jest.config.js +13 -0
- package/package.json +60 -0
- package/src/index.ts +545 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <div style={{display: "flex", justifyContent: "space-between", alignItems: "center", padding: 16}}>
|
|
3
|
+
* <p style={{fontWeight: "normal"}}>Official <a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Introduction.html">DynamoDB</a> adapter for Auth.js / NextAuth.js.</p>
|
|
4
|
+
* <a href="https://docs.aws.amazon.com/dynamodb/index.html">
|
|
5
|
+
* <img style={{display: "block"}} src="https://authjs.dev/img/adapters/dynamodb.png" width="48"/>
|
|
6
|
+
* </a>
|
|
7
|
+
* </div>
|
|
8
|
+
*
|
|
9
|
+
* ## Installation
|
|
10
|
+
*
|
|
11
|
+
* ```bash npm2yarn2pnpm
|
|
12
|
+
* npm install next-auth @auth/dynamodb-adapter
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* @module @auth/dynamodb-adapter
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type {
|
|
19
|
+
BatchWriteCommandInput,
|
|
20
|
+
DynamoDBDocument,
|
|
21
|
+
} from "@aws-sdk/lib-dynamodb"
|
|
22
|
+
import type {
|
|
23
|
+
Adapter,
|
|
24
|
+
AdapterSession,
|
|
25
|
+
AdapterAccount,
|
|
26
|
+
AdapterUser,
|
|
27
|
+
VerificationToken,
|
|
28
|
+
} from "@auth/core/adapters"
|
|
29
|
+
|
|
30
|
+
export interface DynamoDBAdapterOptions {
|
|
31
|
+
tableName?: string
|
|
32
|
+
partitionKey?: string
|
|
33
|
+
sortKey?: string
|
|
34
|
+
indexName?: string
|
|
35
|
+
indexPartitionKey?: string
|
|
36
|
+
indexSortKey?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ## Setup
|
|
41
|
+
*
|
|
42
|
+
* 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. To automatically delete sessions and verification requests after they expire using [dynamodb TTL](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/TTL.html) you should [enable the TTL](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/time-to-live-ttl-how-to.html) with attribute name 'expires'. You can set whatever you want as the table name and the billing method.
|
|
43
|
+
* You can find the full schema in the table structure section below.
|
|
44
|
+
*
|
|
45
|
+
* ### Configuring Auth.js
|
|
46
|
+
*
|
|
47
|
+
* You need to pass `DynamoDBDocument` client from the modular [`aws-sdk`](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/dynamodb-example-dynamodb-utilities.html) v3 to the adapter.
|
|
48
|
+
* The default table name is `next-auth`, but you can customise that by passing `{ tableName: 'your-table-name' }` as the second parameter in the adapter.
|
|
49
|
+
*
|
|
50
|
+
* ```javascript title="pages/api/auth/[...nextauth].js"
|
|
51
|
+
* import { DynamoDB, DynamoDBClientConfig } from "@aws-sdk/client-dynamodb"
|
|
52
|
+
* import { DynamoDBDocument } from "@aws-sdk/lib-dynamodb"
|
|
53
|
+
* import NextAuth from "next-auth";
|
|
54
|
+
* import Providers from "next-auth/providers";
|
|
55
|
+
* import { DynamoDBAdapter } from "@auth/dynamodb-adapter"
|
|
56
|
+
*
|
|
57
|
+
* const config: DynamoDBClientConfig = {
|
|
58
|
+
* credentials: {
|
|
59
|
+
* accessKeyId: process.env.NEXT_AUTH_AWS_ACCESS_KEY as string,
|
|
60
|
+
* secretAccessKey: process.env.NEXT_AUTH_AWS_SECRET_KEY as string,
|
|
61
|
+
* },
|
|
62
|
+
* region: process.env.NEXT_AUTH_AWS_REGION,
|
|
63
|
+
* };
|
|
64
|
+
*
|
|
65
|
+
* const client = DynamoDBDocument.from(new DynamoDB(config), {
|
|
66
|
+
* marshallOptions: {
|
|
67
|
+
* convertEmptyValues: true,
|
|
68
|
+
* removeUndefinedValues: true,
|
|
69
|
+
* convertClassInstanceToMap: true,
|
|
70
|
+
* },
|
|
71
|
+
* })
|
|
72
|
+
*
|
|
73
|
+
* export default NextAuth({
|
|
74
|
+
* // Configure one or more authentication providers
|
|
75
|
+
* providers: [
|
|
76
|
+
* Providers.GitHub({
|
|
77
|
+
* clientId: process.env.GITHUB_ID,
|
|
78
|
+
* clientSecret: process.env.GITHUB_SECRET,
|
|
79
|
+
* }),
|
|
80
|
+
* Providers.Email({
|
|
81
|
+
* server: process.env.EMAIL_SERVER,
|
|
82
|
+
* from: process.env.EMAIL_FROM,
|
|
83
|
+
* }),
|
|
84
|
+
* // ...add more providers here
|
|
85
|
+
* ],
|
|
86
|
+
* adapter: DynamoDBAdapter(
|
|
87
|
+
* client
|
|
88
|
+
* ),
|
|
89
|
+
* ...
|
|
90
|
+
* });
|
|
91
|
+
* ```
|
|
92
|
+
*
|
|
93
|
+
* (AWS secrets start with `NEXT_AUTH_` in order to not conflict with [Vercel's reserved environment variables](https://vercel.com/docs/environment-variables#reserved-environment-variables).)
|
|
94
|
+
*
|
|
95
|
+
* ## Advanced usage
|
|
96
|
+
*
|
|
97
|
+
* ### Default schema
|
|
98
|
+
*
|
|
99
|
+
* The table respects the single table design pattern. This has many advantages:
|
|
100
|
+
*
|
|
101
|
+
* - Only one table to manage, monitor and provision.
|
|
102
|
+
* - Querying relations is faster than with multi-table schemas (for eg. retrieving all sessions for a user).
|
|
103
|
+
* - Only one table needs to be replicated if you want to go multi-region.
|
|
104
|
+
*
|
|
105
|
+
* > This schema is adapted for use in DynamoDB and based upon our main [schema](https://authjs.dev/reference/adapters#models)
|
|
106
|
+
*
|
|
107
|
+
* 
|
|
108
|
+
*
|
|
109
|
+
* You can create this table with infrastructure as code using [`aws-cdk`](https://github.com/aws/aws-cdk) with the following table definition:
|
|
110
|
+
*
|
|
111
|
+
* ```javascript title=stack.ts
|
|
112
|
+
* new dynamodb.Table(this, `NextAuthTable`, {
|
|
113
|
+
* tableName: "next-auth",
|
|
114
|
+
* partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
|
|
115
|
+
* sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
|
|
116
|
+
* timeToLiveAttribute: "expires",
|
|
117
|
+
* }).addGlobalSecondaryIndex({
|
|
118
|
+
* indexName: "GSI1",
|
|
119
|
+
* partitionKey: { name: "GSI1PK", type: dynamodb.AttributeType.STRING },
|
|
120
|
+
* sortKey: { name: "GSI1SK", type: dynamodb.AttributeType.STRING },
|
|
121
|
+
* })
|
|
122
|
+
* ```
|
|
123
|
+
*
|
|
124
|
+
* Alternatively, you can use this cloudformation template:
|
|
125
|
+
*
|
|
126
|
+
* ```yaml title=cloudformation.yaml
|
|
127
|
+
* NextAuthTable:
|
|
128
|
+
* Type: "AWS::DynamoDB::Table"
|
|
129
|
+
* Properties:
|
|
130
|
+
* TableName: next-auth
|
|
131
|
+
* AttributeDefinitions:
|
|
132
|
+
* - AttributeName: pk
|
|
133
|
+
* AttributeType: S
|
|
134
|
+
* - AttributeName: sk
|
|
135
|
+
* AttributeType: S
|
|
136
|
+
* - AttributeName: GSI1PK
|
|
137
|
+
* AttributeType: S
|
|
138
|
+
* - AttributeName: GSI1SK
|
|
139
|
+
* AttributeType: S
|
|
140
|
+
* KeySchema:
|
|
141
|
+
* - AttributeName: pk
|
|
142
|
+
* KeyType: HASH
|
|
143
|
+
* - AttributeName: sk
|
|
144
|
+
* KeyType: RANGE
|
|
145
|
+
* GlobalSecondaryIndexes:
|
|
146
|
+
* - IndexName: GSI1
|
|
147
|
+
* Projection:
|
|
148
|
+
* ProjectionType: ALL
|
|
149
|
+
* KeySchema:
|
|
150
|
+
* - AttributeName: GSI1PK
|
|
151
|
+
* KeyType: HASH
|
|
152
|
+
* - AttributeName: GSI1SK
|
|
153
|
+
* KeyType: RANGE
|
|
154
|
+
* TimeToLiveSpecification:
|
|
155
|
+
* AttributeName: expires
|
|
156
|
+
* Enabled: true
|
|
157
|
+
* ```
|
|
158
|
+
*
|
|
159
|
+
* ### Using a custom schema
|
|
160
|
+
*
|
|
161
|
+
* You can configure your custom table schema by passing the `options` key to the adapter constructor:
|
|
162
|
+
*
|
|
163
|
+
* ```javascript
|
|
164
|
+
* const adapter = DynamoDBAdapter(client, {
|
|
165
|
+
* tableName: "custom-table-name",
|
|
166
|
+
* partitionKey: "custom-pk",
|
|
167
|
+
* sortKey: "custom-sk",
|
|
168
|
+
* indexName: "custom-index-name",
|
|
169
|
+
* indexPartitionKey: "custom-index-pk",
|
|
170
|
+
* indexSortKey: "custom-index-sk",
|
|
171
|
+
* })
|
|
172
|
+
* ```
|
|
173
|
+
**/
|
|
174
|
+
export function DynamoDBAdapter(
|
|
175
|
+
client: DynamoDBDocument,
|
|
176
|
+
options?: DynamoDBAdapterOptions
|
|
177
|
+
): Adapter {
|
|
178
|
+
const TableName = options?.tableName ?? "next-auth"
|
|
179
|
+
const pk = options?.partitionKey ?? "pk"
|
|
180
|
+
const sk = options?.sortKey ?? "sk"
|
|
181
|
+
const IndexName = options?.indexName ?? "GSI1"
|
|
182
|
+
const GSI1PK = options?.indexPartitionKey ?? "GSI1PK"
|
|
183
|
+
const GSI1SK = options?.indexSortKey ?? "GSI1SK"
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
async createUser(data) {
|
|
187
|
+
const user: AdapterUser = {
|
|
188
|
+
...(data as any),
|
|
189
|
+
id: crypto.randomUUID(),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await client.put({
|
|
193
|
+
TableName,
|
|
194
|
+
Item: format.to({
|
|
195
|
+
...user,
|
|
196
|
+
[pk]: `USER#${user.id}`,
|
|
197
|
+
[sk]: `USER#${user.id}`,
|
|
198
|
+
type: "USER",
|
|
199
|
+
[GSI1PK]: `USER#${user.email}`,
|
|
200
|
+
[GSI1SK]: `USER#${user.email}`,
|
|
201
|
+
}),
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
return user
|
|
205
|
+
},
|
|
206
|
+
async getUser(userId) {
|
|
207
|
+
const data = await client.get({
|
|
208
|
+
TableName,
|
|
209
|
+
Key: {
|
|
210
|
+
[pk]: `USER#${userId}`,
|
|
211
|
+
[sk]: `USER#${userId}`,
|
|
212
|
+
},
|
|
213
|
+
})
|
|
214
|
+
return format.from<AdapterUser>(data.Item)
|
|
215
|
+
},
|
|
216
|
+
async getUserByEmail(email) {
|
|
217
|
+
const data = await client.query({
|
|
218
|
+
TableName,
|
|
219
|
+
IndexName,
|
|
220
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
221
|
+
ExpressionAttributeNames: {
|
|
222
|
+
"#gsi1pk": GSI1PK,
|
|
223
|
+
"#gsi1sk": GSI1SK,
|
|
224
|
+
},
|
|
225
|
+
ExpressionAttributeValues: {
|
|
226
|
+
":gsi1pk": `USER#${email}`,
|
|
227
|
+
":gsi1sk": `USER#${email}`,
|
|
228
|
+
},
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return format.from<AdapterUser>(data.Items?.[0])
|
|
232
|
+
},
|
|
233
|
+
async getUserByAccount({ provider, providerAccountId }) {
|
|
234
|
+
const data = await client.query({
|
|
235
|
+
TableName,
|
|
236
|
+
IndexName,
|
|
237
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
238
|
+
ExpressionAttributeNames: {
|
|
239
|
+
"#gsi1pk": GSI1PK,
|
|
240
|
+
"#gsi1sk": GSI1SK,
|
|
241
|
+
},
|
|
242
|
+
ExpressionAttributeValues: {
|
|
243
|
+
":gsi1pk": `ACCOUNT#${provider}`,
|
|
244
|
+
":gsi1sk": `ACCOUNT#${providerAccountId}`,
|
|
245
|
+
},
|
|
246
|
+
})
|
|
247
|
+
if (!data.Items?.length) return null
|
|
248
|
+
|
|
249
|
+
const accounts = data.Items[0] as AdapterAccount
|
|
250
|
+
const res = await client.get({
|
|
251
|
+
TableName,
|
|
252
|
+
Key: {
|
|
253
|
+
[pk]: `USER#${accounts.userId}`,
|
|
254
|
+
[sk]: `USER#${accounts.userId}`,
|
|
255
|
+
},
|
|
256
|
+
})
|
|
257
|
+
return format.from<AdapterUser>(res.Item)
|
|
258
|
+
},
|
|
259
|
+
async updateUser(user) {
|
|
260
|
+
const {
|
|
261
|
+
UpdateExpression,
|
|
262
|
+
ExpressionAttributeNames,
|
|
263
|
+
ExpressionAttributeValues,
|
|
264
|
+
} = generateUpdateExpression(user)
|
|
265
|
+
const data = await client.update({
|
|
266
|
+
TableName,
|
|
267
|
+
Key: {
|
|
268
|
+
// next-auth type is incorrect it should be Partial<AdapterUser> & {id: string} instead of just Partial<AdapterUser>
|
|
269
|
+
[pk]: `USER#${user.id as string}`,
|
|
270
|
+
[sk]: `USER#${user.id as string}`,
|
|
271
|
+
},
|
|
272
|
+
UpdateExpression,
|
|
273
|
+
ExpressionAttributeNames,
|
|
274
|
+
ExpressionAttributeValues,
|
|
275
|
+
ReturnValues: "ALL_NEW",
|
|
276
|
+
})
|
|
277
|
+
|
|
278
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
279
|
+
return format.from<AdapterUser>(data.Attributes)!
|
|
280
|
+
},
|
|
281
|
+
async deleteUser(userId) {
|
|
282
|
+
// query all the items related to the user to delete
|
|
283
|
+
const res = await client.query({
|
|
284
|
+
TableName,
|
|
285
|
+
KeyConditionExpression: "#pk = :pk",
|
|
286
|
+
ExpressionAttributeNames: { "#pk": pk },
|
|
287
|
+
ExpressionAttributeValues: { ":pk": `USER#${userId}` },
|
|
288
|
+
})
|
|
289
|
+
if (!res.Items) return null
|
|
290
|
+
const items = res.Items
|
|
291
|
+
// find the user we want to delete to return at the end of the function call
|
|
292
|
+
const user = items.find((item) => item.type === "USER")
|
|
293
|
+
const itemsToDelete = items.map((item) => {
|
|
294
|
+
return {
|
|
295
|
+
DeleteRequest: {
|
|
296
|
+
Key: {
|
|
297
|
+
[sk]: item.sk,
|
|
298
|
+
[pk]: item.pk,
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
}
|
|
302
|
+
})
|
|
303
|
+
// batch write commands cannot handle more than 25 requests at once
|
|
304
|
+
const itemsToDeleteMax = itemsToDelete.slice(0, 25)
|
|
305
|
+
const param: BatchWriteCommandInput = {
|
|
306
|
+
RequestItems: { [TableName]: itemsToDeleteMax },
|
|
307
|
+
}
|
|
308
|
+
await client.batchWrite(param)
|
|
309
|
+
return format.from<AdapterUser>(user)
|
|
310
|
+
},
|
|
311
|
+
async linkAccount(data) {
|
|
312
|
+
const item = {
|
|
313
|
+
...data,
|
|
314
|
+
id: crypto.randomUUID(),
|
|
315
|
+
[pk]: `USER#${data.userId}`,
|
|
316
|
+
[sk]: `ACCOUNT#${data.provider}#${data.providerAccountId}`,
|
|
317
|
+
[GSI1PK]: `ACCOUNT#${data.provider}`,
|
|
318
|
+
[GSI1SK]: `ACCOUNT#${data.providerAccountId}`,
|
|
319
|
+
}
|
|
320
|
+
await client.put({ TableName, Item: format.to(item) })
|
|
321
|
+
return data
|
|
322
|
+
},
|
|
323
|
+
async unlinkAccount({ provider, providerAccountId }) {
|
|
324
|
+
const data = await client.query({
|
|
325
|
+
TableName,
|
|
326
|
+
IndexName,
|
|
327
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
328
|
+
ExpressionAttributeNames: {
|
|
329
|
+
"#gsi1pk": GSI1PK,
|
|
330
|
+
"#gsi1sk": GSI1SK,
|
|
331
|
+
},
|
|
332
|
+
ExpressionAttributeValues: {
|
|
333
|
+
":gsi1pk": `ACCOUNT#${provider}`,
|
|
334
|
+
":gsi1sk": `ACCOUNT#${providerAccountId}`,
|
|
335
|
+
},
|
|
336
|
+
})
|
|
337
|
+
const account = format.from<AdapterAccount>(data.Items?.[0])
|
|
338
|
+
if (!account) return
|
|
339
|
+
await client.delete({
|
|
340
|
+
TableName,
|
|
341
|
+
Key: {
|
|
342
|
+
[pk]: `USER#${account.userId}`,
|
|
343
|
+
[sk]: `ACCOUNT#${provider}#${providerAccountId}`,
|
|
344
|
+
},
|
|
345
|
+
ReturnValues: "ALL_OLD",
|
|
346
|
+
})
|
|
347
|
+
return account
|
|
348
|
+
},
|
|
349
|
+
async getSessionAndUser(sessionToken) {
|
|
350
|
+
const data = await client.query({
|
|
351
|
+
TableName,
|
|
352
|
+
IndexName,
|
|
353
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
354
|
+
ExpressionAttributeNames: {
|
|
355
|
+
"#gsi1pk": GSI1PK,
|
|
356
|
+
"#gsi1sk": GSI1SK,
|
|
357
|
+
},
|
|
358
|
+
ExpressionAttributeValues: {
|
|
359
|
+
":gsi1pk": `SESSION#${sessionToken}`,
|
|
360
|
+
":gsi1sk": `SESSION#${sessionToken}`,
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
const session = format.from<AdapterSession>(data.Items?.[0])
|
|
364
|
+
if (!session) return null
|
|
365
|
+
const res = await client.get({
|
|
366
|
+
TableName,
|
|
367
|
+
Key: {
|
|
368
|
+
[pk]: `USER#${session.userId}`,
|
|
369
|
+
[sk]: `USER#${session.userId}`,
|
|
370
|
+
},
|
|
371
|
+
})
|
|
372
|
+
const user = format.from<AdapterUser>(res.Item)
|
|
373
|
+
if (!user) return null
|
|
374
|
+
return { user, session }
|
|
375
|
+
},
|
|
376
|
+
async createSession(data) {
|
|
377
|
+
const session = {
|
|
378
|
+
id: crypto.randomUUID(),
|
|
379
|
+
...data,
|
|
380
|
+
}
|
|
381
|
+
await client.put({
|
|
382
|
+
TableName,
|
|
383
|
+
Item: format.to({
|
|
384
|
+
[pk]: `USER#${data.userId}`,
|
|
385
|
+
[sk]: `SESSION#${data.sessionToken}`,
|
|
386
|
+
[GSI1SK]: `SESSION#${data.sessionToken}`,
|
|
387
|
+
[GSI1PK]: `SESSION#${data.sessionToken}`,
|
|
388
|
+
type: "SESSION",
|
|
389
|
+
...data,
|
|
390
|
+
}),
|
|
391
|
+
})
|
|
392
|
+
return session
|
|
393
|
+
},
|
|
394
|
+
async updateSession(session) {
|
|
395
|
+
const { sessionToken } = session
|
|
396
|
+
const data = await client.query({
|
|
397
|
+
TableName,
|
|
398
|
+
IndexName,
|
|
399
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
400
|
+
ExpressionAttributeNames: {
|
|
401
|
+
"#gsi1pk": GSI1PK,
|
|
402
|
+
"#gsi1sk": GSI1SK,
|
|
403
|
+
},
|
|
404
|
+
ExpressionAttributeValues: {
|
|
405
|
+
":gsi1pk": `SESSION#${sessionToken}`,
|
|
406
|
+
":gsi1sk": `SESSION#${sessionToken}`,
|
|
407
|
+
},
|
|
408
|
+
})
|
|
409
|
+
if (!data.Items?.length) return null
|
|
410
|
+
const { pk, sk } = data.Items[0] as any
|
|
411
|
+
const {
|
|
412
|
+
UpdateExpression,
|
|
413
|
+
ExpressionAttributeNames,
|
|
414
|
+
ExpressionAttributeValues,
|
|
415
|
+
} = generateUpdateExpression(session)
|
|
416
|
+
const res = await client.update({
|
|
417
|
+
TableName,
|
|
418
|
+
Key: { pk, sk },
|
|
419
|
+
UpdateExpression,
|
|
420
|
+
ExpressionAttributeNames,
|
|
421
|
+
ExpressionAttributeValues,
|
|
422
|
+
ReturnValues: "ALL_NEW",
|
|
423
|
+
})
|
|
424
|
+
return format.from<AdapterSession>(res.Attributes)
|
|
425
|
+
},
|
|
426
|
+
async deleteSession(sessionToken) {
|
|
427
|
+
const data = await client.query({
|
|
428
|
+
TableName,
|
|
429
|
+
IndexName,
|
|
430
|
+
KeyConditionExpression: "#gsi1pk = :gsi1pk AND #gsi1sk = :gsi1sk",
|
|
431
|
+
ExpressionAttributeNames: {
|
|
432
|
+
"#gsi1pk": GSI1PK,
|
|
433
|
+
"#gsi1sk": GSI1SK,
|
|
434
|
+
},
|
|
435
|
+
ExpressionAttributeValues: {
|
|
436
|
+
":gsi1pk": `SESSION#${sessionToken}`,
|
|
437
|
+
":gsi1sk": `SESSION#${sessionToken}`,
|
|
438
|
+
},
|
|
439
|
+
})
|
|
440
|
+
if (!data?.Items?.length) return null
|
|
441
|
+
|
|
442
|
+
const { pk, sk } = data.Items[0]
|
|
443
|
+
|
|
444
|
+
const res = await client.delete({
|
|
445
|
+
TableName,
|
|
446
|
+
Key: { pk, sk },
|
|
447
|
+
ReturnValues: "ALL_OLD",
|
|
448
|
+
})
|
|
449
|
+
return format.from<AdapterSession>(res.Attributes)
|
|
450
|
+
},
|
|
451
|
+
async createVerificationToken(data) {
|
|
452
|
+
await client.put({
|
|
453
|
+
TableName,
|
|
454
|
+
Item: format.to({
|
|
455
|
+
[pk]: `VT#${data.identifier}`,
|
|
456
|
+
[sk]: `VT#${data.token}`,
|
|
457
|
+
type: "VT",
|
|
458
|
+
...data,
|
|
459
|
+
}),
|
|
460
|
+
})
|
|
461
|
+
return data
|
|
462
|
+
},
|
|
463
|
+
async useVerificationToken({ identifier, token }) {
|
|
464
|
+
const data = await client.delete({
|
|
465
|
+
TableName,
|
|
466
|
+
Key: {
|
|
467
|
+
[pk]: `VT#${identifier}`,
|
|
468
|
+
[sk]: `VT#${token}`,
|
|
469
|
+
},
|
|
470
|
+
ReturnValues: "ALL_OLD",
|
|
471
|
+
})
|
|
472
|
+
return format.from<VerificationToken>(data.Attributes)
|
|
473
|
+
},
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// https://github.com/honeinc/is-iso-date/blob/master/index.js
|
|
478
|
+
const isoDateRE =
|
|
479
|
+
/(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d\.\d+([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))|(\d{4}-[01]\d-[0-3]\dT[0-2]\d:[0-5]\d([+-][0-2]\d:[0-5]\d|Z))/
|
|
480
|
+
function isDate(value: any) {
|
|
481
|
+
return value && isoDateRE.test(value) && !isNaN(Date.parse(value))
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const format = {
|
|
485
|
+
/** Takes a plain old JavaScript object and turns it into a Dynamodb object */
|
|
486
|
+
to(object: Record<string, any>) {
|
|
487
|
+
const newObject: Record<string, unknown> = {}
|
|
488
|
+
for (const key in object) {
|
|
489
|
+
const value = object[key]
|
|
490
|
+
if (value instanceof Date) {
|
|
491
|
+
// DynamoDB requires the TTL attribute be a UNIX timestamp (in secs).
|
|
492
|
+
if (key === "expires") newObject[key] = value.getTime() / 1000
|
|
493
|
+
else newObject[key] = value.toISOString()
|
|
494
|
+
} else newObject[key] = value
|
|
495
|
+
}
|
|
496
|
+
return newObject
|
|
497
|
+
},
|
|
498
|
+
/** Takes a Dynamo object and returns a plain old JavaScript object */
|
|
499
|
+
from<T = Record<string, unknown>>(object?: Record<string, any>): T | null {
|
|
500
|
+
if (!object) return null
|
|
501
|
+
const newObject: Record<string, unknown> = {}
|
|
502
|
+
for (const key in object) {
|
|
503
|
+
// Filter DynamoDB specific attributes so it doesn't get passed to core,
|
|
504
|
+
// to avoid revealing the type of database
|
|
505
|
+
if (["pk", "sk", "GSI1PK", "GSI1SK"].includes(key)) continue
|
|
506
|
+
|
|
507
|
+
const value = object[key]
|
|
508
|
+
|
|
509
|
+
if (isDate(value)) newObject[key] = new Date(value)
|
|
510
|
+
// hack to keep type property in account
|
|
511
|
+
else if (key === "type" && ["SESSION", "VT", "USER"].includes(value))
|
|
512
|
+
continue
|
|
513
|
+
// The expires property is stored as a UNIX timestamp in seconds, but
|
|
514
|
+
// JavaScript needs it in milliseconds, so multiply by 1000.
|
|
515
|
+
else if (key === "expires" && typeof value === "number")
|
|
516
|
+
newObject[key] = new Date(value * 1000)
|
|
517
|
+
else newObject[key] = value
|
|
518
|
+
}
|
|
519
|
+
return newObject as T
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function generateUpdateExpression(object: Record<string, any>): {
|
|
524
|
+
UpdateExpression: string
|
|
525
|
+
ExpressionAttributeNames: Record<string, string>
|
|
526
|
+
ExpressionAttributeValues: Record<string, unknown>
|
|
527
|
+
} {
|
|
528
|
+
const formattedSession = format.to(object)
|
|
529
|
+
let UpdateExpression = "set"
|
|
530
|
+
const ExpressionAttributeNames: Record<string, string> = {}
|
|
531
|
+
const ExpressionAttributeValues: Record<string, unknown> = {}
|
|
532
|
+
for (const property in formattedSession) {
|
|
533
|
+
UpdateExpression += ` #${property} = :${property},`
|
|
534
|
+
ExpressionAttributeNames["#" + property] = property
|
|
535
|
+
ExpressionAttributeValues[":" + property] = formattedSession[property]
|
|
536
|
+
}
|
|
537
|
+
UpdateExpression = UpdateExpression.slice(0, -1)
|
|
538
|
+
return {
|
|
539
|
+
UpdateExpression,
|
|
540
|
+
ExpressionAttributeNames,
|
|
541
|
+
ExpressionAttributeValues,
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export { format, generateUpdateExpression }
|