Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 60 additions & 26 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import * as jose from "jose";
import { UnauthorizedError } from "./errors";

const ALLOWED_IDENTITIES = process.env.ALLOWED_IDENTITIES?.split(",")
.map((identity) => identity.trim().toLowerCase())
.filter(Boolean);
.map(identity => identity.trim().toLowerCase())
.filter(Boolean);

const getAllowedIdentities = () => {
if (!ALLOWED_IDENTITIES) return null;
Expand All @@ -19,40 +19,74 @@ export const isIdentityAllowed = (identity?: string | null) => {
return allowedIdentities.has(identityNormalized);
};

export const verifyToken = async (idToken: string) => {
const JWKS = jose.createRemoteJWKSet(
new URL("https://www.googleapis.com/oauth2/v3/certs"),
);
/**
* Hard cap on how long a session may live after sign-in, regardless of
* activity. The idle timeout is the cookie's `maxAge` (see index.ts), which
* rolls while the session is in use.
*/
export const SESSION_ABSOLUTE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;

/**
* cookie-session only re-sends the cookie when the session object changes, so
* to roll the idle timeout we touch `lastActiveAt` — but at most this often, to
* avoid a Set-Cookie on every request.
*/
export const SESSION_TOUCH_INTERVAL_MS = 5 * 60 * 1000;

export interface ActiveSession {
/** Claims of the ID token verified by openid-client at sign-in. */
claims: jose.JWTPayload & { email?: string };
}

/**
* Returns the signed-in identity for a session, or null when the session is
* missing, malformed, or past its absolute lifetime.
*
* The ID token was verified (signature, issuer, audience) by openid-client in
* the OIDC callback, and the session cookie itself is signed with
* COOKIE_SECRET, so the token is not re-verified here. In particular its `exp`
* is NOT treated as the session expiry: Google issues ID tokens for about an
* hour, and using that as the session lifetime signed everyone out hourly.
*/
export function getActiveSession(
session: CookieSessionInterfaces.CookieSessionObject | null | undefined,
): ActiveSession | null {
const idToken = session?.id_token;
if (typeof idToken !== "string" || !idToken) return null;

let claims: jose.JWTPayload;
try {
const { payload } = await jose.jwtVerify(idToken, JWKS, {
issuer: "https://accounts.google.com",
audience: process.env.GOOGLE_CLIENT_ID,
});

return payload;
} catch (e) {
console.error(e);
claims = jose.decodeJwt(idToken);
} catch {
return null;
}
};

export const authenticated = async (req: Request, res: Response, next: NextFunction) => {
const idToken = req.session?.id_token;
if (!idToken) throw new UnauthorizedError();
// Sessions created before `authenticatedAt` existed fall back to the
// token's issue time, which is when the user actually signed in.
const authenticatedAt: unknown =
session?.authenticatedAt ?? (claims.iat && claims.iat * 1000);
if (typeof authenticatedAt !== "number") return null;
if (Date.now() - authenticatedAt > SESSION_ABSOLUTE_MAX_AGE_MS) return null;

const payload = await verifyToken(idToken);
if (!payload) throw new UnauthorizedError();
if (!payload.exp) throw new UnauthorizedError();
return { claims };
}

if (new Date(payload.exp * 1000) < new Date()) {
throw new UnauthorizedError();
}
export const authenticated = async (req: Request, res: Response, next: NextFunction) => {
const active = getActiveSession(req.session);
if (!active) throw new UnauthorizedError();

const email = (payload as { email?: string }).email;
if (!isIdentityAllowed(email)) {
if (!isIdentityAllowed(active.claims.email)) {
throw new UnauthorizedError("Account is not in the allowlist", "account_not_allowed");
}

// Roll the idle timeout while the session is in use.
const lastActiveAt = req.session!.lastActiveAt;
if (
typeof lastActiveAt !== "number" ||
Date.now() - lastActiveAt > SESSION_TOUCH_INTERVAL_MS
) {
req.session!.lastActiveAt = Date.now();
}

next();
};
2 changes: 2 additions & 0 deletions src/oidc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export const Callback = async (req: express.Request, res: express.Response) => {
}

req.session!.id_token = tokenSet.id_token;
req.session!.authenticatedAt = Date.now();
req.session!.lastActiveAt = Date.now();

await prisma.user.upsert({
where: { googleId: tokenClaims.sub },
Expand Down
10 changes: 6 additions & 4 deletions src/webrtc-signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { Socket } from "node:net";
import { Device } from "@prisma/client";
import { Server, ServerResponse } from "node:http";
import { cookieSessionMiddleware } from ".";
import { getActiveSession } from "./auth";

// Maintain the shared state
export const activeConnections: Map<string, [WebSocket, string, string | null]> =
Expand Down Expand Up @@ -243,15 +244,16 @@ async function handleClientSocketRequest(
// Authenticate the client connection
async function authenticateClientRequest(req: Request & { session: any }) {
const session = req.session;
const token = session?.id_token;
const active = getActiveSession(session);

if (!token) {
console.log("[Client] No authentication token.");
if (!active) {
console.log("[Client] No active session.");
return { deviceId: null };
}
const token: string = session.id_token;

try {
const { sub } = jose.decodeJwt(token);
const { sub } = active.claims;
const url = new URL(req.url || "", "http://localhost");
const deviceId = url.searchParams.get("id");

Expand Down
123 changes: 123 additions & 0 deletions test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from "vitest";
import { Request, Response } from "express";
import * as jose from "jose";
import {
authenticated,
getActiveSession,
SESSION_ABSOLUTE_MAX_AGE_MS,
SESSION_TOUCH_INTERVAL_MS,
} from "../src/auth";
import { UnauthorizedError } from "../src/errors";

const HOUR = 60 * 60 * 1000;
const DAY = 24 * HOUR;

// Builds an ID token like the one openid-client stores in the session after
// sign-in. The signature is irrelevant here: sessions are trusted because the
// cookie is signed with COOKIE_SECRET, not because the token is re-verified.
async function idToken(opts: { issuedAgoMs: number; ttlMs?: number; email?: string }) {
const now = Date.now();
const iat = Math.floor((now - opts.issuedAgoMs) / 1000);
const exp = Math.floor((now - opts.issuedAgoMs + (opts.ttlMs ?? HOUR)) / 1000);
return new jose.SignJWT({ email: opts.email ?? "user@example.com", sub: "123" })
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://accounts.google.com")
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(new TextEncoder().encode("test-key"));
}

function runMiddleware(session: Record<string, unknown> | null) {
const req = { session } as unknown as Request;
const res = {} as Response;
const next = vi.fn();
return { req, run: () => authenticated(req, res, next), next };
}

describe("getActiveSession", () => {
it("returns null without a session or token", () => {
expect(getActiveSession(null)).toBeNull();
expect(getActiveSession(undefined)).toBeNull();
expect(getActiveSession({} as any)).toBeNull();
expect(getActiveSession({ id_token: "not-a-jwt" } as any)).toBeNull();
});

it("stays active after the ID token's own expiry has passed", async () => {
// Google ID tokens live ~1h; that is not the session lifetime.
const token = await idToken({ issuedAgoMs: 5 * HOUR });
const active = getActiveSession({
id_token: token,
authenticatedAt: Date.now() - 5 * HOUR,
} as any);
expect(active?.claims.email).toBe("user@example.com");
});

it("expires once the absolute lifetime is exceeded", async () => {
const token = await idToken({ issuedAgoMs: 1 * HOUR });
const authenticatedAt = Date.now() - SESSION_ABSOLUTE_MAX_AGE_MS - DAY;
expect(getActiveSession({ id_token: token, authenticatedAt } as any)).toBeNull();
});

it("falls back to the token's iat for sessions created before authenticatedAt", async () => {
const recent = await idToken({ issuedAgoMs: 2 * DAY });
expect(getActiveSession({ id_token: recent } as any)).not.toBeNull();

const ancient = await idToken({ issuedAgoMs: 40 * DAY });
expect(getActiveSession({ id_token: ancient } as any)).toBeNull();
});
});

describe("authenticated middleware", () => {
it("rejects requests without a session", async () => {
const { run, next } = runMiddleware(null);
await expect(run()).rejects.toBeInstanceOf(UnauthorizedError);
expect(next).not.toHaveBeenCalled();
});

it("accepts a session whose ID token expired hours ago", async () => {
const token = await idToken({ issuedAgoMs: 6 * HOUR });
const { run, next } = runMiddleware({
id_token: token,
authenticatedAt: Date.now() - 6 * HOUR,
});
await run();
expect(next).toHaveBeenCalledOnce();
});

it("rejects a session past the absolute lifetime", async () => {
const token = await idToken({ issuedAgoMs: 0 });
const { run, next } = runMiddleware({
id_token: token,
authenticatedAt: Date.now() - SESSION_ABSOLUTE_MAX_AGE_MS - 1,
});
await expect(run()).rejects.toBeInstanceOf(UnauthorizedError);
expect(next).not.toHaveBeenCalled();
});

it("touches lastActiveAt so the cookie's idle timeout rolls, but not on every request", async () => {
const token = await idToken({ issuedAgoMs: 0 });
const session: Record<string, unknown> = {
id_token: token,
authenticatedAt: Date.now(),
};

const first = runMiddleware(session);
await first.run();
const touched = session.lastActiveAt as number;
expect(typeof touched).toBe("number");

// Within the touch interval the session is left untouched, so
// cookie-session doesn't re-send the cookie on every request.
const second = runMiddleware(session);
await second.run();
expect(session.lastActiveAt).toBe(touched);

// Once the interval has elapsed, the next request touches it again.
const stale = Date.now() - SESSION_TOUCH_INTERVAL_MS - 1;
session.lastActiveAt = stale;
const before = Date.now();
const third = runMiddleware(session);
await third.run();
expect(session.lastActiveAt as number).toBeGreaterThanOrEqual(before);
});
});