@effect-auth/core 0.1.0-alpha.1 → 0.1.0-alpha.13

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 (255) hide show
  1. package/README.md +790 -73
  2. package/dist/ApiKey.d.ts +207 -0
  3. package/dist/ApiKey.d.ts.map +1 -0
  4. package/dist/ApiKey.js +275 -0
  5. package/dist/ApiKey.js.map +1 -0
  6. package/dist/AuditLog.d.ts +335 -1
  7. package/dist/AuditLog.d.ts.map +1 -1
  8. package/dist/AuditLog.js +318 -1
  9. package/dist/AuditLog.js.map +1 -1
  10. package/dist/AuthConfig.d.ts +42 -0
  11. package/dist/AuthConfig.d.ts.map +1 -0
  12. package/dist/AuthConfig.js +43 -0
  13. package/dist/AuthConfig.js.map +1 -0
  14. package/dist/AuthFlow.d.ts +462 -36
  15. package/dist/AuthFlow.d.ts.map +1 -1
  16. package/dist/AuthFlow.js +657 -5
  17. package/dist/AuthFlow.js.map +1 -1
  18. package/dist/AuthKernel.d.ts +3 -0
  19. package/dist/AuthKernel.d.ts.map +1 -0
  20. package/dist/AuthKernel.js +9 -0
  21. package/dist/AuthKernel.js.map +1 -0
  22. package/dist/Challenge.d.ts +24 -4
  23. package/dist/Challenge.d.ts.map +1 -1
  24. package/dist/Challenge.js +167 -1
  25. package/dist/Challenge.js.map +1 -1
  26. package/dist/Client.d.ts +449 -0
  27. package/dist/Client.d.ts.map +1 -0
  28. package/dist/Client.js +985 -0
  29. package/dist/Client.js.map +1 -0
  30. package/dist/Crypto.d.ts +6 -1
  31. package/dist/Crypto.d.ts.map +1 -1
  32. package/dist/Crypto.js +62 -1
  33. package/dist/Crypto.js.map +1 -1
  34. package/dist/DomainVerification.d.ts +200 -0
  35. package/dist/DomainVerification.d.ts.map +1 -0
  36. package/dist/DomainVerification.js +304 -0
  37. package/dist/DomainVerification.js.map +1 -0
  38. package/dist/EffectQbSqliteStorage.d.ts +985 -0
  39. package/dist/EffectQbSqliteStorage.d.ts.map +1 -0
  40. package/dist/EffectQbSqliteStorage.js +2824 -0
  41. package/dist/EffectQbSqliteStorage.js.map +1 -0
  42. package/dist/EmailOtp.d.ts +120 -0
  43. package/dist/EmailOtp.d.ts.map +1 -0
  44. package/dist/EmailOtp.js +286 -0
  45. package/dist/EmailOtp.js.map +1 -0
  46. package/dist/EmailVerification.d.ts +121 -0
  47. package/dist/EmailVerification.d.ts.map +1 -0
  48. package/dist/EmailVerification.js +200 -0
  49. package/dist/EmailVerification.js.map +1 -0
  50. package/dist/HttpApi/Api.d.ts +2410 -0
  51. package/dist/HttpApi/Api.d.ts.map +1 -0
  52. package/dist/HttpApi/Api.js +3026 -0
  53. package/dist/HttpApi/Api.js.map +1 -0
  54. package/dist/HttpApi/Endpoints.d.ts +1073 -0
  55. package/dist/HttpApi/Endpoints.d.ts.map +1 -0
  56. package/dist/HttpApi/Endpoints.js +428 -0
  57. package/dist/HttpApi/Endpoints.js.map +1 -0
  58. package/dist/HttpApi/Errors.d.ts +114 -0
  59. package/dist/HttpApi/Errors.d.ts.map +1 -0
  60. package/dist/HttpApi/Errors.js +230 -0
  61. package/dist/HttpApi/Errors.js.map +1 -0
  62. package/dist/HttpApi/Schemas.d.ts +1254 -0
  63. package/dist/HttpApi/Schemas.d.ts.map +1 -0
  64. package/dist/HttpApi/Schemas.js +862 -0
  65. package/dist/HttpApi/Schemas.js.map +1 -0
  66. package/dist/HttpApi/Security.d.ts +159 -0
  67. package/dist/HttpApi/Security.d.ts.map +1 -0
  68. package/dist/HttpApi/Security.js +530 -0
  69. package/dist/HttpApi/Security.js.map +1 -0
  70. package/dist/HttpApi/Transport.d.ts +65 -0
  71. package/dist/HttpApi/Transport.d.ts.map +1 -0
  72. package/dist/HttpApi/Transport.js +311 -0
  73. package/dist/HttpApi/Transport.js.map +1 -0
  74. package/dist/HttpApi/index.d.ts +8 -0
  75. package/dist/HttpApi/index.d.ts.map +1 -0
  76. package/dist/HttpApi/index.js +7 -0
  77. package/dist/HttpApi/index.js.map +1 -0
  78. package/dist/HttpApi.d.ts +2 -0
  79. package/dist/HttpApi.d.ts.map +1 -0
  80. package/dist/HttpApi.js +2 -0
  81. package/dist/HttpApi.js.map +1 -0
  82. package/dist/Identifiers.d.ts +3 -0
  83. package/dist/Identifiers.d.ts.map +1 -1
  84. package/dist/Identifiers.js +2 -0
  85. package/dist/Identifiers.js.map +1 -1
  86. package/dist/IncidentAction.d.ts +80 -0
  87. package/dist/IncidentAction.d.ts.map +1 -0
  88. package/dist/IncidentAction.js +158 -0
  89. package/dist/IncidentAction.js.map +1 -0
  90. package/dist/Internal.d.ts +7 -0
  91. package/dist/Internal.d.ts.map +1 -0
  92. package/dist/Internal.js +12 -0
  93. package/dist/Internal.js.map +1 -0
  94. package/dist/Jwt.d.ts +281 -0
  95. package/dist/Jwt.d.ts.map +1 -0
  96. package/dist/Jwt.js +721 -0
  97. package/dist/Jwt.js.map +1 -0
  98. package/dist/LoginApproval.d.ts +314 -0
  99. package/dist/LoginApproval.d.ts.map +1 -0
  100. package/dist/LoginApproval.js +551 -0
  101. package/dist/LoginApproval.js.map +1 -0
  102. package/dist/LoginNotification.d.ts +183 -0
  103. package/dist/LoginNotification.d.ts.map +1 -0
  104. package/dist/LoginNotification.js +329 -0
  105. package/dist/LoginNotification.js.map +1 -0
  106. package/dist/LoginRisk.d.ts +344 -0
  107. package/dist/LoginRisk.d.ts.map +1 -0
  108. package/dist/LoginRisk.js +484 -0
  109. package/dist/LoginRisk.js.map +1 -0
  110. package/dist/MachineAuth.d.ts +73 -0
  111. package/dist/MachineAuth.d.ts.map +1 -0
  112. package/dist/MachineAuth.js +147 -0
  113. package/dist/MachineAuth.js.map +1 -0
  114. package/dist/MagicLink.d.ts +85 -0
  115. package/dist/MagicLink.d.ts.map +1 -0
  116. package/dist/MagicLink.js +206 -0
  117. package/dist/MagicLink.js.map +1 -0
  118. package/dist/Mailer.d.ts +147 -0
  119. package/dist/Mailer.d.ts.map +1 -0
  120. package/dist/Mailer.js +208 -0
  121. package/dist/Mailer.js.map +1 -0
  122. package/dist/OAuth.d.ts +1890 -0
  123. package/dist/OAuth.d.ts.map +1 -0
  124. package/dist/OAuth.js +3063 -0
  125. package/dist/OAuth.js.map +1 -0
  126. package/dist/Passkey.d.ts +334 -0
  127. package/dist/Passkey.d.ts.map +1 -0
  128. package/dist/Passkey.js +422 -0
  129. package/dist/Passkey.js.map +1 -0
  130. package/dist/PasskeySimpleWebAuthn.d.ts +11 -0
  131. package/dist/PasskeySimpleWebAuthn.d.ts.map +1 -0
  132. package/dist/PasskeySimpleWebAuthn.js +181 -0
  133. package/dist/PasskeySimpleWebAuthn.js.map +1 -0
  134. package/dist/Password.d.ts +237 -0
  135. package/dist/Password.d.ts.map +1 -0
  136. package/dist/Password.js +641 -0
  137. package/dist/Password.js.map +1 -0
  138. package/dist/Policy.d.ts +7 -4
  139. package/dist/Policy.d.ts.map +1 -1
  140. package/dist/Policy.js +28 -5
  141. package/dist/Policy.js.map +1 -1
  142. package/dist/Privacy.d.ts +15 -0
  143. package/dist/Privacy.d.ts.map +1 -1
  144. package/dist/Privacy.js +58 -1
  145. package/dist/Privacy.js.map +1 -1
  146. package/dist/RateLimiter.d.ts +104 -0
  147. package/dist/RateLimiter.d.ts.map +1 -0
  148. package/dist/RateLimiter.js +118 -0
  149. package/dist/RateLimiter.js.map +1 -0
  150. package/dist/RecoveryCode.d.ts +169 -0
  151. package/dist/RecoveryCode.d.ts.map +1 -0
  152. package/dist/RecoveryCode.js +299 -0
  153. package/dist/RecoveryCode.js.map +1 -0
  154. package/dist/RefreshToken.d.ts +222 -0
  155. package/dist/RefreshToken.d.ts.map +1 -0
  156. package/dist/RefreshToken.js +342 -0
  157. package/dist/RefreshToken.js.map +1 -0
  158. package/dist/Retention.d.ts +98 -0
  159. package/dist/Retention.d.ts.map +1 -0
  160. package/dist/Retention.js +204 -0
  161. package/dist/Retention.js.map +1 -0
  162. package/dist/SecurityTimeline.d.ts +78 -0
  163. package/dist/SecurityTimeline.d.ts.map +1 -0
  164. package/dist/SecurityTimeline.js +461 -0
  165. package/dist/SecurityTimeline.js.map +1 -0
  166. package/dist/Sessions.d.ts +123 -11
  167. package/dist/Sessions.d.ts.map +1 -1
  168. package/dist/Sessions.js +534 -1
  169. package/dist/Sessions.js.map +1 -1
  170. package/dist/StepUp.d.ts +44 -0
  171. package/dist/StepUp.d.ts.map +1 -0
  172. package/dist/StepUp.js +82 -0
  173. package/dist/StepUp.js.map +1 -0
  174. package/dist/Storage.d.ts +73 -5
  175. package/dist/Storage.d.ts.map +1 -1
  176. package/dist/Storage.js +11 -1
  177. package/dist/Storage.js.map +1 -1
  178. package/dist/StorageMigrations.d.ts +23 -1
  179. package/dist/StorageMigrations.d.ts.map +1 -1
  180. package/dist/StorageMigrations.js +462 -2
  181. package/dist/StorageMigrations.js.map +1 -1
  182. package/dist/Testing.d.ts +9 -1
  183. package/dist/Testing.d.ts.map +1 -1
  184. package/dist/Testing.js +30 -2
  185. package/dist/Testing.js.map +1 -1
  186. package/dist/Totp.d.ts +220 -0
  187. package/dist/Totp.d.ts.map +1 -0
  188. package/dist/Totp.js +477 -0
  189. package/dist/Totp.js.map +1 -0
  190. package/dist/TrustedDevice.d.ts +127 -0
  191. package/dist/TrustedDevice.d.ts.map +1 -0
  192. package/dist/TrustedDevice.js +252 -0
  193. package/dist/TrustedDevice.js.map +1 -0
  194. package/dist/WaitUntil.d.ts.map +1 -1
  195. package/dist/WaitUntil.js +1 -1
  196. package/dist/WaitUntil.js.map +1 -1
  197. package/dist/Webhook.d.ts +380 -0
  198. package/dist/Webhook.d.ts.map +1 -0
  199. package/dist/Webhook.js +592 -0
  200. package/dist/Webhook.js.map +1 -0
  201. package/dist/alchemy/cloudflare/Email.d.ts +25 -0
  202. package/dist/alchemy/cloudflare/Email.d.ts.map +1 -0
  203. package/dist/alchemy/cloudflare/Email.js +117 -0
  204. package/dist/alchemy/cloudflare/Email.js.map +1 -0
  205. package/dist/alchemy/cloudflare/RateLimitDurableObject.d.ts +34 -0
  206. package/dist/alchemy/cloudflare/RateLimitDurableObject.d.ts.map +1 -0
  207. package/dist/alchemy/cloudflare/RateLimitDurableObject.js +30 -0
  208. package/dist/alchemy/cloudflare/RateLimitDurableObject.js.map +1 -0
  209. package/dist/cloudflare/Email.d.ts +25 -0
  210. package/dist/cloudflare/Email.d.ts.map +1 -0
  211. package/dist/cloudflare/Email.js +153 -0
  212. package/dist/cloudflare/Email.js.map +1 -0
  213. package/dist/cloudflare/RateLimitDurableObject.d.ts +50 -0
  214. package/dist/cloudflare/RateLimitDurableObject.d.ts.map +1 -0
  215. package/dist/cloudflare/RateLimitDurableObject.js +51 -0
  216. package/dist/cloudflare/RateLimitDurableObject.js.map +1 -0
  217. package/dist/index.d.ts +25 -0
  218. package/dist/index.d.ts.map +1 -1
  219. package/dist/index.js +25 -0
  220. package/dist/index.js.map +1 -1
  221. package/dist/internal/RateLimitDurableObject.d.ts +32 -0
  222. package/dist/internal/RateLimitDurableObject.d.ts.map +1 -0
  223. package/dist/internal/RateLimitDurableObject.js +80 -0
  224. package/dist/internal/RateLimitDurableObject.js.map +1 -0
  225. package/migrations/0002_auth_password.sql +25 -0
  226. package/migrations/0003_auth_login_approval_review.sql +22 -0
  227. package/migrations/0004_auth_trusted_device.sql +12 -0
  228. package/migrations/0005_auth_passkey.sql +17 -0
  229. package/migrations/0006_auth_totp_factor.sql +17 -0
  230. package/migrations/0007_auth_recovery_code.sql +13 -0
  231. package/migrations/0008_auth_api_key.sql +17 -0
  232. package/migrations/0009_auth_refresh_token.sql +21 -0
  233. package/migrations/0010_auth_jwt_revocation.sql +8 -0
  234. package/migrations/0011_auth_oauth_account.sql +16 -0
  235. package/migrations/0012_auth_oauth_provider_token_vault.sql +20 -0
  236. package/migrations/0013_auth_security_timeline.sql +16 -0
  237. package/migrations/0014_auth_webhook_outbox.sql +16 -0
  238. package/migrations/0015_auth_webhook_replay.sql +7 -0
  239. package/migrations/0016_auth_login_risk_history.sql +23 -0
  240. package/migrations/0017_auth_audit_log.sql +17 -0
  241. package/migrations/0018_auth_domain_verification.sql +18 -0
  242. package/migrations/0019_auth_oauth_authorization_code.sql +17 -0
  243. package/migrations/0020_auth_oauth_provider_mode_token.sql +24 -0
  244. package/migrations/0021_auth_oauth_client_secret.sql +14 -0
  245. package/migrations/0022_auth_oauth_client.sql +15 -0
  246. package/migrations/0023_auth_oauth_consent.sql +15 -0
  247. package/package.json +162 -26
  248. package/dist/D1Kysely.d.ts +0 -40
  249. package/dist/D1Kysely.d.ts.map +0 -1
  250. package/dist/D1Kysely.js +0 -75
  251. package/dist/D1Kysely.js.map +0 -1
  252. package/dist/KyselyStorage.d.ts +0 -41
  253. package/dist/KyselyStorage.d.ts.map +0 -1
  254. package/dist/KyselyStorage.js +0 -176
  255. package/dist/KyselyStorage.js.map +0 -1
