@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
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { type RouterOps, RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
2
|
+
import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
|
|
3
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
4
|
+
import { mock } from 'vitest-mock-extended'
|
|
5
|
+
import { AuthenticateServiceLive, AuthenticationService, GetCredential } from './authenticate'
|
|
6
|
+
import * as Fixture from './authenticate.fixture'
|
|
7
|
+
import { StorageService } from '../storage/storage'
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
describe('authenticate should', () => {
|
|
11
|
+
test('return a valid principal', async () => {
|
|
12
|
+
const assertions = E.gen(function* (_) {
|
|
13
|
+
const service = yield* _(AuthenticationService)
|
|
14
|
+
const result = yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
15
|
+
|
|
16
|
+
expect(result).toEqual(Fixture.principal)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const service = pipe(
|
|
20
|
+
AuthenticateServiceLive,
|
|
21
|
+
L.provide(Fixture.getCredentialTest),
|
|
22
|
+
L.provide(Fixture.capabilitiesTest),
|
|
23
|
+
L.provide(Fixture.storageServiceTest),
|
|
24
|
+
L.provide(Fixture.rpcClientTest),
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
const effect = pipe(E.provide(assertions, service), Logger.withMinimumLogLevel(LogLevel.None))
|
|
28
|
+
|
|
29
|
+
return E.runPromise(effect)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('pass the authentication request to the backend', async () => {
|
|
33
|
+
const assertions = E.gen(function* (_) {
|
|
34
|
+
const service = yield* _(AuthenticationService)
|
|
35
|
+
yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
36
|
+
|
|
37
|
+
const rpcClient = yield* _(RpcClient)
|
|
38
|
+
expect(rpcClient.getAuthenticationOptions).toHaveBeenCalledOnce()
|
|
39
|
+
expect(rpcClient.verifyAuthenticationCredential).toHaveBeenCalledOnce()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const rpcClientTest = L.effect(
|
|
43
|
+
RpcClient,
|
|
44
|
+
E.sync(() => {
|
|
45
|
+
const rpcMock = mock<RouterOps>()
|
|
46
|
+
|
|
47
|
+
rpcMock.getAuthenticationOptions.mockReturnValue(E.succeed(Fixture.optionsRes))
|
|
48
|
+
rpcMock.verifyAuthenticationCredential.mockReturnValue(E.succeed(Fixture.verificationRes))
|
|
49
|
+
|
|
50
|
+
return rpcMock
|
|
51
|
+
}),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
const service = pipe(
|
|
55
|
+
AuthenticateServiceLive,
|
|
56
|
+
L.provide(Fixture.getCredentialTest),
|
|
57
|
+
L.provide(Fixture.capabilitiesTest),
|
|
58
|
+
L.provide(Fixture.storageServiceTest),
|
|
59
|
+
L.provide(rpcClientTest),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
const layers = Layer.merge(service, rpcClientTest)
|
|
63
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
64
|
+
|
|
65
|
+
return E.runPromise(effect)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('send the credential to the backend', async () => {
|
|
69
|
+
const assertions = E.gen(function* (_) {
|
|
70
|
+
const service = yield* _(AuthenticationService)
|
|
71
|
+
yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
72
|
+
|
|
73
|
+
const rpcClient = yield* _(RpcClient)
|
|
74
|
+
expect(rpcClient.getAuthenticationOptions).toHaveBeenCalledOnce()
|
|
75
|
+
expect(rpcClient.verifyAuthenticationCredential).toHaveBeenCalledWith(Fixture.verificationReq)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const rpcClientTest = L.effect(
|
|
79
|
+
RpcClient,
|
|
80
|
+
E.sync(() => {
|
|
81
|
+
const rpcMock = mock<RouterOps>()
|
|
82
|
+
|
|
83
|
+
rpcMock.getAuthenticationOptions.mockReturnValue(E.succeed(Fixture.optionsRes))
|
|
84
|
+
rpcMock.verifyAuthenticationCredential.mockReturnValue(E.succeed(Fixture.verificationRes))
|
|
85
|
+
|
|
86
|
+
return rpcMock
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
const service = pipe(
|
|
91
|
+
AuthenticateServiceLive,
|
|
92
|
+
L.provide(Fixture.getCredentialTest),
|
|
93
|
+
L.provide(Fixture.capabilitiesTest),
|
|
94
|
+
L.provide(Fixture.storageServiceTest),
|
|
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('store the credential in local storage', async () => {
|
|
105
|
+
const assertions = E.gen(function* (_) {
|
|
106
|
+
const service = yield* _(AuthenticationService)
|
|
107
|
+
yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
108
|
+
|
|
109
|
+
const storageService = yield* _(StorageService)
|
|
110
|
+
expect(storageService.storeToken).toHaveBeenCalledWith(Fixture.principal)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const storageServiceTest = L.effect(
|
|
114
|
+
StorageService,
|
|
115
|
+
E.sync(() => {
|
|
116
|
+
const storageMock = mock<StorageService>()
|
|
117
|
+
|
|
118
|
+
storageMock.storeToken.mockReturnValue(E.unit)
|
|
119
|
+
storageMock.clearExpiredToken.mockReturnValue(E.unit)
|
|
120
|
+
|
|
121
|
+
return storageMock
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
const service = pipe(
|
|
126
|
+
AuthenticateServiceLive,
|
|
127
|
+
L.provide(Fixture.getCredentialTest),
|
|
128
|
+
L.provide(Fixture.capabilitiesTest),
|
|
129
|
+
L.provide(Fixture.rpcClientTest),
|
|
130
|
+
L.provide(storageServiceTest),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
const layers = Layer.merge(service, storageServiceTest)
|
|
134
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
135
|
+
|
|
136
|
+
return E.runPromise(effect)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('schedule deletion of the local token', async () => {
|
|
140
|
+
const assertions = E.gen(function* (_) {
|
|
141
|
+
const service = yield* _(AuthenticationService)
|
|
142
|
+
yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
143
|
+
|
|
144
|
+
const storageService = yield* _(StorageService)
|
|
145
|
+
expect(storageService.clearExpiredToken).toHaveBeenCalledWith('passkey')
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
const storageServiceTest = L.effect(
|
|
149
|
+
StorageService,
|
|
150
|
+
E.sync(() => {
|
|
151
|
+
const storageMock = mock<StorageService>()
|
|
152
|
+
|
|
153
|
+
storageMock.storeToken.mockReturnValue(E.unit)
|
|
154
|
+
storageMock.clearExpiredToken.mockReturnValue(E.unit)
|
|
155
|
+
|
|
156
|
+
return storageMock
|
|
157
|
+
}),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
const service = pipe(
|
|
161
|
+
AuthenticateServiceLive,
|
|
162
|
+
L.provide(Fixture.getCredentialTest),
|
|
163
|
+
L.provide(Fixture.capabilitiesTest),
|
|
164
|
+
L.provide(Fixture.rpcClientTest),
|
|
165
|
+
L.provide(storageServiceTest),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
const layers = Layer.merge(service, storageServiceTest)
|
|
169
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
170
|
+
|
|
171
|
+
return E.runPromise(effect)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("return an error if the browser can't create a credential", async () => {
|
|
175
|
+
const assertions = E.gen(function* (_) {
|
|
176
|
+
const service = yield* _(AuthenticationService)
|
|
177
|
+
yield* _(service.authenticatePasskey({ userVerification: 'preferred' }))
|
|
178
|
+
|
|
179
|
+
const getCredential = yield* _(GetCredential)
|
|
180
|
+
expect(getCredential).toHaveBeenCalledOnce()
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const getCredentialTest = L.effect(
|
|
184
|
+
GetCredential,
|
|
185
|
+
E.sync(() => {
|
|
186
|
+
const getCredentialMock = vi.fn()
|
|
187
|
+
|
|
188
|
+
getCredentialMock.mockReturnValue(E.succeed(Fixture.credential))
|
|
189
|
+
|
|
190
|
+
return getCredentialMock
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const service = pipe(
|
|
195
|
+
AuthenticateServiceLive,
|
|
196
|
+
L.provide(Fixture.storageServiceTest),
|
|
197
|
+
L.provide(Fixture.capabilitiesTest),
|
|
198
|
+
L.provide(Fixture.rpcClientTest),
|
|
199
|
+
L.provide(getCredentialTest),
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
const layers = Layer.merge(service, getCredentialTest)
|
|
203
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
204
|
+
|
|
205
|
+
return E.runPromise(effect)
|
|
206
|
+
})
|
|
207
|
+
})
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey authentication effects
|
|
3
|
+
*/
|
|
4
|
+
import {
|
|
5
|
+
type CredentialRequestOptionsJSON,
|
|
6
|
+
parseRequestOptionsFromJSON,
|
|
7
|
+
} from '@github/webauthn-json/browser-ponyfill'
|
|
8
|
+
import {
|
|
9
|
+
type BadRequest,
|
|
10
|
+
InternalBrowserError,
|
|
11
|
+
type NotSupported,
|
|
12
|
+
} from '@passlock/shared/dist/error/error'
|
|
13
|
+
import type {
|
|
14
|
+
VerificationErrors} from '@passlock/shared/dist/rpc/authentication';
|
|
15
|
+
import {
|
|
16
|
+
OptionsReq,
|
|
17
|
+
VerificationReq,
|
|
18
|
+
} from '@passlock/shared/dist/rpc/authentication'
|
|
19
|
+
import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
20
|
+
import type {
|
|
21
|
+
AuthenticationCredential,
|
|
22
|
+
Principal,
|
|
23
|
+
UserVerification } 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
|
+
|
|
28
|
+
|
|
29
|
+
/* Requests */
|
|
30
|
+
|
|
31
|
+
export type AuthenticationRequest = { userVerification?: UserVerification }
|
|
32
|
+
|
|
33
|
+
/* Errors */
|
|
34
|
+
|
|
35
|
+
export type AuthenticationErrors = NotSupported | BadRequest | VerificationErrors
|
|
36
|
+
|
|
37
|
+
/* Dependencies */
|
|
38
|
+
|
|
39
|
+
export type GetCredential = (
|
|
40
|
+
options: CredentialRequestOptions,
|
|
41
|
+
) => E.Effect<AuthenticationCredential, InternalBrowserError>
|
|
42
|
+
export const GetCredential = Context.GenericTag<GetCredential>('@services/Get')
|
|
43
|
+
|
|
44
|
+
/* Service */
|
|
45
|
+
|
|
46
|
+
export type AuthenticationService = {
|
|
47
|
+
authenticatePasskey: (data: AuthenticationRequest) => E.Effect<Principal, AuthenticationErrors>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const AuthenticationService = Context.GenericTag<AuthenticationService>(
|
|
51
|
+
'@services/AuthenticationService',
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
/* Utilities */
|
|
55
|
+
|
|
56
|
+
const fetchOptions = (req: OptionsReq) => {
|
|
57
|
+
return E.gen(function* (_) {
|
|
58
|
+
yield* _(E.logDebug('Making request'))
|
|
59
|
+
|
|
60
|
+
const rpcClient = yield* _(RpcClient)
|
|
61
|
+
const { publicKey, session } = yield* _(rpcClient.getAuthenticationOptions(req))
|
|
62
|
+
|
|
63
|
+
yield* _(E.logDebug('Converting Passlock options to CredentialRequestOptions'))
|
|
64
|
+
const options = yield* _(toRequestOptions({ publicKey }))
|
|
65
|
+
|
|
66
|
+
return { options, session }
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const toRequestOptions = (options: CredentialRequestOptionsJSON) => {
|
|
71
|
+
return pipe(
|
|
72
|
+
E.try(() => parseRequestOptionsFromJSON(options)),
|
|
73
|
+
E.mapError(
|
|
74
|
+
error =>
|
|
75
|
+
new InternalBrowserError({
|
|
76
|
+
message: 'Browser was unable to create credential request options',
|
|
77
|
+
detail: String(error.error),
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const verifyCredential = (req: VerificationReq) => {
|
|
84
|
+
return E.gen(function* (_) {
|
|
85
|
+
yield* _(E.logDebug('Making request'))
|
|
86
|
+
|
|
87
|
+
const rpcClient = yield* _(RpcClient)
|
|
88
|
+
const { principal } = yield* _(rpcClient.verifyAuthenticationCredential(req))
|
|
89
|
+
|
|
90
|
+
return principal
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* Effects */
|
|
95
|
+
|
|
96
|
+
type Dependencies = GetCredential | Capabilities | StorageService | RpcClient
|
|
97
|
+
|
|
98
|
+
export const authenticatePasskey = (
|
|
99
|
+
request: AuthenticationRequest,
|
|
100
|
+
): E.Effect<Principal, AuthenticationErrors, Dependencies> => {
|
|
101
|
+
const effect = E.gen(function* (_) {
|
|
102
|
+
yield* _(E.logInfo('Checking if browser supports Passkeys'))
|
|
103
|
+
const capabilities = yield* _(Capabilities)
|
|
104
|
+
yield* _(capabilities.passkeySupport)
|
|
105
|
+
|
|
106
|
+
yield* _(E.logInfo('Fetching authentication options from Passlock'))
|
|
107
|
+
const { options, session } = yield* _(fetchOptions(new OptionsReq(request)))
|
|
108
|
+
|
|
109
|
+
yield* _(E.logInfo('Looking up credential'))
|
|
110
|
+
const get = yield* _(GetCredential)
|
|
111
|
+
const credential = yield* _(get(options))
|
|
112
|
+
|
|
113
|
+
yield* _(E.logInfo('Verifying credential with Passlock'))
|
|
114
|
+
const principal = yield* _(verifyCredential(new VerificationReq({ credential, session })))
|
|
115
|
+
|
|
116
|
+
const storageService = yield* _(StorageService)
|
|
117
|
+
yield* _(storageService.storeToken(principal))
|
|
118
|
+
yield* _(E.logDebug('Stored token in local storage'))
|
|
119
|
+
|
|
120
|
+
yield* _(E.logDebug('Defering local token deletion'))
|
|
121
|
+
const delayedClearTokenE = pipe(
|
|
122
|
+
storageService.clearExpiredToken('passkey'),
|
|
123
|
+
E.delay('6 minutes'),
|
|
124
|
+
E.fork,
|
|
125
|
+
)
|
|
126
|
+
yield* _(delayedClearTokenE)
|
|
127
|
+
|
|
128
|
+
return principal
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
return E.catchTag(effect, 'InternalBrowserError', e => E.die(e))
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* Live */
|
|
135
|
+
|
|
136
|
+
/* v8 ignore start */
|
|
137
|
+
export const AuthenticateServiceLive = Layer.effect(
|
|
138
|
+
AuthenticationService,
|
|
139
|
+
E.gen(function* (_) {
|
|
140
|
+
const context = yield* _(E.context<GetCredential | RpcClient | Capabilities | StorageService>())
|
|
141
|
+
|
|
142
|
+
return AuthenticationService.of({
|
|
143
|
+
authenticatePasskey: flow(authenticatePasskey, E.provide(context)),
|
|
144
|
+
})
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
/* v8 ignore stop */
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test if the browser supports passkeys, conditional UI etc
|
|
3
|
+
*/
|
|
4
|
+
import { NotSupported } from '@passlock/shared/dist/error/error'
|
|
5
|
+
import { Context, Effect as E, Layer, identity, pipe } from 'effect'
|
|
6
|
+
|
|
7
|
+
/* Service */
|
|
8
|
+
|
|
9
|
+
export type Capabilities = {
|
|
10
|
+
passkeySupport: E.Effect<void, NotSupported>
|
|
11
|
+
isPasskeySupport: E.Effect<boolean>
|
|
12
|
+
autofillSupport: E.Effect<void, NotSupported>
|
|
13
|
+
isAutofillSupport: E.Effect<boolean>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const Capabilities = Context.GenericTag<Capabilities>('@services/Capabilities')
|
|
17
|
+
|
|
18
|
+
/* Effects */
|
|
19
|
+
|
|
20
|
+
const hasWebAuthn = E.suspend(() =>
|
|
21
|
+
typeof window.PublicKeyCredential === 'function'
|
|
22
|
+
? E.unit
|
|
23
|
+
: new NotSupported({ message: 'WebAuthn API is not supported on this device' }),
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const hasPlatformAuth = pipe(
|
|
27
|
+
E.tryPromise(() => window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()),
|
|
28
|
+
E.filterOrFail(
|
|
29
|
+
identity,
|
|
30
|
+
() => new NotSupported({ message: 'No platform authenticator available on this device' }),
|
|
31
|
+
),
|
|
32
|
+
E.asUnit,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const hasConditionalUi = pipe(
|
|
36
|
+
E.tryPromise({
|
|
37
|
+
try: () => window.PublicKeyCredential.isConditionalMediationAvailable(),
|
|
38
|
+
catch: () =>
|
|
39
|
+
new NotSupported({ message: 'Conditional mediation not available on this device' }),
|
|
40
|
+
}),
|
|
41
|
+
E.filterOrFail(
|
|
42
|
+
identity,
|
|
43
|
+
() => new NotSupported({ message: 'Conditional mediation not available on this device' }),
|
|
44
|
+
),
|
|
45
|
+
E.asUnit,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
export const passkeySupport = pipe(
|
|
49
|
+
hasWebAuthn,
|
|
50
|
+
E.andThen(hasPlatformAuth),
|
|
51
|
+
E.catchTag('UnknownException', e => E.die(e)),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export const isPasskeySupport = pipe(
|
|
55
|
+
passkeySupport,
|
|
56
|
+
E.match({
|
|
57
|
+
onFailure: () => false,
|
|
58
|
+
onSuccess: () => true,
|
|
59
|
+
}),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
export const autofillSupport = pipe(passkeySupport, E.andThen(hasConditionalUi))
|
|
63
|
+
|
|
64
|
+
export const isAutofillSupport = pipe(
|
|
65
|
+
autofillSupport,
|
|
66
|
+
E.match({
|
|
67
|
+
onFailure: () => false,
|
|
68
|
+
onSuccess: () => true,
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
/* Live */
|
|
73
|
+
|
|
74
|
+
/* v8 ignore start */
|
|
75
|
+
export const capabilitiesLive = Layer.succeed(Capabilities, {
|
|
76
|
+
passkeySupport,
|
|
77
|
+
isPasskeySupport,
|
|
78
|
+
autofillSupport,
|
|
79
|
+
isAutofillSupport,
|
|
80
|
+
})
|
|
81
|
+
/* v8 ignore stop */
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { RpcConfig } from '@passlock/shared/dist/rpc/rpc'
|
|
2
|
+
import { Context, Layer } from 'effect'
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
export const DefaultEndpoint = 'https://api.passlock.dev'
|
|
6
|
+
|
|
7
|
+
export type Tenancy = {
|
|
8
|
+
tenancyId: string
|
|
9
|
+
clientId: string
|
|
10
|
+
}
|
|
11
|
+
export const Tenancy = Context.GenericTag<Tenancy>('@services/Tenancy')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Allow developers to override the endpoint e.g. to
|
|
15
|
+
* point to a regional endpoint or a self-hosted backend
|
|
16
|
+
*/
|
|
17
|
+
export type Endpoint = {
|
|
18
|
+
endpoint?: string
|
|
19
|
+
}
|
|
20
|
+
export const Endpoint = Context.GenericTag<Endpoint>('@services/Endpoint')
|
|
21
|
+
|
|
22
|
+
export type Config = Tenancy & Endpoint
|
|
23
|
+
export const Config = Context.GenericTag<Config>('@services/Config')
|
|
24
|
+
|
|
25
|
+
export const buildConfigLayers = (config: Config) => {
|
|
26
|
+
const tenancyLayer = Layer.succeed(Tenancy, Tenancy.of(config))
|
|
27
|
+
const endpointLayer = Layer.succeed(Endpoint, Endpoint.of(config))
|
|
28
|
+
return Layer.mergeAll(tenancyLayer, endpointLayer)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const buildRpcConfigLayers = (config: Config) => {
|
|
32
|
+
const endpoint = config.endpoint || DefaultEndpoint
|
|
33
|
+
return Layer.succeed(
|
|
34
|
+
RpcConfig,
|
|
35
|
+
RpcConfig.of({
|
|
36
|
+
endpoint: endpoint,
|
|
37
|
+
tenancyId: config.tenancyId,
|
|
38
|
+
clientId: config.clientId,
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type RequestDependencies = Endpoint | Tenancy | Storage | RpcConfig
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { BadRequest } from '@passlock/shared/dist/error/error'
|
|
2
|
+
import { PreConnectReq, PreConnectRes } from '@passlock/shared/dist/rpc/connection'
|
|
3
|
+
import { RpcClient } from '@passlock/shared/dist/rpc/rpc'
|
|
4
|
+
import { Effect as E, Layer as L } from 'effect'
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
export const rpcClientTest = L.succeed(
|
|
8
|
+
RpcClient,
|
|
9
|
+
RpcClient.of({
|
|
10
|
+
preConnect: () => E.succeed({ warmed: true }),
|
|
11
|
+
isExistingUser: () => E.succeed({ existingUser: true }),
|
|
12
|
+
verifyEmail: () => E.succeed({ verified: true }),
|
|
13
|
+
getRegistrationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
14
|
+
verifyRegistrationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
15
|
+
getAuthenticationOptions: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
16
|
+
verifyAuthenticationCredential: () => E.fail(new BadRequest({ message: 'Not implemeneted' })),
|
|
17
|
+
}),
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
export const preConnectReq = new PreConnectReq({})
|
|
21
|
+
export const preConnectRes = new PreConnectRes({ warmed: true })
|
|
22
|
+
|
|
23
|
+
export const rpcConfig = {
|
|
24
|
+
endpoint: 'https://example.com',
|
|
25
|
+
tenancyId: 'tenancyId', clientId:
|
|
26
|
+
'clientId'
|
|
27
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type RouterOps, RpcClient, RpcConfig, NetworkService } from '@passlock/shared/dist/rpc/rpc'
|
|
2
|
+
import { Effect as E, Layer as L, Layer, LogLevel, Logger, pipe } from 'effect'
|
|
3
|
+
import { describe, expect, test } from 'vitest'
|
|
4
|
+
import { mock } from 'vitest-mock-extended'
|
|
5
|
+
import { ConnectionService, ConnectionServiceLive } from './connection'
|
|
6
|
+
import * as Fixture from './connection.fixture'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
describe('preConnect should', () => {
|
|
10
|
+
test('hit the rpc endpoint', async () => {
|
|
11
|
+
const assertions = E.gen(function* (_) {
|
|
12
|
+
const service = yield* _(ConnectionService)
|
|
13
|
+
yield* _(service.preConnect())
|
|
14
|
+
|
|
15
|
+
const rpcClient = yield* _(RpcClient)
|
|
16
|
+
expect(rpcClient.preConnect).toBeCalledWith(Fixture.preConnectReq)
|
|
17
|
+
|
|
18
|
+
const networkService = yield* _(NetworkService)
|
|
19
|
+
expect(networkService.get).toBeCalledWith(`/token/token?warm=true`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
const rpcClientTest = Layer.effect(
|
|
23
|
+
RpcClient,
|
|
24
|
+
E.sync(() => {
|
|
25
|
+
const rpcMock = mock<RouterOps>()
|
|
26
|
+
|
|
27
|
+
rpcMock.preConnect.mockReturnValue(E.succeed(Fixture.preConnectRes))
|
|
28
|
+
|
|
29
|
+
return rpcMock
|
|
30
|
+
}),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const rpcConfigTest = Layer.succeed(
|
|
34
|
+
RpcConfig,
|
|
35
|
+
RpcConfig.of(Fixture.rpcConfig)
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
const networkServiceTest = Layer.effect(
|
|
39
|
+
NetworkService,
|
|
40
|
+
E.sync(() => {
|
|
41
|
+
const networkMock = mock<NetworkService['Type']>()
|
|
42
|
+
|
|
43
|
+
networkMock.get.mockReturnValue(E.succeed({ }))
|
|
44
|
+
|
|
45
|
+
return networkMock
|
|
46
|
+
}),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const service = pipe(
|
|
50
|
+
ConnectionServiceLive,
|
|
51
|
+
L.provide(rpcClientTest),
|
|
52
|
+
L.provide(networkServiceTest),
|
|
53
|
+
L.provide(rpcConfigTest)
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const layers = L.mergeAll(service, rpcClientTest, networkServiceTest)
|
|
57
|
+
const effect = pipe(E.provide(assertions, layers), Logger.withMinimumLogLevel(LogLevel.None))
|
|
58
|
+
|
|
59
|
+
return E.runPromise(effect)
|
|
60
|
+
})
|
|
61
|
+
})
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hits the rpc endpoint to warm up a lambda
|
|
3
|
+
*/
|
|
4
|
+
import { PreConnectReq } from '@passlock/shared/dist/rpc/connection'
|
|
5
|
+
import { NetworkService, RpcClient, RpcConfig } from '@passlock/shared/dist/rpc/rpc'
|
|
6
|
+
import { Context, Effect as E, Layer, flow, pipe } from 'effect'
|
|
7
|
+
|
|
8
|
+
/* Service */
|
|
9
|
+
|
|
10
|
+
export type ConnectionService = {
|
|
11
|
+
preConnect: () => E.Effect<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const ConnectionService = Context.GenericTag<ConnectionService>('@services/ConnectService')
|
|
15
|
+
|
|
16
|
+
/* Effects */
|
|
17
|
+
|
|
18
|
+
const hitPrincipal = pipe(
|
|
19
|
+
E.logInfo('Pre-connecting to Principal endpoint'),
|
|
20
|
+
E.zipRight(NetworkService),
|
|
21
|
+
E.flatMap(networkService => networkService.get('/token/token?warm=true')),
|
|
22
|
+
E.asUnit,
|
|
23
|
+
E.catchAll(() => E.unit)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
const hitRpc = pipe(
|
|
27
|
+
E.logInfo('Pre-connecting to RPC endpoint'),
|
|
28
|
+
E.zipRight(RpcClient),
|
|
29
|
+
E.flatMap(rpcClient => rpcClient.preConnect(new PreConnectReq({}))),
|
|
30
|
+
E.asUnit
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
export const preConnect = () => pipe(
|
|
34
|
+
E.all([hitPrincipal, hitRpc], { concurrency: 2 }),
|
|
35
|
+
E.asUnit
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
/* Live */
|
|
39
|
+
|
|
40
|
+
/* v8 ignore start */
|
|
41
|
+
export const ConnectionServiceLive = Layer.effect(
|
|
42
|
+
ConnectionService,
|
|
43
|
+
E.gen(function* (_) {
|
|
44
|
+
const context = yield* _(E.context<RpcClient | NetworkService | RpcConfig>())
|
|
45
|
+
|
|
46
|
+
return ConnectionService.of({
|
|
47
|
+
preConnect: flow(preConnect, E.provide(context)),
|
|
48
|
+
})
|
|
49
|
+
}),
|
|
50
|
+
)
|
|
51
|
+
/* v8 ignore stop */
|