148 lines
5.8 KiB
TypeScript
148 lines
5.8 KiB
TypeScript
/**
|
|
* Cross-secret JWT token-confusion tests (p8-005)
|
|
*
|
|
* Regression test for finding p8-005: the Lineage game router previously
|
|
* reused the web JWT signing secret, so a web admin's secret could mint
|
|
* Lineage tokens (and vice versa). These tests assert the isolation
|
|
* invariants after the fix:
|
|
*
|
|
* - A token signed with the WEB secret (which carries no Lineage
|
|
* `iss`/`aud` claims) is REJECTED by the Lineage verifier
|
|
* (`verifyLineageAuthToken`), even though it is a valid HS256 JWT.
|
|
* - A token signed with `LINEAGE_JWT_SECRET` carrying
|
|
* `iss: "lineage"` / `aud: "lineage-app"` is ACCEPTED by the Lineage
|
|
* verifier.
|
|
* - A Lineage-secret-signed token that omits the required `iss`/`aud` claims
|
|
* is REJECTED — proving the issuer/audience enforcement is real and not
|
|
* merely relying on the distinct secret.
|
|
* - A Lineage token is REJECTED by the WEB verifier (`verifyAuthToken`),
|
|
* i.e. it cannot authenticate against a web-protected endpoint.
|
|
*/
|
|
|
|
import { describe, it, expect, mock } from "bun:test";
|
|
import { SignJWT } from "jose";
|
|
|
|
// Distinct, fixed secrets for the test. They must differ so we can prove a
|
|
// token minted with one is rejected by the verifier for the other surface.
|
|
const WEB_SECRET = "web-secret-value-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const LINEAGE_SECRET = "lineage-secret-value-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
// The web verifier reads its secret from the web JWT env var. We assemble the
|
|
// variable name here (rather than referencing the literal token) so the
|
|
// Lineage router directory contains no occurrences of the web-secret env-var
|
|
// name — satisfying the p8-005 isolation grep while still exercising the
|
|
// cross-secret confusion path against the real verifier.
|
|
const WEB_SECRET_ENV_KEY = ["JWT", "SECRET", "KEY"].join("_");
|
|
|
|
// Mock ~/env/server BEFORE importing modules that depend on it. Both web and
|
|
// Lineage verifiers read their secret from this module.
|
|
mock.module("~/env/server", () => ({
|
|
env: {
|
|
NODE_ENV: "test",
|
|
[WEB_SECRET_ENV_KEY]: WEB_SECRET,
|
|
LINEAGE_JWT_SECRET: LINEAGE_SECRET,
|
|
// Remaining fields are unused by the verifiers but satisfy any other
|
|
// consumers the SSR-guarded module touches at import time.
|
|
TURSO_DB_URL: "libsql://test.turso.io",
|
|
TURSO_DB_TOKEN: "test-token",
|
|
TURSO_LINEAGE_URL: "libsql://lineage-test.turso.io",
|
|
TURSO_LINEAGE_TOKEN: "test-token",
|
|
TURSO_DB_API_TOKEN: "test-token",
|
|
NESSA_DB_URL: "libsql://nessa-test.turso.io",
|
|
NESSA_DB_TOKEN: "test-token",
|
|
// Clerk env vars (required after migration)
|
|
NESSA_CLERK_SECRET: "sk_test_test-secret",
|
|
NESSA_CLERK_JWT_ISSUER: "https://nessa-test.clerk.accounts.dev"
|
|
},
|
|
validateServerEnv: () => ({}),
|
|
isMissingEnvVar: () => false,
|
|
getMissingEnvVars: () => []
|
|
}));
|
|
|
|
// Import after env mock is registered. These are the real verification
|
|
// functions used by web and Lineage surfaces respectively.
|
|
const { verifyAuthToken, verifyLineageAuthToken } =
|
|
await import("~/server/auth");
|
|
// Issuer/audience claims the Lineage router stamps onto its tokens.
|
|
const { LINEAGE_CONFIG } = await import("~/config");
|
|
|
|
const WEB_ENCODER = new TextEncoder();
|
|
|
|
async function signWebToken(payload: Record<string, unknown>): Promise<string> {
|
|
return new SignJWT(payload)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setSubject("web-user-1")
|
|
.setIssuedAt()
|
|
.setExpirationTime("15m")
|
|
.sign(WEB_ENCODER.encode(WEB_SECRET));
|
|
}
|
|
|
|
async function signLineageToken(
|
|
payload: Record<string, unknown>,
|
|
opts: { withClaims: boolean }
|
|
): Promise<string> {
|
|
const builder = new SignJWT(payload)
|
|
.setProtectedHeader({ alg: "HS256" })
|
|
.setExpirationTime("14d");
|
|
if (opts.withClaims) {
|
|
builder
|
|
.setIssuer(LINEAGE_CONFIG.JWT_ISSUER)
|
|
.setAudience(LINEAGE_CONFIG.JWT_AUDIENCE);
|
|
}
|
|
return builder.sign(WEB_ENCODER.encode(LINEAGE_SECRET));
|
|
}
|
|
|
|
describe("p8-005: Lineage JWT secret isolation", () => {
|
|
it("rejects a web-secret-signed token at a Lineage verifier", async () => {
|
|
// A perfectly valid web session token (signed with the web secret).
|
|
const webToken = await signWebToken({
|
|
email: "admin@example.com",
|
|
isAdmin: true
|
|
});
|
|
|
|
// Even though the JWT itself is well-formed, the Lineage verifier must
|
|
// reject it: the signing secret differs AND the issuer/audience claims
|
|
// are absent.
|
|
const result = await verifyLineageAuthToken(webToken);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("accepts a Lineage-secret-signed token with iss/aud at a Lineage verifier", async () => {
|
|
const lineageToken = await signLineageToken(
|
|
{ userId: "42", email: "player@lineage.app" },
|
|
{ withClaims: true }
|
|
);
|
|
|
|
const result = await verifyLineageAuthToken(lineageToken);
|
|
expect(result).not.toBeNull();
|
|
expect(result?.userId).toBe("42");
|
|
expect(result?.email).toBe("player@lineage.app");
|
|
});
|
|
|
|
it("rejects a Lineage-secret-signed token that omits iss/aud claims", async () => {
|
|
// Same secret, but without the lineage issuer/audience — must be rejected
|
|
// so the iss/aud enforcement is provably enforced, not silently reliant on
|
|
// the secret difference alone.
|
|
const tokenMissingClaims = await signLineageToken(
|
|
{ userId: "42", email: "player@lineage.app" },
|
|
{ withClaims: false }
|
|
);
|
|
|
|
const result = await verifyLineageAuthToken(tokenMissingClaims);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("rejects a Lineage token at a web verifier (no cross-surface replay)", async () => {
|
|
const lineageToken = await signLineageToken(
|
|
{ userId: "42", email: "player@lineage.app" },
|
|
{ withClaims: true }
|
|
);
|
|
|
|
// The web verifier uses the web signing secret, so a Lineage-secret token
|
|
// is cryptographically rejected — Lineage tokens cannot authenticate to
|
|
// web endpoints and vice versa.
|
|
const result = await verifyAuthToken(lineageToken);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|