package/README.md CHANGED
@@ -2,24 +2,80 @@
2
2
 
3
3
  Composable Effect-first authentication primitives.
4
4
 
5
- The current alpha surface is intentionally small: service contracts and small helpers that can be wired with application-owned layers.
5
+ The current alpha surface is primitive-first: service contracts, small helpers, `make*` factories, and reference `Live` layers that can be wired with application-owned policy and routes.
6
+
7
+ ## Public API Surface
8
+
9
+ The root import is the stable alpha primitive surface:
10
+
11
+ ```ts
12
+ import { PasswordLogin, Sessions, UserId } from "@effect-auth/core";
13
+ ```
14
+
15
+ Root exports are runtime-neutral auth primitives, service contracts, factory functions, reference layers, identifiers, storage ports, and policy helpers. They should not require framework, database, Cloudflare, Alchemy, SimpleWebAuthn, or test-only dependencies.
16
+
17
+ Primitive module subpaths mirror the root surface for focused imports and are also stable alpha API. Examples include `@effect-auth/core/Password`, `@effect-auth/core/Sessions`, `@effect-auth/core/OAuth`, `@effect-auth/core/Jwt`, `@effect-auth/core/ApiKey`, `@effect-auth/core/Storage`, and `@effect-auth/core/StorageMigrations`.
18
+
19
+ Integration subpaths are public but opt-in. They may expose runtime-specific or transport-specific types and can have optional peer dependencies:
20
+
21
+ ```txt
22
+ @effect-auth/core/Client
23
+ @effect-auth/core/HttpApi
24
+ @effect-auth/core/EffectQbSqliteStorage
25
+ @effect-auth/core/RateLimiter
26
+ ```
27
+
28
+ Adapter subpaths are public alpha adapters. Their names may still be normalized before beta:
29
+
30
+ ```txt
31
+ @effect-auth/core/CloudflareEmail
32
+ @effect-auth/core/CloudflareRateLimitDurableObject
33
+ @effect-auth/core/AlchemyCloudflareEmail
34
+ @effect-auth/core/AlchemyCloudflareRateLimitDurableObject
35
+ @effect-auth/core/PasskeySimpleWebAuthn
36
+ ```
37
+
38
+ `@effect-auth/core/Testing` is test/dev-only API. Do not wire it into production applications.
39
+
40
+ SQL migrations are packaged as files for copy/read workflows, but the supported TypeScript API is `@effect-auth/core/StorageMigrations` and `authStorageMigrations`. Raw SQL files are not importable package subpaths.
41
+
42
+ Before beta, breaking changes are still allowed when they simplify this API surface. Prefer adding new app-specific code through subpaths instead of expanding the root barrel.
43
+
44
+ ## HTTP Client
45
+
46
+ `@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.
47
+
48
+ ```ts
49
+ import { createAuthClient } from "@effect-auth/core/Client";
50
+
51
+ const authClient = createAuthClient();
52
+
53
+ const result = await authClient.password.signIn({
54
+ email: "reader@example.com",
55
+ password: "correct horse battery staple",
56
+ });
57
+
58
+ const session = await authClient.session.currentOrUndefined();
59
+ ```
60
+
61
+ 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.
6
62
 
7
63
  ## Policy
8
64
 
9
65
  `Policy` is an `Effect` used for authorization. Success means access is allowed. `AuthzError` means access is denied. Other failures stay operational errors.
10
66
 
11
67
  ```ts
