okengine 0.15.2 → 0.16.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 +1 -1
- package/site/content/docs/elements/flow.mdx +6 -0
- package/site/content/docs/elements/gate.mdx +55 -0
- package/site/content/docs/elements/store.mdx +13 -15
- package/site/content/docs/elements/vault.mdx +3 -0
- package/site/content/docs/reference/client.mdx +3 -0
- package/site/content/docs/reference/fx.mdx +29 -18
- package/site/content/docs/reference/security.md +1 -1
- package/src/auth/api-key-sql.test.ts +47 -0
- package/src/auth/api-key-sql.ts +254 -0
- package/src/auth/api-keys.ts +273 -14
- package/src/auth/auth.test.ts +56 -0
- package/src/auth/config.ts +5 -5
- package/src/auth/index.ts +25 -1
- package/src/auth/schema.ts +8 -0
- package/src/cli/attach-host-console.test.ts +37 -0
- package/src/cli/attach-host-console.ts +6 -0
- package/src/compiler/effects-infer.ts +17 -2
- package/src/console/server/access.test.ts +116 -8
- package/src/console/server/access.ts +70 -23
- package/src/console/server/app.ts +1 -0
- package/src/console/server/dev-identities.test.ts +127 -0
- package/src/console/server/dev-identities.ts +229 -0
- package/src/console/server/flows-invoke.test.ts +21 -4
- package/src/console/server/flows.ts +113 -58
- package/src/console/server/gates.test.ts +24 -0
- package/src/console/server/gates.ts +8 -21
- package/src/console/server/index.ts +1 -0
- package/src/console/server/live.test.ts +26 -1
- package/src/console/server/live.ts +4 -2
- package/src/console/server/operator-db.ts +2 -0
- package/src/console/server/state.ts +29 -55
- package/src/console/ui-next/dist/assets/access-page-CAGHrA9H.js +4 -0
- package/src/console/ui-next/dist/assets/{agent-disclosure-C0X1fbWF.js → agent-disclosure-BP0Y0Sux.js} +1 -1
- package/src/console/ui-next/dist/assets/cache-glyph-B5X-NM0-.js +1 -0
- package/src/console/ui-next/dist/assets/call-pii-button-ChAUiLo9.js +1 -0
- package/src/console/ui-next/dist/assets/collapsible-DGnOM2ph.js +1 -0
- package/src/console/ui-next/dist/assets/copy-inline-button-DBHhgHKP.js +1 -0
- package/src/console/ui-next/dist/assets/detail-header-DhHM1iaZ.js +1 -0
- package/src/console/ui-next/dist/assets/dropdown-menu-_NjTEo5_.js +1 -0
- package/src/console/ui-next/dist/assets/element-icons-BVXtRyd3.js +1 -0
- package/src/console/ui-next/dist/assets/explorer-empty-2uIhBu0_.js +1 -0
- package/src/console/ui-next/dist/assets/flows-page-B-OUtiAu.js +1 -0
- package/src/console/ui-next/dist/assets/{highlighted-json-CS_O8L-r.js → highlighted-json-Awq7gYdu.js} +1 -1
- package/src/console/ui-next/dist/assets/http-method-_UHM2ODJ.js +1 -0
- package/src/console/ui-next/dist/assets/index-CGoZkILK.js +66 -0
- package/src/console/ui-next/dist/assets/index-Ck88Jmv8.css +2 -0
- package/src/console/ui-next/dist/assets/link-BX6Vqztd.js +1 -0
- package/src/console/ui-next/dist/assets/observability-page-BDyXalNR.js +4 -0
- package/src/console/ui-next/dist/assets/replica-lag-DKRrbvdo.js +18 -0
- package/src/console/ui-next/dist/assets/request-meta-DV0ywz7t.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-JQIZlWfm.js +1 -0
- package/src/console/ui-next/dist/assets/shortcut-keys-DKxNTe_m.js +1 -0
- package/src/console/ui-next/dist/assets/skeleton-D-czQJT6.js +1 -0
- package/src/console/ui-next/dist/assets/store-page-Xh8Kn3rx.js +41 -0
- package/src/console/ui-next/dist/assets/trace-detail-sheet-B09O8rA6.js +2 -0
- package/src/console/ui-next/dist/assets/tree-expand-toggle-DkOXA12R.js +54 -0
- package/src/console/ui-next/dist/assets/units-page-Dpk40kOQ.js +1 -0
- package/src/console/ui-next/dist/assets/{use-vault-list-BLbZhzyF.js → use-vault-list-uk4WVboC.js} +1 -1
- package/src/console/ui-next/dist/assets/useMutation-B8EO02Ej.js +1 -0
- package/src/console/ui-next/dist/assets/useRender-BE2A9BWC.js +1 -0
- package/src/console/ui-next/dist/assets/vault-page-B1dB9Ft0.js +2 -0
- package/src/console/ui-next/dist/index.html +11 -9
- package/src/console/ui-next/index.html +3 -3
- package/src/console/ui-next/src/client.ts +167 -0
- package/src/console/ui-next/src/components/shell/app-sidebar.tsx +2 -0
- package/src/console/ui-next/src/components/shell/command-items.test.ts +1 -0
- package/src/console/ui-next/src/components/shell/command-items.ts +8 -0
- package/src/console/ui-next/src/components/shell/shell-layout.tsx +2 -1
- package/src/console/ui-next/src/components/ui/sheet-form.tsx +8 -3
- package/src/console/ui-next/src/features/access/access-page.tsx +347 -0
- package/src/console/ui-next/src/features/access/data/use-access.ts +23 -0
- package/src/console/ui-next/src/features/access/detail/access-detail.tsx +552 -0
- package/src/console/ui-next/src/features/access/explorer/access-list.tsx +125 -0
- package/src/console/ui-next/src/features/access/lib/format-when.test.ts +139 -0
- package/src/console/ui-next/src/features/access/lib/format-when.ts +296 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.test.ts +34 -0
- package/src/console/ui-next/src/features/access/lib/scope-groups.ts +40 -0
- package/src/console/ui-next/src/features/access/sheets/access-allow-fields.tsx +236 -0
- package/src/console/ui-next/src/features/access/sheets/access-create-sheet.tsx +316 -0
- package/src/console/ui-next/src/features/access/sheets/access-edit-sheet.tsx +240 -0
- package/src/console/ui-next/src/features/access/sheets/access-expiry-fields.tsx +66 -0
- package/src/console/ui-next/src/features/access/sheets/access-refresh-sheet.tsx +104 -0
- package/src/console/ui-next/src/features/access/sheets/access-scope-field.tsx +115 -0
- package/src/console/ui-next/src/features/access/sheets/access-secret-sheet.tsx +89 -0
- package/src/console/ui-next/src/features/access/sheets/access-sheet-search.tsx +49 -0
- package/src/console/ui-next/src/features/access/state/access-selection.ts +84 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.test.ts +5 -0
- package/src/console/ui-next/src/features/auth/auth-redirect.ts +18 -2
- package/src/console/ui-next/src/features/not-found/not-found-page.tsx +1 -1
- package/src/console/ui-next/src/lib/document-meta.test.ts +1 -0
- package/src/console/ui-next/src/lib/document-meta.ts +2 -1
- package/src/console/ui-next/src/lib/last-module-search.test.ts +1 -0
- package/src/console/ui-next/src/lib/last-module-search.ts +2 -1
- package/src/console/ui-next/src/lib/shortcut.test.ts +2 -0
- package/src/console/ui-next/src/lib/shortcut.ts +3 -0
- package/src/console/ui-next/src/router.tsx +10 -1
- package/src/console/ui-next/ui-next-seed-app-schema.ts +88 -37
- package/src/console/ui-next/ui-next-seed-store.ts +43 -15
- package/src/console/xss.gate.test.ts +1 -0
- package/src/elements/gate/declare.ts +2 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/runtime.ts +14 -0
- package/src/elements/store/schema-decl.ts +99 -41
- package/src/elements/store/sql-select.types.test.ts +71 -0
- package/src/elements/store/sql-session.ts +54 -32
- package/src/elements/store.ts +3 -0
- package/src/index.ts +1 -0
- package/src/kernel/api-key-host-persist.test.ts +143 -0
- package/src/kernel/app.ts +14 -2
- package/src/kernel/auth-resolve.ts +10 -5
- package/src/kernel/boot.test.ts +4 -4
- package/src/kernel/fx-auth-keys.test.ts +88 -0
- package/src/kernel/fx-auth-keys.ts +198 -0
- package/src/kernel/fx.test.ts +17 -1
- package/src/kernel/fx.ts +36 -18
- package/src/kernel/pipeline-api-key.test.ts +192 -7
- package/src/kernel/pipeline.ts +4 -2
- package/src/manifest/types.ts +9 -3
- package/src/release/build-lib.ts +1 -0
- package/src/console/ui-next/dist/assets/cache-glyph-F1FI122b.js +0 -1
- package/src/console/ui-next/dist/assets/call-pii-button-DEDCl_j3.js +0 -1
- package/src/console/ui-next/dist/assets/collapsible-LPqGvfoz.js +0 -1
- package/src/console/ui-next/dist/assets/confirm-sheet-3ptDzXbz.js +0 -1
- package/src/console/ui-next/dist/assets/explorer-empty-BvYrygyO.js +0 -1
- package/src/console/ui-next/dist/assets/flows-page-Dluo1Bd7.js +0 -1
- package/src/console/ui-next/dist/assets/http-method--sWDdXSB.js +0 -1
- package/src/console/ui-next/dist/assets/index-Ca3HZMVq.js +0 -66
- package/src/console/ui-next/dist/assets/index-UtTDRQpS.css +0 -2
- package/src/console/ui-next/dist/assets/link-COeyggt-.js +0 -1
- package/src/console/ui-next/dist/assets/observability-page-HK9-BO8z.js +0 -4
- package/src/console/ui-next/dist/assets/replica-lag-DBIFFf7d.js +0 -18
- package/src/console/ui-next/dist/assets/sheet-form-Dnwa5oOR.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-Cgzuv4k1.js +0 -1
- package/src/console/ui-next/dist/assets/shortcut-keys-CoqwEIi0.js +0 -1
- package/src/console/ui-next/dist/assets/skeleton-BHcfDYcb.js +0 -1
- package/src/console/ui-next/dist/assets/store-page-BSohH3wM.js +0 -41
- package/src/console/ui-next/dist/assets/trace-detail-sheet-CFIGRnnA.js +0 -2
- package/src/console/ui-next/dist/assets/tree-expand-toggle-BgLl34w7.js +0 -54
- package/src/console/ui-next/dist/assets/units-page-B0cFE76A.js +0 -1
- package/src/console/ui-next/dist/assets/vault-page-B1SbYe10.js +0 -2
package/src/auth/api-keys.ts
CHANGED
|
@@ -3,14 +3,23 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A key can never exceed the permissions of whoever created it.
|
|
5
5
|
* The raw secret is returned exactly once on create / rotate.
|
|
6
|
+
* Hash is HMAC-SHA-256 with the auth pepper (gate.auth.secret).
|
|
6
7
|
*
|
|
7
8
|
* @see docs/spec/console.md §3.3 · §9.14
|
|
8
9
|
*/
|
|
9
10
|
|
|
11
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
12
|
+
import { parseDurationMs } from "../elements/clock/duration.ts";
|
|
10
13
|
import { assertAttenuated } from "./attenuation.ts";
|
|
11
14
|
import type { AuthPlane } from "./planes.ts";
|
|
12
15
|
import type { ApiKeyRow } from "./tables.ts";
|
|
13
16
|
|
|
17
|
+
/** Capability / Manifest resource for `fx.auth` key methods. */
|
|
18
|
+
export const AUTH_API_KEYS_RESOURCE = "auth:api-keys";
|
|
19
|
+
|
|
20
|
+
/** Default HMAC pepper when no `gate.auth.secret` is bound (tests). */
|
|
21
|
+
const DEFAULT_PEPPER = "oke.api-key";
|
|
22
|
+
|
|
14
23
|
/** Options when creating an API key. */
|
|
15
24
|
export interface CreateApiKeyOptions {
|
|
16
25
|
readonly plane: AuthPlane;
|
|
@@ -27,6 +36,19 @@ export interface CreateApiKeyOptions {
|
|
|
27
36
|
readonly secret?: string;
|
|
28
37
|
readonly hash?: string;
|
|
29
38
|
readonly now?: () => number;
|
|
39
|
+
/** HMAC pepper override (defaults to store pepper). */
|
|
40
|
+
readonly pepper?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Patch for {@link updateApiKey}. */
|
|
44
|
+
export interface UpdateApiKeyOptions {
|
|
45
|
+
readonly name?: string;
|
|
46
|
+
readonly scopes?: readonly string[];
|
|
47
|
+
readonly expiresAt?: number | null;
|
|
48
|
+
readonly rateLimit?: { max: number; per: string } | null;
|
|
49
|
+
readonly ipAllowlist?: readonly string[];
|
|
50
|
+
/** Ceiling for scope changes (stored creatorScopes ∩ live scopes). */
|
|
51
|
+
readonly ceiling: ReadonlySet<string> | Iterable<string>;
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
/** Result of key creation — secret shown exactly once. */
|
|
@@ -36,27 +58,76 @@ export interface CreatedApiKey {
|
|
|
36
58
|
readonly secret: string;
|
|
37
59
|
}
|
|
38
60
|
|
|
39
|
-
/**
|
|
61
|
+
/** Public key row returned by `fx.auth.listApiKeys` (never includes hash). */
|
|
62
|
+
export interface ApiKeyPublicRow {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly name: string;
|
|
65
|
+
readonly plane: AuthPlane;
|
|
66
|
+
readonly scopes: readonly string[];
|
|
67
|
+
readonly createdAt: number;
|
|
68
|
+
readonly lastUsedAt: number | null;
|
|
69
|
+
readonly expiresAt: number | null;
|
|
70
|
+
readonly revokedAt: number | null;
|
|
71
|
+
readonly rateLimit: { max: number; per: string } | null;
|
|
72
|
+
readonly ipAllowlist: readonly string[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* API key store — in-memory Map, optionally HMAC-peppered.
|
|
77
|
+
* SQL persist is a write-through adapter bound at boot.
|
|
78
|
+
*/
|
|
40
79
|
export interface ApiKeyStore {
|
|
41
80
|
keys: Map<string, ApiKeyRow>;
|
|
81
|
+
/** HMAC pepper (`gate.auth.secret`). */
|
|
82
|
+
pepper?: string;
|
|
83
|
+
/** Optional write-through persist (SQL). */
|
|
84
|
+
persist?: (row: ApiKeyRow) => void | Promise<void>;
|
|
85
|
+
/** Sliding-window hits for per-key rateLimit (subject = key id). */
|
|
86
|
+
rateHits?: Map<string, number[]>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Options for {@link createApiKeyStore}. */
|
|
90
|
+
export interface CreateApiKeyStoreOptions {
|
|
91
|
+
readonly pepper?: string;
|
|
92
|
+
readonly persist?: (row: ApiKeyRow) => void | Promise<void>;
|
|
42
93
|
}
|
|
43
94
|
|
|
44
95
|
/**
|
|
45
96
|
* Create an empty API key store.
|
|
97
|
+
*
|
|
98
|
+
* @param options - Optional HMAC pepper / persist hook
|
|
46
99
|
*/
|
|
47
|
-
export function createApiKeyStore(): ApiKeyStore {
|
|
48
|
-
return {
|
|
100
|
+
export function createApiKeyStore(options: CreateApiKeyStoreOptions = {}): ApiKeyStore {
|
|
101
|
+
return {
|
|
102
|
+
keys: new Map(),
|
|
103
|
+
...(options.pepper !== undefined ? { pepper: options.pepper } : {}),
|
|
104
|
+
...(options.persist !== undefined ? { persist: options.persist } : {}),
|
|
105
|
+
};
|
|
49
106
|
}
|
|
50
107
|
|
|
51
108
|
/**
|
|
52
|
-
*
|
|
109
|
+
* HMAC-SHA-256 hex of an API key secret.
|
|
53
110
|
*
|
|
54
111
|
* @param secret - Raw secret
|
|
112
|
+
* @param pepper - HMAC key (`gate.auth.secret`); defaults to a test pepper
|
|
55
113
|
*/
|
|
56
|
-
export async function hashApiKeySecret(
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
114
|
+
export async function hashApiKeySecret(
|
|
115
|
+
secret: string,
|
|
116
|
+
pepper: string = DEFAULT_PEPPER,
|
|
117
|
+
): Promise<string> {
|
|
118
|
+
const key = await crypto.subtle.importKey(
|
|
119
|
+
"raw",
|
|
120
|
+
new TextEncoder().encode(pepper),
|
|
121
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
122
|
+
false,
|
|
123
|
+
["sign"],
|
|
124
|
+
);
|
|
125
|
+
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(secret));
|
|
126
|
+
return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function persistRow(store: ApiKeyStore, row: ApiKeyRow): Promise<void> {
|
|
130
|
+
await store.persist?.(row);
|
|
60
131
|
}
|
|
61
132
|
|
|
62
133
|
/**
|
|
@@ -74,7 +145,8 @@ export async function createApiKey(
|
|
|
74
145
|
const now = options.now ?? (() => Date.now());
|
|
75
146
|
const id = options.id ?? crypto.randomUUID();
|
|
76
147
|
const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
|
|
77
|
-
const
|
|
148
|
+
const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
|
|
149
|
+
const hash = options.hash ?? (await hashApiKeySecret(secret, pepper));
|
|
78
150
|
const creatorScopes = [...options.creatorScopes];
|
|
79
151
|
|
|
80
152
|
const row: ApiKeyRow = {
|
|
@@ -93,32 +165,77 @@ export async function createApiKey(
|
|
|
93
165
|
revokedAt: null,
|
|
94
166
|
};
|
|
95
167
|
store.keys.set(id, row);
|
|
168
|
+
await persistRow(store, row);
|
|
96
169
|
return { row, secret };
|
|
97
170
|
}
|
|
98
171
|
|
|
172
|
+
/** Resolve a stored hostname to addresses (injected in tests). */
|
|
173
|
+
export type AllowlistLookup = (host: string) => Promise<readonly string[]>;
|
|
174
|
+
|
|
175
|
+
/** Options for {@link authenticateApiKey}. */
|
|
176
|
+
export interface AuthenticateApiKeyOptions {
|
|
177
|
+
readonly now?: () => number;
|
|
178
|
+
/** Client IP for allowlist enforcement. */
|
|
179
|
+
readonly ip?: string;
|
|
180
|
+
/** Hostname → A/AAAA (defaults to system DNS). */
|
|
181
|
+
readonly lookup?: AllowlistLookup;
|
|
182
|
+
}
|
|
183
|
+
|
|
99
184
|
/**
|
|
100
|
-
* Resolve a key by raw secret; enforces expiry and
|
|
185
|
+
* Resolve a key by raw secret; enforces expiry, revocation, and allowlist.
|
|
101
186
|
*
|
|
102
187
|
* @param store - Key store
|
|
103
188
|
* @param secret - Raw secret
|
|
104
|
-
* @param
|
|
189
|
+
* @param nowOrOptions - Clock, or clock + client IP
|
|
105
190
|
*/
|
|
106
191
|
export async function authenticateApiKey(
|
|
107
192
|
store: ApiKeyStore,
|
|
108
193
|
secret: string,
|
|
109
|
-
|
|
194
|
+
nowOrOptions: (() => number) | AuthenticateApiKeyOptions = () => Date.now(),
|
|
110
195
|
): Promise<ApiKeyRow | null> {
|
|
111
|
-
const
|
|
196
|
+
const options: AuthenticateApiKeyOptions =
|
|
197
|
+
typeof nowOrOptions === "function" ? { now: nowOrOptions } : nowOrOptions;
|
|
198
|
+
const now = options.now ?? (() => Date.now());
|
|
199
|
+
const pepper = store.pepper ?? DEFAULT_PEPPER;
|
|
200
|
+
const hash = await hashApiKeySecret(secret, pepper);
|
|
112
201
|
for (const row of store.keys.values()) {
|
|
113
202
|
if (row.hash !== hash) continue;
|
|
114
203
|
if (row.revokedAt !== null) return null;
|
|
115
204
|
if (row.expiresAt !== null && row.expiresAt <= now()) return null;
|
|
205
|
+
if (row.ipAllowlist.length > 0) {
|
|
206
|
+
const allowed = await allowlistAllowsIp(row.ipAllowlist, options.ip, options.lookup);
|
|
207
|
+
if (!allowed) return null;
|
|
208
|
+
}
|
|
209
|
+
if (row.rateLimit && !takeKeyRate(store, row.id, row.rateLimit, now())) return null;
|
|
116
210
|
row.lastUsedAt = now();
|
|
211
|
+
await persistRow(store, row);
|
|
117
212
|
return row;
|
|
118
213
|
}
|
|
119
214
|
return null;
|
|
120
215
|
}
|
|
121
216
|
|
|
217
|
+
/**
|
|
218
|
+
* List keys created by `creatorId` (includes revoked rows for audit).
|
|
219
|
+
*
|
|
220
|
+
* @param store - Key store
|
|
221
|
+
* @param creatorId - Issuer id
|
|
222
|
+
*/
|
|
223
|
+
export function listApiKeys(store: ApiKeyStore, creatorId: string): ApiKeyRow[] {
|
|
224
|
+
return [...store.keys.values()]
|
|
225
|
+
.filter((row) => row.creatorId === creatorId)
|
|
226
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Load one key by id.
|
|
231
|
+
*
|
|
232
|
+
* @param store - Key store
|
|
233
|
+
* @param id - Key id
|
|
234
|
+
*/
|
|
235
|
+
export function getApiKey(store: ApiKeyStore, id: string): ApiKeyRow | undefined {
|
|
236
|
+
return store.keys.get(id);
|
|
237
|
+
}
|
|
238
|
+
|
|
122
239
|
/**
|
|
123
240
|
* Revoke an API key — irreversible. The row remains for audit / hygiene.
|
|
124
241
|
*
|
|
@@ -134,6 +251,7 @@ export function revokeApiKey(
|
|
|
134
251
|
const row = store.keys.get(id);
|
|
135
252
|
if (!row || row.revokedAt !== null) return null;
|
|
136
253
|
row.revokedAt = now();
|
|
254
|
+
void persistRow(store, row);
|
|
137
255
|
return row;
|
|
138
256
|
}
|
|
139
257
|
|
|
@@ -150,15 +268,156 @@ export async function rotateApiKey(
|
|
|
150
268
|
options: {
|
|
151
269
|
readonly secret?: string;
|
|
152
270
|
readonly now?: () => number;
|
|
271
|
+
readonly pepper?: string;
|
|
153
272
|
} = {},
|
|
154
273
|
): Promise<CreatedApiKey | null> {
|
|
155
274
|
const row = store.keys.get(id);
|
|
156
275
|
if (!row || row.revokedAt !== null) return null;
|
|
157
276
|
const secret = options.secret ?? `oke_${id.replace(/-/g, "")}_${randomSecret()}`;
|
|
158
|
-
|
|
277
|
+
const pepper = options.pepper ?? store.pepper ?? DEFAULT_PEPPER;
|
|
278
|
+
row.hash = await hashApiKeySecret(secret, pepper);
|
|
279
|
+
await persistRow(store, row);
|
|
159
280
|
return { row, secret };
|
|
160
281
|
}
|
|
161
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Update key metadata. Scope changes re-run {@link assertAttenuated}.
|
|
285
|
+
*
|
|
286
|
+
* @param store - Key store
|
|
287
|
+
* @param id - Key id
|
|
288
|
+
* @param options - Patch + attenuation ceiling
|
|
289
|
+
*/
|
|
290
|
+
export function updateApiKey(
|
|
291
|
+
store: ApiKeyStore,
|
|
292
|
+
id: string,
|
|
293
|
+
options: UpdateApiKeyOptions,
|
|
294
|
+
): ApiKeyRow | null {
|
|
295
|
+
const row = store.keys.get(id);
|
|
296
|
+
if (!row || row.revokedAt !== null) return null;
|
|
297
|
+
if (options.scopes !== undefined) {
|
|
298
|
+
assertAttenuated(options.ceiling, options.scopes, "api key");
|
|
299
|
+
row.scopes = [...options.scopes];
|
|
300
|
+
}
|
|
301
|
+
if (options.name !== undefined) row.name = options.name;
|
|
302
|
+
if (options.expiresAt !== undefined) row.expiresAt = options.expiresAt;
|
|
303
|
+
if (options.rateLimit !== undefined) row.rateLimit = options.rateLimit;
|
|
304
|
+
if (options.ipAllowlist !== undefined) row.ipAllowlist = [...options.ipAllowlist];
|
|
305
|
+
void persistRow(store, row);
|
|
306
|
+
return row;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Public projection of a key row (no hash / creatorScopes).
|
|
311
|
+
*
|
|
312
|
+
* @param row - Stored row
|
|
313
|
+
*/
|
|
314
|
+
export function toApiKeyPublicRow(row: ApiKeyRow): ApiKeyPublicRow {
|
|
315
|
+
return {
|
|
316
|
+
id: row.id,
|
|
317
|
+
name: row.name,
|
|
318
|
+
plane: row.plane,
|
|
319
|
+
scopes: [...row.scopes],
|
|
320
|
+
createdAt: row.createdAt,
|
|
321
|
+
lastUsedAt: row.lastUsedAt,
|
|
322
|
+
expiresAt: row.expiresAt,
|
|
323
|
+
revokedAt: row.revokedAt,
|
|
324
|
+
rateLimit: row.rateLimit,
|
|
325
|
+
ipAllowlist: [...row.ipAllowlist],
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Whether the client IP matches an allowlist entry.
|
|
331
|
+
* Literals compare exactly. Hostnames resolve at verify time (fail closed).
|
|
332
|
+
*
|
|
333
|
+
* @param entries - IPs and/or hostnames
|
|
334
|
+
* @param ip - Client IP
|
|
335
|
+
* @param lookup - Hostname resolver
|
|
336
|
+
*/
|
|
337
|
+
export async function allowlistAllowsIp(
|
|
338
|
+
entries: readonly string[],
|
|
339
|
+
ip: string | undefined,
|
|
340
|
+
lookup: AllowlistLookup = lookupAllowlistHost,
|
|
341
|
+
): Promise<boolean> {
|
|
342
|
+
if (entries.length === 0) return true;
|
|
343
|
+
if (ip === undefined) return false;
|
|
344
|
+
const client = ip.trim().toLowerCase();
|
|
345
|
+
for (const raw of entries) {
|
|
346
|
+
const entry = normalizeAllowlistEntry(raw);
|
|
347
|
+
if (entry.length === 0) continue;
|
|
348
|
+
if (isIpLiteral(entry)) {
|
|
349
|
+
if (entry === client) return true;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
try {
|
|
353
|
+
const addrs = await lookup(entry);
|
|
354
|
+
if (addrs.some((addr) => addr.trim().toLowerCase() === client)) return true;
|
|
355
|
+
} catch {
|
|
356
|
+
// This host fails closed; other entries may still match.
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return false;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function normalizeAllowlistEntry(raw: string): string {
|
|
363
|
+
let entry = raw.trim().toLowerCase();
|
|
364
|
+
entry = entry.replace(/^https?:\/\//, "");
|
|
365
|
+
const slash = entry.indexOf("/");
|
|
366
|
+
if (slash >= 0) entry = entry.slice(0, slash);
|
|
367
|
+
if (entry.endsWith(".")) entry = entry.slice(0, -1);
|
|
368
|
+
return entry;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function isIpLiteral(entry: string): boolean {
|
|
372
|
+
if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(entry)) {
|
|
373
|
+
return entry.split(".").every((octet) => Number(octet) <= 255);
|
|
374
|
+
}
|
|
375
|
+
return entry.includes(":") && /^[0-9a-f:.]+$/.test(entry);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
async function lookupAllowlistHost(host: string): Promise<readonly string[]> {
|
|
379
|
+
const rows = await dnsLookup(host, { all: true, verbatim: true });
|
|
380
|
+
return rows.map((row) => row.address);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Client IP from X-Forwarded-For (right-most trusted hop) or X-Real-IP.
|
|
385
|
+
*
|
|
386
|
+
* @param request - Incoming request
|
|
387
|
+
* @param trustedProxyDepth - Hops from the right (default 1)
|
|
388
|
+
*/
|
|
389
|
+
export function clientIpFromRequest(request: Request, trustedProxyDepth = 1): string | undefined {
|
|
390
|
+
const xff = request.headers.get("x-forwarded-for");
|
|
391
|
+
if (xff) {
|
|
392
|
+
const hops = xff
|
|
393
|
+
.split(",")
|
|
394
|
+
.map((h) => h.trim())
|
|
395
|
+
.filter((h) => h.length > 0);
|
|
396
|
+
const index = hops.length - trustedProxyDepth;
|
|
397
|
+
if (index >= 0) return hops[index];
|
|
398
|
+
}
|
|
399
|
+
return request.headers.get("x-real-ip")?.trim() || undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function takeKeyRate(
|
|
403
|
+
store: ApiKeyStore,
|
|
404
|
+
keyId: string,
|
|
405
|
+
limit: { max: number; per: string },
|
|
406
|
+
nowMs: number,
|
|
407
|
+
): boolean {
|
|
408
|
+
const windowMs = parseDurationMs(limit.per);
|
|
409
|
+
const hits = store.rateHits ?? (store.rateHits = new Map<string, number[]>());
|
|
410
|
+
const prev = hits.get(keyId) ?? [];
|
|
411
|
+
const kept = prev.filter((t) => nowMs - t < windowMs);
|
|
412
|
+
if (kept.length >= limit.max) {
|
|
413
|
+
hits.set(keyId, kept);
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
kept.push(nowMs);
|
|
417
|
+
hits.set(keyId, kept);
|
|
418
|
+
return true;
|
|
419
|
+
}
|
|
420
|
+
|
|
162
421
|
function randomSecret(): string {
|
|
163
422
|
const bytes = crypto.getRandomValues(new Uint8Array(24));
|
|
164
423
|
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
|
package/src/auth/auth.test.ts
CHANGED
|
@@ -114,6 +114,62 @@ describe("auth plugin", () => {
|
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
describe("API key allowlist", () => {
|
|
118
|
+
test("hostname entry matches the resolved client IP", async () => {
|
|
119
|
+
const { authenticateApiKey } = await import("./api-keys.ts");
|
|
120
|
+
const store = createApiKeyStore();
|
|
121
|
+
const created = await createApiKey(store, {
|
|
122
|
+
plane: "user",
|
|
123
|
+
name: "ci",
|
|
124
|
+
scopes: ["member"],
|
|
125
|
+
creatorId: "u1",
|
|
126
|
+
creatorScopes: ["member"],
|
|
127
|
+
ipAllowlist: ["ci.example.com"],
|
|
128
|
+
});
|
|
129
|
+
const lookup = async (host: string) => (host === "ci.example.com" ? ["203.0.113.10"] : []);
|
|
130
|
+
expect(
|
|
131
|
+
await authenticateApiKey(store, created.secret, {
|
|
132
|
+
ip: "203.0.113.10",
|
|
133
|
+
lookup,
|
|
134
|
+
}),
|
|
135
|
+
).not.toBeNull();
|
|
136
|
+
expect(
|
|
137
|
+
await authenticateApiKey(store, created.secret, {
|
|
138
|
+
ip: "198.51.100.7",
|
|
139
|
+
lookup,
|
|
140
|
+
}),
|
|
141
|
+
).toBeNull();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("hostname lookup failure is closed", async () => {
|
|
145
|
+
const { authenticateApiKey } = await import("./api-keys.ts");
|
|
146
|
+
const store = createApiKeyStore();
|
|
147
|
+
const created = await createApiKey(store, {
|
|
148
|
+
plane: "user",
|
|
149
|
+
name: "ci",
|
|
150
|
+
scopes: ["member"],
|
|
151
|
+
creatorId: "u1",
|
|
152
|
+
creatorScopes: ["member"],
|
|
153
|
+
ipAllowlist: ["ci.example.com", "203.0.113.10"],
|
|
154
|
+
});
|
|
155
|
+
const lookup = async () => {
|
|
156
|
+
throw new Error("nxdomain");
|
|
157
|
+
};
|
|
158
|
+
expect(
|
|
159
|
+
await authenticateApiKey(store, created.secret, {
|
|
160
|
+
ip: "203.0.113.10",
|
|
161
|
+
lookup,
|
|
162
|
+
}),
|
|
163
|
+
).not.toBeNull();
|
|
164
|
+
expect(
|
|
165
|
+
await authenticateApiKey(store, created.secret, {
|
|
166
|
+
ip: "198.51.100.7",
|
|
167
|
+
lookup,
|
|
168
|
+
}),
|
|
169
|
+
).toBeNull();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
117
173
|
describe("API key attenuation", () => {
|
|
118
174
|
test("key cannot be created with a scope its creator lacks", async () => {
|
|
119
175
|
const store = createApiKeyStore();
|
package/src/auth/config.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import type { PasswordHashOptions } from "../runtime/types.ts";
|
|
8
8
|
import type { SessionStore } from "./sessions.ts";
|
|
9
|
-
import type
|
|
9
|
+
import { createApiKeyStore, type ApiKeyStore } from "./api-keys.ts";
|
|
10
10
|
import type { BreachCheckFn } from "./breach-check.ts";
|
|
11
11
|
import type { PasswordPolicyOptions } from "./password-policy.ts";
|
|
12
12
|
import {
|
|
@@ -111,8 +111,8 @@ export interface GateAuthOptions extends AuthSchemaOptions {
|
|
|
111
111
|
*/
|
|
112
112
|
readonly sessions?: SessionStore;
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* Shared API key store. Created automatically when omitted
|
|
115
|
+
* (`gate.auth.secret` is the HMAC pepper).
|
|
116
116
|
*/
|
|
117
117
|
readonly apiKeyStore?: ApiKeyStore;
|
|
118
118
|
/** Injectable clock. */
|
|
@@ -170,7 +170,7 @@ export interface ResolvedGateAuth {
|
|
|
170
170
|
};
|
|
171
171
|
readonly hooks: AuthDatabaseHooks | undefined;
|
|
172
172
|
readonly sessions: SessionStore | undefined;
|
|
173
|
-
readonly apiKeyStore: ApiKeyStore
|
|
173
|
+
readonly apiKeyStore: ApiKeyStore;
|
|
174
174
|
readonly now: (() => number) | undefined;
|
|
175
175
|
}
|
|
176
176
|
|
|
@@ -278,7 +278,7 @@ export function resolveGateAuth(options: ResolveGateAuthOptions): ResolvedGateAu
|
|
|
278
278
|
},
|
|
279
279
|
hooks: auth.hooks,
|
|
280
280
|
sessions: auth.sessions,
|
|
281
|
-
apiKeyStore: auth.apiKeyStore,
|
|
281
|
+
apiKeyStore: auth.apiKeyStore ?? createApiKeyStore({ pepper: secret }),
|
|
282
282
|
now: auth.now,
|
|
283
283
|
};
|
|
284
284
|
}
|
package/src/auth/index.ts
CHANGED
|
@@ -161,17 +161,41 @@ export {
|
|
|
161
161
|
} from "./attenuation.ts";
|
|
162
162
|
|
|
163
163
|
export {
|
|
164
|
+
AUTH_API_KEYS_RESOURCE,
|
|
165
|
+
allowlistAllowsIp,
|
|
166
|
+
authenticateApiKey,
|
|
167
|
+
clientIpFromRequest,
|
|
164
168
|
createApiKey,
|
|
165
169
|
createApiKeyStore,
|
|
166
|
-
|
|
170
|
+
getApiKey,
|
|
167
171
|
hashApiKeySecret,
|
|
172
|
+
listApiKeys,
|
|
168
173
|
revokeApiKey,
|
|
169
174
|
rotateApiKey,
|
|
175
|
+
toApiKeyPublicRow,
|
|
176
|
+
updateApiKey,
|
|
177
|
+
type AllowlistLookup,
|
|
178
|
+
type ApiKeyPublicRow,
|
|
170
179
|
type ApiKeyStore,
|
|
180
|
+
type AuthenticateApiKeyOptions,
|
|
171
181
|
type CreateApiKeyOptions,
|
|
182
|
+
type CreateApiKeyStoreOptions,
|
|
172
183
|
type CreatedApiKey,
|
|
184
|
+
type UpdateApiKeyOptions,
|
|
173
185
|
} from "./api-keys.ts";
|
|
174
186
|
|
|
187
|
+
export {
|
|
188
|
+
apiKeySqlExec,
|
|
189
|
+
bindApiKeySqlPersist,
|
|
190
|
+
bindHostApiKeySql,
|
|
191
|
+
bindHostApiKeySqlFromStore,
|
|
192
|
+
ensureApiKeyTable,
|
|
193
|
+
hydrateApiKeyStore,
|
|
194
|
+
persistApiKeyRow,
|
|
195
|
+
type ApiKeySqlExec,
|
|
196
|
+
type HostApiKeySqlRuntime,
|
|
197
|
+
} from "./api-key-sql.ts";
|
|
198
|
+
|
|
175
199
|
export {
|
|
176
200
|
createOperatorInviteStore,
|
|
177
201
|
createOperatorInvite,
|
package/src/auth/schema.ts
CHANGED
|
@@ -105,6 +105,10 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
|
|
|
105
105
|
name: "name",
|
|
106
106
|
scopes: "scopes",
|
|
107
107
|
expiresAt: "expires_at",
|
|
108
|
+
rateLimit: "rate_limit",
|
|
109
|
+
ipAllowlist: "ip_allowlist",
|
|
110
|
+
creatorId: "creator_id",
|
|
111
|
+
creatorScopes: "creator_scopes",
|
|
108
112
|
createdAt: "created_at",
|
|
109
113
|
lastUsedAt: "last_used_at",
|
|
110
114
|
revokedAt: "revoked_at",
|
|
@@ -190,6 +194,10 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
|
|
|
190
194
|
scopes: "TEXT",
|
|
191
195
|
audience: "TEXT",
|
|
192
196
|
lastUsedAt: "INTEGER",
|
|
197
|
+
rateLimit: "TEXT",
|
|
198
|
+
ipAllowlist: "TEXT",
|
|
199
|
+
creatorId: "TEXT",
|
|
200
|
+
creatorScopes: "TEXT",
|
|
193
201
|
};
|
|
194
202
|
|
|
195
203
|
/**
|
|
@@ -123,6 +123,43 @@ export const app = oke({
|
|
|
123
123
|
await attached!.stop();
|
|
124
124
|
});
|
|
125
125
|
|
|
126
|
+
test("Console Access uses the host apiKeyStore instance (not Console SQL)", async () => {
|
|
127
|
+
const dir = await mkdtemp(join(tmpdir(), "oke-attach-keys-"));
|
|
128
|
+
const entry = join(dir, "app.ts");
|
|
129
|
+
await writeFile(
|
|
130
|
+
entry,
|
|
131
|
+
`
|
|
132
|
+
import { oke, on, flow, http, gate, store } from ${JSON.stringify(OKE)};
|
|
133
|
+
const db = store.sql("db");
|
|
134
|
+
const member = gate.policy("member", ({ auth }) => !!auth.verified);
|
|
135
|
+
export const ping = on(
|
|
136
|
+
http.get("/ping").gate(member),
|
|
137
|
+
flow("main.ping", { do: () => ({ ok: true }) }),
|
|
138
|
+
);
|
|
139
|
+
export const app = oke({
|
|
140
|
+
name: "attach-keys",
|
|
141
|
+
stores: [db],
|
|
142
|
+
gate: {
|
|
143
|
+
auth: { secret: "attach-host-key-pepper", http: false },
|
|
144
|
+
policies: [member],
|
|
145
|
+
},
|
|
146
|
+
}).adopt({ ping });
|
|
147
|
+
`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const consoleKeys = { keys: new Map(), persist: async () => {} };
|
|
151
|
+
const state = {
|
|
152
|
+
invokeUserFlow: null,
|
|
153
|
+
storeRuntime: null,
|
|
154
|
+
apiKeys: consoleKeys,
|
|
155
|
+
} as unknown as ConsoleState;
|
|
156
|
+
const attached = await attachHostToConsole({ entry, cwd: dir, state });
|
|
157
|
+
expect(attached).not.toBeNull();
|
|
158
|
+
expect(state.apiKeys).not.toBe(consoleKeys);
|
|
159
|
+
expect(state.apiKeys.persist).not.toBe(consoleKeys.persist);
|
|
160
|
+
await attached!.stop();
|
|
161
|
+
});
|
|
162
|
+
|
|
126
163
|
test("returns null when the entry is not a bootable app", async () => {
|
|
127
164
|
const dir = await mkdtemp(join(tmpdir(), "oke-attach-empty-"));
|
|
128
165
|
const entry = join(dir, "app.ts");
|
|
@@ -69,6 +69,12 @@ export async function attachHostToConsole(
|
|
|
69
69
|
if (vault) options.state.vaultRuntime = vault;
|
|
70
70
|
const ai = host.elements?.ai ?? host.bootResult?.ai;
|
|
71
71
|
if (ai) bindAiRuntime(options.state, ai);
|
|
72
|
+
const hostKeys = host.apiKeys;
|
|
73
|
+
if (hostKeys) {
|
|
74
|
+
// Host `gate.auth.apiKeyStore` is the durable store (`oke_api_keys` on
|
|
75
|
+
// `store.sql()`). Console displays that instance — never the reverse.
|
|
76
|
+
options.state.apiKeys = hostKeys;
|
|
77
|
+
}
|
|
72
78
|
|
|
73
79
|
return {
|
|
74
80
|
stop: async () => {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type {
|
|
10
|
+
AuthApiKeysResourceRef,
|
|
10
11
|
Effects,
|
|
11
12
|
PromptRef,
|
|
12
13
|
ResourceRef,
|
|
@@ -145,8 +146,8 @@ const TABLE_ARG_METHODS = new Set([
|
|
|
145
146
|
* @param options - Handler AST, bindings, and annotation flag
|
|
146
147
|
*/
|
|
147
148
|
export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
148
|
-
const reads = new Set<ResourceRef | SignalResourceRef>();
|
|
149
|
-
const writes = new Set<ResourceRef>();
|
|
149
|
+
const reads = new Set<ResourceRef | SignalResourceRef | AuthApiKeysResourceRef>();
|
|
150
|
+
const writes = new Set<ResourceRef | AuthApiKeysResourceRef>();
|
|
150
151
|
const emits = new Set<SignalRef>();
|
|
151
152
|
const sends = new Set<TemplateRef>();
|
|
152
153
|
const asks = new Set<PromptRef>();
|
|
@@ -217,6 +218,20 @@ export function inferEffects(options: InferEffectsOptions): InferredEffects {
|
|
|
217
218
|
continue;
|
|
218
219
|
}
|
|
219
220
|
|
|
221
|
+
if (chain.rootMethod === "auth" && call === chain.rootCall) {
|
|
222
|
+
const method = chain.methods[1];
|
|
223
|
+
if (method === "listApiKeys") reads.add("auth:api-keys");
|
|
224
|
+
if (
|
|
225
|
+
method === "createApiKey" ||
|
|
226
|
+
method === "revokeApiKey" ||
|
|
227
|
+
method === "rotateApiKey" ||
|
|
228
|
+
method === "updateApiKey"
|
|
229
|
+
) {
|
|
230
|
+
writes.add("auth:api-keys");
|
|
231
|
+
}
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
|
|
220
235
|
if (chain.rootMethod === "search" && call === chain.rootCall) {
|
|
221
236
|
const ref = resolveNamed(call.arguments[0], options.bindings, "embed");
|
|
222
237
|
if (ref) {
|