@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
|
-
|
|
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" } },
|
|
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
|
|
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
|
-
},
|
|
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" } },
|
|
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
|
|
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
|
-
},
|
|
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.");
|