12
- import { Effect } from "effect"
13
- import * as Policy from "@effect-auth/core/Policy"
68
+ import { Effect } from "effect";
69
+ import * as Policy from "@effect-auth/core/Policy";
14
70
 
15
- const canEditProject = Policy.deny("missing-permission")
71
+ const canEditProject = Policy.deny("missing-permission");
16
72
 
17
73
  const updateProject = Effect.succeed({ updated: true }).pipe(
18
74
  Policy.require(canEditProject)
19
- )
75
+ );
20
76
 
21
- const decision = Policy.check(canEditProject)
22
- const detailed = Policy.checkDetailed(canEditProject)
77
+ const decision = Policy.check(canEditProject);
78
+ const detailed = Policy.checkDetailed(canEditProject);
23
79
  ```
24
80
 
25
81
  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,117 +83,730 @@ Use `Policy.check` when callers should branch on allowed/denied and let operatio
27
83
  Policies compose with `all`, `any`, and `not`:
28
84
 
29
85
  ```ts
30
- declare const isProjectOwner: Policy.Policy
31
- declare const hasUpdateGrant: Policy.Policy
32
- declare const isSuspended: Policy.Policy
86
+ declare const isProjectOwner: Policy.Policy;
87
+ declare const hasUpdateGrant: Policy.Policy;
88
+ declare const isSuspended: Policy.Policy;
33
89
 
34
- const canEditProject = Policy.any(
35
- isProjectOwner,
36
- hasUpdateGrant
37
- )
90
+ const canEditProject = Policy.any(isProjectOwner, hasUpdateGrant);
38
91
 
39
92
  const canEditAndNotSuspended = Policy.all(
40
93
  canEditProject,
41
94
  Policy.not(isSuspended, "account-disabled")
42
- )
95
+ );
96
+ ```
97
+
98
+ Step-up gates live in `@effect-auth/core/StepUp` and can be converted to policies when a product action requires stronger or fresher auth:
99
+
100
+ ```ts
101
+ import { Duration } from "effect";
102
+ import * as Policy from "@effect-auth/core/Policy";
103
+ import * as StepUp from "@effect-auth/core/StepUp";
104
+
105
+ const canGenerateApiKey = StepUp.toPolicy({
106
+ aal: "aal2",
107
+ maxAge: Duration.minutes(15),
108
+ });
109
+
110
+ const generateApiKey = createApiKey.pipe(Policy.require(canGenerateApiKey));
111
+ ```
112
+
113
+ ## Rate Limiter
114
+
115
+ `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`.
116
+
117
+ ```ts
118
+ import { Duration, Effect } from "effect";
119
+ import { EmailHash, type IpHash } from "@effect-auth/core/Identifiers";
120
+ import * as RateLimit from "@effect-auth/core/RateLimiter";
121
+
122
+ const PasswordSignInEmailLimit = RateLimit.RateLimitPolicy.fixedWindow({
123
+ id: "auth.password.sign_in.email",
124
+ key: RateLimit.RateLimitKey.emailHash(EmailHash("hmac-email-hash")),
125
+ limit: 5,
126
+ window: Duration.minutes(10),
127
+ });
128
+
129
+ const signIn = Effect.gen(function* () {
130
+ yield* RateLimit.require(PasswordSignInEmailLimit);
131
+
132
+ return yield* Effect.succeed({ ok: true });
133
+ });
134
+ ```
135
+
136
+ `fixedWindowBy` and `tokenBucketBy` accept any safe `RateLimitKey`, including composite keys built from other safe keys:
137
+
138
+ ```ts
139
+ declare const ipHash: IpHash;
140
+ declare const emailHash: EmailHash;
141
+
142
+ const PasswordSignInIpEmailLimit = RateLimit.RateLimitPolicy.fixedWindowBy({
143
+ id: "auth.password.sign_in.ip_email",
144
+ by: RateLimit.RateLimitKey.combine(
145
+ "ip-email",
146
+ RateLimit.RateLimitKey.ipHash(ipHash),
147
+ RateLimit.RateLimitKey.emailHash(emailHash)
148
+ ),
149
+ limit: 5,
150
+ window: Duration.minutes(10),
151
+ });
152
+ ```
153
+
154
+ 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`.
155
+
156
+ ```ts
157
+ import { Redacted } from "effect";
158
+ import { PrivacyLive } from "@effect-auth/core/Privacy";
159
+
160
+ const AuthPrivacyLive = PrivacyLive({
161
+ secret: Redacted.make(env.AUTH_PRIVACY_SECRET),
162
+ });
163
+ ```
164
+
165
+ 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.
166
+
167
+ ```ts
168
+ import { Layer, Redacted } from "effect";
169
+ import { RateLimiter as PersistenceRateLimiter } from "effect/unstable/persistence";
170
+ import { AuthHttpApiLive } from "@effect-auth/core/HttpApi";
171
+ import {
172
+ RateLimitStoreDurableObject,
173
+ type CloudflareRateLimitDurableObjectNamespace,
174
+ } from "@effect-auth/core/CloudflareRateLimitDurableObject";
175
+ import { PrivacyLive } from "@effect-auth/core/Privacy";
176
+ import { RateLimiterLive } from "@effect-auth/core/RateLimiter";
177
+
178
+ export const makeAuthHttpApiLayer = (
179
+ namespace: CloudflareRateLimitDurableObjectNamespace,
180
+ privacySecret: string
181
+ ) => {
182
+ const DurableObjectRateLimiterLive = RateLimiterLive.pipe(
183
+ Layer.provide(PersistenceRateLimiter.layer),
184
+ Layer.provide(RateLimitStoreDurableObject.layer({ namespace }))
185
+ );
186
+
187
+ return AuthHttpApiLive.pipe(
188
+ Layer.provide(AuthServicesLive),
189
+ Layer.provide(DurableObjectRateLimiterLive),
190
+ Layer.provide(PrivacyLive({ secret: Redacted.make(privacySecret) }))
191
+ );
192
+ };
193
+ ```
194
+
195
+ `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`.
196
+
197
+ 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.
198
+
199
+ Use `AuthHttpApiBareLive` only when you explicitly do not want built-in auth rate limits:
200
+
201
+ ```ts
202
+ import { Layer } from "effect";
203
+ import { AuthHttpApiBareLive } from "@effect-auth/core/HttpApi";
204
+
205
+ export const AuthHttpApiLayer = AuthHttpApiBareLive.pipe(
206
+ Layer.provide(AuthServicesLive)
207
+ );
43
208
  ```
