Skip to content

feat(auth): add Authentik OIDC provider + CAP_DISABLE_EMAIL_AUTH flag#1869

Open
stig-codes wants to merge 7 commits into
CapSoftware:mainfrom
stig-codes:feat/authentik-oidc-provider
Open

feat(auth): add Authentik OIDC provider + CAP_DISABLE_EMAIL_AUTH flag#1869
stig-codes wants to merge 7 commits into
CapSoftware:mainfrom
stig-codes:feat/authentik-oidc-provider

Conversation

@stig-codes
Copy link
Copy Markdown

@stig-codes stig-codes commented May 24, 2026

Summary

Adds a NextAuth authentik provider to self-hosted Cap so OIDC-only deployments can sign in via Authentik (or any OIDC IdP that respects standard discovery), plus an optional CAP_DISABLE_EMAIL_AUTH flag for OIDC-only deployments that want to hide the magic-link UI entirely.

Closes #914 (CAP-453).

The patch is purely additive — every change is gated on a new env var, so deployments that don't set them see no behavior change. Cap Cloud and existing self-hosted users are unaffected.

What this changes

Authentik OIDC provider (AUTHENTIK_ISSUER / _CLIENT_ID / _CLIENT_SECRET)

  • packages/database/auth/auth-options.ts — spreads in AuthentikProvider from next-auth/providers/authentik when AUTHENTIK_ISSUER is set.
  • packages/env/server.ts — adds three optional env keys.
  • apps/web/utils/public-env.tsx + app/layout.tsx — surfaces authentikAuthAvailable to client components.
  • apps/web/app/(org)/login/form.tsx, app/(org)/signup/form.tsx, app/s/[videoId]/_components/AuthOverlay.tsx — render a "Login/Sign up with Authentik" button alongside the existing Google/WorkOS options, using the same publicEnv.<provider>AuthAvailable pattern.

CAP_DISABLE_EMAIL_AUTH flag (default false)

For OIDC-only deployments. When true:

  • Hides the email input + Login/Sign up with email button.
  • Hides the OR divider between email and OAuth.
  • Hides the "Don't have an account? Sign up here" link on /login.
  • Hides the email-specific ToS line ("By typing your email…").
  • Redirects /signup/login — OAuth users sign up via first sign-in via the database adapter.
  • The EmailProvider stays wired server-side as a break-glass option for direct API access; this is a UI-only toggle.

Notes

  • The provider uses the upstream-canonical name authentik, but works against any OIDC-compliant IdP (Keycloak, Zitadel, Pocket ID, Authelia, etc.) by setting AUTHENTIK_ISSUER to that IdP's OIDC discovery URL.
  • The next-auth/providers/authentik documentation says the issuer should be the application slug URL without a trailing slash, so set e.g. AUTHENTIK_ISSUER=https://auth.example.com/application/o/cap (no trailing slash). The provider appends /.well-known/openid-configuration itself.
  • Default callback path is /api/auth/callback/authentik — matches NextAuth's convention; that's the URL to register in your Authentik provider config.

Commits

5 focused commits:

  1. feat(auth): add Authentik OIDC provider for self-hosted SSO — provider + env keys.
  2. feat(auth): render Authentik sign-in button on login/signup/share pages — UI buttons + publicEnv.authentikAuthAvailable.
  3. feat(auth): CAP_DISABLE_EMAIL_AUTH env to hide magic-link UI — flag + initial UI gates.
  4. feat(auth): hide OR divider when CAP_DISABLE_EMAIL_AUTH=true — separator gate.
  5. feat(auth): hide email-specific ToS line + redirect /signup when CAP_DISABLE_EMAIL_AUTH — ToS gate + signup-page redirect.

