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
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "OTP"
|
|
3
|
+
description: "Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit provider or app mode."
|
|
4
|
+
icon: "KeyRound"
|
|
5
|
+
source: "docs/spec/unified-theory.md"
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
`otp()` signs people in with a one-time code. You must set `mode` — there is no
|
|
9
|
+
auto-detect. Provider mode is SMS Verify via the bound driver; app mode is
|
|
10
|
+
app-owned delivery across the channels you declare.
|
|
11
|
+
|
|
12
|
+
<Callout title="The one rule">
|
|
13
|
+
Enable `gate.auth`, then `.plug(otp({ mode: "provider" }))` or
|
|
14
|
+
`.plug(otp({ mode: "app", channels: [...] }))`. Never omit `mode`. Never log
|
|
15
|
+
raw OTPs.
|
|
16
|
+
</Callout>
|
|
17
|
+
|
|
18
|
+
<Callout type="info" title="One otp() per app">
|
|
19
|
+
Provider and app mode cannot both be active — they claim the same fixed `/auth/otp/*` routes. Need
|
|
20
|
+
two OTP-like mechanisms? Combine `otp()` with a different plugin (e.g. `magicLink()`), not a
|
|
21
|
+
second `otp()`.
|
|
22
|
+
</Callout>
|
|
23
|
+
|
|
24
|
+
<Callout type="info" title="fx.sendOtp vs otp()">
|
|
25
|
+
`fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
|
|
26
|
+
build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
|
|
27
|
+
it and call the raw methods in your own flow without losing the provider connection.
|
|
28
|
+
</Callout>
|
|
29
|
+
|
|
30
|
+
## Quick start
|
|
31
|
+
|
|
32
|
+
<Steps>
|
|
33
|
+
|
|
34
|
+
<Step>
|
|
35
|
+
### Plug app mode (multi-channel)
|
|
36
|
+
|
|
37
|
+
```typescript title="src/app.ts"
|
|
38
|
+
import { oke } from "okengine";
|
|
39
|
+
import { otp } from "okengine/plugins";
|
|
40
|
+
|
|
41
|
+
export const app = oke({
|
|
42
|
+
name: "shop",
|
|
43
|
+
env: "local",
|
|
44
|
+
gate: { auth: {} },
|
|
45
|
+
}).plug(
|
|
46
|
+
otp({
|
|
47
|
+
mode: "app",
|
|
48
|
+
channels: ["sms", "whatsapp", "email"],
|
|
49
|
+
exposeDevOtp: true, // local DX only
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
</Step>
|
|
55
|
+
|
|
56
|
+
<Step>
|
|
57
|
+
### Request a code
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
const { data } = await api.auth.requestOtp({
|
|
61
|
+
phone: "+15551234567",
|
|
62
|
+
email: "ali@example.com",
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`POST /auth/otp/request`. Prior active challenges for that principal are
|
|
67
|
+
invalidated. Delivery follows `channels` order for addresses you pass.
|
|
68
|
+
|
|
69
|
+
</Step>
|
|
70
|
+
|
|
71
|
+
<Step>
|
|
72
|
+
### Resend on another channel (app mode only)
|
|
73
|
+
|
|
74
|
+
```typescript
|
|
75
|
+
const { data } = await api.auth.resendOtp({
|
|
76
|
+
phone: "+15551234567",
|
|
77
|
+
email: "ali@example.com",
|
|
78
|
+
channel: "email",
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
|
|
83
|
+
resend surface — the provider owns the code.
|
|
84
|
+
|
|
85
|
+
</Step>
|
|
86
|
+
|
|
87
|
+
<Step>
|
|
88
|
+
### Verify
|
|
89
|
+
|
|
90
|
+
```typescript
|
|
91
|
+
const { data } = await api.auth.verifyOtp({
|
|
92
|
+
phone: "+15551234567",
|
|
93
|
+
otp,
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`POST /auth/otp/verify` — five failed attempts consume the challenge.
|
|
98
|
+
|
|
99
|
+
</Step>
|
|
100
|
+
|
|
101
|
+
</Steps>
|
|
102
|
+
|
|
103
|
+
## Modes
|
|
104
|
+
|
|
105
|
+
| | Provider mode | App mode |
|
|
106
|
+
| -------------------- | ----------------------------- | --------------------------------------------- |
|
|
107
|
+
| Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
|
|
108
|
+
| Who owns the code | Provider (Verify API) | Your app |
|
|
109
|
+
| Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
|
|
110
|
+
| Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
|
|
111
|
+
| Resend other channel | Impossible | `POST /auth/otp/resend` |
|
|
112
|
+
| `exposeDevOtp` | Forbidden | Optional (default off) |
|
|
113
|
+
|
|
114
|
+
<Callout type="warn" title="Provider mode limitation">
|
|
115
|
+
Resend-via-different-channel is impossible in provider mode — the code value is never visible to
|
|
116
|
+
OKE. Use app mode when you need SMS → email fallback for the same code.
|
|
117
|
+
</Callout>
|
|
118
|
+
|
|
119
|
+
### Provider mode setup
|
|
120
|
+
|
|
121
|
+
```typescript title="oke.config.ts"
|
|
122
|
+
export default {
|
|
123
|
+
drivers: {
|
|
124
|
+
channel: {
|
|
125
|
+
sms: { local: "console", docker: "taqnyat", test: "console", prod: "taqnyat" },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
|
|
132
|
+
app mode, or bind a Verify-capable driver (for example `taqnyat`).
|
|
133
|
+
|
|
134
|
+
### App mode delivery
|
|
135
|
+
|
|
136
|
+
| Concern | Behavior |
|
|
137
|
+
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
138
|
+
| Storage | SHA-256 hash for verify + sealed AES-GCM copy (HKDF `oke-otp-seal-v1`) for redelivery |
|
|
139
|
+
| Seal lifetime | Wiped on verify, lockout, or TTL expiry — never left after the challenge dies |
|
|
140
|
+
| Challenge TTL | Default 10 minutes (`ttlMs`) |
|
|
141
|
+
| Resend cooldown | Default 60 seconds (`resendCooldownMs`) — separate from TTL |
|
|
142
|
+
| Auto failover | On real provider send errors, sently `FallbackTransport` walks remaining media; Taqnyat WhatsApp may use `sendWithFailover` |
|
|
143
|
+
| User resend | Explicit `resend` with `channel` — not automatic |
|
|
144
|
+
|
|
145
|
+
Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
|
|
146
|
+
`{{otp}}`). SMS here is a plain message — not Taqnyat Verify.
|
|
147
|
+
|
|
148
|
+
## Options
|
|
149
|
+
|
|
150
|
+
| Option | Type | Default | Meaning |
|
|
151
|
+
| ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
|
|
152
|
+
| `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
|
|
153
|
+
| `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
|
|
154
|
+
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
155
|
+
| `resendCooldownMs` | `number` | 60s | App-mode resend spacing |
|
|
156
|
+
| `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
|
|
157
|
+
| `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
|
|
158
|
+
| `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
|
|
159
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
160
|
+
| `identities` | `IdentityStore` | new | Email → user |
|
|
161
|
+
| `phones` | `PhoneStore` | new | Phone → user |
|
|
162
|
+
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
163
|
+
|
|
164
|
+
## Surfaces
|
|
165
|
+
|
|
166
|
+
| Flow | Path | Gate | Mode |
|
|
167
|
+
| ----------------- | ------------------------ | ------------------------ | ------------- |
|
|
168
|
+
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
|
|
169
|
+
| `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
|
|
170
|
+
| `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | app mode only |
|
|
171
|
+
|
|
172
|
+
## Troubleshooting
|
|
173
|
+
|
|
174
|
+
<Accordions>
|
|
175
|
+
<Accordion title='otp(): mode is required'>
|
|
176
|
+
|
|
177
|
+
You omitted `mode`. Set `mode: "provider"` or `mode: "app"` explicitly — OKE
|
|
178
|
+
never infers which mechanism you meant.
|
|
179
|
+
|
|
180
|
+
</Accordion>
|
|
181
|
+
<Accordion title="Boot fails in provider mode">
|
|
182
|
+
|
|
183
|
+
No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
|
|
184
|
+
(or another driver with `sendOtp`/`verifyOtp`), or switch to
|
|
185
|
+
`otp({ mode: "app", channels: [...] })`.
|
|
186
|
+
|
|
187
|
+
</Accordion>
|
|
188
|
+
<Accordion title="resend_cooldown">
|
|
189
|
+
|
|
190
|
+
Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
|
|
191
|
+
only delivery is rate-limited.
|
|
192
|
+
|
|
193
|
+
</Accordion>
|
|
194
|
+
<Accordion title="No email / SMS with the code (app mode)">
|
|
195
|
+
|
|
196
|
+
In `local` / `test` the `console` driver captures messages. Use
|
|
197
|
+
`exposeDevOtp: true` for unit tests without a real provider.
|
|
198
|
+
|
|
199
|
+
</Accordion>
|
|
200
|
+
</Accordions>
|
|
201
|
+
|
|
202
|
+
## Learn more
|
|
203
|
+
|
|
204
|
+
- [Magic link](/docs/plugins/magic-link) — link instead of a code
|
|
205
|
+
- [Channel](/docs/elements/channel) — `fx.send`, `fx.sendOtp`, drivers, Mailpit
|
|
206
|
+
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
207
|
+
|
|
208
|
+
## Next
|
|
209
|
+
|
|
210
|
+
<Cards>
|
|
211
|
+
<Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
|
|
212
|
+
<Card title="Two-factor" description="TOTP second factor." href="/docs/plugins/two-factor" />
|
|
213
|
+
<Card title="Channel" description="Delivery drivers and Mailpit." href="/docs/elements/channel" />
|
|
214
|
+
</Cards>
|
|
@@ -107,12 +107,12 @@ enable. A recovery code works once, then is consumed.
|
|
|
107
107
|
- [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
|
|
108
108
|
- [Gate](/docs/elements/gate) — session + policies
|
|
109
109
|
- [Username](/docs/plugins/username) — first factor to enroll against
|
|
110
|
-
- OTP over SMS or email instead of TOTP? See [
|
|
110
|
+
- OTP over SMS or email instead of TOTP? See [OTP](/docs/plugins/otp) / [Magic link](/docs/plugins/magic-link)
|
|
111
111
|
|
|
112
112
|
## Next
|
|
113
113
|
|
|
114
114
|
<Cards>
|
|
115
115
|
<Card title="Passkey" description="WebAuthn register and assert." href="/docs/plugins/passkey" />
|
|
116
116
|
<Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
|
|
117
|
-
<Card title="
|
|
117
|
+
<Card title="OTP" description="SMS, WhatsApp, or email codes." href="/docs/plugins/otp" />
|
|
118
118
|
</Cards>
|
|
@@ -33,8 +33,9 @@ oke db seed --env prod --force # CI: skip docker/prod confirmation prompt
|
|
|
33
33
|
oke vault set STRIPE_KEY # also: list · import .env · key rotate
|
|
34
34
|
oke client add <url> # types for a separate frontend repo
|
|
35
35
|
|
|
36
|
-
oke docker # docker/Dockerfile +
|
|
37
|
-
|
|
36
|
+
oke docker # docker/Dockerfile + compose.yml + compose.<role>.yml + compose.all.yml · …
|
|
37
|
+
# opt-in images.proxy → Caddyfile (caddy) or Traefik labels + socket-proxy
|
|
38
|
+
oke docker --prod # + compose.prod.yml (HEALTHCHECK /_/ready, deploy, limits); folded into compose.all.yml
|
|
38
39
|
oke docker clean # TTY: pick oke-dev-* stacks → down -v (containers, networks, volumes)
|
|
39
40
|
oke docker clean --yes # non-TTY: current project stack only
|
|
40
41
|
oke docker clean --all --yes # non-TTY: every oke-dev-* stack on this machine
|
|
@@ -75,16 +75,32 @@ Container image pins keyed by element role — vendor choice lives here, never i
|
|
|
75
75
|
```typescript
|
|
76
76
|
images: {
|
|
77
77
|
"store.sql": "postgres:18",
|
|
78
|
+
pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
|
|
78
79
|
"store.kv": "redis:8",
|
|
80
|
+
// or: "valkey/valkey:8-alpine"
|
|
81
|
+
// or: "docker.dragonflydb.io/dragonflydb/dragonfly"
|
|
79
82
|
"store.files": "rustfs/rustfs:1.0.0-beta.11",
|
|
80
83
|
"channel.email": "axllent/mailpit:v1.22.3",
|
|
81
84
|
vault: "openbao/openbao:2.6.1",
|
|
82
85
|
ai: "ollama/ollama:latest",
|
|
86
|
+
// opt-in TLS edge — omit for the default (app publishes 6530):
|
|
87
|
+
// proxy: "caddy:2-alpine", // simplest automatic HTTPS
|
|
88
|
+
// proxy: "traefik:v3.3", // label auto-discovery for --scale app=N
|
|
83
89
|
},
|
|
84
90
|
```
|
|
85
91
|
|
|
92
|
+
When both `store.sql` and `pgdog` are pinned, `DATABASE_URL` points at PgDog
|
|
93
|
+
(`6432`) — wire-protocol transparent to Bun.SQL / Drizzle. See
|
|
94
|
+
[Store · Multiple environments](/docs/elements/store#multiple-environments).
|
|
95
|
+
|
|
96
|
+
For `store.kv`, pin Redis (default), Valkey, or Dragonfly — driver id stays
|
|
97
|
+
`redis`, same `REDIS_URL`. Image table + licenses: [Store · KV](/docs/elements/store#kv).
|
|
98
|
+
|
|
86
99
|
For `ai`, the Ollama recipe pulls `OKE_AI_MODEL` (default `qwen3.5:9b` — a balanced local-dev starting point; override freely; on Apple Silicon prefer `qwen3.5:9b-mlx`) and writes `OKE_AI_URL`.
|
|
87
100
|
|
|
101
|
+
For `proxy`, pin **either** Caddy or Traefik — never both. Opt-in only; see
|
|
102
|
+
[Reverse proxy](/docs/deployment/reverse-proxy).
|
|
103
|
+
|
|
88
104
|
## i18n
|
|
89
105
|
|
|
90
106
|
| Option | Type | Default | Meaning |
|
|
@@ -13,14 +13,15 @@ OKE reads environment variables at boot for connection detail and secrets — ne
|
|
|
13
13
|
|
|
14
14
|
## SQL store
|
|
15
15
|
|
|
16
|
-
| Variable | Used for
|
|
17
|
-
| ------------------- |
|
|
18
|
-
| `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit) | `postgres://localhost:5432/oke` |
|
|
19
|
-
| `OKE_STORE_SQL_URL` |
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
16
|
+
| Variable | Used for | Default when unset |
|
|
17
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- |
|
|
18
|
+
| `DATABASE_URL` | Postgres connection (`postgres` store / clock / journal drivers, drizzle-kit). When PgDog is in the stack, this points at the pooler (`:6432`). | `postgres://localhost:5432/oke` |
|
|
19
|
+
| `OKE_STORE_SQL_URL` | Direct SQL URL (Postgres host, even when PgDog fronts the app) | — |
|
|
20
|
+
| `OKE_PGDOG_URL` | PgDog pooler URL (same value as `DATABASE_URL` when both are present) | — |
|
|
21
|
+
| `OKE_SQLITE_URL` | SQLite file path (`sqlite` driver) | `.oke/app.sqlite` |
|
|
22
|
+
| `OKE_LIBSQL_URL` | libSQL URL or file path (`libsql` driver) | `.oke/app.libsql` |
|
|
23
|
+
| `OKE_PGLITE_URL` | PGlite data dir or `memory://` (`pglite` driver) | `.oke/pgdata` |
|
|
24
|
+
| `OKE_SQL_DRIVER` | Force the sql driver id at boot | config map |
|
|
24
25
|
|
|
25
26
|
## Index store
|
|
26
27
|
|
|
@@ -243,7 +243,7 @@ Extending an existing **app-owned** table with plugin columns is not supported i
|
|
|
243
243
|
|
|
244
244
|
## Learn more
|
|
245
245
|
|
|
246
|
-
- [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [
|
|
246
|
+
- [Plugins](/docs/plugins) — [username](/docs/plugins/username) · [anonymous](/docs/plugins/anonymous) · [magic link](/docs/plugins/magic-link) · [OTP](/docs/plugins/otp) · [two-factor](/docs/plugins/two-factor) · [passkey](/docs/plugins/passkey) · [Headers](/docs/plugins/headers) · [CORS](/docs/plugins/cors) · [CSRF](/docs/plugins/csrf) · [Compression](/docs/plugins/compression) · [Maintenance Mode](/docs/plugins/maintenance-mode) · [IP Allowlist](/docs/plugins/ip-allowlist)
|
|
247
247
|
- [Flow](/docs/elements/flow) — what plugin flows and hooks plug into
|
|
248
248
|
- [Store](/docs/elements/store) — `field.*` builders and schema sync
|
|
249
249
|
- [Configuration](/docs/reference/configuration) — where plugin config is declared
|
package/src/auth/auth.test.ts
CHANGED
|
@@ -231,6 +231,42 @@ describe("hybrid sessions", () => {
|
|
|
231
231
|
SessionError,
|
|
232
232
|
);
|
|
233
233
|
});
|
|
234
|
+
|
|
235
|
+
test("scopes and audience survive on the session row across store instances", async () => {
|
|
236
|
+
const store = createSessionStore();
|
|
237
|
+
let now = 2_000_000;
|
|
238
|
+
const crypto = {
|
|
239
|
+
secret: "test-secret",
|
|
240
|
+
now: () => now,
|
|
241
|
+
audience: "oke-app",
|
|
242
|
+
};
|
|
243
|
+
const issued = await issueSessionWithScopes(store, crypto, {
|
|
244
|
+
id: "user-2",
|
|
245
|
+
plane: "user",
|
|
246
|
+
scopes: ["notes:write", "notes:read"],
|
|
247
|
+
});
|
|
248
|
+
expect(issued.session.scopes).toEqual(["notes:write", "notes:read"]);
|
|
249
|
+
expect(issued.session.audience).toBe("oke-app");
|
|
250
|
+
|
|
251
|
+
// Simulate process restart: copy rows into a fresh SessionStore (no module Maps).
|
|
252
|
+
const restored = createSessionStore();
|
|
253
|
+
for (const [id, row] of store.sessions) {
|
|
254
|
+
restored.sessions.set(id, { ...row, scopes: [...row.scopes] });
|
|
255
|
+
}
|
|
256
|
+
for (const [id, row] of store.refresh) {
|
|
257
|
+
restored.refresh.set(id, { ...row });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
now += 1_000;
|
|
261
|
+
const rotated = await rotateRefresh(restored, crypto, issued.refreshToken);
|
|
262
|
+
const { verifyAccess } = await import("./sessions.ts");
|
|
263
|
+
const claims = await verifyAccess(restored, crypto.secret, rotated.accessToken, {
|
|
264
|
+
now: () => now,
|
|
265
|
+
audience: "oke-app",
|
|
266
|
+
});
|
|
267
|
+
expect(claims.scopes).toEqual(["notes:write", "notes:read"]);
|
|
268
|
+
expect(claims.aud).toBe("oke-app");
|
|
269
|
+
});
|
|
234
270
|
});
|
|
235
271
|
|
|
236
272
|
describe("operator plane", () => {
|
package/src/auth/bindings.ts
CHANGED
|
@@ -32,7 +32,7 @@ import {
|
|
|
32
32
|
type SessionCrypto,
|
|
33
33
|
type SessionStore,
|
|
34
34
|
} from "./sessions.ts";
|
|
35
|
-
import {
|
|
35
|
+
import { type LoginAttemptBag, createLoginAttemptBag } from "./rate.ts";
|
|
36
36
|
|
|
37
37
|
/** Built-in policy: verified user session (for `/auth/me` and step-up surfaces). */
|
|
38
38
|
export const AUTH_SESSION_GATE: PolicyGateDecl = gate.policy(
|
|
@@ -297,17 +297,8 @@ export function createAuthHttpBindings(
|
|
|
297
297
|
out: SessionTokensOut,
|
|
298
298
|
errors: { AuthFailed, AuthRateLimited },
|
|
299
299
|
do: async (input) => {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (!bag) {
|
|
303
|
-
bag = [];
|
|
304
|
-
loginAttempts.set(key, bag);
|
|
305
|
-
}
|
|
306
|
-
if (touchRateLimit(bag, ctx.now()) === "rate_limited") {
|
|
307
|
-
return fail("AuthRateLimited", {
|
|
308
|
-
reason: "rate_limited",
|
|
309
|
-
});
|
|
310
|
-
}
|
|
300
|
+
// Rate limit is Gate KV (`signInRate` on the binding) — shared across
|
|
301
|
+
// instances when `drivers.store.kv` is redis. No process-local email bag.
|
|
311
302
|
const user = await authenticateUser(identities, input.email, input.password);
|
|
312
303
|
// Enumeration hygiene: identical failure for unknown email / bad password.
|
|
313
304
|
if (!user) return fail("AuthFailed", { reason: "invalid_credentials" });
|
package/src/auth/identity.ts
CHANGED
|
@@ -77,6 +77,39 @@ export function normalizeEmail(email: string): string {
|
|
|
77
77
|
return email.trim().toLowerCase();
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Find or create a user row keyed by email (magic-link / OTP sign-in).
|
|
82
|
+
*
|
|
83
|
+
* @param store - Identity store
|
|
84
|
+
* @param email - Normalized email
|
|
85
|
+
* @param now - Clock
|
|
86
|
+
*/
|
|
87
|
+
export function ensureUserByEmail(
|
|
88
|
+
store: IdentityStore,
|
|
89
|
+
email: string,
|
|
90
|
+
now: number,
|
|
91
|
+
): UserIdentityRow {
|
|
92
|
+
const existingId = store.byEmail.get(email);
|
|
93
|
+
if (existingId) {
|
|
94
|
+
const existing = store.users.get(existingId);
|
|
95
|
+
if (existing) return existing;
|
|
96
|
+
}
|
|
97
|
+
const id = crypto.randomUUID();
|
|
98
|
+
const user: UserIdentityRow = {
|
|
99
|
+
id,
|
|
100
|
+
email,
|
|
101
|
+
name: email.split("@")[0] || "user",
|
|
102
|
+
emailVerified: true,
|
|
103
|
+
status: "active",
|
|
104
|
+
createdAt: now,
|
|
105
|
+
updatedAt: now,
|
|
106
|
+
extra: {},
|
|
107
|
+
};
|
|
108
|
+
store.users.set(id, user);
|
|
109
|
+
store.byEmail.set(email, id);
|
|
110
|
+
return user;
|
|
111
|
+
}
|
|
112
|
+
|
|
80
113
|
/**
|
|
81
114
|
* Create a user with a credential account (provider `credential`).
|
|
82
115
|
*
|
package/src/auth/index.ts
CHANGED
|
@@ -69,10 +69,14 @@ export {
|
|
|
69
69
|
createVerificationStore,
|
|
70
70
|
putVerification,
|
|
71
71
|
findActiveVerification,
|
|
72
|
+
consumeVerification,
|
|
73
|
+
invalidateVerifications,
|
|
74
|
+
wipeSealedOtp,
|
|
72
75
|
hashChallenge,
|
|
73
76
|
generateOtp,
|
|
74
77
|
type VerificationStore,
|
|
75
78
|
type VerificationRow,
|
|
79
|
+
type OtpChannel,
|
|
76
80
|
} from "./verification.ts";
|
|
77
81
|
|
|
78
82
|
export { constantTimeEqual } from "./constant-time.ts";
|
|
@@ -83,6 +87,7 @@ export {
|
|
|
83
87
|
authenticateUser,
|
|
84
88
|
getUserById,
|
|
85
89
|
normalizeEmail,
|
|
90
|
+
ensureUserByEmail,
|
|
86
91
|
IdentityError,
|
|
87
92
|
type IdentityStore,
|
|
88
93
|
type UserIdentityRow,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-loud capability checks for the otp() plugin (provider / app mode).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ChannelDriver, SmsOtpTransport } from "../drivers/channel-types.ts";
|
|
6
|
+
import type { OtpChannel } from "./verification.ts";
|
|
7
|
+
|
|
8
|
+
/** Config snapshot stored on the otp plugin for boot-time assertion. */
|
|
9
|
+
export interface OtpPluginConfig {
|
|
10
|
+
readonly method: "otp";
|
|
11
|
+
readonly mode: "provider" | "app";
|
|
12
|
+
readonly channels?: readonly OtpChannel[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether an SMS transport exposes provider OTP via Verify (structural).
|
|
17
|
+
*
|
|
18
|
+
* @param t - Candidate transport
|
|
19
|
+
*/
|
|
20
|
+
export function isSmsOtpTransport(t: unknown): t is SmsOtpTransport {
|
|
21
|
+
if (!t || typeof t !== "object") return false;
|
|
22
|
+
const o = t as Partial<SmsOtpTransport>;
|
|
23
|
+
return typeof o.sendOtp === "function" && typeof o.verifyOtp === "function";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find a bound SMS driver with real sendOtp/verifyOtp, or undefined.
|
|
28
|
+
*
|
|
29
|
+
* @param drivers - Bound channel drivers
|
|
30
|
+
*/
|
|
31
|
+
export function findOtpSmsDriver(
|
|
32
|
+
drivers: readonly ChannelDriver[],
|
|
33
|
+
): { readonly driver: ChannelDriver; readonly otp: SmsOtpTransport } | undefined {
|
|
34
|
+
for (const d of drivers) {
|
|
35
|
+
if (isSmsOtpTransport(d.smsTransport)) {
|
|
36
|
+
return { driver: d, otp: d.smsTransport };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Whether any bound driver can deliver on `medium`.
|
|
44
|
+
*
|
|
45
|
+
* @param drivers - Bound channel drivers
|
|
46
|
+
* @param medium - OTP channel
|
|
47
|
+
*/
|
|
48
|
+
export function driverCoversMedium(drivers: readonly ChannelDriver[], medium: OtpChannel): boolean {
|
|
49
|
+
for (const d of drivers) {
|
|
50
|
+
if (medium === "email" && d.transport) return true;
|
|
51
|
+
if (medium === "sms" && (d.smsTransport || d.channel?.mediums.includes("sms"))) return true;
|
|
52
|
+
if (medium === "whatsapp" && (d.whatsappTransport || d.channel?.mediums.includes("whatsapp"))) {
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
if (d.channel?.mediums.includes(medium) || d.channel?.mediums.includes("any")) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Assert provider mode: a Verify-capable SMS driver must be bound.
|
|
62
|
+
*
|
|
63
|
+
* @param drivers - Bound channel drivers
|
|
64
|
+
*/
|
|
65
|
+
export function assertOtpProviderModeCapability(drivers: readonly ChannelDriver[]): void {
|
|
66
|
+
if (findOtpSmsDriver(drivers)) return;
|
|
67
|
+
const sms = drivers.filter((d) => d.smsTransport);
|
|
68
|
+
if (sms.length === 0) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
'otp({ mode: "provider" }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ mode: "app", channels: [...] })',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const id = sms[0]?.id ?? "unknown";
|
|
74
|
+
throw new Error(
|
|
75
|
+
`otp({ mode: "provider" }): SMS driver "${id}" does not support provider OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ mode: "app", channels: [...] })`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Assert app mode: every declared channel has a deliverable driver.
|
|
81
|
+
*
|
|
82
|
+
* @param drivers - Bound channel drivers
|
|
83
|
+
* @param channels - Declared channel order
|
|
84
|
+
*/
|
|
85
|
+
export function assertOtpAppModeChannels(
|
|
86
|
+
drivers: readonly ChannelDriver[],
|
|
87
|
+
channels: readonly OtpChannel[],
|
|
88
|
+
): void {
|
|
89
|
+
for (const ch of channels) {
|
|
90
|
+
if (!driverCoversMedium(drivers, ch)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`otp({ mode: "app" }): no channel driver covers "${ch}" — configure drivers.channel.${ch === "email" ? "email" : ch === "sms" ? "sms" : "whatsapp"}`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Run the matching boot assertion for an otp plugin config snapshot.
|
|
100
|
+
*
|
|
101
|
+
* @param config - Plugin configSnapshot
|
|
102
|
+
* @param drivers - Bound channel drivers
|
|
103
|
+
*/
|
|
104
|
+
export function assertOtpPluginCapability(
|
|
105
|
+
config: unknown,
|
|
106
|
+
drivers: readonly ChannelDriver[],
|
|
107
|
+
): void {
|
|
108
|
+
if (!config || typeof config !== "object") return;
|
|
109
|
+
const c = config as Partial<OtpPluginConfig>;
|
|
110
|
+
if (c.method !== "otp") return;
|
|
111
|
+
if (c.mode === "provider") {
|
|
112
|
+
assertOtpProviderModeCapability(drivers);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (c.mode === "app") {
|
|
116
|
+
const channels = c.channels ?? [];
|
|
117
|
+
assertOtpAppModeChannels(drivers, channels);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTP seal — HKDF domain separation + AES-GCM round-trip.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { deriveOtpSealKey, OTP_SEAL_HKDF_INFO, sealOtp, unsealOtp } from "./otp-seal.ts";
|
|
7
|
+
|
|
8
|
+
describe("otp-seal", () => {
|
|
9
|
+
test("info constant is the literal oke-otp-seal-v1", () => {
|
|
10
|
+
expect(OTP_SEAL_HKDF_INFO).toBe("oke-otp-seal-v1");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("round-trip seal / unseal", async () => {
|
|
14
|
+
const secret = "test-secret-at-least-16";
|
|
15
|
+
const otp = "482913";
|
|
16
|
+
const blob = await sealOtp(secret, otp);
|
|
17
|
+
expect(blob).not.toContain(otp);
|
|
18
|
+
expect(await unsealOtp(secret, blob)).toBe(otp);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("secret A cannot unseal secret B", async () => {
|
|
22
|
+
const blob = await sealOtp("secret-aaaaaaaaaaaa", "123456");
|
|
23
|
+
await expect(unsealOtp("secret-bbbbbbbbbbbb", blob)).rejects.toThrow();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("deriveOtpSealKey produces an AES-GCM key (not raw secret)", async () => {
|
|
27
|
+
const key = await deriveOtpSealKey("test-secret-at-least-16");
|
|
28
|
+
expect(key.type).toBe("secret");
|
|
29
|
+
expect(key.algorithm).toMatchObject({ name: "AES-GCM" });
|
|
30
|
+
// Non-extractable — raw secret bytes never leave HKDF as exportable AES material.
|
|
31
|
+
expect(key.extractable).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("HKDF info bytes pin domain separation (known key length)", async () => {
|
|
35
|
+
const ikm = new TextEncoder().encode("fixed-secret-for-vector");
|
|
36
|
+
const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
37
|
+
const bits = await crypto.subtle.deriveBits(
|
|
38
|
+
{
|
|
39
|
+
name: "HKDF",
|
|
40
|
+
hash: "SHA-256",
|
|
41
|
+
salt: new Uint8Array(0),
|
|
42
|
+
info: new TextEncoder().encode(OTP_SEAL_HKDF_INFO),
|
|
43
|
+
},
|
|
44
|
+
baseKey,
|
|
45
|
+
256,
|
|
46
|
+
);
|
|
47
|
+
expect(new Uint8Array(bits).byteLength).toBe(32);
|
|
48
|
+
// Different info → different bits
|
|
49
|
+
const other = await crypto.subtle.deriveBits(
|
|
50
|
+
{
|
|
51
|
+
name: "HKDF",
|
|
52
|
+
hash: "SHA-256",
|
|
53
|
+
salt: new Uint8Array(0),
|
|
54
|
+
info: new TextEncoder().encode("session-hmac-v1"),
|
|
55
|
+
},
|
|
56
|
+
baseKey,
|
|
57
|
+
256,
|
|
58
|
+
);
|
|
59
|
+
expect(Buffer.from(bits).equals(Buffer.from(other))).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|