44
209
 
45
210
  ## AuthFlow
46
211
 
47
- Login methods should verify a factor, then hand the result to `AuthFlow`. `AuthFlow` decides whether to issue a session, require MFA, require verification, or deny the attempt.
212
+ 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
213
 
49
214
  ```ts
50
- import { Effect } from "effect"
51
- import { AuthFlow } from "@effect-auth/core/AuthFlow"
52
- import type { UserId } from "@effect-auth/core/Identifiers"
215
+ import { Effect } from "effect";
216
+ import { AuthFlow } from "@effect-auth/core/AuthFlow";
217
+ import type { UserId } from "@effect-auth/core/Identifiers";
53
218
 
54
219
  export const completePassword = (userId: UserId) =>
55
220
  Effect.gen(function* () {
56
- const authFlow = yield* AuthFlow
221
+ const authFlow = yield* AuthFlow;
57
222
 
58
223
  return yield* authFlow.completePrimaryFactor({
59
224
  userId,
60
225
  method: "password",
61
226
  amr: ["pwd"],
62
- aal: "aal1"
63
- })
64
- })
227
+ aal: "aal1",
228
+ });
229
+ });
65
230
  ```
66
231
 
67
232
  Service contracts expose small `make` factories for typed layer wiring:
68
233
 
69
234
  ```ts
70
- import { Effect, Layer } from "effect"
71
- import { AuthFlow, denied } from "@effect-auth/core/AuthFlow"
235
+ import { Effect, Layer } from "effect";
236
+ import { AuthFlow, AuthResult } from "@effect-auth/core/AuthFlow";
237
+ import type { IssuedSession } from "@effect-auth/core/Sessions";
238
+
239
+ declare const issuedSession: IssuedSession;
72
240
 
73
241
  const AuthFlowLive = Layer.succeed(
74
242
  AuthFlow,
75
243
  AuthFlow.make({
76
- startPrimaryFactor: () => Effect.succeed(denied("policy-denied")),
77
- completePrimaryFactor: () => Effect.succeed(denied("policy-denied")),
78
- completeMfa: () => Effect.succeed(denied("policy-denied")),
79
- complete: () => Effect.succeed(denied("policy-denied"))
244
+ startPrimaryFactor: () =>
245
+ Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
246
+ completePrimaryFactor: () =>
247
+ Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
248
+ completeMfa: () =>
249
+ Effect.succeed(AuthResult.PolicyDenied({ reason: "test" })),
250
+ complete: () =>
251
+ Effect.succeed(AuthResult.Authenticated({ session: issuedSession })),
80
252
  })
81
- )
253
+ );
82
254
  ```
83
255
 
84
256
  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
257
 
