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
8 changes: 5 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand Down
10 changes: 5 additions & 5 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -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())
Expand All @@ -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;
Expand Down
42 changes: 18 additions & 24 deletions src/devices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
24 changes: 12 additions & 12 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 });
},
Expand Down Expand Up @@ -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) => {
/*
Expand Down
62 changes: 62 additions & 0 deletions src/oidc-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import * as jose from "jose";
import { Issuer } from "openid-client";

const DEFAULT_OIDC_SCOPES = "openid email profile";

let issuerPromise: ReturnType<typeof Issuer.discover> | null = null;
let jwks: ReturnType<typeof jose.createRemoteJWKSet> | 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;
};
60 changes: 43 additions & 17 deletions src/oidc.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,42 @@
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"],
});
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 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
Expand All @@ -28,15 +45,15 @@ export const Google = 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);
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.
Expand All @@ -47,7 +64,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);
Expand All @@ -65,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;
Expand All @@ -85,6 +102,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");
}
Expand Down Expand Up @@ -161,9 +182,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;
1 change: 1 addition & 0 deletions src/webrtc-signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ function setupClientWebSocket(clientWs: WebSocket, deviceId: string, token: stri
sd: msg.data.sd,
ip,
iceServers,
OidcToken: token,
OidcGoogle: token,
},
}),
Expand Down
1 change: 1 addition & 0 deletions src/webrtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export const CreateSession = async (req: express.Request, res: express.Response)
sd,
ip,
iceServers,
OidcToken: idToken,
OidcGoogle: idToken,
}),
);
Expand Down
24 changes: 24 additions & 0 deletions test/oidc.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});