okengine 0.8.0 → 0.9.1
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 +10 -5
- 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 +214 -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 +238 -0
- package/src/plugins/otp.ts +572 -0
- package/src/plugins/taqnyat.live.test.ts +5 -7
- 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
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Email OTP"
|
|
3
|
-
description: "Official plugin — six-digit email one-time codes to sign in under /auth."
|
|
4
|
-
icon: "MailCheck"
|
|
5
|
-
source: "docs/spec/unified-theory.md"
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
`emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts). Request sends the
|
|
9
|
-
code via Channel (`auth-email-otp`); verify creates the user on first success and returns hybrid
|
|
10
|
-
session tokens.
|
|
11
|
-
|
|
12
|
-
<Callout title="The one rule">
|
|
13
|
-
Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs. Delivery goes through `fx.send`;
|
|
14
|
-
use `exposeDevOtp` only for local DX without SMTP.
|
|
15
|
-
</Callout>
|
|
16
|
-
|
|
17
|
-
## Quick start
|
|
18
|
-
|
|
19
|
-
<Steps>
|
|
20
|
-
|
|
21
|
-
<Step>
|
|
22
|
-
### Plug it
|
|
23
|
-
|
|
24
|
-
```typescript title="src/app.ts"
|
|
25
|
-
import { oke } from "okengine";
|
|
26
|
-
import { emailOtp } from "okengine/plugins";
|
|
27
|
-
|
|
28
|
-
export const app = oke({
|
|
29
|
-
name: "shop",
|
|
30
|
-
env: "local",
|
|
31
|
-
gate: { auth: {} },
|
|
32
|
-
}).plug(emailOtp());
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
</Step>
|
|
36
|
-
|
|
37
|
-
<Step>
|
|
38
|
-
### Request a code
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
|
|
42
|
-
// Channel delivers auth-email-otp; data.devOtp only when exposeDevOtp
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`POST /auth/email-otp/request` — prior active codes for that email are invalidated.
|
|
46
|
-
|
|
47
|
-
</Step>
|
|
48
|
-
|
|
49
|
-
<Step>
|
|
50
|
-
### Verify
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
const { data } = await api.auth.verifyEmailOtp({
|
|
54
|
-
email: "ali@example.com",
|
|
55
|
-
otp,
|
|
56
|
-
});
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
`POST /auth/email-otp/verify`. Wrong code increments attempts; after 5 →
|
|
60
|
-
`invalid_credentials`.
|
|
61
|
-
|
|
62
|
-
</Step>
|
|
63
|
-
|
|
64
|
-
</Steps>
|
|
65
|
-
|
|
66
|
-
## Options
|
|
67
|
-
|
|
68
|
-
| Option | Type | Default | Meaning |
|
|
69
|
-
| --------------- | ------------------- | -------------------------- | --------------------------------------- |
|
|
70
|
-
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
71
|
-
| `sessions` | `SessionStore` | active\* | Session store |
|
|
72
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
73
|
-
| `from` | `string` | `OKE <no-reply@oke.local>` | Template From address |
|
|
74
|
-
| `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
|
|
75
|
-
| `identities` | `IdentityStore` | new | Email → user map |
|
|
76
|
-
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
77
|
-
|
|
78
|
-
## Surfaces
|
|
79
|
-
|
|
80
|
-
| Flow | Path | Gate |
|
|
81
|
-
| ---------------------- | ------------------------------ | ------------------------ |
|
|
82
|
-
| `auth.requestEmailOtp` | `POST /auth/email-otp/request` | `gate.public` + otp rate |
|
|
83
|
-
| `auth.verifyEmailOtp` | `POST /auth/email-otp/verify` | `gate.public` + otp rate |
|
|
84
|
-
|
|
85
|
-
**Consequence:** the plugin contributes the `auth-email-otp` Channel template and EN/AR
|
|
86
|
-
catalog bodies (`{{otp}}`). Override copy by merging your own catalog at boot.
|
|
87
|
-
|
|
88
|
-
## Troubleshooting
|
|
89
|
-
|
|
90
|
-
<Accordions>
|
|
91
|
-
<Accordion title="No email with the code">
|
|
92
|
-
|
|
93
|
-
In `local` / `test` the `console` driver captures mail — open the inbox, or run
|
|
94
|
-
`oke dev --docker` and check Mailpit. For unit tests without SMTP, set `exposeDevOtp: true`.
|
|
95
|
-
|
|
96
|
-
</Accordion>
|
|
97
|
-
<Accordion title="AuthFailed after a few tries">
|
|
98
|
-
|
|
99
|
-
Five failed attempts consume the challenge. Request a new OTP.
|
|
100
|
-
|
|
101
|
-
</Accordion>
|
|
102
|
-
</Accordions>
|
|
103
|
-
|
|
104
|
-
## Learn more
|
|
105
|
-
|
|
106
|
-
- [Magic link](/docs/plugins/magic-link) — link instead of a code
|
|
107
|
-
- [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164; delivery deferred)
|
|
108
|
-
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
|
|
109
|
-
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
110
|
-
|
|
111
|
-
## Next
|
|
112
|
-
|
|
113
|
-
<Cards>
|
|
114
|
-
<Card title="Phone number" description="E.164 SMS OTP." href="/docs/plugins/phone-number" />
|
|
115
|
-
<Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
|
|
116
|
-
<Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
|
|
117
|
-
</Cards>
|
|
@@ -1,172 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Phone number"
|
|
3
|
-
description: "Official plugin — E.164 phone OTP request and verify under /auth, with provider-managed delivery via Taqnyat Verify."
|
|
4
|
-
icon: "Smartphone"
|
|
5
|
-
source: "docs/spec/unified-theory.md"
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
`phoneNumber()` signs people in with a phone number in E.164 form (`+15551234567`). Delivery
|
|
9
|
-
has two shapes: provider-managed OTP through Taqnyat Verify when an SMS driver is bound, or a
|
|
10
|
-
local hashed code (10 minutes, 5 attempts) you deliver yourself.
|
|
11
|
-
|
|
12
|
-
<Callout title="The one rule">
|
|
13
|
-
Enable `gate.auth`, then `.plug(phoneNumber())`. For real SMS set `drivers.channel.sms` to
|
|
14
|
-
`taqnyat` — the plugin uses Taqnyat's Verify API, never a self-generated code. Use `exposeDevOtp`
|
|
15
|
-
only when no SMS driver is bound.
|
|
16
|
-
</Callout>
|
|
17
|
-
|
|
18
|
-
## Quick start
|
|
19
|
-
|
|
20
|
-
<Steps>
|
|
21
|
-
|
|
22
|
-
<Step>
|
|
23
|
-
### Plug it
|
|
24
|
-
|
|
25
|
-
```typescript title="src/app.ts"
|
|
26
|
-
import { oke } from "okengine";
|
|
27
|
-
import { phoneNumber } from "okengine/plugins";
|
|
28
|
-
|
|
29
|
-
export const app = oke({
|
|
30
|
-
name: "shop",
|
|
31
|
-
env: "local",
|
|
32
|
-
gate: { auth: {} },
|
|
33
|
-
}).plug(phoneNumber({ exposeDevOtp: true }));
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
</Step>
|
|
37
|
-
|
|
38
|
-
<Step>
|
|
39
|
-
### Request a code
|
|
40
|
-
|
|
41
|
-
```typescript
|
|
42
|
-
const { data, error } = await api.auth.requestPhoneOtp({ phone: "+15551234567" });
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`POST /auth/phone/request`. Invalid E.164 → `AuthFailed` / `invalid_phone`. Optional `lang:
|
|
46
|
-
"en" | "ar"` picks the OTP message language on the Taqnyat path.
|
|
47
|
-
|
|
48
|
-
</Step>
|
|
49
|
-
|
|
50
|
-
<Step>
|
|
51
|
-
### Verify
|
|
52
|
-
|
|
53
|
-
```typescript
|
|
54
|
-
const { data } = await api.auth.verifyPhoneOtp({
|
|
55
|
-
phone: "+15551234567",
|
|
56
|
-
otp: data!.devOtp!,
|
|
57
|
-
});
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
`POST /auth/phone/verify` — first success creates a phone → `userId` mapping.
|
|
61
|
-
|
|
62
|
-
</Step>
|
|
63
|
-
|
|
64
|
-
</Steps>
|
|
65
|
-
|
|
66
|
-
## Delivery
|
|
67
|
-
|
|
68
|
-
| Bound SMS driver | Request behavior | Verify behavior |
|
|
69
|
-
| -------------------- | ---------------------------------------------------------- | ------------------------------ |
|
|
70
|
-
| `taqnyat` | Taqnyat Verify `sendOtp` (provider generates the code) | Taqnyat Verify `verifyOtp` |
|
|
71
|
-
| none | Local 6-digit code, hashed at rest (`exposeDevOtp` for DX) | Local hash compare, 5 attempts |
|
|
72
|
-
| any other SMS driver | Fails loudly — provider-managed OTP unsupported | — |
|
|
73
|
-
|
|
74
|
-
**Consequence:** the request response never contains `devOtp` on the Taqnyat path — the code
|
|
75
|
-
exists only inside the SMS Taqnyat sends.
|
|
76
|
-
|
|
77
|
-
### Taqnyat Verify
|
|
78
|
-
|
|
79
|
-
```typescript title="oke.config.ts"
|
|
80
|
-
export default {
|
|
81
|
-
drivers: {
|
|
82
|
-
channel: {
|
|
83
|
-
sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
| Env | Meaning |
|
|
90
|
-
| ---------------------- | -------------------------------------------- |
|
|
91
|
-
| `TAQNYAT_BEARER_TOKEN` | Taqnyat bearer token (`TAQNYAT_TOKEN` alias) |
|
|
92
|
-
| `TAQNYAT_SENDER` | Pre-approved sender id |
|
|
93
|
-
|
|
94
|
-
Request stores a provider challenge (`taqnyat:<requestId>`, 10m TTL, 5 attempts) and calls
|
|
95
|
-
`fx.sendOtp`; verify replays the same `requestId` with the code through `fx.verifyOtp`. Both
|
|
96
|
-
are capability-gated `send` effects — dry-run records them without contacting Taqnyat.
|
|
97
|
-
|
|
98
|
-
An SMS driver without Verify support (for example a generic SMS sender) makes the request fail
|
|
99
|
-
with a clear error: use `exposeDevOtp` locally or switch `drivers.channel.sms` to `taqnyat`.
|
|
100
|
-
|
|
101
|
-
## Options
|
|
102
|
-
|
|
103
|
-
| Option | Type | Default | Meaning |
|
|
104
|
-
| --------------- | ------------------- | -------- | --------------------------------------------------------- |
|
|
105
|
-
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
106
|
-
| `sessions` | `SessionStore` | active\* | Session store |
|
|
107
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
108
|
-
| `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response (local path only) |
|
|
109
|
-
| `phones` | `PhoneStore` | new | Phone → userId map |
|
|
110
|
-
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
111
|
-
|
|
112
|
-
## Surfaces
|
|
113
|
-
|
|
114
|
-
| Flow | Path | Gate |
|
|
115
|
-
| ---------------------- | -------------------------- | ------------------------ |
|
|
116
|
-
| `auth.requestPhoneOtp` | `POST /auth/phone/request` | `gate.public` + otp rate |
|
|
117
|
-
| `auth.verifyPhoneOtp` | `POST /auth/phone/verify` | `gate.public` + otp rate |
|
|
118
|
-
|
|
119
|
-
E.164 pattern: `^\+[1-9]\d{7,14}$`.
|
|
120
|
-
|
|
121
|
-
## Live tests (opt-in)
|
|
122
|
-
|
|
123
|
-
The Taqnyat live suite sends real SMS and burns real quota, so it is double-gated: it runs only when
|
|
124
|
-
`OKE_SMS_LIVE=1` **and** the real credentials (`TAQNYAT_TOKEN`, `TAQNYAT_SENDER`, plus
|
|
125
|
-
`OKE_TEST_TAQNYAT_PHONE` — `TAQNYAT_TO` also accepted) are all present. Credentials alone never send.
|
|
126
|
-
|
|
127
|
-
The suite sends exactly one SMS and never asks for the code.
|
|
128
|
-
|
|
129
|
-
```bash
|
|
130
|
-
OKE_SMS_LIVE=1 TAQNYAT_TOKEN=… TAQNYAT_SENDER=YourBrand \
|
|
131
|
-
OKE_TEST_TAQNYAT_PHONE=+9665xxxxxxxx bun test src/plugins
|
|
132
|
-
```
|
|
133
|
-
|
|
134
|
-
Without the flag the suite skips visibly — never a silent pass.
|
|
135
|
-
|
|
136
|
-
## Troubleshooting
|
|
137
|
-
|
|
138
|
-
<Accordions>
|
|
139
|
-
<Accordion title="invalid_phone">
|
|
140
|
-
|
|
141
|
-
Number must start with `+` and a non-zero country digit, then 7–14 more digits. No spaces or
|
|
142
|
-
dashes.
|
|
143
|
-
|
|
144
|
-
</Accordion>
|
|
145
|
-
<Accordion title="SMS driver does not support provider-managed OTP">
|
|
146
|
-
|
|
147
|
-
The configured `drivers.channel.sms` driver has no Verify API. Either switch to `taqnyat` or
|
|
148
|
-
remove the SMS driver so the local hashed path (`exposeDevOtp`) is used.
|
|
149
|
-
|
|
150
|
-
</Accordion>
|
|
151
|
-
<Accordion title="No SMS arrived">
|
|
152
|
-
|
|
153
|
-
On the local path no SMS is sent by design — use `exposeDevOtp`, or bind `taqnyat` with valid
|
|
154
|
-
`TAQNYAT_*` credentials. On the Taqnyat path check the sender id is pre-approved and the
|
|
155
|
-
number is E.164.
|
|
156
|
-
|
|
157
|
-
</Accordion>
|
|
158
|
-
</Accordions>
|
|
159
|
-
|
|
160
|
-
## Learn more
|
|
161
|
-
|
|
162
|
-
- [Email OTP](/docs/plugins/email-otp) — email variant of the same OTP rules
|
|
163
|
-
- [Magic link](/docs/plugins/magic-link) — one-time email link sign-in
|
|
164
|
-
- [Channel](/docs/elements/channel) — SMS / email drivers and delivery
|
|
165
|
-
|
|
166
|
-
## Next
|
|
167
|
-
|
|
168
|
-
<Cards>
|
|
169
|
-
<Card title="Email OTP" description="Six-digit email codes." href="/docs/plugins/email-otp" />
|
|
170
|
-
<Card title="Two-factor" description="TOTP step-up." href="/docs/plugins/two-factor" />
|
|
171
|
-
<Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
|
|
172
|
-
</Cards>
|
package/src/plugins/email-otp.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Email OTP Gate auth method plugin.
|
|
3
|
-
*
|
|
4
|
-
* Delivers the one-time code via Channel (`fx.send` + `auth-email-otp`
|
|
5
|
-
* template). {@link EmailOtpOptions.exposeDevOtp} remains available for
|
|
6
|
-
* local DX without Mailpit / SMTP.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
createIdentityStore,
|
|
11
|
-
normalizeEmail,
|
|
12
|
-
type IdentityStore,
|
|
13
|
-
type UserIdentityRow,
|
|
14
|
-
} from "../auth/identity.ts";
|
|
15
|
-
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
16
|
-
import {
|
|
17
|
-
createVerificationStore,
|
|
18
|
-
findActiveVerification,
|
|
19
|
-
generateOtp,
|
|
20
|
-
hashChallenge,
|
|
21
|
-
putVerification,
|
|
22
|
-
type VerificationStore,
|
|
23
|
-
} from "../auth/verification.ts";
|
|
24
|
-
import { channel } from "../elements/channel.ts";
|
|
25
|
-
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
26
|
-
import {
|
|
27
|
-
AuthFailed,
|
|
28
|
-
AuthRateLimited,
|
|
29
|
-
SessionTokensOut,
|
|
30
|
-
bindPublicAuth,
|
|
31
|
-
createMethodRuntime,
|
|
32
|
-
fail,
|
|
33
|
-
flow,
|
|
34
|
-
z,
|
|
35
|
-
type AuthMethodOptions,
|
|
36
|
-
} from "./auth/shared.ts";
|
|
37
|
-
|
|
38
|
-
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
39
|
-
const MAX_ATTEMPTS = 5;
|
|
40
|
-
const DEFAULT_FROM = "OKE <no-reply@oke.local>";
|
|
41
|
-
|
|
42
|
-
/** Channel template for email OTP delivery. */
|
|
43
|
-
export const emailOtpTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-email-otp", {
|
|
44
|
-
description: "Email OTP sign-in code",
|
|
45
|
-
schema: z.object({
|
|
46
|
-
email: z.string(),
|
|
47
|
-
otp: z.string(),
|
|
48
|
-
}),
|
|
49
|
-
locales: ["en", "ar"],
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
/** Default EN/AR bodies for {@link emailOtpTemplate}. */
|
|
53
|
-
export const emailOtpCatalog = {
|
|
54
|
-
"auth-email-otp": {
|
|
55
|
-
en: {
|
|
56
|
-
subject: "Your sign-in code",
|
|
57
|
-
text: "Your one-time sign-in code is: {{otp}}\n",
|
|
58
|
-
html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
|
|
59
|
-
},
|
|
60
|
-
ar: {
|
|
61
|
-
subject: "رمز تسجيل الدخول",
|
|
62
|
-
text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
|
|
63
|
-
html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
|
|
64
|
-
},
|
|
65
|
-
},
|
|
66
|
-
} as const;
|
|
67
|
-
|
|
68
|
-
/** Options for {@link emailOtp}. */
|
|
69
|
-
export interface EmailOtpOptions extends AuthMethodOptions {
|
|
70
|
-
/** Challenge TTL (default 10m). */
|
|
71
|
-
readonly ttlMs?: number;
|
|
72
|
-
readonly identities?: IdentityStore;
|
|
73
|
-
readonly verifications?: VerificationStore;
|
|
74
|
-
/** Return raw OTP in the request response (test / local). */
|
|
75
|
-
readonly exposeDevOtp?: boolean;
|
|
76
|
-
/** Override the template `from` address. */
|
|
77
|
-
readonly from?: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Email OTP request + verify (6-digit, hashed, 5 attempts).
|
|
82
|
-
*
|
|
83
|
-
* @param opts - TTL / stores / dev OTP
|
|
84
|
-
*/
|
|
85
|
-
export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
|
|
86
|
-
const runtime = createMethodRuntime(opts);
|
|
87
|
-
const identities = opts.identities ?? createIdentityStore();
|
|
88
|
-
const verifications = opts.verifications ?? createVerificationStore();
|
|
89
|
-
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
90
|
-
const tmpl =
|
|
91
|
-
opts.from !== undefined
|
|
92
|
-
? channel.email({ from: opts.from }).template("auth-email-otp", {
|
|
93
|
-
description: "Email OTP sign-in code",
|
|
94
|
-
schema: z.object({
|
|
95
|
-
email: z.string(),
|
|
96
|
-
otp: z.string(),
|
|
97
|
-
}),
|
|
98
|
-
locales: ["en", "ar"],
|
|
99
|
-
})
|
|
100
|
-
: emailOtpTemplate;
|
|
101
|
-
|
|
102
|
-
const request = flow({
|
|
103
|
-
name: "auth.requestEmailOtp",
|
|
104
|
-
unit: "auth",
|
|
105
|
-
plane: "user",
|
|
106
|
-
in: z.object({ email: z.string().min(3) }),
|
|
107
|
-
out: z.object({
|
|
108
|
-
ok: z.literal(true),
|
|
109
|
-
devOtp: z.string().optional(),
|
|
110
|
-
}),
|
|
111
|
-
errors: { AuthFailed, AuthRateLimited },
|
|
112
|
-
effects: { sends: ["auth-email-otp"] },
|
|
113
|
-
do: async (input, fx) => {
|
|
114
|
-
const email = normalizeEmail(input.email);
|
|
115
|
-
if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
|
|
116
|
-
const otp = generateOtp(6);
|
|
117
|
-
const now = runtime.now();
|
|
118
|
-
// Invalidate prior active challenges for this email.
|
|
119
|
-
for (const row of verifications.rows.values()) {
|
|
120
|
-
if (row.identifier === `email-otp:${email}` && row.consumedAt === null) {
|
|
121
|
-
row.consumedAt = now;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
putVerification(verifications, {
|
|
125
|
-
id: crypto.randomUUID(),
|
|
126
|
-
identifier: `email-otp:${email}`,
|
|
127
|
-
value: await hashChallenge(otp),
|
|
128
|
-
expiresAt: now + ttlMs,
|
|
129
|
-
createdAt: now,
|
|
130
|
-
consumedAt: null,
|
|
131
|
-
attempts: 0,
|
|
132
|
-
});
|
|
133
|
-
await fx.send(tmpl, {
|
|
134
|
-
to: email,
|
|
135
|
-
data: { email, otp },
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
ok: true as const,
|
|
139
|
-
...(opts.exposeDevOtp ? { devOtp: otp } : {}),
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const verify = flow({
|
|
145
|
-
name: "auth.verifyEmailOtp",
|
|
146
|
-
unit: "auth",
|
|
147
|
-
plane: "user",
|
|
148
|
-
in: z.object({
|
|
149
|
-
email: z.string().min(3),
|
|
150
|
-
otp: z.string().min(4).max(8),
|
|
151
|
-
}),
|
|
152
|
-
out: SessionTokensOut,
|
|
153
|
-
errors: { AuthFailed, AuthRateLimited },
|
|
154
|
-
do: async (input) => {
|
|
155
|
-
const email = normalizeEmail(input.email);
|
|
156
|
-
const now = runtime.now();
|
|
157
|
-
const row = findActiveVerification(verifications, `email-otp:${email}`, now);
|
|
158
|
-
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
159
|
-
if (row.attempts >= MAX_ATTEMPTS) {
|
|
160
|
-
row.consumedAt = now;
|
|
161
|
-
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
162
|
-
}
|
|
163
|
-
const hash = await hashChallenge(input.otp.trim());
|
|
164
|
-
if (hash !== row.value) {
|
|
165
|
-
row.attempts += 1;
|
|
166
|
-
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
167
|
-
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
168
|
-
}
|
|
169
|
-
row.consumedAt = now;
|
|
170
|
-
const user = ensureUserByEmail(identities, email, now);
|
|
171
|
-
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
172
|
-
id: user.id,
|
|
173
|
-
plane: "user",
|
|
174
|
-
scopes: [],
|
|
175
|
-
});
|
|
176
|
-
return {
|
|
177
|
-
accessToken: issued.accessToken,
|
|
178
|
-
refreshToken: issued.refreshToken,
|
|
179
|
-
accessExpiresAt: issued.accessExpiresAt,
|
|
180
|
-
userId: user.id,
|
|
181
|
-
};
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
return plugin("emailOtp", { version: "0.0.1", config: { method: "email-otp" } })
|
|
186
|
-
.needs("auth")
|
|
187
|
-
.needs("channel")
|
|
188
|
-
.channelTemplate(tmpl)
|
|
189
|
-
.channelCatalog(emailOtpCatalog)
|
|
190
|
-
.binding(bindPublicAuth("/email-otp/request", request, "otp"))
|
|
191
|
-
.binding(bindPublicAuth("/email-otp/verify", verify, "otp"));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
|
|
195
|
-
const existingId = store.byEmail.get(email);
|
|
196
|
-
if (existingId) {
|
|
197
|
-
const existing = store.users.get(existingId);
|
|
198
|
-
if (existing) return existing;
|
|
199
|
-
}
|
|
200
|
-
const id = crypto.randomUUID();
|
|
201
|
-
const user: UserIdentityRow = {
|
|
202
|
-
id,
|
|
203
|
-
email,
|
|
204
|
-
name: email.split("@")[0] || "user",
|
|
205
|
-
emailVerified: true,
|
|
206
|
-
status: "active",
|
|
207
|
-
createdAt: now,
|
|
208
|
-
updatedAt: now,
|
|
209
|
-
extra: {},
|
|
210
|
-
};
|
|
211
|
-
store.users.set(id, user);
|
|
212
|
-
store.byEmail.set(email, id);
|
|
213
|
-
return user;
|
|
214
|
-
}
|