258
+ The first default live implementations are available for the phase-3 web session path:
259
+
260
+ ```ts
261
+ import { Layer, Redacted } from "effect";
262
+ import { AuthFlowLive } from "@effect-auth/core/AuthFlow";
263
+ import { ChallengeLive } from "@effect-auth/core/Challenge";
264
+ import { WebCryptoLive } from "@effect-auth/core/Crypto";
265
+ import {
266
+ PasswordLoginLive,
267
+ PasswordRegistrationLive,
268
+ Pbkdf2PasswordHasherLive,
269
+ } from "@effect-auth/core/Password";
270
+ import { SessionCookieLive, SessionsLive } from "@effect-auth/core/Sessions";
271
+ import { EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";
272
+
273
+ const AuthLive = Layer.mergeAll(
274
+ WebCryptoLive(),
275
+ EffectQbSqliteAuthStorageLive(executor),
276
+ Pbkdf2PasswordHasherLive(),
277
+ SessionsLive({ secret: Redacted.make(env.AUTH_SESSION_SECRET) }),
278
+ SessionCookieLive(),
279
+ ChallengeLive({ secret: Redacted.make(env.AUTH_CHALLENGE_SECRET) }),
280
+ AuthFlowLive,
281
+ PasswordLoginLive,
282
+ PasswordRegistrationLive
283
+ );
284
+ ```
285
+
286
+ `SessionsLive` stores only `sessionId` plus HMAC secret hash in the database. The browser cookie contains the opaque bearer token in `sessionId.secret` format.
287
+
288
+ ## Password
289
+
290
+ `PasswordLogin` verifies a password factor and hands the result to `AuthFlow`. It does not create sessions directly.
291
+
292
+ ```ts
293
+ import { Effect, Redacted } from "effect";
294
+ import { Email } from "@effect-auth/core/Identifiers";
295
+ import { PasswordLogin } from "@effect-auth/core/Password";
296
+
297
+ export const signIn = Effect.gen(function* () {
298
+ const password = yield* PasswordLogin;
299
+
300
+ return yield* password.signIn({
301
+ email: Email("user@example.com"),
302
+ password: Redacted.make("correct horse battery staple"),
303
+ });
304
+ });
305
+ ```
306
+
307
+ `PasswordLoginLive` requires `UserStore`, `CredentialStore`, `PasswordHasher`, and `AuthFlow`. Invalid credentials return `AuthResult.InvalidCredentials()`, while storage/hash/session failures stay as typed errors.
308
+
309
+ `PasswordRegistration` creates a user and password credential, then hands the primary password factor to `AuthFlow`.
310
+
311
+ ```ts
312
+ import { Effect, Redacted } from "effect";
313
+ import { Email } from "@effect-auth/core/Identifiers";
314
+ import { PasswordRegistration } from "@effect-auth/core/Password";
315
+
316
+ export const signUp = Effect.gen(function* () {
317
+ const registration = yield* PasswordRegistration;
318
+
319
+ return yield* registration.signUp({
320
+ email: Email("user@example.com"),
321
+ password: Redacted.make("correct horse battery staple"),
322
+ });
323
+ });
324
+ ```
325
+
326
+ Duplicate emails fail with `EmailAlreadyRegisteredError`; callers decide whether to expose that as a conflict, a neutral response, or another boundary-specific behavior.
327
+
328
+ `Pbkdf2PasswordHasherLive` is the first reference hasher. It uses Web Crypto PBKDF2-SHA256 and stores hashes as `pbkdf2-sha256$iterations$salt$hash`.
329
+
330
+ ```ts
331
+ import { Layer } from "effect";
332
+ import { WebCryptoLive } from "@effect-auth/core/Crypto";
333
+ import { Pbkdf2PasswordHasherLive } from "@effect-auth/core/Password";
334
+
335
+ const PasswordHashingLive = Layer.mergeAll(
336
+ WebCryptoLive(),
337
+ Pbkdf2PasswordHasherLive()
338
+ );
339
+ ```
340
+
341
+ ## HTTP API
342
+
343
+ `@effect-auth/core/HttpApi` contains the first Effect v4 HttpApi contract for the built-in auth surface. It exports `AuthApi`, module-owned endpoint groups, and handler layers for the core services.
344
+
345
+ ```ts
346
+ import { Layer } from "effect";
347
+ import { HttpRouter } from "effect/unstable/http";
348
+ import { AuthHttpApiLive } from "@effect-auth/core/HttpApi";
349
+ import { PrivacyLive } from "./auth/privacy";
350
+ import { RateLimiterLive } from "./auth/rate-limiter";
351
+
352
+ export const AuthRoutesLive = HttpRouter.serve(
353
+ AuthHttpApiLive.pipe(
354
+ Layer.provide(AuthServicesLive),
355
+ Layer.provide(RateLimiterLive),
356
+ Layer.provide(PrivacyLive)
357
+ )
358
+ );
359
+ ```
360
+
361
+ The contract currently includes these endpoint groups:
362
+
363
+ | Group | Endpoints |
364
+ | --- | --- |
365
+ | Password | `POST /auth/password/sign-in`, `POST /auth/password/sign-up`, `POST /auth/password/reset/start`, `POST /auth/password/reset/verify`, `POST /auth/password/set`, `POST /auth/password/change`. |
366
+ | Sessions | `GET /auth/session`, `POST /auth/session/refresh`, `POST /auth/logout`, `GET /auth/sessions`, `POST /auth/sessions/revoke`, `POST /auth/sessions/revoke-others`. |
367
+ | Email verification | `POST /auth/email-verification/start`, `POST /auth/email-verification/verify`. |
368
+ | Email OTP | `POST /auth/email-otp/start`, `POST /auth/email-otp/verify`. |
369
+ | Magic link | `POST /auth/magic-link/start`, `POST /auth/magic-link/verify`. |
370
+ | Login approval | `POST /auth/login-approval/approve`, `POST /auth/login-approval/status`, `POST /auth/login-approval/finalize`. |
371
+ | Security | `POST /auth/security/login/report`. |
372
+
373
+ `AuthHttpApiLive` wires the full built-in surface. Apps can own the final HTTP contract and reuse individual endpoints, handlers, or services when a feature needs app-specific payloads or responses.
374
+
375
+ `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.
376
+
377
+ `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.
378
+
379
+ `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 })`.
380
+
381
+ `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.
382
+
383
+ 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:
384
+
385
+ ```ts
386
+ const csrfToken = crypto.randomUUID();
387
+
388
+ return new Response(JSON.stringify({ csrfToken }), {
389
+ headers: {
390
+ "content-type": "application/json",
391
+ "set-cookie": `__Host-csrf=${encodeURIComponent(csrfToken)}; Path=/; Secure; HttpOnly; SameSite=Lax`,
392
+ },
393
+ });
394
+
395
+ await fetch("/auth/session/refresh", {
396
+ method: "POST",
397
+ credentials: "include",
398
+ headers: { "x-csrf-token": csrfToken },
399
+ });
400
+ ```
401
+
402
+ `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:
403
+
404
+ ```ts
405
+ import { Duration, Layer } from "effect";
406
+ import {
407
+ AuthHttpApiConfigLive,
408
+ AuthHttpApiLive,
409
+ } from "@effect-auth/core/HttpApi";
410
+ import { PrivacyLive } from "./auth/privacy";
411
+ import { RateLimiterLive } from "./auth/rate-limiter";
412
+
413
+ export const AuthHttpApiLayer = AuthHttpApiLive.pipe(
414
+ Layer.provide(
415
+ AuthHttpApiConfigLive({
416
+ rateLimit: {
417
+ trustProxyHeaders: true,
418
+ passwordSignIn: {
419
+ byIp: { limit: 20, window: Duration.minutes(10) },
420
+ byEmail: { limit: 5, window: Duration.minutes(10) },
421
+ },
422
+ passwordSignUp: {
423
+ byIp: false,
424
+ byEmail: { limit: 3, window: Duration.hours(1) },
425
+ },
426
+ emailVerificationStart: false,
427
+ },
428
+ })
429
+ ),
430
+ Layer.provide(AuthServicesLive),
431
+ Layer.provide(RateLimiterLive),
432
+ Layer.provide(PrivacyLive)
433
+ );
434
+ ```
435
+
436
+ 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`.
437
+
438
+ ```ts
439
+ import { AuthHttpApiBareLive } from "@effect-auth/core/HttpApi";
440
+
441
+ export const AuthHttpApiLayer = AuthHttpApiBareLive.pipe(
442
+ Layer.provide(AuthServicesLive)
443
+ );
444
+ ```
445
+
446
+ `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.
447
+
448
+ Password HTTP handlers can start `EmailVerificationFlow` automatically when a custom auth flow returns `RequiresEmailVerification`. The HTTP response remains `requires_email_verification` and does not include the user id, email, challenge secret, or delivery provider details.
449
+
450
+ 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:
451
+
452
+ ```ts
453
+ import { Effect, Schema } from "effect";
454
+ import {
455
+ HttpApi,
456
+ HttpApiBuilder,
457
+ HttpApiEndpoint,
458
+ HttpApiGroup,
459
+ } from "effect/unstable/httpapi";
460
+ import {
461
+ AuthBadRequestError,
462
+ AuthEmailAlreadyRegisteredError,
463
+ AuthHttp,
464
+ AuthInternalError,
465
+ AuthOriginCheckMiddleware,
466
+ AuthPolicyDeniedError,
467
+ AuthRateLimitedError,
468
+ AuthSchemaErrorMiddleware,
469
+ makePasswordSignInHandler,
470
+ passwordSignInEndpoint,
471
+ PasswordAuthSuccess,
472
+ } from "@effect-auth/core/HttpApi";
473
+ import { EmailSchema } from "@effect-auth/core/Identifiers";
474
+ import { PasswordLogin } from "@effect-auth/core/Password";
475
+
476
+ const PhoneNumber = Schema.String.check(Schema.isPattern(/^\+[1-9]\d{7,14}$/));
477
+
478
+ const PhoneSignUpBody = Schema.Struct({
479
+ email: EmailSchema,
480
+ password: Schema.String,
481
+ phone: PhoneNumber,
482
+ });
483
+
484
+ const phoneSignUpEndpoint = HttpApiEndpoint.post("signUp", "/sign-up", {
485
+ payload: PhoneSignUpBody,
486
+ success: PasswordAuthSuccess,
487
+ error: [
488
+ AuthBadRequestError,
489
+ AuthEmailAlreadyRegisteredError,
490
+ AuthPolicyDeniedError,
491
+ AuthRateLimitedError,
492
+ AuthInternalError,
493
+ ],
494
+ });
495
+
496
+ class PasswordApi extends HttpApiGroup.make("password")
497
+ .add(passwordSignInEndpoint, phoneSignUpEndpoint)
498
+ .prefix("/auth/password")
499
+ .middleware(AuthSchemaErrorMiddleware)
500
+ .middleware(AuthOriginCheckMiddleware) {}
501
+
502
+ class AppAuthApi extends HttpApi.make("AppAuthApi").add(PasswordApi) {}
503
+
504
+ export const AppPasswordApiLive = HttpApiBuilder.group(
505
+ AppAuthApi,
506
+ "password",
507
+ Effect.fn("app.auth.password.group")(function* (handlers) {
508
+ const password = yield* PasswordLogin;
509
+ const authHttp = yield* AuthHttp;
510
+
511
+ return handlers
512
+ .handle("signIn", makePasswordSignInHandler({ password, authHttp }))
513
+ .handle(
514
+ "signUp",
515
+ Effect.fn("app.auth.password.sign_up")(function* ({ payload }) {
516
+ return yield* signUpWithPhone(payload, authHttp);
517
+ })
518
+ );
519
+ })
520
+ );
521
+ ```
522
+
86
523
  ## Sessions
