From 91d1c8318438269d8ea20a155863a68286e94251 Mon Sep 17 00:00:00 2001 From: Diego Garcia <44004958+diegog@users.noreply.github.com> Date: Tue, 28 Jul 2026 01:26:09 -0600 Subject: [PATCH] feat(dashboard): add generic OIDC provider for self-host SSO (#2423) * feat(dashboard): add generic OIDC provider for self-host SSO Allow any OIDC identity provider (Okta, Auth0, Entra ID, Keycloak, ...) to be configured via env: AUTH_OIDC_ISSUER enables the provider and login button, AUTH_OIDC_ID/SECRET configure the client, and AUTH_OIDC_NAME customizes the button label (default "SSO"). Sign-in syncs name and photo from standard OIDC claims, mirroring the Google provider. The callback id is fixed at "oidc" so the redirect URI stays stable regardless of IdP. * ci: apply automated fixes * fix(dashboard): oidc provider review feedback drop email-linking for arbitrary IdPs, extract shared profile-sync helper, derive first/last name from the name claim when split claims are absent. --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- .env.docker.example | 7 ++ apps/dashboard/.env.example | 4 + .../app/login/_components/login-button.tsx | 2 +- apps/dashboard/src/app/login/page.tsx | 13 +++ apps/dashboard/src/lib/auth/index.ts | 89 +++++++++++++------ apps/dashboard/src/lib/auth/providers.ts | 11 +++ 6 files changed, 97 insertions(+), 29 deletions(-) diff --git a/.env.docker.example b/.env.docker.example index d146a300..bef5b3d5 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -69,6 +69,13 @@ AUTH_GITHUB_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= +# Generic OIDC SSO (optional) - works with any OIDC provider (Okta, Auth0, Entra ID, Keycloak, ...) +# Setting ISSUER enables the SSO login button; NAME customizes the button label (default: "SSO") +AUTH_OIDC_ISSUER= +AUTH_OIDC_ID= +AUTH_OIDC_SECRET= +AUTH_OIDC_NAME= + # GOOGLE CLOUD # ============================================================================ # Google Cloud Platform (optional - for scheduled tasks) diff --git a/apps/dashboard/.env.example b/apps/dashboard/.env.example index ba4253dd..538f9497 100644 --- a/apps/dashboard/.env.example +++ b/apps/dashboard/.env.example @@ -65,6 +65,10 @@ AUTH_GITHUB_ID= AUTH_GITHUB_SECRET= AUTH_GOOGLE_ID= AUTH_GOOGLE_SECRET= +AUTH_OIDC_ISSUER= +AUTH_OIDC_ID= +AUTH_OIDC_SECRET= +AUTH_OIDC_NAME= PAGERDUTY_APP_ID= diff --git a/apps/dashboard/src/app/login/_components/login-button.tsx b/apps/dashboard/src/app/login/_components/login-button.tsx index 2d3a213b..01f55753 100644 --- a/apps/dashboard/src/app/login/_components/login-button.tsx +++ b/apps/dashboard/src/app/login/_components/login-button.tsx @@ -7,7 +7,7 @@ import { useEffect, useState } from "react"; const STORAGE_KEY = "openstatus:last-login-provider"; -type Provider = "github" | "google" | "email"; +type Provider = "github" | "google" | "oidc" | "email"; export function LoginButton({ provider, diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx index e443923a..1a8f0fce 100644 --- a/apps/dashboard/src/app/login/page.tsx +++ b/apps/dashboard/src/app/login/page.tsx @@ -68,6 +68,19 @@ export default async function Page(props: { Sign in with Google + {process.env.AUTH_OIDC_ISSUER ? ( +
{ + "use server"; + await signIn("oidc", { redirectTo: redirectTo ?? undefined }); + }} + className="w-full" + > + + Sign in with {process.env.AUTH_OIDC_NAME ?? "SSO"} + +
+ ) : null}

