From 4635fd4daa31cc5eb893970044d903b4d8db5d9d Mon Sep 17 00:00:00 2001 From: Lynton Infra Date: Sun, 24 May 2026 14:49:52 +0000 Subject: [PATCH 1/7] feat(auth): add Authentik OIDC provider for self-hosted SSO NextAuth ships next-auth/providers/authentik but Cap does not currently wire it up, so Authentik users have no clean way to sign in to a self- hosted Cap. The provider is gated on AUTHENTIK_ISSUER being set, so Cap Cloud builds (and any deployment that has not configured Authentik) remain unchanged. Closes #914 (CAP-453) --- packages/database/auth/auth-options.ts | 10 ++++++++++ packages/env/server.ts | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/packages/database/auth/auth-options.ts b/packages/database/auth/auth-options.ts index 1e3a886b2b1..e2a3e0fb460 100644 --- a/packages/database/auth/auth-options.ts +++ b/packages/database/auth/auth-options.ts @@ -6,6 +6,7 @@ import type { NextAuthOptions } from "next-auth"; import { getServerSession as _getServerSession } from "next-auth"; import type { Adapter } from "next-auth/adapters"; import { decode, type JWT, type JWTDecodeParams } from "next-auth/jwt"; +import AuthentikProvider from "next-auth/providers/authentik"; import EmailProvider from "next-auth/providers/email"; import GoogleProvider from "next-auth/providers/google"; import type { Provider } from "next-auth/providers/index"; @@ -69,6 +70,15 @@ export const authOptions = (): NextAuthOptions => { get providers() { if (_providers) return _providers; _providers = [ + ...(serverEnv().AUTHENTIK_ISSUER + ? [ + AuthentikProvider({ + clientId: serverEnv().AUTHENTIK_CLIENT_ID as string, + clientSecret: serverEnv().AUTHENTIK_CLIENT_SECRET as string, + issuer: serverEnv().AUTHENTIK_ISSUER as string, + }), + ] + : []), GoogleProvider({ clientId: serverEnv().GOOGLE_CLIENT_ID as string, clientSecret: serverEnv().GOOGLE_CLIENT_SECRET as string, diff --git a/packages/env/server.ts b/packages/env/server.ts index febc9e3e873..0e952321dd0 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -72,6 +72,14 @@ function createServerEnv() { WORKOS_CLIENT_ID: z.string().optional(), WORKOS_API_KEY: z.string().optional(), + /// Authentik OIDC (self-hosted SSO) + // Provide these to enable a "Sign in with Authentik" provider. + // AUTHENTIK_ISSUER must include the application slug; no trailing slash: + // https://auth.example.com/application/o/ + AUTHENTIK_CLIENT_ID: z.string().optional(), + AUTHENTIK_CLIENT_SECRET: z.string().optional(), + AUTHENTIK_ISSUER: z.string().optional(), + /// Settings CAP_VIDEOS_DEFAULT_PUBLIC: boolString(true).describe( "Should videos be public or private by default", From 3d0a31bdff5297f99167f99c0488e1b233b12855 Mon Sep 17 00:00:00 2001 From: Lynton Infra Date: Sun, 24 May 2026 15:13:06 +0000 Subject: [PATCH 2/7] feat(auth): render Authentik sign-in button on login/signup/share pages Adds an authentikAuthAvailable boolean to publicEnv (gated on AUTHENTIK_ISSUER) and renders a Login/Sign-up with Authentik button alongside the existing Google and WorkOS options in apps/web/app/(org)/login/form.tsx, apps/web/app/(org)/signup/form.tsx, and apps/web/app/s/[videoId]/_components/ AuthOverlay.tsx. Mirrors the existing pattern; deployments without Authentik configured see no UI change. Refs #914 (CAP-453) --- apps/web/app/(org)/login/form.tsx | 31 ++++++++++++- apps/web/app/(org)/signup/form.tsx | 33 +++++++++++++- apps/web/app/layout.tsx | 1 + .../s/[videoId]/_components/AuthOverlay.tsx | 43 +++++++++++++------ apps/web/utils/public-env.tsx | 1 + 5 files changed, 94 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 50ffff2733f..f64df0df7ac 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -6,6 +6,7 @@ import { faArrowLeft, faEnvelope, faExclamationCircle, + faRightToBracket, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { AnimatePresence, motion } from "framer-motion"; @@ -115,6 +116,17 @@ export function LoginForm() { }); }; + const handleAuthentikSignIn = () => { + trackEvent("auth_started", { + method: "authentik", + is_signup: false, + auth_surface: "login", + }); + signIn("authentik", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + }; + const handleOrganizationLookup = async (e: React.FormEvent) => { e.preventDefault(); if (!organizationId) { @@ -326,6 +338,7 @@ export function LoginForm() { loading={loading} oauthError={oauthError} handleGoogleSignIn={handleGoogleSignIn} + handleAuthentikSignIn={handleAuthentikSignIn} /> )} @@ -405,6 +418,7 @@ const NormalLogin = ({ loading, oauthError, handleGoogleSignIn, + handleAuthentikSignIn, }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -413,6 +427,7 @@ const NormalLogin = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + handleAuthentikSignIn: () => void; }) => { const publicEnv = usePublicEnv(); @@ -465,7 +480,9 @@ const NormalLogin = ({ - {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( + {(publicEnv.googleAuthAvailable || + publicEnv.workosAuthAvailable || + publicEnv.authentikAuthAvailable) && ( <>
@@ -476,6 +493,18 @@ const NormalLogin = ({ layout className="flex flex-col gap-3 justify-center items-center" > + {publicEnv.authentikAuthAvailable && !oauthError && ( + + + Login with Authentik + + )} {publicEnv.googleAuthAvailable && !oauthError && ( { + trackEvent("auth_started", { + method: "authentik", + is_signup: true, + auth_surface: "signup", + }); + signIn("authentik", { + ...(next && next.length > 0 ? { callbackUrl: next } : {}), + }); + }; + const handleOrganizationLookup = async (e: React.FormEvent) => { e.preventDefault(); if (!organizationId) { @@ -326,6 +338,7 @@ export function SignupForm() { loading={loading} oauthError={oauthError} handleGoogleSignIn={handleGoogleSignIn} + handleAuthentikSignIn={handleAuthentikSignIn} /> )} @@ -419,6 +432,7 @@ const NormalSignup = ({ loading, oauthError, handleGoogleSignIn, + handleAuthentikSignIn, }: { setShowOrgInput: (show: boolean) => void; email: string; @@ -427,6 +441,7 @@ const NormalSignup = ({ loading: boolean; oauthError: boolean; handleGoogleSignIn: () => void; + handleAuthentikSignIn: () => void; }) => { const publicEnv = usePublicEnv(); @@ -456,7 +471,9 @@ const NormalSignup = ({ Sign up with email - {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable) && ( + {(publicEnv.googleAuthAvailable || + publicEnv.workosAuthAvailable || + publicEnv.authentikAuthAvailable) && ( <>
@@ -467,7 +484,19 @@ const NormalSignup = ({ layout className="flex flex-col gap-3 justify-center items-center" > - {!oauthError && ( + {publicEnv.authentikAuthAvailable && !oauthError && ( + + + Sign up with Authentik + + )} + {publicEnv.googleAuthAvailable && !oauthError && ( webUrl: buildEnv.NEXT_PUBLIC_WEB_URL, workosAuthAvailable: !!serverEnv().WORKOS_CLIENT_ID, googleAuthAvailable: !!serverEnv().GOOGLE_CLIENT_ID, + authentikAuthAvailable: !!serverEnv().AUTHENTIK_ISSUER, }} > diff --git a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx index 75a3c834089..8f00da12bea 100644 --- a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx +++ b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx @@ -1,6 +1,10 @@ import { NODE_ENV } from "@cap/env"; import { Button, Dialog, DialogContent, Input, LogoBadge } from "@cap/ui"; -import { faArrowLeft, faEnvelope } from "@fortawesome/free-solid-svg-icons"; +import { + faArrowLeft, + faEnvelope, + faRightToBracket, +} from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import Image from "next/image"; import Link from "next/link"; @@ -224,23 +228,38 @@ const StepOne = ({ : "Email sent to your inbox" : "Continue with Email"} - {publicEnv.googleAuthAvailable && ( + {(publicEnv.googleAuthAvailable || + publicEnv.authentikAuthAvailable) && ( <>

OR

- + {publicEnv.authentikAuthAvailable && ( + + )} + {publicEnv.googleAuthAvailable && ( + + )} )} diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx index 94999923909..119ec507639 100644 --- a/apps/web/utils/public-env.tsx +++ b/apps/web/utils/public-env.tsx @@ -6,6 +6,7 @@ type PublicEnvContext = { webUrl: string; googleAuthAvailable: boolean; workosAuthAvailable: boolean; + authentikAuthAvailable: boolean; }; const Context = createContext(null); From e3aaea343b1734817d9d432f0973f181955d28dc Mon Sep 17 00:00:00 2001 From: Lynton Infra Date: Sun, 24 May 2026 15:21:10 +0000 Subject: [PATCH 3/7] feat(auth): CAP_DISABLE_EMAIL_AUTH env to hide magic-link UI Adds an opt-in CAP_DISABLE_EMAIL_AUTH flag (boolean, default false) that hides the email input + Login/Sign up with email button on /login, /signup, and the share-page sign-in modal, plus the "Sign up here" link on /login. The EmailProvider stays wired server-side so the magic-link flow remains available as break-glass via direct API call. Useful for OIDC-only deployments. Refs #914 (CAP-453) --- apps/web/app/(org)/login/form.tsx | 74 ++++++++++--------- apps/web/app/(org)/signup/form.tsx | 50 +++++++------ apps/web/app/layout.tsx | 1 + .../s/[videoId]/_components/AuthOverlay.tsx | 60 ++++++++------- apps/web/utils/public-env.tsx | 1 + packages/env/server.ts | 4 + 6 files changed, 103 insertions(+), 87 deletions(-) diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index f64df0df7ac..24f284c0f4c 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -433,29 +433,30 @@ const NormalLogin = ({ return ( - - { - setEmail(e.target.value.toLowerCase()); - }} - /> - } - > - Login with email - + {!publicEnv.disableEmailAuth && ( + + { + setEmail(e.target.value.toLowerCase()); + }} + /> + } + > + Login with email + {/* {NODE_ENV === "development" && (

@@ -466,19 +467,22 @@ const NormalLogin = ({

)} */} -
- - Don't have an account?{" "} - + )} + {!publicEnv.disableEmailAuth && ( + - Sign up here - - + Don't have an account?{" "} + + Sign up here + + + )} {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable || diff --git a/apps/web/app/(org)/signup/form.tsx b/apps/web/app/(org)/signup/form.tsx index df6de6e67d3..b27cee2fcb5 100644 --- a/apps/web/app/(org)/signup/form.tsx +++ b/apps/web/app/(org)/signup/form.tsx @@ -447,30 +447,32 @@ const NormalSignup = ({ return ( - - { - setEmail(e.target.value.toLowerCase()); - }} - /> - } - > - Sign up with email - - + {!publicEnv.disableEmailAuth && ( + + { + setEmail(e.target.value.toLowerCase()); + }} + /> + } + > + Sign up with email + + + )} {(publicEnv.googleAuthAvailable || publicEnv.workosAuthAvailable || publicEnv.authentikAuthAvailable) && ( diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 0522cb3f793..ae3eb785bd4 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -127,6 +127,7 @@ export default ({ children }: PropsWithChildren) => workosAuthAvailable: !!serverEnv().WORKOS_CLIENT_ID, googleAuthAvailable: !!serverEnv().GOOGLE_CLIENT_ID, authentikAuthAvailable: !!serverEnv().AUTHENTIK_ISSUER, + disableEmailAuth: serverEnv().CAP_DISABLE_EMAIL_AUTH, }} > diff --git a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx index 8f00da12bea..261a239e942 100644 --- a/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx +++ b/apps/web/app/s/[videoId]/_components/AuthOverlay.tsx @@ -200,34 +200,38 @@ const StepOne = ({ }} className="flex flex-col gap-3" > -
- { - setEmail(e.target.value.toLowerCase()); - }} - /> -
- + {!publicEnv.disableEmailAuth && ( + <> +
+ { + setEmail(e.target.value.toLowerCase()); + }} + /> +
+ + + )} {(publicEnv.googleAuthAvailable || publicEnv.authentikAuthAvailable) && ( <> diff --git a/apps/web/utils/public-env.tsx b/apps/web/utils/public-env.tsx index 119ec507639..2d4a8e83cb4 100644 --- a/apps/web/utils/public-env.tsx +++ b/apps/web/utils/public-env.tsx @@ -7,6 +7,7 @@ type PublicEnvContext = { googleAuthAvailable: boolean; workosAuthAvailable: boolean; authentikAuthAvailable: boolean; + disableEmailAuth: boolean; }; const Context = createContext(null); diff --git a/packages/env/server.ts b/packages/env/server.ts index 0e952321dd0..a36f9939885 100644 --- a/packages/env/server.ts +++ b/packages/env/server.ts @@ -88,6 +88,10 @@ function createServerEnv() { .string() .optional() .describe("Comma-separated list of permitted signup domains"), + CAP_DISABLE_EMAIL_AUTH: boolString(false).describe( + "Hide the email magic-link UI on login/signup/share pages. " + + "The provider stays wired server-side as break-glass.", + ), /// AI providers DEEPGRAM_API_KEY: z.string().optional().describe("Audio transcription"), From cc23f58f3858c8ea41f2abcf0adaca80cc082190 Mon Sep 17 00:00:00 2001 From: Lynton Infra Date: Sun, 24 May 2026 15:31:17 +0000 Subject: [PATCH 4/7] feat(auth): hide OR divider when CAP_DISABLE_EMAIL_AUTH=true The OR separator between email and OAuth buttons is meaningless when the email block above it is hidden. Wrap the divider in the same !publicEnv.disableEmailAuth gate so an OIDC-only deployment renders a clean Authentik-only login form. Refs #914 (CAP-453) --- apps/web/app/(org)/login/form.tsx | 12 +++++++----- apps/web/app/(org)/signup/form.tsx | 12 +++++++----- apps/web/app/s/[videoId]/_components/AuthOverlay.tsx | 12 +++++++----- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/web/app/(org)/login/form.tsx b/apps/web/app/(org)/login/form.tsx index 24f284c0f4c..8009320bf30 100644 --- a/apps/web/app/(org)/login/form.tsx +++ b/apps/web/app/(org)/login/form.tsx @@ -488,11 +488,13 @@ const NormalLogin = ({ publicEnv.workosAuthAvailable || publicEnv.authentikAuthAvailable) && ( <> -
- -

OR

- -
+ {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} -
- -

OR

- -
+ {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} -
- -

OR

- -
+ {!publicEnv.disableEmailAuth && ( +
+ +

OR

+ +
+ )} {publicEnv.authentikAuthAvailable && (