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/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?")}{" "} 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..2d273eaa2 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,79 @@ 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`] || `SSO (${providerId.toLowerCase()})`; + + try { + new URL(discoveryUrl || ""); + } catch { + continue; // Skip invalid URLs + } + + 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