-
Notifications
You must be signed in to change notification settings - Fork 0
/
dynamostore.coffee
172 lines (159 loc) · 6.08 KB
/
dynamostore.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
# dynamostore.coffee
#
# copyright (c) Jess Austin <[email protected]>, MIT license
#
# Passwordless Token Store based on AWS' DynamoDB. The constructor takes an
# optional options object, with three optional members. The "dynamoOptions"
# option is passed to the aws-sdk DynamoDB object constructor. The
# "tableParams" option is passed to the DynamoDB:createTable method. The
# "stronglyConsistentAuth" option defaults to false, and controls whether the
# authenticate method waits for strong consistency, or as is more typical in
# AWS, settles for eventual consistency. If you have weird problems try
# changing this option first.
{pseudoRandomBytes} = require 'crypto'
{DynamoDB} = require 'aws-sdk'
bcrypt = require 'bcryptjs'
deepExtend = require 'deep-extend'
module.exports = class DynamoStore
constructor: ({dynamoOptions, tableParams, stronglyConsistentAuth}={}) ->
@db = new DynamoDB dynamoOptions ? {}
@stronglyConsistentAuth = stronglyConsistentAuth ? no
# use promises so constructor is sync; other methods async anyway, so they
# should @table.then() to ensure the table exists
@table = new Promise (resolve, reject) =>
TableName = tableParams?.TableName
if TableName?
@db.describeTable {TableName}, (err) ->
# XXX probably shouldn't do this
if err then delete tableParams.TableName else resolve TableName
newTable @db, tableParams
.then resolve, reject
storeOrUpdate: (token, uid, msToLive, originUrl, callback) ->
unless token and uid and msToLive and callback
throw new InvalidParams 'storeOrUpdate'
# most methods will wait on the table
@table.then (tableName) =>
# is this data safe at rest?
bcrypt.hash token, 10, (err, hashedToken) =>
if err then callback err else @db.putItem
TableName: tableName
Item:
uid: S: uid
dummy: N: '0'
invalid: N: Date.now() + msToLive + ''
hashedToken: S: hashedToken
originUrl: S: originUrl if originUrl?
, callback
, callback
authenticate: (token, uid, callback) ->
throw new InvalidParams 'authenticate' unless token and uid and callback
@table.then (tableName) =>
@db.query
TableName: tableName
ConsistentRead: @stronglyConsistentAuth
ExpressionAttributeValues:
':uid': S: uid
':now': N: Date.now() + ''
IndexName: 'uid-invalid-index'
KeyConditionExpression: 'uid = :uid and invalid > :now'
ProjectionExpression: 'invalid, hashedToken, originUrl'
, (err, data) ->
if err
callback err, no
else if not data.Items.length
callback null, no
else
bcrypt.compare token, data.Items[0]?.hashedToken?.S, (err, valid) ->
if err
callback err, no
else if valid
callback null, yes, data.Items[0].originUrl?.S
else
callback null, no
, (err) ->
callback err, no
invalidateUser: (uid, callback) ->
throw new InvalidParams 'invalidateUser' unless uid and callback
@table.then (tableName) =>
@db.deleteItem
TableName: tableName
Key:
uid: S: uid
dummy: N: '0'
, callback
, callback
clear: (callback) ->
throw new InvalidParams 'clear' unless callback
@dropTable()
.then =>
@table = newTable @db, {}
@table.then ->
callback()
, callback
, callback
length: (callback) ->
throw new InvalidParams 'length' unless callback
@table.then (tableName) =>
@db.scan
TableName: tableName
Select: 'COUNT'
, (err, data) ->
if err then callback err else callback null, data.Count
, callback
# not a standard token store method, useful during testing; returns a promise
dropTable: ->
@table.then (TableName) =>
new Promise (resolve, reject) =>
@db.deleteTable {TableName}, (err) ->
if err then reject err else resolve()
# returns a promise; can be .then()'ed to be sure there is a table
newTable = (db, tableParams) ->
new Promise (resolve, reject) =>
pseudoRandomBytes 6, (err, bytes) =>
if err
reject "couldn't get random bytes"
else
TableName = "passwordless-dynamostore-#{bytes.toString 'base64'
.replace /[^a-zA-Z0-9_.-]/g, ''}"
params = deepExtend {}, defaultParams, {TableName}, tableParams
db.createTable params, (err) =>
if err
reject "couldn't create table #{TableName}, #{err}"
else
waitOnTableCreation db, TableName, resolve, reject
# everything has to wait on table creation; ConsistentRead doesn't help
waitOnTableCreation = (db, TableName, resolve, reject) ->
db.describeTable {TableName}, (err, data) ->
if err
reject err
else if data?.Table?.TableStatus is 'ACTIVE'
resolve TableName
else
waitOnTableCreation db, TableName, resolve, reject # otherwise, recurse
class DynamoError extends Error
targetClass: DynamoStore
class InvalidParams extends DynamoError
constructor: (method) ->
@message = "#{@targetClass}:#{method} called with invalid parameters"
# if you pass in wildly different overriding tableParams you will have problems
defaultParams =
AttributeDefinitions: [
AttributeType: 'S', AttributeName: 'uid'
, AttributeType: 'N', AttributeName: 'dummy'
, {AttributeType: 'N', AttributeName: 'invalid'} # extra {} for cs oddity
]
KeySchema: [
KeyType: 'HASH', AttributeName: 'uid'
, KeyType: 'RANGE', AttributeName: 'dummy' # need dummy range attr to
] # allow *local* 2I
LocalSecondaryIndexes: [ # need local 2I to allow
IndexName: 'uid-invalid-index', # ConsistentRead
KeySchema: [
KeyType: 'HASH', AttributeName: 'uid'
, KeyType: 'RANGE', AttributeName: 'invalid'
]
Projection: ProjectionType: 'ALL'
]
ProvisionedThroughput: # these can be increased
ReadCapacityUnits: 1
WriteCapacityUnits: 1