Test plan

  • pnpm i --frozen-lockfile succeeds.
  • pnpm run build:web succeeds (built locally as cap-web:lynton-authentik, 620 MB).
  • Without AUTHENTIK_ISSUER set, /api/auth/providers returns the existing {google, workos, email} set unchanged. Login UI renders as before.
  • Without CAP_DISABLE_EMAIL_AUTH, the email block, OR divider, ToS line, and signup link all render unchanged.
  • With AUTHENTIK_ISSUER set: /api/auth/providers includes authentik; /login shows "Login with Authentik".
  • POST /api/auth/signin/authentik returns a properly signed authorize URL with PKCE code_challenge_method=S256, scope openid email profile, and the registered redirect_uri.
  • End-to-end against a real Authentik instance: Authentik returns 302 to its login page; consent + callback round-trip completes; user record auto-provisioned via DrizzleAdapter; /dashboard reachable.
  • With CAP_DISABLE_EMAIL_AUTH=true: /signup 307-redirects to /login; /login shows only the Authentik button (no email block, no OR, no ToS prose, no signup link).
  • No new runtime errors in cap-web logs; no new TypeScript errors.

Compatibility

  • No new dependencies. Uses next-auth/providers/authentik already shipped with next-auth@4.24.5.
  • No schema migrations.
  • No changes to existing env vars or callback URLs.

Greptile Summary

This PR adds an Authentik OIDC provider to self-hosted Cap deployments and a CAP_DISABLE_EMAIL_AUTH flag that hides magic-link UI for OIDC-only setups. The changes are purely additive and env-gated, leaving existing deployments unaffected.

  • packages/database/auth/auth-options.ts / packages/env/server.ts: Registers AuthentikProvider when AUTHENTIK_ISSUER is set; adds three optional Authentik env vars and the boolean CAP_DISABLE_EMAIL_AUTH flag.
  • login/form.tsx, signup/form.tsx, signup/page.tsx: Renders the Authentik button with analytics and callbackUrl support; hides email inputs, OR divider, ToS prose, and signup link behind disableEmailAuth; server-redirects /signup/login when the flag is on.
  • AuthOverlay.tsx: Adds the Authentik button to the share-page comment overlay, but is missing a callbackUrl back to the video and omits analytics tracking — unlike the Google handler in the same file.

Confidence Score: 3/5

The login and signup forms are well-implemented, but two issues in AuthOverlay.tsx and a misconfiguration risk in auth-options.ts need fixing before merge.

The AuthOverlay Authentik button drops the video callbackUrl, so users authenticating from a share page are redirected away from the video instead of back to it. Separately, AuthentikProvider is constructed using AUTHENTIK_CLIENT_ID/AUTHENTIK_CLIENT_SECRET cast to string with no validation that they are actually set — a partial configuration silently produces a broken provider whose failures only surface at token-exchange time.

apps/web/app/s/[videoId]/_components/AuthOverlay.tsx and packages/database/auth/auth-options.ts need attention before this is ready to merge.

Important Files Changed

Filename Overview
packages/database/auth/auth-options.ts Adds AuthentikProvider gated on AUTHENTIK_ISSUER, but CLIENT_ID/CLIENT_SECRET are not validated alongside it — undefined credentials are cast to string, causing a silent misconfiguration.
apps/web/app/s/[videoId]/_components/AuthOverlay.tsx Adds Authentik button and disableEmailAuth gating, but the Authentik signIn call is missing a callbackUrl back to the video page, and the email ToS paragraph is not gated on disableEmailAuth.
packages/env/server.ts Adds three optional Authentik env vars and the CAP_DISABLE_EMAIL_AUTH boolean flag; schema is correct and follows existing patterns.
apps/web/app/(org)/login/form.tsx Adds Authentik sign-in button with analytics, callbackUrl, and disableEmailAuth gating applied correctly to all relevant UI sections.
apps/web/app/(org)/signup/form.tsx Mirrors login form changes; Authentik button and disableEmailAuth gates applied consistently.
apps/web/app/(org)/signup/page.tsx Adds server-side redirect to /login when CAP_DISABLE_EMAIL_AUTH is set; logic is correct.
apps/web/app/layout.tsx Passes authentikAuthAvailable and disableEmailAuth into the public env context; straightforward and correct.
apps/web/utils/public-env.tsx Extends PublicEnvContext type with authentikAuthAvailable and disableEmailAuth; clean type addition.

