From 62096e54b49088e0048ce3989c886b08c9097c6a Mon Sep 17 00:00:00 2001 From: eekdood Date: Thu, 16 Jul 2026 15:54:00 -0400 Subject: [PATCH 1/2] Make cloud OIDC provider configurable --- .env.example | 8 ++++-- src/auth.ts | 10 +++---- src/devices.ts | 42 ++++++++++++---------------- src/index.ts | 24 ++++++++-------- src/oidc-config.ts | 62 +++++++++++++++++++++++++++++++++++++++++ src/oidc.ts | 37 +++++++++++++----------- src/webrtc-signaling.ts | 1 + src/webrtc.ts | 1 + 8 files changed, 125 insertions(+), 60 deletions(-) create mode 100644 src/oidc-config.ts diff --git a/.env.example b/.env.example index a7a246a..a9786e4 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,12 @@ PORT=3000 DATABASE_URL="postgresql://jetkvm:jetkvm@localhost:5432/jetkvm?schema=public" ## -## Google Auth with OIDC Configuration +## OIDC Authentication Provider ## -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= +OIDC_ISSUER= +OIDC_CLIENT_ID= +OIDC_CLIENT_SECRET= +OIDC_SCOPES=openid email profile API_HOSTNAME= APP_HOSTNAME= diff --git a/src/auth.ts b/src/auth.ts index 8985196..972b433 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,6 +1,7 @@ import { type NextFunction, type Request, type Response } from "express"; import * as jose from "jose"; import { UnauthorizedError } from "./errors"; +import { getOidcClientId, getOidcExpectedIssuer, getOidcJwks } from "./oidc-config"; const ALLOWED_IDENTITIES = process.env.ALLOWED_IDENTITIES?.split(",") .map((identity) => identity.trim().toLowerCase()) @@ -20,14 +21,13 @@ export const isIdentityAllowed = (identity?: string | null) => { }; export const verifyToken = async (idToken: string) => { - const JWKS = jose.createRemoteJWKSet( - new URL("https://www.googleapis.com/oauth2/v3/certs"), - ); + const JWKS = await getOidcJwks(); + const clientId = getOidcClientId(); try { const { payload } = await jose.jwtVerify(idToken, JWKS, { - issuer: "https://accounts.google.com", - audience: process.env.GOOGLE_CLIENT_ID, + issuer: await getOidcExpectedIssuer(), + audience: clientId, }); return payload; diff --git a/src/devices.ts b/src/devices.ts index ba42ccf..8c15688 100644 --- a/src/devices.ts +++ b/src/devices.ts @@ -13,31 +13,25 @@ import { activeConnections } from "./webrtc-signaling"; export const List = async (req: express.Request, res: express.Response) => { const idToken = req.session?.id_token; - const { iss, sub } = jose.decodeJwt(idToken); - - // Authorization server’s identifier for the user - const isGoogle = iss === "https://accounts.google.com"; - if (isGoogle) { - const devices = await prisma.device.findMany({ - where: { user: { googleId: sub } }, - select: { id: true, name: true, lastSeen: true }, - }); + const { sub } = jose.decodeJwt(idToken); - return res.json({ - devices: devices.map(device => { - const activeDevice = activeConnections.get(device.id); - const version = activeDevice?.[2] || null; - - return { - ...device, - online: !!activeDevice, - version, - }; - }), - }); - } else { - throw new BadRequestError("Token is not from Google"); - } + const devices = await prisma.device.findMany({ + where: { user: { googleId: sub } }, + select: { id: true, name: true, lastSeen: true }, + }); + + return res.json({ + devices: devices.map(device => { + const activeDevice = activeConnections.get(device.id); + const version = activeDevice?.[2] || null; + + return { + ...device, + online: !!activeDevice, + version, + }; + }), + }); }; export const Retrieve = async ( diff --git a/src/index.ts b/src/index.ts index b6fdecb..988b68c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,9 +25,11 @@ declare global { APP_HOSTNAME: string; COOKIE_SECRET: string; - // We use Google OIDC for authentication - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; + // OIDC authentication provider + OIDC_ISSUER: string; + OIDC_CLIENT_ID: string; + OIDC_CLIENT_SECRET: string; + OIDC_SCOPES?: string; // We use Cloudflare STUN & TURN server for cloud users CLOUDFLARE_TURN_ID: string; @@ -100,15 +102,12 @@ app.get( authenticated, async (req: express.Request, res: express.Response) => { const idToken = req.session?.id_token; - const { sub, iss, exp, aud, iat, jti, nbf } = jose.decodeJwt(idToken); - - let user; - if (iss === "https://accounts.google.com") { - user = await prisma.user.findUnique({ - where: { googleId: sub }, - select: { picture: true, email: true }, - }); - } + const { sub } = jose.decodeJwt(idToken); + + const user = await prisma.user.findUnique({ + where: { googleId: sub }, + select: { picture: true, email: true }, + }); return res.json({ ...user, sub }); }, @@ -136,6 +135,7 @@ app.post( ); app.post("/oidc/google", OIDC.Google); +app.post("/oidc/login", OIDC.Login); app.get("/oidc/callback_o", OIDC.Callback); app.get("/oidc/callback", (req, res) => { /* diff --git a/src/oidc-config.ts b/src/oidc-config.ts new file mode 100644 index 0000000..897bf11 --- /dev/null +++ b/src/oidc-config.ts @@ -0,0 +1,62 @@ +import * as jose from "jose"; +import { Issuer } from "openid-client"; + +const DEFAULT_OIDC_SCOPES = "openid email profile"; + +let issuerPromise: ReturnType | null = null; +let jwks: ReturnType | null = null; + +const getRequiredEnv = (name: string) => { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing ${name}`); + } + return value; +}; + +export const getOidcIssuerUrl = () => getRequiredEnv("OIDC_ISSUER"); + +export const getOidcClientId = () => getRequiredEnv("OIDC_CLIENT_ID"); + +export const getOidcClientSecret = () => getRequiredEnv("OIDC_CLIENT_SECRET"); + +export const getOidcScopes = () => + process.env.OIDC_SCOPES || DEFAULT_OIDC_SCOPES; + +export const getOidcIssuer = async () => { + if (!issuerPromise) { + issuerPromise = Issuer.discover(getOidcIssuerUrl()); + } + return issuerPromise; +}; + +export const getOidcExpectedIssuer = async () => { + const issuer = await getOidcIssuer(); + return issuer.metadata.issuer || getOidcIssuerUrl(); +}; + +export const getOidcClient = async (redirectUri: string) => { + const issuer = await getOidcIssuer(); + const clientId = getOidcClientId(); + const clientSecret = getOidcClientSecret(); + + return new issuer.Client({ + client_id: clientId, + client_secret: clientSecret, + redirect_uris: [redirectUri], + response_types: ["code"], + }); +}; + +export const getOidcJwks = async () => { + if (jwks) return jwks; + + const issuer = await getOidcIssuer(); + const jwksUri = issuer.metadata.jwks_uri; + if (!jwksUri) { + throw new Error("OIDC issuer does not expose a jwks_uri"); + } + + jwks = jose.createRemoteJWKSet(new URL(jwksUri)); + return jwks; +}; diff --git a/src/oidc.ts b/src/oidc.ts index 7908e76..3cbd80c 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -1,25 +1,21 @@ -import { generators, Issuer } from "openid-client"; +import { generators } from "openid-client"; import express from "express"; import { prisma } from "./db"; import { BadRequestError, UnauthorizedError } from "./errors"; import { isIdentityAllowed } from "./auth"; import * as crypto from "crypto"; +import { + getOidcClient, + getOidcClientId, + getOidcIssuerUrl, + getOidcScopes, +} from "./oidc-config"; const API_HOSTNAME = process.env.API_HOSTNAME; const APP_HOSTNAME = process.env.APP_HOSTNAME; const REDIRECT_URI = `${API_HOSTNAME}/oidc/callback`; -const getGoogleOIDCClient = async () => { - const googleIssuer = await Issuer.discover("https://accounts.google.com"); - return new googleIssuer.Client({ - client_id: process.env.GOOGLE_CLIENT_ID, - client_secret: process.env.GOOGLE_CLIENT_SECRET, - redirect_uris: [REDIRECT_URI], - response_types: ["code"], - }); -}; - -export const Google = async (req: express.Request, res: express.Response) => { +export const Login = async (req: express.Request, res: express.Response) => { const state = new URLSearchParams(); // Generate a CSRF token and store it in the session, so the callback @@ -34,9 +30,9 @@ export const Google = async (req: express.Request, res: express.Response) => { const code_challenge = generators.codeChallenge(code_verifier); req.session!.code_verifier = code_verifier; - const client = await getGoogleOIDCClient(); + const client = await getOidcClient(REDIRECT_URI); const authorizationUrl = client.authorizationUrl({ - scope: "openid email profile", + scope: getOidcScopes(), state: state.toString(), // This ensures that to even issue the token, the client must have the code_verifier, // which is stored in the session cookie. @@ -47,7 +43,7 @@ export const Google = async (req: express.Request, res: express.Response) => { }; export const Callback = async (req: express.Request, res: express.Response) => { - const client = await getGoogleOIDCClient(); + const client = await getOidcClient(REDIRECT_URI); // Retrieve recognized callback parameters from the request, e.g. code and state const params = client.callbackParams(req); @@ -85,6 +81,10 @@ export const Callback = async (req: express.Request, res: express.Response) => { throw new BadRequestError("Missing claims in token", "missing_claims"); } + if (typeof tokenClaims.sub !== "string") { + throw new BadRequestError("Missing subject claim", "missing_subject_claim"); + } + if (!tokenSet.id_token) { throw new BadRequestError("Missing ID Token", "missing_id_token"); } @@ -161,9 +161,14 @@ export const Callback = async (req: express.Request, res: express.Response) => { const url = new URL(returnTo); url.searchParams.append("tempToken", tempToken); url.searchParams.append("deviceId", deviceId); + url.searchParams.append("oidcToken", tokenSet.id_token.toString()); + url.searchParams.append("oidcClientId", getOidcClientId() || ""); + url.searchParams.append("oidcIssuer", getOidcIssuerUrl()); url.searchParams.append("oidcGoogle", tokenSet.id_token.toString()); - url.searchParams.append("clientId", process.env.GOOGLE_CLIENT_ID); + url.searchParams.append("clientId", getOidcClientId() || ""); return res.redirect(url.toString()); } return res.redirect(returnTo); }; + +export const Google = Login; diff --git a/src/webrtc-signaling.ts b/src/webrtc-signaling.ts index 310b513..f91d652 100644 --- a/src/webrtc-signaling.ts +++ b/src/webrtc-signaling.ts @@ -329,6 +329,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri sd: msg.data.sd, ip, iceServers, + OidcToken: token, OidcGoogle: token, }, }), diff --git a/src/webrtc.ts b/src/webrtc.ts index fca86bc..48afc14 100644 --- a/src/webrtc.ts +++ b/src/webrtc.ts @@ -66,6 +66,7 @@ export const CreateSession = async (req: express.Request, res: express.Response) sd, ip, iceServers, + OidcToken: idToken, OidcGoogle: idToken, }), ); From ffdec2eccb305fe83c283387d261b925fa76fce6 Mon Sep 17 00:00:00 2001 From: eekdood Date: Fri, 17 Jul 2026 21:38:16 -0400 Subject: [PATCH 2/2] Harden OIDC return redirects --- src/oidc.ts | 25 +++++++++++++++++++++++-- test/oidc.test.ts | 24 ++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 test/oidc.test.ts diff --git a/src/oidc.ts b/src/oidc.ts index 3cbd80c..4c0562f 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -14,6 +14,27 @@ import { const API_HOSTNAME = process.env.API_HOSTNAME; const APP_HOSTNAME = process.env.APP_HOSTNAME; const REDIRECT_URI = `${API_HOSTNAME}/oidc/callback`; +const CONTROL_CHARACTERS = /[\u0000-\u001F\u007F]/g; + +export const normalizeReturnTo = ( + returnTo: unknown, + appHostname = APP_HOSTNAME, +) => { + const fallback = `${appHostname}/devices`; + if (typeof returnTo !== "string") return fallback; + + const sanitized = returnTo.replace(CONTROL_CHARACTERS, "").trim(); + if (!sanitized) return fallback; + + try { + const url = new URL(sanitized, appHostname); + const appUrl = new URL(appHostname || ""); + if (url.origin !== appUrl.origin) return fallback; + return url.toString(); + } catch { + return fallback; + } +}; export const Login = async (req: express.Request, res: express.Response) => { const state = new URLSearchParams(); @@ -24,7 +45,7 @@ export const Login = async (req: express.Request, res: express.Response) => { req.session!.csrf = state.get("csrf"); req.session!.deviceId = req.body.deviceId; - req.session!.returnTo = req.body.returnTo; + req.session!.returnTo = normalizeReturnTo(req.body.returnTo); const code_verifier = generators.codeVerifier(); const code_challenge = generators.codeChallenge(code_verifier); @@ -61,7 +82,7 @@ export const Callback = async (req: express.Request, res: express.Response) => { } const deviceId = req.session?.deviceId as string | undefined; - const returnTo = (req.session?.returnTo ?? `${APP_HOSTNAME}/devices`) as string; + const returnTo = normalizeReturnTo(req.session?.returnTo); req.session!.csrf = null; req.session!.returnTo = null; diff --git a/test/oidc.test.ts b/test/oidc.test.ts new file mode 100644 index 0000000..7b25c0f --- /dev/null +++ b/test/oidc.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { normalizeReturnTo } from "../src/oidc"; + +describe("normalizeReturnTo", () => { + const appHostname = "https://jetkvm.example.com"; + + it("removes control characters from return URLs", () => { + expect( + normalizeReturnTo("https://jetkvm.example.com/\r\n\tdevices", appHostname), + ).toBe("https://jetkvm.example.com/devices"); + }); + + it("falls back when return URLs point off-origin", () => { + expect(normalizeReturnTo("https://example.net/devices", appHostname)).toBe( + "https://jetkvm.example.com/devices", + ); + }); + + it("allows relative return URLs on the app origin", () => { + expect(normalizeReturnTo("/devices", appHostname)).toBe( + "https://jetkvm.example.com/devices", + ); + }); +});