okengine 0.7.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 +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- 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 +44 -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 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -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/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- 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-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -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 +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -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 +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- 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 +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- 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 +20 -27
- 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 +172 -0
- 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 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
|
@@ -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`
|
|
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
|
|
|
@@ -74,6 +75,8 @@ Read when `drivers.channel.email` resolves to that driver id.
|
|
|
74
75
|
| `RESEND_API_KEY` | `resend` API key |
|
|
75
76
|
| `SNDR_API_KEY` | `sndr` API key |
|
|
76
77
|
| `SNDR_BASE_URL` | Optional SNDR API origin (default `api.sndr.sh`) |
|
|
78
|
+
| `TAQNYAT_MAIL_TOKEN` | `taqnyat-mail` bearer token (Email-enabled) |
|
|
79
|
+
| `TAQNYAT_CAMPAIGN` | `taqnyat-mail` campaign name |
|
|
77
80
|
|
|
78
81
|
## Channel (SMS) — boot binder
|
|
79
82
|
|
|
@@ -94,6 +97,16 @@ Read when `drivers.channel.sms` resolves to that driver id (`console` opens noth
|
|
|
94
97
|
WhatsApp (`wa-cloud`) and push (`webpush` / `fcm`) are not opened from env at
|
|
95
98
|
boot — pass them on `BootOptions.channel.drivers` with their open options.
|
|
96
99
|
|
|
100
|
+
## Live test gates (opt-in)
|
|
101
|
+
|
|
102
|
+
Provider-quota-burning live suites are double-gated: the medium flag **plus** that
|
|
103
|
+
provider's real credentials — credentials alone never send.
|
|
104
|
+
|
|
105
|
+
| Variable | Used for |
|
|
106
|
+
| ---------------- | --------------------------------------------------------- |
|
|
107
|
+
| `OKE_SMS_LIVE` | `=1` allows live SMS provider tests (e.g. Taqnyat OTP) |
|
|
108
|
+
| `OKE_EMAIL_LIVE` | `=1` allows live email provider tests (e.g. Taqnyat Mail) |
|
|
109
|
+
|
|
97
110
|
## AI providers
|
|
98
111
|
|
|
99
112
|
| Variable | Used for | Default when unset |
|
|
@@ -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 (Tier 1 / Tier 2).
|
|
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 tier: 1 | 2;
|
|
12
|
+
readonly channels?: readonly OtpChannel[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Whether an SMS transport exposes provider-managed OTP (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 Tier 1: a Verify-capable SMS driver must be bound.
|
|
62
|
+
*
|
|
63
|
+
* @param drivers - Bound channel drivers
|
|
64
|
+
*/
|
|
65
|
+
export function assertOtpTier1Capability(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({ tier: 1 }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ tier: 2, channels: [...] })',
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const id = sms[0]?.id ?? "unknown";
|
|
74
|
+
throw new Error(
|
|
75
|
+
`otp({ tier: 1 }): SMS driver "${id}" does not support provider-managed OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ tier: 2, channels: [...] })`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Assert Tier 2: every declared channel has a deliverable driver.
|
|
81
|
+
*
|
|
82
|
+
* @param drivers - Bound channel drivers
|
|
83
|
+
* @param channels - Declared channel order
|
|
84
|
+
*/
|
|
85
|
+
export function assertOtpTier2Channels(
|
|
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({ tier: 2 }): 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.tier === 1) {
|
|
112
|
+
assertOtpTier1Capability(drivers);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (c.tier === 2) {
|
|
116
|
+
const channels = c.channels ?? [];
|
|
117
|
+
assertOtpTier2Channels(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
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain-separated sealed OTP copy for Tier-2 redelivery.
|
|
3
|
+
*
|
|
4
|
+
* AES-GCM key is **never** the raw auth secret. It is derived via
|
|
5
|
+
* HKDF-SHA-256 with info `oke-otp-seal-v1` so session HMAC material stays
|
|
6
|
+
* cryptographically isolated from seal encryption.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** HKDF `info` — versioned; bump when the seal format changes. */
|
|
10
|
+
export const OTP_SEAL_HKDF_INFO = "oke-otp-seal-v1";
|
|
11
|
+
|
|
12
|
+
const INFO_BYTES = new TextEncoder().encode(OTP_SEAL_HKDF_INFO);
|
|
13
|
+
const EMPTY_SALT = new Uint8Array(0);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Derive the AES-256-GCM key for OTP sealing from the method secret.
|
|
17
|
+
*
|
|
18
|
+
* @param secret - Root auth secret (`resolveMethodSecret`)
|
|
19
|
+
*/
|
|
20
|
+
export async function deriveOtpSealKey(secret: string): Promise<CryptoKey> {
|
|
21
|
+
const ikm = new TextEncoder().encode(secret);
|
|
22
|
+
const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
|
|
23
|
+
return crypto.subtle.deriveKey(
|
|
24
|
+
{
|
|
25
|
+
name: "HKDF",
|
|
26
|
+
hash: "SHA-256",
|
|
27
|
+
salt: EMPTY_SALT,
|
|
28
|
+
info: INFO_BYTES,
|
|
29
|
+
},
|
|
30
|
+
baseKey,
|
|
31
|
+
{ name: "AES-GCM", length: 256 },
|
|
32
|
+
false,
|
|
33
|
+
["encrypt", "decrypt"],
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Seal a plaintext OTP for later redelivery (base64 `IV || ciphertext+tag`).
|
|
39
|
+
*
|
|
40
|
+
* @param secret - Root auth secret
|
|
41
|
+
* @param otp - Raw OTP digits
|
|
42
|
+
*/
|
|
43
|
+
export async function sealOtp(secret: string, otp: string): Promise<string> {
|
|
44
|
+
const key = await deriveOtpSealKey(secret);
|
|
45
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
46
|
+
const cipher = await crypto.subtle.encrypt(
|
|
47
|
+
{ name: "AES-GCM", iv },
|
|
48
|
+
key,
|
|
49
|
+
new TextEncoder().encode(otp),
|
|
50
|
+
);
|
|
51
|
+
const packed = new Uint8Array(iv.length + cipher.byteLength);
|
|
52
|
+
packed.set(iv, 0);
|
|
53
|
+
packed.set(new Uint8Array(cipher), iv.length);
|
|
54
|
+
return Buffer.from(packed).toString("base64");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Unseal a previously sealed OTP. Call only at the deliberate delivery boundary.
|
|
59
|
+
*
|
|
60
|
+
* @param secret - Root auth secret
|
|
61
|
+
* @param blob - Base64 sealed blob from {@link sealOtp}
|
|
62
|
+
*/
|
|
63
|
+
export async function unsealOtp(secret: string, blob: string): Promise<string> {
|
|
64
|
+
const key = await deriveOtpSealKey(secret);
|
|
65
|
+
const packed = Buffer.from(blob, "base64");
|
|
66
|
+
if (packed.byteLength < 13) {
|
|
67
|
+
throw new Error("otp-seal: malformed sealed blob");
|
|
68
|
+
}
|
|
69
|
+
const iv = packed.subarray(0, 12);
|
|
70
|
+
const data = packed.subarray(12);
|
|
71
|
+
const plain = await crypto.subtle.decrypt(
|
|
72
|
+
{ name: "AES-GCM", iv: new Uint8Array(iv) },
|
|
73
|
+
key,
|
|
74
|
+
toArrayBuffer(data),
|
|
75
|
+
);
|
|
76
|
+
return new TextDecoder().decode(plain);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Copy into a real ArrayBuffer for Web Crypto BufferSource typing. */
|
|
80
|
+
function toArrayBuffer(view: Uint8Array): ArrayBuffer {
|
|
81
|
+
const copy = new Uint8Array(view.byteLength);
|
|
82
|
+
copy.set(view);
|
|
83
|
+
return copy.buffer;
|
|
84
|
+
}
|
package/src/auth/schema.ts
CHANGED
|
@@ -73,6 +73,8 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
|
|
|
73
73
|
createdAt: "created_at",
|
|
74
74
|
expiresAt: "expires_at",
|
|
75
75
|
lastActiveAt: "last_active_at",
|
|
76
|
+
scopes: "scopes",
|
|
77
|
+
audience: "audience",
|
|
76
78
|
},
|
|
77
79
|
refreshToken: {
|
|
78
80
|
id: "id",
|
|
@@ -186,6 +188,7 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
|
|
|
186
188
|
value: "TEXT",
|
|
187
189
|
description: "TEXT",
|
|
188
190
|
scopes: "TEXT",
|
|
191
|
+
audience: "TEXT",
|
|
189
192
|
lastUsedAt: "INTEGER",
|
|
190
193
|
};
|
|
191
194
|
|
package/src/auth/sessions.ts
CHANGED
|
@@ -130,11 +130,10 @@ export async function issueSession(
|
|
|
130
130
|
createdAt: t,
|
|
131
131
|
expiresAt: t + absoluteCap,
|
|
132
132
|
lastActiveAt: t,
|
|
133
|
+
scopes: [...principal.scopes],
|
|
134
|
+
...(crypto.audience !== undefined ? { audience: crypto.audience } : {}),
|
|
133
135
|
};
|
|
134
136
|
store.sessions.set(sessionId, session);
|
|
135
|
-
if (crypto.audience !== undefined) {
|
|
136
|
-
sessionAudiences.set(sessionId, crypto.audience);
|
|
137
|
-
}
|
|
138
137
|
|
|
139
138
|
const refreshRaw = `rt_${cryptoRandomId()}`;
|
|
140
139
|
const refreshRow: RefreshTokenRow = {
|
|
@@ -223,20 +222,15 @@ export async function rotateRefresh(
|
|
|
223
222
|
};
|
|
224
223
|
store.refresh.set(newRefresh.id, newRefresh);
|
|
225
224
|
|
|
226
|
-
// Recover scopes from the previous access path — stored on session via
|
|
227
|
-
// a side map would be ideal; for builtin we re-sign with empty and let
|
|
228
|
-
// callers pass scopes through verify. We keep scopes on a claim cache:
|
|
229
|
-
const priorScopes = sessionScopes.get(session.id) ?? [];
|
|
230
|
-
const priorAud = sessionAudiences.get(session.id);
|
|
231
225
|
const accessExpiresAt = t + accessTtl;
|
|
232
226
|
const accessToken = await signAccess(crypto.secret, {
|
|
233
227
|
sub: session.principalId,
|
|
234
228
|
plane: session.plane,
|
|
235
229
|
sid: session.id,
|
|
236
|
-
scopes:
|
|
230
|
+
scopes: session.scopes,
|
|
237
231
|
iat: t,
|
|
238
232
|
exp: accessExpiresAt,
|
|
239
|
-
...(
|
|
233
|
+
...(session.audience !== undefined ? { aud: session.audience } : {}),
|
|
240
234
|
});
|
|
241
235
|
|
|
242
236
|
return {
|
|
@@ -247,34 +241,44 @@ export async function rotateRefresh(
|
|
|
247
241
|
};
|
|
248
242
|
}
|
|
249
243
|
|
|
250
|
-
/** Session id → scopes (access-token material). */
|
|
251
|
-
const sessionScopes = new Map<string, string[]>();
|
|
252
|
-
|
|
253
|
-
/** Session id → audience stamped at issue time. */
|
|
254
|
-
const sessionAudiences = new Map<string, string>();
|
|
255
|
-
|
|
256
244
|
/**
|
|
257
245
|
* Remember scopes for refresh rotation (call after {@link issueSession}).
|
|
246
|
+
* Scopes live on the session row — shared with any store hydrate/persist path.
|
|
258
247
|
*
|
|
248
|
+
* @param store - Session store
|
|
259
249
|
* @param sessionId - Session id
|
|
260
250
|
* @param scopes - Scopes
|
|
261
251
|
*/
|
|
262
|
-
export function bindSessionScopes(
|
|
263
|
-
|
|
252
|
+
export function bindSessionScopes(
|
|
253
|
+
store: SessionStore,
|
|
254
|
+
sessionId: string,
|
|
255
|
+
scopes: Iterable<string>,
|
|
256
|
+
): void {
|
|
257
|
+
const session = store.sessions.get(sessionId);
|
|
258
|
+
if (!session) return;
|
|
259
|
+
// Mutate in place — SessionRow is a store-owned mutable row.
|
|
260
|
+
(session as { scopes: string[] }).scopes = [...scopes];
|
|
264
261
|
}
|
|
265
262
|
|
|
266
263
|
/**
|
|
267
264
|
* Remember audience for refresh rotation (call after {@link issueSession}).
|
|
268
265
|
*
|
|
266
|
+
* @param store - Session store
|
|
269
267
|
* @param sessionId - Session id
|
|
270
268
|
* @param audience - Audience claim
|
|
271
269
|
*/
|
|
272
|
-
export function bindSessionAudience(
|
|
273
|
-
|
|
270
|
+
export function bindSessionAudience(
|
|
271
|
+
store: SessionStore,
|
|
272
|
+
sessionId: string,
|
|
273
|
+
audience: string,
|
|
274
|
+
): void {
|
|
275
|
+
const session = store.sessions.get(sessionId);
|
|
276
|
+
if (!session) return;
|
|
277
|
+
(session as { audience?: string }).audience = audience;
|
|
274
278
|
}
|
|
275
279
|
|
|
276
280
|
/**
|
|
277
|
-
* Issue a session and
|
|
281
|
+
* Issue a session with scopes (and optional audience) on the session row.
|
|
278
282
|
*
|
|
279
283
|
* @param store - Session store
|
|
280
284
|
* @param crypto - Crypto
|
|
@@ -289,12 +293,7 @@ export async function issueSessionWithScopes(
|
|
|
289
293
|
readonly scopes: Iterable<string>;
|
|
290
294
|
},
|
|
291
295
|
): Promise<IssuedSession> {
|
|
292
|
-
|
|
293
|
-
bindSessionScopes(issued.session.id, principal.scopes);
|
|
294
|
-
if (crypto.audience !== undefined) {
|
|
295
|
-
bindSessionAudience(issued.session.id, crypto.audience);
|
|
296
|
-
}
|
|
297
|
-
return issued;
|
|
296
|
+
return issueSession(store, crypto, principal);
|
|
298
297
|
}
|
|
299
298
|
|
|
300
299
|
/**
|
package/src/auth/tables.ts
CHANGED
|
@@ -121,6 +121,10 @@ export interface SessionRow {
|
|
|
121
121
|
expiresAt: number;
|
|
122
122
|
/** Last activity epoch-ms (idle timeout). Updated on refresh / access touch. */
|
|
123
123
|
lastActiveAt: number;
|
|
124
|
+
/** Access-token scopes — survives refresh / process restart with the session row. */
|
|
125
|
+
scopes: string[];
|
|
126
|
+
/** Audience stamped at issue (`oke-console` · `oke-mcp` · `oke-app`). */
|
|
127
|
+
audience?: string;
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
/** Refresh token (hashed at rest); rotation with reuse detection. */
|