Comments Outside Diff (1)

  1. apps/web/app/s/[videoId]/_components/AuthOverlay.tsx, line 94-113 (link)

    P2 Email ToS text not gated when email auth is disabled

    The login and signup forms wrap their email-specific ToS paragraph in a publicEnv.disableEmailAuth check. The matching paragraph in AuthOverlay (rendered outside StepOne, always visible on step 1) has no such gate — so "By entering your email…" stays visible when email auth is disabled, even though the email input is hidden and only OAuth buttons are shown.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: apps/web/app/s/[videoId]/_components/AuthOverlay.tsx
    Line: 94-113
    
    Comment:
    **Email ToS text not gated when email auth is disabled**
    
    The login and signup forms wrap their email-specific ToS paragraph in a `publicEnv.disableEmailAuth` check. The matching paragraph in `AuthOverlay` (rendered outside `StepOne`, always visible on step 1) has no such gate — so "By entering your email…" stays visible when email auth is disabled, even though the email input is hidden and only OAuth buttons are shown.
    
    How can I resolve this? If you propose a fix, please make it concise.
Prompt To Fix All With AI
Fix the following 4 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 4
packages/database/auth/auth-options.ts:73-81
**Missing companion-var validation for Authentik credentials**

`AUTHENTIK_ISSUER` gates provider registration, but `AUTHENTIK_CLIENT_ID` and `AUTHENTIK_CLIENT_SECRET` are each independently optional in the schema. If a deployer sets only `AUTHENTIK_ISSUER` (or sets the issuer but misspells either credential key), `serverEnv().AUTHENTIK_CLIENT_ID` evaluates to `undefined`, the `as string` cast silences TypeScript, and `AuthentikProvider` is registered with empty credentials. The OIDC flow will fail at token exchange with a cryptic IdP error rather than a clear startup-time misconfiguration message.

### Issue 2 of 4
apps/web/app/s/[videoId]/_components/AuthOverlay.tsx:247-254
**Authentik sign-in in `AuthOverlay` drops the video `callbackUrl`**

The Google handler in the same component sets `callbackUrl: \`${window.location.origin}/s/${videoId}\`` so the user is returned to the share page after OAuth. The Authentik button calls `signIn("authentik")` with no options, so NextAuth will redirect to its default post-sign-in URL (typically `/dashboard`) instead of back to the video. A user clicking "Login with Authentik" on a share page will lose their place and the comment dialog will close.

### Issue 3 of 4
apps/web/app/s/[videoId]/_components/AuthOverlay.tsx:245-256
**Authentik sign-in missing analytics tracking**

Every other auth method in this file calls `trackEvent("auth_started", ...)` before `signIn`. The Authentik button skips this, creating a gap in auth-surface analytics. It also doesn't set `loading` or `redirect: false`, making it inconsistent with the Google handler's pattern.

```suggestion
					{publicEnv.authentikAuthAvailable && (
						<Button
							variant="gray"
							type="button"
							className="flex gap-2 justify-center items-center my-1 w-full text-sm"
							onClick={() => {
								trackEvent("auth_started", {
									method: "authentik",
									is_signup: false,
									auth_surface: "share_overlay",
									video_id: videoId,
								});
								setLoading(true);
								signIn("authentik", {
									callbackUrl: `${window.location.origin}/s/${videoId}`,
								});
							}}
							disabled={loading}
						>
							<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
							Login with Authentik
						</Button>
					)}
```

### Issue 4 of 4
apps/web/app/s/[videoId]/_components/AuthOverlay.tsx:94-113
**Email ToS text not gated when email auth is disabled**

The login and signup forms wrap their email-specific ToS paragraph in a `publicEnv.disableEmailAuth` check. The matching paragraph in `AuthOverlay` (rendered outside `StepOne`, always visible on step 1) has no such gate — so "By entering your email…" stays visible when email auth is disabled, even though the email input is hidden and only OAuth buttons are shown.

Reviews (1): Last reviewed commit: "feat(auth): hide email-specific ToS line..." | Re-trigger Greptile

Greptile also left 3 inline comments on this PR.

Lynton Infra added 5 commits May 24, 2026 14:49
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 CapSoftware#914 (CAP-453)
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 CapSoftware#914 (CAP-453)
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 CapSoftware#914 (CAP-453)
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 CapSoftware#914 (CAP-453)
…DISABLE_EMAIL_AUTH

Two cleanups for OIDC-only deployments:

1. The "By typing your email and clicking continue..." Terms-of-Service
   line on /login and /signup is orphan copy when the email block is
   hidden. Wrap it in the same !publicEnv.disableEmailAuth gate.

