okengine 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/package.json +3 -1
  2. package/site/content/docs/ai/index.mdx +24 -0
  3. package/site/content/docs/ai/llms-txt.mdx +3 -0
  4. package/site/content/docs/ai/meta.json +1 -1
  5. package/site/content/docs/console/gates.mdx +46 -8
  6. package/site/content/docs/console/index.mdx +54 -0
  7. package/site/content/docs/console/meta.json +1 -0
  8. package/site/content/docs/elements/gate.mdx +187 -48
  9. package/site/content/docs/elements/index.mdx +45 -0
  10. package/site/content/docs/elements/meta.json +1 -1
  11. package/site/content/docs/get-started/basic-usage.mdx +4 -3
  12. package/site/content/docs/get-started/index.mdx +33 -0
  13. package/site/content/docs/get-started/meta.json +1 -1
  14. package/site/content/docs/index.mdx +9 -31
  15. package/site/content/docs/plugins/anonymous.mdx +95 -0
  16. package/site/content/docs/plugins/compression.mdx +2 -2
  17. package/site/content/docs/plugins/cors.mdx +2 -2
  18. package/site/content/docs/plugins/csrf.mdx +2 -2
  19. package/site/content/docs/plugins/email-otp.mdx +111 -0
  20. package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +1 -1
  21. package/site/content/docs/plugins/index.mdx +69 -0
  22. package/site/content/docs/plugins/magic-link.mdx +112 -0
  23. package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
  24. package/site/content/docs/plugins/meta.json +10 -1
  25. package/site/content/docs/plugins/passkey.mdx +128 -0
  26. package/site/content/docs/plugins/phone-number.mdx +111 -0
  27. package/site/content/docs/plugins/two-factor.mdx +116 -0
  28. package/site/content/docs/plugins/username.mdx +117 -0
  29. package/site/content/docs/reference/client.mdx +331 -0
  30. package/site/content/docs/reference/fx.mdx +20 -5
  31. package/site/content/docs/reference/index.mdx +45 -0
  32. package/site/content/docs/reference/meta.json +11 -1
  33. package/site/content/docs/reference/plugins.mdx +25 -14
  34. package/src/auth/auth.test.ts +20 -2
  35. package/src/auth/bindings.ts +439 -0
  36. package/src/auth/breach-check.ts +112 -0
  37. package/src/auth/config.ts +288 -0
  38. package/src/auth/cookies.ts +123 -0
  39. package/src/auth/gate-auth.test.ts +379 -0
  40. package/src/auth/identity.ts +190 -0
  41. package/src/auth/index.ts +117 -1
  42. package/src/auth/method-context.ts +33 -0
  43. package/src/auth/operator.ts +27 -1
  44. package/src/auth/password-policy.test.ts +126 -0
  45. package/src/auth/password-policy.ts +77 -0
  46. package/src/auth/plugin.ts +62 -4
  47. package/src/auth/rate.ts +45 -0
  48. package/src/auth/schema.ts +260 -0
  49. package/src/auth/secondary-storage.ts +37 -0
  50. package/src/auth/sessions.ts +58 -1
  51. package/src/auth/tables.ts +4 -0
  52. package/src/auth/verification.ts +78 -0
  53. package/src/cli/dev.test.ts +3 -3
  54. package/src/cli/schema.ts +95 -23
  55. package/src/client/auth.ts +120 -0
  56. package/src/client-react/index.ts +93 -0
  57. package/src/compiler/aot.test.ts +2 -1
  58. package/src/compiler/extract.ts +19 -0
  59. package/src/compiler/response.ts +16 -2
  60. package/src/console/server/app.ts +10 -6
  61. package/src/console/server/auth-rate.test.ts +3 -3
  62. package/src/console/server/bind.ts +12 -1
  63. package/src/console/server/channels.test.ts +1 -1
  64. package/src/console/server/console-gates.ts +14 -0
  65. package/src/console/server/console.test.ts +6 -6
  66. package/src/console/server/flows-invoke.test.ts +2 -2
  67. package/src/console/server/flows.ts +2 -0
  68. package/src/console/server/gates.ts +8 -1
  69. package/src/console/server/operator-db.test.ts +4 -4
  70. package/src/console/server/operator-db.ts +22 -4
  71. package/src/console/server/security.gate.test.ts +3 -3
  72. package/src/console/ui/gates/fixture.ts +4 -0
  73. package/src/console/ui/gates/types.ts +2 -0
  74. package/src/console/ui/shell/client.ts +1 -0
  75. package/src/elements/gate/boot.ts +136 -0
  76. package/src/elements/gate/config.ts +69 -0
  77. package/src/elements/gate/declare.ts +51 -1
  78. package/src/elements/gate/runtime.ts +3 -1
  79. package/src/elements/gate.test.ts +77 -0
  80. package/src/elements/gate.ts +20 -1
  81. package/src/elements/index.ts +8 -0
  82. package/src/index.ts +11 -0
  83. package/src/kernel/app.ts +253 -32
  84. package/src/kernel/boot.test.ts +40 -3
  85. package/src/kernel/boot.ts +8 -0
  86. package/src/kernel/call.test.ts +46 -2
  87. package/src/kernel/edge.test.ts +3 -3
  88. package/src/kernel/flow.test.ts +2 -2
  89. package/src/kernel/fx.test.ts +1 -0
  90. package/src/kernel/fx.ts +60 -0
  91. package/src/kernel/hooks.test.ts +4 -4
  92. package/src/kernel/index.ts +12 -0
  93. package/src/kernel/pipeline.test.ts +12 -8
  94. package/src/kernel/pipeline.ts +23 -4
  95. package/src/kernel/plugin/decorate.test.ts +3 -3
  96. package/src/kernel/plugin/scoping.test.ts +3 -3
  97. package/src/kernel/plugin-elements.test.ts +51 -0
  98. package/src/kernel/plugin-needs.test.ts +83 -0
  99. package/src/kernel/plugin-needs.ts +129 -0
  100. package/src/kernel/plugin.ts +101 -0
  101. package/src/kernel/registry-isolation.test.ts +5 -5
  102. package/src/kernel/registry.ts +102 -3
  103. package/src/manifest/types.ts +2 -0
  104. package/src/plugins/anonymous.ts +58 -0
  105. package/src/plugins/auth/shared.ts +121 -0
  106. package/src/plugins/auth-methods.test.ts +176 -0
  107. package/src/plugins/compression.test.ts +5 -5
  108. package/src/plugins/config-source.test.ts +1 -1
  109. package/src/plugins/cors.test.ts +16 -10
  110. package/src/plugins/csrf.test.ts +1 -1
  111. package/src/plugins/email-otp.ts +161 -0
  112. package/src/plugins/index.ts +31 -0
  113. package/src/plugins/ip-allowlist.test.ts +19 -9
  114. package/src/plugins/magic-link.ts +163 -0
  115. package/src/plugins/maintenance-mode.test.ts +9 -5
  116. package/src/plugins/passkey.ts +216 -0
  117. package/src/plugins/phone-number.ts +149 -0
  118. package/src/plugins/security-headers.test.ts +14 -14
  119. package/src/plugins/two-factor.ts +249 -0
  120. package/src/plugins/username.ts +148 -0
  121. package/src/runs/runs.test.ts +6 -2
  122. package/src/runtime/primitives.ts +37 -4
  123. package/src/runtime/serve.test.ts +3 -2
  124. package/src/runtime/types.ts +25 -2
  125. package/src/test/create-test-app.test.ts +1 -1
  126. package/src/test/create-test-app.ts +4 -1
  127. package/src/test/provisions.integration.test.ts +1 -1
