okengine 0.3.6 → 0.5.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/AGENTS.md +2 -0
- package/package.json +14 -12
- package/site/content/docs/ai/index.mdx +24 -0
- package/site/content/docs/ai/llms-txt.mdx +3 -0
- package/site/content/docs/ai/meta.json +1 -1
- package/site/content/docs/ai/skills.mdx +5 -3
- package/site/content/docs/console/gates.mdx +46 -8
- package/site/content/docs/console/index.mdx +54 -0
- package/site/content/docs/console/meta.json +1 -0
- package/site/content/docs/elements/ai.mdx +2 -0
- package/site/content/docs/elements/channel.mdx +2 -0
- package/site/content/docs/elements/clock.mdx +1 -4
- package/site/content/docs/elements/flow.mdx +4 -10
- package/site/content/docs/elements/gate.mdx +189 -48
- package/site/content/docs/elements/index.mdx +45 -0
- package/site/content/docs/elements/meta.json +1 -1
- package/site/content/docs/elements/signal.mdx +1 -5
- package/site/content/docs/elements/store.mdx +27 -6
- package/site/content/docs/elements/vault.mdx +10 -11
- package/site/content/docs/get-started/basic-usage.mdx +79 -43
- package/site/content/docs/get-started/index.mdx +33 -0
- package/site/content/docs/get-started/installation.mdx +95 -43
- package/site/content/docs/get-started/introduction.mdx +128 -75
- package/site/content/docs/get-started/meta.json +1 -1
- package/site/content/docs/get-started/why.mdx +141 -0
- package/site/content/docs/index.mdx +9 -31
- package/site/content/docs/plugins/anonymous.mdx +95 -0
- package/site/content/docs/plugins/compression.mdx +2 -2
- package/site/content/docs/plugins/cors.mdx +2 -2
- package/site/content/docs/plugins/csrf.mdx +2 -2
- package/site/content/docs/plugins/email-otp.mdx +111 -0
- package/site/content/docs/plugins/{security-headers.mdx → headers.mdx} +2 -2
- package/site/content/docs/plugins/index.mdx +69 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +112 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +8 -8
- package/site/content/docs/plugins/meta.json +10 -1
- package/site/content/docs/plugins/passkey.mdx +128 -0
- package/site/content/docs/plugins/phone-number.mdx +111 -0
- package/site/content/docs/plugins/two-factor.mdx +116 -0
- package/site/content/docs/plugins/username.mdx +117 -0
- package/site/content/docs/reference/client.mdx +331 -0
- package/site/content/docs/reference/configuration.mdx +1 -1
- package/site/content/docs/reference/environment-variables.mdx +5 -3
- package/site/content/docs/reference/fx.mdx +36 -8
- package/site/content/docs/reference/index.mdx +45 -0
- package/site/content/docs/reference/meta.json +11 -1
- package/site/content/docs/reference/plugins.mdx +25 -14
- package/src/auth/auth.test.ts +20 -2
- package/src/auth/bindings.ts +439 -0
- package/src/auth/breach-check.ts +112 -0
- package/src/auth/config.ts +288 -0
- package/src/auth/cookies.ts +123 -0
- package/src/auth/gate-auth.test.ts +379 -0
- package/src/auth/identity.ts +190 -0
- package/src/auth/index.ts +117 -1
- package/src/auth/method-context.ts +33 -0
- package/src/auth/operator.ts +27 -1
- package/src/auth/password-policy.test.ts +126 -0
- package/src/auth/password-policy.ts +77 -0
- package/src/auth/plugin.ts +62 -4
- package/src/auth/rate.ts +45 -0
- package/src/auth/schema.ts +260 -0
- package/src/auth/secondary-storage.ts +37 -0
- package/src/auth/sessions.ts +58 -1
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +78 -0
- package/src/cli/competitor-mention-removal.test.ts +117 -0
- package/src/cli/dev.test.ts +3 -3
- package/src/cli/dev.ts +20 -0
- package/src/cli/meilisearch-local.test.ts +69 -0
- package/src/cli/meilisearch-local.ts +188 -0
- package/src/cli/schema.ts +95 -23
- package/src/client/auth.ts +120 -0
- package/src/client-react/index.ts +93 -0
- package/src/compiler/aot.test.ts +2 -1
- package/src/compiler/extract.ts +19 -0
- package/src/compiler/fixtures/skyport/src/flows/payments/index.ts +5 -1
- package/src/compiler/response.ts +16 -2
- package/src/console/server/app.ts +10 -6
- package/src/console/server/auth-rate.test.ts +3 -3
- package/src/console/server/bind.ts +12 -1
- package/src/console/server/channels.test.ts +1 -1
- package/src/console/server/console-gates.ts +14 -0
- package/src/console/server/console.test.ts +6 -6
- package/src/console/server/flows-invoke.test.ts +2 -2
- package/src/console/server/flows.ts +2 -0
- package/src/console/server/gates.ts +8 -1
- package/src/console/server/operator-db.test.ts +4 -4
- package/src/console/server/operator-db.ts +22 -4
- package/src/console/server/security.gate.test.ts +3 -3
- package/src/console/server/store.test.ts +1 -1
- package/src/console/server/store.ts +11 -1
- package/src/console/ui/dist/assets/index-CjxwRGVv.js +10 -0
- package/src/console/ui/dist/assets/panel-access-BGv45snf.js +64 -0
- package/src/console/ui/dist/assets/{panel-ai-D_m6WQI8.js → panel-ai-B2S7LEii.js} +1 -1
- package/src/console/ui/dist/assets/{panel-architecture-CKnXFyUx.js → panel-architecture-D7UJh91v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-channels-DCDd4WAC.js → panel-channels-9T3ybqRu.js} +1 -1
- package/src/console/ui/dist/assets/panel-clock-Cb1UXGRQ.js +1 -0
- package/src/console/ui/dist/assets/{panel-diff-cdonmH8c.js → panel-diff-DmYbKWmN.js} +1 -1
- package/src/console/ui/dist/assets/panel-flows-PiHwT55z.js +48 -0
- package/src/console/ui/dist/assets/{panel-gates-B5eTE8XH.js → panel-gates-BQGYXvjT.js} +1 -1
- package/src/console/ui/dist/assets/panel-overview-BBnRO18l.js +1 -0
- package/src/console/ui/dist/assets/{panel-plugins-Cj7DK1er.js → panel-plugins-D0PsmVw2.js} +1 -1
- package/src/console/ui/dist/assets/panel-runs-CWuRDe0r.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-whmDXIg3.js → panel-signals-Bbg4ewpP.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-CEMHLvaw.js → panel-store-CPCbsDRa.js} +1 -1
- package/src/console/ui/dist/assets/panel-traces-DVAzuA_S.js +1 -0
- package/src/console/ui/dist/assets/{panel-vault-C9wjbki8.js → panel-vault-D1_MvOmo.js} +1 -1
- package/src/console/ui/dist/assets/{rolldown-runtime-CNC7AqOf.js → rolldown-runtime-B0Z9INg1.js} +1 -1
- package/src/console/ui/dist/index.html +2 -2
- package/src/console/ui/gates/fixture.ts +4 -0
- package/src/console/ui/gates/types.ts +2 -0
- package/src/console/ui/shell/client.ts +1 -0
- package/src/docker/compose.ts +5 -0
- package/src/docker/docker.test.ts +41 -0
- package/src/docker/recipes/index.ts +10 -2
- package/src/docker/recipes/meilisearch.ts +31 -0
- package/src/drivers/conformance.test.ts +16 -1
- package/src/drivers/conformance.ts +40 -3
- package/src/drivers/index.ts +14 -2
- package/src/drivers/libsql.ts +4 -4
- package/src/drivers/meilisearch.integration.test.ts +77 -0
- package/src/drivers/meilisearch.test.ts +181 -0
- package/src/drivers/meilisearch.ts +208 -0
- package/src/drivers/memory.ts +4 -4
- package/src/drivers/pgvector.ts +6 -6
- package/src/drivers/types.ts +93 -12
- package/src/drivers/vault-driver-removal.test.ts +6 -0
- package/src/drivers/vault-types.ts +4 -4
- package/src/elements/ai/runtime.ts +6 -0
- package/src/elements/ai.test.ts +22 -0
- package/src/elements/gate/boot.ts +136 -0
- package/src/elements/gate/config.ts +69 -0
- package/src/elements/gate/declare.ts +51 -1
- package/src/elements/gate/runtime.ts +3 -1
- package/src/elements/gate.test.ts +77 -0
- package/src/elements/gate.ts +20 -1
- package/src/elements/index.ts +8 -0
- package/src/elements/store/index-boot.test.ts +49 -7
- package/src/elements/store/runtime.ts +50 -15
- package/src/elements/store.ts +2 -0
- package/src/elements/vault.test.ts +27 -4
- package/src/elements/vault.ts +1 -1
- package/src/index.ts +15 -0
- package/src/kernel/app.ts +253 -32
- package/src/kernel/boot-bind/store.test.ts +9 -0
- package/src/kernel/boot-bind/store.ts +30 -2
- package/src/kernel/boot.test.ts +40 -3
- package/src/kernel/boot.ts +8 -0
- package/src/kernel/call.test.ts +46 -2
- package/src/kernel/concurrency.test.ts +58 -0
- package/src/kernel/concurrency.ts +48 -0
- package/src/kernel/edge.test.ts +3 -3
- package/src/kernel/flow.test.ts +2 -2
- package/src/kernel/fx.test.ts +12 -2
- package/src/kernel/fx.ts +97 -5
- package/src/kernel/hooks.test.ts +4 -4
- package/src/kernel/index.ts +22 -1
- package/src/kernel/pipeline.test.ts +12 -8
- package/src/kernel/pipeline.ts +23 -4
- package/src/kernel/plugin/decorate.test.ts +3 -3
- package/src/kernel/plugin/scoping.test.ts +3 -3
- package/src/kernel/plugin-elements.test.ts +51 -0
- package/src/kernel/plugin-needs.test.ts +83 -0
- package/src/kernel/plugin-needs.ts +129 -0
- package/src/kernel/plugin.ts +101 -0
- package/src/kernel/redacted.ts +74 -0
- package/src/kernel/registry-isolation.test.ts +5 -5
- package/src/kernel/registry.ts +102 -3
- package/src/kernel/router.ts +3 -3
- package/src/manifest/types.ts +2 -0
- package/src/plugins/anonymous.ts +58 -0
- package/src/plugins/auth/shared.ts +121 -0
- package/src/plugins/auth-methods.test.ts +176 -0
- package/src/plugins/compression.test.ts +5 -5
- package/src/plugins/config-source.test.ts +1 -1
- package/src/plugins/cors.test.ts +16 -10
- package/src/plugins/csrf.test.ts +1 -1
- package/src/plugins/email-otp.ts +161 -0
- package/src/plugins/index.ts +31 -0
- package/src/plugins/ip-allowlist.test.ts +19 -9
- package/src/plugins/magic-link.ts +163 -0
- package/src/plugins/maintenance-mode.test.ts +9 -5
- package/src/plugins/passkey.ts +216 -0
- package/src/plugins/phone-number.ts +149 -0
- package/src/plugins/security-headers.test.ts +14 -14
- package/src/plugins/two-factor.ts +249 -0
- package/src/plugins/username.ts +148 -0
- package/src/runs/runs.test.ts +6 -2
- package/src/runtime/primitives.ts +37 -4
- package/src/runtime/serve.test.ts +3 -2
- package/src/runtime/types.ts +25 -2
- package/src/test/create-test-app.test.ts +1 -1
- package/src/test/create-test-app.ts +4 -1
- package/src/test/provisions.integration.test.ts +2 -2
- package/site/content/docs/get-started/comparison.mdx +0 -65
- package/src/console/ui/dist/assets/index-CrKMmO__.js +0 -10
- package/src/console/ui/dist/assets/panel-access-C0J2D-a2.js +0 -64
- package/src/console/ui/dist/assets/panel-clock-DjGGFPzr.js +0 -1
- package/src/console/ui/dist/assets/panel-flows-DlCU5zjA.js +0 -45
- package/src/console/ui/dist/assets/panel-overview-BsFvDdts.js +0 -1
- package/src/console/ui/dist/assets/panel-runs-C0gmnoYL.js +0 -1
- package/src/console/ui/dist/assets/panel-traces-BDiAuVSK.js +0 -1
- package/src/drivers/vault-infisical.ts +0 -57
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Passkey (WebAuthn-shaped) Gate auth method plugin — simplified v1 ceremony.
|
|
3
|
+
*
|
|
4
|
+
* Registration / authentication accept attestation-like payloads for tests
|
|
5
|
+
* without a full WebAuthn library. Production should replace the ceremony
|
|
6
|
+
* with a standards-compliant verifier.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
createVerificationStore,
|
|
11
|
+
hashChallenge,
|
|
12
|
+
putVerification,
|
|
13
|
+
type VerificationStore,
|
|
14
|
+
} from "../auth/verification.ts";
|
|
15
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
16
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
17
|
+
import {
|
|
18
|
+
AuthFailed,
|
|
19
|
+
AuthRateLimited,
|
|
20
|
+
SessionTokensOut,
|
|
21
|
+
bindPublicAuth,
|
|
22
|
+
bindSessionAuth,
|
|
23
|
+
createMethodRuntime,
|
|
24
|
+
fail,
|
|
25
|
+
flow,
|
|
26
|
+
z,
|
|
27
|
+
type AuthMethodOptions,
|
|
28
|
+
} from "./auth/shared.ts";
|
|
29
|
+
|
|
30
|
+
/** Stored passkey credential (simplified). */
|
|
31
|
+
export interface PasskeyCredential {
|
|
32
|
+
readonly credentialId: string;
|
|
33
|
+
readonly userId: string;
|
|
34
|
+
readonly publicKey: string;
|
|
35
|
+
counter: number;
|
|
36
|
+
readonly createdAt: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** In-memory passkey store. */
|
|
40
|
+
export interface PasskeyStore {
|
|
41
|
+
readonly byCredentialId: Map<string, PasskeyCredential>;
|
|
42
|
+
readonly byUserId: Map<string, PasskeyCredential[]>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create an empty passkey store.
|
|
47
|
+
*/
|
|
48
|
+
export function createPasskeyStore(): PasskeyStore {
|
|
49
|
+
return { byCredentialId: new Map(), byUserId: new Map() };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Options for {@link passkey}. */
|
|
53
|
+
export interface PasskeyOptions extends AuthMethodOptions {
|
|
54
|
+
readonly passkeys?: PasskeyStore;
|
|
55
|
+
readonly challenges?: VerificationStore;
|
|
56
|
+
/** Relying party id (default `localhost`). */
|
|
57
|
+
readonly rpId?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Passkey register / authenticate options + simplified ceremony (`oke_passkeys`).
|
|
62
|
+
*
|
|
63
|
+
* @param opts - Stores / RP id
|
|
64
|
+
*/
|
|
65
|
+
export function passkey(opts: PasskeyOptions = {}): PluginDef {
|
|
66
|
+
const runtime = createMethodRuntime(opts);
|
|
67
|
+
const passkeys = opts.passkeys ?? createPasskeyStore();
|
|
68
|
+
const challenges = opts.challenges ?? createVerificationStore();
|
|
69
|
+
const rpId = opts.rpId ?? "localhost";
|
|
70
|
+
|
|
71
|
+
const registerOptions = flow({
|
|
72
|
+
name: "auth.passkeyRegisterOptions",
|
|
73
|
+
unit: "auth",
|
|
74
|
+
plane: "user",
|
|
75
|
+
out: z.object({
|
|
76
|
+
challenge: z.string(),
|
|
77
|
+
rpId: z.string(),
|
|
78
|
+
userId: z.string(),
|
|
79
|
+
}),
|
|
80
|
+
errors: { AuthFailed },
|
|
81
|
+
do: async (_input, fx) => {
|
|
82
|
+
const userId = fx.auth.userId;
|
|
83
|
+
if (!userId) return fail("AuthFailed", { reason: "unauthenticated" });
|
|
84
|
+
const challenge = crypto.randomUUID().replace(/-/g, "");
|
|
85
|
+
const now = runtime.now();
|
|
86
|
+
putVerification(challenges, {
|
|
87
|
+
id: crypto.randomUUID(),
|
|
88
|
+
identifier: `passkey-reg:${userId}`,
|
|
89
|
+
value: await hashChallenge(challenge),
|
|
90
|
+
expiresAt: now + 5 * 60 * 1000,
|
|
91
|
+
createdAt: now,
|
|
92
|
+
consumedAt: null,
|
|
93
|
+
attempts: 0,
|
|
94
|
+
});
|
|
95
|
+
return { challenge, rpId, userId };
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const register = flow({
|
|
100
|
+
name: "auth.passkeyRegister",
|
|
101
|
+
unit: "auth",
|
|
102
|
+
plane: "user",
|
|
103
|
+
in: z.object({
|
|
104
|
+
credentialId: z.string().min(1),
|
|
105
|
+
publicKey: z.string().min(1),
|
|
106
|
+
userId: z.string().min(1),
|
|
107
|
+
challenge: z.string().optional(),
|
|
108
|
+
}),
|
|
109
|
+
out: z.object({ ok: z.literal(true) }),
|
|
110
|
+
errors: { AuthFailed },
|
|
111
|
+
do: async (input, fx) => {
|
|
112
|
+
const sessionUser = fx.auth.userId;
|
|
113
|
+
if (!sessionUser || sessionUser !== input.userId) {
|
|
114
|
+
return fail("AuthFailed", { reason: "unauthenticated" });
|
|
115
|
+
}
|
|
116
|
+
if (input.challenge) {
|
|
117
|
+
const hash = await hashChallenge(input.challenge);
|
|
118
|
+
const now = runtime.now();
|
|
119
|
+
let found = false;
|
|
120
|
+
for (const row of challenges.rows.values()) {
|
|
121
|
+
if (row.identifier !== `passkey-reg:${input.userId}`) continue;
|
|
122
|
+
if (row.consumedAt !== null || row.expiresAt <= now) continue;
|
|
123
|
+
if (row.value === hash) {
|
|
124
|
+
row.consumedAt = now;
|
|
125
|
+
found = true;
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if (!found) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
130
|
+
}
|
|
131
|
+
if (passkeys.byCredentialId.has(input.credentialId)) {
|
|
132
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
133
|
+
}
|
|
134
|
+
const cred: PasskeyCredential = {
|
|
135
|
+
credentialId: input.credentialId,
|
|
136
|
+
userId: input.userId,
|
|
137
|
+
publicKey: input.publicKey,
|
|
138
|
+
counter: 0,
|
|
139
|
+
createdAt: runtime.now(),
|
|
140
|
+
};
|
|
141
|
+
passkeys.byCredentialId.set(cred.credentialId, cred);
|
|
142
|
+
const list = passkeys.byUserId.get(cred.userId) ?? [];
|
|
143
|
+
list.push(cred);
|
|
144
|
+
passkeys.byUserId.set(cred.userId, list);
|
|
145
|
+
return { ok: true as const };
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const authenticateOptions = flow({
|
|
150
|
+
name: "auth.passkeyAuthenticateOptions",
|
|
151
|
+
unit: "auth",
|
|
152
|
+
plane: "user",
|
|
153
|
+
in: z.object({ email: z.string().optional() }),
|
|
154
|
+
out: z.object({
|
|
155
|
+
challenge: z.string(),
|
|
156
|
+
rpId: z.string(),
|
|
157
|
+
allowCredentials: z.array(z.string()),
|
|
158
|
+
}),
|
|
159
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
160
|
+
do: async (input) => {
|
|
161
|
+
const challenge = crypto.randomUUID().replace(/-/g, "");
|
|
162
|
+
const now = runtime.now();
|
|
163
|
+
const key = input.email?.trim().toLowerCase() || "anonymous";
|
|
164
|
+
putVerification(challenges, {
|
|
165
|
+
id: crypto.randomUUID(),
|
|
166
|
+
identifier: `passkey-auth:${key}`,
|
|
167
|
+
value: await hashChallenge(challenge),
|
|
168
|
+
expiresAt: now + 5 * 60 * 1000,
|
|
169
|
+
createdAt: now,
|
|
170
|
+
consumedAt: null,
|
|
171
|
+
attempts: 0,
|
|
172
|
+
});
|
|
173
|
+
// Simplified: empty allow list when no email mapping (caller supplies credentialId).
|
|
174
|
+
return { challenge, rpId, allowCredentials: [] as string[] };
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
const authenticate = flow({
|
|
179
|
+
name: "auth.passkeyAuthenticate",
|
|
180
|
+
unit: "auth",
|
|
181
|
+
plane: "user",
|
|
182
|
+
in: z.object({
|
|
183
|
+
credentialId: z.string().min(1),
|
|
184
|
+
userId: z.string().min(1),
|
|
185
|
+
}),
|
|
186
|
+
out: SessionTokensOut,
|
|
187
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
188
|
+
do: async (input) => {
|
|
189
|
+
// Simplified v1: presence of stored credential + matching userId issues a session.
|
|
190
|
+
const cred = passkeys.byCredentialId.get(input.credentialId);
|
|
191
|
+
if (!cred || cred.userId !== input.userId) {
|
|
192
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
193
|
+
}
|
|
194
|
+
cred.counter += 1;
|
|
195
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
196
|
+
id: cred.userId,
|
|
197
|
+
plane: "user",
|
|
198
|
+
scopes: [],
|
|
199
|
+
});
|
|
200
|
+
return {
|
|
201
|
+
accessToken: issued.accessToken,
|
|
202
|
+
refreshToken: issued.refreshToken,
|
|
203
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
204
|
+
userId: cred.userId,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
return plugin("passkey", { version: "0.0.1", config: { method: "passkey" } })
|
|
210
|
+
.needs("auth")
|
|
211
|
+
.table("oke_passkeys", undefined, { plane: "user", description: "WebAuthn credentials" })
|
|
212
|
+
.binding(bindSessionAuth("/passkey/register/options", registerOptions))
|
|
213
|
+
.binding(bindSessionAuth("/passkey/register", register))
|
|
214
|
+
.binding(bindPublicAuth("/passkey/authenticate/options", authenticateOptions, "otp"))
|
|
215
|
+
.binding(bindPublicAuth("/passkey/authenticate", authenticate, "otp"));
|
|
216
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phone-number OTP Gate auth method plugin (E.164).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
6
|
+
import {
|
|
7
|
+
createVerificationStore,
|
|
8
|
+
findActiveVerification,
|
|
9
|
+
generateOtp,
|
|
10
|
+
hashChallenge,
|
|
11
|
+
putVerification,
|
|
12
|
+
type VerificationStore,
|
|
13
|
+
} from "../auth/verification.ts";
|
|
14
|
+
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
15
|
+
import {
|
|
16
|
+
AuthFailed,
|
|
17
|
+
AuthRateLimited,
|
|
18
|
+
SessionTokensOut,
|
|
19
|
+
bindPublicAuth,
|
|
20
|
+
createMethodRuntime,
|
|
21
|
+
fail,
|
|
22
|
+
flow,
|
|
23
|
+
z,
|
|
24
|
+
type AuthMethodOptions,
|
|
25
|
+
} from "./auth/shared.ts";
|
|
26
|
+
|
|
27
|
+
const E164 = /^\+[1-9]\d{7,14}$/;
|
|
28
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
29
|
+
const MAX_ATTEMPTS = 5;
|
|
30
|
+
|
|
31
|
+
/** Phone principal store (phone → userId). */
|
|
32
|
+
export interface PhoneStore {
|
|
33
|
+
readonly byPhone: Map<string, string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Create an empty phone → user map.
|
|
38
|
+
*/
|
|
39
|
+
export function createPhoneStore(): PhoneStore {
|
|
40
|
+
return { byPhone: new Map() };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Options for {@link phoneNumber}. */
|
|
44
|
+
export interface PhoneNumberOptions extends AuthMethodOptions {
|
|
45
|
+
readonly ttlMs?: number;
|
|
46
|
+
readonly phones?: PhoneStore;
|
|
47
|
+
readonly verifications?: VerificationStore;
|
|
48
|
+
/** Return raw OTP in the request response (test / local). */
|
|
49
|
+
readonly exposeDevOtp?: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Phone OTP request + verify (E.164, 6-digit, hashed, 5 attempts).
|
|
54
|
+
*
|
|
55
|
+
* @param opts - TTL / stores / dev OTP
|
|
56
|
+
*/
|
|
57
|
+
export function phoneNumber(opts: PhoneNumberOptions = {}): PluginDef {
|
|
58
|
+
const runtime = createMethodRuntime(opts);
|
|
59
|
+
const phones = opts.phones ?? createPhoneStore();
|
|
60
|
+
const verifications = opts.verifications ?? createVerificationStore();
|
|
61
|
+
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
62
|
+
|
|
63
|
+
const request = flow({
|
|
64
|
+
name: "auth.requestPhoneOtp",
|
|
65
|
+
unit: "auth",
|
|
66
|
+
plane: "user",
|
|
67
|
+
in: z.object({ phone: z.string().min(8) }),
|
|
68
|
+
out: z.object({
|
|
69
|
+
ok: z.literal(true),
|
|
70
|
+
devOtp: z.string().optional(),
|
|
71
|
+
}),
|
|
72
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
73
|
+
do: async (input) => {
|
|
74
|
+
const phone = input.phone.trim();
|
|
75
|
+
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
76
|
+
const otp = generateOtp(6);
|
|
77
|
+
const now = runtime.now();
|
|
78
|
+
for (const row of verifications.rows.values()) {
|
|
79
|
+
if (row.identifier === `phone-otp:${phone}` && row.consumedAt === null) {
|
|
80
|
+
row.consumedAt = now;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
putVerification(verifications, {
|
|
84
|
+
id: crypto.randomUUID(),
|
|
85
|
+
identifier: `phone-otp:${phone}`,
|
|
86
|
+
value: await hashChallenge(otp),
|
|
87
|
+
expiresAt: now + ttlMs,
|
|
88
|
+
createdAt: now,
|
|
89
|
+
consumedAt: null,
|
|
90
|
+
attempts: 0,
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
ok: true as const,
|
|
94
|
+
...(opts.exposeDevOtp ? { devOtp: otp } : {}),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const verify = flow({
|
|
100
|
+
name: "auth.verifyPhoneOtp",
|
|
101
|
+
unit: "auth",
|
|
102
|
+
plane: "user",
|
|
103
|
+
in: z.object({
|
|
104
|
+
phone: z.string().min(8),
|
|
105
|
+
otp: z.string().min(4).max(8),
|
|
106
|
+
}),
|
|
107
|
+
out: SessionTokensOut,
|
|
108
|
+
errors: { AuthFailed, AuthRateLimited },
|
|
109
|
+
do: async (input) => {
|
|
110
|
+
const phone = input.phone.trim();
|
|
111
|
+
if (!E164.test(phone)) return fail("AuthFailed", { reason: "invalid_phone" });
|
|
112
|
+
const now = runtime.now();
|
|
113
|
+
const row = findActiveVerification(verifications, `phone-otp:${phone}`, now);
|
|
114
|
+
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
115
|
+
if (row.attempts >= MAX_ATTEMPTS) {
|
|
116
|
+
row.consumedAt = now;
|
|
117
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
118
|
+
}
|
|
119
|
+
const hash = await hashChallenge(input.otp.trim());
|
|
120
|
+
if (hash !== row.value) {
|
|
121
|
+
row.attempts += 1;
|
|
122
|
+
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
123
|
+
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
124
|
+
}
|
|
125
|
+
row.consumedAt = now;
|
|
126
|
+
let userId = phones.byPhone.get(phone);
|
|
127
|
+
if (!userId) {
|
|
128
|
+
userId = crypto.randomUUID();
|
|
129
|
+
phones.byPhone.set(phone, userId);
|
|
130
|
+
}
|
|
131
|
+
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
132
|
+
id: userId,
|
|
133
|
+
plane: "user",
|
|
134
|
+
scopes: [],
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
accessToken: issued.accessToken,
|
|
138
|
+
refreshToken: issued.refreshToken,
|
|
139
|
+
accessExpiresAt: issued.accessExpiresAt,
|
|
140
|
+
userId,
|
|
141
|
+
};
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
return plugin("phoneNumber", { version: "0.0.1", config: { method: "phone-number" } })
|
|
146
|
+
.needs("auth")
|
|
147
|
+
.binding(bindPublicAuth("/phone/request", request, "otp"))
|
|
148
|
+
.binding(bindPublicAuth("/phone/verify", verify, "otp"));
|
|
149
|
+
}
|
|
@@ -19,7 +19,7 @@ beforeEach(() => {
|
|
|
19
19
|
describe("securityHeaders plugin", () => {
|
|
20
20
|
test("sets the default trio on a successful response", async () => {
|
|
21
21
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
22
|
-
const app = oke({ name: "sec" }).plug(securityHeaders());
|
|
22
|
+
const app = oke({ autoBoot: false, name: "sec" }).plug(securityHeaders());
|
|
23
23
|
|
|
24
24
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
25
25
|
|
|
@@ -32,7 +32,7 @@ describe("securityHeaders plugin", () => {
|
|
|
32
32
|
|
|
33
33
|
test("helmet-parity defaults: agent cluster, dns prefetch, download, cross-domain, xss filter", async () => {
|
|
34
34
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
35
|
-
const app = oke({ name: "sec-parity" }).plug(securityHeaders());
|
|
35
|
+
const app = oke({ autoBoot: false, name: "sec-parity" }).plug(securityHeaders());
|
|
36
36
|
|
|
37
37
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
38
38
|
|
|
@@ -45,7 +45,7 @@ describe("securityHeaders plugin", () => {
|
|
|
45
45
|
|
|
46
46
|
test("each parity default can be switched off", async () => {
|
|
47
47
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
48
|
-
const app = oke({ name: "sec-off" }).plug(
|
|
48
|
+
const app = oke({ autoBoot: false, name: "sec-off" }).plug(
|
|
49
49
|
securityHeaders({
|
|
50
50
|
originAgentCluster: false,
|
|
51
51
|
dnsPrefetchControl: false,
|
|
@@ -65,7 +65,7 @@ describe("securityHeaders plugin", () => {
|
|
|
65
65
|
|
|
66
66
|
test("dnsPrefetchControl allow, custom cross-domain policy, COEP stamps when configured", async () => {
|
|
67
67
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
68
|
-
const app = oke({ name: "sec-tuned" }).plug(
|
|
68
|
+
const app = oke({ autoBoot: false, name: "sec-tuned" }).plug(
|
|
69
69
|
securityHeaders({
|
|
70
70
|
dnsPrefetchControl: { allow: true },
|
|
71
71
|
permittedCrossDomainPolicies: "by-content-type",
|
|
@@ -92,7 +92,7 @@ describe("securityHeaders plugin", () => {
|
|
|
92
92
|
}),
|
|
93
93
|
}),
|
|
94
94
|
);
|
|
95
|
-
return oke({ name }).plug(securityHeaders(options));
|
|
95
|
+
return oke({ autoBoot: false, name }).plug(securityHeaders(options));
|
|
96
96
|
};
|
|
97
97
|
|
|
98
98
|
const removed = await poweredByApp({}, "sec-pb-off").fetch(new Request("http://localhost/x"));
|
|
@@ -112,7 +112,7 @@ describe("securityHeaders plugin", () => {
|
|
|
112
112
|
|
|
113
113
|
test("structured CSP merges over helmet's defaults, camelCase keys, report-only mode", async () => {
|
|
114
114
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
115
|
-
const app = oke({ name: "sec-csp-builder" }).plug(
|
|
115
|
+
const app = oke({ autoBoot: false, name: "sec-csp-builder" }).plug(
|
|
116
116
|
securityHeaders({
|
|
117
117
|
contentSecurityPolicy: {
|
|
118
118
|
directives: { scriptSrc: ["'self'", "https://cdn.example.com"] },
|
|
@@ -132,7 +132,7 @@ describe("securityHeaders plugin", () => {
|
|
|
132
132
|
|
|
133
133
|
test("structured CSP with useDefaults: false emits exactly the given directives", async () => {
|
|
134
134
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
135
|
-
const app = oke({ name: "sec-csp-bare" }).plug(
|
|
135
|
+
const app = oke({ autoBoot: false, name: "sec-csp-bare" }).plug(
|
|
136
136
|
securityHeaders({
|
|
137
137
|
contentSecurityPolicy: { useDefaults: false, directives: { "default-src": ["'none'"] } },
|
|
138
138
|
}),
|
|
@@ -155,7 +155,7 @@ describe("securityHeaders plugin", () => {
|
|
|
155
155
|
fail("Forbidden", {}),
|
|
156
156
|
),
|
|
157
157
|
);
|
|
158
|
-
const app = oke({ name: "sec-fail" }).plug(securityHeaders());
|
|
158
|
+
const app = oke({ autoBoot: false, name: "sec-fail" }).plug(securityHeaders());
|
|
159
159
|
|
|
160
160
|
const res = await app.fetch(new Request("http://localhost/deny"));
|
|
161
161
|
|
|
@@ -165,7 +165,7 @@ describe("securityHeaders plugin", () => {
|
|
|
165
165
|
|
|
166
166
|
test("adds CSP only when configured", async () => {
|
|
167
167
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
168
|
-
const app = oke({ name: "sec-csp" }).plug(
|
|
168
|
+
const app = oke({ autoBoot: false, name: "sec-csp" }).plug(
|
|
169
169
|
securityHeaders({ contentSecurityPolicy: "default-src 'self'" }),
|
|
170
170
|
);
|
|
171
171
|
|
|
@@ -176,7 +176,7 @@ describe("securityHeaders plugin", () => {
|
|
|
176
176
|
|
|
177
177
|
test("HSTS is off by default", async () => {
|
|
178
178
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
179
|
-
const app = oke({ name: "sec-hsts-off" }).plug(securityHeaders());
|
|
179
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-off" }).plug(securityHeaders());
|
|
180
180
|
|
|
181
181
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
182
182
|
|
|
@@ -185,7 +185,7 @@ describe("securityHeaders plugin", () => {
|
|
|
185
185
|
|
|
186
186
|
test("hsts: true stamps a one-year max-age", async () => {
|
|
187
187
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
188
|
-
const app = oke({ name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
|
|
188
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-on" }).plug(securityHeaders({ hsts: true }));
|
|
189
189
|
|
|
190
190
|
const res = await app.fetch(new Request("http://localhost/x"));
|
|
191
191
|
|
|
@@ -194,7 +194,7 @@ describe("securityHeaders plugin", () => {
|
|
|
194
194
|
|
|
195
195
|
test("hsts object tunes max-age, subdomains, preload", async () => {
|
|
196
196
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
197
|
-
const app = oke({ name: "sec-hsts-tuned" }).plug(
|
|
197
|
+
const app = oke({ autoBoot: false, name: "sec-hsts-tuned" }).plug(
|
|
198
198
|
securityHeaders({ hsts: { maxAge: 63072000, includeSubDomains: true, preload: true } }),
|
|
199
199
|
);
|
|
200
200
|
|
|
@@ -207,7 +207,7 @@ describe("securityHeaders plugin", () => {
|
|
|
207
207
|
|
|
208
208
|
test("permissions-policy and cross-origin policies stamp when configured", async () => {
|
|
209
209
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
210
|
-
const app = oke({ name: "sec-extra" }).plug(
|
|
210
|
+
const app = oke({ autoBoot: false, name: "sec-extra" }).plug(
|
|
211
211
|
securityHeaders({
|
|
212
212
|
permissionsPolicy: "camera=(), microphone=()",
|
|
213
213
|
crossOriginOpenerPolicy: "same-origin",
|
|
@@ -224,7 +224,7 @@ describe("securityHeaders plugin", () => {
|
|
|
224
224
|
|
|
225
225
|
test("an explicit app-set value wins by default", async () => {
|
|
226
226
|
on(http.get("/x"), flow({ name: "x.get", do: () => ({ ok: true }) }));
|
|
227
|
-
const app = oke({ name: "sec-keep" });
|
|
227
|
+
const app = oke({ autoBoot: false, name: "sec-keep" });
|
|
228
228
|
app.hook("onResponse", (ctx) => {
|
|
229
229
|
if (!ctx.response) return;
|
|
230
230
|
const headers = new Headers(ctx.response.headers);
|