By clicking continue, you agree to our{" "} diff --git a/apps/dashboard/src/lib/auth/index.ts b/apps/dashboard/src/lib/auth/index.ts index a34f1bbb..8a46f4f2 100644 --- a/apps/dashboard/src/lib/auth/index.ts +++ b/apps/dashboard/src/lib/auth/index.ts @@ -7,17 +7,43 @@ import NextAuth from "next-auth"; import { headers } from "next/headers"; import { adapter } from "./adapter"; -import { GitHubProvider, GoogleProvider, ResendProvider } from "./providers"; +import { + GitHubProvider, + GoogleProvider, + OIDCProvider, + ResendProvider, +} from "./providers"; export type { DefaultSession }; +async function syncUser( + userId: number, + fields: { + firstName?: string | null; + lastName?: string | null; + photoUrl?: string | null; + name?: string | null; + }, +) { + await db + .update(user) + .set({ ...fields, updatedAt: new Date() }) + .where(eq(user.id, userId)) + .run(); +} + export const { handlers, signIn, signOut, auth } = NextAuth({ // debug: true, adapter, - providers: - process.env.NODE_ENV === "development" || process.env.SELF_HOST === "true" - ? [GitHubProvider, GoogleProvider, ResendProvider] - : [GitHubProvider, GoogleProvider], + providers: [ + GitHubProvider, + GoogleProvider, + ...(process.env.AUTH_OIDC_ISSUER ? [OIDCProvider] : []), + ...(process.env.NODE_ENV === "development" || + process.env.SELF_HOST === "true" + ? [ResendProvider] + : []), + ], callbacks: { async redirect({ url, baseUrl }) { // Allow relative URLs, but not protocol-relative `//evil.com` which the @@ -44,34 +70,41 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; - await db - .update(user) - .set({ - firstName: params.profile.given_name, - lastName: params.profile.family_name || "", - photoUrl: params.profile.picture, - // keep the name in sync - name: `${params.profile.given_name} ${ - params.profile.family_name || "" - }`.trim(), - updatedAt: new Date(), - }) - .where(eq(user.id, Number(params.user.id))) - .run(); + await syncUser(Number(params.user.id), { + firstName: params.profile.given_name, + lastName: params.profile.family_name || "", + photoUrl: params.profile.picture, + // keep the name in sync + name: `${params.profile.given_name} ${ + params.profile.family_name || "" + }`.trim(), + }); } if (params.account?.provider === "github") { if (!params.profile) return true; if (Number.isNaN(Number(params.user.id))) return true; - await db - .update(user) - .set({ - name: params.profile.name, - photoUrl: String(params.profile.avatar_url), - updatedAt: new Date(), - }) - .where(eq(user.id, Number(params.user.id))) - .run(); + await syncUser(Number(params.user.id), { + name: params.profile.name, + photoUrl: String(params.profile.avatar_url), + }); + } + + if (params.account?.provider === "oidc") { + if (!params.profile) return true; + if (Number.isNaN(Number(params.user.id))) return true; + + const { given_name, family_name, name, picture } = params.profile; + // some IdPs only send a combined `name` claim + const [nameFirst, ...nameRest] = name?.split(" ") ?? []; + const fullName = [given_name, family_name].filter(Boolean).join(" "); + + await syncUser(Number(params.user.id), { + firstName: given_name ?? nameFirst, + lastName: family_name || nameRest.join(" "), + photoUrl: picture, + name: fullName || name, + }); } // REMINDER: only used in dev mode diff --git a/apps/dashboard/src/lib/auth/providers.ts b/apps/dashboard/src/lib/auth/providers.ts index c990eaf6..7ad88a9f 100644 --- a/apps/dashboard/src/lib/auth/providers.ts +++ b/apps/dashboard/src/lib/auth/providers.ts @@ -1,3 +1,5 @@ +import type { Profile } from "next-auth"; +import type { OIDCConfig } from "next-auth/providers"; import GitHub from "next-auth/providers/github"; import Google from "next-auth/providers/google"; import Resend from "next-auth/providers/resend"; @@ -18,6 +20,15 @@ export const GoogleProvider = Google({ }, }); +export const OIDCProvider: OIDCConfig = { + id: "oidc", + name: process.env.AUTH_OIDC_NAME ?? "SSO", + type: "oidc", + issuer: process.env.AUTH_OIDC_ISSUER, + clientId: process.env.AUTH_OIDC_ID, + clientSecret: process.env.AUTH_OIDC_SECRET, +}; + export const ResendProvider = Resend({ apiKey: undefined, // REMINDER: keep undefined to avoid sending emails async sendVerificationRequest(params) { -- 2.51.2