@valbuild/server 0.111.0 → 0.113.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/dist/declarations/src/getValidationErrorFileRef.d.ts +1 -1
- package/dist/declarations/src/jwt.d.ts +51 -6
- package/dist/declarations/src/patchStore.d.ts +6 -6
- package/dist/valbuild-server.cjs.dev.js +170 -43
- package/dist/valbuild-server.cjs.prod.js +170 -43
- package/dist/valbuild-server.esm.js +169 -43
- package/package.json +4 -4
|
@@ -5,4 +5,4 @@ import { ValidationError } from "@valbuild/core";
|
|
|
5
5
|
* There is no schema in hand here — a `ValidationError` carries only the value
|
|
6
6
|
* it flagged — so the shape is all there is to go on.
|
|
7
7
|
*/
|
|
8
|
-
export declare function getValidationErrorFileRef(validationError: ValidationError):
|
|
8
|
+
export declare function getValidationErrorFileRef(validationError: ValidationError): string | null;
|
|
@@ -1,9 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
declare const JwtPayloadSchema: z.ZodObject<{
|
|
3
|
+
sub: z.ZodString;
|
|
4
|
+
exp: z.ZodNumber;
|
|
5
|
+
org: z.ZodString;
|
|
6
|
+
project: z.ZodString;
|
|
7
|
+
}, z.core.$loose>;
|
|
8
|
+
export type JwtPayload = z.infer<typeof JwtPayloadSchema>;
|
|
9
|
+
export type JwtFailureReason =
|
|
10
|
+
/** Not a JWT we issued: wrong number of segments, bad header, or a payload that is not a Val payload. */
|
|
11
|
+
"malformed"
|
|
12
|
+
/** The HMAC did not match: the token was forged or tampered with. */
|
|
13
|
+
| "invalid-signature"
|
|
14
|
+
/** The token parsed and verified, but `exp` is in the past. */
|
|
15
|
+
| "expired"
|
|
16
|
+
/** Programming error: {@link verifyJwt} was called without a secret. */
|
|
17
|
+
| "missing-secret";
|
|
18
|
+
export type JwtFailure = {
|
|
19
|
+
success: false;
|
|
20
|
+
reason: JwtFailureReason;
|
|
21
|
+
message: string;
|
|
6
22
|
};
|
|
7
|
-
export
|
|
23
|
+
export type JwtResult = {
|
|
24
|
+
success: true;
|
|
25
|
+
data: JwtPayload;
|
|
26
|
+
} | JwtFailure;
|
|
27
|
+
/**
|
|
28
|
+
* Verify a JWT that we issued with {@link encodeJwt}, and return its payload.
|
|
29
|
+
*
|
|
30
|
+
* Checks, in order: the segment count, the header (`alg` is pinned to HS256, so
|
|
31
|
+
* algorithm confusion is rejected before the signature is looked at), the HMAC
|
|
32
|
+
* (compared in constant time), the payload shape, and `exp`.
|
|
33
|
+
*
|
|
34
|
+
* There is deliberately no way to make this skip the signature check: the
|
|
35
|
+
* secret is required, and an empty one is a failure rather than a token that
|
|
36
|
+
* validates against `HMAC("")`. Use {@link decodeJwtWithoutVerifying} - and read
|
|
37
|
+
* what it says - when you genuinely do not hold the signing key.
|
|
38
|
+
*/
|
|
39
|
+
export declare function verifyJwt(token: string, secretKey: string): JwtResult;
|
|
40
|
+
/**
|
|
41
|
+
* Parse a JWT's payload **without checking its signature**.
|
|
42
|
+
*
|
|
43
|
+
* The header, the payload shape and `exp` are still validated, but nothing here
|
|
44
|
+
* establishes that the token was issued by anyone in particular. Only use this
|
|
45
|
+
* for a token whose authenticity is already established by the channel it
|
|
46
|
+
* arrived on - the app token we fetch from val.build over an api-key
|
|
47
|
+
* authenticated HTTPS request is the one such case. Anything that arrives from
|
|
48
|
+
* a browser (a cookie, a header, a query parameter) must go through
|
|
49
|
+
* {@link verifyJwt}.
|
|
50
|
+
*/
|
|
51
|
+
export declare function decodeJwtWithoutVerifying(token: string): JwtResult;
|
|
8
52
|
export declare function getExpire(): number;
|
|
9
53
|
export declare function encodeJwt(payload: object, sessionKey: string): string;
|
|
54
|
+
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { PatchId } from "@valbuild/core";
|
|
1
|
+
import { ModuleFilePath, PatchId } from "@valbuild/core";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import type { AuthorId, BaseSha } from "./ValOps.js";
|
|
4
4
|
import { PatchLogEntry, PatchLogProblem } from "./patchLog.js";
|
|
@@ -32,14 +32,14 @@ export declare const FSPatch: z.ZodObject<{
|
|
|
32
32
|
patch: z.ZodArray<z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
33
33
|
op: z.ZodLiteral<"add">;
|
|
34
34
|
path: z.ZodArray<z.ZodString>;
|
|
35
|
-
value: z.ZodType<
|
|
35
|
+
value: z.ZodType<import("@valbuild/core/patch").JSONValue, unknown, z.core.$ZodTypeInternals<import("@valbuild/core/patch").JSONValue, unknown>>;
|
|
36
36
|
}, z.core.$strict>, z.ZodObject<{
|
|
37
37
|
op: z.ZodLiteral<"remove">;
|
|
38
38
|
path: z.ZodTuple<[z.ZodString], z.ZodString>;
|
|
39
39
|
}, z.core.$strict>, z.ZodObject<{
|
|
40
40
|
op: z.ZodLiteral<"replace">;
|
|
41
41
|
path: z.ZodArray<z.ZodString>;
|
|
42
|
-
value: z.ZodType<
|
|
42
|
+
value: z.ZodType<import("@valbuild/core/patch").JSONValue, unknown, z.core.$ZodTypeInternals<import("@valbuild/core/patch").JSONValue, unknown>>;
|
|
43
43
|
}, z.core.$strict>, z.ZodObject<{
|
|
44
44
|
op: z.ZodLiteral<"move">;
|
|
45
45
|
from: z.ZodTuple<[z.ZodString], z.ZodString>;
|
|
@@ -51,15 +51,15 @@ export declare const FSPatch: z.ZodObject<{
|
|
|
51
51
|
}, z.core.$strict>, z.ZodObject<{
|
|
52
52
|
op: z.ZodLiteral<"test">;
|
|
53
53
|
path: z.ZodArray<z.ZodString>;
|
|
54
|
-
value: z.ZodType<
|
|
54
|
+
value: z.ZodType<import("@valbuild/core/patch").JSONValue, unknown, z.core.$ZodTypeInternals<import("@valbuild/core/patch").JSONValue, unknown>>;
|
|
55
55
|
}, z.core.$strict>, z.ZodObject<{
|
|
56
56
|
op: z.ZodLiteral<"file">;
|
|
57
57
|
path: z.ZodArray<z.ZodString>;
|
|
58
58
|
filePath: z.ZodString;
|
|
59
|
-
value: z.ZodType<
|
|
59
|
+
value: z.ZodType<import("@valbuild/core/patch").JSONValue, unknown, z.core.$ZodTypeInternals<import("@valbuild/core/patch").JSONValue, unknown>>;
|
|
60
60
|
remote: z.ZodBoolean;
|
|
61
61
|
nestedFilePath: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
62
|
-
metadata: z.ZodOptional<z.ZodType<
|
|
62
|
+
metadata: z.ZodOptional<z.ZodType<import("@valbuild/core/patch").JSONValue, unknown, z.core.$ZodTypeInternals<import("@valbuild/core/patch").JSONValue, unknown>>>;
|
|
63
63
|
}, z.core.$strict>], "op">>;
|
|
64
64
|
patchId: z.ZodString;
|
|
65
65
|
baseSha: z.ZodString & z.ZodType<BaseSha, string, z.core.$ZodTypeInternals<BaseSha, string>>;
|
|
@@ -1936,45 +1936,142 @@ class Service {
|
|
|
1936
1936
|
}
|
|
1937
1937
|
}
|
|
1938
1938
|
|
|
1939
|
-
|
|
1939
|
+
const JwtPayloadSchema = z.z.object({
|
|
1940
|
+
sub: z.z.string(),
|
|
1941
|
+
exp: z.z.number(),
|
|
1942
|
+
org: z.z.string(),
|
|
1943
|
+
project: z.z.string()
|
|
1944
|
+
})
|
|
1945
|
+
// NOTE: passthrough, because the session cookie payload carries more than
|
|
1946
|
+
// this (the val.build token, notably). This is the minimum every Val JWT has.
|
|
1947
|
+
.passthrough();
|
|
1948
|
+
/**
|
|
1949
|
+
* `exp` is an absolute Unix timestamp: a token is rejected once that moment has
|
|
1950
|
+
* passed, with this much slack to absorb clock drift between whoever signed the
|
|
1951
|
+
* token and us.
|
|
1952
|
+
*/
|
|
1953
|
+
const CLOCK_SKEW_LEEWAY_SECONDS = 60;
|
|
1954
|
+
function failure(reason, message) {
|
|
1955
|
+
// NOTE: deliberately never logs the token itself - it is a credential, and
|
|
1956
|
+
// anything we print here ends up in the host's request logs.
|
|
1957
|
+
console.debug(`Invalid JWT (${reason}): ${message}`);
|
|
1958
|
+
return {
|
|
1959
|
+
success: false,
|
|
1960
|
+
reason,
|
|
1961
|
+
message
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
function splitToken(token) {
|
|
1940
1965
|
const [headerBase64, payloadBase64, signatureBase64, ...rest] = token.split(".");
|
|
1941
1966
|
if (!headerBase64 || !payloadBase64 || !signatureBase64 || rest.length > 0) {
|
|
1942
|
-
console.debug("Invalid JWT: format is not exactly {header}.{payload}.{signature}", token);
|
|
1943
1967
|
return null;
|
|
1944
1968
|
}
|
|
1969
|
+
return {
|
|
1970
|
+
headerBase64,
|
|
1971
|
+
payloadBase64,
|
|
1972
|
+
signatureBase64
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** Returns the failure if the header is not one of ours, or null if it is fine. */
|
|
1977
|
+
function verifyHeader(headerBase64) {
|
|
1978
|
+
let parsedHeader;
|
|
1945
1979
|
try {
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
console.debug("Invalid JWT: invalid header", parsedHeader);
|
|
1950
|
-
return null;
|
|
1951
|
-
}
|
|
1952
|
-
if (headerVerification.data.typ !== jwtHeader.typ) {
|
|
1953
|
-
console.debug("Invalid JWT: invalid header typ", parsedHeader);
|
|
1954
|
-
return null;
|
|
1955
|
-
}
|
|
1956
|
-
if (headerVerification.data.alg !== jwtHeader.alg) {
|
|
1957
|
-
console.debug("Invalid JWT: invalid header alg", parsedHeader);
|
|
1958
|
-
return null;
|
|
1959
|
-
}
|
|
1960
|
-
} catch (err) {
|
|
1961
|
-
console.debug("Invalid JWT: could not parse header", err);
|
|
1962
|
-
return null;
|
|
1980
|
+
parsedHeader = JSON.parse(Buffer.from(headerBase64, "base64").toString("utf8"));
|
|
1981
|
+
} catch {
|
|
1982
|
+
return failure("malformed", "could not parse header");
|
|
1963
1983
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1984
|
+
const headerVerification = JwtHeaderSchema.safeParse(parsedHeader);
|
|
1985
|
+
if (!headerVerification.success) {
|
|
1986
|
+
// NOTE: JwtHeaderSchema pins alg to HS256, so `alg: "none"` and every other
|
|
1987
|
+
// algorithm-confusion header is rejected here, before we look at the
|
|
1988
|
+
// signature at all.
|
|
1989
|
+
return failure("malformed", "unsupported header");
|
|
1970
1990
|
}
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
function parsePayload(payloadBase64) {
|
|
1994
|
+
let parsedPayload;
|
|
1971
1995
|
try {
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
console.debug("Invalid JWT: could not parse payload", err);
|
|
1976
|
-
return null;
|
|
1996
|
+
parsedPayload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8"));
|
|
1997
|
+
} catch {
|
|
1998
|
+
return failure("malformed", "could not parse payload");
|
|
1977
1999
|
}
|
|
2000
|
+
const payloadVerification = JwtPayloadSchema.safeParse(parsedPayload);
|
|
2001
|
+
if (!payloadVerification.success) {
|
|
2002
|
+
return failure("malformed", "payload is not a Val JWT payload");
|
|
2003
|
+
}
|
|
2004
|
+
const payload = payloadVerification.data;
|
|
2005
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
2006
|
+
if (payload.exp + CLOCK_SKEW_LEEWAY_SECONDS <= nowSeconds) {
|
|
2007
|
+
return failure("expired", "token expired");
|
|
2008
|
+
}
|
|
2009
|
+
return {
|
|
2010
|
+
success: true,
|
|
2011
|
+
data: payload
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Verify a JWT that we issued with {@link encodeJwt}, and return its payload.
|
|
2017
|
+
*
|
|
2018
|
+
* Checks, in order: the segment count, the header (`alg` is pinned to HS256, so
|
|
2019
|
+
* algorithm confusion is rejected before the signature is looked at), the HMAC
|
|
2020
|
+
* (compared in constant time), the payload shape, and `exp`.
|
|
2021
|
+
*
|
|
2022
|
+
* There is deliberately no way to make this skip the signature check: the
|
|
2023
|
+
* secret is required, and an empty one is a failure rather than a token that
|
|
2024
|
+
* validates against `HMAC("")`. Use {@link decodeJwtWithoutVerifying} - and read
|
|
2025
|
+
* what it says - when you genuinely do not hold the signing key.
|
|
2026
|
+
*/
|
|
2027
|
+
function verifyJwt(token, secretKey) {
|
|
2028
|
+
if (!secretKey) {
|
|
2029
|
+
return failure("missing-secret", "no secret key was provided");
|
|
2030
|
+
}
|
|
2031
|
+
const segments = splitToken(token);
|
|
2032
|
+
if (!segments) {
|
|
2033
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2034
|
+
}
|
|
2035
|
+
const {
|
|
2036
|
+
headerBase64,
|
|
2037
|
+
payloadBase64,
|
|
2038
|
+
signatureBase64
|
|
2039
|
+
} = segments;
|
|
2040
|
+
const headerFailure = verifyHeader(headerBase64);
|
|
2041
|
+
if (headerFailure) {
|
|
2042
|
+
return headerFailure;
|
|
2043
|
+
}
|
|
2044
|
+
const expectedSignature = crypto__default["default"].createHmac("sha256", secretKey).update(`${headerBase64}.${payloadBase64}`).digest();
|
|
2045
|
+
const actualSignature = Buffer.from(signatureBase64, "base64");
|
|
2046
|
+
// NOTE: the length check is what makes timingSafeEqual usable at all (it
|
|
2047
|
+
// throws on a length mismatch), and the length of an HMAC-SHA256 is public.
|
|
2048
|
+
if (actualSignature.length !== expectedSignature.length || !crypto__default["default"].timingSafeEqual(actualSignature, expectedSignature)) {
|
|
2049
|
+
return failure("invalid-signature", "signature does not match");
|
|
2050
|
+
}
|
|
2051
|
+
return parsePayload(payloadBase64);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Parse a JWT's payload **without checking its signature**.
|
|
2056
|
+
*
|
|
2057
|
+
* The header, the payload shape and `exp` are still validated, but nothing here
|
|
2058
|
+
* establishes that the token was issued by anyone in particular. Only use this
|
|
2059
|
+
* for a token whose authenticity is already established by the channel it
|
|
2060
|
+
* arrived on - the app token we fetch from val.build over an api-key
|
|
2061
|
+
* authenticated HTTPS request is the one such case. Anything that arrives from
|
|
2062
|
+
* a browser (a cookie, a header, a query parameter) must go through
|
|
2063
|
+
* {@link verifyJwt}.
|
|
2064
|
+
*/
|
|
2065
|
+
function decodeJwtWithoutVerifying(token) {
|
|
2066
|
+
const segments = splitToken(token);
|
|
2067
|
+
if (!segments) {
|
|
2068
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2069
|
+
}
|
|
2070
|
+
const headerFailure = verifyHeader(segments.headerBase64);
|
|
2071
|
+
if (headerFailure) {
|
|
2072
|
+
return headerFailure;
|
|
2073
|
+
}
|
|
2074
|
+
return parsePayload(segments.payloadBase64);
|
|
1978
2075
|
}
|
|
1979
2076
|
function getExpire() {
|
|
1980
2077
|
return Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 4; // 4 days
|
|
@@ -7618,7 +7715,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7618
7715
|
}).then(async res => {
|
|
7619
7716
|
if (res.status === 200) {
|
|
7620
7717
|
const token = await res.text();
|
|
7621
|
-
|
|
7718
|
+
// NOTE: this token is signed by val.build with a key we do not
|
|
7719
|
+
// hold, so we cannot verify it. What stands in for a signature check
|
|
7720
|
+
// is where it came from: the POST above, authenticated with the
|
|
7721
|
+
// project's api key, to the configured `valBuildUrl`. It is not user
|
|
7722
|
+
// input - but it is only as trustworthy as that host and that
|
|
7723
|
+
// channel, and `valBuildUrl` is overridable (`opts.valBuildUrl`,
|
|
7724
|
+
// `VAL_BUILD_URL`) and not required to be https. Point it at a plain
|
|
7725
|
+
// http host and this payload - which is re-signed into the session
|
|
7726
|
+
// cookie below - is whatever the network says it is.
|
|
7727
|
+
const decoded = decodeJwtWithoutVerifying(token);
|
|
7728
|
+
if (!decoded.success) {
|
|
7729
|
+
return null;
|
|
7730
|
+
}
|
|
7731
|
+
const verification = ValAppJwtPayload.safeParse(decoded.data);
|
|
7622
7732
|
if (!verification.success) {
|
|
7623
7733
|
return null;
|
|
7624
7734
|
}
|
|
@@ -7650,8 +7760,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7650
7760
|
}
|
|
7651
7761
|
}
|
|
7652
7762
|
if (typeof cookie === "string") {
|
|
7653
|
-
const
|
|
7654
|
-
if (!
|
|
7763
|
+
const verifiedToken = verifyJwt(cookie, options.valSecret);
|
|
7764
|
+
if (!verifiedToken.success) {
|
|
7655
7765
|
if (serverOps instanceof ValOpsFS) {
|
|
7656
7766
|
return {
|
|
7657
7767
|
error: null,
|
|
@@ -7659,10 +7769,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7659
7769
|
};
|
|
7660
7770
|
}
|
|
7661
7771
|
return {
|
|
7662
|
-
error:
|
|
7772
|
+
error: sessionErrorMessage(verifiedToken.reason)
|
|
7663
7773
|
};
|
|
7664
7774
|
}
|
|
7665
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
7775
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
7666
7776
|
if (!verification.success) {
|
|
7667
7777
|
if (serverOps instanceof ValOpsFS) {
|
|
7668
7778
|
return {
|
|
@@ -7671,7 +7781,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7671
7781
|
};
|
|
7672
7782
|
}
|
|
7673
7783
|
return {
|
|
7674
|
-
error: "Session invalid
|
|
7784
|
+
error: "Session invalid. You will need to login again."
|
|
7675
7785
|
};
|
|
7676
7786
|
}
|
|
7677
7787
|
return {
|
|
@@ -10151,25 +10261,41 @@ const IntegratedServerJwtPayload = z.z.object({
|
|
|
10151
10261
|
org: z.z.string(),
|
|
10152
10262
|
project: z.z.string()
|
|
10153
10263
|
});
|
|
10264
|
+
/**
|
|
10265
|
+
* The message we show the user when their session cookie does not verify. The
|
|
10266
|
+
* distinction matters: an expired session is normal and self-explanatory, while
|
|
10267
|
+
* a bad signature means the cookie was tampered with or `VAL_SECRET` changed.
|
|
10268
|
+
*/
|
|
10269
|
+
function sessionErrorMessage(reason) {
|
|
10270
|
+
switch (reason) {
|
|
10271
|
+
case "expired":
|
|
10272
|
+
return "Session expired. You will need to login again.";
|
|
10273
|
+
case "malformed":
|
|
10274
|
+
case "invalid-signature":
|
|
10275
|
+
return "Could not verify session (invalid token). You will need to login again.";
|
|
10276
|
+
case "missing-secret":
|
|
10277
|
+
return "Setup is not correct: secret is missing";
|
|
10278
|
+
}
|
|
10279
|
+
}
|
|
10154
10280
|
async function withAuth(secret, cookies, errorMessageType, handler) {
|
|
10155
10281
|
const cookie = cookies[internal.VAL_SESSION_COOKIE];
|
|
10156
10282
|
if (typeof cookie === "string") {
|
|
10157
|
-
const
|
|
10158
|
-
if (!
|
|
10283
|
+
const verifiedToken = verifyJwt(cookie, secret);
|
|
10284
|
+
if (!verifiedToken.success) {
|
|
10159
10285
|
return {
|
|
10160
10286
|
status: 401,
|
|
10161
10287
|
json: {
|
|
10162
|
-
message:
|
|
10163
|
-
details:
|
|
10288
|
+
message: sessionErrorMessage(verifiedToken.reason),
|
|
10289
|
+
details: verifiedToken.reason
|
|
10164
10290
|
}
|
|
10165
10291
|
};
|
|
10166
10292
|
}
|
|
10167
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
10293
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
10168
10294
|
if (!verification.success) {
|
|
10169
10295
|
return {
|
|
10170
10296
|
status: 401,
|
|
10171
10297
|
json: {
|
|
10172
|
-
message: "Session invalid
|
|
10298
|
+
message: "Session invalid. You will need to login again.",
|
|
10173
10299
|
details: zodValidationError.fromError(verification.error).toString()
|
|
10174
10300
|
}
|
|
10175
10301
|
};
|
|
@@ -12862,7 +12988,7 @@ exports.createValApiRouter = createValApiRouter;
|
|
|
12862
12988
|
exports.createValModuleFileInspector = createValModuleFileInspector;
|
|
12863
12989
|
exports.createValServer = createValServer;
|
|
12864
12990
|
exports.currentFixHandlers = currentFixHandlers;
|
|
12865
|
-
exports.
|
|
12991
|
+
exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
|
|
12866
12992
|
exports.describePatchStoreProblems = describePatchStoreProblems;
|
|
12867
12993
|
exports.downloadFileFromRemote = downloadFileFromRemote;
|
|
12868
12994
|
exports.encodeJwt = encodeJwt;
|
|
@@ -12904,3 +13030,4 @@ exports.safeReadGit = safeReadGit;
|
|
|
12904
13030
|
exports.startValLogin = startValLogin;
|
|
12905
13031
|
exports.uploadRemoteFile = uploadRemoteFile;
|
|
12906
13032
|
exports.validateMetadata = validateMetadata;
|
|
13033
|
+
exports.verifyJwt = verifyJwt;
|
|
@@ -1936,45 +1936,142 @@ class Service {
|
|
|
1936
1936
|
}
|
|
1937
1937
|
}
|
|
1938
1938
|
|
|
1939
|
-
|
|
1939
|
+
const JwtPayloadSchema = z.z.object({
|
|
1940
|
+
sub: z.z.string(),
|
|
1941
|
+
exp: z.z.number(),
|
|
1942
|
+
org: z.z.string(),
|
|
1943
|
+
project: z.z.string()
|
|
1944
|
+
})
|
|
1945
|
+
// NOTE: passthrough, because the session cookie payload carries more than
|
|
1946
|
+
// this (the val.build token, notably). This is the minimum every Val JWT has.
|
|
1947
|
+
.passthrough();
|
|
1948
|
+
/**
|
|
1949
|
+
* `exp` is an absolute Unix timestamp: a token is rejected once that moment has
|
|
1950
|
+
* passed, with this much slack to absorb clock drift between whoever signed the
|
|
1951
|
+
* token and us.
|
|
1952
|
+
*/
|
|
1953
|
+
const CLOCK_SKEW_LEEWAY_SECONDS = 60;
|
|
1954
|
+
function failure(reason, message) {
|
|
1955
|
+
// NOTE: deliberately never logs the token itself - it is a credential, and
|
|
1956
|
+
// anything we print here ends up in the host's request logs.
|
|
1957
|
+
console.debug(`Invalid JWT (${reason}): ${message}`);
|
|
1958
|
+
return {
|
|
1959
|
+
success: false,
|
|
1960
|
+
reason,
|
|
1961
|
+
message
|
|
1962
|
+
};
|
|
1963
|
+
}
|
|
1964
|
+
function splitToken(token) {
|
|
1940
1965
|
const [headerBase64, payloadBase64, signatureBase64, ...rest] = token.split(".");
|
|
1941
1966
|
if (!headerBase64 || !payloadBase64 || !signatureBase64 || rest.length > 0) {
|
|
1942
|
-
console.debug("Invalid JWT: format is not exactly {header}.{payload}.{signature}", token);
|
|
1943
1967
|
return null;
|
|
1944
1968
|
}
|
|
1969
|
+
return {
|
|
1970
|
+
headerBase64,
|
|
1971
|
+
payloadBase64,
|
|
1972
|
+
signatureBase64
|
|
1973
|
+
};
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
/** Returns the failure if the header is not one of ours, or null if it is fine. */
|
|
1977
|
+
function verifyHeader(headerBase64) {
|
|
1978
|
+
let parsedHeader;
|
|
1945
1979
|
try {
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
console.debug("Invalid JWT: invalid header", parsedHeader);
|
|
1950
|
-
return null;
|
|
1951
|
-
}
|
|
1952
|
-
if (headerVerification.data.typ !== jwtHeader.typ) {
|
|
1953
|
-
console.debug("Invalid JWT: invalid header typ", parsedHeader);
|
|
1954
|
-
return null;
|
|
1955
|
-
}
|
|
1956
|
-
if (headerVerification.data.alg !== jwtHeader.alg) {
|
|
1957
|
-
console.debug("Invalid JWT: invalid header alg", parsedHeader);
|
|
1958
|
-
return null;
|
|
1959
|
-
}
|
|
1960
|
-
} catch (err) {
|
|
1961
|
-
console.debug("Invalid JWT: could not parse header", err);
|
|
1962
|
-
return null;
|
|
1980
|
+
parsedHeader = JSON.parse(Buffer.from(headerBase64, "base64").toString("utf8"));
|
|
1981
|
+
} catch {
|
|
1982
|
+
return failure("malformed", "could not parse header");
|
|
1963
1983
|
}
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1984
|
+
const headerVerification = JwtHeaderSchema.safeParse(parsedHeader);
|
|
1985
|
+
if (!headerVerification.success) {
|
|
1986
|
+
// NOTE: JwtHeaderSchema pins alg to HS256, so `alg: "none"` and every other
|
|
1987
|
+
// algorithm-confusion header is rejected here, before we look at the
|
|
1988
|
+
// signature at all.
|
|
1989
|
+
return failure("malformed", "unsupported header");
|
|
1970
1990
|
}
|
|
1991
|
+
return null;
|
|
1992
|
+
}
|
|
1993
|
+
function parsePayload(payloadBase64) {
|
|
1994
|
+
let parsedPayload;
|
|
1971
1995
|
try {
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
console.debug("Invalid JWT: could not parse payload", err);
|
|
1976
|
-
return null;
|
|
1996
|
+
parsedPayload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8"));
|
|
1997
|
+
} catch {
|
|
1998
|
+
return failure("malformed", "could not parse payload");
|
|
1977
1999
|
}
|
|
2000
|
+
const payloadVerification = JwtPayloadSchema.safeParse(parsedPayload);
|
|
2001
|
+
if (!payloadVerification.success) {
|
|
2002
|
+
return failure("malformed", "payload is not a Val JWT payload");
|
|
2003
|
+
}
|
|
2004
|
+
const payload = payloadVerification.data;
|
|
2005
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
2006
|
+
if (payload.exp + CLOCK_SKEW_LEEWAY_SECONDS <= nowSeconds) {
|
|
2007
|
+
return failure("expired", "token expired");
|
|
2008
|
+
}
|
|
2009
|
+
return {
|
|
2010
|
+
success: true,
|
|
2011
|
+
data: payload
|
|
2012
|
+
};
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Verify a JWT that we issued with {@link encodeJwt}, and return its payload.
|
|
2017
|
+
*
|
|
2018
|
+
* Checks, in order: the segment count, the header (`alg` is pinned to HS256, so
|
|
2019
|
+
* algorithm confusion is rejected before the signature is looked at), the HMAC
|
|
2020
|
+
* (compared in constant time), the payload shape, and `exp`.
|
|
2021
|
+
*
|
|
2022
|
+
* There is deliberately no way to make this skip the signature check: the
|
|
2023
|
+
* secret is required, and an empty one is a failure rather than a token that
|
|
2024
|
+
* validates against `HMAC("")`. Use {@link decodeJwtWithoutVerifying} - and read
|
|
2025
|
+
* what it says - when you genuinely do not hold the signing key.
|
|
2026
|
+
*/
|
|
2027
|
+
function verifyJwt(token, secretKey) {
|
|
2028
|
+
if (!secretKey) {
|
|
2029
|
+
return failure("missing-secret", "no secret key was provided");
|
|
2030
|
+
}
|
|
2031
|
+
const segments = splitToken(token);
|
|
2032
|
+
if (!segments) {
|
|
2033
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2034
|
+
}
|
|
2035
|
+
const {
|
|
2036
|
+
headerBase64,
|
|
2037
|
+
payloadBase64,
|
|
2038
|
+
signatureBase64
|
|
2039
|
+
} = segments;
|
|
2040
|
+
const headerFailure = verifyHeader(headerBase64);
|
|
2041
|
+
if (headerFailure) {
|
|
2042
|
+
return headerFailure;
|
|
2043
|
+
}
|
|
2044
|
+
const expectedSignature = crypto__default["default"].createHmac("sha256", secretKey).update(`${headerBase64}.${payloadBase64}`).digest();
|
|
2045
|
+
const actualSignature = Buffer.from(signatureBase64, "base64");
|
|
2046
|
+
// NOTE: the length check is what makes timingSafeEqual usable at all (it
|
|
2047
|
+
// throws on a length mismatch), and the length of an HMAC-SHA256 is public.
|
|
2048
|
+
if (actualSignature.length !== expectedSignature.length || !crypto__default["default"].timingSafeEqual(actualSignature, expectedSignature)) {
|
|
2049
|
+
return failure("invalid-signature", "signature does not match");
|
|
2050
|
+
}
|
|
2051
|
+
return parsePayload(payloadBase64);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Parse a JWT's payload **without checking its signature**.
|
|
2056
|
+
*
|
|
2057
|
+
* The header, the payload shape and `exp` are still validated, but nothing here
|
|
2058
|
+
* establishes that the token was issued by anyone in particular. Only use this
|
|
2059
|
+
* for a token whose authenticity is already established by the channel it
|
|
2060
|
+
* arrived on - the app token we fetch from val.build over an api-key
|
|
2061
|
+
* authenticated HTTPS request is the one such case. Anything that arrives from
|
|
2062
|
+
* a browser (a cookie, a header, a query parameter) must go through
|
|
2063
|
+
* {@link verifyJwt}.
|
|
2064
|
+
*/
|
|
2065
|
+
function decodeJwtWithoutVerifying(token) {
|
|
2066
|
+
const segments = splitToken(token);
|
|
2067
|
+
if (!segments) {
|
|
2068
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2069
|
+
}
|
|
2070
|
+
const headerFailure = verifyHeader(segments.headerBase64);
|
|
2071
|
+
if (headerFailure) {
|
|
2072
|
+
return headerFailure;
|
|
2073
|
+
}
|
|
2074
|
+
return parsePayload(segments.payloadBase64);
|
|
1978
2075
|
}
|
|
1979
2076
|
function getExpire() {
|
|
1980
2077
|
return Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 4; // 4 days
|
|
@@ -7618,7 +7715,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7618
7715
|
}).then(async res => {
|
|
7619
7716
|
if (res.status === 200) {
|
|
7620
7717
|
const token = await res.text();
|
|
7621
|
-
|
|
7718
|
+
// NOTE: this token is signed by val.build with a key we do not
|
|
7719
|
+
// hold, so we cannot verify it. What stands in for a signature check
|
|
7720
|
+
// is where it came from: the POST above, authenticated with the
|
|
7721
|
+
// project's api key, to the configured `valBuildUrl`. It is not user
|
|
7722
|
+
// input - but it is only as trustworthy as that host and that
|
|
7723
|
+
// channel, and `valBuildUrl` is overridable (`opts.valBuildUrl`,
|
|
7724
|
+
// `VAL_BUILD_URL`) and not required to be https. Point it at a plain
|
|
7725
|
+
// http host and this payload - which is re-signed into the session
|
|
7726
|
+
// cookie below - is whatever the network says it is.
|
|
7727
|
+
const decoded = decodeJwtWithoutVerifying(token);
|
|
7728
|
+
if (!decoded.success) {
|
|
7729
|
+
return null;
|
|
7730
|
+
}
|
|
7731
|
+
const verification = ValAppJwtPayload.safeParse(decoded.data);
|
|
7622
7732
|
if (!verification.success) {
|
|
7623
7733
|
return null;
|
|
7624
7734
|
}
|
|
@@ -7650,8 +7760,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7650
7760
|
}
|
|
7651
7761
|
}
|
|
7652
7762
|
if (typeof cookie === "string") {
|
|
7653
|
-
const
|
|
7654
|
-
if (!
|
|
7763
|
+
const verifiedToken = verifyJwt(cookie, options.valSecret);
|
|
7764
|
+
if (!verifiedToken.success) {
|
|
7655
7765
|
if (serverOps instanceof ValOpsFS) {
|
|
7656
7766
|
return {
|
|
7657
7767
|
error: null,
|
|
@@ -7659,10 +7769,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7659
7769
|
};
|
|
7660
7770
|
}
|
|
7661
7771
|
return {
|
|
7662
|
-
error:
|
|
7772
|
+
error: sessionErrorMessage(verifiedToken.reason)
|
|
7663
7773
|
};
|
|
7664
7774
|
}
|
|
7665
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
7775
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
7666
7776
|
if (!verification.success) {
|
|
7667
7777
|
if (serverOps instanceof ValOpsFS) {
|
|
7668
7778
|
return {
|
|
@@ -7671,7 +7781,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7671
7781
|
};
|
|
7672
7782
|
}
|
|
7673
7783
|
return {
|
|
7674
|
-
error: "Session invalid
|
|
7784
|
+
error: "Session invalid. You will need to login again."
|
|
7675
7785
|
};
|
|
7676
7786
|
}
|
|
7677
7787
|
return {
|
|
@@ -10151,25 +10261,41 @@ const IntegratedServerJwtPayload = z.z.object({
|
|
|
10151
10261
|
org: z.z.string(),
|
|
10152
10262
|
project: z.z.string()
|
|
10153
10263
|
});
|
|
10264
|
+
/**
|
|
10265
|
+
* The message we show the user when their session cookie does not verify. The
|
|
10266
|
+
* distinction matters: an expired session is normal and self-explanatory, while
|
|
10267
|
+
* a bad signature means the cookie was tampered with or `VAL_SECRET` changed.
|
|
10268
|
+
*/
|
|
10269
|
+
function sessionErrorMessage(reason) {
|
|
10270
|
+
switch (reason) {
|
|
10271
|
+
case "expired":
|
|
10272
|
+
return "Session expired. You will need to login again.";
|
|
10273
|
+
case "malformed":
|
|
10274
|
+
case "invalid-signature":
|
|
10275
|
+
return "Could not verify session (invalid token). You will need to login again.";
|
|
10276
|
+
case "missing-secret":
|
|
10277
|
+
return "Setup is not correct: secret is missing";
|
|
10278
|
+
}
|
|
10279
|
+
}
|
|
10154
10280
|
async function withAuth(secret, cookies, errorMessageType, handler) {
|
|
10155
10281
|
const cookie = cookies[internal.VAL_SESSION_COOKIE];
|
|
10156
10282
|
if (typeof cookie === "string") {
|
|
10157
|
-
const
|
|
10158
|
-
if (!
|
|
10283
|
+
const verifiedToken = verifyJwt(cookie, secret);
|
|
10284
|
+
if (!verifiedToken.success) {
|
|
10159
10285
|
return {
|
|
10160
10286
|
status: 401,
|
|
10161
10287
|
json: {
|
|
10162
|
-
message:
|
|
10163
|
-
details:
|
|
10288
|
+
message: sessionErrorMessage(verifiedToken.reason),
|
|
10289
|
+
details: verifiedToken.reason
|
|
10164
10290
|
}
|
|
10165
10291
|
};
|
|
10166
10292
|
}
|
|
10167
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
10293
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
10168
10294
|
if (!verification.success) {
|
|
10169
10295
|
return {
|
|
10170
10296
|
status: 401,
|
|
10171
10297
|
json: {
|
|
10172
|
-
message: "Session invalid
|
|
10298
|
+
message: "Session invalid. You will need to login again.",
|
|
10173
10299
|
details: zodValidationError.fromError(verification.error).toString()
|
|
10174
10300
|
}
|
|
10175
10301
|
};
|
|
@@ -12862,7 +12988,7 @@ exports.createValApiRouter = createValApiRouter;
|
|
|
12862
12988
|
exports.createValModuleFileInspector = createValModuleFileInspector;
|
|
12863
12989
|
exports.createValServer = createValServer;
|
|
12864
12990
|
exports.currentFixHandlers = currentFixHandlers;
|
|
12865
|
-
exports.
|
|
12991
|
+
exports.decodeJwtWithoutVerifying = decodeJwtWithoutVerifying;
|
|
12866
12992
|
exports.describePatchStoreProblems = describePatchStoreProblems;
|
|
12867
12993
|
exports.downloadFileFromRemote = downloadFileFromRemote;
|
|
12868
12994
|
exports.encodeJwt = encodeJwt;
|
|
@@ -12904,3 +13030,4 @@ exports.safeReadGit = safeReadGit;
|
|
|
12904
13030
|
exports.startValLogin = startValLogin;
|
|
12905
13031
|
exports.uploadRemoteFile = uploadRemoteFile;
|
|
12906
13032
|
exports.validateMetadata = validateMetadata;
|
|
13033
|
+
exports.verifyJwt = verifyJwt;
|
|
@@ -1902,45 +1902,142 @@ class Service {
|
|
|
1902
1902
|
}
|
|
1903
1903
|
}
|
|
1904
1904
|
|
|
1905
|
-
|
|
1905
|
+
const JwtPayloadSchema = z.object({
|
|
1906
|
+
sub: z.string(),
|
|
1907
|
+
exp: z.number(),
|
|
1908
|
+
org: z.string(),
|
|
1909
|
+
project: z.string()
|
|
1910
|
+
})
|
|
1911
|
+
// NOTE: passthrough, because the session cookie payload carries more than
|
|
1912
|
+
// this (the val.build token, notably). This is the minimum every Val JWT has.
|
|
1913
|
+
.passthrough();
|
|
1914
|
+
/**
|
|
1915
|
+
* `exp` is an absolute Unix timestamp: a token is rejected once that moment has
|
|
1916
|
+
* passed, with this much slack to absorb clock drift between whoever signed the
|
|
1917
|
+
* token and us.
|
|
1918
|
+
*/
|
|
1919
|
+
const CLOCK_SKEW_LEEWAY_SECONDS = 60;
|
|
1920
|
+
function failure(reason, message) {
|
|
1921
|
+
// NOTE: deliberately never logs the token itself - it is a credential, and
|
|
1922
|
+
// anything we print here ends up in the host's request logs.
|
|
1923
|
+
console.debug(`Invalid JWT (${reason}): ${message}`);
|
|
1924
|
+
return {
|
|
1925
|
+
success: false,
|
|
1926
|
+
reason,
|
|
1927
|
+
message
|
|
1928
|
+
};
|
|
1929
|
+
}
|
|
1930
|
+
function splitToken(token) {
|
|
1906
1931
|
const [headerBase64, payloadBase64, signatureBase64, ...rest] = token.split(".");
|
|
1907
1932
|
if (!headerBase64 || !payloadBase64 || !signatureBase64 || rest.length > 0) {
|
|
1908
|
-
console.debug("Invalid JWT: format is not exactly {header}.{payload}.{signature}", token);
|
|
1909
1933
|
return null;
|
|
1910
1934
|
}
|
|
1935
|
+
return {
|
|
1936
|
+
headerBase64,
|
|
1937
|
+
payloadBase64,
|
|
1938
|
+
signatureBase64
|
|
1939
|
+
};
|
|
1940
|
+
}
|
|
1941
|
+
|
|
1942
|
+
/** Returns the failure if the header is not one of ours, or null if it is fine. */
|
|
1943
|
+
function verifyHeader(headerBase64) {
|
|
1944
|
+
let parsedHeader;
|
|
1911
1945
|
try {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
console.debug("Invalid JWT: invalid header", parsedHeader);
|
|
1916
|
-
return null;
|
|
1917
|
-
}
|
|
1918
|
-
if (headerVerification.data.typ !== jwtHeader.typ) {
|
|
1919
|
-
console.debug("Invalid JWT: invalid header typ", parsedHeader);
|
|
1920
|
-
return null;
|
|
1921
|
-
}
|
|
1922
|
-
if (headerVerification.data.alg !== jwtHeader.alg) {
|
|
1923
|
-
console.debug("Invalid JWT: invalid header alg", parsedHeader);
|
|
1924
|
-
return null;
|
|
1925
|
-
}
|
|
1926
|
-
} catch (err) {
|
|
1927
|
-
console.debug("Invalid JWT: could not parse header", err);
|
|
1928
|
-
return null;
|
|
1946
|
+
parsedHeader = JSON.parse(Buffer.from(headerBase64, "base64").toString("utf8"));
|
|
1947
|
+
} catch {
|
|
1948
|
+
return failure("malformed", "could not parse header");
|
|
1929
1949
|
}
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1950
|
+
const headerVerification = JwtHeaderSchema.safeParse(parsedHeader);
|
|
1951
|
+
if (!headerVerification.success) {
|
|
1952
|
+
// NOTE: JwtHeaderSchema pins alg to HS256, so `alg: "none"` and every other
|
|
1953
|
+
// algorithm-confusion header is rejected here, before we look at the
|
|
1954
|
+
// signature at all.
|
|
1955
|
+
return failure("malformed", "unsupported header");
|
|
1936
1956
|
}
|
|
1957
|
+
return null;
|
|
1958
|
+
}
|
|
1959
|
+
function parsePayload(payloadBase64) {
|
|
1960
|
+
let parsedPayload;
|
|
1937
1961
|
try {
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
console.debug("Invalid JWT: could not parse payload", err);
|
|
1942
|
-
return null;
|
|
1962
|
+
parsedPayload = JSON.parse(Buffer.from(payloadBase64, "base64").toString("utf8"));
|
|
1963
|
+
} catch {
|
|
1964
|
+
return failure("malformed", "could not parse payload");
|
|
1943
1965
|
}
|
|
1966
|
+
const payloadVerification = JwtPayloadSchema.safeParse(parsedPayload);
|
|
1967
|
+
if (!payloadVerification.success) {
|
|
1968
|
+
return failure("malformed", "payload is not a Val JWT payload");
|
|
1969
|
+
}
|
|
1970
|
+
const payload = payloadVerification.data;
|
|
1971
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
1972
|
+
if (payload.exp + CLOCK_SKEW_LEEWAY_SECONDS <= nowSeconds) {
|
|
1973
|
+
return failure("expired", "token expired");
|
|
1974
|
+
}
|
|
1975
|
+
return {
|
|
1976
|
+
success: true,
|
|
1977
|
+
data: payload
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
/**
|
|
1982
|
+
* Verify a JWT that we issued with {@link encodeJwt}, and return its payload.
|
|
1983
|
+
*
|
|
1984
|
+
* Checks, in order: the segment count, the header (`alg` is pinned to HS256, so
|
|
1985
|
+
* algorithm confusion is rejected before the signature is looked at), the HMAC
|
|
1986
|
+
* (compared in constant time), the payload shape, and `exp`.
|
|
1987
|
+
*
|
|
1988
|
+
* There is deliberately no way to make this skip the signature check: the
|
|
1989
|
+
* secret is required, and an empty one is a failure rather than a token that
|
|
1990
|
+
* validates against `HMAC("")`. Use {@link decodeJwtWithoutVerifying} - and read
|
|
1991
|
+
* what it says - when you genuinely do not hold the signing key.
|
|
1992
|
+
*/
|
|
1993
|
+
function verifyJwt(token, secretKey) {
|
|
1994
|
+
if (!secretKey) {
|
|
1995
|
+
return failure("missing-secret", "no secret key was provided");
|
|
1996
|
+
}
|
|
1997
|
+
const segments = splitToken(token);
|
|
1998
|
+
if (!segments) {
|
|
1999
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2000
|
+
}
|
|
2001
|
+
const {
|
|
2002
|
+
headerBase64,
|
|
2003
|
+
payloadBase64,
|
|
2004
|
+
signatureBase64
|
|
2005
|
+
} = segments;
|
|
2006
|
+
const headerFailure = verifyHeader(headerBase64);
|
|
2007
|
+
if (headerFailure) {
|
|
2008
|
+
return headerFailure;
|
|
2009
|
+
}
|
|
2010
|
+
const expectedSignature = crypto$1.createHmac("sha256", secretKey).update(`${headerBase64}.${payloadBase64}`).digest();
|
|
2011
|
+
const actualSignature = Buffer.from(signatureBase64, "base64");
|
|
2012
|
+
// NOTE: the length check is what makes timingSafeEqual usable at all (it
|
|
2013
|
+
// throws on a length mismatch), and the length of an HMAC-SHA256 is public.
|
|
2014
|
+
if (actualSignature.length !== expectedSignature.length || !crypto$1.timingSafeEqual(actualSignature, expectedSignature)) {
|
|
2015
|
+
return failure("invalid-signature", "signature does not match");
|
|
2016
|
+
}
|
|
2017
|
+
return parsePayload(payloadBase64);
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
/**
|
|
2021
|
+
* Parse a JWT's payload **without checking its signature**.
|
|
2022
|
+
*
|
|
2023
|
+
* The header, the payload shape and `exp` are still validated, but nothing here
|
|
2024
|
+
* establishes that the token was issued by anyone in particular. Only use this
|
|
2025
|
+
* for a token whose authenticity is already established by the channel it
|
|
2026
|
+
* arrived on - the app token we fetch from val.build over an api-key
|
|
2027
|
+
* authenticated HTTPS request is the one such case. Anything that arrives from
|
|
2028
|
+
* a browser (a cookie, a header, a query parameter) must go through
|
|
2029
|
+
* {@link verifyJwt}.
|
|
2030
|
+
*/
|
|
2031
|
+
function decodeJwtWithoutVerifying(token) {
|
|
2032
|
+
const segments = splitToken(token);
|
|
2033
|
+
if (!segments) {
|
|
2034
|
+
return failure("malformed", "format is not exactly {header}.{payload}.{signature}");
|
|
2035
|
+
}
|
|
2036
|
+
const headerFailure = verifyHeader(segments.headerBase64);
|
|
2037
|
+
if (headerFailure) {
|
|
2038
|
+
return headerFailure;
|
|
2039
|
+
}
|
|
2040
|
+
return parsePayload(segments.payloadBase64);
|
|
1944
2041
|
}
|
|
1945
2042
|
function getExpire() {
|
|
1946
2043
|
return Math.floor(Date.now() / 1000) + 60 * 60 * 24 * 4; // 4 days
|
|
@@ -7584,7 +7681,20 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7584
7681
|
}).then(async res => {
|
|
7585
7682
|
if (res.status === 200) {
|
|
7586
7683
|
const token = await res.text();
|
|
7587
|
-
|
|
7684
|
+
// NOTE: this token is signed by val.build with a key we do not
|
|
7685
|
+
// hold, so we cannot verify it. What stands in for a signature check
|
|
7686
|
+
// is where it came from: the POST above, authenticated with the
|
|
7687
|
+
// project's api key, to the configured `valBuildUrl`. It is not user
|
|
7688
|
+
// input - but it is only as trustworthy as that host and that
|
|
7689
|
+
// channel, and `valBuildUrl` is overridable (`opts.valBuildUrl`,
|
|
7690
|
+
// `VAL_BUILD_URL`) and not required to be https. Point it at a plain
|
|
7691
|
+
// http host and this payload - which is re-signed into the session
|
|
7692
|
+
// cookie below - is whatever the network says it is.
|
|
7693
|
+
const decoded = decodeJwtWithoutVerifying(token);
|
|
7694
|
+
if (!decoded.success) {
|
|
7695
|
+
return null;
|
|
7696
|
+
}
|
|
7697
|
+
const verification = ValAppJwtPayload.safeParse(decoded.data);
|
|
7588
7698
|
if (!verification.success) {
|
|
7589
7699
|
return null;
|
|
7590
7700
|
}
|
|
@@ -7616,8 +7726,8 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7616
7726
|
}
|
|
7617
7727
|
}
|
|
7618
7728
|
if (typeof cookie === "string") {
|
|
7619
|
-
const
|
|
7620
|
-
if (!
|
|
7729
|
+
const verifiedToken = verifyJwt(cookie, options.valSecret);
|
|
7730
|
+
if (!verifiedToken.success) {
|
|
7621
7731
|
if (serverOps instanceof ValOpsFS) {
|
|
7622
7732
|
return {
|
|
7623
7733
|
error: null,
|
|
@@ -7625,10 +7735,10 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7625
7735
|
};
|
|
7626
7736
|
}
|
|
7627
7737
|
return {
|
|
7628
|
-
error:
|
|
7738
|
+
error: sessionErrorMessage(verifiedToken.reason)
|
|
7629
7739
|
};
|
|
7630
7740
|
}
|
|
7631
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
7741
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
7632
7742
|
if (!verification.success) {
|
|
7633
7743
|
if (serverOps instanceof ValOpsFS) {
|
|
7634
7744
|
return {
|
|
@@ -7637,7 +7747,7 @@ const ValServer = (valModules, options, callbacks) => {
|
|
|
7637
7747
|
};
|
|
7638
7748
|
}
|
|
7639
7749
|
return {
|
|
7640
|
-
error: "Session invalid
|
|
7750
|
+
error: "Session invalid. You will need to login again."
|
|
7641
7751
|
};
|
|
7642
7752
|
}
|
|
7643
7753
|
return {
|
|
@@ -10117,25 +10227,41 @@ const IntegratedServerJwtPayload = z.object({
|
|
|
10117
10227
|
org: z.string(),
|
|
10118
10228
|
project: z.string()
|
|
10119
10229
|
});
|
|
10230
|
+
/**
|
|
10231
|
+
* The message we show the user when their session cookie does not verify. The
|
|
10232
|
+
* distinction matters: an expired session is normal and self-explanatory, while
|
|
10233
|
+
* a bad signature means the cookie was tampered with or `VAL_SECRET` changed.
|
|
10234
|
+
*/
|
|
10235
|
+
function sessionErrorMessage(reason) {
|
|
10236
|
+
switch (reason) {
|
|
10237
|
+
case "expired":
|
|
10238
|
+
return "Session expired. You will need to login again.";
|
|
10239
|
+
case "malformed":
|
|
10240
|
+
case "invalid-signature":
|
|
10241
|
+
return "Could not verify session (invalid token). You will need to login again.";
|
|
10242
|
+
case "missing-secret":
|
|
10243
|
+
return "Setup is not correct: secret is missing";
|
|
10244
|
+
}
|
|
10245
|
+
}
|
|
10120
10246
|
async function withAuth(secret, cookies, errorMessageType, handler) {
|
|
10121
10247
|
const cookie = cookies[VAL_SESSION_COOKIE];
|
|
10122
10248
|
if (typeof cookie === "string") {
|
|
10123
|
-
const
|
|
10124
|
-
if (!
|
|
10249
|
+
const verifiedToken = verifyJwt(cookie, secret);
|
|
10250
|
+
if (!verifiedToken.success) {
|
|
10125
10251
|
return {
|
|
10126
10252
|
status: 401,
|
|
10127
10253
|
json: {
|
|
10128
|
-
message:
|
|
10129
|
-
details:
|
|
10254
|
+
message: sessionErrorMessage(verifiedToken.reason),
|
|
10255
|
+
details: verifiedToken.reason
|
|
10130
10256
|
}
|
|
10131
10257
|
};
|
|
10132
10258
|
}
|
|
10133
|
-
const verification = IntegratedServerJwtPayload.safeParse(
|
|
10259
|
+
const verification = IntegratedServerJwtPayload.safeParse(verifiedToken.data);
|
|
10134
10260
|
if (!verification.success) {
|
|
10135
10261
|
return {
|
|
10136
10262
|
status: 401,
|
|
10137
10263
|
json: {
|
|
10138
|
-
message: "Session invalid
|
|
10264
|
+
message: "Session invalid. You will need to login again.",
|
|
10139
10265
|
details: fromError(verification.error).toString()
|
|
10140
10266
|
}
|
|
10141
10267
|
};
|
|
@@ -12800,4 +12926,4 @@ function readCapturedReport(snapshotDir) {
|
|
|
12800
12926
|
return JSON.parse(fs.readFileSync(reportPath, "utf-8"));
|
|
12801
12927
|
}
|
|
12802
12928
|
|
|
12803
|
-
export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValServer, currentFixHandlers,
|
|
12929
|
+
export { DEFAULT_LOGIN_HOST, DEFAULT_LOGIN_MAX_DURATION, DEFAULT_LOGIN_POLL_INTERVAL, Service, ValFSHost, ValLoginError, ValModuleLoader, ValOpsFS, ValOpsHttp, ValSourceFileHandler, analyzeValModule, awaitValLoginConfirmation, checkRemoteRef, classifyJsonValuesOp, compareWithCapturedReport, createDefaultValFSHost, createFixPatch, createJsonEntryPathMap, createModulePathMap, createService, createValApiRouter, createValModuleFileInspector, createValServer, currentFixHandlers, decodeJwtWithoutVerifying, describePatchStoreProblems, downloadFileFromRemote, encodeJwt, evalValConfigFile, extractFileMetadata, extractImageMetadata, extractJsonValuesEntry, findAndEvalValConfigFile, findJsonEntryFilePath, fixHandlers, formatPatchSourceError, formatSyntaxErrorTree, getCachedRemoteFileDir, getCachedRemoteFilePath, getCompilerOptions, getExpire, getFileExt, getModulePathRange, getPersonalAccessTokenPath, getSettings, getValidationErrorFileRef, handleCheckAllFiles, handleFileMetadata, handleJsonValuesExtractEntry, handleRemoteFileCheck, handleRemoteFileDownload, handleRemoteFileUpload, handleRemoteGalleryFileUpload, handleUniqueFolderCheck, loadValModules, parsePersonalAccessTokenFile, patchSourceFile, persistPersonalAccessToken, readCapturedReport, readPatchStore, rebaseContentOp, replaySnapshot, safeReadGit, startValLogin, uploadRemoteFile, validateMetadata, verifyJwt };
|
package/package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"./package.json": "./package.json"
|
|
17
17
|
},
|
|
18
18
|
"types": "dist/valbuild-server.cjs.d.ts",
|
|
19
|
-
"version": "0.
|
|
19
|
+
"version": "0.113.0",
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@prettier/sync": "^0.6.1",
|
|
22
22
|
"@types/jest": "^30.0.0"
|
|
@@ -29,9 +29,9 @@
|
|
|
29
29
|
"typescript": "^6.0.3",
|
|
30
30
|
"zod": "^4.4.3",
|
|
31
31
|
"zod-validation-error": "^5.0.0",
|
|
32
|
-
"@valbuild/
|
|
33
|
-
"@valbuild/
|
|
34
|
-
"@valbuild/
|
|
32
|
+
"@valbuild/shared": "0.113.0",
|
|
33
|
+
"@valbuild/ui": "0.113.0",
|
|
34
|
+
"@valbuild/core": "0.111.0"
|
|
35
35
|
},
|
|
36
36
|
"engines": {
|
|
37
37
|
"node": "^20.19.0 || >=22"
|