87
524
 
88
525
  `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
526
 
90
527
  ```ts
91
- import { Effect } from "effect"
92
- import type { UserId } from "@effect-auth/core/Identifiers"
93
- import { SessionCookie, Sessions } from "@effect-auth/core/Sessions"
528
+ import { Effect } from "effect";
529
+ import type { UserId } from "@effect-auth/core/Identifiers";
530
+ import { SessionCookie, Sessions } from "@effect-auth/core/Sessions";
94
531
 
95
532
  export const createBrowserSession = (userId: UserId) =>
96
533
  Effect.gen(function* () {
97
- const sessions = yield* Sessions
98
- const sessionCookie = yield* SessionCookie
534
+ const sessions = yield* Sessions;
535
+ const sessionCookie = yield* SessionCookie;
99
536
 
100
537
  const issued = yield* sessions.create({
101
538
  userId,
102
539
  method: "password",
103
540
  amr: ["pwd"],
104
- aal: "aal1"
105
- })
541
+ aal: "aal1",
542
+ claims: {
543
+ emailVerified: false,
544
+ requirements: ["email_verification"],
545
+ },
546
+ });
106
547
 
107
- const cookie = yield* sessionCookie.commit(issued)
548
+ const cookie = yield* sessionCookie.commit(issued);
108
549
 
109
- return { issued, cookie }
110
- })
550
+ return { issued, cookie };
551
+ });
111
552
  ```
112
553
 
554
+ `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. Effect-QB storage persists claims in the existing `auth_session.metadata` column, so no additional storage migration is required.
555
+
556
+ Password auth passes the user's email verification state into `AuthFlow`, where `EmailVerificationSessionPolicy` decides whether unverified users get a normal session or a constrained session. The default mode is `allow-unverified` for compatibility. Use `limited-session` to issue sessions with an `email_verification` requirement. Per-login email approval is a separate `UserSecurityPolicy` / `LoginApprovalPolicy` concern, not an email verification session mode.
557
+
558
+ ```ts
559
+ import { AuthFlowLiveWithEmailVerificationPolicy } from "@effect-auth/core/AuthFlow";
560
+
561
+ export const AllowUnverifiedAuthFlowLive =
562
+ AuthFlowLiveWithEmailVerificationPolicy({
563
+ mode: "allow-unverified",
564
+ });
565
+
566
+ export const LimitedSessionAuthFlowLive =
567
+ AuthFlowLiveWithEmailVerificationPolicy({
568
+ mode: "limited-session",
569
+ });
570
+ ```
571
+
572
+ 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`.
573
+
574
+ ```ts
575
+ import { Effect } from "effect";
576
+ import * as Policy from "@effect-auth/core/Policy";
577
+
578
+ export const updateBillingSettings = Effect.gen(function* () {
579
+ return yield* saveBillingSettings();
580
+ }).pipe(Policy.require(Policy.requireEmailVerified()));
581
+ ```
582
+
583
+ `LoginApprovalPolicy` is a separate primary-factor decision for high-security sign-in flows. It decides whether a verified primary factor can continue to session creation or should require app-owned login approval. When `AuthFlow` has a `LoginApprovalFlow` and `AuthFlowState`, it stores the pending primary factor, issues a `login-approval` challenge, and returns `AuthResult.RequiresLoginApproval`. The HTTP response is `requires_login_approval` and includes only `flowId`, `approvalChallengeId`, `channel`, `sessionBinding`, `sameDeviceRequired`, and `reason`.
584
+
585
+ The built-in HTTP API completes approval with `POST /auth/login-approval/approve` using `flowId`, `approvalChallengeId`, and an optional challenge `secret`. It verifies the approval, checks the approval belongs to the pending `flowId`, consumes `AuthFlowState`, marks the user email verified when the approval requested it, and creates the session. `sessionBinding: "originating-device"` protects the pending auth flow state with an HttpOnly `__Host-login-approval` cookie secret; approving from another device fails before session creation. `sessionBinding: "approval-device"` creates the session on the device that completes approval. `sessionBinding: "none"` records an approval review but does not create a user session from the approval response; the originating device finalizes later with `LoginApprovalFinalize`.
586
+
587
+ Custom policy code is the primary extension point. Presets such as `mode: "new-device"` are convenience defaults for simple apps; high-security apps should model product-specific risk logic directly in `LoginApprovalPolicy.custom`.
588
+
589
+ ```ts
590
+ import { Effect, Layer } from "effect";
591
+ import { AuthFlowStateLive, AuthFlowLive } from "@effect-auth/core/AuthFlow";
592
+ import {
593
+ LoginApprovalDecision,
594
+ LoginApprovalDeliveryFromAuthMailerLive,
595
+ LoginApprovalFlowLive,
596
+ LoginApprovalLive,
597
+ LoginApprovalPolicy,
598
+ LoginApprovalPolicyLive,
599
+ } from "@effect-auth/core/LoginApproval";
600
+ import {
601
+ LoginRiskContext,
602
+ LoginRiskEngine,
603
+ LoginRiskSignal,
604
+ } from "@effect-auth/core/LoginRisk";
605
+
606
+ export const SimpleEmailApprovalPolicyLive = LoginApprovalPolicyLive({
607
+ mode: "new-device",
608
+ channel: "email",
609
+ sessionBinding: "originating-device",
610
+ requireForUnverifiedEmail: true,
611
+ skipMethods: ["passkey"],
612
+ skipWhenAalAtLeast: "aal2",
613
+ });
614
+
615
+ export const RiskBasedApprovalPolicyLive = Layer.succeed(
616
+ LoginApprovalPolicy,
617
+ LoginApprovalPolicy.custom((input) =>
618
+ Effect.succeed(
619
+ input.risk.hasSignal("impossible-travel")
620
+ ? LoginApprovalDecision.emailCode({
621
+ reason: "impossible-travel",
622
+ })
623
+ : LoginApprovalDecision.continue()
624
+ )
625
+ )
626
+ );
627
+
628
+ export const AppLoginRiskEngineLive = Layer.succeed(
629
+ LoginRiskEngine,
630
+ LoginRiskEngine.custom((input) =>
631
+ Effect.succeed(
632
+ input.request?.country === "AU"
633
+ ? LoginRiskContext.make({
634
+ level: "critical",
635
+ signals: [
636
+ LoginRiskSignal.make({
637
+ type: "impossible-travel",
638
+ level: "critical",
639
+ }),
640
+ ],
641
+ })
642
+ : LoginRiskContext.make({ level: "low", signals: [] })
643
+ )
644
+ )
645
+ );
646
+
647
+ export const AppLoginApprovalLive = Layer.mergeAll(
648
+ AppLoginRiskEngineLive,
649
+ AuthFlowStateLive(),
650
+ LoginApprovalLive(),
651
+ LoginApprovalFlowLive,
652
+ LoginApprovalDeliveryFromAuthMailerLive,
653
+ AuthFlowLive
654
+ );
655
+ ```
656
+
657
+ The default preset policy is `mode: "never"`. Passkeys or other high-assurance methods can bypass preset approval with `skipMethods` or `skipWhenAalAtLeast`. Approval `channel` and `reason` are open strings, so apps can model `admin`, `sms`, `push`, `manual-review`, or risk-specific reasons without extending core. `LoginApprovalDecision.emailCode`, `emailLink`, `sms`, `adminReview`, and `custom` set semantic `sessionBinding`; `sameDeviceRequired` remains available as the low-level legacy flag. `adminReview` uses `sessionBinding: "none"`, `LoginApprovalReview.approve` / `deny` record the out-of-band decision, and `LoginApprovalFinalize.complete` creates the session only for the originating device. Email is required only for the built-in email delivery path; non-email channels can omit it and use a custom `LoginApprovalDelivery`. `AuthFlow` always supplies policy with typed `device` and `risk`; the default risk engine returns `LoginRiskContext.make({ level: "unknown", signals: [] })`, and apps can provide `LoginRiskEngine` to calculate impossible travel, IP reputation, tenant policy, or other risk signals. `LoginRiskContext` and `LoginRiskSignal` are `Schema.Class` values, so custom engines should return `LoginRiskContext.make(...)`; policies can use helpers such as `input.risk.hasSignal(...)`, `input.risk.getSignal(...)`, and `input.risk.isAtLeast(...)`. `LoginRiskEngine` receives request context only when the primary-factor integration supplies it; core does not trust proxy or geo headers by default.
658
+
659
+ `LoginApproval.inspect` validates a login approval challenge without consuming it. Use it for GET preview pages or admin panels that need to display pending approval details. For admin, push, or Slack approvals, configure `LoginApprovalReview` with a persistent store, let the out-of-band actor call `approve` or `deny`, then have the user's original browser poll review status and call `LoginApprovalFinalize.complete` with its pending-cookie secret. The built-in HTTP API exposes this originating-browser path as `POST /auth/login-approval/status` and `POST /auth/login-approval/finalize`, also available as `client.loginApproval.status(...)` and `client.loginApproval.finalize(...)`. `EffectQbSqliteAuthStorageLive` provides durable `LoginApprovalReviewStore`; the included `LoginApprovalReviewStoreMemoryLive` is for tests and local composition.
660
+
661
+ `AuthFlowState` stores a short-lived, single-use pending primary-factor result in the existing challenge store. It is the bridge between “password/passkey/OTP verified” and “approval/MFA completed, now create a session”. The stored state contains `userId`, `method`, `amr`, `aal`, optional session claims, and app metadata such as `redirectTo`.
662
+
663
+ ```ts
664
+ import { AuthFlowStateLive } from "@effect-auth/core/AuthFlow";
665
+
666
+ export const AppAuthFlowStateLive = AuthFlowStateLive();
667
+ ```
668
+
669
+ ## Trusted Devices
670
+
671
+ `TrustedDevice` is a small boundary for “remember this device” login policy. It stores only an HMAC hash of an opaque device token and returns `LoginDeviceContext` as `unknown`, `new`, or `known`. `LoginApprovalPolicy({ mode: "new-device" })` can use that context to require approval only for first-time devices.
672
+
673
+ ```ts
674
+ import { Layer, Redacted } from "effect";
675
+ import {
676
+ TrustedDeviceCookieLive,
677
+ TrustedDeviceLive,
678
+ TrustedDeviceStoreMemoryLive,
679
+ } from "@effect-auth/core/TrustedDevice";
680
+
681
+ export const AppTrustedDeviceLive = Layer.mergeAll(
682
+ TrustedDeviceStoreMemoryLive,
683
+ TrustedDeviceLive({ secret: Redacted.make("trusted-device-secret") }),
684
+ TrustedDeviceCookieLive()
685
+ );
686
+ ```
687
+
688
+ The built-in password sign-in, email OTP verify, and magic-link verify HTTP handlers read the HttpOnly `__Host-auth-device` cookie when `TrustedDeviceCookie` is provided and pass the trusted-device token into `AuthFlow`; `AuthFlow` resolves the device status centrally with `TrustedDevice`. `POST /auth/login-approval/approve` accepts `rememberDevice: true`; when `TrustedDevice` and `TrustedDeviceCookie` are provided it trusts the approved user/device and sets `__Host-auth-device` with the final session response. `EffectQbSqliteAuthStorageLive` provides durable `TrustedDeviceStore`; the in-memory store is intended for tests and local composition.
689
+
113
690
  ## Challenge
114
691
 
115
692
  `Challenge` is the shared primitive for short-lived proofs: email OTP, magic link, passkey challenges, OAuth state, reset password, and MFA.
116
693
 
117
694
  ```ts
