@passlock/client 0.9.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 +21 -0
- package/README.md +42 -0
- package/dist/authentication/authenticate.d.ts +22 -0
- package/dist/authentication/authenticate.d.ts.map +1 -0
- package/dist/authentication/authenticate.fixture.d.ts +36 -0
- package/dist/authentication/authenticate.fixture.d.ts.map +1 -0
- package/dist/authentication/authenticate.fixture.js +52 -0
- package/dist/authentication/authenticate.fixture.js.map +1 -0
- package/dist/authentication/authenticate.js +69 -0
- package/dist/authentication/authenticate.js.map +1 -0
- package/dist/authentication/authenticate.test.d.ts +2 -0
- package/dist/authentication/authenticate.test.d.ts.map +1 -0
- package/dist/authentication/authenticate.test.js +111 -0
- package/dist/authentication/authenticate.test.js.map +1 -0
- package/dist/capabilities/capabilities.d.ts +18 -0
- package/dist/capabilities/capabilities.d.ts.map +1 -0
- package/dist/capabilities/capabilities.js +35 -0
- package/dist/capabilities/capabilities.js.map +1 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +20 -0
- package/dist/config.js.map +1 -0
- package/dist/connection/connection.d.ts +9 -0
- package/dist/connection/connection.d.ts.map +1 -0
- package/dist/connection/connection.fixture.d.ts +12 -0
- package/dist/connection/connection.fixture.d.ts.map +1 -0
- package/dist/connection/connection.fixture.js +20 -0
- package/dist/connection/connection.fixture.js.map +1 -0
- package/dist/connection/connection.js +21 -0
- package/dist/connection/connection.js.map +1 -0
- package/dist/connection/connection.test.d.ts +2 -0
- package/dist/connection/connection.test.d.ts.map +1 -0
- package/dist/connection/connection.test.js +34 -0
- package/dist/connection/connection.test.js.map +1 -0
- package/dist/effect.d.ts +33 -0
- package/dist/effect.d.ts.map +1 -0
- package/dist/effect.js +62 -0
- package/dist/effect.js.map +1 -0
- package/dist/email/email.d.ts +37 -0
- package/dist/email/email.d.ts.map +1 -0
- package/dist/email/email.fixture.d.ts +33 -0
- package/dist/email/email.fixture.d.ts.map +1 -0
- package/dist/email/email.fixture.js +30 -0
- package/dist/email/email.fixture.js.map +1 -0
- package/dist/email/email.js +78 -0
- package/dist/email/email.js.map +1 -0
- package/dist/email/email.test.d.ts +2 -0
- package/dist/email/email.test.d.ts.map +1 -0
- package/dist/email/email.test.js +101 -0
- package/dist/email/email.test.js.map +1 -0
- package/dist/event/event.d.ts +9 -0
- package/dist/event/event.d.ts.map +1 -0
- package/dist/event/event.js +23 -0
- package/dist/event/event.js.map +1 -0
- package/dist/event/event.node.test.d.ts +2 -0
- package/dist/event/event.node.test.d.ts.map +1 -0
- package/dist/event/event.node.test.js +14 -0
- package/dist/event/event.node.test.js.map +1 -0
- package/dist/event/event.test.d.ts +2 -0
- package/dist/event/event.test.d.ts.map +1 -0
- package/dist/event/event.test.js +30 -0
- package/dist/event/event.test.js.map +1 -0
- package/dist/exit.d.ts +64 -0
- package/dist/exit.d.ts.map +1 -0
- package/dist/exit.js +106 -0
- package/dist/exit.js.map +1 -0
- package/dist/index.d.ts +110 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +108 -0
- package/dist/index.js.map +1 -0
- package/dist/logging/eventLogger.d.ts +18 -0
- package/dist/logging/eventLogger.d.ts.map +1 -0
- package/dist/logging/eventLogger.js +32 -0
- package/dist/logging/eventLogger.js.map +1 -0
- package/dist/logging/eventLogger.test.d.ts +2 -0
- package/dist/logging/eventLogger.test.d.ts.map +1 -0
- package/dist/logging/eventLogger.test.js +74 -0
- package/dist/logging/eventLogger.test.js.map +1 -0
- package/dist/registration/register.d.ts +29 -0
- package/dist/registration/register.d.ts.map +1 -0
- package/dist/registration/register.fixture.d.ts +40 -0
- package/dist/registration/register.fixture.d.ts.map +1 -0
- package/dist/registration/register.fixture.js +65 -0
- package/dist/registration/register.fixture.js.map +1 -0
- package/dist/registration/register.js +84 -0
- package/dist/registration/register.js.map +1 -0
- package/dist/registration/register.test.d.ts +2 -0
- package/dist/registration/register.test.d.ts.map +1 -0
- package/dist/registration/register.test.js +122 -0
- package/dist/registration/register.test.js.map +1 -0
- package/dist/storage/storage.d.ts +53 -0
- package/dist/storage/storage.d.ts.map +1 -0
- package/dist/storage/storage.fixture.d.ts +6 -0
- package/dist/storage/storage.fixture.d.ts.map +1 -0
- package/dist/storage/storage.fixture.js +26 -0
- package/dist/storage/storage.fixture.js.map +1 -0
- package/dist/storage/storage.js +102 -0
- package/dist/storage/storage.js.map +1 -0
- package/dist/storage/storage.test.d.ts +2 -0
- package/dist/storage/storage.test.d.ts.map +1 -0
- package/dist/storage/storage.test.js +122 -0
- package/dist/storage/storage.test.js.map +1 -0
- package/dist/test/fixtures.d.ts +14 -0
- package/dist/test/fixtures.d.ts.map +1 -0
- package/dist/test/fixtures.js +39 -0
- package/dist/test/fixtures.js.map +1 -0
- package/dist/user/user.d.ts +18 -0
- package/dist/user/user.d.ts.map +1 -0
- package/dist/user/user.fixture.d.ts +8 -0
- package/dist/user/user.fixture.d.ts.map +1 -0
- package/dist/user/user.fixture.js +17 -0
- package/dist/user/user.fixture.js.map +1 -0
- package/dist/user/user.js +23 -0
- package/dist/user/user.js.map +1 -0
- package/dist/user/user.test.d.ts +2 -0
- package/dist/user/user.test.d.ts.map +1 -0
- package/dist/user/user.test.js +37 -0
- package/dist/user/user.test.js.map +1 -0
- package/package.json +87 -0
- package/src/authentication/authenticate.fixture.ts +72 -0
- package/src/authentication/authenticate.test.ts +207 -0
- package/src/authentication/authenticate.ts +147 -0
- package/src/capabilities/capabilities.ts +81 -0
- package/src/config.ts +43 -0
- package/src/connection/connection.fixture.ts +27 -0
- package/src/connection/connection.test.ts +61 -0
- package/src/connection/connection.ts +51 -0
- package/src/effect.ts +278 -0
- package/src/email/email.fixture.ts +49 -0
- package/src/email/email.test.ts +186 -0
- package/src/email/email.ts +139 -0
- package/src/event/event.node.test.ts +20 -0
- package/src/event/event.test.ts +37 -0
- package/src/event/event.ts +25 -0
- package/src/index.ts +275 -0
- package/src/logging/eventLogger.test.ts +102 -0
- package/src/logging/eventLogger.ts +35 -0
- package/src/registration/register.fixture.ts +94 -0
- package/src/registration/register.test.ts +247 -0
- package/src/registration/register.ts +178 -0
- package/src/storage/storage.fixture.ts +33 -0
- package/src/storage/storage.test.ts +196 -0
- package/src/storage/storage.ts +165 -0
- package/src/test/fixtures.ts +51 -0
- package/src/user/user.fixture.ts +23 -0
- package/src/user/user.test.ts +53 -0
- package/src/user/user.ts +50 -0
package/src/effect.ts
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { create, get as getCredential } from '@github/webauthn-json/browser-ponyfill'
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
BadRequest,
|
|
5
|
+
Disabled,
|
|
6
|
+
Duplicate,
|
|
7
|
+
Forbidden,
|
|
8
|
+
InternalBrowserError,
|
|
9
|
+
NotFound,
|
|
10
|
+
NotSupported,
|
|
11
|
+
Unauthorized,
|
|
12
|
+
} from '@passlock/shared/dist/error/error'
|
|
13
|
+
|
|
14
|
+
export type { Principal } from '@passlock/shared/dist/schema/schema'
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type BadRequest,
|
|
18
|
+
type Disabled,
|
|
19
|
+
Duplicate,
|
|
20
|
+
type Forbidden,
|
|
21
|
+
InternalBrowserError,
|
|
22
|
+
type NotFound,
|
|
23
|
+
type NotSupported,
|
|
24
|
+
type Unauthorized,
|
|
25
|
+
} from '@passlock/shared/dist/error/error'
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
NetworkServiceLive,
|
|
29
|
+
RetrySchedule,
|
|
30
|
+
RpcClientLive,
|
|
31
|
+
RpcConfig,
|
|
32
|
+
} from '@passlock/shared/dist/rpc/rpc'
|
|
33
|
+
|
|
34
|
+
import type { Principal } from '@passlock/shared/dist/schema/schema'
|
|
35
|
+
import { Context, Effect as E, Layer as L, Layer, Schedule, pipe } from 'effect'
|
|
36
|
+
import type { NoSuchElementException } from 'effect/Cause'
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
AuthenticateServiceLive,
|
|
40
|
+
type AuthenticationRequest,
|
|
41
|
+
AuthenticationService,
|
|
42
|
+
GetCredential,
|
|
43
|
+
} from './authentication/authenticate'
|
|
44
|
+
|
|
45
|
+
import { capabilitiesLive } from './capabilities/capabilities'
|
|
46
|
+
import { ConnectionService, ConnectionServiceLive } from './connection/connection'
|
|
47
|
+
import { EmailService, EmailServiceLive, LocationSearch, type VerifyRequest } from './email/email'
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
CreateCredential,
|
|
51
|
+
type RegistrationRequest,
|
|
52
|
+
RegistrationService,
|
|
53
|
+
RegistrationServiceLive,
|
|
54
|
+
} from './registration/register'
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
type AuthType,
|
|
58
|
+
Storage,
|
|
59
|
+
StorageService,
|
|
60
|
+
StorageServiceLive,
|
|
61
|
+
type StoredToken,
|
|
62
|
+
} from './storage/storage'
|
|
63
|
+
|
|
64
|
+
import { type Email, UserService, UserServiceLive } from './user/user'
|
|
65
|
+
|
|
66
|
+
/* Layers */
|
|
67
|
+
|
|
68
|
+
const createLive = L.succeed(
|
|
69
|
+
CreateCredential,
|
|
70
|
+
CreateCredential.of((options: CredentialCreationOptions) =>
|
|
71
|
+
pipe(
|
|
72
|
+
E.tryPromise({
|
|
73
|
+
try: () => create(options),
|
|
74
|
+
catch: e => {
|
|
75
|
+
if (e instanceof Error && e.message.includes('excludeCredentials')) {
|
|
76
|
+
return new Duplicate({
|
|
77
|
+
message: 'Passkey already registered on this device or cloud account',
|
|
78
|
+
})
|
|
79
|
+
} else {
|
|
80
|
+
return new InternalBrowserError({
|
|
81
|
+
message: 'Unable to create credential',
|
|
82
|
+
detail: String(e),
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
}),
|
|
87
|
+
E.map(credential => credential.toJSON()),
|
|
88
|
+
),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const getLive = L.succeed(
|
|
93
|
+
GetCredential,
|
|
94
|
+
GetCredential.of((options: CredentialRequestOptions) =>
|
|
95
|
+
pipe(
|
|
96
|
+
E.tryPromise({
|
|
97
|
+
try: () => getCredential(options),
|
|
98
|
+
catch: e =>
|
|
99
|
+
new InternalBrowserError({
|
|
100
|
+
message: 'Unable to get authentication credential',
|
|
101
|
+
detail: String(e),
|
|
102
|
+
}),
|
|
103
|
+
}),
|
|
104
|
+
E.map(credential => credential.toJSON()),
|
|
105
|
+
),
|
|
106
|
+
),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const schedule = Schedule.intersect(Schedule.recurs(3), Schedule.exponential('100 millis'))
|
|
110
|
+
|
|
111
|
+
const retryScheduleLive = L.succeed(RetrySchedule, RetrySchedule.of({ schedule }))
|
|
112
|
+
|
|
113
|
+
const networkServiceLive = pipe(NetworkServiceLive, L.provide(retryScheduleLive))
|
|
114
|
+
|
|
115
|
+
const rpcClientLive = pipe(RpcClientLive, L.provide(networkServiceLive))
|
|
116
|
+
|
|
117
|
+
const storageServiceLive = StorageServiceLive
|
|
118
|
+
|
|
119
|
+
const userServiceLive = pipe(UserServiceLive, L.provide(rpcClientLive))
|
|
120
|
+
|
|
121
|
+
const registrationServiceLive = pipe(
|
|
122
|
+
RegistrationServiceLive,
|
|
123
|
+
L.provide(rpcClientLive),
|
|
124
|
+
L.provide(userServiceLive),
|
|
125
|
+
L.provide(capabilitiesLive),
|
|
126
|
+
L.provide(createLive),
|
|
127
|
+
L.provide(storageServiceLive),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
const authenticationServiceLive = pipe(
|
|
131
|
+
AuthenticateServiceLive,
|
|
132
|
+
L.provide(rpcClientLive),
|
|
133
|
+
L.provide(capabilitiesLive),
|
|
134
|
+
L.provide(getLive),
|
|
135
|
+
L.provide(storageServiceLive),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
const connectionServiceLive = pipe(
|
|
139
|
+
ConnectionServiceLive,
|
|
140
|
+
L.provide(rpcClientLive),
|
|
141
|
+
L.provide(networkServiceLive)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
const locationSearchLive = Layer.succeed(
|
|
145
|
+
LocationSearch,
|
|
146
|
+
LocationSearch.of(E.sync(() => globalThis.window.location.search)),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
const emailServiceLive = pipe(
|
|
150
|
+
EmailServiceLive,
|
|
151
|
+
L.provide(locationSearchLive),
|
|
152
|
+
L.provide(rpcClientLive),
|
|
153
|
+
L.provide(capabilitiesLive),
|
|
154
|
+
L.provide(authenticationServiceLive),
|
|
155
|
+
L.provide(storageServiceLive),
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
export const allRequirements = Layer.mergeAll(
|
|
159
|
+
capabilitiesLive,
|
|
160
|
+
userServiceLive,
|
|
161
|
+
registrationServiceLive,
|
|
162
|
+
authenticationServiceLive,
|
|
163
|
+
connectionServiceLive,
|
|
164
|
+
emailServiceLive,
|
|
165
|
+
storageServiceLive,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
export class Config extends Context.Tag('Config')<
|
|
169
|
+
Config,
|
|
170
|
+
{
|
|
171
|
+
tenancyId: string
|
|
172
|
+
clientId: string
|
|
173
|
+
endpoint?: string
|
|
174
|
+
}
|
|
175
|
+
>() {}
|
|
176
|
+
|
|
177
|
+
const storageLive = Layer.effect(
|
|
178
|
+
Storage,
|
|
179
|
+
E.sync(() => Storage.of(globalThis.localStorage)),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const exchangeConfig = <A, E>(effect: E.Effect<A, E, RpcConfig | Storage>) => {
|
|
183
|
+
return pipe(
|
|
184
|
+
Config,
|
|
185
|
+
E.flatMap(config => E.provideService(effect, RpcConfig, RpcConfig.of(config))),
|
|
186
|
+
effect => E.provide(effect, storageLive),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const preConnect = (): E.Effect<void, never, Config> =>
|
|
191
|
+
pipe(
|
|
192
|
+
ConnectionService,
|
|
193
|
+
E.flatMap(service => service.preConnect()),
|
|
194
|
+
E.provide(connectionServiceLive),
|
|
195
|
+
exchangeConfig,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
export const isRegistered = (email: Email): E.Effect<boolean, BadRequest, Config> =>
|
|
199
|
+
pipe(
|
|
200
|
+
UserService,
|
|
201
|
+
E.flatMap(service => service.isExistingUser(email)),
|
|
202
|
+
E.provide(userServiceLive),
|
|
203
|
+
exchangeConfig,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
export type RegistrationErrors = NotSupported | BadRequest | Duplicate | Unauthorized | Forbidden
|
|
207
|
+
|
|
208
|
+
export const registerPasskey = (
|
|
209
|
+
request: RegistrationRequest,
|
|
210
|
+
): E.Effect<Principal, RegistrationErrors, Config> =>
|
|
211
|
+
pipe(
|
|
212
|
+
RegistrationService,
|
|
213
|
+
E.flatMap(service => service.registerPasskey(request)),
|
|
214
|
+
E.provide(registrationServiceLive),
|
|
215
|
+
exchangeConfig,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
export type AuthenticationErrors =
|
|
219
|
+
| NotSupported
|
|
220
|
+
| BadRequest
|
|
221
|
+
| NotFound
|
|
222
|
+
| Disabled
|
|
223
|
+
| Unauthorized
|
|
224
|
+
| Forbidden
|
|
225
|
+
|
|
226
|
+
export const authenticatePasskey = (
|
|
227
|
+
request: AuthenticationRequest,
|
|
228
|
+
): E.Effect<Principal, AuthenticationErrors, Config> =>
|
|
229
|
+
pipe(
|
|
230
|
+
AuthenticationService,
|
|
231
|
+
E.flatMap(service => service.authenticatePasskey(request)),
|
|
232
|
+
E.provide(authenticationServiceLive),
|
|
233
|
+
exchangeConfig,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
export type VerifyEmailErrors =
|
|
237
|
+
| NotSupported
|
|
238
|
+
| BadRequest
|
|
239
|
+
| NotFound
|
|
240
|
+
| Disabled
|
|
241
|
+
| Unauthorized
|
|
242
|
+
| Forbidden
|
|
243
|
+
|
|
244
|
+
export const verifyEmailCode = (
|
|
245
|
+
request: VerifyRequest,
|
|
246
|
+
): E.Effect<boolean, VerifyEmailErrors, Config> =>
|
|
247
|
+
pipe(
|
|
248
|
+
EmailService,
|
|
249
|
+
E.flatMap(service => service.verifyEmailCode(request)),
|
|
250
|
+
E.provide(emailServiceLive),
|
|
251
|
+
exchangeConfig,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
export const verifyEmailLink = (): E.Effect<boolean, VerifyEmailErrors, Config> =>
|
|
255
|
+
pipe(
|
|
256
|
+
EmailService,
|
|
257
|
+
E.flatMap(service => service.verifyEmailLink()),
|
|
258
|
+
E.provide(emailServiceLive),
|
|
259
|
+
exchangeConfig,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
export const getSessionToken = (
|
|
263
|
+
authType: AuthType,
|
|
264
|
+
): E.Effect<StoredToken, NoSuchElementException> =>
|
|
265
|
+
pipe(
|
|
266
|
+
StorageService,
|
|
267
|
+
E.flatMap(service => service.getToken(authType)),
|
|
268
|
+
E.provide(storageServiceLive),
|
|
269
|
+
E.provide(storageLive),
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
export const clearExpiredTokens = (): E.Effect<void> =>
|
|
273
|
+
pipe(
|
|
274
|
+
StorageService,
|
|
275
|
+
E.flatMap(service => service.clearExpiredTokens),
|
|
276
|
+
E.provide(storageServiceLive),
|
|
277
|
+
E.provide(storageLive),
|
|
278
|
+
)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
|
|
2
|
+
import { BadRequest } from '@passlock/shared/dist/error/error'
|
|
3
|
+
import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
4
|
+
import { VerifyEmailReq, VerifyEmailRes } from '@passlock/shared/dist/rpc/user'
|
|
5
|
+
import { Effect as E, Layer as L } from 'effect'
|
|
6
|
+
import { LocationSearch } from './email'
|
|
7
|
+
import { AuthenticationService } from '../authentication/authenticate'
|
|
8
|
+
import * as Fixtures from '../test/fixtures'
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
export const token = 'token'
|
|
12
|
+
export const code = 'code'
|
|
13
|
+
export const authType = 'passkey'
|
|
14
|
+
export const expireAt = Date.now() + 10000
|
|
15
|
+
|
|
16
|
+
export const locationSearchTest = L.succeed(
|
|
17
|
+
LocationSearch,
|
|
18
|
+
LocationSearch.of(E.succeed(`?code=${code}`)),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
export const authenticationServiceTest = L.succeed(
|
|
22
|
+
AuthenticationService,
|
|
23
|
+
AuthenticationService.of({
|
|
24
|
+
authenticatePasskey: () => E.succeed(Fixtures.principal),
|
|
25
|
+
}),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
export const rpcClientTest = L.succeed(
|
|
29
|
+
RpcClient,
|
|
30
|
+
RpcClient.of({
|
|
31
|
+
preConnect: () => E.succeed({ warmed: true }),
|
|
32
|
+
isExistingUser: () => E.succeed({ existingUser: true }),
|
|
33
|
+
verifyEmail: () => E.succeed({ verified: true }),
|
|
34
|
+
getRegistrationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
35
|
+
verifyRegistrationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
36
|
+
getAuthenticationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
37
|
+
verifyAuthenticationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
export const verifyEmailReq = new VerifyEmailReq({ token, code })
|
|
42
|
+
|
|
43
|
+
export const verifyEmailRes = new VerifyEmailRes({ verified: true })
|
|
44
|
+
|
|
45
|
+
export const principal = Fixtures.principal
|
|
46
|
+
|
|
47
|
+
export const storedToken = Fixtures.storedToken
|
|
48
|
+
|
|
49
|
+
export const storageServiceTest = Fixtures.storageServiceTest
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { type RouterOps, RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
2
|
+
import { Effect as E, Layer as L, LogLevel, Logger, pipe } from 'effect'
|
|
3
|
+
import { NoSuchElementException } from 'effect/Cause'
|
|
4
|
+
import { describe, expect, test } from 'vitest'
|
|
5
|
+
import { mock } from 'vitest-mock-extended'
|
|
6
|
+
import { EmailService, EmailServiceLive } from './email'
|
|
7
|
+
import * as Fixture from './email.fixture'
|
|
8
|
+
import { AuthenticationService } from '../authentication/authenticate'
|
|
9
|
+
import { StorageService } from '../storage/storage'
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
describe('verifyEmailCode should', () => {
|
|
13
|
+
test('return true when the verification is successful', async () => {
|
|
14
|
+
const assertions = E.gen(function* (_) {
|
|
15
|
+
const service = yield* _(EmailService)
|
|
16
|
+
const result = yield* _(service.verifyEmailCode({ code: '123' }))
|
|
17
|
+
|
|
18
|
+
expect(result).toBe(true)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const service = pipe(
|
|
22
|
+
EmailServiceLive,
|
|
23
|
+
L.provide(Fixture.locationSearchTest),
|
|
24
|
+
L.provide(Fixture.authenticationServiceTest),
|
|
25
|
+
L.provide(Fixture.storageServiceTest),
|
|
26
|
+
L.provide(Fixture.rpcClientTest),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
|
|
30
|
+
|
|
31
|
+
return E.runPromise(effect)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('check for a token in local storage', async () => {
|
|
35
|
+
const assertions = E.gen(function* (_) {
|
|
36
|
+
const service = yield* _(EmailService)
|
|
37
|
+
yield* _(service.verifyEmailCode({ code: '123' }))
|
|
38
|
+
|
|
39
|
+
const storageService = yield* _(StorageService)
|
|
40
|
+
expect(storageService.getToken).toHaveBeenCalledWith('passkey')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const storageServiceTest = L.effect(
|
|
44
|
+
StorageService,
|
|
45
|
+
E.sync(() => {
|
|
46
|
+
const storageServiceMock = mock<StorageService>()
|
|
47
|
+
|
|
48
|
+
storageServiceMock.getToken.mockReturnValue(E.succeed(Fixture.storedToken))
|
|
49
|
+
storageServiceMock.clearToken.mockReturnValue(E.unit)
|
|
50
|
+
|
|
51
|
+
return storageServiceMock
|
|
52
|
+
}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
const service = pipe(
|
|
56
|
+
EmailServiceLive,
|
|
57
|
+
L.provide(Fixture.locationSearchTest),
|
|
58
|
+
L.provide(Fixture.authenticationServiceTest),
|
|
59
|
+
L.provide(storageServiceTest),
|
|
60
|
+
L.provide(Fixture.rpcClientTest),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const layers = L.merge(service, storageServiceTest)
|
|
64
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
65
|
+
|
|
66
|
+
return E.runPromise(effect)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('re-authenticate the user if no local token', async () => {
|
|
70
|
+
const assertions = E.gen(function* (_) {
|
|
71
|
+
const service = yield* _(EmailService)
|
|
72
|
+
yield* _(service.verifyEmailCode({ code: '123' }))
|
|
73
|
+
|
|
74
|
+
const authService = yield* _(AuthenticationService)
|
|
75
|
+
expect(authService.authenticatePasskey).toHaveBeenCalled()
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const storageServiceTest = L.effect(
|
|
79
|
+
StorageService,
|
|
80
|
+
E.sync(() => {
|
|
81
|
+
const storageServiceMock = mock<StorageService>()
|
|
82
|
+
|
|
83
|
+
storageServiceMock.getToken.mockReturnValue(E.fail(new NoSuchElementException()))
|
|
84
|
+
storageServiceMock.clearToken.mockReturnValue(E.unit)
|
|
85
|
+
|
|
86
|
+
return storageServiceMock
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const authServiceTest = L.effect(
|
|
91
|
+
AuthenticationService,
|
|
92
|
+
E.sync(() => {
|
|
93
|
+
const authServiceMock = mock<AuthenticationService>()
|
|
94
|
+
|
|
95
|
+
authServiceMock.authenticatePasskey.mockReturnValue(E.succeed(Fixture.principal))
|
|
96
|
+
|
|
97
|
+
return authServiceMock
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
const service = pipe(
|
|
102
|
+
EmailServiceLive,
|
|
103
|
+
L.provide(Fixture.locationSearchTest),
|
|
104
|
+
L.provide(authServiceTest),
|
|
105
|
+
L.provide(storageServiceTest),
|
|
106
|
+
L.provide(Fixture.rpcClientTest),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
const layers = L.mergeAll(service, storageServiceTest, authServiceTest)
|
|
110
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
111
|
+
|
|
112
|
+
return E.runPromise(effect)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
test('call the backend', async () => {
|
|
116
|
+
const assertions = E.gen(function* (_) {
|
|
117
|
+
const service = yield* _(EmailService)
|
|
118
|
+
yield* _(service.verifyEmailCode({ code: Fixture.code }))
|
|
119
|
+
|
|
120
|
+
const rpcClient = yield* _(RpcClient)
|
|
121
|
+
expect(rpcClient.verifyEmail).toHaveBeenCalledWith(Fixture.verifyEmailReq)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
const rpcClientTest = L.effect(
|
|
125
|
+
RpcClient,
|
|
126
|
+
E.sync(() => {
|
|
127
|
+
const rpcMock = mock<RouterOps>()
|
|
128
|
+
|
|
129
|
+
rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.verifyEmailRes))
|
|
130
|
+
|
|
131
|
+
return rpcMock
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const service = pipe(
|
|
136
|
+
EmailServiceLive,
|
|
137
|
+
L.provide(Fixture.locationSearchTest),
|
|
138
|
+
L.provide(Fixture.authenticationServiceTest),
|
|
139
|
+
L.provide(Fixture.storageServiceTest),
|
|
140
|
+
L.provide(rpcClientTest),
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
const layers = L.merge(service, rpcClientTest)
|
|
144
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
145
|
+
|
|
146
|
+
return E.runPromise(effect)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
describe('verifyEmailLink should', () => {
|
|
151
|
+
test('extract the code from the current url', async () => {
|
|
152
|
+
const assertions = E.gen(function* (_) {
|
|
153
|
+
const service = yield* _(EmailService)
|
|
154
|
+
yield* _(service.verifyEmailLink())
|
|
155
|
+
|
|
156
|
+
// LocationSearch return ?code=code
|
|
157
|
+
// and we expect rpcClient to be called with code
|
|
158
|
+
const rpcClient = yield* _(RpcClient)
|
|
159
|
+
expect(rpcClient.verifyEmail).toBeCalledWith(Fixture.verifyEmailReq)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
const rpcClientTest = L.effect(
|
|
163
|
+
RpcClient,
|
|
164
|
+
E.sync(() => {
|
|
165
|
+
const rpcMock = mock<RouterOps>()
|
|
166
|
+
|
|
167
|
+
rpcMock.verifyEmail.mockReturnValue(E.succeed(Fixture.verifyEmailRes))
|
|
168
|
+
|
|
169
|
+
return rpcMock
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const service = pipe(
|
|
174
|
+
EmailServiceLive,
|
|
175
|
+
L.provide(Fixture.locationSearchTest),
|
|
176
|
+
L.provide(Fixture.storageServiceTest),
|
|
177
|
+
L.provide(Fixture.authenticationServiceTest),
|
|
178
|
+
L.provide(rpcClientTest),
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
const layers = L.merge(service, rpcClientTest)
|
|
182
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
183
|
+
|
|
184
|
+
return E.runPromise(effect)
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email verification effects
|
|
3
|
+
*/
|
|
4
|
+
import { BadRequest } from '@passlock/shared/dist/error/error'
|
|
5
|
+
import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
6
|
+
import type { VerifyEmailErrors as RpcErrors} from '@passlock/shared/dist/rpc/user';
|
|
7
|
+
import { VerifyEmailReq } from '@passlock/shared/dist/rpc/user'
|
|
8
|
+
import { Context, Effect as E, Layer, Option as O, flow, identity, pipe } from 'effect'
|
|
9
|
+
import { type AuthenticationErrors, AuthenticationService } from '../authentication/authenticate'
|
|
10
|
+
import { StorageService } from '../storage/storage'
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
/* Requests */
|
|
14
|
+
|
|
15
|
+
export type VerifyRequest = {
|
|
16
|
+
code: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/* Errors */
|
|
20
|
+
|
|
21
|
+
export type VerifyEmailErrors = RpcErrors | AuthenticationErrors
|
|
22
|
+
|
|
23
|
+
/* Dependencies */
|
|
24
|
+
|
|
25
|
+
export class LocationSearch extends Context.Tag('LocationSearch')<
|
|
26
|
+
LocationSearch,
|
|
27
|
+
E.Effect<string>
|
|
28
|
+
>() {}
|
|
29
|
+
|
|
30
|
+
/* Service */
|
|
31
|
+
|
|
32
|
+
export type EmailService = {
|
|
33
|
+
verifyEmailCode: (request: VerifyRequest) => E.Effect<boolean, VerifyEmailErrors>
|
|
34
|
+
verifyEmailLink: () => E.Effect<boolean, VerifyEmailErrors>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const EmailService = Context.GenericTag<EmailService>('@services/EmailService')
|
|
38
|
+
|
|
39
|
+
/* Utils */
|
|
40
|
+
|
|
41
|
+
export type Dependencies = StorageService | AuthenticationService | RpcClient
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Check for existing token in sessionStorage,
|
|
45
|
+
* otherwise force passkey re-authentication
|
|
46
|
+
* @returns
|
|
47
|
+
*/
|
|
48
|
+
const getToken = () => {
|
|
49
|
+
return E.gen(function* (_) {
|
|
50
|
+
// Check for existing token
|
|
51
|
+
const storageService = yield* _(StorageService)
|
|
52
|
+
const existingTokenE = storageService.getToken('passkey')
|
|
53
|
+
const authenticationService = yield* _(AuthenticationService)
|
|
54
|
+
|
|
55
|
+
const tokenE = E.matchEffect(existingTokenE, {
|
|
56
|
+
onSuccess: token => E.succeed(token),
|
|
57
|
+
onFailure: () =>
|
|
58
|
+
// No token, need to authenticate the user
|
|
59
|
+
pipe(
|
|
60
|
+
authenticationService.authenticatePasskey({ userVerification: 'preferred' }),
|
|
61
|
+
E.map(principal => ({
|
|
62
|
+
token: principal.token,
|
|
63
|
+
authType: principal.authStatement.authType,
|
|
64
|
+
expiresAt: principal.expireAt.getTime(),
|
|
65
|
+
})),
|
|
66
|
+
),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const token = yield* _(tokenE)
|
|
70
|
+
yield* _(storageService.clearToken('passkey'))
|
|
71
|
+
|
|
72
|
+
return token
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Look for ?code=<code> in the url
|
|
78
|
+
* @returns
|
|
79
|
+
*/
|
|
80
|
+
export const extractCodeFromHref = () => {
|
|
81
|
+
return pipe(
|
|
82
|
+
LocationSearch,
|
|
83
|
+
E.flatMap(identity),
|
|
84
|
+
E.map(search => new URLSearchParams(search)),
|
|
85
|
+
E.flatMap(params => O.fromNullable(params.get('code'))),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Effects */
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Verify the mailbox using the given code
|
|
93
|
+
* @param verificationRequest
|
|
94
|
+
* @returns
|
|
95
|
+
*/
|
|
96
|
+
export const verifyEmail = (
|
|
97
|
+
verificationRequest: VerifyRequest,
|
|
98
|
+
): E.Effect<boolean, VerifyEmailErrors, Dependencies> => {
|
|
99
|
+
return E.gen(function* (_) {
|
|
100
|
+
// Re-authenticate the user if required
|
|
101
|
+
const { token } = yield* _(getToken())
|
|
102
|
+
|
|
103
|
+
yield* _(E.logDebug('Making request'))
|
|
104
|
+
const client = yield* _(RpcClient)
|
|
105
|
+
const { verified } = yield* _(
|
|
106
|
+
client.verifyEmail(new VerifyEmailReq({ token, code: verificationRequest.code })),
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return verified
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Look for a code in the current url and verify it
|
|
115
|
+
* @returns
|
|
116
|
+
*/
|
|
117
|
+
export const verifyEmailLink = () =>
|
|
118
|
+
pipe(
|
|
119
|
+
extractCodeFromHref(),
|
|
120
|
+
E.mapError(() => new BadRequest({ message: 'Expected ?code=xxx in window.location' })),
|
|
121
|
+
E.flatMap(code => verifyEmail({ code })),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
/* Live */
|
|
125
|
+
|
|
126
|
+
/* v8 ignore start */
|
|
127
|
+
export const EmailServiceLive = Layer.effect(
|
|
128
|
+
EmailService,
|
|
129
|
+
E.gen(function* (_) {
|
|
130
|
+
const context = yield* _(
|
|
131
|
+
E.context<RpcClient | AuthenticationService | StorageService | LocationSearch>(),
|
|
132
|
+
)
|
|
133
|
+
return EmailService.of({
|
|
134
|
+
verifyEmailCode: flow(verifyEmail, E.provide(context)),
|
|
135
|
+
verifyEmailLink: flow(verifyEmailLink, E.provide(context)),
|
|
136
|
+
})
|
|
137
|
+
}),
|
|
138
|
+
)
|
|
139
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect, pipe } from 'effect'
|
|
2
|
+
import { describe, expect, test } from 'vitest'
|
|
3
|
+
import { fireEvent } from './event'
|
|
4
|
+
|
|
5
|
+
// @vitest-environment node
|
|
6
|
+
|
|
7
|
+
describe('isPasslockEvent', () => {
|
|
8
|
+
test("return a Passlock error if custom events aren't supported", async () => {
|
|
9
|
+
const program = pipe(
|
|
10
|
+
fireEvent('hello world'),
|
|
11
|
+
Effect.flip,
|
|
12
|
+
Effect.tap(e => {
|
|
13
|
+
expect(e._tag).toBe('InternalBrowserError')
|
|
14
|
+
expect(e.message).toBe('Unable to fire custom event')
|
|
15
|
+
}),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
await Effect.runPromise(program)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { Effect } from 'effect'
|
|
2
|
+
import { afterEach, describe, expect, test, vi } from 'vitest'
|
|
3
|
+
import { DebugMessage, fireEvent, isPasslockEvent } from './event'
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
describe('fireEvent', () => {
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks()
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('fire a custom log event', () => {
|
|
12
|
+
const effect = fireEvent('hello world')
|
|
13
|
+
const eventSpy = vi.spyOn(globalThis, 'dispatchEvent')
|
|
14
|
+
Effect.runSync(effect)
|
|
15
|
+
|
|
16
|
+
const expectedEvent = new CustomEvent(DebugMessage, {
|
|
17
|
+
detail: 'hello world',
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
expect(eventSpy).toHaveBeenCalledWith(expectedEvent)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('isPasslockEvent', () => {
|
|
25
|
+
test('return true for our custom event', () => {
|
|
26
|
+
const passlockEvent = new CustomEvent(DebugMessage, {
|
|
27
|
+
detail: 'hello world',
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
expect(isPasslockEvent(passlockEvent)).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('return false for other events', () => {
|
|
34
|
+
const otherEvent = new MouseEvent('click')
|
|
35
|
+
expect(isPasslockEvent(otherEvent)).toBe(false)
|
|
36
|
+
})
|
|
37
|
+
})
|