From 87acdc3b5cf452982841a6695c243c277796e428 Mon Sep 17 00:00:00 2001 From: Aiden Date: Thu, 26 Feb 2026 21:41:46 -0500 Subject: [PATCH 1/4] Update Next.js to 15.5.8 to fix security vulnerability Co-Authored-By: Claude Opus 4.6 --- client/package-lock.json | 186 +++++++++++++++++++++++++++++++++++++-- client/package.json | 2 +- 2 files changed, 178 insertions(+), 10 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 30abd25f7..316056bfe 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -61,7 +61,7 @@ "lucide-react": "0.510.0", "luxon": "3.6.1", "mapbox-gl": "^3.15.0", - "next": "15.5.7", + "next": "^15.5.8", "next-themes": "0.4.6", "nuqs": "^2.7.3", "ol": "^10.6.1", @@ -1868,9 +1868,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.8", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.8.tgz", + "integrity": "sha512-ejZHa3ogTxcy851dFoNtfB5B2h7AbSAtHbR5CymUlnz4yW1QjHNufVpvTu8PTnWBKFKjrd4k6Gbi2SsCiJKvxw==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -2104,6 +2104,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/annotations/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/annotations/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/axes": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/axes/-/axes-0.96.0.tgz", @@ -2184,6 +2208,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/axes/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/axes/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/bar": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/bar/-/bar-0.96.0.tgz", @@ -2271,6 +2319,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/bar/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/bar/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/calendar": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/calendar/-/calendar-0.96.0.tgz", @@ -2413,6 +2485,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/core/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/core/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/legends": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/legends/-/legends-0.96.0.tgz", @@ -2513,6 +2609,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/line/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/line/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/scales": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/scales/-/scales-0.96.0.tgz", @@ -2610,6 +2730,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/text/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/text/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/theming": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/theming/-/theming-0.96.0.tgz", @@ -2696,6 +2840,30 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/@nivo/tooltip/node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/@nivo/tooltip/node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/@nivo/voronoi": { "version": "0.96.0", "resolved": "https://registry.npmjs.org/@nivo/voronoi/-/voronoi-0.96.0.tgz", @@ -5488,7 +5656,6 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -10386,12 +10553,13 @@ "license": "MIT" }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.8", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.8.tgz", + "integrity": "sha512-Tma2R50eiM7Fx6fbDeHiThq7sPgl06mBr76j6Ga0lMFGrmaLitFsy31kykgb8Z++DR2uIEKi2RZ0iyjIwFd15Q==", + "deprecated": "This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.", "license": "MIT", "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.8", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", diff --git a/client/package.json b/client/package.json index 89d121b2a..38c2763d4 100644 --- a/client/package.json +++ b/client/package.json @@ -65,7 +65,7 @@ "lucide-react": "0.510.0", "luxon": "3.6.1", "mapbox-gl": "^3.15.0", - "next": "15.5.7", + "next": "^15.5.8", "next-themes": "0.4.6", "nuqs": "^2.7.3", "ol": "^10.6.1", From 6cfb88319d8ba317fe1d1034d308e3121b797e1d Mon Sep 17 00:00:00 2001 From: Aiden Vigue Date: Sat, 22 Nov 2025 01:30:31 -0500 Subject: [PATCH 2/4] Add support for OpenID Connect authentication --- README.md | 1 + client/src/components/auth/SocialButtons.tsx | 59 ++++++++++++---- client/src/lib/auth.ts | 7 +- client/src/lib/configs.ts | 6 ++ docker-compose.cloud.yml | 5 ++ docker-compose.yml | 6 ++ server/src/api/getConfig.ts | 10 ++- server/src/lib/auth.ts | 43 ++++++------ server/src/lib/const.ts | 73 ++++++++++++++++++++ 9 files changed, 171 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 0d3524a39..33461c816 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ See how Rybbit compares to other analytics solutions: | **Error Tracking** | ✅ | ❌ | ❌ | ❌ | | **Public Dashboards** | ✅ | ❌ | ✅ | ❌ | | **Organizations** | ✅ | ✅ | ✅ | ✅ | +| **SSO / OpenID Connect** | ✅ | ✅ | ✅ | ✅ | | **Free Tier** | ✅ | ✅ | ❌ | ✅ | | **Frog 🐸** | ✅ | ❌ | ❌ | ❌ | diff --git a/client/src/components/auth/SocialButtons.tsx b/client/src/components/auth/SocialButtons.tsx index e4bb6af53..4b903e2d0 100644 --- a/client/src/components/auth/SocialButtons.tsx +++ b/client/src/components/auth/SocialButtons.tsx @@ -1,11 +1,10 @@ "use client"; import { Button } from "@/components/ui/button"; -import { SiGithub } from "@icons-pack/react-simple-icons"; +import { SiGoogle, SiGithub, SiOpenid } from "@icons-pack/react-simple-icons"; import { authClient } from "@/lib/auth"; -import { IS_CLOUD } from "@/lib/const"; +import { useConfigs } from "@/lib/configs"; import { useExtracted } from "next-intl"; -import Image from "next/image"; interface SocialButtonsProps { onError: (error: string) => void; @@ -16,10 +15,32 @@ interface SocialButtonsProps { export function SocialButtons({ onError, callbackURL, mode = "signin", className = "" }: SocialButtonsProps) { const t = useExtracted(); + const { configs, isLoading } = useConfigs(); - if (!IS_CLOUD) return null; + if (isLoading || !configs) { + return null; + } - const handleSocialAuth = async (provider: "google" | "github" | "twitter") => { + const hasProviders = configs.enabledOIDCProviders.length > 0 || configs.enabledSocialProviders.length > 0; + + if (!hasProviders) { + return null; + } + + const handleOIDCAuth = async (providerId: string) => { + try { + await authClient.signIn.oauth2({ + providerId, + ...(callbackURL && mode !== "signup" ? { callbackURL } : {}), + // For signup flow, new users should be redirected to the same callbackURL + ...(mode === "signup" && callbackURL ? { newUserCallbackURL: callbackURL } : {}), + }); + } catch (error) { + onError(String(error)); + } + } + + const handleSocialAuth = async (provider: string) => { try { await authClient.signIn.social({ provider, @@ -35,14 +56,26 @@ export function SocialButtons({ onError, callbackURL, mode = "signin", className return ( <>
- - + {configs?.enabledOIDCProviders.map((provider) => ( + + ))} + + {configs?.enabledSocialProviders.includes("google") && ( + + )} + + {configs?.enabledSocialProviders.includes("github") && ( + + )}
diff --git a/client/src/lib/auth.ts b/client/src/lib/auth.ts index 31f662cf5..0c4869951 100644 --- a/client/src/lib/auth.ts +++ b/client/src/lib/auth.ts @@ -1,11 +1,12 @@ -import { adminClient, organizationClient, emailOTPClient, apiKeyClient } from "better-auth/client/plugins"; +import { adminClient, organizationClient, emailOTPClient, apiKeyClient, genericOAuthClient } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; +//TODO: Load socialProviders from configs, but we can't use hooks here export const authClient = createAuthClient({ baseURL: process.env.NEXT_PUBLIC_BACKEND_URL, - plugins: [adminClient(), organizationClient(), emailOTPClient(), apiKeyClient()], + plugins: [adminClient(), organizationClient(), emailOTPClient(), apiKeyClient(), genericOAuthClient()], fetchOptions: { credentials: "include", }, - socialProviders: ["google", "github", "twitter"], + socialProviders: ['google', 'github'], }); diff --git a/client/src/lib/configs.ts b/client/src/lib/configs.ts index 7dd9e7746..097f1a46a 100644 --- a/client/src/lib/configs.ts +++ b/client/src/lib/configs.ts @@ -3,7 +3,13 @@ import { authedFetch } from "../api/utils"; interface Configs { disableSignup: boolean; + internalAuthEnabled: boolean; mapboxToken: string; + enabledOIDCProviders: Array<{ + providerId: string; + name: string; + }>; + enabledSocialProviders: string[]; } export function useConfigs() { diff --git a/docker-compose.cloud.yml b/docker-compose.cloud.yml index 45d0124ea..a04df2b5d 100644 --- a/docker-compose.cloud.yml +++ b/docker-compose.cloud.yml @@ -128,6 +128,11 @@ services: - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} - OPENROUTER_API_KEY=${OPENROUTER_API_KEY} - OPENROUTER_MODEL=${OPENROUTER_MODEL} + # Optional for OpenID Connect. Can have multiple OIDC providers by adding more env vars with different "PROVIDER" + # - OIDC_{PROVIDER}_NAME="SSO Provider" + # - OIDC_{PROVIDER}_CLIENT_ID=${OIDC_PROVIDER_CLIENT_ID} + # - OIDC_{PROVIDER}_CLIENT_SECRET=${OIDC_PROVIDER_CLIENT_SECRET} + # - OIDC_{PROVIDER}_DISCOVERY_URL=${OIDC_PROVIDER_DISCOVERY_URL} # e.g. https://accounts.google.com/.well-known/openid-configuration depends_on: clickhouse: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 76cd89eb6..f5dccaa3b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,6 +90,12 @@ services: - DISABLE_SIGNUP=${DISABLE_SIGNUP} - DISABLE_TELEMETRY=${DISABLE_TELEMETRY} - MAPBOX_TOKEN=${MAPBOX_TOKEN} + + # Optional for OpenID Connect. Can have multiple OIDC providers by adding more env vars with different "PROVIDER" + # - OIDC_{PROVIDER}_NAME="SSO Provider" + # - OIDC_{PROVIDER}_CLIENT_ID=${OIDC_PROVIDER_CLIENT_ID} + # - OIDC_{PROVIDER}_CLIENT_SECRET=${OIDC_PROVIDER_CLIENT_SECRET} + # - OIDC_{PROVIDER}_DISCOVERY_URL=${OIDC_PROVIDER_DISCOVERY_URL} # e.g. https://accounts.google.com/.well-known/openid-configuration depends_on: clickhouse: condition: service_healthy diff --git a/server/src/api/getConfig.ts b/server/src/api/getConfig.ts index 3f5e8377b..5154f29b1 100644 --- a/server/src/api/getConfig.ts +++ b/server/src/api/getConfig.ts @@ -1,6 +1,6 @@ import { FastifyRequest, FastifyReply } from "fastify"; import { createRequire } from "module"; -import { DISABLE_SIGNUP, MAPBOX_TOKEN } from "../lib/const.js"; +import { DISABLE_SIGNUP, INTERNAL_AUTHENTICATION_ENABLED, MAPBOX_TOKEN, getOIDCProviders, getSocialProviders } from "../lib/const.js"; const require = createRequire(import.meta.url); const { version } = require("../../package.json"); @@ -8,7 +8,15 @@ const { version } = require("../../package.json"); export async function getConfig(_: FastifyRequest, reply: FastifyReply) { return reply.send({ disableSignup: DISABLE_SIGNUP, + internalAuthEnabled: INTERNAL_AUTHENTICATION_ENABLED, mapboxToken: MAPBOX_TOKEN, + enabledOIDCProviders: getOIDCProviders().map((provider) => { + return { + providerId: provider.providerId, + name: provider.name, + } + }), + enabledSocialProviders: Object.keys(getSocialProviders()), }); } diff --git a/server/src/lib/auth.ts b/server/src/lib/auth.ts index a80c83ef9..1e7ac489b 100644 --- a/server/src/lib/auth.ts +++ b/server/src/lib/auth.ts @@ -1,6 +1,6 @@ import { betterAuth } from "better-auth"; import { APIError, createAuthMiddleware } from "better-auth/api"; -import { admin, captcha, emailOTP, organization, apiKey } from "better-auth/plugins"; +import { admin, captcha, emailOTP, organization, apiKey, genericOAuth } from "better-auth/plugins"; import dotenv from "dotenv"; import { and, asc, eq } from "drizzle-orm"; import pg from "pg"; @@ -8,7 +8,7 @@ import pg from "pg"; import { db } from "../db/postgres/postgres.js"; import * as schema from "../db/postgres/schema.js"; import { invitation, member, memberSiteAccess, user } from "../db/postgres/schema.js"; -import { DISABLE_SIGNUP, IS_CLOUD } from "./const.js"; +import { DISABLE_SIGNUP, INTERNAL_AUTHENTICATION_ENABLED, IS_CLOUD, getOIDCProviders, getSocialProviders } from "./const.js"; import { addContactToAudience, sendInvitationEmail, sendOtpEmail, sendWelcomeEmail } from "./email/email.js"; import { onboardingTipsService } from "../services/onboardingTips/onboardingTipsService.js"; @@ -58,19 +58,27 @@ const pluginList = [ }, }, }), - emailOTP({ - async sendVerificationOTP({ email, otp, type }) { - await sendOtpEmail(email, otp, type); - }, + genericOAuth({ + // OIDC back-redirect will only work if backend and frontend are running on the same port. + // For development, it will redirect back to the backend, and you will manually need to go back to the frontend. + // + // This is fine, because there's really no need for yet another seperate env variable for frontend URL just for dev. + config: getOIDCProviders() }), + ...(INTERNAL_AUTHENTICATION_ENABLED ? [ + emailOTP({ + async sendVerificationOTP({ email, otp, type }) { + await sendOtpEmail(email, otp, type); + }, + })] : []), // Add Cloudflare Turnstile captcha (cloud only) ...(IS_CLOUD && process.env.TURNSTILE_SECRET_KEY && process.env.NODE_ENV === "production" ? [ - captcha({ - provider: "cloudflare-turnstile", - secretKey: process.env.TURNSTILE_SECRET_KEY, - }), - ] + captcha({ + provider: "cloudflare-turnstile", + secretKey: process.env.TURNSTILE_SECRET_KEY, + }), + ] : []), ]; @@ -84,21 +92,12 @@ export const auth = betterAuth({ password: process.env.POSTGRES_PASSWORD, }), emailAndPassword: { - enabled: true, + enabled: INTERNAL_AUTHENTICATION_ENABLED, // Disable email verification for now requireEmailVerification: false, disableSignUp: DISABLE_SIGNUP, }, - socialProviders: { - google: { - clientId: process.env.GOOGLE_CLIENT_ID!, - clientSecret: process.env.GOOGLE_CLIENT_SECRET!, - }, - github: { - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, - }, - }, + socialProviders: getSocialProviders(), user: { additionalFields: { sendAutoEmailReports: { diff --git a/server/src/lib/const.ts b/server/src/lib/const.ts index 0ccb5feb0..f55f66036 100644 --- a/server/src/lib/const.ts +++ b/server/src/lib/const.ts @@ -1,3 +1,4 @@ +import { SocialProviders } from "better-auth/social-providers"; import dotenv from "dotenv"; dotenv.config(); @@ -5,6 +6,8 @@ dotenv.config(); export const IS_CLOUD = process.env.CLOUD === "true"; export const DISABLE_SIGNUP = process.env.DISABLE_SIGNUP === "true"; export const DISABLE_TELEMETRY = process.env.DISABLE_TELEMETRY === "true"; +export const INTERNAL_AUTHENTICATION_ENABLED = process.env.INTERNAL_AUTHENTICATION_ENABLED !== "false"; + export const SECRET = process.env.BETTER_AUTH_SECRET; export const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN; @@ -509,3 +512,73 @@ export const getStripePrices = () => { priceId: TEST_TO_PRICE_ID[price.name as keyof typeof TEST_TO_PRICE_ID], })); }; + +export const getOIDCProviders = () => { + const providers: Array<{ providerId: string; clientId: string; clientSecret: string; discoveryUrl: string; scopes: string[]; name: string }> = []; + const oidcClientIdRegex = /^OIDC_([A-Z0-9_]+)_CLIENT_ID$/; + const oidcClientSecretRegex = /^OIDC_([A-Z0-9_]+)_CLIENT_SECRET$/; + const oidcDiscoveryUrlRegex = /^OIDC_([A-Z0-9_]+)_DISCOVERY_URL$/; + const oidcNameRegex = /^OIDC_([A-Z0-9_]+)_NAME$/; + + const providerIds = new Set(); + + for (const key in process.env) { + const clientIdMatch = key.match(oidcClientIdRegex); + if (clientIdMatch && process.env[key]) { + providerIds.add(clientIdMatch[1]); + } + + const clientSecretMatch = key.match(oidcClientSecretRegex); + if (clientSecretMatch && process.env[key]) { + providerIds.add(clientSecretMatch[1]); + } + + const discoveryUrlMatch = key.match(oidcDiscoveryUrlRegex); + if (discoveryUrlMatch && process.env[key]) { + providerIds.add(discoveryUrlMatch[1]); + } + + const nameMatch = key.match(oidcNameRegex); + if (nameMatch && process.env[key]) { + providerIds.add(nameMatch[1]); + } + } + + for (const providerId of providerIds) { + const clientId = process.env[`OIDC_${providerId}_CLIENT_ID`]; + const clientSecret = process.env[`OIDC_${providerId}_CLIENT_SECRET`]; + const discoveryUrl = process.env[`OIDC_${providerId}_DISCOVERY_URL`]; + const name = process.env[`OIDC_${providerId}_NAME`] || 'OpenID Connect'; + + if (clientId && clientSecret && discoveryUrl) { + providers.push({ + providerId: providerId.toLowerCase(), + clientId, + clientSecret, + discoveryUrl, + scopes: ["openid", "profile", "email"], + name + }); + } + } + + return providers; +} + + +export const getSocialProviders = (): SocialProviders => { + return { + ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET ? { + google: { + clientId: process.env.GOOGLE_CLIENT_ID, + clientSecret: process.env.GOOGLE_CLIENT_SECRET, + } + } : {}), + ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET ? { + github: { + clientId: process.env.GITHUB_CLIENT_ID, + clientSecret: process.env.GITHUB_CLIENT_SECRET, + } + } : {}), + } +} \ No newline at end of file From 070860cb511d843bacd2debfd0cca4bf8cab9e60 Mon Sep 17 00:00:00 2001 From: Aiden Vigue Date: Sat, 22 Nov 2025 02:30:39 -0500 Subject: [PATCH 3/4] Nitpicks --- server/src/lib/const.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/src/lib/const.ts b/server/src/lib/const.ts index f55f66036..2d273eaa2 100644 --- a/server/src/lib/const.ts +++ b/server/src/lib/const.ts @@ -548,7 +548,13 @@ export const getOIDCProviders = () => { const clientId = process.env[`OIDC_${providerId}_CLIENT_ID`]; const clientSecret = process.env[`OIDC_${providerId}_CLIENT_SECRET`]; const discoveryUrl = process.env[`OIDC_${providerId}_DISCOVERY_URL`]; - const name = process.env[`OIDC_${providerId}_NAME`] || 'OpenID Connect'; + const name = process.env[`OIDC_${providerId}_NAME`] || `SSO (${providerId.toLowerCase()})`; + + try { + new URL(discoveryUrl || ""); + } catch { + continue; // Skip invalid URLs + } if (clientId && clientSecret && discoveryUrl) { providers.push({ From e11ed14d32ea112f581294026d2dc6e29e5e88c1 Mon Sep 17 00:00:00 2001 From: Aiden Date: Thu, 26 Feb 2026 21:51:30 -0500 Subject: [PATCH 4/4] Add internalAuthEnabled guards and SSO auto-redirect to login/signup/invitation pages Co-Authored-By: Claude Opus 4.6 --- .../src/app/invitation/components/login.tsx | 72 ++++++++---- client/src/app/login/page.tsx | 109 ++++++++++-------- .../src/app/signup/components/AccountStep.tsx | 78 +++++++------ 3 files changed, 155 insertions(+), 104 deletions(-) diff --git a/client/src/app/invitation/components/login.tsx b/client/src/app/invitation/components/login.tsx index 7a165d3a1..0c0de48ea 100644 --- a/client/src/app/invitation/components/login.tsx +++ b/client/src/app/invitation/components/login.tsx @@ -4,6 +4,7 @@ import { useExtracted } from "next-intl"; import { useState } from "react"; import { authClient } from "../../../lib/auth"; import { userStore } from "../../../lib/userStore"; +import { useConfigs } from "../../../lib/configs"; import { AuthInput } from "@/components/auth/AuthInput"; import { AuthButton } from "@/components/auth/AuthButton"; import { AuthError } from "@/components/auth/AuthError"; @@ -19,6 +20,21 @@ export function Login({ callbackURL }: LoginProps) { const [error, setError] = useState(""); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); + const { configs } = useConfigs(); + + const handleSSOLogin = async () => { + if (!configs?.enabledOIDCProviders.length) return; + + const provider = configs.enabledOIDCProviders[0]; + try { + await authClient.signIn.oauth2({ + providerId: provider.providerId, + callbackURL, + }); + } catch (err) { + setError(String(err)); + } + }; const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); @@ -53,27 +69,41 @@ export function Login({ callbackURL }: LoginProps) {
- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - - {t("Login to Accept Invitation")} - + {configs?.internalAuthEnabled && ( + <> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + + {t("Login to Accept Invitation")} + + + )} + {configs?.enabledOIDCProviders.length ? ( + + Login with SSO + + ) : null}
diff --git a/client/src/app/login/page.tsx b/client/src/app/login/page.tsx index a88eb100f..dedbe51f1 100644 --- a/client/src/app/login/page.tsx +++ b/client/src/app/login/page.tsx @@ -8,7 +8,7 @@ import { Turnstile } from "@/components/auth/Turnstile"; import { useExtracted } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { RybbitTextLogo } from "../../components/RybbitLogo"; import { SpinningGlobe } from "../../components/SpinningGlobe"; import { useSetPageTitle } from "../../hooks/useSetPageTitle"; @@ -73,6 +73,19 @@ export default function Page() { const turnstileEnabled = IS_CLOUD && process.env.NODE_ENV === "production"; + // Auto-redirect to SSO if internal auth is disabled and there's only one OIDC provider + useEffect(() => { + if (!isLoadingConfigs && configs && !configs.internalAuthEnabled && configs.enabledOIDCProviders.length === 1) { + const provider = configs.enabledOIDCProviders[0]; + authClient.signIn.oauth2({ + providerId: provider.providerId, + callbackURL: "/", + }).catch(err => { + setError(String(err)); + }); + } + }, [configs, isLoadingConfigs]); + return (
{/* Left panel - login form */} @@ -87,55 +100,57 @@ export default function Page() {

{t("Welcome back")}

-
-
- setEmail(e.target.value)} - /> - - setPassword(e.target.value)} - rightElement={ - IS_CLOUD && ( - - {t("Forgot password?")} - - ) - } - /> - - {turnstileEnabled && ( - setTurnstileToken(token)} - onError={() => setTurnstileToken("")} - onExpire={() => setTurnstileToken("")} - className="flex justify-center" + {configs?.internalAuthEnabled && ( + +
+ setEmail(e.target.value)} /> - )} - - {t("Login")} - + setPassword(e.target.value)} + rightElement={ + IS_CLOUD && ( + + {t("Forgot password?")} + + ) + } + /> - -
- + {turnstileEnabled && ( + setTurnstileToken(token)} + onError={() => setTurnstileToken("")} + onExpire={() => setTurnstileToken("")} + className="flex justify-center" + /> + )} + + + {t("Login")} + + + +
+ + )} {(!configs?.disableSignup || !isLoadingConfigs) && (
diff --git a/client/src/app/signup/components/AccountStep.tsx b/client/src/app/signup/components/AccountStep.tsx index a5462c6a3..b66010828 100644 --- a/client/src/app/signup/components/AccountStep.tsx +++ b/client/src/app/signup/components/AccountStep.tsx @@ -6,6 +6,7 @@ import { ArrowRight } from "lucide-react"; import { useExtracted } from "next-intl"; import Link from "next/link"; +import { useConfigs } from "../../../lib/configs"; import { IS_CLOUD } from "../../../lib/const"; interface AccountStepProps { @@ -32,49 +33,54 @@ export function AccountStep({ setError, }: AccountStepProps) { const t = useExtracted(); + const { configs } = useConfigs(); return (

{t("Signup")}

- setEmail(e.target.value)} - /> - setPassword(e.target.value)} - /> - {IS_CLOUD && ( - setTurnstileToken(token)} - onError={() => setTurnstileToken("")} - onExpire={() => setTurnstileToken("")} - className="flex justify-center" - /> + {configs?.internalAuthEnabled && ( + <> + setEmail(e.target.value)} + /> + setPassword(e.target.value)} + /> + {IS_CLOUD && ( + setTurnstileToken(token)} + onError={() => setTurnstileToken("")} + onExpire={() => setTurnstileToken("")} + className="flex justify-center" + /> + )} + + {t("Continue")} + + + )} - - {t("Continue")} - -
{t("Already have an account?")}{" "}