@getstrata/core 1.1.5 → 1.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @getstrata/core changelog
2
2
 
3
+ ## 1.1.7
4
+
5
+ - Fix generated webhook SQL, billing hook, and hybrid SPA smoke
6
+
7
+ ## 1.1.6
8
+
9
+ - Add optional OIDC cookie login and loopback discovery
10
+
11
+ ## Unreleased
12
+
13
+ - `sealOidcPkceCookie()` / `readOidcPkceCookie()` on `@getstrata/core/security/oauthState` store the OIDC PKCE handshake in the `oidc_pkce` cookie, bound to the signed OAuth state.
14
+ - OIDC discovery, token, and JWKS fetches allow loopback issuers outside production. `OIDC_ALLOW_PRIVATE=true` allows other private hosts and is ignored in production.
15
+ - `OidcProvider.getAuthorizationUrl()` still throws. Call `createAuthorization()`.
16
+
3
17
  ## 1.1.5
4
18
 
5
19
  - Close dogfood gaps: webhook docs, starter extras, signing helper
@@ -5,6 +5,12 @@ interface OidcDiscovery {
5
5
  token_endpoint?: string;
6
6
  jwks_uri?: string;
7
7
  }
8
+ declare function oidcSafeFetchOptions(url: string): {
9
+ allowHttp: boolean;
10
+ allowPrivate: boolean;
11
+ timeoutMs: number;
12
+ maxRedirects: number;
13
+ };
8
14
  declare function loadOidcDiscovery(issuer: string): Promise<OidcDiscovery>;
9
15
  declare function oidcAccessTokenHash(accessToken: string): string;
