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
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified OTP Gate auth method plugin — Tier 1 (provider-owned) or Tier 2
|
|
3
|
+
* (app-owned multi-channel). Replaces emailOtp() + phoneNumber().
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { constantTimeEqual } from "../auth/constant-time.ts";
|
|
7
|
+
import {
|
|
8
|
+
createIdentityStore,
|
|
9
|
+
ensureUserByEmail,
|
|
10
|
+
normalizeEmail,
|
|
11
|
+
type IdentityStore,
|
|
12
|
+
} from "../auth/identity.ts";
|
|
13
|
+
import type { OtpPluginConfig } from "../auth/otp-capability.ts";
|
|
14
|
+
import { sealOtp, unsealOtp } from "../auth/otp-seal.ts";
|
|
15
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
16
|
+
import {
|
|
17
|
+
consumeVerification,
|
|
18
|
+
createVerificationStore,
|
|
19
|
+
findActiveVerification,
|
|
20
|
+
generateOtp,
|
|
21
|
+
hashChallenge,
|
|
22
|
+
invalidateVerifications,
|
|
23
|
+
putVerification,
|
|
24
|
+
type OtpChannel,
|
|
25
|
+
type VerificationStore,
|
|
26
|
+
} from "../auth/verification.ts";
|
|
27
|
+
import { channel } from "../elements/channel.ts";
|
|
28
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
29
|
+
import {
|
|
30
|
+
AuthFailed,
|
|
31
|
+
AuthRateLimited,
|
|
32
|
+
SessionTokensOut,
|
|
33
|
+
bindPublicAuth,
|
|
34
|
+
createMethodRuntime,
|
|
35
|
+
fail,
|
|
36
|
+
flow,
|
|
37
|
+
resolveMethodSecret,
|
|
38
|
+
z,
|
|
39
|
+
type AuthMethodOptions,
|
|
40
|
+
} from "./auth/shared.ts";
|
|
41
|
+
|
|
42
|
+
const E164 = /^\+[1-9]\d{7,14}$/;
|
|
43
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
44
|
+
const DEFAULT_RESEND_COOLDOWN_MS = 60_000;
|
|
45
|
+
const MAX_ATTEMPTS = 5;
|
|
46
|
+
const DEFAULT_FROM = "OKE <no-reply@oke.local>";
|
|
47
|
+
const OTP_CHANNELS = ["sms", "whatsapp", "email"] as const;
|
|
48
|
+
|
|
49
|
+
/** Phone principal store (phone → userId). */
|
|
50
|
+
export interface PhoneStore {
|
|
51
|
+
readonly byPhone: Map<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create an empty phone → user map.
|
|
56
|
+
*/
|
|
57
|
+
export function createPhoneStore(): PhoneStore {
|
|
58
|
+
return { byPhone: new Map() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Shared base options. */
|
|
62
|
+
interface OtpBaseOptions extends AuthMethodOptions {
|
|
63
|
+
readonly ttlMs?: number;
|
|
64
|
+
readonly verifications?: VerificationStore;
|
|
65
|
+
readonly phones?: PhoneStore;
|
|
66
|
+
readonly identities?: IdentityStore;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tier 1 — provider-owned OTP via fx.sendOtp / fx.verifyOtp. */
|
|
70
|
+
export interface OtpTier1Options extends OtpBaseOptions {
|
|
71
|
+
readonly tier: 1;
|
|
72
|
+
/** Forbidden on Tier 1 — fail loud if set. */
|
|
73
|
+
readonly channels?: never;
|
|
74
|
+
/** Forbidden on Tier 1 — code never exists server-side. */
|
|
75
|
+
readonly exposeDevOtp?: never;
|
|
76
|
+
readonly resendCooldownMs?: never;
|
|
77
|
+
readonly from?: never;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Tier 2 — app-owned OTP with multi-channel delivery. */
|
|
81
|
+
export interface OtpTier2Options extends OtpBaseOptions {
|
|
82
|
+
readonly tier: 2;
|
|
83
|
+
/** Build-time preferred channel order (required). */
|
|
84
|
+
readonly channels: readonly OtpChannel[];
|
|
85
|
+
readonly resendCooldownMs?: number;
|
|
86
|
+
readonly exposeDevOtp?: boolean;
|
|
87
|
+
readonly from?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Options for {@link otp}. `tier` is mandatory — no auto-detect. */
|
|
91
|
+
export type OtpOptions = OtpTier1Options | OtpTier2Options;
|
|
92
|
+
|
|
93
|
+
/** Email OTP template (Tier 2). */
|
|
94
|
+
export const otpEmailTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-otp-email", {
|
|
95
|
+
description: "OTP sign-in code (email)",
|
|
96
|
+
schema: z.object({ email: z.string(), otp: z.string() }),
|
|
97
|
+
locales: ["en", "ar"],
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
/** SMS OTP template (Tier 2 — plain message, not provider Verify). */
|
|
101
|
+
export const otpSmsTemplate = channel.sms().template("auth-otp-sms", {
|
|
102
|
+
description: "OTP sign-in code (SMS)",
|
|
103
|
+
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
104
|
+
locales: ["en", "ar"],
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** WhatsApp OTP template (Tier 2). */
|
|
108
|
+
export const otpWhatsappTemplate = channel.whatsapp().template("auth-otp-whatsapp", {
|
|
109
|
+
description: "OTP sign-in code (WhatsApp)",
|
|
110
|
+
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
111
|
+
locales: ["en", "ar"],
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** Default EN/AR bodies for OTP templates. */
|
|
115
|
+
export const otpCatalog = {
|
|
116
|
+
"auth-otp-email": {
|
|
117
|
+
en: {
|
|
118
|
+
subject: "Your sign-in code",
|
|
119
|
+
text: "Your one-time sign-in code is: {{otp}}\n",
|
|
120
|
+
html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
|
|
121
|
+
},
|
|
122
|
+
ar: {
|
|
123
|
+
subject: "رمز تسجيل الدخول",
|
|
124
|
+
text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
|
|
125
|
+
html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
"auth-otp-sms": {
|
|
129
|
+
en: { text: "Your sign-in code is: {{otp}}" },
|
|
130
|
+
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
131
|
+
},
|
|
132
|
+
"auth-otp-whatsapp": {
|
|
133
|
+
en: { text: "Your sign-in code is: {{otp}}" },
|
|
134
|
+
ar: { text: "رمز تسجيل الدخول: {{otp}}" },
|
|
135
|
+
},
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
function assertOtpOptions(opts: OtpOptions): void {
|
|
139
|
+
if (opts.tier !== 1 && opts.tier !== 2) {
|
|
140
|
+
throw new Error("otp(): tier is required — set tier: 1 or tier: 2 (no auto-detect)");
|
|
141
|
+
}
|
|
142
|
+
if (opts.tier === 1) {
|
|
143
|
+
if ("channels" in opts && opts.channels !== undefined) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
"otp({ tier: 1 }): channels are forbidden — provider-owned OTP cannot resend via a different channel; use tier: 2 for multi-channel",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
if ("exposeDevOtp" in opts && (opts as { exposeDevOtp?: boolean }).exposeDevOtp === true) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"otp({ tier: 1 }): exposeDevOtp is forbidden — the code never exists server-side",
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const channels = opts.channels;
|
|
156
|
+
if (!channels || channels.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'otp({ tier: 2 }): channels is required — declare at least one of "sms" | "whatsapp" | "email"',
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
for (const ch of channels) {
|
|
162
|
+
if (!(OTP_CHANNELS as readonly string[]).includes(ch)) {
|
|
163
|
+
throw new Error(`otp({ tier: 2 }): unknown channel "${String(ch)}"`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function principalKey(phone: string | undefined, email: string | undefined): string | undefined {
|
|
169
|
+
if (phone) return phone;
|
|
170
|
+
if (email) return email;
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function pickRequestChannel(
|
|
175
|
+
channels: readonly OtpChannel[],
|
|
176
|
+
phone: string | undefined,
|
|
177
|
+
email: string | undefined,
|
|
178
|
+
override: OtpChannel | undefined,
|
|
179
|
+
): OtpChannel | undefined {
|
|
180
|
+
const order = override ? [override, ...channels.filter((c) => c !== override)] : channels;
|
|
181
|
+
for (const ch of order) {
|
|
182
|
+
if (ch === "email" && email) return ch;
|
|
183
|
+
if ((ch === "sms" || ch === "whatsapp") && phone) return ch;
|
|
184
|
+
}
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Unified OTP request + verify (+ Tier-2 resend).
|
|
190
|
+
*
|
|
191
|
+
* @param opts - Must include `tier: 1` or `tier: 2`
|
|
192
|
+
*/
|
|
193
|
+
export function otp(opts: OtpOptions): PluginDef {
|
|
194
|
+
assertOtpOptions(opts);
|
|
195
|
+
|
|
196
|
+
const runtime = createMethodRuntime(opts);
|
|
197
|
+
const secret = resolveMethodSecret(opts);
|
|
198
|
+
const phones = opts.phones ?? createPhoneStore();
|
|
199
|
+
const identities = opts.identities ?? createIdentityStore();
|
|
200
|
+
const verifications = opts.verifications ?? createVerificationStore();
|
|
201
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
202
|
+
const resendCooldownMs =
|
|
203
|
+
opts.tier === 2
|
|
204
|
+
? (opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS)
|
|
205
|
+
: DEFAULT_RESEND_COOLDOWN_MS;
|
|
206
|
+
const channels = opts.tier === 2 ? opts.channels : ([] as const);
|
|
207
|
+
const exposeDevOtp = opts.tier === 2 && opts.exposeDevOtp === true;
|
|
208
|
+
const from = opts.tier === 2 ? opts.from : undefined;
|
|
209
|
+
|
|
210
|
+
const emailTmpl =
|
|
211
|
+
from !== undefined
|
|
212
|
+
? channel.email({ from }).template("auth-otp-email", {
|
|
213
|
+
description: "OTP sign-in code (email)",
|
|
214
|
+
schema: z.object({ email: z.string(), otp: z.string() }),
|
|
215
|
+
locales: ["en", "ar"],
|
|
216
|
+
})
|
|
217
|
+
: otpEmailTemplate;
|
|
218
|
+
|
|
219
|
+
const templates = {
|
|
220
|
+
email: emailTmpl.name,
|
|
221
|
+
sms: otpSmsTemplate.name,
|
|
222
|
+
whatsapp: otpWhatsappTemplate.name,
|
|
223
|
+
} as const;
|
|
224
|
+
|
|
225
|
+
const configSnapshot: OtpPluginConfig =
|
|
226
|
+
opts.tier === 1
|
|
227
|
+
? { method: "otp", tier: 1 }
|
|
228
|
+
: { method: "otp", tier: 2, channels: [...channels] };
|
|
229
|
+
|
|
230
|
+
const requestOut = z.object({
|
|
231
|
+
ok: z.literal(true),
|
|
232
|
+
devOtp: z.string().optional(),
|
|
233
|
+
channel: z.enum(["sms", "whatsapp", "email"]).optional(),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
if (opts.tier === 1) {
|
|
237
|
+
const request = flow({
|
|
238
|
+
name: "auth.requestOtp",
|
|
239
|
+
unit: "auth",
|
|
240
|
+
plane: "user",
|
|
241
|
+
in: z.object({
|
|
242
|
+
phone: z.string().min(8),
|
|
243
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
244
|
+
}),
|
|
245
|
+
out: requestOut,
|
|
246
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
247
|
+
effects: { sends: ["sms-otp"] },
|
|
248
|
+
do: async (input, fx) => {
|
|
249
|
+
const phone = input.phone.trim();
|
|
250
|
+
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
251
|
+
const now = runtime.now();
|
|
252
|
+
const identifier = `otp:${phone}`;
|
|
253
|
+
invalidateVerifications(verifications, identifier, now);
|
|
254
|
+
|
|
255
|
+
const requestId = crypto.randomUUID();
|
|
256
|
+
await fx.sendOtp({
|
|
257
|
+
to: phone,
|
|
258
|
+
requestId,
|
|
259
|
+
...(input.lang ? { lang: input.lang } : {}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
putVerification(verifications, {
|
|
263
|
+
id: crypto.randomUUID(),
|
|
264
|
+
identifier,
|
|
265
|
+
value: `provider:${requestId}`,
|
|
266
|
+
expiresAt: now + ttlMs,
|
|
267
|
+
createdAt: now,
|
|
268
|
+
consumedAt: null,
|
|
269
|
+
attempts: 0,
|
|
270
|
+
phone,
|
|
271
|
+
sealedOtp: null,
|
|
272
|
+
});
|
|
273
|
+
// Tier 1: resend-via-different-channel is impossible — provider owns the code.
|
|
274
|
+
return { ok: true as const, channel: "sms" as const };
|
|
275
|
+
},
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const verify = flow({
|
|
279
|
+
name: "auth.verifyOtp",
|
|
280
|
+
unit: "auth",
|
|
281
|
+
plane: "user",
|
|
282
|
+
in: z.object({
|
|
283
|
+
phone: z.string().min(8),
|
|
284
|
+
otp: z.string().min(4).max(8),
|
|
285
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
286
|
+
}),
|
|
287
|
+
out: SessionTokensOut,
|
|
288
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
289
|
+
effects: { sends: ["sms-otp"] },
|
|
290
|
+
do: async (input, fx) => {
|
|
291
|
+
const phone = input.phone.trim();
|
|
292
|
+
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
293
|
+
const now = runtime.now();
|
|
294
|
+
const row = findActiveVerification(verifications, `otp:${phone}`, now);
|
|
295
|
+
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
296
|
+
if (row.attempts >= MAX_ATTEMPTS) {
|
|
297
|
+
consumeVerification(row, now);
|
|
298
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const prefix = row.value.startsWith("provider:")
|
|
302
|
+
? "provider:"
|
|
303
|
+
: row.value.startsWith("taqnyat:")
|
|
304
|
+
? "taqnyat:"
|
|
305
|
+
: null;
|
|
306
|
+
if (!prefix) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
307
|
+
const requestId = row.value.slice(prefix.length);
|
|
308
|
+
try {
|
|
309
|
+
await fx.verifyOtp({
|
|
310
|
+
to: phone,
|
|
311
|
+
requestId,
|
|
312
|
+
code: input.otp.trim(),
|
|
313
|
+
...(input.lang ? { lang: input.lang } : {}),
|
|
314
|
+
});
|
|
315
|
+
} catch {
|
|
316
|
+
row.attempts += 1;
|
|
317
|
+
if (row.attempts >= MAX_ATTEMPTS) consumeVerification(row, now);
|
|
318
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
319
|
+
}
|
|
320
|
+
consumeVerification(row, now);
|
|
321
|
+
|
|
322
|
+
let userId = phones.byPhone.get(phone);
|
|
323
|
+
if (!userId) {
|
|
324
|
+
userId = crypto.randomUUID();
|
|
325
|
+
phones.byPhone.set(phone, userId);
|
|
326
|
+
}
|
|
327
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
328
|
+
id: userId,
|
|
329
|
+
plane: "user",
|
|
330
|
+
scopes: [],
|
|
331
|
+
});
|
|
332
|
+
return {
|
|
333
|
+
accessToken: issued.accessToken,
|
|
334
|
+
refreshToken: issued.refreshToken,
|
|
335
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
336
|
+
userId,
|
|
337
|
+
};
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return plugin("otp", { version: "0.0.1", config: configSnapshot })
|
|
342
|
+
.needs("auth")
|
|
343
|
+
.binding(bindPublicAuth("/otp/request", request, "otp"))
|
|
344
|
+
.binding(bindPublicAuth("/otp/verify", verify, "otp"));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ── Tier 2 ────────────────────────────────────────────────────────────
|
|
348
|
+
const request = flow({
|
|
349
|
+
name: "auth.requestOtp",
|
|
350
|
+
unit: "auth",
|
|
351
|
+
plane: "user",
|
|
352
|
+
in: z.object({
|
|
353
|
+
email: z.string().min(3).optional(),
|
|
354
|
+
phone: z.string().min(8).optional(),
|
|
355
|
+
channel: z.enum(["sms", "whatsapp", "email"]).optional(),
|
|
356
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
357
|
+
}),
|
|
358
|
+
out: requestOut,
|
|
359
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
360
|
+
effects: { sends: ["auth-otp", "auth-otp-email", "auth-otp-sms", "auth-otp-whatsapp"] },
|
|
361
|
+
do: async (input, fx) => {
|
|
362
|
+
const email = input.email ? normalizeEmail(input.email) : undefined;
|
|
363
|
+
const phone = input.phone?.trim();
|
|
364
|
+
if (email !== undefined && !email.includes("@")) {
|
|
365
|
+
return fail("AuthFailed", { reason: "invalid_email" });
|
|
366
|
+
}
|
|
367
|
+
if (phone !== undefined && !E164.test(phone)) {
|
|
368
|
+
return fail("AuthFailed", { reason: "invalid_phone" });
|
|
369
|
+
}
|
|
370
|
+
const principal = principalKey(phone, email);
|
|
371
|
+
if (!principal) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
372
|
+
|
|
373
|
+
const deliverChannel = pickRequestChannel(channels, phone, email, input.channel);
|
|
374
|
+
if (!deliverChannel) {
|
|
375
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const otpCode = generateOtp(6);
|
|
379
|
+
const now = runtime.now();
|
|
380
|
+
const identifier = `otp:${principal}`;
|
|
381
|
+
invalidateVerifications(verifications, identifier, now);
|
|
382
|
+
|
|
383
|
+
const sealed = await sealOtp(secret, otpCode);
|
|
384
|
+
putVerification(verifications, {
|
|
385
|
+
id: crypto.randomUUID(),
|
|
386
|
+
identifier,
|
|
387
|
+
value: await hashChallenge(otpCode),
|
|
388
|
+
expiresAt: now + ttlMs,
|
|
389
|
+
createdAt: now,
|
|
390
|
+
consumedAt: null,
|
|
391
|
+
attempts: 0,
|
|
392
|
+
sealedOtp: sealed,
|
|
393
|
+
lastDeliveredAt: now,
|
|
394
|
+
lastChannel: deliverChannel,
|
|
395
|
+
email: email ?? null,
|
|
396
|
+
phone: phone ?? null,
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
const delivered = await fx.deliverOtp({
|
|
400
|
+
channels: [...channels],
|
|
401
|
+
templates,
|
|
402
|
+
...(email ? { email } : {}),
|
|
403
|
+
...(phone ? { phone } : {}),
|
|
404
|
+
data: { otp: otpCode, ...(email ? { email } : {}), ...(phone ? { phone } : {}) },
|
|
405
|
+
...(input.lang ? { locale: input.lang } : {}),
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
ok: true as const,
|
|
410
|
+
channel: delivered.channel,
|
|
411
|
+
...(exposeDevOtp ? { devOtp: otpCode } : {}),
|
|
412
|
+
};
|
|
413
|
+
},
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const resend = flow({
|
|
417
|
+
name: "auth.resendOtp",
|
|
418
|
+
unit: "auth",
|
|
419
|
+
plane: "user",
|
|
420
|
+
in: z.object({
|
|
421
|
+
email: z.string().min(3).optional(),
|
|
422
|
+
phone: z.string().min(8).optional(),
|
|
423
|
+
channel: z.enum(["sms", "whatsapp", "email"]),
|
|
424
|
+
lang: z.enum(["en", "ar"]).optional(),
|
|
425
|
+
}),
|
|
426
|
+
out: requestOut,
|
|
427
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
428
|
+
effects: { sends: ["auth-otp", "auth-otp-email", "auth-otp-sms", "auth-otp-whatsapp"] },
|
|
429
|
+
do: async (input, fx) => {
|
|
430
|
+
const email = input.email ? normalizeEmail(input.email) : undefined;
|
|
431
|
+
const phone = input.phone?.trim();
|
|
432
|
+
if (email !== undefined && !email.includes("@")) {
|
|
433
|
+
return fail("AuthFailed", { reason: "invalid_email" });
|
|
434
|
+
}
|
|
435
|
+
if (phone !== undefined && !E164.test(phone)) {
|
|
436
|
+
return fail("AuthFailed", { reason: "invalid_phone" });
|
|
437
|
+
}
|
|
438
|
+
const principal = principalKey(phone, email);
|
|
439
|
+
if (!principal) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
440
|
+
|
|
441
|
+
const now = runtime.now();
|
|
442
|
+
const row = findActiveVerification(verifications, `otp:${principal}`, now);
|
|
443
|
+
if (!row || !row.sealedOtp) {
|
|
444
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
445
|
+
}
|
|
446
|
+
if (row.lastDeliveredAt !== undefined && now - row.lastDeliveredAt < resendCooldownMs) {
|
|
447
|
+
return fail("AuthFailed", { reason: "resend_cooldown" });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const targetEmail = email ?? row.email ?? undefined;
|
|
451
|
+
const targetPhone = phone ?? row.phone ?? undefined;
|
|
452
|
+
if (input.channel === "email" && !targetEmail) {
|
|
453
|
+
return fail("AuthFailed", { reason: "invalid_email" });
|
|
454
|
+
}
|
|
455
|
+
if ((input.channel === "sms" || input.channel === "whatsapp") && !targetPhone) {
|
|
456
|
+
return fail("AuthFailed", { reason: "invalid_phone" });
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const otpCode = await unsealOtp(secret, row.sealedOtp);
|
|
460
|
+
const delivered = await fx.deliverOtp({
|
|
461
|
+
channels: [...channels],
|
|
462
|
+
templates,
|
|
463
|
+
...(targetEmail ? { email: targetEmail } : {}),
|
|
464
|
+
...(targetPhone ? { phone: targetPhone } : {}),
|
|
465
|
+
data: {
|
|
466
|
+
otp: otpCode,
|
|
467
|
+
...(targetEmail ? { email: targetEmail } : {}),
|
|
468
|
+
...(targetPhone ? { phone: targetPhone } : {}),
|
|
469
|
+
},
|
|
470
|
+
...(input.lang ? { locale: input.lang } : {}),
|
|
471
|
+
only: input.channel,
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
row.lastDeliveredAt = now;
|
|
475
|
+
row.lastChannel = delivered.channel;
|
|
476
|
+
if (targetEmail) row.email = targetEmail;
|
|
477
|
+
if (targetPhone) row.phone = targetPhone;
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
ok: true as const,
|
|
481
|
+
channel: delivered.channel,
|
|
482
|
+
...(exposeDevOtp ? { devOtp: otpCode } : {}),
|
|
483
|
+
};
|
|
484
|
+
},
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
const verify = flow({
|
|
488
|
+
name: "auth.verifyOtp",
|
|
489
|
+
unit: "auth",
|
|
490
|
+
plane: "user",
|
|
491
|
+
in: z.object({
|
|
492
|
+
email: z.string().min(3).optional(),
|
|
493
|
+
phone: z.string().min(8).optional(),
|
|
494
|
+
otp: z.string().min(4).max(8),
|
|
495
|
+
}),
|
|
496
|
+
out: SessionTokensOut,
|
|
497
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
498
|
+
do: async (input) => {
|
|
499
|
+
const email = input.email ? normalizeEmail(input.email) : undefined;
|
|
500
|
+
const phone = input.phone?.trim();
|
|
501
|
+
if (email !== undefined && !email.includes("@")) {
|
|
502
|
+
return fail("AuthFailed", { reason: "invalid_email" });
|
|
503
|
+
}
|
|
504
|
+
if (phone !== undefined && !E164.test(phone)) {
|
|
505
|
+
return fail("AuthFailed", { reason: "invalid_phone" });
|
|
506
|
+
}
|
|
507
|
+
const principal = principalKey(phone, email);
|
|
508
|
+
if (!principal) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
509
|
+
|
|
510
|
+
const now = runtime.now();
|
|
511
|
+
const row = findActiveVerification(verifications, `otp:${principal}`, now);
|
|
512
|
+
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
513
|
+
if (row.attempts >= MAX_ATTEMPTS) {
|
|
514
|
+
consumeVerification(row, now);
|
|
515
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const hash = await hashChallenge(input.otp.trim());
|
|
519
|
+
if (!constantTimeEqual(hash, row.value)) {
|
|
520
|
+
row.attempts += 1;
|
|
521
|
+
if (row.attempts >= MAX_ATTEMPTS) consumeVerification(row, now);
|
|
522
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
523
|
+
}
|
|
524
|
+
consumeVerification(row, now);
|
|
525
|
+
|
|
526
|
+
if (phone) {
|
|
527
|
+
let userId = phones.byPhone.get(phone);
|
|
528
|
+
if (!userId) {
|
|
529
|
+
userId = crypto.randomUUID();
|
|
530
|
+
phones.byPhone.set(phone, userId);
|
|
531
|
+
}
|
|
532
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
533
|
+
id: userId,
|
|
534
|
+
plane: "user",
|
|
535
|
+
scopes: [],
|
|
536
|
+
});
|
|
537
|
+
return {
|
|
538
|
+
accessToken: issued.accessToken,
|
|
539
|
+
refreshToken: issued.refreshToken,
|
|
540
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
541
|
+
userId,
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const user = ensureUserByEmail(identities, email!, now);
|
|
546
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
547
|
+
id: user.id,
|
|
548
|
+
plane: "user",
|
|
549
|
+
scopes: [],
|
|
550
|
+
});
|
|
551
|
+
return {
|
|
552
|
+
accessToken: issued.accessToken,
|
|
553
|
+
refreshToken: issued.refreshToken,
|
|
554
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
555
|
+
userId: user.id,
|
|
556
|
+
};
|
|
557
|
+
},
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
return plugin("otp", { version: "0.0.1", config: configSnapshot })
|
|
561
|
+
.needs("auth")
|
|
562
|
+
.needs("channel")
|
|
563
|
+
.channelTemplate(emailTmpl)
|
|
564
|
+
.channelTemplate(otpSmsTemplate)
|
|
565
|
+
.channelTemplate(otpWhatsappTemplate)
|
|
566
|
+
.channelCatalog(otpCatalog)
|
|
567
|
+
.binding(bindPublicAuth("/otp/request", request, "otp"))
|
|
568
|
+
.binding(bindPublicAuth("/otp/verify", verify, "otp"))
|
|
569
|
+
.binding(bindPublicAuth("/otp/resend", resend, "otp"));
|
|
570
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Real Taqnyat end-to-end:
|
|
2
|
+
* Real Taqnyat end-to-end: otp({ tier: 1 }) via Verify API + magic-link via Taqnyat Mail.
|
|
3
3
|
*
|
|
4
4
|
* Double-gated — runs ONLY when BOTH:
|
|
5
5
|
* 1. The global per-medium opt-in flag is set explicitly
|
|
@@ -34,7 +34,7 @@ import { oke } from "../kernel/app.ts";
|
|
|
34
34
|
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
35
35
|
import { resetBindings } from "../kernel/on.ts";
|
|
36
36
|
import { magicLink } from "./magic-link.ts";
|
|
37
|
-
import {
|
|
37
|
+
import { otp } from "./otp.ts";
|
|
38
38
|
|
|
39
39
|
const SECRET = "test-secret-at-least-16";
|
|
40
40
|
|
|
@@ -94,7 +94,7 @@ describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
|
|
|
94
94
|
config: {
|
|
95
95
|
drivers: { channel: { sms: { test: "taqnyat" } } },
|
|
96
96
|
},
|
|
97
|
-
}).plug(
|
|
97
|
+
}).plug(otp({ tier: 1 }));
|
|
98
98
|
await app.boot({ env: "test" });
|
|
99
99
|
|
|
100
100
|
// Wrap the live transport so the plugin's single real send also proves
|
|
@@ -117,9 +117,7 @@ describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
|
|
|
117
117
|
return result;
|
|
118
118
|
};
|
|
119
119
|
|
|
120
|
-
const res = await app.fetch(
|
|
121
|
-
jsonPost("/auth/phone/request", { phone: SMS_PHONE, lang: "en" }),
|
|
122
|
-
);
|
|
120
|
+
const res = await app.fetch(jsonPost("/auth/otp/request", { phone: SMS_PHONE, lang: "en" }));
|
|
123
121
|
const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
|
|
124
122
|
if (res.status !== 200) {
|
|
125
123
|
console.log("taqnyat sendOtp response", res.status, body);
|
|
@@ -33,8 +33,7 @@ export const OFFICIAL_PLUGIN_BUDGETS: readonly OfficialPluginBudget[] = [
|
|
|
33
33
|
{ name: "username", file: "username.ts", category: "auth" },
|
|
34
34
|
{ name: "anonymous", file: "anonymous.ts", category: "auth" },
|
|
35
35
|
{ name: "magicLink", file: "magic-link.ts", category: "auth" },
|
|
36
|
-
{ name: "
|
|
37
|
-
{ name: "phoneNumber", file: "phone-number.ts", category: "auth" },
|
|
36
|
+
{ name: "otp", file: "otp.ts", category: "auth" },
|
|
38
37
|
{ name: "twoFactor", file: "two-factor.ts", category: "auth" },
|
|
39
38
|
{ name: "passkey", file: "passkey.ts", category: "auth" },
|
|
40
39
|
{ name: "headers", file: "headers.ts", category: "security" },
|