@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.
Files changed (147) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +42 -0
  3. package/dist/authentication/authenticate.d.ts +22 -0
  4. package/dist/authentication/authenticate.d.ts.map +1 -0
  5. package/dist/authentication/authenticate.fixture.d.ts +36 -0
  6. package/dist/authentication/authenticate.fixture.d.ts.map +1 -0
  7. package/dist/authentication/authenticate.fixture.js +52 -0
  8. package/dist/authentication/authenticate.fixture.js.map +1 -0
  9. package/dist/authentication/authenticate.js +69 -0
  10. package/dist/authentication/authenticate.js.map +1 -0
  11. package/dist/authentication/authenticate.test.d.ts +2 -0
  12. package/dist/authentication/authenticate.test.d.ts.map +1 -0
  13. package/dist/authentication/authenticate.test.js +111 -0
  14. package/dist/authentication/authenticate.test.js.map +1 -0
  15. package/dist/capabilities/capabilities.d.ts +18 -0
  16. package/dist/capabilities/capabilities.d.ts.map +1 -0
  17. package/dist/capabilities/capabilities.js +35 -0
  18. package/dist/capabilities/capabilities.js.map +1 -0
  19. package/dist/config.d.ts +22 -0
  20. package/dist/config.d.ts.map +1 -0
  21. package/dist/config.js +20 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/connection/connection.d.ts +9 -0
  24. package/dist/connection/connection.d.ts.map +1 -0
  25. package/dist/connection/connection.fixture.d.ts +12 -0
  26. package/dist/connection/connection.fixture.d.ts.map +1 -0
  27. package/dist/connection/connection.fixture.js +20 -0
  28. package/dist/connection/connection.fixture.js.map +1 -0
  29. package/dist/connection/connection.js +21 -0
  30. package/dist/connection/connection.js.map +1 -0
  31. package/dist/connection/connection.test.d.ts +2 -0
  32. package/dist/connection/connection.test.d.ts.map +1 -0
  33. package/dist/connection/connection.test.js +34 -0
  34. package/dist/connection/connection.test.js.map +1 -0
  35. package/dist/effect.d.ts +33 -0
  36. package/dist/effect.d.ts.map +1 -0
  37. package/dist/effect.js +62 -0
  38. package/dist/effect.js.map +1 -0
  39. package/dist/email/email.d.ts +37 -0
  40. package/dist/email/email.d.ts.map +1 -0
  41. package/dist/email/email.fixture.d.ts +33 -0
  42. package/dist/email/email.fixture.d.ts.map +1 -0
  43. package/dist/email/email.fixture.js +30 -0
  44. package/dist/email/email.fixture.js.map +1 -0
  45. package/dist/email/email.js +78 -0
  46. package/dist/email/email.js.map +1 -0
  47. package/dist/email/email.test.d.ts +2 -0
  48. package/dist/email/email.test.d.ts.map +1 -0
  49. package/dist/email/email.test.js +101 -0
  50. package/dist/email/email.test.js.map +1 -0
  51. package/dist/event/event.d.ts +9 -0
  52. package/dist/event/event.d.ts.map +1 -0
  53. package/dist/event/event.js +23 -0
  54. package/dist/event/event.js.map +1 -0
  55. package/dist/event/event.node.test.d.ts +2 -0
  56. package/dist/event/event.node.test.d.ts.map +1 -0
  57. package/dist/event/event.node.test.js +14 -0
  58. package/dist/event/event.node.test.js.map +1 -0
  59. package/dist/event/event.test.d.ts +2 -0
  60. package/dist/event/event.test.d.ts.map +1 -0
  61. package/dist/event/event.test.js +30 -0
  62. package/dist/event/event.test.js.map +1 -0
  63. package/dist/exit.d.ts +64 -0
  64. package/dist/exit.d.ts.map +1 -0
  65. package/dist/exit.js +106 -0
  66. package/dist/exit.js.map +1 -0
  67. package/dist/index.d.ts +110 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +108 -0
  70. package/dist/index.js.map +1 -0
  71. package/dist/logging/eventLogger.d.ts +18 -0
  72. package/dist/logging/eventLogger.d.ts.map +1 -0
  73. package/dist/logging/eventLogger.js +32 -0
  74. package/dist/logging/eventLogger.js.map +1 -0
  75. package/dist/logging/eventLogger.test.d.ts +2 -0
  76. package/dist/logging/eventLogger.test.d.ts.map +1 -0
  77. package/dist/logging/eventLogger.test.js +74 -0
  78. package/dist/logging/eventLogger.test.js.map +1 -0
  79. package/dist/registration/register.d.ts +29 -0
  80. package/dist/registration/register.d.ts.map +1 -0
  81. package/dist/registration/register.fixture.d.ts +40 -0
  82. package/dist/registration/register.fixture.d.ts.map +1 -0
  83. package/dist/registration/register.fixture.js +65 -0
  84. package/dist/registration/register.fixture.js.map +1 -0
  85. package/dist/registration/register.js +84 -0
  86. package/dist/registration/register.js.map +1 -0
  87. package/dist/registration/register.test.d.ts +2 -0
  88. package/dist/registration/register.test.d.ts.map +1 -0
  89. package/dist/registration/register.test.js +122 -0
  90. package/dist/registration/register.test.js.map +1 -0
  91. package/dist/storage/storage.d.ts +53 -0
  92. package/dist/storage/storage.d.ts.map +1 -0
  93. package/dist/storage/storage.fixture.d.ts +6 -0
  94. package/dist/storage/storage.fixture.d.ts.map +1 -0
  95. package/dist/storage/storage.fixture.js +26 -0
  96. package/dist/storage/storage.fixture.js.map +1 -0
  97. package/dist/storage/storage.js +102 -0
  98. package/dist/storage/storage.js.map +1 -0
  99. package/dist/storage/storage.test.d.ts +2 -0
  100. package/dist/storage/storage.test.d.ts.map +1 -0
  101. package/dist/storage/storage.test.js +122 -0
  102. package/dist/storage/storage.test.js.map +1 -0
  103. package/dist/test/fixtures.d.ts +14 -0
  104. package/dist/test/fixtures.d.ts.map +1 -0
  105. package/dist/test/fixtures.js +39 -0
  106. package/dist/test/fixtures.js.map +1 -0
  107. package/dist/user/user.d.ts +18 -0
  108. package/dist/user/user.d.ts.map +1 -0
  109. package/dist/user/user.fixture.d.ts +8 -0
  110. package/dist/user/user.fixture.d.ts.map +1 -0
  111. package/dist/user/user.fixture.js +17 -0
  112. package/dist/user/user.fixture.js.map +1 -0
  113. package/dist/user/user.js +23 -0
  114. package/dist/user/user.js.map +1 -0
  115. package/dist/user/user.test.d.ts +2 -0
  116. package/dist/user/user.test.d.ts.map +1 -0
  117. package/dist/user/user.test.js +37 -0
  118. package/dist/user/user.test.js.map +1 -0
  119. package/package.json +87 -0
  120. package/src/authentication/authenticate.fixture.ts +72 -0
  121. package/src/authentication/authenticate.test.ts +207 -0
  122. package/src/authentication/authenticate.ts +147 -0
  123. package/src/capabilities/capabilities.ts +81 -0
  124. package/src/config.ts +43 -0
  125. package/src/connection/connection.fixture.ts +27 -0
  126. package/src/connection/connection.test.ts +61 -0
  127. package/src/connection/connection.ts +51 -0
  128. package/src/effect.ts +278 -0
  129. package/src/email/email.fixture.ts +49 -0
  130. package/src/email/email.test.ts +186 -0
  131. package/src/email/email.ts +139 -0
  132. package/src/event/event.node.test.ts +20 -0
  133. package/src/event/event.test.ts +37 -0
  134. package/src/event/event.ts +25 -0
  135. package/src/index.ts +275 -0
  136. package/src/logging/eventLogger.test.ts +102 -0
  137. package/src/logging/eventLogger.ts +35 -0
  138. package/src/registration/register.fixture.ts +94 -0
  139. package/src/registration/register.test.ts +247 -0
  140. package/src/registration/register.ts +178 -0
  141. package/src/storage/storage.fixture.ts +33 -0
  142. package/src/storage/storage.test.ts +196 -0
  143. package/src/storage/storage.ts +165 -0
  144. package/src/test/fixtures.ts +51 -0
  145. package/src/user/user.fixture.ts +23 -0
  146. package/src/user/user.test.ts +53 -0
  147. package/src/user/user.ts +50 -0
