diff --git a/server/lib/config.ts b/server/lib/config.ts index 9df1d7a..8bac680 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -92,7 +92,18 @@ const configSchema = z.object({ }) .optional(), trust_proxy: z.boolean().optional().default(true), - secret: z.string() + secret: z + .string() + .optional() + .transform(getEnvOrYaml("SERVER_SECRET")) + .pipe( + z + .string() + .min( + 32, + "SERVER_SECRET must be at least 32 characters long" + ) + ) }), traefik: z.object({ http_entrypoint: z.string(), diff --git a/server/lib/crypto.ts b/server/lib/crypto.ts new file mode 100644 index 0000000..db248e8 --- /dev/null +++ b/server/lib/crypto.ts @@ -0,0 +1,37 @@ +import * as crypto from "crypto"; + +const ALGORITHM = "aes-256-gcm"; + +export function encrypt(value: string, key: string): string { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ALGORITHM, key, iv); + + const encrypted = Buffer.concat([ + cipher.update(value, "utf8"), + cipher.final() + ]); + const authTag = cipher.getAuthTag(); + + return [ + iv.toString("base64"), + encrypted.toString("base64"), + authTag.toString("base64") + ].join(":"); +} + +export function decrypt(encryptedValue: string, key: string): string { + const [ivB64, encryptedB64, authTagB64] = encryptedValue.split(":"); + + const iv = Buffer.from(ivB64, "base64"); + const encrypted = Buffer.from(encryptedB64, "base64"); + const authTag = Buffer.from(authTagB64, "base64"); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); + decipher.setAuthTag(authTag); + + const decrypted = Buffer.concat([ + decipher.update(encrypted), + decipher.final() + ]); + return decrypted.toString("utf8"); +} diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index e26064d..b07d287 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -9,6 +9,8 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; const paramsSchema = z.object({}).strict(); @@ -22,7 +24,8 @@ const bodySchema = z identifierPath: z.string().nonempty(), emailPath: z.string().optional(), namePath: z.string().optional(), - scopes: z.array(z.string().nonempty()) + scopes: z.array(z.string().nonempty()), + autoProvision: z.boolean().optional() }) .strict(); @@ -73,9 +76,15 @@ export async function createOidcIdp( identifierPath, emailPath, namePath, - name + name, + autoProvision } = parsedBody.data; + const key = config.getRawConfig().server.secret; + + const encryptedSecret = encrypt(clientSecret, key); + const encryptedClientId = encrypt(clientId, key); + let idpId: number | undefined; await db.transaction(async (trx) => { const [idpRes] = await trx @@ -90,11 +99,11 @@ export async function createOidcIdp( await trx.insert(idpOidcConfig).values({ idpId: idpRes.idpId, - clientId, - clientSecret, + clientId: encryptedClientId, + clientSecret: encryptedSecret, authUrl, tokenUrl, - autoProvision: true, + autoProvision, scopes: JSON.stringify(scopes), identifierPath, emailPath, diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 4cb616e..e73d6b4 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -13,6 +13,7 @@ import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import cookie from "cookie"; import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; +import { decrypt } from "@server/lib/crypto"; const paramsSchema = z .object({ @@ -77,10 +78,21 @@ export async function generateOidcUrl( const parsedScopes = JSON.parse(existingIdp.idpOidcConfig.scopes); + const key = config.getRawConfig().server.secret; + + const decryptedClientId = decrypt( + existingIdp.idpOidcConfig.clientId, + key + ); + const decryptedClientSecret = decrypt( + existingIdp.idpOidcConfig.clientSecret, + key + ); + const redirectUrl = generateOidcRedirectUrl(idpId); const client = new arctic.OAuth2Client( - existingIdp.idpOidcConfig.clientId, - existingIdp.idpOidcConfig.clientSecret, + decryptedClientId, + decryptedClientSecret, redirectUrl ); diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 232838d..1a78fb1 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -28,6 +28,7 @@ import { generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; +import { decrypt } from "@server/lib/crypto"; const paramsSchema = z .object({ @@ -90,10 +91,21 @@ export async function validateOidcCallback( ); } + const key = config.getRawConfig().server.secret; + + const decryptedClientId = decrypt( + existingIdp.idpOidcConfig.clientId, + key + ); + const decryptedClientSecret = decrypt( + existingIdp.idpOidcConfig.clientSecret, + key + ); + const redirectUrl = generateOidcRedirectUrl(existingIdp.idp.idpId); const client = new arctic.OAuth2Client( - existingIdp.idpOidcConfig.clientId, - existingIdp.idpOidcConfig.clientSecret, + decryptedClientId, + decryptedClientSecret, redirectUrl ); diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index 6e0a1e4..1d6ec80 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -6,6 +6,15 @@ import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp"; import { AxiosResponse } from "axios"; import { useRouter } from "next/navigation"; import { useEffect, useState } from "react"; +import { + Card, + CardHeader, + CardTitle, + CardContent, + CardDescription +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; type ValidateOidcTokenParams = { orgId: string; @@ -13,6 +22,7 @@ type ValidateOidcTokenParams = { code: string | undefined; expectedState: string | undefined; stateCookie: string | undefined; + idp: {name: string}; }; export default function ValidateOidcToken(props: ValidateOidcTokenParams) { @@ -50,6 +60,9 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { router.push("/"); } + setLoading(false); + await new Promise((resolve) => setTimeout(resolve, 100)); + if (redirectUrl.startsWith("http")) { window.location.href = res.data.data.redirectUrl; // TODO: validate this to make sure it's safe } else { @@ -67,11 +80,36 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { }, []); return ( - <> -

Validating OIDC Token...

- {loading &&

Loading...

} - {!loading &&

Token validated successfully!

} - {error &&

Error: {error}

} - +
+ + + Connecting to {props.idp.name} + Validating your identity + + + {loading && ( +
+ + Connecting... +
+ )} + {!loading && !error && ( +
+ + Connected +
+ )} + {error && ( + + + + There was a problem connecting to {props.idp.name}. Please contact your administrator. + {error} + + + )} +
+
+
); } diff --git a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx index 9e051f2..cba7479 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/page.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/page.tsx @@ -1,5 +1,8 @@ import { cookies } from "next/headers"; import ValidateOidcToken from "./ValidateOidcToken"; +import { idp } from "@server/db/schemas"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; export default async function Page(props: { params: Promise<{ orgId: string; idpId: string }>; @@ -14,6 +17,16 @@ export default async function Page(props: { const allCookies = await cookies(); const stateCookie = allCookies.get("p_oidc_state")?.value; + // query db directly in server component because just need the name + const [idpRes] = await db + .select({ name: idp.name }) + .from(idp) + .where(eq(idp.idpId, parseInt(params.idpId!))); + + if (!idpRes) { + return
IdP not found
; + } + return ( <> ); diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index 2480cd6..77dd5d5 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -490,7 +490,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { className={`${numMethods <= 1 ? "mt-0" : ""}`} > await handleSSOAuth() } diff --git a/src/app/globals.css b/src/app/globals.css index 7afe607..21e7444 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -23,7 +23,7 @@ --border: hsl(20 5.9% 90%); --input: hsl(20 5.9% 75%); --ring: hsl(24.6 95% 53.1%); - --radius: 0.50rem; + --radius: 0.75rem; --chart-1: hsl(12 76% 61%); --chart-2: hsl(173 58% 39%); --chart-3: hsl(197 37% 24%); diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index 6fa784d..e4564c0 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -24,7 +24,7 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { LoginResponse } from "@server/routers/auth"; import { useRouter } from "next/navigation"; -import { AxiosResponse, AxiosResponse } from "axios"; +import { AxiosResponse } from "axios"; import { formatAxiosError } from "@app/lib/api"; import { LockIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; @@ -136,7 +136,7 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) { const res = await api.post>( `/auth/idp/${idpId}/oidc/generate-url`, { - redirectUrl: redirect || "/" // this is the post auth redirect url + redirectUrl: redirect || "/" } ); diff --git a/src/lib/cleanRedirect.ts b/src/lib/cleanRedirect.ts index dbe3651..b573ab6 100644 --- a/src/lib/cleanRedirect.ts +++ b/src/lib/cleanRedirect.ts @@ -9,10 +9,10 @@ const patterns: PatternConfig[] = [ { name: "Resource Auth Portal", regex: /^\/auth\/resource\/\d+$/ } ]; -export function cleanRedirect(input: string): string { +export function cleanRedirect(input: string, fallback?: string): string { if (!input || typeof input !== "string") { return "/"; } const isAccepted = patterns.some((pattern) => pattern.regex.test(input)); - return isAccepted ? input : "/"; + return isAccepted ? input : fallback || "/"; }