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) {