@@ -0,0 +1,247 @@
1
+ import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error'
2
+ import { type RouterOps, RpcClient } from '@passlock/shared/dist/rpc/rpc'
3
+ import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
4
+ import { describe, expect, test, vi } from 'vitest'
5
+ import { mock } from 'vitest-mock-extended'
6
+ import { CreateCredential, RegistrationService, RegistrationServiceLive } from './register'
7
+ import * as Fixture from './register.fixture'
8
+ import { UserService } from '../user/user'
9
+
10
+
11
+ describe('register should', () => {
12
+ test('return a valid credential', async () => {
13
+ const assertions = E.gen(function* (_) {
14
+ const service = yield* _(RegistrationService)
15
+ const result = yield* _(service.registerPasskey(Fixture.registrationRequest))
16
+ expect(result).toEqual(Fixture.principal)
17
+ })
18
+
19
+ const service = pipe(
20
+ RegistrationServiceLive,
21
+ L.provide(Fixture.createCredentialTest),
22
+ L.provide(Fixture.userServiceTest),
23
+ L.provide(Fixture.capabilitiesTest),
24
+ L.provide(Fixture.storageServiceTest),
25
+ L.provide(Fixture.rpcClientTest),
26
+ )
27
+
28
+ const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
29
+
30
+ return E.runPromise(effect)
31
+ })
32
+
33
+ test('check if the user is already registered', async () => {
34
+ const assertions = E.gen(function* (_) {
35
+ const service = yield* _(RegistrationService)
36
+ yield* _(service.registerPasskey(Fixture.registrationRequest))
37
+
38
+ const userService = yield* _(UserService)
39
+ expect(userService.isExistingUser).toHaveBeenCalled()
40
+ })
41
+
42
+ const userServiceTest = L.effect(
43
+ UserService,
44
+ E.sync(() => {
45
+ const userMock = mock<UserService>()
46
+
47
+ userMock.isExistingUser.mockReturnValue(E.succeed(false))
48
+
49
+ return userMock
50
+ }),
51
+ )
52
+
53
+ const service = pipe(
54
+ RegistrationServiceLive,
55
+ L.provide(Fixture.createCredentialTest),
56
+ L.provide(Fixture.capabilitiesTest),
57
+ L.provide(Fixture.storageServiceTest),
58
+ L.provide(Fixture.rpcClientTest),
59
+ L.provide(userServiceTest),
60
+ )
61
+
62
+ const layers = Layer.merge(service, userServiceTest)
63
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
64
+
65
+ return E.runPromise(effect)
66
+ })
67
+
68
+ test('pass the registration data to the backend', async () => {
69
+ const assertions = E.gen(function* (_) {
70
+ const service = yield* _(RegistrationService)
71
+ yield* _(service.registerPasskey(Fixture.registrationRequest))
72
+
73
+ const rpcClient = yield* _(RpcClient)
74
+ expect(rpcClient.getRegistrationOptions).toHaveBeenCalledWith(Fixture.optionsReq)
75
+ })
76
+
77
+ const rpcClientTest = L.effect(
78
+ RpcClient,
79
+ E.sync(() => {
80
+ const rpcMock = mock<RouterOps>()
81
+
82
+ rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.optionsRes))
83
+ rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.verificationRes))
84
+
85
+ return rpcMock
86
+ }),
87
+ )
88
+
89
+ const service = pipe(
90
+ RegistrationServiceLive,
91
+ L.provide(Fixture.createCredentialTest),
92
+ L.provide(Fixture.capabilitiesTest),
93
+ L.provide(Fixture.storageServiceTest),
94
+ L.provide(Fixture.userServiceTest),
95
+ L.provide(rpcClientTest),
96
+ )
97
+
98
+ const layers = Layer.merge(service, rpcClientTest)
99
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
100
+
101
+ return E.runPromise(effect)
102
+ })
103
+
104
+ test('send the new credential to the backend', async () => {
105
+ const assertions = E.gen(function* (_) {
106
+ const service = yield* _(RegistrationService)
107
+ yield* _(service.registerPasskey(Fixture.registrationRequest))
108
+
109
+ const rpcClient = yield* _(RpcClient)
110
+ expect(rpcClient.verifyRegistrationCredential).toHaveBeenCalledWith(Fixture.verificationReq)
111
+ })
112
+
113
+ const rpcClientTest = L.effect(
114
+ RpcClient,
115
+ E.sync(() => {
116
+ const rpcMock = mock<RouterOps>()
117
+
118
+ rpcMock.getRegistrationOptions.mockReturnValue(E.succeed(Fixture.optionsRes))
119
+ rpcMock.verifyRegistrationCredential.mockReturnValue(E.succeed(Fixture.verificationRes))
120
+
121
+ return rpcMock
122
+ }),
123
+ )
124
+
125
+ const service = pipe(
126
+ RegistrationServiceLive,
127
+ L.provide(Fixture.createCredentialTest),
128
+ L.provide(Fixture.capabilitiesTest),
129
+ L.provide(Fixture.storageServiceTest),
130
+ L.provide(Fixture.userServiceTest),
131
+ L.provide(rpcClientTest),
132
+ )
133
+
134
+ const layers = Layer.merge(service, rpcClientTest)
135
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
136
+
137
+ return E.runPromise(effect)
138
+ })
139
+
140
+ test('short-circuit if the user is already registered', async () => {
141
+ const assertions = E.gen(function* (_) {
142
+ const service = yield* _(RegistrationService)
143
+
144
+ const error = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip)
145
+
146
+ expect(error).toBeInstanceOf(Duplicate)
147
+ })
148
+
149
+ const userServiceTest = L.effect(
150
+ UserService,
151
+ E.sync(() => {
152
+ const userMock = mock<UserService>()
153
+
154
+ userMock.isExistingUser.mockReturnValue(E.succeed(true))
155
+
156
+ return userMock
157
+ }),
158
+ )
159
+
160
+ const service = pipe(
161
+ RegistrationServiceLive,
162
+ L.provide(Fixture.createCredentialTest),
163
+ L.provide(Fixture.capabilitiesTest),
164
+ L.provide(Fixture.storageServiceTest),
165
+ L.provide(Fixture.rpcClientTest),
166
+ L.provide(userServiceTest),
167
+ )
168
+
169
+ const layers = Layer.merge(service, userServiceTest)
170
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
171
+
172
+ return E.runPromise(effect)
173
+ })
174
+
175
+ test('return an error if we try to re-register a credential', async () => {
176
+ const assertions = E.gen(function* (_) {
177
+ const service = yield* _(RegistrationService)
178
+
179
+ const defect = yield* _(service.registerPasskey(Fixture.registrationRequest), E.flip)
180
+
181
+ expect(defect).toBeInstanceOf(Duplicate)
182
+ })
183
+
184
+ const createTest = L.effect(
185
+ CreateCredential,
186
+ E.sync(() => {
187
+ const createTest = vi.fn()
188
+
189
+ createTest.mockReturnValue(E.fail(new Duplicate({ message: 'boom!' })))
190
+
191
+ return createTest
192
+ }),
193
+ )
194
+
195
+ const service = pipe(
196
+ RegistrationServiceLive,
197
+ L.provide(Fixture.userServiceTest),
198
+ L.provide(Fixture.capabilitiesTest),
199
+ L.provide(Fixture.storageServiceTest),
200
+ L.provide(Fixture.rpcClientTest),
201
+ L.provide(createTest),
202
+ )
203
+
204
+ const layers = Layer.merge(service, createTest)
205
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
206
+
207
+ return E.runPromise(effect)
208
+ })
209
+
210
+ test("throw an error if the browser can't create a credential", async () => {
211
+ const assertions = E.gen(function* (_) {
212
+ const service = yield* _(RegistrationService)
213
+
214
+ const defect = yield* _(
215
+ service.registerPasskey(Fixture.registrationRequest),
216
+ E.catchAllDefect(defect => E.succeed(defect)),
217
+ )
218
+
219
+ expect(defect).toBeInstanceOf(InternalBrowserError)
220
+ })
221
+
222
+ const createTest = L.effect(
223
+ CreateCredential,
224
+ E.sync(() => {
225
+ const createTest = vi.fn()
226
+
227
+ createTest.mockReturnValue(E.fail(new InternalBrowserError({ message: 'boom!' })))
228
+
229
+ return createTest
230
+ }),
231
+ )
232
+
233
+ const service = pipe(
234
+ RegistrationServiceLive,
235
+ L.provide(Fixture.userServiceTest),
236
+ L.provide(Fixture.capabilitiesTest),
237
+ L.provide(Fixture.storageServiceTest),
238
+ L.provide(Fixture.rpcClientTest),
239
+ L.provide(createTest),
240
+ )
241
+
242
+ const layers = Layer.merge(service, createTest)
243
+ const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
244
+
245
+ return E.runPromise(effect)
246
+ })
247
+ })
@@ -0,0 +1,178 @@
1
+ /**
2
+ * User & passkey registration effects
3
+ */
4
+ import {
5
+ type CredentialCreationOptionsJSON,
6
+ parseCreationOptionsFromJSON,
7
+ } from '@github/webauthn-json/browser-ponyfill'
8
+ import type { NotSupported } from '@passlock/shared/dist/error/error';
9
+ import { Duplicate, InternalBrowserError } from '@passlock/shared/dist/error/error'
10
+ import type {
11
+ OptionsErrors,
12
+ VerificationErrors} from '@passlock/shared/dist/rpc/registration';
13
+ import {
14
+ OptionsReq,
15
+ VerificationReq,
16
+ } from '@passlock/shared/dist/rpc/registration'
17
+ import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
18
+ import type {
19
+ Principal,
20
+ RegistrationCredential,
21
+ UserVerification,
22
+ VerifyEmail,
23
+ } from '@passlock/shared/dist/schema/schema'
24
+ import { Context, Effect as E, Layer, flow, pipe } from 'effect'
25
+ import { Capabilities } from '../capabilities/capabilities'
26
+ import { StorageService } from '../storage/storage'
27
+ import { UserService } from '../user/user'
28
+
29
+ /* Requests */
30
+
31
+ export type RegistrationRequest = {
32
+ email: string
33
+ firstName: string
34
+ lastName: string
35
+ userVerification?: UserVerification
36
+ verifyEmail?: VerifyEmail
37
+ redirectUrl?: string
38
+ }
39
+
40
+ /* Dependencies */
41
+
42
+ export type CreateCredential = (
43
+ options: CredentialCreationOptions,
44
+ ) => E.Effect<RegistrationCredential, InternalBrowserError | Duplicate>
45
+ export const CreateCredential = Context.GenericTag<CreateCredential>('@services/Create')
46
+
47
+ /* Errors */
48
+
49
+ export type RegistrationErrors = NotSupported | OptionsErrors | VerificationErrors
50
+
51
+ /* Service */
52
+
53
+ export type RegistrationService = {
54
+ registerPasskey: (request: RegistrationRequest) => E.Effect<Principal, RegistrationErrors>
55
+ }
56
+
57
+ export const RegistrationService = Context.GenericTag<RegistrationService>(
58
+ '@services/RegistrationService',
59
+ )
60
+
61
+ /* Utilities */
62
+
63
+ const fetchOptions = (req: OptionsReq) => {
64
+ return E.gen(function* (_) {
65
+ yield* _(E.logDebug('Making request'))
66
+
67
+ const rpcClient = yield* _(RpcClient)
68
+ const { publicKey, session } = yield* _(rpcClient.getRegistrationOptions(req))
69
+
70
+ yield* _(E.logDebug('Converting Passlock options to CredentialCreationOptions'))
71
+ const options = yield* _(toCreationOptions({ publicKey }))
72
+
73
+ return { options, session }
74
+ })
75
+ }
76
+
77
+ const toCreationOptions = (jsonOptions: CredentialCreationOptionsJSON) => {
78
+ return pipe(
79
+ E.try(() => parseCreationOptionsFromJSON(jsonOptions)),
80
+ E.mapError(
81
+ error =>
82
+ new InternalBrowserError({
83
+ message: 'Browser was unable to create credential creation options',
84
+ detail: String(error.error),
85
+ }),
86
+ ),
87
+ )
88
+ }
89
+
90
+ const verifyCredential = (req: VerificationReq) => {
91
+ return E.gen(function* (_) {
92
+ yield* _(E.logDebug('Making request'))
93
+
94
+ const rpcClient = yield* _(RpcClient)
95
+ const { principal } = yield* _(rpcClient.verifyRegistrationCredential(req))
96
+
97
+ return principal
98
+ })
99
+ }
100
+
101
+ const isNewUser = (email: string) => {
102
+ return pipe(
103
+ UserService,
104
+ E.flatMap(service => service.isExistingUser({ email })),
105
+ E.catchTag('BadRequest', () => E.unit),
106
+ E.flatMap(isExistingUser => {
107
+ return isExistingUser
108
+ ? new Duplicate({ message: 'User already has a passkey registered' })
109
+ : E.unit
110
+ }),
111
+ )
112
+ }
113
+
114
+ /* Effects */
115
+
116
+ type Dependencies = Capabilities | CreateCredential | StorageService | UserService | RpcClient
117
+
118
+ export const registerPasskey = (
119
+ request: RegistrationRequest,
120
+ ): E.Effect<Principal, RegistrationErrors, Dependencies> => {
121
+ const effect = E.gen(function* (_) {
122
+ yield* _(E.logInfo('Checking if browser supports Passkeys'))
123
+ const capabilities = yield* _(Capabilities)
124
+ yield* _(capabilities.passkeySupport)
125
+
126
+ yield* _(E.logInfo('Checking if already registered'))
127
+ yield* _(isNewUser(request.email))
128
+
129
+ yield* _(E.logInfo('Fetching registration options from Passlock'))
130
+ const { options, session } = yield* _(fetchOptions(new OptionsReq(request)))
131
+
132
+ yield* _(E.logInfo('Building new credential'))
133
+ const createCredential = yield* _(CreateCredential)
134
+ const credential = yield* _(createCredential(options))
135
+
136
+ yield* _(E.logInfo('Storing credential public key in Passlock'))
137
+ const verificationRequest = new VerificationReq({
138
+ ...request,
139
+ credential,
140
+ session,
141
+ })
142
+
143
+ const principal = yield* _(verifyCredential(verificationRequest))
144
+
145
+ const storageService = yield* _(StorageService)
146
+ yield* _(storageService.storeToken(principal))
147
+ yield* _(E.logDebug('Storing token in local storage'))
148
+
149
+ yield* _(E.logDebug('Defering local token deletion'))
150
+ const delayedClearTokenE = pipe(
151
+ storageService.clearExpiredToken('passkey'),
152
+ E.delay('6 minutes'),
153
+ E.fork,
154
+ )
155
+ yield* _(delayedClearTokenE)
156
+
157
+ return principal
158
+ })
159
+
160
+ return E.catchTag(effect, 'InternalBrowserError', e => E.die(e))
161
+ }
162
+
163
+ /* Live */
164
+
165
+ /* v8 ignore start */
166
+ export const RegistrationServiceLive = Layer.effect(
167
+ RegistrationService,
168
+ E.gen(function* (_) {
169
+ const context = yield* _(
170
+ E.context<CreateCredential | RpcClient | Capabilities | StorageService | UserService>(),
171
+ )
172
+
173
+ return RegistrationService.of({
174
+ registerPasskey: flow(registerPasskey, E.provide(context)),
175
+ })
176
+ }),
177
+ )
178
+ /* v8 ignore stop */
@@ -0,0 +1,33 @@
1
+ import type { Principal } from '@passlock/shared/dist/schema/schema'
2
+ import { Effect as E, Layer, pipe } from 'effect'
3
+ import { mock } from 'vitest-mock-extended'
4
+ import { Storage, StorageServiceLive } from './storage'
5
+
6
+ // Frontend receives dates as objects
7
+ export const principal: Principal = {
8
+ token: 'token',
9
+ subject: {
10
+ id: '1',
11
+ email: 'john.doe@gmail.com',
12
+ firstName: 'john',
13
+ lastName: 'doe',
14
+ emailVerified: false,
15
+ },
16
+ authStatement: {
17
+ authType: 'passkey',
18
+ userVerified: false,
19
+ authTimestamp: new Date(0),
20
+ },
21
+ expireAt: new Date(100),
22
+ }
23
+
24
+ const storageTest = Layer.effect(
25
+ Storage,
26
+ E.sync(() => mock<Storage>()),
27
+ )
28
+
29
+ export const testLayers = (storage: Layer.Layer<Storage> = storageTest) => {
30
+ const storageService = pipe(StorageServiceLive, Layer.provide(storage))
31
+
32
+ return Layer.merge(storage, storageService)
33
+ }
@@ -0,0 +1,196 @@
1
+ import { Effect as E, Layer, LogLevel, Logger, identity, pipe } from 'effect'
2
+ import { describe, expect, test } from 'vitest'
3
+ import { mock } from 'vitest-mock-extended'
4
+ import { Storage, StorageService, clearExpiredToken, clearToken, getToken } from './storage'
5
+ import { principal, testLayers } from './storage.fixture'
6
+
7
+ // eslint chokes on expect(storage.setItem) etc
8
+ /* eslint @typescript-eslint/unbound-method: 0 */
9
+
10
+ describe('storeToken should', () => {
11
+ test('set the token in local storage', () => {
12
+ const assertions = E.gen(function* (_) {
13
+ const service = yield* _(StorageService)
14
+ yield* _(service.storeToken(principal))
15
+
16
+ const storage = yield* _(Storage)
17
+ expect(storage.setItem).toHaveBeenCalled()
18
+ })
19
+
20
+ const effect = pipe(
21
+ E.provide(assertions, testLayers()),
22
+ Logger.withMinimumLogLevel(LogLevel.None),
23
+ )
24
+
25
+ E.runSync(effect)
26
+ })
27
+
28
+ test('with the key passlock:passkey:token', () => {
29
+ const assertions = E.gen(function* (_) {
30
+ const service = yield* _(StorageService)
31
+ yield* _(service.storeToken(principal))
32
+
33
+ const storage = yield* _(Storage)
34
+ expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', expect.any(String))
35
+ })
36
+
37
+ const effect = pipe(
38
+ E.provide(assertions, testLayers()),
39
+ Logger.withMinimumLogLevel(LogLevel.None),
40
+ )
41
+
42
+ E.runSync(effect)
43
+ })
44
+
45
+ test('with the value token:expiry', () => {
46
+ const assertions = E.gen(function* (_) {
47
+ const service = yield* _(StorageService)
48
+ yield* _(service.storeToken(principal))
49
+
50
+ const storage = yield* _(Storage)
51
+ const token = principal.token
52
+ const expiry = principal.expireAt.getTime()
53
+ expect(storage.setItem).toHaveBeenCalledWith('passlock:passkey:token', `${token}:${expiry}`)
54
+ })
55
+
56
+ const effect = pipe(
57
+ E.provide(assertions, testLayers()),
58
+ Logger.withMinimumLogLevel(LogLevel.None),
59
+ )
60
+
61
+ E.runSync(effect)
62
+ })
63
+ })
64
+
65
+ describe('getToken should', () => {
66
+ test('get the token from local storage', () => {
67
+ const assertions = E.gen(function* (_) {
68
+ const service = yield* _(StorageService)
69
+ yield* _(service.getToken('passkey'))
70
+
71
+ const storage = yield* _(Storage)
72
+ expect(storage.getItem).toHaveBeenCalled()
73
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
74
+ })
75
+
76
+ const storageTest = Layer.effect(
77
+ Storage,
78
+ E.sync(() => {
79
+ const mockStorage = mock<Storage>()
80
+ const expiry = Date.now() + 1000
81
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
82
+ return mockStorage
83
+ }),
84
+ )
85
+
86
+ const effect = pipe(
87
+ E.provide(assertions, testLayers(storageTest)),
88
+ Logger.withMinimumLogLevel(LogLevel.None),
89
+ )
90
+
91
+ E.runSync(effect)
92
+ })
93
+
94
+ test('filter out expired tokens', () => {
95
+ const assertions = pipe(
96
+ getToken('passkey'),
97
+ E.match({
98
+ onSuccess: identity,
99
+ onFailure: () => undefined,
100
+ }),
101
+ E.flatMap(result =>
102
+ E.sync(() => {
103
+ expect(result).toBeUndefined()
104
+ }),
105
+ ),
106
+ )
107
+
108
+ const storageTest = Layer.effect(
109
+ Storage,
110
+ E.sync(() => {
111
+ const mockStorage = mock<Storage>()
112
+ const expiry = Date.now() - 1000
113
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
114
+ return mockStorage
115
+ }),
116
+ )
117
+
118
+ const effect = pipe(
119
+ E.provide(assertions, testLayers(storageTest)),
120
+ Logger.withMinimumLogLevel(LogLevel.None),
121
+ )
122
+
123
+ E.runSync(effect)
124
+ })
125
+ })
126
+
127
+ describe('clearToken should', () => {
128
+ test('clear the token in local storage', () => {
129
+ const assertions = E.gen(function* (_) {
130
+ const storage = yield* _(Storage)
131
+ yield* _(clearToken('passkey'))
132
+ expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token')
133
+ })
134
+
135
+ const effect = pipe(
136
+ E.provide(assertions, testLayers()),
137
+ Logger.withMinimumLogLevel(LogLevel.None),
138
+ )
139
+
140
+ E.runSync(effect)
141
+ })
142
+ })
143
+
144
+ describe('clearExpiredToken should', () => {
145
+ test('clear an expired token from local storage', () => {
146
+ const assertions = E.gen(function* (_) {
147
+ const storage = yield* _(Storage)
148
+ yield* _(clearExpiredToken('passkey'))
149
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
150
+ expect(storage.removeItem).toHaveBeenCalledWith('passlock:passkey:token')
151
+ })
152
+
153
+ const storageTest = Layer.effect(
154
+ Storage,
155
+ E.sync(() => {
156
+ const mockStorage = mock<Storage>()
157
+ const expiry = Date.now() - 1000
158
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
159
+ return mockStorage
160
+ }),
161
+ )
162
+
163
+ const effect = pipe(
164
+ E.provide(assertions, testLayers(storageTest)),
165
+ Logger.withMinimumLogLevel(LogLevel.None),
166
+ )
167
+
168
+ E.runSync(effect)
169
+ })
170
+
171
+ test('leave a live token in local storage', () => {
172
+ const assertions = E.gen(function* (_) {
173
+ const storage = yield* _(Storage)
174
+ yield* _(clearExpiredToken('passkey'))
175
+ expect(storage.getItem).toHaveBeenCalledWith('passlock:passkey:token')
176
+ expect(storage.removeItem).not.toHaveBeenCalled()
177
+ })
178
+
179
+ const storageTest = Layer.effect(
180
+ Storage,
181
+ E.sync(() => {
182
+ const mockStorage = mock<Storage>()
183
+ const expiry = Date.now() + 1000
184
+ mockStorage.getItem.mockReturnValue(`token:${expiry}`)
185
+ return mockStorage
186
+ }),
187
+ )
188
+
189
+ const effect = pipe(
190
+ E.provide(assertions, testLayers(storageTest)),
191
+ Logger.withMinimumLogLevel(LogLevel.None),
192
+ )
193
+
194
+ E.runSync(effect)
195
+ })
196
+ })