2. With email auth disabled, OAuth users sign up via first sign-in
   (NextAuth auto-provisions through the database adapter), so /signup
   has no separate flow. Add a server-side redirect /signup -> /login
   when CAP_DISABLE_EMAIL_AUTH=true.

Refs CapSoftware#914 (CAP-453)
@superagent-security superagent-security Bot added contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis. labels May 24, 2026
Comment thread packages/database/auth/auth-options.ts Outdated
Comment on lines +73 to +81
...(serverEnv().AUTHENTIK_ISSUER
? [
AuthentikProvider({
clientId: serverEnv().AUTHENTIK_CLIENT_ID as string,
clientSecret: serverEnv().AUTHENTIK_CLIENT_SECRET as string,
issuer: serverEnv().AUTHENTIK_ISSUER as string,
}),
]
: []),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing companion-var validation for Authentik credentials

AUTHENTIK_ISSUER gates provider registration, but AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET are each independently optional in the schema. If a deployer sets only AUTHENTIK_ISSUER (or sets the issuer but misspells either credential key), serverEnv().AUTHENTIK_CLIENT_ID evaluates to undefined, the as string cast silences TypeScript, and AuthentikProvider is registered with empty credentials. The OIDC flow will fail at token exchange with a cryptic IdP error rather than a clear startup-time misconfiguration message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/database/auth/auth-options.ts
Line: 73-81

Comment:
**Missing companion-var validation for Authentik credentials**

`AUTHENTIK_ISSUER` gates provider registration, but `AUTHENTIK_CLIENT_ID` and `AUTHENTIK_CLIENT_SECRET` are each independently optional in the schema. If a deployer sets only `AUTHENTIK_ISSUER` (or sets the issuer but misspells either credential key), `serverEnv().AUTHENTIK_CLIENT_ID` evaluates to `undefined`, the `as string` cast silences TypeScript, and `AuthentikProvider` is registered with empty credentials. The OIDC flow will fail at token exchange with a cryptic IdP error rather than a clear startup-time misconfiguration message.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +247 to +254
variant="gray"
type="button"
className="flex gap-2 justify-center items-center my-1 w-full text-sm"
onClick={() => signIn("authentik")}
disabled={loading}
>
<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
Login with Authentik
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Authentik sign-in in AuthOverlay drops the video callbackUrl

The Google handler in the same component sets callbackUrl: \${window.location.origin}/s/${videoId}`so the user is returned to the share page after OAuth. The Authentik button callssignIn("authentik")with no options, so NextAuth will redirect to its default post-sign-in URL (typically/dashboard`) instead of back to the video. A user clicking "Login with Authentik" on a share page will lose their place and the comment dialog will close.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/AuthOverlay.tsx
Line: 247-254

Comment:
**Authentik sign-in in `AuthOverlay` drops the video `callbackUrl`**

