okengine 0.5.0 → 0.5.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 +1 -1
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/plugins/auth-methods.security.test.ts +762 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +11 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/security-headers.ts +0 -255
package/package.json
CHANGED
|
@@ -93,16 +93,16 @@ ninth element — the set of eight is closed.
|
|
|
93
93
|
|
|
94
94
|
## Traditional vs OKE
|
|
95
95
|
|
|
96
|
-
| Seam | Maintained by hand | Derived by OKE
|
|
97
|
-
| ------------------ | --------------------------------------------------------- |
|
|
98
|
-
| Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects`
|
|
99
|
-
| Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx`
|
|
100
|
-
| HTTP glue | Middleware copied per repo | Official plugins — `
|
|
101
|
-
| Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot
|
|
102
|
-
| Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`)
|
|
103
|
-
| Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects
|
|
104
|
-
| Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker`
|
|
105
|
-
| Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest
|
|
96
|
+
| Seam | Maintained by hand | Derived by OKE |
|
|
97
|
+
| ------------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- |
|
|
98
|
+
| Behavior model | Endpoints, jobs, consumers, workflows as separate species | One species — Flow: `on(Trigger) → Effects` |
|
|
99
|
+
| Cache invalidation | Hand-written keys; drift from writers | Derived from effects recorded through `fx` |
|
|
100
|
+
| HTTP glue | Middleware copied per repo | Official plugins — `headers`, `cors`, `csrf`, compression, IP allowlist |
|
|
101
|
+
| Secrets / config | Env sprawl; fails on first request | Vault contracts; `VaultBootError` at boot |
|
|
102
|
+
| Observability | Bolted on; separate source of truth | Console from the Manifest, dev and prod (`:6533`) |
|
|
103
|
+
| Permissions | Ad-hoc checks scattered in handlers | Least-privilege matrix from declared effects |
|
|
104
|
+
| Local vs prod | Vendor clients; one-off compose | Protocol drivers; vendor in `images`; `oke dev --docker` |
|
|
105
|
+
| Client / agents | Separate codegen or hand-kept schemas | Typed client and MCP (`:6535`) from the same Manifest |
|
|
106
106
|
|
|
107
107
|
## Ambition, stated plainly
|
|
108
108
|
|
|
@@ -2,18 +2,18 @@
|
|
|
2
2
|
title: "Headers"
|
|
3
3
|
description: "Official plugin — the complete secure-headers set on every HTTP response, failures included. Full helmet.js parity with API-first defaults, a CSP builder with report-only mode, and live DB-driven config."
|
|
4
4
|
icon: "ShieldCheck"
|
|
5
|
-
source: "src/plugins/
|
|
5
|
+
source: "src/plugins/headers.ts"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`
|
|
8
|
+
`headers()` stamps security headers on **every** HTTP flow response — successes, failures, and short-circuits alike, because it runs at `onResponse`, the last pipeline stage. An explicit value your app already set is never overridden unless you ask for it.
|
|
9
9
|
|
|
10
10
|
## Quick start
|
|
11
11
|
|
|
12
12
|
```typescript title="src/app.ts"
|
|
13
13
|
import { oke } from "okengine";
|
|
14
|
-
import {
|
|
14
|
+
import { headers } from "okengine/plugins";
|
|
15
15
|
|
|
16
|
-
export const app = oke({ name: "shop", env: "dev" }).plug(
|
|
16
|
+
export const app = oke({ name: "shop", env: "dev" }).plug(headers());
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
Every HTTP response now carries:
|
|
@@ -75,7 +75,7 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
|
|
|
75
75
|
|
|
76
76
|
```typescript
|
|
77
77
|
.plug(
|
|
78
|
-
|
|
78
|
+
headers({
|
|
79
79
|
contentSecurityPolicy: {
|
|
80
80
|
directives: { scriptSrc: ["'self'", "https://cdn.example.com"] }, // merged over the defaults
|
|
81
81
|
reportOnly: true, // Content-Security-Policy-Report-Only while you tune
|
|
@@ -97,18 +97,18 @@ Beyond parity: headers land on **failures too** (middleware that only wraps happ
|
|
|
97
97
|
Header policy is exactly the config you want to flip without a redeploy — enable HSTS the day HTTPS lands, tighten the CSP after an audit. Pass a `configSource()` and options follow the database within one sync interval:
|
|
98
98
|
|
|
99
99
|
```typescript
|
|
100
|
-
const
|
|
101
|
-
plugin: "
|
|
100
|
+
const headerConfig = configSource({
|
|
101
|
+
plugin: "headers",
|
|
102
102
|
code: { hsts: false }, // safe floor for local dev
|
|
103
103
|
db: { store: db },
|
|
104
104
|
kv: cache,
|
|
105
105
|
});
|
|
106
|
-
on(every("30s"),
|
|
107
|
-
export const app = oke({ name: "shop", env: "dev" }).plug(
|
|
106
|
+
on(every("30s"), headerConfig.sync());
|
|
107
|
+
export const app = oke({ name: "shop", env: "dev" }).plug(headers(headerConfig));
|
|
108
108
|
```
|
|
109
109
|
|
|
110
110
|
```sql
|
|
111
|
-
INSERT INTO
|
|
111
|
+
INSERT INTO headers_config ("key", "value")
|
|
112
112
|
VALUES ('config', '{"hsts": true}');
|
|
113
113
|
```
|
|
114
114
|
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Passkey"
|
|
3
|
-
description: "Official plugin —
|
|
3
|
+
description: "Official plugin — WebAuthn register and authenticate under /auth with signature and origin checks."
|
|
4
4
|
icon: "FingerprintPattern"
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`passkey()` adds register and authenticate Flows for
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
`passkey()` adds register and authenticate Flows for WebAuthn credentials (`oke_passkeys`).
|
|
9
|
+
Options return a challenge; register and authenticate verify `clientDataJSON` origin + challenge,
|
|
10
|
+
`authenticatorData` rpId hash, and an ECDSA P-256 signature against the stored SPKI public key.
|
|
11
11
|
|
|
12
12
|
<Callout title="The one rule">
|
|
13
13
|
Enable `gate.auth`, then `.plug(passkey())`. Registration needs a Bearer session; authenticate is
|
|
14
|
-
public.
|
|
15
|
-
|
|
14
|
+
public. Post the full ceremony fields — presence of a stored credential alone never issues a
|
|
15
|
+
session.
|
|
16
16
|
</Callout>
|
|
17
17
|
|
|
18
18
|
## Quick start
|
|
@@ -30,7 +30,7 @@ export const app = oke({
|
|
|
30
30
|
name: "shop",
|
|
31
31
|
env: "local",
|
|
32
32
|
gate: { auth: {} },
|
|
33
|
-
}).plug(passkey());
|
|
33
|
+
}).plug(passkey({ origins: ["http://localhost", "https://localhost"] }));
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
</Step>
|
|
@@ -45,10 +45,13 @@ const opts = await api.auth.passkeyRegisterOptions({});
|
|
|
45
45
|
// opts.data: { challenge, rpId, userId }
|
|
46
46
|
|
|
47
47
|
await api.auth.passkeyRegister({
|
|
48
|
-
credentialId: "...",
|
|
49
|
-
publicKey: "...",
|
|
48
|
+
credentialId: "...", // base64url
|
|
49
|
+
publicKey: "...", // base64url SPKI (ECDSA P-256)
|
|
50
50
|
userId: opts.data!.userId,
|
|
51
51
|
challenge: opts.data!.challenge,
|
|
52
|
+
clientDataJSON: "...", // base64url JSON { type, challenge, origin }
|
|
53
|
+
authenticatorData: "...", // base64url
|
|
54
|
+
signature: "...", // base64url ECDSA over authData || SHA-256(clientDataJSON)
|
|
52
55
|
});
|
|
53
56
|
```
|
|
54
57
|
|
|
@@ -60,15 +63,18 @@ Paths: `POST /auth/passkey/register/options`, `POST /auth/passkey/register`.
|
|
|
60
63
|
### Authenticate
|
|
61
64
|
|
|
62
65
|
```typescript
|
|
63
|
-
await api.auth.passkeyAuthenticateOptions({});
|
|
66
|
+
const opts = await api.auth.passkeyAuthenticateOptions({});
|
|
64
67
|
const { data } = await api.auth.passkeyAuthenticate({
|
|
65
68
|
credentialId: "...",
|
|
66
|
-
|
|
69
|
+
challenge: opts.data!.challenge,
|
|
70
|
+
clientDataJSON: "...",
|
|
71
|
+
authenticatorData: "...",
|
|
72
|
+
signature: "...",
|
|
67
73
|
});
|
|
68
74
|
```
|
|
69
75
|
|
|
70
76
|
Paths: `POST /auth/passkey/authenticate/options`, `POST /auth/passkey/authenticate`.
|
|
71
|
-
|
|
77
|
+
Challenges are single-use; wrong origin → `invalid_origin`; bad signature → `invalid_credentials`.
|
|
72
78
|
|
|
73
79
|
</Step>
|
|
74
80
|
|
|
@@ -76,14 +82,15 @@ v1 issues a session when the stored credential matches `userId`.
|
|
|
76
82
|
|
|
77
83
|
## Options
|
|
78
84
|
|
|
79
|
-
| Option | Type | Default
|
|
80
|
-
| ------------ | ------------------- |
|
|
81
|
-
| `secret` | `string` | active\*
|
|
82
|
-
| `sessions` | `SessionStore` | active\*
|
|
83
|
-
| `now` | `() => number` | `Date.now`
|
|
84
|
-
| `passkeys` | `PasskeyStore` | new
|
|
85
|
-
| `challenges` | `VerificationStore` | new
|
|
86
|
-
| `rpId` | `string` | `"localhost"`
|
|
85
|
+
| Option | Type | Default | Meaning |
|
|
86
|
+
| ------------ | ------------------- | ------------------------------------------ | -------------------------------- |
|
|
87
|
+
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
88
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
89
|
+
| `now` | `() => number` | `Date.now` | Injectable clock |
|
|
90
|
+
| `passkeys` | `PasskeyStore` | new | Credential → user mapping |
|
|
91
|
+
| `challenges` | `VerificationStore` | new | Registration / auth challenges |
|
|
92
|
+
| `rpId` | `string` | `"localhost"` | Relying party id |
|
|
93
|
+
| `origins` | `string[]` | `["http://localhost","https://localhost"]` | Allowed `clientDataJSON.origin` |
|
|
87
94
|
|
|
88
95
|
## Surfaces
|
|
89
96
|
|
|
@@ -94,8 +101,8 @@ v1 issues a session when the stored credential matches `userId`.
|
|
|
94
101
|
| `auth.passkeyAuthenticateOptions` | `POST /auth/passkey/authenticate/options` | `gate.public` + otp rate |
|
|
95
102
|
| `auth.passkeyAuthenticate` | `POST /auth/passkey/authenticate` | `gate.public` + otp rate |
|
|
96
103
|
|
|
97
|
-
**Consequence:**
|
|
98
|
-
|
|
104
|
+
**Consequence:** a stolen `credentialId` without the private key cannot mint a session. Set
|
|
105
|
+
`origins` to your real app origins before production.
|
|
99
106
|
|
|
100
107
|
## Troubleshooting
|
|
101
108
|
|
|
@@ -104,11 +111,16 @@ WebAuthn verifier when you leave local/test.
|
|
|
104
111
|
|
|
105
112
|
Sign in with another method first. `userId` in the body must match the Bearer session.
|
|
106
113
|
|
|
114
|
+
</Accordion>
|
|
115
|
+
<Accordion title="authenticate returns invalid_origin">
|
|
116
|
+
|
|
117
|
+
`clientDataJSON.origin` must be in `passkey({ origins })`. Default allows only localhost HTTP/S.
|
|
118
|
+
|
|
107
119
|
</Accordion>
|
|
108
120
|
<Accordion title="authenticate returns invalid_credentials">
|
|
109
121
|
|
|
110
|
-
Unknown `credentialId`,
|
|
111
|
-
|
|
122
|
+
Unknown `credentialId`, consumed/expired challenge, bad signature, or rpId hash mismatch.
|
|
123
|
+
Re-run authenticate options for a fresh challenge, then sign with the enrolled private key.
|
|
112
124
|
|
|
113
125
|
</Accordion>
|
|
114
126
|
</Accordions>
|
|
@@ -7,6 +7,7 @@ source: "docs/spec/unified-theory.md"
|
|
|
7
7
|
|
|
8
8
|
`twoFactor()` adds RFC 6238 TOTP (HMAC-SHA1, 6 digits, 30s) after someone already has a session.
|
|
9
9
|
Enable returns a secret, `otpauth://` URL, and recovery codes; verify mints a new session.
|
|
10
|
+
TOTP codes are compared in constant time (±1 step window).
|
|
10
11
|
|
|
11
12
|
<Callout title="The one rule">
|
|
12
13
|
Enable `gate.auth`, then `.plug(twoFactor())`. Enable and disable need a Bearer session —
|
|
@@ -103,7 +104,7 @@ enable. A recovery code works once, then is consumed.
|
|
|
103
104
|
|
|
104
105
|
## Learn more
|
|
105
106
|
|
|
106
|
-
- [Passkey](/docs/plugins/passkey) — WebAuthn
|
|
107
|
+
- [Passkey](/docs/plugins/passkey) — WebAuthn register / authenticate
|
|
107
108
|
- [Gate](/docs/elements/gate) — session + policies
|
|
108
109
|
- [Username](/docs/plugins/username) — first factor to enroll against
|
|
109
110
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-time equality for auth secrets (OTP, claim codes, challenges).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Constant-time string equality (UTF-16 code units).
|
|
7
|
+
* Length mismatches still scan to `max(len)` so timing does not leak length
|
|
8
|
+
* beyond the longer operand.
|
|
9
|
+
*
|
|
10
|
+
* @param a - Expected
|
|
11
|
+
* @param b - Candidate
|
|
12
|
+
*/
|
|
13
|
+
export function constantTimeEqual(a: string, b: string): boolean {
|
|
14
|
+
const len = Math.max(a.length, b.length);
|
|
15
|
+
let mismatch = a.length === b.length ? 0 : 1;
|
|
16
|
+
for (let i = 0; i < len; i++) {
|
|
17
|
+
const ac = a.charCodeAt(i) || 0;
|
|
18
|
+
const bc = b.charCodeAt(i) || 0;
|
|
19
|
+
mismatch |= ac ^ bc;
|
|
20
|
+
}
|
|
21
|
+
return mismatch === 0;
|
|
22
|
+
}
|
package/src/auth/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Gate: named competitor comparisons stay gone from the tracked tree.
|
|
3
3
|
*
|
|
4
4
|
* Mirrors {@link ./doc-staleness.test.ts} — `git grep` must find zero hits
|
|
5
|
-
* (the
|
|
5
|
+
* (the headers decoy header fixture may still use a common
|
|
6
6
|
* `X-Powered-By` value; that path is allow-listed).
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -28,8 +28,8 @@ const FORBIDDEN_COMPARISON_PATH = ["get-started/", "comparison"].join("");
|
|
|
28
28
|
/** Word-boundary classic Node framework — `-F` false-positives on `CallExpression`. */
|
|
29
29
|
const FORBIDDEN_EXPRESS = ["\\b", "Ex", "press", "\\b"].join("");
|
|
30
30
|
|
|
31
|
-
/** Decoy `X-Powered-By` fixture value in the
|
|
32
|
-
const EXPRESS_ALLOW = ["src/plugins/
|
|
31
|
+
/** Decoy `X-Powered-By` fixture value in the headers plugin tests. */
|
|
32
|
+
const EXPRESS_ALLOW = ["src/plugins/headers.test.ts:"];
|
|
33
33
|
|
|
34
34
|
/**
|
|
35
35
|
* Run `git grep -F` and assert zero matches after ignoring allow-listed paths.
|