118
- import { Duration, Effect, Redacted } from "effect"
119
- import { Challenge } from "@effect-auth/core/Challenge"
695
+ import { Duration, Effect, Redacted } from "effect";
696
+ import { Challenge } from "@effect-auth/core/Challenge";
120
697
 
121
698
  export const issueEmailOtp = (email: string, code: string) =>
122
699
  Effect.gen(function* () {
123
- const challenge = yield* Challenge
700
+ const challenge = yield* Challenge;
124
701
 
125
702
  return yield* challenge.issue({
126
703
  type: "email-otp",
127
704
  subject: email,
128
705
  ttl: Duration.minutes(10),
129
- secret: Redacted.make(code)
130
- })
131
- })
706
+ secret: Redacted.make(code),
707
+ });
708
+ });
709
+ ```
710
+
711
+ ## Email Verification
712
+
713
+ `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.
714
+
715
+ ```ts
716
+ import { Effect, Redacted } from "effect";
717
+ import {
718
+ EmailDelivery,
719
+ EmailVerification,
720
+ EmailVerificationFlow,
721
+ } from "@effect-auth/core/EmailVerification";
722
+ import type { ChallengeId, Email, UserId } from "@effect-auth/core/Identifiers";
723
+
724
+ const emailDelivery = EmailDelivery.make({
725
+ sendEmailVerification: ({ email, secret, challengeId }) =>
726
+ sendMail({
727
+ to: email,
728
+ subject: "Verify your email",
729
+ body:
730
+ secret === undefined
731
+ ? `Open /verify-email?challengeId=${challengeId}`
732
+ : `Your verification code is ${Redacted.value(secret)}`,
733
+ }),
734
+ });
735
+
736
+ export const startEmailVerification = (
737
+ userId: UserId,
738
+ email: Email,
739
+ code: string
740
+ ) =>
741
+ Effect.gen(function* () {
742
+ const flow = yield* EmailVerificationFlow;
743
+
744
+ return yield* flow.start({
745
+ userId,
746
+ email,
747
+ secret: Redacted.make(code),
748
+ });
749
+ });
750
+
751
+ export const verifyEmail = (challengeId: ChallengeId, code: string) =>
752
+ Effect.gen(function* () {
753
+ const emailVerification = yield* EmailVerification;
754
+
755
+ return yield* emailVerification.verify({
756
+ challengeId,
757
+ secret: Redacted.make(code),
758
+ });
759
+ });
132
760
  ```
133
761
 
762
+ ## Retention Cleanup
763
+
764
+ `@effect-auth/core/Retention` provides a runtime-neutral helper for app-owned cleanup jobs across audit log, security timeline, login risk history, webhook outbox, and webhook replay stores.
765
+
766
+ ```ts
767
+ import { Effect } from "effect";
768
+ import { EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";
769
+ import { UnixMillis } from "@effect-auth/core/Identifiers";
770
+ import { runAuthRetentionCleanup } from "@effect-auth/core/Retention";
771
+
772
+ const days = (count: number) => count * 24 * 60 * 60 * 1000;
773
+
774
+ export const cleanupAuthRetention = runAuthRetentionCleanup({
775
+ now: UnixMillis(Date.now()),
776
+ auditLog: { retentionMillis: days(365) },
777
+ securityTimeline: { retentionMillis: days(90) },
778
+ loginRiskHistory: { retentionMillis: days(180) },
779
+ webhookOutbox: { retentionMillis: days(30) },
780
+ webhookReplay: true,
781
+ }).pipe(Effect.provide(EffectQbSqliteAuthStorageLive(executor)));
782
+ ```
783
+
784
+ Core still leaves the scheduler, retention windows, tenant/legal-hold policy, SIEM/export confirmation, and access control to the application.
785
+
134
786
  ## Storage