The Google handler in the same component sets `callbackUrl: \`${window.location.origin}/s/${videoId}\`` so the user is returned to the share page after OAuth. The Authentik button calls `signIn("authentik")` with no options, so NextAuth will redirect to its default post-sign-in URL (typically `/dashboard`) instead of back to the video. A user clicking "Login with Authentik" on a share page will lose their place and the comment dialog will close.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +245 to +256
{publicEnv.authentikAuthAvailable && (
<Button
variant="gray"
type="button"
className="flex gap-2 justify-center items-center my-1 w-full text-sm"
onClick={() => signIn("authentik")}
disabled={loading}
>
<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
Login with Authentik
</Button>
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Authentik sign-in missing analytics tracking

Every other auth method in this file calls trackEvent("auth_started", ...) before signIn. The Authentik button skips this, creating a gap in auth-surface analytics. It also doesn't set loading or redirect: false, making it inconsistent with the Google handler's pattern.

Suggested change
{publicEnv.authentikAuthAvailable && (
<Button
variant="gray"
type="button"
className="flex gap-2 justify-center items-center my-1 w-full text-sm"
onClick={() => signIn("authentik")}
disabled={loading}
>
<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
Login with Authentik
</Button>
)}
{publicEnv.authentikAuthAvailable && (
<Button
variant="gray"
type="button"
className="flex gap-2 justify-center items-center my-1 w-full text-sm"
onClick={() => {
trackEvent("auth_started", {
method: "authentik",
is_signup: false,
auth_surface: "share_overlay",
video_id: videoId,
});
setLoading(true);
signIn("authentik", {
callbackUrl: `${window.location.origin}/s/${videoId}`,
});
}}
disabled={loading}
>
<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
Login with Authentik
</Button>
)}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/web/app/s/[videoId]/_components/AuthOverlay.tsx
Line: 245-256

Comment:
**Authentik sign-in missing analytics tracking**

Every other auth method in this file calls `trackEvent("auth_started", ...)` before `signIn`. The Authentik button skips this, creating a gap in auth-surface analytics. It also doesn't set `loading` or `redirect: false`, making it inconsistent with the Google handler's pattern.

```suggestion
					{publicEnv.authentikAuthAvailable && (
						<Button
							variant="gray"
							type="button"
							className="flex gap-2 justify-center items-center my-1 w-full text-sm"
							onClick={() => {
								trackEvent("auth_started", {
									method: "authentik",
									is_signup: false,
									auth_surface: "share_overlay",
									video_id: videoId,
								});
								setLoading(true);
								signIn("authentik", {
									callbackUrl: `${window.location.origin}/s/${videoId}`,
								});
							}}
							disabled={loading}
						>
							<FontAwesomeIcon className="size-4" icon={faRightToBracket} />
							Login with Authentik
						</Button>
					)}
```

How can I resolve this? If you propose a fix, please make it concise.

Lynton Infra added 2 commits May 24, 2026 16:57
…s set

Address greptile-apps[bot] P1 review on PR CapSoftware#1869: previously the
`AUTHENTIK_ISSUER` env gated provider registration, but
`AUTHENTIK_CLIENT_ID` and `AUTHENTIK_CLIENT_SECRET` were each
independently optional. A deployer who set the issuer but mistyped a
companion key would silently register `AuthentikProvider` with empty
credentials and only fail at the IdP token-exchange step with a
cryptic error.

Now all three vars are read once, and we throw a clear startup error
if `AUTHENTIK_ISSUER` is set without the companions. Drops the unsafe
`as string` casts that had been masking the type mismatch.

Refs CapSoftware#1869
…thOverlay

Address greptile-apps[bot] P1+P2 review on PR CapSoftware#1869: the share-page
`AuthOverlay` Authentik button called `signIn("authentik")` with no
options, so a user clicking it from a video share page would land on
`/dashboard` instead of returning to the video. It also skipped the
`trackEvent("auth_started", ...)` analytics call and the `setLoading`
state setup that the Google handler in the same component performs.

Replaces the inline onClick with a `handleAuthentikSignIn` handler
that mirrors `handleGoogleSignIn`: tracks the auth_started event with
`auth_surface: "share_overlay"` and `video_id`, sets loading, and
passes `callbackUrl: \`${window.location.origin}/s/${videoId}\`` so the
user returns to the share page after sign-in.

Refs CapSoftware#1869
@stig-codes
Copy link
Copy Markdown
Author

Thanks for the careful review! All three findings addressed in the two new commits on top:

  • P1 — companion-var validation (commit 3132c2e): all three AUTHENTIK_* env vars are now read once and validated together. If AUTHENTIK_ISSUER is set without its companions we throw a clear startup error instead of registering the provider with empty credentials. Drops the unsafe as string casts.
  • P1 — AuthOverlay callbackUrl (commit ba23887): the share-page Authentik button now goes through a handleAuthentikSignIn handler that mirrors handleGoogleSignIn exactly — passes callbackUrl: \${window.location.origin}/s/${videoId}`` so users return to the video share page after sign-in.
  • P2 — analytics + loading state (same commit ba23887): the new handler also calls trackEvent("auth_started", { method: "authentik", auth_surface: "share_overlay", video_id: videoId }) and setLoading(true), matching the Google handler's structure.

Verified end-to-end against an Authentik instance:

  • AUTHENTIK_ISSUER set without companions → startup throws the documented error.
  • All three set → provider registers, signin flow works, share-page sign-in returns the user to the original video.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor:verified Contributor passed trust analysis. pr:verified PR passed security analysis.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SSO Adding to Self-hosting

1 participant