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