diff --git a/.env.example b/.env.example index a0c6369..a7a246a 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,9 @@ R2_CDN_URL= # Allowed CORS Origins, split by comma CORS_ORIGINS=https://app.jetkvm.com,http://localhost:5173 +# Allowed account emails, split by comma (leave empty to allow all) +ALLOWED_IDENTITIES= + # Real IP Header for the reverse proxy (e.g. X-Real-IP), leave empty if not needed REAL_IP_HEADER= diff --git a/src/auth.ts b/src/auth.ts index 96e208e..8985196 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -2,6 +2,22 @@ import { type NextFunction, type Request, type Response } from "express"; import * as jose from "jose"; import { UnauthorizedError } from "./errors"; +const ALLOWED_IDENTITIES = process.env.ALLOWED_IDENTITIES?.split(",") + .map((identity) => identity.trim().toLowerCase()) + .filter(Boolean); + +const getAllowedIdentities = () => { + if (!ALLOWED_IDENTITIES) return null; + return ALLOWED_IDENTITIES.length > 0 ? new Set(ALLOWED_IDENTITIES) : null; +}; + +export const isIdentityAllowed = (identity?: string | null) => { + const allowedIdentities = getAllowedIdentities(); + const identityNormalized = identity?.trim().toLowerCase(); + if (!allowedIdentities) return true; + if (!identityNormalized) return false; + return allowedIdentities.has(identityNormalized); +}; export const verifyToken = async (idToken: string) => { const JWKS = jose.createRemoteJWKSet( @@ -33,5 +49,10 @@ export const authenticated = async (req: Request, res: Response, next: NextFunct throw new UnauthorizedError(); } + const email = (payload as { email?: string }).email; + if (!isIdentityAllowed(email)) { + throw new UnauthorizedError("Account is not in the allowlist", "account_not_allowed"); + } + next(); }; diff --git a/src/index.ts b/src/index.ts index 347f0e9..b6fdecb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,6 +45,8 @@ declare global { // Real IP REAL_IP_HEADER: string; ICE_SERVERS: string; + + ALLOWED_IDENTITIES?: string; } } } diff --git a/src/oidc.ts b/src/oidc.ts index ea5056f..7908e76 100644 --- a/src/oidc.ts +++ b/src/oidc.ts @@ -1,7 +1,8 @@ import { generators, Issuer } from "openid-client"; import express from "express"; import { prisma } from "./db"; -import { BadRequestError } from "./errors"; +import { BadRequestError, UnauthorizedError } from "./errors"; +import { isIdentityAllowed } from "./auth"; import * as crypto from "crypto"; const API_HOSTNAME = process.env.API_HOSTNAME; @@ -88,6 +89,15 @@ export const Callback = async (req: express.Request, res: express.Response) => { throw new BadRequestError("Missing ID Token", "missing_id_token"); } + if (!userInfo.email) { + req.session = null; + throw new BadRequestError("Missing email claim in user info", "missing_email_claim"); + } + if (!isIdentityAllowed(userInfo.email)) { + req.session = null; + throw new UnauthorizedError("Account is not in the allowlist", "account_not_allowed"); + } + req.session!.id_token = tokenSet.id_token; await prisma.user.upsert({