@effect-auth/core 0.1.0-alpha.1 → 0.1.0-alpha.2
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/README.md +622 -61
- package/dist/AuditLog.d.ts.map +1 -1
- package/dist/AuditLog.js +1 -1
- package/dist/AuditLog.js.map +1 -1
- package/dist/AuthConfig.d.ts +51 -0
- package/dist/AuthConfig.d.ts.map +1 -0
- package/dist/AuthConfig.js +56 -0
- package/dist/AuthConfig.js.map +1 -0
- package/dist/AuthFlow.d.ts +292 -35
- package/dist/AuthFlow.d.ts.map +1 -1
- package/dist/AuthFlow.js +130 -6
- package/dist/AuthFlow.js.map +1 -1
- package/dist/AuthKernel.d.ts +3 -0
- package/dist/AuthKernel.d.ts.map +1 -0
- package/dist/AuthKernel.js +9 -0
- package/dist/AuthKernel.js.map +1 -0
- package/dist/Challenge.d.ts +19 -3
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +157 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Client.d.ts +36 -0
- package/dist/Client.d.ts.map +1 -0
- package/dist/Client.js +55 -0
- package/dist/Client.js.map +1 -0
- package/dist/Crypto.d.ts +6 -1
- package/dist/Crypto.d.ts.map +1 -1
- package/dist/Crypto.js +62 -1
- package/dist/Crypto.js.map +1 -1
- package/dist/D1Kysely.d.ts +6 -3
- package/dist/D1Kysely.d.ts.map +1 -1
- package/dist/D1Kysely.js +8 -5
- package/dist/D1Kysely.js.map +1 -1
- package/dist/EffectQbSqliteStorage.d.ts +183 -0
- package/dist/EffectQbSqliteStorage.d.ts.map +1 -0
- package/dist/EffectQbSqliteStorage.js +446 -0
- package/dist/EffectQbSqliteStorage.js.map +1 -0
- package/dist/EmailOtp.d.ts +117 -0
- package/dist/EmailOtp.d.ts.map +1 -0
- package/dist/EmailOtp.js +279 -0
- package/dist/EmailOtp.js.map +1 -0
- package/dist/EmailVerification.d.ts +121 -0
- package/dist/EmailVerification.d.ts.map +1 -0
- package/dist/EmailVerification.js +201 -0
- package/dist/EmailVerification.js.map +1 -0
- package/dist/HttpApi/Api.d.ts +175 -0
- package/dist/HttpApi/Api.d.ts.map +1 -0
- package/dist/HttpApi/Api.js +340 -0
- package/dist/HttpApi/Api.js.map +1 -0
- package/dist/HttpApi/Endpoints.d.ts +92 -0
- package/dist/HttpApi/Endpoints.d.ts.map +1 -0
- package/dist/HttpApi/Endpoints.js +48 -0
- package/dist/HttpApi/Endpoints.js.map +1 -0
- package/dist/HttpApi/Errors.d.ts +82 -0
- package/dist/HttpApi/Errors.d.ts.map +1 -0
- package/dist/HttpApi/Errors.js +184 -0
- package/dist/HttpApi/Errors.js.map +1 -0
- package/dist/HttpApi/Schemas.d.ts +86 -0
- package/dist/HttpApi/Schemas.d.ts.map +1 -0
- package/dist/HttpApi/Schemas.js +63 -0
- package/dist/HttpApi/Schemas.js.map +1 -0
- package/dist/HttpApi/Security.d.ts +147 -0
- package/dist/HttpApi/Security.d.ts.map +1 -0
- package/dist/HttpApi/Security.js +439 -0
- package/dist/HttpApi/Security.js.map +1 -0
- package/dist/HttpApi/Transport.d.ts +31 -0
- package/dist/HttpApi/Transport.d.ts.map +1 -0
- package/dist/HttpApi/Transport.js +169 -0
- package/dist/HttpApi/Transport.js.map +1 -0
- package/dist/HttpApi/index.d.ts +8 -0
- package/dist/HttpApi/index.d.ts.map +1 -0
- package/dist/HttpApi/index.js +7 -0
- package/dist/HttpApi/index.js.map +1 -0
- package/dist/HttpApi.d.ts +2 -0
- package/dist/HttpApi.d.ts.map +1 -0
- package/dist/HttpApi.js +2 -0
- package/dist/HttpApi.js.map +1 -0
- package/dist/Identifiers.d.ts.map +1 -1
- package/dist/Identifiers.js.map +1 -1
- package/dist/Internal.d.ts +7 -0
- package/dist/Internal.d.ts.map +1 -0
- package/dist/Internal.js +12 -0
- package/dist/Internal.js.map +1 -0
- package/dist/KyselyStorage.d.ts +28 -2
- package/dist/KyselyStorage.d.ts.map +1 -1
- package/dist/KyselyStorage.js +223 -33
- package/dist/KyselyStorage.js.map +1 -1
- package/dist/Mailer.d.ts +116 -0
- package/dist/Mailer.d.ts.map +1 -0
- package/dist/Mailer.js +101 -0
- package/dist/Mailer.js.map +1 -0
- package/dist/Password.d.ts +107 -0
- package/dist/Password.d.ts.map +1 -0
- package/dist/Password.js +252 -0
- package/dist/Password.js.map +1 -0
- package/dist/Policy.d.ts +7 -4
- package/dist/Policy.d.ts.map +1 -1
- package/dist/Policy.js +28 -5
- package/dist/Policy.js.map +1 -1
- package/dist/Privacy.d.ts +15 -0
- package/dist/Privacy.d.ts.map +1 -1
- package/dist/Privacy.js +58 -1
- package/dist/Privacy.js.map +1 -1
- package/dist/RateLimiter.d.ts +104 -0
- package/dist/RateLimiter.d.ts.map +1 -0
- package/dist/RateLimiter.js +118 -0
- package/dist/RateLimiter.js.map +1 -0
- package/dist/Sessions.d.ts +73 -11
- package/dist/Sessions.d.ts.map +1 -1
- package/dist/Sessions.js +412 -1
- package/dist/Sessions.js.map +1 -1
- package/dist/Storage.d.ts +60 -5
- package/dist/Storage.d.ts.map +1 -1
- package/dist/Storage.js +11 -1
- package/dist/Storage.js.map +1 -1
- package/dist/StorageMigrations.d.ts +2 -1
- package/dist/StorageMigrations.d.ts.map +1 -1
- package/dist/StorageMigrations.js +33 -2
- package/dist/StorageMigrations.js.map +1 -1
- package/dist/Testing.d.ts +9 -1
- package/dist/Testing.d.ts.map +1 -1
- package/dist/Testing.js +30 -2
- package/dist/Testing.js.map +1 -1
- package/dist/WaitUntil.d.ts.map +1 -1
- package/dist/WaitUntil.js +1 -1
- package/dist/WaitUntil.js.map +1 -1
- package/dist/alchemy/cloudflare/RateLimitDurableObject.d.ts +34 -0
- package/dist/alchemy/cloudflare/RateLimitDurableObject.d.ts.map +1 -0
- package/dist/alchemy/cloudflare/RateLimitDurableObject.js +30 -0
- package/dist/alchemy/cloudflare/RateLimitDurableObject.js.map +1 -0
- package/dist/cloudflare/RateLimitDurableObject.d.ts +50 -0
- package/dist/cloudflare/RateLimitDurableObject.d.ts.map +1 -0
- package/dist/cloudflare/RateLimitDurableObject.js +51 -0
- package/dist/cloudflare/RateLimitDurableObject.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/RateLimitDurableObject.d.ts +32 -0
- package/dist/internal/RateLimitDurableObject.d.ts.map +1 -0
- package/dist/internal/RateLimitDurableObject.js +80 -0
- package/dist/internal/RateLimitDurableObject.js.map +1 -0
- package/migrations/0002_auth_password.sql +25 -0
- package/package.json +76 -20
package/README.md
CHANGED
|
@@ -4,22 +4,41 @@ Composable Effect-first authentication primitives.
|
|
|
4
4
|
|
|
5
5
|
The current alpha surface is intentionally small: service contracts and small helpers that can be wired with application-owned layers.
|
|
6
6
|
|
|
7
|
+
## HTTP Client
|
|
8
|
+
|
|
9
|
+
`@effect-auth/core/Client` exposes a browser-friendly client generated from the same `AuthApi` contract used by the server. It accepts plain JSON input types, includes cookies by default, and decodes successful/error responses through Effect Schema.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { createAuthClient } from "@effect-auth/core/Client";
|
|
13
|
+
|
|
14
|
+
const authClient = createAuthClient();
|
|
15
|
+
|
|
16
|
+
const result = await authClient.password.signIn({
|
|
17
|
+
email: "reader@example.com",
|
|
18
|
+
password: "correct horse battery staple",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const session = await authClient.session.currentOrUndefined();
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Use `@effect-auth/core/HttpApi` when you are wiring the server or need the raw `HttpApi` contract. Use `@effect-auth/core/Client` in application UI code.
|
|
25
|
+
|
|
7
26
|
## Policy
|
|
8
27
|
|
|
9
28
|
`Policy` is an `Effect` used for authorization. Success means access is allowed. `AuthzError` means access is denied. Other failures stay operational errors.
|
|
10
29
|
|
|
11
30
|
```ts
|
|
12
|
-
import { Effect } from "effect"
|
|
13
|
-
import * as Policy from "@effect-auth/core/Policy"
|
|
31
|
+
import { Effect } from "effect";
|
|
32
|
+
import * as Policy from "@effect-auth/core/Policy";
|
|
14
33
|
|
|
15
|
-
const canEditProject = Policy.deny("missing-permission")
|
|
34
|
+
const canEditProject = Policy.deny("missing-permission");
|
|
16
35
|
|
|
17
36
|
const updateProject = Effect.succeed({ updated: true }).pipe(
|
|
18
37
|
Policy.require(canEditProject)
|
|
19
|
-
)
|
|
38
|
+
);
|
|
20
39
|
|
|
21
|
-
const decision = Policy.check(canEditProject)
|
|
22
|
-
const detailed = Policy.checkDetailed(canEditProject)
|
|
40
|
+
const decision = Policy.check(canEditProject);
|
|
41
|
+
const detailed = Policy.checkDetailed(canEditProject);
|
|
23
42
|
```
|
|
24
43
|
|
|
25
44
|
Use `Policy.check` when callers should branch on allowed/denied and let operational errors fail. Use `Policy.checkDetailed` when callers need a value for allowed, denied, or failed.
|
|
@@ -27,87 +46,479 @@ Use `Policy.check` when callers should branch on allowed/denied and let operatio
|
|
|
27
46
|
Policies compose with `all`, `any`, and `not`:
|
|
28
47
|
|
|
29
48
|
```ts
|
|
30
|
-
declare const isProjectOwner: Policy.Policy
|
|
31
|
-
declare const hasUpdateGrant: Policy.Policy
|
|
32
|
-
declare const isSuspended: Policy.Policy
|
|
49
|
+
declare const isProjectOwner: Policy.Policy;
|
|
50
|
+
declare const hasUpdateGrant: Policy.Policy;
|
|
51
|
+
declare const isSuspended: Policy.Policy;
|
|
33
52
|
|
|
34
|
-
const canEditProject = Policy.any(
|
|
35
|
-
isProjectOwner,
|
|
36
|
-
hasUpdateGrant
|
|
37
|
-
)
|
|
53
|
+
const canEditProject = Policy.any(isProjectOwner, hasUpdateGrant);
|
|
38
54
|
|
|
39
55
|
const canEditAndNotSuspended = Policy.all(
|
|
40
56
|
canEditProject,
|
|
41
57
|
Policy.not(isSuspended, "account-disabled")
|
|
42
|
-
)
|
|
58
|
+
);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Rate Limiter
|
|
62
|
+
|
|
63
|
+
`RateLimiter` is a small auth-oriented wrapper around Effect's persistent rate limiter. It keeps the public auth API focused on policies and safe keys while delegating fixed-window/token-bucket mechanics to `effect/unstable/persistence`.
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { Duration, Effect } from "effect";
|
|
67
|
+
import { EmailHash, type IpHash } from "@effect-auth/core/Identifiers";
|
|
68
|
+
import * as RateLimit from "@effect-auth/core/RateLimiter";
|
|
69
|
+
|
|
70
|
+
const PasswordSignInEmailLimit = RateLimit.RateLimitPolicy.fixedWindow({
|
|
71
|
+
id: "auth.password.sign_in.email",
|
|
72
|
+
key: RateLimit.RateLimitKey.emailHash(EmailHash("hmac-email-hash")),
|
|
73
|
+
limit: 5,
|
|
74
|
+
window: Duration.minutes(10),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const signIn = Effect.gen(function* () {
|
|
78
|
+
yield* RateLimit.require(PasswordSignInEmailLimit);
|
|
79
|
+
|
|
80
|
+
return yield* Effect.succeed({ ok: true });
|
|
81
|
+
});
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
`fixedWindowBy` and `tokenBucketBy` accept any safe `RateLimitKey`, including composite keys built from other safe keys:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
declare const ipHash: IpHash;
|
|
88
|
+
declare const emailHash: EmailHash;
|
|
89
|
+
|
|
90
|
+
const PasswordSignInIpEmailLimit = RateLimit.RateLimitPolicy.fixedWindowBy({
|
|
91
|
+
id: "auth.password.sign_in.ip_email",
|
|
92
|
+
by: RateLimit.RateLimitKey.combine(
|
|
93
|
+
"ip-email",
|
|
94
|
+
RateLimit.RateLimitKey.ipHash(ipHash),
|
|
95
|
+
RateLimit.RateLimitKey.emailHash(emailHash)
|
|
96
|
+
),
|
|
97
|
+
limit: 5,
|
|
98
|
+
window: Duration.minutes(10),
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Use `RateLimitKey.emailHash`, `ipHash`, `userAgentHash`, `customHash`, or `combine` with values produced by `Privacy` or another app-owned hashing layer; raw emails, IPs, tokens, and user agents should not be used as store keys. `PrivacyLive({ secret })` provides a production HMAC implementation with conservative input normalization. Use a dedicated secret such as `AUTH_PRIVACY_SECRET`, separate from session and challenge secrets. `RateLimiterMemoryLive` is process-local for tests/dev, and production deployments can provide a persistent Effect `RateLimiterStore` such as Redis or `@effect-auth/core/CloudflareRateLimitDurableObject`.
|
|
103
|
+
|
|
104
|
+
```ts
|
|
105
|
+
import { Redacted } from "effect";
|
|
106
|
+
import { PrivacyLive } from "@effect-auth/core/Privacy";
|
|
107
|
+
|
|
108
|
+
const AuthPrivacyLive = PrivacyLive({
|
|
109
|
+
secret: Redacted.make(env.AUTH_PRIVACY_SECRET),
|
|
110
|
+
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The built-in HTTP auth API includes default IP and email rate limits. Production deployments still provide their own durable `RateLimiter` store and HMAC `Privacy` secret. `AuthServicesLive` below stands for your app-owned auth service wiring.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
import { Layer, Redacted } from "effect";
|
|
117
|
+
import { RateLimiter as PersistenceRateLimiter } from "effect/unstable/persistence";
|
|
118
|
+
import { AuthHttpApiLive } from "@effect-auth/core/HttpApi";
|
|
119
|
+
import {
|
|
120
|
+
RateLimitStoreDurableObject,
|
|
121
|
+
type CloudflareRateLimitDurableObjectNamespace,
|
|
122
|
+
} from "@effect-auth/core/CloudflareRateLimitDurableObject";
|
|
123
|
+
import { PrivacyLive } from "@effect-auth/core/Privacy";
|
|
124
|
+
import { RateLimiterLive } from "@effect-auth/core/RateLimiter";
|
|
125
|
+
|
|
126
|
+
export const makeAuthHttpApiLayer = (
|
|
127
|
+
namespace: CloudflareRateLimitDurableObjectNamespace,
|
|
128
|
+
privacySecret: string
|
|
129
|
+
) => {
|
|
130
|
+
const DurableObjectRateLimiterLive = RateLimiterLive.pipe(
|
|
131
|
+
Layer.provide(PersistenceRateLimiter.layer),
|
|
132
|
+
Layer.provide(RateLimitStoreDurableObject.layer({ namespace }))
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
return AuthHttpApiLive.pipe(
|
|
136
|
+
Layer.provide(AuthServicesLive),
|
|
137
|
+
Layer.provide(DurableObjectRateLimiterLive),
|
|
138
|
+
Layer.provide(PrivacyLive({ secret: Redacted.make(privacySecret) }))
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`AuthHttpApiLive` enforces IP limits in HTTP middleware and email limits after schema decoding in the built-in handlers. A single request may consume both a request-level rule and a payload-level rule. `RateLimitPolicy.all(...)` is still available for custom flows and consumes sequentially; put the most specific limits first, such as `ip + email`, then broader limits such as `email`.
|
|
144
|
+
|
|
145
|
+
Advanced apps can replace the `AuthApiRateLimit` service directly. `AuthApiRateLimitMiddlewareLive` delegates request-level checks to that service, so custom policies can still use the built-in endpoint middleware and handler hooks.
|
|
146
|
+
|
|
147
|
+
Use `AuthHttpApiBareLive` only when you explicitly do not want built-in auth rate limits:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { Layer } from "effect";
|
|
151
|
+
import { AuthHttpApiBareLive } from "@effect-auth/core/HttpApi";
|
|
152
|
+
|
|
153
|
+
export const AuthHttpApiLayer = AuthHttpApiBareLive.pipe(
|
|
154
|
+
Layer.provide(AuthServicesLive)
|
|
155
|
+
);
|
|
43
156
|
```
|
|
44
157
|
|
|
45
158
|
## AuthFlow
|
|
46
159
|
|
|
47
|
-
Login methods should verify a factor, then hand the result to `AuthFlow`. `AuthFlow`
|
|
160
|
+
Login methods should verify a factor, then hand the result to `AuthFlow`. `AuthFlow` returns a flat `AuthResult` tagged enum: authenticated, step-up/verification requirements, or successful auth outcomes such as invalid credentials.
|
|
48
161
|
|
|
49
162
|
```ts
|
|
50
|
-
import { Effect } from "effect"
|
|
51
|
-
import { AuthFlow } from "@effect-auth/core/AuthFlow"
|
|
52
|
-
import type { UserId } from "@effect-auth/core/Identifiers"
|
|
163
|
+
import { Effect } from "effect";
|
|
164
|
+
import { AuthFlow } from "@effect-auth/core/AuthFlow";
|
|
165
|
+
import type { UserId } from "@effect-auth/core/Identifiers";
|
|
53
166
|
|
|
54
167
|
export const completePassword = (userId: UserId) =>
|
|
55
168
|
Effect.gen(function* () {
|
|
56
|
-
const authFlow = yield* AuthFlow
|
|
169
|
+
const authFlow = yield* AuthFlow;
|
|
57
170
|
|
|
58
171
|
return yield* authFlow.completePrimaryFactor({
|
|
59
172
|
userId,
|
|
60
173
|
method: "password",
|
|
61
174
|
amr: ["pwd"],
|
|
62
|
-
aal: "aal1"
|
|
63
|
-
})
|
|
64
|
-
})
|
|
175
|
+
aal: "aal1",
|
|
176
|
+
});
|
|
177
|
+
});
|
|
65
178
|
```
|
|
66
179
|
|
|
67
180
|
Service contracts expose small `make` factories for typed layer wiring:
|
|
68
181
|
|
|
69
182
|
```ts
|
|
70
|
-
import { Effect, Layer } from "effect"
|
|
71
|
-
import { AuthFlow,
|
|
183
|
+
import { Effect, Layer } from "effect";
|
|
184
|
+
import { AuthFlow, AuthResult } from "@effect-auth/core/AuthFlow";
|
|
185
|
+
import type { IssuedSession } from "@effect-auth/core/Sessions";
|
|
186
|
+
|
|
187
|
+
declare const issuedSession: IssuedSession;
|
|
72
188
|
|
|
73
189
|
const AuthFlowLive = Layer.succeed(
|
|
74
190
|
AuthFlow,
|
|
75
191
|
AuthFlow.make({
|
|
76
|
-
startPrimaryFactor: () =>
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
192
|
+
startPrimaryFactor: () =>
|
|
193
|
+
Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
|
|
194
|
+
completePrimaryFactor: () =>
|
|
195
|
+
Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
|
|
196
|
+
completeMfa: () =>
|
|
197
|
+
Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
|
|
198
|
+
complete: () =>
|
|
199
|
+
Effect.succeed(AuthResult.Authenticated({ session: issuedSession })),
|
|
80
200
|
})
|
|
81
|
-
)
|
|
201
|
+
);
|
|
82
202
|
```
|
|
83
203
|
|
|
84
204
|
The same pattern is available on core services such as `AuditLog.make`, `Sessions.make`, `SessionCookie.make`, `Challenge.make`, `Crypto.make`, `Privacy.make`, and `WaitUntil.make`.
|
|
85
205
|
|
|
206
|
+
The first default live implementations are available for the phase-3 web session path:
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { Layer, Redacted } from "effect";
|
|
210
|
+
import { AuthFlowLive } from "@effect-auth/core/AuthFlow";
|
|
211
|
+
import { ChallengeLive } from "@effect-auth/core/Challenge";
|
|
212
|
+
import { WebCryptoLive } from "@effect-auth/core/Crypto";
|
|
213
|
+
import {
|
|
214
|
+
PasswordLoginLive,
|
|
215
|
+
PasswordRegistrationLive,
|
|
216
|
+
Pbkdf2PasswordHasherLive,
|
|
217
|
+
} from "@effect-auth/core/Password";
|
|
218
|
+
import { SessionCookieLive, SessionsLive } from "@effect-auth/core/Sessions";
|
|
219
|
+
import { KyselyAuthStorageLive } from "@effect-auth/core/KyselyStorage";
|
|
220
|
+
|
|
221
|
+
const AuthLive = Layer.mergeAll(
|
|
222
|
+
WebCryptoLive(),
|
|
223
|
+
KyselyAuthStorageLive(db),
|
|
224
|
+
Pbkdf2PasswordHasherLive(),
|
|
225
|
+
SessionsLive({ secret: Redacted.make(env.AUTH_SESSION_SECRET) }),
|
|
226
|
+
SessionCookieLive(),
|
|
227
|
+
ChallengeLive({ secret: Redacted.make(env.AUTH_CHALLENGE_SECRET) }),
|
|
228
|
+
AuthFlowLive,
|
|
229
|
+
PasswordLoginLive,
|
|
230
|
+
PasswordRegistrationLive
|
|
231
|
+
);
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`SessionsLive` stores only `sessionId` plus HMAC secret hash in the database. The browser cookie contains the opaque bearer token in `sessionId.secret` format.
|
|
235
|
+
|
|
236
|
+
## Password
|
|
237
|
+
|
|
238
|
+
`PasswordLogin` verifies a password factor and hands the result to `AuthFlow`. It does not create sessions directly.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
import { Effect, Redacted } from "effect";
|
|
242
|
+
import { Email } from "@effect-auth/core/Identifiers";
|
|
243
|
+
import { PasswordLogin } from "@effect-auth/core/Password";
|
|
244
|
+
|
|
245
|
+
export const signIn = Effect.gen(function* () {
|
|
246
|
+
const password = yield* PasswordLogin;
|
|
247
|
+
|
|
248
|
+
return yield* password.signIn({
|
|
249
|
+
email: Email("user@example.com"),
|
|
250
|
+
password: Redacted.make("correct horse battery staple"),
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
`PasswordLoginLive` requires `UserStore`, `CredentialStore`, `PasswordHasher`, and `AuthFlow`. Invalid credentials return `AuthResult.InvalidCredentials()`, while storage/hash/session failures stay as typed errors.
|
|
256
|
+
|
|
257
|
+
`PasswordRegistration` creates a user and password credential, then hands the primary password factor to `AuthFlow`.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
import { Effect, Redacted } from "effect";
|
|
261
|
+
import { Email } from "@effect-auth/core/Identifiers";
|
|
262
|
+
import { PasswordRegistration } from "@effect-auth/core/Password";
|
|
263
|
+
|
|
264
|
+
export const signUp = Effect.gen(function* () {
|
|
265
|
+
const registration = yield* PasswordRegistration;
|
|
266
|
+
|
|
267
|
+
return yield* registration.signUp({
|
|
268
|
+
email: Email("user@example.com"),
|
|
269
|
+
password: Redacted.make("correct horse battery staple"),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Duplicate emails fail with `EmailAlreadyRegisteredError`; callers decide whether to expose that as a conflict, a neutral response, or another boundary-specific behavior.
|
|
275
|
+
|
|
276
|
+
`Pbkdf2PasswordHasherLive` is the first reference hasher. It uses Web Crypto PBKDF2-SHA256 and stores hashes as `pbkdf2-sha256$iterations$salt$hash`.
|
|
277
|
+
|
|
278
|
+
```ts
|
|
279
|
+
import { Layer } from "effect";
|
|
280
|
+
import { WebCryptoLive } from "@effect-auth/core/Crypto";
|
|
281
|
+
import { Pbkdf2PasswordHasherLive } from "@effect-auth/core/Password";
|
|
282
|
+
|
|
283
|
+
const PasswordHashingLive = Layer.mergeAll(
|
|
284
|
+
WebCryptoLive(),
|
|
285
|
+
Pbkdf2PasswordHasherLive()
|
|
286
|
+
);
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
## HTTP API
|
|
290
|
+
|
|
291
|
+
`@effect-auth/core/HttpApi` contains the first Effect v4 HttpApi contract for password and session auth. It exports `AuthApi`, module-owned `PasswordApi` and `SessionApi` groups, and handler layers for the core services.
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
import { Layer } from "effect";
|
|
295
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
296
|
+
import { AuthHttpApiLive } from "@effect-auth/core/HttpApi";
|
|
297
|
+
import { PrivacyLive } from "./auth/privacy";
|
|
298
|
+
import { RateLimiterLive } from "./auth/rate-limiter";
|
|
299
|
+
|
|
300
|
+
export const AuthRoutesLive = HttpRouter.serve(
|
|
301
|
+
AuthHttpApiLive.pipe(
|
|
302
|
+
Layer.provide(AuthServicesLive),
|
|
303
|
+
Layer.provide(RateLimiterLive),
|
|
304
|
+
Layer.provide(PrivacyLive)
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The contract currently includes `POST /auth/password/sign-in`, `POST /auth/password/sign-up`, `GET /auth/session`, `POST /auth/session/refresh`, `POST /auth/logout`, `POST /auth/email-verification/start`, and `POST /auth/email-verification/verify`.
|
|
310
|
+
|
|
311
|
+
`Authenticated` results set the configured session cookie and never include the opaque session token in JSON bodies. Invalid credentials and disabled accounts map to the same `401 invalid_credentials` response by default. Duplicate password sign-up emails fail as `EmailAlreadyRegisteredError` in the domain layer and map to a `409 email_already_registered` HTTP API error.
|
|
312
|
+
|
|
313
|
+
`SessionApi` reads the configured session cookie and validates it with `Sessions`, returning a safe current-session JSON body or `401 unauthenticated`. Refresh extends the current session when it is eligible, commits the refreshed session cookie, and returns the safe current-session JSON body. Safe authenticated bodies include session claims when present, but never include the opaque session token. Logout revokes the current session when present and clears the session cookie with a `204` response.
|
|
314
|
+
|
|
315
|
+
`AuthHttpApiLive` includes `AuthOriginCheckMiddlewareLive()` for cookie-based unsafe methods and built-in IP/email rate limits for auth abuse control. Origin checks allow same-origin requests, reject cross-origin browser requests with `403 request_rejected`, and allow missing `Origin`/`Referer` headers by default for non-browser clients. Browser-only apps can compose their own `HttpApiBuilder.layer(AuthApi)` and provide `AuthOriginCheckMiddlewareLive({ allowMissingOrigin: false })`.
|
|
316
|
+
|
|
317
|
+
`AuthCsrfMiddlewareLive()` is available for apps that want double-submit CSRF checks on unsafe cookie endpoints. It compares the `__Host-csrf` cookie to the `x-csrf-token` header by default and rejects mismatches with `403 request_rejected`. App-owned HTTP handlers should map core `AuthzError` policy failures to safe `403 policy_denied` responses without exposing policy reasons or internal details.
|
|
318
|
+
|
|
319
|
+
Apps using `AuthCsrfMiddleware` must issue the CSRF token themselves, usually from an app-owned bootstrap endpoint or page render. The token should be high-entropy, set as the `__Host-csrf` cookie, and echoed by browser code in the `x-csrf-token` header on unsafe cookie requests:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const csrfToken = crypto.randomUUID();
|
|
323
|
+
|
|
324
|
+
return new Response(JSON.stringify({ csrfToken }), {
|
|
325
|
+
headers: {
|
|
326
|
+
"content-type": "application/json",
|
|
327
|
+
"set-cookie": `__Host-csrf=${encodeURIComponent(csrfToken)}; Path=/; Secure; HttpOnly; SameSite=Lax`,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
await fetch("/auth/session/refresh", {
|
|
332
|
+
method: "POST",
|
|
333
|
+
credentials: "include",
|
|
334
|
+
headers: { "x-csrf-token": csrfToken },
|
|
335
|
+
});
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
`AuthRequestMetadataMiddlewareLive()` can provide method, URL, origin, user-agent, and trusted proxy IP metadata to app-owned handlers. Built-in auth rate limiting is configured in one place with `AuthHttpApiConfigLive(...)`. It applies IP limits in HTTP middleware, then payload-level email limits after schema decoding:
|
|
339
|
+
|
|
340
|
+
```ts
|
|
341
|
+
import { Duration, Layer } from "effect";
|
|
342
|
+
import {
|
|
343
|
+
AuthHttpApiConfigLive,
|
|
344
|
+
AuthHttpApiLive,
|
|
345
|
+
} from "@effect-auth/core/HttpApi";
|
|
346
|
+
import { PrivacyLive } from "./auth/privacy";
|
|
347
|
+
import { RateLimiterLive } from "./auth/rate-limiter";
|
|
348
|
+
|
|
349
|
+
export const AuthHttpApiLayer = AuthHttpApiLive.pipe(
|
|
350
|
+
Layer.provide(
|
|
351
|
+
AuthHttpApiConfigLive({
|
|
352
|
+
rateLimit: {
|
|
353
|
+
trustProxyHeaders: true,
|
|
354
|
+
passwordSignIn: {
|
|
355
|
+
byIp: { limit: 20, window: Duration.minutes(10) },
|
|
356
|
+
byEmail: { limit: 5, window: Duration.minutes(10) },
|
|
357
|
+
},
|
|
358
|
+
passwordSignUp: {
|
|
359
|
+
byIp: false,
|
|
360
|
+
byEmail: { limit: 3, window: Duration.hours(1) },
|
|
361
|
+
},
|
|
362
|
+
emailVerificationStart: false,
|
|
363
|
+
},
|
|
364
|
+
})
|
|
365
|
+
),
|
|
366
|
+
Layer.provide(AuthServicesLive),
|
|
367
|
+
Layer.provide(RateLimiterLive),
|
|
368
|
+
Layer.provide(PrivacyLive)
|
|
369
|
+
);
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Using `AuthHttpApiLive` without `AuthHttpApiConfigLive(...)` uses conservative defaults for `passwordSignIn`, `passwordSignUp`, and `emailVerificationStart`. In `rateLimit` config, `true` means default rules, `false` disables that endpoint or rule, omitted values keep defaults, and an object overrides `limit`, `window`, or `tokens`. Use `AuthHttpApiBareLive` to opt out explicitly. `AuthHttpApiLive` never installs a process-local store by itself; production apps still provide a durable `RateLimiter` and `PrivacyLive`.
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { AuthHttpApiBareLive } from "@effect-auth/core/HttpApi";
|
|
376
|
+
|
|
377
|
+
export const AuthHttpApiLayer = AuthHttpApiBareLive.pipe(
|
|
378
|
+
Layer.provide(AuthServicesLive)
|
|
379
|
+
);
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`EmailVerificationApi` exposes `start` and `verify` endpoints. `start` calls `EmailVerificationFlow` to issue and send a verification challenge through the app-owned `EmailDelivery` adapter. `verify` calls `EmailVerification` and returns `204` on success. When the request includes a valid current session cookie for the same user, `verify` also marks that session's claims as email verified and removes the `email_verification` requirement. Error responses are generic and do not include emails, user ids, challenge secrets, or provider details.
|
|
383
|
+
|
|
384
|
+
Default password HTTP handlers start `EmailVerificationFlow` automatically when password sign-in or sign-up returns `RequiresEmailVerification`. The HTTP response remains `requires_email_verification` and does not include the user id, email, challenge secret, or delivery provider details.
|
|
385
|
+
|
|
386
|
+
You can own the final HTTP contract while reusing core endpoints. For example, replace only password sign-up with an app-specific phone payload and keep the default sign-in endpoint:
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { Effect, Schema } from "effect";
|
|
390
|
+
import {
|
|
391
|
+
HttpApi,
|
|
392
|
+
HttpApiBuilder,
|
|
393
|
+
HttpApiEndpoint,
|
|
394
|
+
HttpApiGroup,
|
|
395
|
+
} from "effect/unstable/httpapi";
|
|
396
|
+
import {
|
|
397
|
+
AuthBadRequestError,
|
|
398
|
+
AuthEmailAlreadyRegisteredError,
|
|
399
|
+
AuthHttp,
|
|
400
|
+
AuthInternalError,
|
|
401
|
+
AuthOriginCheckMiddleware,
|
|
402
|
+
AuthPolicyDeniedError,
|
|
403
|
+
AuthRateLimitedError,
|
|
404
|
+
AuthSchemaErrorMiddleware,
|
|
405
|
+
makePasswordSignInHandler,
|
|
406
|
+
passwordSignInEndpoint,
|
|
407
|
+
PasswordAuthSuccess,
|
|
408
|
+
} from "@effect-auth/core/HttpApi";
|
|
409
|
+
import { EmailSchema } from "@effect-auth/core/Identifiers";
|
|
410
|
+
import { PasswordLogin } from "@effect-auth/core/Password";
|
|
411
|
+
|
|
412
|
+
const PhoneNumber = Schema.String.check(Schema.isPattern(/^\+[1-9]\d{7,14}$/));
|
|
413
|
+
|
|
414
|
+
const PhoneSignUpBody = Schema.Struct({
|
|
415
|
+
email: EmailSchema,
|
|
416
|
+
password: Schema.String,
|
|
417
|
+
phone: PhoneNumber,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
const phoneSignUpEndpoint = HttpApiEndpoint.post("signUp", "/sign-up", {
|
|
421
|
+
payload: PhoneSignUpBody,
|
|
422
|
+
success: PasswordAuthSuccess,
|
|
423
|
+
error: [
|
|
424
|
+
AuthBadRequestError,
|
|
425
|
+
AuthEmailAlreadyRegisteredError,
|
|
426
|
+
AuthPolicyDeniedError,
|
|
427
|
+
AuthRateLimitedError,
|
|
428
|
+
AuthInternalError,
|
|
429
|
+
],
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
class PasswordApi extends HttpApiGroup.make("password")
|
|
433
|
+
.add(passwordSignInEndpoint, phoneSignUpEndpoint)
|
|
434
|
+
.prefix("/auth/password")
|
|
435
|
+
.middleware(AuthSchemaErrorMiddleware)
|
|
436
|
+
.middleware(AuthOriginCheckMiddleware) {}
|
|
437
|
+
|
|
438
|
+
class AppAuthApi extends HttpApi.make("AppAuthApi").add(PasswordApi) {}
|
|
439
|
+
|
|
440
|
+
export const AppPasswordApiLive = HttpApiBuilder.group(
|
|
441
|
+
AppAuthApi,
|
|
442
|
+
"password",
|
|
443
|
+
Effect.fn("app.auth.password.group")(function* (handlers) {
|
|
444
|
+
const password = yield* PasswordLogin;
|
|
445
|
+
const authHttp = yield* AuthHttp;
|
|
446
|
+
|
|
447
|
+
return handlers
|
|
448
|
+
.handle("signIn", makePasswordSignInHandler({ password, authHttp }))
|
|
449
|
+
.handle(
|
|
450
|
+
"signUp",
|
|
451
|
+
Effect.fn("app.auth.password.sign_up")(function* ({ payload }) {
|
|
452
|
+
return yield* signUpWithPhone(payload, authHttp);
|
|
453
|
+
})
|
|
454
|
+
);
|
|
455
|
+
})
|
|
456
|
+
);
|
|
457
|
+
```
|
|
458
|
+
|
|
86
459
|
## Sessions
|
|
87
460
|
|
|
88
461
|
`Sessions` is the server-side session authority. Transports such as cookies are separate services so browser, API, and custom transports can share the same session strategy.
|
|
89
462
|
|
|
90
463
|
```ts
|
|
91
|
-
import { Effect } from "effect"
|
|
92
|
-
import type { UserId } from "@effect-auth/core/Identifiers"
|
|
93
|
-
import { SessionCookie, Sessions } from "@effect-auth/core/Sessions"
|
|
464
|
+
import { Effect } from "effect";
|
|
465
|
+
import type { UserId } from "@effect-auth/core/Identifiers";
|
|
466
|
+
import { SessionCookie, Sessions } from "@effect-auth/core/Sessions";
|
|
94
467
|
|
|
95
468
|
export const createBrowserSession = (userId: UserId) =>
|
|
96
469
|
Effect.gen(function* () {
|
|
97
|
-
const sessions = yield* Sessions
|
|
98
|
-
const sessionCookie = yield* SessionCookie
|
|
470
|
+
const sessions = yield* Sessions;
|
|
471
|
+
const sessionCookie = yield* SessionCookie;
|
|
99
472
|
|
|
100
473
|
const issued = yield* sessions.create({
|
|
101
474
|
userId,
|
|
102
475
|
method: "password",
|
|
103
476
|
amr: ["pwd"],
|
|
104
|
-
aal: "aal1"
|
|
105
|
-
|
|
477
|
+
aal: "aal1",
|
|
478
|
+
claims: {
|
|
479
|
+
emailVerified: false,
|
|
480
|
+
requirements: ["email_verification"],
|
|
481
|
+
},
|
|
482
|
+
});
|
|
106
483
|
|
|
107
|
-
const cookie = yield* sessionCookie.commit(issued)
|
|
484
|
+
const cookie = yield* sessionCookie.commit(issued);
|
|
108
485
|
|
|
109
|
-
return { issued, cookie }
|
|
110
|
-
})
|
|
486
|
+
return { issued, cookie };
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
`SessionClaims` are a safe, transport-visible session summary. They currently include `emailVerified` and `requirements`, and are returned by password auth success, current-session, and refresh HTTP responses when present. Use `Sessions.updateClaims` to update an active session without rotating the opaque session token. Kysely storage persists claims in the existing `auth_session.metadata` column, so no additional storage migration is required.
|
|
491
|
+
|
|
492
|
+
Password auth passes the user's email verification state into `AuthFlow`, where `EmailVerificationSessionPolicy` decides what happens before a session is created. The default mode is `allow-unverified` for compatibility. Use `limited-session` to issue sessions with an `email_verification` requirement, or `require-before-session` to return `RequiresEmailVerification` without creating a session.
|
|
493
|
+
|
|
494
|
+
```ts
|
|
495
|
+
import { AuthFlowLiveWithEmailVerificationPolicy } from "@effect-auth/core/AuthFlow";
|
|
496
|
+
|
|
497
|
+
export const AllowUnverifiedAuthFlowLive =
|
|
498
|
+
AuthFlowLiveWithEmailVerificationPolicy({
|
|
499
|
+
mode: "allow-unverified",
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
export const LimitedSessionAuthFlowLive =
|
|
503
|
+
AuthFlowLiveWithEmailVerificationPolicy({
|
|
504
|
+
mode: "limited-session",
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
export const RequireBeforeSessionAuthFlowLive =
|
|
508
|
+
AuthFlowLiveWithEmailVerificationPolicy({
|
|
509
|
+
mode: "require-before-session",
|
|
510
|
+
});
|
|
511
|
+
```
|
|
512
|
+
|
|
513
|
+
For `limited-session`, protect sensitive app routes by requiring the current session to be email verified. This policy expects your route middleware to provide `CurrentSession`.
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
import { Effect } from "effect";
|
|
517
|
+
import * as Policy from "@effect-auth/core/Policy";
|
|
518
|
+
|
|
519
|
+
export const updateBillingSettings = Effect.gen(function* () {
|
|
520
|
+
return yield* saveBillingSettings();
|
|
521
|
+
}).pipe(Policy.require(Policy.requireEmailVerified()));
|
|
111
522
|
```
|
|
112
523
|
|
|
113
524
|
## Challenge
|
|
@@ -115,29 +526,97 @@ export const createBrowserSession = (userId: UserId) =>
|
|
|
115
526
|
`Challenge` is the shared primitive for short-lived proofs: email OTP, magic link, passkey challenges, OAuth state, reset password, and MFA.
|
|
116
527
|
|
|
117
528
|
```ts
|
|
118
|
-
import { Duration, Effect, Redacted } from "effect"
|
|
119
|
-
import { Challenge } from "@effect-auth/core/Challenge"
|
|
529
|
+
import { Duration, Effect, Redacted } from "effect";
|
|
530
|
+
import { Challenge } from "@effect-auth/core/Challenge";
|
|
120
531
|
|
|
121
532
|
export const issueEmailOtp = (email: string, code: string) =>
|
|
122
533
|
Effect.gen(function* () {
|
|
123
|
-
const challenge = yield* Challenge
|
|
534
|
+
const challenge = yield* Challenge;
|
|
124
535
|
|
|
125
536
|
return yield* challenge.issue({
|
|
126
537
|
type: "email-otp",
|
|
127
538
|
subject: email,
|
|
128
539
|
ttl: Duration.minutes(10),
|
|
129
|
-
secret: Redacted.make(code)
|
|
130
|
-
})
|
|
131
|
-
})
|
|
540
|
+
secret: Redacted.make(code),
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
## Email Verification
|
|
546
|
+
|
|
547
|
+
`EmailVerification` composes `Challenge` with `UserStore.markEmailVerified`. It issues an `email-verification` challenge for an unverified user email, then verifies the challenge and marks the user email as verified. `EmailVerificationFlow` adds the app-owned `EmailDelivery` port so issuing and sending happen together without baking a mail provider into core.
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
import { Effect, Redacted } from "effect";
|
|
551
|
+
import {
|
|
552
|
+
EmailDelivery,
|
|
553
|
+
EmailVerification,
|
|
554
|
+
EmailVerificationFlow,
|
|
555
|
+
} from "@effect-auth/core/EmailVerification";
|
|
556
|
+
import type { ChallengeId, Email, UserId } from "@effect-auth/core/Identifiers";
|
|
557
|
+
|
|
558
|
+
const emailDelivery = EmailDelivery.make({
|
|
559
|
+
sendEmailVerification: ({ email, secret, challengeId }) =>
|
|
560
|
+
sendMail({
|
|
561
|
+
to: email,
|
|
562
|
+
subject: "Verify your email",
|
|
563
|
+
body:
|
|
564
|
+
secret === undefined
|
|
565
|
+
? `Open /verify-email?challenge=${challengeId}`
|
|
566
|
+
: `Your verification code is ${Redacted.value(secret)}`,
|
|
567
|
+
}),
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
export const startEmailVerification = (
|
|
571
|
+
userId: UserId,
|
|
572
|
+
email: Email,
|
|
573
|
+
code: string
|
|
574
|
+
) =>
|
|
575
|
+
Effect.gen(function* () {
|
|
576
|
+
const flow = yield* EmailVerificationFlow;
|
|
577
|
+
|
|
578
|
+
return yield* flow.start({
|
|
579
|
+
userId,
|
|
580
|
+
email,
|
|
581
|
+
secret: Redacted.make(code),
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
export const verifyEmail = (challengeId: ChallengeId, code: string) =>
|
|
586
|
+
Effect.gen(function* () {
|
|
587
|
+
const emailVerification = yield* EmailVerification;
|
|
588
|
+
|
|
589
|
+
return yield* emailVerification.verify({
|
|
590
|
+
challengeId,
|
|
591
|
+
secret: Redacted.make(code),
|
|
592
|
+
});
|
|
593
|
+
});
|
|
132
594
|
```
|
|
133
595
|
|
|
134
596
|
## Storage
|
|
135
597
|
|
|
136
|
-
Storage is exposed as ORM-free ports. Core flows depend on store services, while Kysely,
|
|
598
|
+
Storage is exposed as ORM-free ports. Core flows depend on store services, while Kysely, effect-qb, future Drizzle, or app-owned adapters provide the implementations.
|
|
137
599
|
|
|
138
600
|
```ts
|
|
139
|
-
import { Effect, Option } from "effect"
|
|
140
|
-
import {
|
|
601
|
+
import { Effect, Option } from "effect";
|
|
602
|
+
import {
|
|
603
|
+
CredentialStore,
|
|
604
|
+
SessionStore,
|
|
605
|
+
UserStore,
|
|
606
|
+
VerificationStore,
|
|
607
|
+
} from "@effect-auth/core/Storage";
|
|
608
|
+
|
|
609
|
+
const UserStoreLive = UserStore.make({
|
|
610
|
+
findById: () => Effect.succeed(Option.none()),
|
|
611
|
+
findByEmail: () => Effect.succeed(Option.none()),
|
|
612
|
+
insert: () => Effect.void,
|
|
613
|
+
markEmailVerified: () => Effect.succeed(Option.none()),
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const CredentialStoreLive = CredentialStore.make({
|
|
617
|
+
findPasswordByUserId: () => Effect.succeed(Option.none()),
|
|
618
|
+
insertPassword: () => Effect.void,
|
|
619
|
+
});
|
|
141
620
|
|
|
142
621
|
const SessionStoreLive = SessionStore.make({
|
|
143
622
|
findById: () => Effect.succeed(Option.none()),
|
|
@@ -145,43 +624,47 @@ const SessionStoreLive = SessionStore.make({
|
|
|
145
624
|
refresh: () => Effect.succeed(Option.none()),
|
|
146
625
|
rotate: () => Effect.succeed(Option.none()),
|
|
147
626
|
revoke: () => Effect.void,
|
|
148
|
-
revokeAllForUser: () => Effect.void
|
|
149
|
-
})
|
|
627
|
+
revokeAllForUser: () => Effect.void,
|
|
628
|
+
});
|
|
150
629
|
|
|
151
630
|
const VerificationStoreLive = VerificationStore.make({
|
|
152
631
|
findById: () => Effect.succeed(Option.none()),
|
|
153
632
|
insert: () => Effect.void,
|
|
154
633
|
consume: () => Effect.succeed(Option.none()),
|
|
155
|
-
deleteExpired: () => Effect.succeed(0)
|
|
156
|
-
})
|
|
634
|
+
deleteExpired: () => Effect.succeed(0),
|
|
635
|
+
});
|
|
157
636
|
```
|
|
158
637
|
|
|
159
638
|
The first storage adapter is Kysely-based. Applications own their Kysely instance and database dialect, while `@effect-auth/core/KyselyStorage` implements the auth store contracts:
|
|
160
639
|
|
|
161
640
|
```ts
|
|
162
|
-
import { makeKyselyStorage } from "@effect-auth/core/KyselyStorage"
|
|
641
|
+
import { makeKyselyStorage } from "@effect-auth/core/KyselyStorage";
|
|
163
642
|
|
|
164
|
-
const { sessionStore, verificationStore } =
|
|
643
|
+
const { userStore, credentialStore, sessionStore, verificationStore } =
|
|
644
|
+
makeKyselyStorage(db);
|
|
165
645
|
```
|
|
166
646
|
|
|
167
647
|
For Cloudflare D1, use the first-party Kysely dialect instead of an external D1 dialect package:
|
|
168
648
|
|
|
169
649
|
```ts
|
|
170
|
-
import { makeD1Kysely } from "@effect-auth/core/D1Kysely"
|
|
171
|
-
import {
|
|
650
|
+
import { makeD1Kysely } from "@effect-auth/core/D1Kysely";
|
|
651
|
+
import {
|
|
652
|
+
KyselyAuthStorageLive,
|
|
653
|
+
type KyselyAuthDatabase,
|
|
654
|
+
} from "@effect-auth/core/KyselyStorage";
|
|
172
655
|
|
|
173
656
|
interface DB extends KyselyAuthDatabase {
|
|
174
657
|
// app tables live here too
|
|
175
658
|
}
|
|
176
659
|
|
|
177
|
-
const db = makeD1Kysely<DB>(env.DB)
|
|
178
|
-
const StorageLive = KyselyAuthStorageLive(db)
|
|
660
|
+
const db = makeD1Kysely<DB>(env.DB);
|
|
661
|
+
const StorageLive = KyselyAuthStorageLive(db);
|
|
179
662
|
```
|
|
180
663
|
|
|
181
|
-
The default schema expects `
|
|
664
|
+
The default schema expects `auth_user`, `auth_credential`, `auth_session`, and `auth_verification` tables in the Kysely database type:
|
|
182
665
|
|
|
183
666
|
```ts
|
|
184
|
-
import type { KyselyAuthDatabase } from "@effect-auth/core/KyselyStorage"
|
|
667
|
+
import type { KyselyAuthDatabase } from "@effect-auth/core/KyselyStorage";
|
|
185
668
|
|
|
186
669
|
interface DB extends KyselyAuthDatabase {
|
|
187
670
|
// app tables live here too
|
|
@@ -191,5 +674,83 @@ interface DB extends KyselyAuthDatabase {
|
|
|
191
674
|
The default schema is also exported as SQL and as package assets:
|
|
192
675
|
|
|
193
676
|
```ts
|
|
194
|
-
import { authStorageMigrations } from "@effect-auth/core/StorageMigrations"
|
|
677
|
+
import { authStorageMigrations } from "@effect-auth/core/StorageMigrations";
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Effect-qb SQLite/D1 storage is also available. The default path uses the same auth schema and requires no table mapping:
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
import { EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";
|
|
684
|
+
|
|
685
|
+
const StorageLive = EffectQbSqliteAuthStorageLive(executor);
|
|
686
|
+
```
|
|
687
|
+
|
|
688
|
+
For Cloudflare D1, create an effect-qb SQLite executor from the D1 binding:
|
|
689
|
+
|
|
690
|
+
```ts
|
|
691
|
+
import {
|
|
692
|
+
EffectQbSqliteAuthStorageLive,
|
|
693
|
+
makeD1EffectQbSqliteExecutor,
|
|
694
|
+
} from "@effect-auth/core/EffectQbSqliteStorage";
|
|
695
|
+
|
|
696
|
+
const executor = makeD1EffectQbSqliteExecutor(env.DB);
|
|
697
|
+
const StorageLive = EffectQbSqliteAuthStorageLive(executor);
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
Applications with their own effect-qb tables can map semantic auth fields to app-owned columns with `defineEffectQbAuthTables`:
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
import { Column, Query, Table } from "effect-qb/sqlite";
|
|
704
|
+
import {
|
|
705
|
+
defineEffectQbAuthTables,
|
|
706
|
+
EffectQbSqliteAuthStorageLive,
|
|
707
|
+
} from "@effect-auth/core/EffectQbSqliteStorage";
|
|
708
|
+
|
|
709
|
+
const users = Table.make("users", {
|
|
710
|
+
user_id: Column.text().pipe(Column.primaryKey),
|
|
711
|
+
email_address: Column.text(),
|
|
712
|
+
verified_at: Column.int().pipe(Column.nullable),
|
|
713
|
+
inserted_at: Column.int(),
|
|
714
|
+
updated_at: Column.int(),
|
|
715
|
+
disabled_at: Column.int().pipe(Column.nullable),
|
|
716
|
+
metadata_json: Column.text().pipe(Column.nullable),
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
const authTables = defineEffectQbAuthTables({
|
|
720
|
+
user: {
|
|
721
|
+
table: users,
|
|
722
|
+
select: {
|
|
723
|
+
id: users.user_id,
|
|
724
|
+
email: users.email_address,
|
|
725
|
+
emailVerified: users.verified_at,
|
|
726
|
+
createdAt: users.inserted_at,
|
|
727
|
+
updatedAt: users.updated_at,
|
|
728
|
+
disabledAt: users.disabled_at,
|
|
729
|
+
metadata: users.metadata_json,
|
|
730
|
+
},
|
|
731
|
+
insert: (row) =>
|
|
732
|
+
({
|
|
733
|
+
user_id: row.id,
|
|
734
|
+
email_address: row.email,
|
|
735
|
+
verified_at: row.emailVerified ? Number(row.updatedAt) : null,
|
|
736
|
+
inserted_at: Number(row.createdAt),
|
|
737
|
+
updated_at: Number(row.updatedAt),
|
|
738
|
+
disabled_at:
|
|
739
|
+
row.disabledAt === undefined ? null : Number(row.disabledAt),
|
|
740
|
+
metadata_json:
|
|
741
|
+
row.metadata === undefined ? null : JSON.stringify(row.metadata),
|
|
742
|
+
}) satisfies Query.MutationInputOf<Table.InsertOf<typeof users>>,
|
|
743
|
+
markEmailVerified: (input) =>
|
|
744
|
+
({
|
|
745
|
+
verified_at: Number(input.updatedAt),
|
|
746
|
+
updated_at: Number(input.updatedAt),
|
|
747
|
+
}) satisfies Query.MutationInputOf<Table.UpdateOf<typeof users>>,
|
|
748
|
+
},
|
|
749
|
+
|
|
750
|
+
// credential/session/verification mappings follow the same pattern.
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
const StorageLive = EffectQbSqliteAuthStorageLive(executor, {
|
|
754
|
+
tables: authTables,
|
|
755
|
+
});
|
|
195
756
|
```
|