-
-
Notifications
You must be signed in to change notification settings - Fork 142
/
accounts-magic-link.ts
153 lines (132 loc) · 4.38 KB
/
accounts-magic-link.ts
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
import {
type User,
type DatabaseInterface,
type AuthenticationService,
type LoginUserMagicLinkService,
type TokenRecord,
type DatabaseInterfaceUser,
} from '@accounts/types';
import {
AccountsServer,
AccountsJsError,
generateRandomToken,
DatabaseInterfaceUserToken,
} from '@accounts/server';
import { AccountsMagicLinkConfigToken, type ErrorMessages } from './types';
import { errors, AuthenticateErrors, MagicLinkAuthenticatorErrors } from './errors';
import { isString } from './utils/validation';
import { RequestMagicLinkEmailErrors } from './errors';
import { getUserLoginTokens } from './utils/user';
import { Injectable, Inject } from 'graphql-modules';
export interface AccountsMagicLinkOptions {
/**
* Accounts token module errors
*/
errors?: ErrorMessages;
/**
* The number of milliseconds from when a link with a login token is sent until token expires and user can't login with it.
* Defaults to 15 minutes.
*/
loginTokenExpiration?: number;
}
const defaultOptions = {
errors,
// 15 minutes - 15 * 60 * 1000
loginTokenExpiration: 900000,
};
@Injectable({
global: true,
})
export default class AccountsMagicLink<CustomUser extends User = User>
implements AuthenticationService
{
public serviceName = 'magicLink';
public server!: AccountsServer;
private options: AccountsMagicLinkOptions & typeof defaultOptions;
private db!: DatabaseInterfaceUser<CustomUser>;
constructor(
@Inject(AccountsMagicLinkConfigToken) options: AccountsMagicLinkOptions = {},
@Inject(AccountsServer) server?: AccountsServer,
@Inject(DatabaseInterfaceUserToken)
db?: DatabaseInterface<CustomUser> | DatabaseInterfaceUser<CustomUser>
) {
this.options = { ...defaultOptions, ...options };
if (db) {
this.db = db;
}
if (server) {
this.server = server;
}
}
public setUserStore(store: DatabaseInterfaceUser<CustomUser>) {
this.db = store;
}
public setSessionsStore() {
// Empty
}
public async requestMagicLinkEmail(email: string): Promise<void> {
if (!email || !isString(email)) {
throw new AccountsJsError(
this.options.errors.invalidEmail,
RequestMagicLinkEmailErrors.InvalidEmail
);
}
const user = await this.db.findUserByEmail(email);
if (!user) {
throw new AccountsJsError(
this.options.errors.userNotFound,
RequestMagicLinkEmailErrors.UserNotFound
);
}
// Remove pre-existing login tokens on user
await this.db.removeAllLoginTokens(user.id);
const token = generateRandomToken();
await this.db.addLoginToken(user.id, email, token);
const requestMagicLinkMail = this.server.prepareMail(
email,
token,
this.server.sanitizeUser(user),
'magiclink',
this.server.options.emailTemplates.magicLink,
this.server.options.emailTemplates.from
);
await this.server.options.sendMail(requestMagicLinkMail);
}
public async authenticate(params: LoginUserMagicLinkService): Promise<CustomUser> {
const { token } = params;
if (!token) {
throw new AccountsJsError(
this.options.errors.unrecognizedOptionsForLogin,
AuthenticateErrors.UnrecognizedOptionsForLogin
);
}
if (!isString(token)) {
throw new AccountsJsError(this.options.errors.matchFailed, AuthenticateErrors.MatchFailed);
}
const foundUser = await this.magicLinkAuthenticator(token);
// Remove all login tokens for user after login
await this.db.removeAllLoginTokens(foundUser.id);
return foundUser;
}
public isTokenExpired(tokenRecord: TokenRecord, expiryDate: number): boolean {
return Number(tokenRecord.when) + expiryDate < Date.now();
}
private async magicLinkAuthenticator(token: string): Promise<CustomUser> {
const foundUser: CustomUser | null = await this.db.findUserByLoginToken(token);
if (!foundUser) {
throw new AccountsJsError(
this.options.errors.loginTokenExpired,
MagicLinkAuthenticatorErrors.LoginTokenExpired
);
}
const loginTokens = getUserLoginTokens(foundUser);
const tokenRecord = loginTokens.find((t: TokenRecord) => t.token === token);
if (!tokenRecord || this.isTokenExpired(tokenRecord, this.options.loginTokenExpiration)) {
throw new AccountsJsError(
this.options.errors.loginTokenExpired,
MagicLinkAuthenticatorErrors.LoginTokenExpired
);
}
return foundUser;
}
}