@@ -0,0 +1,379 @@
1
+ /**
2
+ * Phase 1 Gate auth — autoBoot / posture / email Flows / schema / security.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { GateBootError } from "../elements/gate.ts";
7
+ import { oke, on, flow, http, resetBindings, plugin } from "../kernel/index.ts";
8
+ import { resetFlowSeq } from "../kernel/flow.ts";
9
+ // resetBindings via kernel; resetFlowSeq is test-only.
10
+ import { resolveAuthSchema } from "./schema.ts";
11
+ import { emitSchemaSource, runSchemaGenerate } from "../cli/schema.ts";
12
+ import { isSessionFresh, AUTH_SESSION_GATE } from "./bindings.ts";
13
+ import { resolveGateAuth } from "./config.ts";
14
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+
18
+ afterEach(() => {
19
+ resetBindings();
20
+ resetFlowSeq();
21
+ });
22
+
23
+ describe("gate.auth — config", () => {
24
+ test("prod without secret fails", () => {
25
+ expect(() =>
26
+ resolveGateAuth({ auth: { emailAndPassword: { enabled: true } }, env: "prod" }),
27
+ ).toThrow(/secret is required/);
28
+ });
29
+
30
+ test("dev mints secret", () => {
31
+ const resolved = resolveGateAuth({
32
+ auth: { emailAndPassword: { enabled: true } },
33
+ env: "dev",
34
+ });
35
+ expect(resolved.secretMinted).toBe(true);
36
+ expect(resolved.secret.length).toBeGreaterThan(8);
37
+ });
38
+
39
+ test("schema customize modelName / fields / additionalFields", () => {
40
+ const schema = resolveAuthSchema({
41
+ user: {
42
+ modelName: "users",
43
+ fields: { email: "email_address", name: "full_name" },
44
+ additionalFields: {
45
+ lang: { type: "string", defaultValue: "en" },
46
+ },
47
+ },
48
+ session: {
49
+ modelName: "user_sessions",
50
+ fields: { principalId: "user_id" },
51
+ },
52
+ });
53
+ expect(schema.models.user.tableName).toBe("users");
54
+ expect(schema.models.user.fields.email).toBe("email_address");
55
+ expect(schema.models.user.columns.some((c) => c.logical === "lang")).toBe(true);
56
+ expect(schema.models.session.tableName).toBe("user_sessions");
57
+ expect(schema.models.session.fields.principalId).toBe("user_id");
58
+ });
59
+ });
60
+
61
+ describe("gate.auth — autoBoot / posture", () => {
62
+ test("auth HTTP bindings are in adopted and fail posture without gates", async () => {
63
+ // Force a gap by constructing bindings then auditing with empty gates stripped —
64
+ // real materialization always attaches gate.public / auth.session.
65
+ const app = oke({
66
+ name: "auth-posture",
67
+ env: "test",
68
+ registry: "ignore",
69
+ gate: {
70
+ auth: {
71
+ secret: "test-secret-at-least-16",
72
+ emailAndPassword: { enabled: true },
73
+ },
74
+ unguardedHttp: "deny",
75
+ },
76
+ });
77
+ const authPaths = app.bindings
78
+ .filter((b) => b.trigger.kind === "http")
79
+ .map(
80
+ (b) =>
81
+ `${(b.trigger as { method: string; path: string }).method} ${(b.trigger as { path: string }).path}`,
82
+ );
83
+ expect(authPaths.some((p) => p.includes("/auth/sign-in/email"))).toBe(true);
84
+ expect(authPaths.some((p) => p.includes("/auth/me"))).toBe(true);
85
+ // Every auth binding has posture.
86
+ for (const b of app.bindings) {
87
+ if (b.trigger.kind !== "http") continue;
88
+ expect((b.trigger as { gates: readonly unknown[] }).gates.length).toBeGreaterThan(0);
89
+ }
90
+ await app.boot({ env: "test" });
91
+ await app.stop();
92
+ });
93
+
94
+ test("bare oke gate.auth fetch goes through ensureBoot → doBoot", async () => {
95
+ const app = oke({
96
+ name: "auth-autoboot",
97
+ env: "local",
98
+ registry: "ignore",
99
+ gate: {
100
+ auth: {
101
+ secret: "test-secret-at-least-16",
102
+ emailAndPassword: { enabled: true },
103
+ },
104
+ },
105
+ });
106
+ expect(app.booted).toBe(false);
107
+ const res = await app.fetch(
108
+ new Request("http://localhost/auth/sign-in/email", {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: JSON.stringify({ email: "a@b.com", password: "not-the-password" }),
112
+ }),
113
+ );
114
+ expect(app.booted).toBe(true);
115
+ expect(res.status).toBeGreaterThanOrEqual(400);
116
+ const body = (await res.json()) as { error?: { code?: string } };
117
+ expect(body.error?.code).toBe("AuthFailed");
118
+ await app.stop();
119
+ });
120
+
121
+ test("auth routes are not registry flows[] alone — they are Bindings", () => {
122
+ const app = oke({
123
+ name: "auth-bindings",
124
+ env: "local",
125
+ registry: "ignore",
126
+ gate: {
127
+ auth: {
128
+ secret: "test-secret-at-least-16",
129
+ emailAndPassword: { enabled: true },
130
+ },
131
+ },
132
+ });
133
+ expect(app.bindings.length).toBeGreaterThan(0);
134
+ expect(app.bindings.every((b) => b.flow && b.trigger)).toBe(true);
135
+ expect(app.router.match("POST", "/auth/sign-in/email")).toBeTruthy();
136
+ });
137
+
138
+ test(".needs(auth) satisfied by gate.auth without .plug(auth())", async () => {
139
+ on(http.get("/x").gate(AUTH_SESSION_GATE), flow({ name: "x", do: () => ({ ok: true }) }));
140
+ const dependent = plugin("needs-auth", { version: "0.0.1" }).needs("auth");
141
+ const app = oke({
142
+ name: "needs-gate-auth",
143
+ env: "test",
144
+ gate: {
145
+ auth: { secret: "test-secret-at-least-16", http: false },
146
+ policies: [AUTH_SESSION_GATE],
147
+ unguardedHttp: "allow",
148
+ },
149
+ }).plug(dependent);
150
+ await expect(app.boot({ env: "test", unguardedHttp: "allow" })).resolves.toBeTruthy();
151
+ await app.stop();
152
+ });
153
+ });
154
+
155
+ describe("gate.auth — email Flows + security", () => {
156
+ test("sign-up then sign-in + refresh + me", async () => {
157
+ const app = oke({
158
+ name: "auth-email",
159
+ env: "test",
160
+ registry: "ignore",
161
+ gate: {
162
+ auth: {
163
+ secret: "test-secret-at-least-16",
164
+ emailAndPassword: { enabled: true },
165
+ },
166
+ unguardedHttp: "deny",
167
+ },
168
+ });
169
+ await app.boot({ env: "test" });
170
+
171
+ const signUp = await app.fetch(
172
+ new Request("http://localhost/auth/sign-up/email", {
173
+ method: "POST",
174
+ headers: { "content-type": "application/json" },
175
+ body: JSON.stringify({
176
+ email: "user@example.com",
177
+ password: "CorrectHorse1",
178
+ name: "User",
179
+ }),
180
+ }),
181
+ );
182
+ expect(signUp.status).toBe(200);
183
+ const signed = (await signUp.json()) as {
184
+ data: { accessToken: string; refreshToken: string; userId: string };
185
+ };
186
+ expect(signed.data.accessToken).toBeTruthy();
187
+ expect(signed.data.userId).toBeTruthy();
188
+
189
+ const bad = await app.fetch(
190
+ new Request("http://localhost/auth/sign-in/email", {
191
+ method: "POST",
192
+ headers: { "content-type": "application/json" },
193
+ body: JSON.stringify({ email: "missing@example.com", password: "CorrectHorse1" }),
194
+ }),
195
+ );
196
+ const badBody = (await bad.json()) as { error: { code: string; data?: { reason?: string } } };
197
+ expect(badBody.error.code).toBe("AuthFailed");
198
+ expect(badBody.error.data?.reason).toBe("invalid_credentials");
199
+
200
+ const me = await app.fetch(
201
+ new Request("http://localhost/auth/me", {
202
+ headers: { authorization: `Bearer ${signed.data.accessToken}` },
203
+ }),
204
+ );
205
+ expect(me.status).toBe(200);
206
+ const meBody = (await me.json()) as { data: { email: string } };
207
+ expect(meBody.data.email).toBe("user@example.com");
208
+
209
+ const refreshed = await app.fetch(
210
+ new Request("http://localhost/auth/refresh", {
211
+ method: "POST",
212
+ headers: { "content-type": "application/json" },
213
+ body: JSON.stringify({ refreshToken: signed.data.refreshToken }),
214
+ }),
215
+ );
216
+ expect(refreshed.status).toBe(200);
217
+
218
+ await app.stop();
219
+ });
220
+
221
+ test("sign-up rejects weak passwords with AuthFailed password_policy", async () => {
222
+ const app = oke({
223
+ name: "auth-weak-password",
224
+ env: "test",
225
+ registry: "ignore",
226
+ gate: {
227
+ auth: {
228
+ secret: "test-secret-at-least-16",
229
+ emailAndPassword: { enabled: true },
230
+ },
231
+ },
232
+ });
233
+ await app.boot({ env: "test" });
234
+
235
+ const res = await app.fetch(
236
+ new Request("http://localhost/auth/sign-up/email", {
237
+ method: "POST",
238
+ headers: { "content-type": "application/json" },
239
+ body: JSON.stringify({
240
+ email: "weak@example.com",
241
+ password: "123",
242
+ name: "Weak",
243
+ }),
244
+ }),
245
+ );
246
+ expect(res.status).toBe(400);
247
+ const body = (await res.json()) as {
248
+ data: null;
249
+ error: { code: string; data?: { reason?: string; reasons?: string[] } };
250
+ };
251
+ expect(body.data).toBeNull();
252
+ expect(body.error.code).toBe("AuthFailed");
253
+ expect(body.error.data?.reason).toBe("password_policy");
254
+ expect(body.error.data?.reasons?.some((r) => r.includes("minLength"))).toBe(true);
255
+
256
+ // Rejection is real — same email can still sign up with a strong password.
257
+ const ok = await app.fetch(
258
+ new Request("http://localhost/auth/sign-up/email", {
259
+ method: "POST",
260
+ headers: { "content-type": "application/json" },
261
+ body: JSON.stringify({
262
+ email: "weak@example.com",
263
+ password: "CorrectHorse1",
264
+ name: "Strong",
265
+ }),
266
+ }),
267
+ );
268
+ expect(ok.status).toBe(200);
269
+
270
+ await app.stop();
271
+ });
272
+
273
+ test("enumeration: email_taken looks like invalid_credentials", async () => {
274
+ const app = oke({
275
+ name: "auth-enum",
276
+ env: "test",
277
+ registry: "ignore",
278
+ gate: {
279
+ auth: {
280
+ secret: "test-secret-at-least-16",
281
+ emailAndPassword: { enabled: true },
282
+ },
283
+ },
284
+ });
285
+ await app.boot({ env: "test" });
286
+ await app.fetch(
287
+ new Request("http://localhost/auth/sign-up/email", {
288
+ method: "POST",
289
+ headers: { "content-type": "application/json" },
290
+ body: JSON.stringify({ email: "dup@example.com", password: "CorrectHorse1" }),
291
+ }),
292
+ );
293
+ const again = await app.fetch(
294
+ new Request("http://localhost/auth/sign-up/email", {
295
+ method: "POST",
296
+ headers: { "content-type": "application/json" },
297
+ body: JSON.stringify({ email: "dup@example.com", password: "CorrectHorse1" }),
298
+ }),
299
+ );
300
+ const body = (await again.json()) as { error: { data?: { reason?: string } } };
301
+ expect(body.error.data?.reason).toBe("invalid_credentials");
302
+ await app.stop();
303
+ });
304
+
305
+ test("freshAge helper", () => {
306
+ const now = 1_000_000;
307
+ expect(isSessionFresh(now - 1000, 60_000, now)).toBe(true);
308
+ expect(isSessionFresh(now - 120_000, 60_000, now)).toBe(false);
309
+ });
310
+
311
+ test("missing gate on custom HTTP still GateBootError", async () => {
312
+ on(http.get("/open"), flow({ name: "open", do: () => ({ ok: true }) }));
313
+ const app = oke({
314
+ name: "gap",
315
+ env: "local",
316
+ gate: {
317
+ auth: { secret: "test-secret-at-least-16", http: false },
318
+ unguardedHttp: "deny",
319
+ },
320
+ startScheduler: false,
321
+ });
322
+ await expect(app.boot({ env: "local", startScheduler: false })).rejects.toBeInstanceOf(
323
+ GateBootError,
324
+ );
325
+ });
326
+ });
327
+
328
+ describe("oke schema generate — auth columns", () => {
329
+ test("emits real columns + --check drift", async () => {
330
+ const schema = resolveAuthSchema({
331
+ user: {
332
+ modelName: "users",
333
+ additionalFields: { lang: { type: "string", defaultValue: "en" } },
334
+ },
335
+ });
336
+ const source = emitSchemaSource([...schema.tableNames], schema);
337
+ expect(source).toContain('model: "user"');
338
+ expect(source).toContain('name: "users"');
339
+ expect(source).toContain("lang:");
340
+ expect(source).toContain('sqlType: "TEXT"');
341
+
342
+ const dir = await mkdtemp(join(tmpdir(), "oke-schema-"));
343
+ try {
344
+ await mkdir(join(dir, "schema"), { recursive: true });
345
+ const code = await runSchemaGenerate({
346
+ cwd: dir,
347
+ authSchema: {
348
+ user: {
349
+ modelName: "users",
350
+ additionalFields: { lang: { type: "string", defaultValue: "en" } },
351
+ },
352
+ },
353
+ write: () => {},
354
+ });
355
+ expect(code).toBe(0);
356
+ const checkOk = await runSchemaGenerate({
357
+ cwd: dir,
358
+ check: true,
359
+ authSchema: {
360
+ user: {
361
+ modelName: "users",
362
+ additionalFields: { lang: { type: "string", defaultValue: "en" } },
363
+ },
364
+ },
365
+ write: () => {},
366
+ });
367
+ expect(checkOk).toBe(0);
368
+ const drift = await runSchemaGenerate({
369
+ cwd: dir,
370
+ check: true,
371
+ authSchema: { user: { modelName: "people" } },
372
+ write: () => {},
373
+ });
374
+ expect(drift).toBe(1);
375
+ } finally {
376
+ await rm(dir, { recursive: true, force: true });
377
+ }
378
+ });
379
+ });
@@ -0,0 +1,190 @@
1
+ /**
2
+ * User-plane identity + credential store (email/password Gate auth).
3
+ */
4
+
5
+ import type { PasswordHashOptions } from "../runtime/types.ts";
6
+ import { createBunCrypto } from "../runtime/primitives.ts";
7
+ import { assertNotBreached, type BreachCheckFn } from "./breach-check.ts";
8
+ import { assertPasswordPolicy, type PasswordPolicyOptions } from "./password-policy.ts";
9
+
10
+ /** User-plane identity row (logical). */
11
+ export interface UserIdentityRow {
12
+ id: string;
13
+ email: string;
14
+ name: string;
15
+ emailVerified: boolean;
16
+ status: "active" | "suspended";
17
+ createdAt: number;
18
+ updatedAt: number;
19
+ /** Extra fields from `additionalFields`. */
20
+ extra: Record<string, unknown>;
21
+ }
22
+
23
+ /** Credential account linked to a user. */
24
+ export interface UserAccountRow {
25
+ id: string;
26
+ userId: string;
27
+ provider: string;
28
+ providerAccountId: string;
29
+ passwordHash: string | null;
30
+ createdAt: number;
31
+ updatedAt: number;
32
+ }
33
+
34
+ /** In-memory user identity store. */
35
+ export interface IdentityStore {
36
+ users: Map<string, UserIdentityRow>;
37
+ /** Lowercased email → user id. */
38
+ byEmail: Map<string, string>;
39
+ accounts: Map<string, UserAccountRow>;
40
+ /** `provider:providerAccountId` → account id. */
41
+ byProvider: Map<string, string>;
42
+ }
43
+
44
+ /**
45
+ * Create an empty identity store.
46
+ */
47
+ export function createIdentityStore(): IdentityStore {
48
+ return {
49
+ users: new Map(),
50
+ byEmail: new Map(),
51
+ accounts: new Map(),
52
+ byProvider: new Map(),
53
+ };
54
+ }
55
+
56
+ /** Options for {@link createUserWithPassword}. */
57
+ export interface CreateUserWithPasswordOptions {
58
+ readonly email: string;
59
+ readonly password: string;
60
+ readonly name?: string;
61
+ readonly id?: string;
62
+ readonly emailVerified?: boolean;
63
+ readonly extra?: Record<string, unknown>;
64
+ readonly passwordPolicy?: PasswordPolicyOptions;
65
+ readonly skipPasswordPolicy?: boolean;
66
+ readonly passwordHash?: PasswordHashOptions;
67
+ readonly breachCheck?: BreachCheckFn;
68
+ readonly now?: () => number;
69
+ }
70
+
71
+ /**
72
+ * Normalize email for lookup / uniqueness.
73
+ *
74
+ * @param email - Raw email
75
+ */
76
+ export function normalizeEmail(email: string): string {
77
+ return email.trim().toLowerCase();
78
+ }
79
+
80
+ /**
81
+ * Create a user with a credential account (provider `credential`).
82
+ *
83
+ * @param store - Identity store
84
+ * @param options - Email + password + profile
85
+ */
86
+ export async function createUserWithPassword(
87
+ store: IdentityStore,
88
+ options: CreateUserWithPasswordOptions,
89
+ ): Promise<UserIdentityRow> {
90
+ const email = normalizeEmail(options.email);
91
+ if (!email.includes("@")) {
92
+ throw new IdentityError("invalid_email", "invalid email");
93
+ }
94
+ if (store.byEmail.has(email)) {
95
+ throw new IdentityError("email_taken", "email already registered");
96
+ }
97
+ if (!options.skipPasswordPolicy) {
98
+ assertPasswordPolicy(options.password, options.passwordPolicy ?? {});
99
+ }
100
+ await assertNotBreached(options.password, options.breachCheck);
101
+
102
+ const crypto = createBunCrypto();
103
+ const now = options.now ?? (() => Date.now());
104
+ const t = now();
105
+ const id = options.id ?? crypto.randomUUID();
106
+ const user: UserIdentityRow = {
107
+ id,
108
+ email,
109
+ name: options.name?.trim() || email.split("@")[0] || "user",
110
+ emailVerified: options.emailVerified === true,
111
+ status: "active",
112
+ createdAt: t,
113
+ updatedAt: t,
114
+ extra: { ...(options.extra ?? {}) },
115
+ };
116
+ const accountId = crypto.randomUUID();
117
+ const account: UserAccountRow = {
118
+ id: accountId,
119
+ userId: id,
120
+ provider: "credential",
121
+ providerAccountId: email,
122
+ passwordHash: await crypto.hashPassword(
123
+ options.password,
124
+ options.passwordHash ?? { algorithm: "argon2id" },
125
+ ),
126
+ createdAt: t,
127
+ updatedAt: t,
128
+ };
129
+
130
+ store.users.set(id, user);
131
+ store.byEmail.set(email, id);
132
+ store.accounts.set(accountId, account);
133
+ store.byProvider.set(`credential:${email}`, accountId);
134
+ return user;
135
+ }
136
+
137
+ /**
138
+ * Authenticate email + password. Enumeration-safe: same timing path on miss.
139
+ *
140
+ * @param store - Identity store
141
+ * @param email - Submitted email
142
+ * @param password - Submitted password
143
+ */
144
+ export async function authenticateUser(
145
+ store: IdentityStore,
146
+ email: string,
147
+ password: string,
148
+ ): Promise<UserIdentityRow | null> {
149
+ const crypto = createBunCrypto();
150
+ const key = normalizeEmail(email);
151
+ const userId = store.byEmail.get(key);
152
+ const user = userId ? store.users.get(userId) : undefined;
153
+ const accountId = store.byProvider.get(`credential:${key}`);
154
+ const account = accountId ? store.accounts.get(accountId) : undefined;
155
+ const hash = account?.passwordHash ?? (await dummyHash(crypto));
156
+ const ok = await crypto.verifyPassword(password, hash);
157
+ if (!ok || !user || user.status !== "active") return null;
158
+ return user;
159
+ }
160
+
161
+ /**
162
+ * Look up a user by id.
163
+ *
164
+ * @param store - Identity store
165
+ * @param id - User id
166
+ */
167
+ export function getUserById(store: IdentityStore, id: string): UserIdentityRow | undefined {
168
+ return store.users.get(id);
169
+ }
170
+
171
+ /** Identity-plane error. */
172
+ export class IdentityError extends Error {
173
+ readonly code: string;
174
+
175
+ constructor(code: string, message: string) {
176
+ super(message);
177
+ this.name = "IdentityError";
178
+ this.code = code;
179
+ }
180
+ }
181
+
182
+ /** Dummy argon2id hash so missing-user paths pay a verify round-trip. */
183
+ let dummyPasswordHash: string | null = null;
184
+
185
+ async function dummyHash(crypto: ReturnType<typeof createBunCrypto>): Promise<string> {
186
+ if (dummyPasswordHash === null) {
187
+ dummyPasswordHash = await crypto.hashPassword("oke-user-timing-dummy", "argon2id");
188
+ }
189
+ return dummyPasswordHash;
190
+ }
package/src/auth/index.ts CHANGED
@@ -6,7 +6,122 @@
6
6
  * @module