135
787
 
136
- Storage is exposed as ORM-free ports. Core flows depend on store services, while Kysely, future Drizzle/effect-qb, or app-owned adapters provide the implementations.
788
+ Storage is exposed as ORM-free ports. Core flows depend on store services, while Effect-QB, future Drizzle, or app-owned adapters provide the implementations.
137
789
 
138
790
  ```ts
139
- import { Effect, Option } from "effect"
140
- import { SessionStore, VerificationStore } from "@effect-auth/core/Storage"
791
+ import { Effect, Option } from "effect";
792
+ import {
793
+ CredentialStore,
794
+ SessionStore,
795
+ UserStore,
796
+ VerificationStore,
797
+ } from "@effect-auth/core/Storage";
798
+
799
+ const UserStoreLive = UserStore.make({
800
+ findById: () => Effect.succeed(Option.none()),
801
+ findByEmail: () => Effect.succeed(Option.none()),
802
+ insert: () => Effect.void,
803
+ markEmailVerified: () => Effect.succeed(Option.none()),
804
+ });
805
+
806
+ const CredentialStoreLive = CredentialStore.make({
807
+ findPasswordByUserId: () => Effect.succeed(Option.none()),
808
+ insertPassword: () => Effect.void,
809
+ });
141
810
 
142
811
  const SessionStoreLive = SessionStore.make({
143
812
  findById: () => Effect.succeed(Option.none()),
@@ -145,51 +814,99 @@ const SessionStoreLive = SessionStore.make({
145
814
  refresh: () => Effect.succeed(Option.none()),
146
815
  rotate: () => Effect.succeed(Option.none()),
147
816
  revoke: () => Effect.void,
148
- revokeAllForUser: () => Effect.void
149
- })
817
+ revokeAllForUser: () => Effect.void,
818
+ });
150
819
 
151
820
  const VerificationStoreLive = VerificationStore.make({
152
821
  findById: () => Effect.succeed(Option.none()),
153
822
  insert: () => Effect.void,
154
823
  consume: () => Effect.succeed(Option.none()),
155
- deleteExpired: () => Effect.succeed(0)
156
- })
824
+ deleteExpired: () => Effect.succeed(0),
825
+ });
157
826
  ```
158
827
 
159
- 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:
828
+ The default schema is exported as SQL and as package assets:
160
829
 
161
830
  ```ts
162
- import { makeKyselyStorage } from "@effect-auth/core/KyselyStorage"
163
-
164
- const { sessionStore, verificationStore } = makeKyselyStorage(db)
831
+ import { authStorageMigrations } from "@effect-auth/core/StorageMigrations";
165
832
  ```
166
833
 
167
- For Cloudflare D1, use the first-party Kysely dialect instead of an external D1 dialect package:
834
+ Effect-QB SQLite/D1 storage is the first-party durable adapter. The default path uses the auth schema and requires no table mapping:
168
835
 
169
836
  ```ts
170
- import { makeD1Kysely } from "@effect-auth/core/D1Kysely"
171
- import { KyselyAuthStorageLive, type KyselyAuthDatabase } from "@effect-auth/core/KyselyStorage"
837
+ import { EffectQbSqliteAuthStorageLive } from "@effect-auth/core/EffectQbSqliteStorage";
172
838
 
173
- interface DB extends KyselyAuthDatabase {
174
- // app tables live here too
175
- }
176
-
177
- const db = makeD1Kysely<DB>(env.DB)
178
- const StorageLive = KyselyAuthStorageLive(db)
839
+ const StorageLive = EffectQbSqliteAuthStorageLive(executor);
179
840
  ```
180
841
 
181
- The default schema expects `auth_session` and `auth_verification` tables in the Kysely database type:
842
+ `EffectQbSqliteAuthStorageLive` provides all durable auth stores in the default schema, including audit log storage, domain verification storage, and the OAuth provider-token vault for already-encrypted provider token ciphertexts. Apps still own provider-token encryption, KMS/secrets-service policy, DNS/HTTP proof checking, and tenant authorization before values reach storage.
843
+
844
+ For Cloudflare D1, create an effect-qb SQLite executor from the D1 binding:
182
845
 
183
846
  ```ts
184
- import type { KyselyAuthDatabase } from "@effect-auth/core/KyselyStorage"
847
+ import {
848
+ EffectQbSqliteAuthStorageLive,
849
+ makeD1EffectQbSqliteExecutor,
850
+ } from "@effect-auth/core/EffectQbSqliteStorage";
185
851
 
186
- interface DB extends KyselyAuthDatabase {
187
- // app tables live here too
188
- }
852
+ const executor = makeD1EffectQbSqliteExecutor(env.DB);
853
+ const StorageLive = EffectQbSqliteAuthStorageLive(executor);
189
854
  ```
190
855
 
191
- The default schema is also exported as SQL and as package assets:
856
+ Applications with their own Effect-QB tables can map semantic auth fields to app-owned columns with `defineEffectQbAuthTables`:
192
857
 
193
858
  ```ts
194
- import { authStorageMigrations } from "@effect-auth/core/StorageMigrations"
859
+ import { Column, Query, Table } from "effect-qb/sqlite";
860
+ import {
861
+ defineEffectQbAuthTables,
862
+ EffectQbSqliteAuthStorageLive,
863
+ } from "@effect-auth/core/EffectQbSqliteStorage";
864
+
865
+ const users = Table.make("users", {
866
+ user_id: Column.text().pipe(Column.primaryKey),
867
+ email_address: Column.text(),
868
+ verified_at: Column.int().pipe(Column.nullable),
869
+ inserted_at: Column.int(),
870
+ updated_at: Column.int(),
871
+ disabled_at: Column.int().pipe(Column.nullable),
872
+ metadata_json: Column.text().pipe(Column.nullable),
873
+ });
874
+
875
+ const authTables = defineEffectQbAuthTables({
876
+ user: {
877
+ table: users,
878
+ select: {
879
+ id: users.user_id,
880
+ email: users.email_address,
881
+ emailVerified: users.verified_at,
882
+ createdAt: users.inserted_at,
883
+ updatedAt: users.updated_at,
884
+ disabledAt: users.disabled_at,
885
+ metadata: users.metadata_json,
886
+ },
887
+ insert: (row) =>
888
+ ({
889
+ user_id: row.id,
890
+ email_address: row.email,
891
+ verified_at: row.emailVerified ? Number(row.updatedAt) : null,
892
+ inserted_at: Number(row.createdAt),
893
+ updated_at: Number(row.updatedAt),
894
+ disabled_at:
895
+ row.disabledAt === undefined ? null : Number(row.disabledAt),
896
+ metadata_json:
897
+ row.metadata === undefined ? null : JSON.stringify(row.metadata),
898
+ }) satisfies Query.MutationInputOf<Table.InsertOf<typeof users>>,
899
+ markEmailVerified: (input) =>
900
+ ({
901
+ verified_at: Number(input.updatedAt),
902
+ updated_at: Number(input.updatedAt),
903
+ }) satisfies Query.MutationInputOf<Table.UpdateOf<typeof users>>,
904
+ },
905
+
906
+ // credential/session/verification/audit/review/trusted-device/passkey/TOTP/recovery-code/API-key/refresh-token/OAuth-account/provider-token-vault/domain-verification mappings follow the same pattern.
907
+ });
908
+
909
+ const StorageLive = EffectQbSqliteAuthStorageLive(executor, {
910
+ tables: authTables,
911
+ });
195
912
  ```