10
16
  declare function verifyOidcIdToken(token: string, options: {
@@ -16,4 +22,4 @@ declare function verifyOidcIdToken(token: string, options: {
16
22
  }): Promise<JwtPayload>;
17
23
  declare function resetOidcDiscoveryCacheForTests(): void;
18
24
  export type { OidcDiscovery };
19
- export { loadOidcDiscovery, oidcAccessTokenHash, resetOidcDiscoveryCacheForTests, verifyOidcIdToken, };
25
+ export { loadOidcDiscovery, oidcAccessTokenHash, oidcSafeFetchOptions, resetOidcDiscoveryCacheForTests, verifyOidcIdToken, };
@@ -1,4 +1,10 @@
1
1
  declare const OAUTH_STATE_COOKIE = "oauth_state";
2
+ declare const OIDC_PKCE_COOKIE = "oidc_pkce";
3
+ interface OidcPkceSeal {
4
+ state: string;
5
+ nonce: string;
6
+ codeVerifier: string;
7
+ }
2
8
  declare function verifySignedOAuthStateValue(value: string): boolean;
3
9
  declare function createOAuthState(): {
4
10
  state: string;
@@ -9,4 +15,11 @@ declare function createOAuthStateCookie(): {
9
15
  };
10
16
  declare function verifyOAuthState(_request: Request, returnedState: string | null): boolean;
11
17
  declare function clearOAuthStateCookie(): string;
12
- export { clearOAuthStateCookie, createOAuthState, createOAuthStateCookie, OAUTH_STATE_COOKIE, verifyOAuthState, verifySignedOAuthStateValue, };
18
+ declare function sealOidcPkceCookie(payload: OidcPkceSeal): string;
19
+ declare function clearOidcPkceCookie(): string;
20
+ declare function readOidcPkceCookie(request: Request, returnedState: string | null): {
21
+ nonce: string;
22
+ codeVerifier: string;
23
+ } | null;
24
+ export type { OidcPkceSeal };
25
+ export { clearOAuthStateCookie, clearOidcPkceCookie, createOAuthState, createOAuthStateCookie, OAUTH_STATE_COOKIE, OIDC_PKCE_COOKIE, readOidcPkceCookie, sealOidcPkceCookie, verifyOAuthState, verifySignedOAuthStateValue, };
@@ -2,6 +2,10 @@
2
2
  // ../../src/core/auth/oauth/oidcProvider.ts
3
3
  import { createHash as createHash2, randomBytes } from "crypto";
4
4
 
5
+ // ../../src/core/security/safeUrl.ts
6
+ import { lookup as dnsLookupImpl } from "dns/promises";
7
+ import { BadRequestError } from "@getstrata/core/errors/http";
8
+
5
9
  // ../../src/core/runtime/appEnv.ts
6
10
  var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
7
11
  function normalizeEnvValue(value) {
@@ -23,8 +27,6 @@ function envFlagEnabled(value) {
23
27
  }
24
28
 
25
29
  // ../../src/core/security/safeUrl.ts
26
- import { lookup as dnsLookupImpl } from "dns/promises";
27
- import { BadRequestError } from "@getstrata/core/errors/http";
28
30
  var dnsLookup = dnsLookupImpl;
29
31
  var BLOCKED_HOSTNAMES = new Set([
30
32
  "localhost",
@@ -330,8 +332,33 @@ var jwksCache = new Map;
330
332
  function issuerOrigin(issuer) {
331
333
  return issuer.replace(/\/$/, "");
332
334
  }
335
+ function oidcLoopbackHost(hostname) {
336
+ const host = hostname.trim().toLowerCase().replace(/^\[|\]$/gu, "");
337
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
338
+ }
339
+ function oidcOutboundAllowsPrivate(url) {
340
+ if (isProductionEnv()) {
341
+ return false;
342
+ }
343
+ if (process.env.OIDC_ALLOW_PRIVATE === "true") {
344
+ return true;
345
+ }
346
+ try {
347
+ return oidcLoopbackHost(new URL(url).hostname);
348
+ } catch {
349
+ return false;
350
+ }
351
+ }
352
+ function oidcSafeFetchOptions(url) {
353
+ return {
354
+ allowHttp: !isProductionEnv(),
355
+ allowPrivate: oidcOutboundAllowsPrivate(url),
356
+ timeoutMs: 1e4,
357
+ maxRedirects: 0
358
+ };
359
+ }
333
360
  async function fetchJson(url) {
334
- const response = await safeFetch(url, { headers: { accept: "application/json" } }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
361
+ const response = await safeFetch(url, { headers: { accept: "application/json" } }, oidcSafeFetchOptions(url));
335
362
  return await response.json();
336
363
  }
337
364
  async function loadOidcDiscovery(issuer) {
@@ -532,11 +559,12 @@ class OidcProvider {
532
559
  client_secret: this.options.clientSecret,
533
560
  code_verifier: handshake.codeVerifier
534
561
  });
535
- const tokenResponse = await safeFetch(requireOidcEndpoint(discovery.token_endpoint, "token_endpoint"), {
562
+ const tokenEndpoint = requireOidcEndpoint(discovery.token_endpoint, "token_endpoint");
563
+ const tokenResponse = await safeFetch(tokenEndpoint, {
536
564
  method: "POST",
537
565
  headers: { "content-type": "application/x-www-form-urlencoded" },
538
566
  body
539
- }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
567
+ }, oidcSafeFetchOptions(tokenEndpoint));
540
568
  const tokenBody = await tokenResponse.json();
541
569
  if (!tokenBody.access_token) {
542
570
  throw new Error("OIDC token exchange failed.");
@@ -90,6 +90,7 @@ function sdkClientClassName() {
90
90
 
91
91
  // ../../src/core/security/oauthState.ts
92
92
  var OAUTH_STATE_COOKIE = "oauth_state";
93
+ var OIDC_PKCE_COOKIE = "oidc_pkce";
93
94
  var OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
94
95
  function resolveOAuthStateSecret() {
95
96
  return requireConfiguredSecret(["OAUTH_STATE_SECRET", "SESSION_SECRET"], "oauth-state-secret");
@@ -144,11 +145,85 @@ function verifyOAuthState(_request, returnedState) {
144
145
  function clearOAuthStateCookie() {
145
146
  return `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${isProductionEnv() ? "; Secure" : ""}`;
146
147
  }
148
+ function oauthCookieSuffix() {
149
+ return `Path=/; HttpOnly; SameSite=Lax${isProductionEnv() ? "; Secure" : ""}`;
150
+ }
151
+ function sealOidcPkceCookie(payload) {
152
+ const body = Buffer.from(JSON.stringify({
153
+ state: payload.state,
154
+ nonce: payload.nonce,
155
+ codeVerifier: payload.codeVerifier,
156
+ iat: Date.now()
157
+ })).toString("base64url");
158
+ const signature = createHmac("sha256", resolveOAuthStateSecret()).update(body).digest("base64url");
159
+ const value = encodeURIComponent(`${body}.${signature}`);
160
+ return `${OIDC_PKCE_COOKIE}=${value}; ${oauthCookieSuffix()}; Max-Age=600`;
161
+ }
162
+ function clearOidcPkceCookie() {
163
+ return `${OIDC_PKCE_COOKIE}=; ${oauthCookieSuffix()}; Max-Age=0`;
164
+ }
165
+ function readOidcPkceCookie(request, returnedState) {
166
+ const state = returnedState?.trim() ?? "";
167
+ if (!state) {
168
+ return null;
169
+ }
170
+ const header = request.headers.get("cookie") ?? "";
171
+ for (const part of header.split(";")) {
172
+ const [name, ...rest] = part.trim().split("=");
173
+ if (name !== OIDC_PKCE_COOKIE) {
174
+ continue;
175
+ }
176
+ return unsealOidcPkceValue(rest.join("="), state);
177
+ }
178
+ return null;
179
+ }
180
+ function unsealOidcPkceValue(rawValue, returnedState) {
181
+ let decoded = rawValue;
182
+ try {
183
+ decoded = decodeURIComponent(rawValue);
184
+ } catch {
185
+ return null;
186
+ }
187
+ const splitAt = decoded.lastIndexOf(".");
188
+ if (splitAt <= 0) {
189
+ return null;
190
+ }
191
+ const body = decoded.slice(0, splitAt);
192
+ const signature = decoded.slice(splitAt + 1);
193
+ const expected = createHmac("sha256", resolveOAuthStateSecret()).update(body).digest("base64url");
194
+ const expectedBuffer = Buffer.from(expected);
195
+ const actualBuffer = Buffer.from(signature);
196
+ if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) {
197
+ return null;
198
+ }
199
+ let parsed;
200
+ try {
201
+ parsed = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
202
+ } catch {
203
+ return null;
204
+ }
205
+ if (typeof parsed.state !== "string" || typeof parsed.nonce !== "string" || typeof parsed.codeVerifier !== "string" || typeof parsed.iat !== "number" || !parsed.nonce.trim() || !parsed.codeVerifier.trim()) {
206
+ return null;
207
+ }
208
+ if (Date.now() - parsed.iat > OAUTH_STATE_TTL_MS) {
209
+ return null;
210
+ }
211
+ const left = Buffer.from(parsed.state);
212
+ const right = Buffer.from(returnedState);
213
+ if (left.length !== right.length || !timingSafeEqual(left, right)) {
214
+ return null;
215
+ }
216
+ return { nonce: parsed.nonce, codeVerifier: parsed.codeVerifier };
217
+ }
147
218
  export {
148
219
  OAUTH_STATE_COOKIE,
220
+ OIDC_PKCE_COOKIE,
149
221
  clearOAuthStateCookie,
222
+ clearOidcPkceCookie,
150
223
  createOAuthState,
151
224
  createOAuthStateCookie,
225
+ readOidcPkceCookie,
226
+ sealOidcPkceCookie,
152
227
  verifyOAuthState,
153
228
  verifySignedOAuthStateValue
154
229
  };
package/dist/index.js CHANGED
@@ -1899,8 +1899,33 @@ var jwksCache = new Map;
1899
1899
  function issuerOrigin(issuer) {
1900
1900
  return issuer.replace(/\/$/, "");
1901
1901
  }
1902
+ function oidcLoopbackHost(hostname) {
1903
+ const host = hostname.trim().toLowerCase().replace(/^\[|\]$/gu, "");
1904
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1905
+ }
1906
+ function oidcOutboundAllowsPrivate(url) {
1907
+ if (isProductionEnv()) {
1908
+ return false;
1909
+ }
1910
+ if (process.env.OIDC_ALLOW_PRIVATE === "true") {
1911
+ return true;
1912
+ }
1913
+ try {
1914
+ return oidcLoopbackHost(new URL(url).hostname);
1915
+ } catch {
1916
+ return false;
1917
+ }
1918
+ }
1919
+ function oidcSafeFetchOptions(url) {
1920
+ return {
1921
+ allowHttp: !isProductionEnv(),
1922
+ allowPrivate: oidcOutboundAllowsPrivate(url),
1923
+ timeoutMs: 1e4,
1924
+ maxRedirects: 0
1925
+ };
1926
+ }
1902
1927
  async function fetchJson(url) {
1903
- const response = await safeFetch(url, { headers: { accept: "application/json" } }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
1928
+ const response = await safeFetch(url, { headers: { accept: "application/json" } }, oidcSafeFetchOptions(url));
1904
1929
  return await response.json();
1905
1930
  }
1906
1931
  async function loadOidcDiscovery(issuer) {
@@ -2097,11 +2122,12 @@ class OidcProvider {
2097
2122
  client_secret: this.options.clientSecret,
2098
2123
  code_verifier: handshake.codeVerifier
2099
2124
  });
2100
- const tokenResponse = await safeFetch(requireOidcEndpoint(discovery.token_endpoint, "token_endpoint"), {
2125
+ const tokenEndpoint = requireOidcEndpoint(discovery.token_endpoint, "token_endpoint");
2126
+ const tokenResponse = await safeFetch(tokenEndpoint, {
2101
2127
  method: "POST",
2102
2128
  headers: { "content-type": "application/x-www-form-urlencoded" },
2103
2129
  body
2104
- }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
2130
+ }, oidcSafeFetchOptions(tokenEndpoint));
2105
2131
  const tokenBody = await tokenResponse.json();
2106
2132
  if (!tokenBody.access_token) {
2107
2133
  throw new Error("OIDC token exchange failed.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getstrata/core",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "description": "Strata Bun framework public API",
5
5
  "type": "module",
6
6
  "license": "MIT",