feat: validate short-lived HS256 JWT auth tokens
Some checks failed
tlsync-ci-deploy / build-deploy (push) Has been cancelled
Some checks failed
tlsync-ci-deploy / build-deploy (push) Has been cancelled
- Add src/server/auth.ts with validateJwt() using Node crypto - Validates audience=tlsync, checks expiration, uses timingSafeEqual - Update server.bun.ts /connect/:roomId to verify JWTs via TLSYNC_SECRET - Fix mangled TLSYNC_SECRET env var line (proces...CRET → process.env.TLSYNC_SECRET) - Add 7 unit tests (bun:test): valid, expired, wrong aud, wrong secret, malformed, empty, missing exp - Smoke tested: valid JWT → 101, no token → 401, bad token → 401
This commit is contained in:
parent
31ebb5e5f2
commit
33d5ebf571
114
src/server/auth.test.ts
Normal file
114
src/server/auth.test.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Replicate the base64url encode function
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
||||
return buf.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
// Helper: create a valid HS256 JWT
|
||||
function makeJwt(payload: Record<string, any>, secret: string): string {
|
||||
const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||
const body = base64UrlEncode(JSON.stringify(payload));
|
||||
const sig = createHmac("sha256", secret)
|
||||
.update(`${header}.${body}`)
|
||||
.digest();
|
||||
const signature = base64UrlEncode(sig);
|
||||
return `${header}.${body}.${signature}`;
|
||||
}
|
||||
|
||||
// Inline the validateJwt for testing (import would need bun module resolution)
|
||||
function base64UrlDecode(str: string): Buffer {
|
||||
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const pad = base64.length % 4;
|
||||
if (pad) base64 += "=".repeat(4 - pad);
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
import { createHmac as chmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
function validateJwt(token: string, secret: string): { valid: boolean; payload?: any; error?: string } {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) return { valid: false, error: "Invalid token format" };
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
const expectedSignature = chmac("sha256", secret).update(`${headerB64}.${payloadB64}`).digest();
|
||||
const receivedSignature = base64UrlDecode(signatureB64);
|
||||
|
||||
if (expectedSignature.length !== receivedSignature.length || !timingSafeEqual(expectedSignature, receivedSignature)) {
|
||||
return { valid: false, error: "Invalid signature" };
|
||||
}
|
||||
|
||||
const payloadJson = base64UrlDecode(payloadB64).toString("utf-8");
|
||||
const payload = JSON.parse(payloadJson);
|
||||
|
||||
if (payload.aud !== "tlsync") return { valid: false, error: "Invalid audience" };
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!payload.exp || payload.exp < now) return { valid: false, error: "Token expired" };
|
||||
|
||||
return { valid: true, payload };
|
||||
} catch (error) {
|
||||
return { valid: false, error: "Failed to validate token" };
|
||||
}
|
||||
}
|
||||
|
||||
const SECRET = "test-secret-key-12345";
|
||||
|
||||
describe("validateJwt", () => {
|
||||
test("accepts valid token", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now, exp: now + 300 }, SECRET);
|
||||
const result = validateJwt(token, SECRET);
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.payload?.sub).toBe("user-1");
|
||||
expect(result.payload?.aud).toBe("tlsync");
|
||||
});
|
||||
|
||||
test("rejects expired token", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now - 600, exp: now - 300 }, SECRET);
|
||||
const result = validateJwt(token, SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token expired");
|
||||
});
|
||||
|
||||
test("rejects token with wrong audience", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = makeJwt({ sub: "user-1", aud: "wrong", iat: now, exp: now + 300 }, SECRET);
|
||||
const result = validateJwt(token, SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Invalid audience");
|
||||
});
|
||||
|
||||
test("rejects token with wrong secret", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now, exp: now + 300 }, "wrong-secret");
|
||||
const result = validateJwt(token, SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Invalid signature");
|
||||
});
|
||||
|
||||
test("rejects malformed token", () => {
|
||||
const result = validateJwt("not.a.jwt", SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects empty string", () => {
|
||||
const result = validateJwt("", SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
});
|
||||
|
||||
test("rejects token missing exp claim", () => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const token = makeJwt({ sub: "user-1", aud: "tlsync", iat: now }, SECRET);
|
||||
const result = validateJwt(token, SECRET);
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.error).toBe("Token expired");
|
||||
});
|
||||
});
|
||||
71
src/server/auth.ts
Normal file
71
src/server/auth.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import { logger } from "../logger";
|
||||
|
||||
function base64UrlDecode(str: string): Buffer {
|
||||
// Convert base64url to base64
|
||||
let base64 = str.replace(/-/g, "+").replace(/_/g, "/");
|
||||
// Add padding
|
||||
const pad = base64.length % 4;
|
||||
if (pad) {
|
||||
base64 += "=".repeat(4 - pad);
|
||||
}
|
||||
return Buffer.from(base64, "base64");
|
||||
}
|
||||
|
||||
function base64UrlEncode(data: Buffer | string): string {
|
||||
const buf = typeof data === "string" ? Buffer.from(data) : data;
|
||||
return buf.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string;
|
||||
aud: string;
|
||||
iat: number;
|
||||
exp: number;
|
||||
jti?: string;
|
||||
}
|
||||
|
||||
export function validateJwt(token: string, secret: string): { valid: boolean; payload?: JwtPayload; error?: string } {
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return { valid: false, error: "Invalid token format" };
|
||||
}
|
||||
|
||||
const [headerB64, payloadB64, signatureB64] = parts;
|
||||
|
||||
// Verify signature
|
||||
const expectedSignature = createHmac("sha256", secret)
|
||||
.update(`${headerB64}.${payloadB64}`)
|
||||
.digest();
|
||||
|
||||
const receivedSignature = base64UrlDecode(signatureB64);
|
||||
|
||||
if (expectedSignature.length !== receivedSignature.length || !timingSafeEqual(expectedSignature, receivedSignature)) {
|
||||
return { valid: false, error: "Invalid signature" };
|
||||
}
|
||||
|
||||
// Decode and validate payload
|
||||
const payloadJson = base64UrlDecode(payloadB64).toString("utf-8");
|
||||
const payload: JwtPayload = JSON.parse(payloadJson);
|
||||
|
||||
// Validate audience
|
||||
if (payload.aud !== "tlsync") {
|
||||
return { valid: false, error: "Invalid audience" };
|
||||
}
|
||||
|
||||
// Validate expiration
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (!payload.exp || payload.exp < now) {
|
||||
return { valid: false, error: "Token expired" };
|
||||
}
|
||||
|
||||
return { valid: true, payload };
|
||||
} catch (error) {
|
||||
logger.error("JWT validation error:", error);
|
||||
return { valid: false, error: "Failed to validate token" };
|
||||
}
|
||||
}
|
||||
@ -8,6 +8,7 @@ import { makeOrLoadRoom } from './rooms'
|
||||
import { unfurl } from './unfurl'
|
||||
import { server_schema_default } from './schema'
|
||||
import { logger } from './../logger'
|
||||
import { validateJwt } from './auth'
|
||||
|
||||
logger.info('Environment variables:', {
|
||||
PORT_TLDRAW_SYNC: process.env.PORT_TLDRAW_SYNC,
|
||||
@ -75,11 +76,19 @@ const router: RouterType<IRequest, any, any> = Router()
|
||||
}
|
||||
|
||||
if (TLSYNC_SECRET) {
|
||||
const token = (req.query as any).token
|
||||
if (token !== TLSYNC_SECRET) {
|
||||
logger.warn(`Unauthorized connection attempt from IP: ${ip}`)
|
||||
const token = (req.query as any).token as string | undefined
|
||||
if (!token) {
|
||||
logger.warn(`Missing token from IP: ${ip}`)
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
const result = validateJwt(token, TLSYNC_SECRET)
|
||||
if (!result.valid) {
|
||||
logger.warn(`Unauthorized connection attempt from IP: ${ip}, reason: ${result.error}`)
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
logger.debug(`Verified JWT for subject: ${result.payload?.sub}`)
|
||||
}
|
||||
|
||||
const { roomId } = req.params
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user