okengine 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +3 -1
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +34 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +2 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -2
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/environment-variables.mdx +9 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +4 -0
- package/src/cli/load-config.ts +3 -0
- package/src/cli/registry.ts +1 -1
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +1 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +152 -114
- package/src/elements/channel.ts +12 -2
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +60 -4
- package/src/kernel/boot-bind/channel.ts +51 -0
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +18 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +5 -5
- package/src/kernel/fx.test.ts +3 -0
- package/src/kernel/fx.ts +49 -0
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +8 -0
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +19 -29
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +4 -6
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -172
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -206
|
@@ -14,7 +14,7 @@ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
|
|
|
14
14
|
import { oke } from "../kernel/app.ts";
|
|
15
15
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
16
16
|
import { resetBindings } from "../kernel/on.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { otp } from "./otp.ts";
|
|
18
18
|
import { magicLink } from "./magic-link.ts";
|
|
19
19
|
|
|
20
20
|
const SECRET = "test-secret-at-least-16";
|
|
@@ -290,21 +290,21 @@ describe("auth delivery — Mailpit integration", () => {
|
|
|
290
290
|
config: {
|
|
291
291
|
drivers: { channel: { email: { test: "smtp" } } },
|
|
292
292
|
},
|
|
293
|
-
}).plug(
|
|
293
|
+
}).plug(otp({ tier: 2, channels: ["email"], exposeDevOtp: true }));
|
|
294
294
|
await app.boot({ env: "test" });
|
|
295
295
|
|
|
296
|
-
const res = await app.fetch(jsonPost("/auth/
|
|
296
|
+
const res = await app.fetch(jsonPost("/auth/otp/request", { email }));
|
|
297
297
|
expect(res.status).toBe(200);
|
|
298
298
|
const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
|
|
299
299
|
expect(body.data.ok).toBe(true);
|
|
300
300
|
expect(body.data.devOtp).toMatch(/^\d{6}$/);
|
|
301
|
-
const
|
|
301
|
+
const code = body.data.devOtp!;
|
|
302
302
|
|
|
303
303
|
const msg = await waitForMessage(uiUrl, email);
|
|
304
304
|
expect(msg.Subject).toBe("Your sign-in code");
|
|
305
305
|
const text = msg.Text ?? "";
|
|
306
306
|
const html = msg.HTML ?? "";
|
|
307
|
-
expect(text.includes(
|
|
307
|
+
expect(text.includes(code) || html.includes(code)).toBe(true);
|
|
308
308
|
|
|
309
309
|
await app.stop();
|
|
310
310
|
} finally {
|
|
@@ -12,11 +12,10 @@ import { oke } from "../kernel/app.ts";
|
|
|
12
12
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
13
13
|
import { resetBindings } from "../kernel/on.ts";
|
|
14
14
|
import { anonymous } from "./anonymous.ts";
|
|
15
|
-
import { emailOtp } from "./email-otp.ts";
|
|
16
15
|
import { magicLink } from "./magic-link.ts";
|
|
16
|
+
import { otp } from "./otp.ts";
|
|
17
17
|
import { passkey } from "./passkey.ts";
|
|
18
18
|
import { b64urlEncode, buildAuthenticatorData, signWebAuthnAssertion } from "./passkey-webauthn.ts";
|
|
19
|
-
import { phoneNumber } from "./phone-number.ts";
|
|
20
19
|
import { twoFactor, verifyTotp, createTwoFactorStore } from "./two-factor.ts";
|
|
21
20
|
import { username } from "./username.ts";
|
|
22
21
|
|
|
@@ -51,8 +50,7 @@ function fullAuthApp() {
|
|
|
51
50
|
.plug(username())
|
|
52
51
|
.plug(anonymous())
|
|
53
52
|
.plug(magicLink({ exposeDevToken: true }))
|
|
54
|
-
.plug(
|
|
55
|
-
.plug(phoneNumber({ exposeDevOtp: true }))
|
|
53
|
+
.plug(otp({ tier: 2, channels: ["email", "sms"], exposeDevOtp: true }))
|
|
56
54
|
.plug(twoFactor())
|
|
57
55
|
.plug(passkey({ origins: ["http://localhost"] }));
|
|
58
56
|
}
|
|
@@ -141,8 +139,7 @@ describe("auth methods — rate limiting / enumeration", () => {
|
|
|
141
139
|
|
|
142
140
|
const paths: Array<{ path: string; body: unknown }> = [
|
|
143
141
|
{ path: "/auth/magic-link/request", body: { email: "r@example.com" } },
|
|
144
|
-
{ path: "/auth/
|
|
145
|
-
{ path: "/auth/phone/request", body: { phone: "+15551234567" } },
|
|
142
|
+
{ path: "/auth/otp/request", body: { email: "r@example.com" } },
|
|
146
143
|
{ path: "/auth/passkey/authenticate/options", body: {} },
|
|
147
144
|
];
|
|
148
145
|
|
|
@@ -225,14 +222,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
|
|
|
225
222
|
const app = fullAuthApp();
|
|
226
223
|
await app.boot({ env: "test" });
|
|
227
224
|
|
|
228
|
-
const req = await app.fetch(jsonPost("/auth/
|
|
225
|
+
const req = await app.fetch(jsonPost("/auth/otp/request", { email: "otp@example.com" }));
|
|
229
226
|
const { data } = (await req.json()) as { data: { devOtp: string } };
|
|
230
227
|
const first = await app.fetch(
|
|
231
|
-
jsonPost("/auth/
|
|
228
|
+
jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
|
|
232
229
|
);
|
|
233
230
|
expect(first.status).toBe(200);
|
|
234
231
|
const reuse = await app.fetch(
|
|
235
|
-
jsonPost("/auth/
|
|
232
|
+
jsonPost("/auth/otp/verify", { email: "otp@example.com", otp: data.devOtp }),
|
|
236
233
|
);
|
|
237
234
|
expect((await readError(reuse)).reason).toBe("invalid_credentials");
|
|
238
235
|
|
|
@@ -243,14 +240,14 @@ describe("auth methods — single-use OTP / token / backup codes", () => {
|
|
|
243
240
|
const app = fullAuthApp();
|
|
244
241
|
await app.boot({ env: "test" });
|
|
245
242
|
|
|
246
|
-
const req = await app.fetch(jsonPost("/auth/
|
|
243
|
+
const req = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15559876543" }));
|
|
247
244
|
const { data } = (await req.json()) as { data: { devOtp: string } };
|
|
248
245
|
const first = await app.fetch(
|
|
249
|
-
jsonPost("/auth/
|
|
246
|
+
jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
|
|
250
247
|
);
|
|
251
248
|
expect(first.status).toBe(200);
|
|
252
249
|
const reuse = await app.fetch(
|
|
253
|
-
jsonPost("/auth/
|
|
250
|
+
jsonPost("/auth/otp/verify", { phone: "+15559876543", otp: data.devOtp }),
|
|
254
251
|
);
|
|
255
252
|
expect((await readError(reuse)).reason).toBe("invalid_credentials");
|
|
256
253
|
|
|
@@ -421,7 +418,7 @@ describe("auth methods — anonymous non-escalation", () => {
|
|
|
421
418
|
});
|
|
422
419
|
|
|
423
420
|
describe("auth methods — channel delivery", () => {
|
|
424
|
-
test("magic
|
|
421
|
+
test("magic uses fx.send; otp Tier 2 uses fx.deliverOtp; exposeDev* stays off by default", async () => {
|
|
425
422
|
resetBindings();
|
|
426
423
|
resetFlowSeq();
|
|
427
424
|
const app = oke({
|
|
@@ -431,8 +428,7 @@ describe("auth methods — channel delivery", () => {
|
|
|
431
428
|
gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
|
|
432
429
|
})
|
|
433
430
|
.plug(magicLink())
|
|
434
|
-
.plug(
|
|
435
|
-
.plug(phoneNumber());
|
|
431
|
+
.plug(otp({ tier: 2, channels: ["email", "sms"] }));
|
|
436
432
|
await app.boot({ env: "test" });
|
|
437
433
|
|
|
438
434
|
const ml = await app.fetch(jsonPost("/auth/magic-link/request", { email: "x@example.com" }));
|
|
@@ -440,30 +436,24 @@ describe("auth methods — channel delivery", () => {
|
|
|
440
436
|
expect(mlBody.data.ok).toBe(true);
|
|
441
437
|
expect(mlBody.data.devToken).toBeUndefined();
|
|
442
438
|
|
|
443
|
-
const
|
|
444
|
-
const otpBody = (await
|
|
439
|
+
const otpRes = await app.fetch(jsonPost("/auth/otp/request", { email: "x@example.com" }));
|
|
440
|
+
const otpBody = (await otpRes.json()) as { data: Record<string, unknown> };
|
|
445
441
|
expect(otpBody.data.ok).toBe(true);
|
|
446
442
|
expect(otpBody.data.devOtp).toBeUndefined();
|
|
447
443
|
|
|
448
|
-
|
|
449
|
-
const phone = await app.fetch(jsonPost("/auth/phone/request", { phone: "+15551112222" }));
|
|
444
|
+
const phone = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551112222" }));
|
|
450
445
|
const phoneBody = (await phone.json()) as { data: Record<string, unknown> };
|
|
451
446
|
expect(phoneBody.data.ok).toBe(true);
|
|
452
447
|
expect(phoneBody.data.devOtp).toBeUndefined();
|
|
453
448
|
|
|
454
449
|
const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
|
|
455
|
-
const
|
|
456
|
-
const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
|
|
450
|
+
const otpSrc = await Bun.file(new URL("./otp.ts", import.meta.url)).text();
|
|
457
451
|
expect(magicSrc).toMatch(/fx\.send\(/);
|
|
458
452
|
expect(magicSrc).toMatch(/channel\./);
|
|
459
|
-
expect(
|
|
460
|
-
expect(
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
// formatter line wraps between `fx` and the method name.
|
|
464
|
-
expect(phoneSrc).toMatch(/fx\s*\.\s*sendOtp/);
|
|
465
|
-
expect(phoneSrc).toMatch(/fx\s*\.\s*verifyOtp/);
|
|
466
|
-
expect(phoneSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
|
|
453
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*deliverOtp/);
|
|
454
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*sendOtp/);
|
|
455
|
+
expect(otpSrc).toMatch(/fx\s*\.\s*verifyOtp/);
|
|
456
|
+
expect(otpSrc).not.toMatch(/taqnyat-sms|sently\/transports/);
|
|
467
457
|
|
|
468
458
|
await app.stop();
|
|
469
459
|
});
|
|
@@ -7,8 +7,8 @@ import { oke } from "../kernel/app.ts";
|
|
|
7
7
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
8
8
|
import { resetBindings } from "../kernel/on.ts";
|
|
9
9
|
import { anonymous } from "./anonymous.ts";
|
|
10
|
-
import { emailOtp } from "./email-otp.ts";
|
|
11
10
|
import { magicLink } from "./magic-link.ts";
|
|
11
|
+
import { otp } from "./otp.ts";
|
|
12
12
|
import { username } from "./username.ts";
|
|
13
13
|
|
|
14
14
|
afterEach(() => {
|
|
@@ -31,7 +31,7 @@ function authApp() {
|
|
|
31
31
|
.plug(username())
|
|
32
32
|
.plug(anonymous())
|
|
33
33
|
.plug(magicLink({ exposeDevToken: true }))
|
|
34
|
-
.plug(
|
|
34
|
+
.plug(otp({ tier: 2, channels: ["email"], exposeDevOtp: true }));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
describe("auth method plugins", () => {
|
|
@@ -42,8 +42,9 @@ describe("auth method plugins", () => {
|
|
|
42
42
|
expect(app.router.match("POST", "/auth/sign-in/anonymous")).toBeTruthy();
|
|
43
43
|
expect(app.router.match("POST", "/auth/magic-link/request")).toBeTruthy();
|
|
44
44
|
expect(app.router.match("POST", "/auth/magic-link/verify")).toBeTruthy();
|
|
45
|
-
expect(app.router.match("POST", "/auth/
|
|
46
|
-
expect(app.router.match("POST", "/auth/
|
|
45
|
+
expect(app.router.match("POST", "/auth/otp/request")).toBeTruthy();
|
|
46
|
+
expect(app.router.match("POST", "/auth/otp/verify")).toBeTruthy();
|
|
47
|
+
expect(app.router.match("POST", "/auth/otp/resend")).toBeTruthy();
|
|
47
48
|
await app.boot({ env: "test" });
|
|
48
49
|
await app.stop();
|
|
49
50
|
});
|
|
@@ -147,7 +148,7 @@ describe("auth method plugins", () => {
|
|
|
147
148
|
await app.boot({ env: "test" });
|
|
148
149
|
|
|
149
150
|
const req = await app.fetch(
|
|
150
|
-
new Request("http://localhost/auth/
|
|
151
|
+
new Request("http://localhost/auth/otp/request", {
|
|
151
152
|
method: "POST",
|
|
152
153
|
headers: { "content-type": "application/json" },
|
|
153
154
|
body: JSON.stringify({ email: "otp@example.com" }),
|
|
@@ -158,7 +159,7 @@ describe("auth method plugins", () => {
|
|
|
158
159
|
expect(requested.data.devOtp).toMatch(/^\d{6}$/);
|
|
159
160
|
|
|
160
161
|
const verify = await app.fetch(
|
|
161
|
-
new Request("http://localhost/auth/
|
|
162
|
+
new Request("http://localhost/auth/otp/verify", {
|
|
162
163
|
method: "POST",
|
|
163
164
|
headers: { "content-type": "application/json" },
|
|
164
165
|
body: JSON.stringify({
|
package/src/plugins/index.ts
CHANGED
|
@@ -15,8 +15,6 @@ export {
|
|
|
15
15
|
type ConfigSourceOptions,
|
|
16
16
|
} from "./config-source.ts";
|
|
17
17
|
export { cors, type CorsOptions } from "./cors.ts";
|
|
18
|
-
export { csrf, type CsrfOptions } from "./csrf.ts";
|
|
19
|
-
export { emailOtp, emailOtpCatalog, emailOtpTemplate, type EmailOtpOptions } from "./email-otp.ts";
|
|
20
18
|
export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
|
|
21
19
|
export {
|
|
22
20
|
magicLink,
|
|
@@ -25,6 +23,18 @@ export {
|
|
|
25
23
|
type MagicLinkOptions,
|
|
26
24
|
} from "./magic-link.ts";
|
|
27
25
|
export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
|
|
26
|
+
export {
|
|
27
|
+
createPhoneStore,
|
|
28
|
+
otp,
|
|
29
|
+
otpCatalog,
|
|
30
|
+
otpEmailTemplate,
|
|
31
|
+
otpSmsTemplate,
|
|
32
|
+
otpWhatsappTemplate,
|
|
33
|
+
type OtpOptions,
|
|
34
|
+
type OtpTier1Options,
|
|
35
|
+
type OtpTier2Options,
|
|
36
|
+
type PhoneStore,
|
|
37
|
+
} from "./otp.ts";
|
|
28
38
|
export {
|
|
29
39
|
createPasskeyStore,
|
|
30
40
|
passkey,
|
|
@@ -40,12 +50,6 @@ export {
|
|
|
40
50
|
verifyWebAuthnCeremony,
|
|
41
51
|
type WebAuthnVerifyOptions,
|
|
42
52
|
} from "./passkey-webauthn.ts";
|
|
43
|
-
export {
|
|
44
|
-
createPhoneStore,
|
|
45
|
-
phoneNumber,
|
|
46
|
-
type PhoneNumberOptions,
|
|
47
|
-
type PhoneStore,
|
|
48
|
-
} from "./phone-number.ts";
|
|
49
53
|
export {
|
|
50
54
|
defaultCspDirectives,
|
|
51
55
|
headers,
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
createIdentityStore,
|
|
11
|
+
ensureUserByEmail,
|
|
11
12
|
normalizeEmail,
|
|
12
13
|
type IdentityStore,
|
|
13
|
-
type UserIdentityRow,
|
|
14
14
|
} from "../auth/identity.ts";
|
|
15
15
|
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
16
16
|
import {
|
|
@@ -199,25 +199,3 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
|
|
|
199
199
|
.binding(bindPublicAuth("/magic-link/request", request, "otp"))
|
|
200
200
|
.binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
|
|
201
201
|
}
|
|
202
|
-
|
|
203
|
-
function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
|
|
204
|
-
const existingId = store.byEmail.get(email);
|
|
205
|
-
if (existingId) {
|
|
206
|
-
const existing = store.users.get(existingId);
|
|
207
|
-
if (existing) return existing;
|
|
208
|
-
}
|
|
209
|
-
const id = crypto.randomUUID();
|
|
210
|
-
const user: UserIdentityRow = {
|
|
211
|
-
id,
|
|
212
|
-
email,
|
|
213
|
-
name: email.split("@")[0] || "user",
|
|
214
|
-
emailVerified: true,
|
|
215
|
-
status: "active",
|
|
216
|
-
createdAt: now,
|
|
217
|
-
updatedAt: now,
|
|
218
|
-
extra: {},
|
|
219
|
-
};
|
|
220
|
-
store.users.set(id, user);
|
|
221
|
-
store.byEmail.set(email, id);
|
|
222
|
-
return user;
|
|
223
|
-
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* otp() plugin — plug-time fail-loud, Tier 2 resend / sealed lifetime, Tier 1 boot.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
createVerificationStore,
|
|
8
|
+
findActiveVerification,
|
|
9
|
+
type VerificationStore,
|
|
10
|
+
} from "../auth/verification.ts";
|
|
11
|
+
import { openConsoleChannel } from "../drivers/channel-console.ts";
|
|
12
|
+
import type { ChannelDriver, SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
|
|
13
|
+
import { oke } from "../kernel/app.ts";
|
|
14
|
+
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
15
|
+
import { resetBindings } from "../kernel/on.ts";
|
|
16
|
+
import { otp } from "./otp.ts";
|
|
17
|
+
|
|
18
|
+
afterEach(() => {
|
|
19
|
+
resetBindings();
|
|
20
|
+
resetFlowSeq();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const SECRET = "test-secret-at-least-16";
|
|
24
|
+
|
|
25
|
+
function jsonPost(path: string, body: unknown): Request {
|
|
26
|
+
return new Request(`http://localhost${path}`, {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers: { "content-type": "application/json" },
|
|
29
|
+
body: JSON.stringify(body),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function mockOtpSmsDriver(): ChannelDriver {
|
|
34
|
+
const codes = new Map<string, string>();
|
|
35
|
+
const otpTransport: SmsTransport & SmsOtpTransport = {
|
|
36
|
+
provider: "mock-otp",
|
|
37
|
+
async send() {
|
|
38
|
+
return { messageId: "m", to: "+", status: "ok", response: "ok" };
|
|
39
|
+
},
|
|
40
|
+
async sendOtp(opts) {
|
|
41
|
+
codes.set(opts.requestId, "pending");
|
|
42
|
+
return {
|
|
43
|
+
requestId: opts.requestId,
|
|
44
|
+
to: opts.to,
|
|
45
|
+
code: 5,
|
|
46
|
+
response: "ok",
|
|
47
|
+
provider: "mock-otp",
|
|
48
|
+
};
|
|
49
|
+
},
|
|
50
|
+
async verifyOtp(opts) {
|
|
51
|
+
if (!codes.has(opts.requestId)) throw new Error("unknown request");
|
|
52
|
+
return { ok: true as const, code: 10, message: "ok", response: "ok", provider: "mock-otp" };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
id: "taqnyat",
|
|
57
|
+
smsTransport: otpTransport,
|
|
58
|
+
channel: {
|
|
59
|
+
provider: "mock-otp",
|
|
60
|
+
mediums: ["sms"],
|
|
61
|
+
async send() {
|
|
62
|
+
return {
|
|
63
|
+
ok: true,
|
|
64
|
+
messageId: "m",
|
|
65
|
+
driverId: "mock-otp",
|
|
66
|
+
attempts: [{ driverId: "mock-otp", ok: true, at: Date.now() }],
|
|
67
|
+
};
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe("otp() plug-time fail-loud", () => {
|
|
74
|
+
test("missing tier throws", () => {
|
|
75
|
+
expect(() => otp({} as never)).toThrow(/tier is required/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("tier 1 with channels throws", () => {
|
|
79
|
+
expect(() => otp({ tier: 1, channels: ["sms"] } as never)).toThrow(/channels are forbidden/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("tier 1 with exposeDevOtp throws", () => {
|
|
83
|
+
expect(() => otp({ tier: 1, exposeDevOtp: true } as never)).toThrow(
|
|
84
|
+
/exposeDevOtp is forbidden/,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("tier 2 without channels throws", () => {
|
|
89
|
+
expect(() => otp({ tier: 2 } as never)).toThrow(/channels is required/);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe("otp() Tier 1 boot", () => {
|
|
94
|
+
test("fails loud without Verify-capable SMS driver", async () => {
|
|
95
|
+
const app = oke({
|
|
96
|
+
name: `otp-t1-${crypto.randomUUID()}`,
|
|
97
|
+
env: "test",
|
|
98
|
+
registry: "ignore",
|
|
99
|
+
gate: { auth: { secret: SECRET } },
|
|
100
|
+
}).plug(otp({ tier: 1 }));
|
|
101
|
+
await expect(app.boot({ env: "test" })).rejects.toThrow(/tier: 1/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("boots when Verify-capable SMS driver is bound", async () => {
|
|
105
|
+
const app = oke({
|
|
106
|
+
name: `otp-t1-ok-${crypto.randomUUID()}`,
|
|
107
|
+
env: "test",
|
|
108
|
+
registry: "ignore",
|
|
109
|
+
gate: { auth: { secret: SECRET } },
|
|
110
|
+
channel: { drivers: [mockOtpSmsDriver()] },
|
|
111
|
+
}).plug(otp({ tier: 1 }));
|
|
112
|
+
await app.boot({ env: "test" });
|
|
113
|
+
const res = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551234567" }));
|
|
114
|
+
expect(res.status).toBe(200);
|
|
115
|
+
const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
|
|
116
|
+
expect(body.data.ok).toBe(true);
|
|
117
|
+
expect(body.data.devOtp).toBeUndefined();
|
|
118
|
+
expect(app.router.match("POST", "/auth/otp/resend")).toBeFalsy();
|
|
119
|
+
await app.stop();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("otp() Tier 2 resend + sealed lifetime", () => {
|
|
124
|
+
test("cross-channel resend keeps same code; cooldown then verify wipes seal", async () => {
|
|
125
|
+
let now = 1_000_000;
|
|
126
|
+
const verifications: VerificationStore = createVerificationStore();
|
|
127
|
+
const app = oke({
|
|
128
|
+
name: `otp-t2-${crypto.randomUUID()}`,
|
|
129
|
+
env: "test",
|
|
130
|
+
registry: "ignore",
|
|
131
|
+
gate: { auth: { secret: SECRET } },
|
|
132
|
+
channel: { drivers: [openConsoleChannel()] },
|
|
133
|
+
fx: { now: () => now },
|
|
134
|
+
}).plug(
|
|
135
|
+
otp({
|
|
136
|
+
tier: 2,
|
|
137
|
+
channels: ["sms", "email"],
|
|
138
|
+
exposeDevOtp: true,
|
|
139
|
+
resendCooldownMs: 60_000,
|
|
140
|
+
verifications,
|
|
141
|
+
now: () => now,
|
|
142
|
+
secret: SECRET,
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
await app.boot({ env: "test" });
|
|
146
|
+
|
|
147
|
+
const req = await app.fetch(
|
|
148
|
+
jsonPost("/auth/otp/request", {
|
|
149
|
+
phone: "+15551234567",
|
|
150
|
+
email: "a@example.com",
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
expect(req.status).toBe(200);
|
|
154
|
+
const requested = (await req.json()) as {
|
|
155
|
+
data: { ok: true; devOtp: string; channel: string };
|
|
156
|
+
};
|
|
157
|
+
expect(requested.data.devOtp).toMatch(/^\d{6}$/);
|
|
158
|
+
const code = requested.data.devOtp;
|
|
159
|
+
const expiresAt = findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt;
|
|
160
|
+
expect(expiresAt).toBeDefined();
|
|
161
|
+
expect(findActiveVerification(verifications, "otp:+15551234567", now)?.sealedOtp).toBeTruthy();
|
|
162
|
+
|
|
163
|
+
const cool = await app.fetch(
|
|
164
|
+
jsonPost("/auth/otp/resend", {
|
|
165
|
+
phone: "+15551234567",
|
|
166
|
+
email: "a@example.com",
|
|
167
|
+
channel: "email",
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
const coolBody = (await cool.json()) as { error?: { data?: { reason?: string } } };
|
|
171
|
+
expect(coolBody.error?.data?.reason).toBe("resend_cooldown");
|
|
172
|
+
|
|
173
|
+
now += 60_001;
|
|
174
|
+
const resent = await app.fetch(
|
|
175
|
+
jsonPost("/auth/otp/resend", {
|
|
176
|
+
phone: "+15551234567",
|
|
177
|
+
email: "a@example.com",
|
|
178
|
+
channel: "email",
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(resent.status).toBe(200);
|
|
182
|
+
const resentBody = (await resent.json()) as { data: { channel: string; devOtp?: string } };
|
|
183
|
+
expect(resentBody.data.channel).toBe("email");
|
|
184
|
+
expect(resentBody.data.devOtp).toBe(code);
|
|
185
|
+
expect(findActiveVerification(verifications, "otp:+15551234567", now)?.expiresAt).toBe(
|
|
186
|
+
expiresAt,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const verify = await app.fetch(
|
|
190
|
+
jsonPost("/auth/otp/verify", { phone: "+15551234567", otp: code }),
|
|
191
|
+
);
|
|
192
|
+
expect(verify.status).toBe(200);
|
|
193
|
+
|
|
194
|
+
const row = [...verifications.rows.values()].find((r) => r.identifier === "otp:+15551234567");
|
|
195
|
+
expect(row?.sealedOtp).toBeNull();
|
|
196
|
+
expect(row?.consumedAt).toBeTruthy();
|
|
197
|
+
|
|
198
|
+
await app.stop();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("expired challenge wipes sealed OTP", async () => {
|
|
202
|
+
let now = 1_000_000;
|
|
203
|
+
const verifications = createVerificationStore();
|
|
204
|
+
const app = oke({
|
|
205
|
+
name: `otp-ttl-${crypto.randomUUID()}`,
|
|
206
|
+
env: "test",
|
|
207
|
+
registry: "ignore",
|
|
208
|
+
gate: { auth: { secret: SECRET } },
|
|
209
|
+
fx: { now: () => now },
|
|
210
|
+
}).plug(
|
|
211
|
+
otp({
|
|
212
|
+
tier: 2,
|
|
213
|
+
channels: ["email"],
|
|
214
|
+
exposeDevOtp: true,
|
|
215
|
+
ttlMs: 1_000,
|
|
216
|
+
verifications,
|
|
217
|
+
now: () => now,
|
|
218
|
+
secret: SECRET,
|
|
219
|
+
}),
|
|
220
|
+
);
|
|
221
|
+
await app.boot({ env: "test" });
|
|
222
|
+
|
|
223
|
+
await app.fetch(jsonPost("/auth/otp/request", { email: "ttl@example.com" }));
|
|
224
|
+
const active = findActiveVerification(verifications, "otp:ttl@example.com", now);
|
|
225
|
+
expect(active?.sealedOtp).toBeTruthy();
|
|
226
|
+
|
|
227
|
+
now += 2_000;
|
|
228
|
+
expect(findActiveVerification(verifications, "otp:ttl@example.com", now)).toBeUndefined();
|
|
229
|
+
const row = [...verifications.rows.values()].find(
|
|
230
|
+
(r) => r.identifier === "otp:ttl@example.com",
|
|
231
|
+
);
|
|
232
|
+
expect(row?.sealedOtp).toBeNull();
|
|
233
|
+
|
|
234
|
+
await app.stop();
|
|
235
|
+
});
|
|
236
|
+
});
|