7
7
  */
8
8
 
9
- export { auth, type AuthPluginOptions } from "./plugin.ts";
9
+ export {
10
+ auth,
11
+ sessionCryptoFromAuthOptions,
12
+ type AuthPluginOptions,
13
+ type AuthSessionOptions,
14
+ } from "./plugin.ts";
15
+
16
+ export {
17
+ resolveGateAuth,
18
+ mintDevAuthSecret,
19
+ normalizeBasePath,
20
+ DEFAULT_FRESH_AGE_MS,
21
+ type GateAuthOptions,
22
+ type GateAuthSessionOptions,
23
+ type EmailAndPasswordOptions,
24
+ type AccountLinkingOptions,
25
+ type AuthCookieOptions,
26
+ type AuthSecondaryStorageOptions,
27
+ type AuthDatabaseHooks,
28
+ type ResolvedGateAuth,
29
+ type ResolveGateAuthOptions,
30
+ } from "./config.ts";
31
+
32
+ export {
33
+ resolveAuthSchema,
34
+ authFieldSqlType,
35
+ AUTH_MODEL_DEFAULT_FIELDS,
36
+ AUTH_MODEL_DEFAULT_TABLES,
37
+ type AuthCoreModel,
38
+ type AuthFieldType,
39
+ type AuthAdditionalField,
40
+ type AuthModelOptions,
41
+ type AuthSchemaOptions,
42
+ type ResolvedAuthSchema,
43
+ type ResolvedAuthModel,
44
+ type ResolvedAuthColumn,
45
+ } from "./schema.ts";
46
+
47
+ export {
48
+ createAuthHttpBindings,
49
+ createAuthRateGates,
50
+ createAuthPolicyGates,
51
+ createFreshSessionGate,
52
+ isSessionFresh,
53
+ bindAuthHttp,
54
+ AUTH_SESSION_GATE,
55
+ AUTH_RATE_PRESETS,
56
+ type AuthHttpMaterialization,
57
+ type AuthRuntimeContext,
58
+ } from "./bindings.ts";
59
+
60
+ export {
61
+ setActiveGateAuthContext,
62
+ getActiveGateAuthContext,
63
+ type ActiveGateAuthContext,
64
+ } from "./method-context.ts";
65
+
66
+ export { createAuthSecondaryStorage, type AuthKv } from "./secondary-storage.ts";
67
+
68
+ export {
69
+ createVerificationStore,
70
+ putVerification,
71
+ findActiveVerification,
72
+ hashChallenge,
73
+ generateOtp,
74
+ type VerificationStore,
75
+ type VerificationRow,
76
+ } from "./verification.ts";
77
+
78
+ export {
79
+ createIdentityStore,
80
+ createUserWithPassword,
81
+ authenticateUser,
82
+ getUserById,
83
+ normalizeEmail,
84
+ IdentityError,
85
+ type IdentityStore,
86
+ type UserIdentityRow,
87
+ type UserAccountRow,
88
+ type CreateUserWithPasswordOptions,
89
+ } from "./identity.ts";
90
+
91
+ export {
92
+ authCookieNames,
93
+ buildAuthSetCookies,
94
+ clearAuthSetCookies,
95
+ tokenFromCookieHeader,
96
+ parseCookieHeader,
97
+ } from "./cookies.ts";
98
+
99
+ export {
100
+ touchRateLimit,
101
+ createLoginAttemptBag,
102
+ AUTH_RATE_LIMIT,
103
+ AUTH_RATE_WINDOW_MS,
104
+ type LoginAttemptBag,
105
+ } from "./rate.ts";
106
+
107
+ export {
108
+ assertPasswordPolicy,
109
+ resolvePasswordPolicy,
110
+ PasswordPolicyError,
111
+ DEFAULT_PASSWORD_MIN_LENGTH,
112
+ type PasswordPolicyOptions,
113
+ type ResolvedPasswordPolicy,
114
+ } from "./password-policy.ts";
115
+
116
+ export {
117
+ assertNotBreached,
118
+ createHibpBreachCheck,
119
+ sha1HexUpper,
120
+ BreachCheckError,
121
+ type BreachCheckFn,
122
+ type BreachCheckErrorMode,
123
+ type HibpBreachCheckOptions,
124
+ } from "./breach-check.ts";
10
125
 
11
126
  export {
12
127
  assertPlaneAccess,
@@ -63,6 +178,7 @@ export {
63
178
  rotateRefresh,
64
179
  verifyAccess,
65
180
  revokeFamily,
181
+ revokePrincipalSessions,
66
182
  bindSessionScopes,
67
183
  bindSessionAudience,
68
184
  ACCESS_TTL_MS,