From fc0ea14f86657c3429a7200517c4d967e055f4ac Mon Sep 17 00:00:00 2001 From: 13 Bytes Date: Wed, 11 Mar 2026 22:11:31 +0100 Subject: [PATCH 1/5] Add Keycloak OAuth provider, BUT currently creating different accounts for different providers! --- .env.example | 7 ++- README.md | 1 + .../migration.sql | 2 + prisma/schema.prisma | 27 +++++---- src/env.mjs | 17 ++++-- src/pages/index.tsx | 59 ++++++++++++++----- src/server/auth.ts | 56 ++++++++++++++++-- 7 files changed, 130 insertions(+), 39 deletions(-) create mode 100644 prisma/migrations/20260309225029_add_refresh_expires_in_for_keycloak/migration.sql diff --git a/.env.example b/.env.example index 8002554..50a9283 100644 --- a/.env.example +++ b/.env.example @@ -37,4 +37,9 @@ LDAP_URL="" LDAP_BIND_USER="" LDAP_BIND_PASSWORT="" LDAP_SEARCH_BASE="" -LDAP_ADMIN_GROUP="" \ No newline at end of file +LDAP_ADMIN_GROUP="" + +# Keycloak +KEYCLOAK_ISSUER="" +KEYCLOAK_CLIENT_ID="" +KEYCLOAK_CLIENT_SECRET="" \ No newline at end of file diff --git a/README.md b/README.md index 9408793..6d032f6 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Create DB Migrations (for production) `npx prisma migrate dev` ### Open ToDos +- OAuth-Accounts not the same as LDAP ones - Automatisches abmelden aller Accounts nach update (via changes) - Android Zahlentastatur Komma ausgeblendet #### Nice Improvements: diff --git a/prisma/migrations/20260309225029_add_refresh_expires_in_for_keycloak/migration.sql b/prisma/migrations/20260309225029_add_refresh_expires_in_for_keycloak/migration.sql new file mode 100644 index 0000000..7b118b9 --- /dev/null +++ b/prisma/migrations/20260309225029_add_refresh_expires_in_for_keycloak/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Account" ADD COLUMN "refresh_expires_in" INTEGER; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b5443f4..7b1ced8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -151,19 +151,20 @@ model Transaction { // ----------------------------------------------------------------------------- // Necessary for Next auth model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? // @db.Text - access_token String? // @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? // @db.Text - session_state String? - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + userId String + type String + provider String + providerAccountId String + refresh_token String? // @db.Text + refresh_expires_in Int? + access_token String? // @db.Text + expires_at Int? + token_type String? + scope String? + id_token String? // @db.Text + session_state String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@unique([provider, providerAccountId]) } diff --git a/src/env.mjs b/src/env.mjs index 295c340..c544b4a 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -14,7 +14,7 @@ const server = z.object({ // Since NextAuth.js automatically uses the VERCEL_URL if present. (str) => process.env.VERCEL_URL ?? str, // VERCEL_URL doesn't include `https` so it cant be validated as a URL - process.env.VERCEL ? z.string().min(1) : z.string().url() + process.env.VERCEL ? z.string().min(1) : z.string().url(), ), DISABLE_PROCUREMENT_ACCOUNT_BACKING_CHECK: z.string().default("false"), @@ -25,6 +25,10 @@ const server = z.object({ LDAP_SEARCH_BASE: z.string(), LDAP_ADMIN_GROUP: z.string(), + KEYCLOAK_ISSUER: z.string().url().optional(), + KEYCLOAK_CLIENT_ID: z.string().optional(), + KEYCLOAK_CLIENT_SECRET: z.string().optional(), + // Add `.min(1) on ID and SECRET if you want to make sure they're not empty EMAIL_SERVER_USER: z.string().optional(), EMAIL_SERVER_PASSWORD: z.string().optional(), @@ -48,7 +52,7 @@ const client = z.object({ * * @type {Record | keyof z.infer, string | undefined>} */ -// const envProvider = process.env +// const envProvider = process.env const processEnv = { DATABASE_URL: process.env.DATABASE_URL, NODE_ENV: process.env.NODE_ENV, @@ -56,9 +60,12 @@ const processEnv = { NEXTAUTH_URL: process.env.NEXTAUTH_URL, LDAP_URL: process.env.LDAP_URL, LDAP_BIND_USER: process.env.LDAP_BIND_USER, - LDAP_BIND_PASSWORT:process.env.LDAP_BIND_PASSWORT, - LDAP_SEARCH_BASE:process.env.LDAP_SEARCH_BASE, - LDAP_ADMIN_GROUP:process.env.LDAP_ADMIN_GROUP, + LDAP_BIND_PASSWORT: process.env.LDAP_BIND_PASSWORT, + LDAP_SEARCH_BASE: process.env.LDAP_SEARCH_BASE, + LDAP_ADMIN_GROUP: process.env.LDAP_ADMIN_GROUP, + KEYCLOAK_ISSUER: process.env.KEYCLOAK_ISSUER, + KEYCLOAK_CLIENT_ID: process.env.KEYCLOAK_CLIENT_ID, + KEYCLOAK_CLIENT_SECRET: process.env.KEYCLOAK_CLIENT_SECRET, DISABLE_PROCUREMENT_ACCOUNT_BACKING_CHECK: process.env.DISABLE_PROCUREMENT_ACCOUNT_BACKING_CHECK, EMAIL_SERVER_USER: process.env.EMAIL_SERVER_USER, EMAIL_SERVER_PASSWORD: process.env.EMAIL_SERVER_PASSWORD, diff --git a/src/pages/index.tsx b/src/pages/index.tsx index ad0ebd9..7b33b8e 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,11 +1,10 @@ import { CheckCircle, Github, Info, XCircle } from "lucide-react" import { type GetServerSidePropsContext, type InferGetServerSidePropsType, type NextPage } from "next" -import { getProviders, signIn } from "next-auth/react" +import { signIn } from "next-auth/react" import { useRouter } from "next/router" import { useEffect, useState } from "react" import { useForm, type SubmitHandler } from "react-hook-form" import CenteredPage from "~/components/Layout/CenteredPage" -import { getServerAuthSession } from "~/server/auth" // NextAuth error messages (https://next-auth.js.org/configuration/pages) // get thrown as query parameters in the URL @@ -31,13 +30,15 @@ type FormData = { type HomeProps = InferGetServerSidePropsType & { isProduction: boolean + keycloakEnabled: boolean + ldapEnabled: boolean } -const Home: NextPage = ({ providers, isProduction }) => { +const Home: NextPage = ({ isProduction, keycloakEnabled, ldapEnabled }) => { const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm() const [error, setError] = useState(null) const [success, setSuccess] = useState(null) - const [activeTab, setActiveTab] = useState<"credentials" | "email">("credentials") + const [activeTab, setActiveTab] = useState<"credentials" | "email" | "keycloak">("credentials") const router = useRouter() // Handle NextAuth errors from query parameters const authError = router.query.error const queryErrorMessage = @@ -50,7 +51,8 @@ const Home: NextPage = ({ providers, isProduction }) => { useEffect(() => { if (authError && typeof authError === "string") { // Clear the error from URL to prevent it from persisting - const { error: _, ...restQuery } = router.query + const restQuery = { ...router.query } + delete restQuery.error void router.replace({ pathname: router.pathname, query: restQuery }, undefined, { shallow: true }) } }, [authError, router]) @@ -66,7 +68,7 @@ const Home: NextPage = ({ providers, isProduction }) => { redirect: false, }) - if(result?.ok) { + if (result?.ok) { void router.push("/buy") } else if (result?.error) { @@ -97,7 +99,7 @@ const Home: NextPage = ({ providers, isProduction }) => { } else if (result?.ok) { setSuccess("Ein Magic Link wurde in der Serverkonsole generiert.") } - } catch (err) { + } catch { setError(nextAuthErrorMessages.default) } } @@ -120,22 +122,32 @@ const Home: NextPage = ({ providers, isProduction }) => {
{/* Tab Navigation (only show if both methods are available) */} - {isDevelopment && ( -
+
+ {ldapEnabled && ( + )} + {keycloakEnabled && ( + + )} + {isDevelopment && ( -
- )} + )} +
{/* Error/Success Messages */} {visibleError && ( @@ -205,7 +217,7 @@ const Home: NextPage = ({ providers, isProduction }) => { )} {/* Magic Link Login Form (Development only) */} - {isDevelopment && activeTab === "email" && ( + {activeTab === "email" && (
@@ -249,6 +261,22 @@ const Home: NextPage = ({ providers, isProduction }) => { )} + {activeTab === "keycloak" && ( +
+ +
+ )} + {/* Info Section */}
@@ -283,8 +311,9 @@ const Home: NextPage = ({ providers, isProduction }) => { export async function getServerSideProps(context: GetServerSidePropsContext) { const { req, res } = context; // Get the session from the server using the getServerSession wrapper function - const session = await getServerAuthSession({ req, res }); + const { getServerAuthSession, keycloakEnabled, ldapEnabled } = await import("~/server/auth") + const session = await getServerAuthSession({ req, res }); // If user is already logged in, redirect if (session) { return { @@ -295,11 +324,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { } } - const providers = await getProviders() return { props: { - providers: providers ?? {}, isProduction: process.env.NODE_ENV === "production", + keycloakEnabled, + ldapEnabled, }, } } diff --git a/src/server/auth.ts b/src/server/auth.ts index bb95873..27cd0b8 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -8,9 +8,21 @@ import { } from "next-auth" import CredentialsProvider from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/email" +import KeycloakProvider from "next-auth/providers/keycloak" import { env } from "~/env.mjs" import { prisma } from "~/server/db" import { manageLdapLogin } from "./ldap" +import { Adapter, AdapterAccount } from "next-auth/adapters" + +export const keycloakEnabled = + !!env.KEYCLOAK_ISSUER && + !!env.KEYCLOAK_CLIENT_ID && + !!env.KEYCLOAK_CLIENT_SECRET + +export const ldapEnabled = + !!env.LDAP_URL && + !!env.LDAP_BIND_USER && + !!env.LDAP_BIND_PASSWORT /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` @@ -40,16 +52,38 @@ declare module "next-auth/jwt" { } } +/** IMPORTANT FOR INTEGRATING KEYCLOAK */ +const prismaAdapter = PrismaAdapter(prisma) as Adapter +const extendedPrismaAdapter: Adapter = { + ...prismaAdapter, + async linkAccount(account: AdapterAccount) { + if (!prismaAdapter.linkAccount) + throw new Error("NextAuth: prismaAdapter.linkAccount not implemented"); + + // Keycloak returns incompatible data with the nextjs-auth schema + // (refresh_expires_in and not-before-policy). + // refresh_expires_in was added to the schema, but not-before-policy is not compatible with Prisma's field naming conventions. + // So, we need to remove this data from the payload before linking an account. + // https://github.com/nextauthjs/next-auth/issues/7655 + if (account.provider === "keycloak") { + delete account["not-before-policy"]; + } + + await prismaAdapter.linkAccount(account); + }, +} + /** * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. * * @see https://next-auth.js.org/configuration/options */ export const authOptions: NextAuthOptions = { - adapter: PrismaAdapter(prisma), + adapter: extendedPrismaAdapter, session: { strategy: "jwt", - }, pages: { + }, + pages: { signIn: "/", }, callbacks: { @@ -69,19 +103,31 @@ export const authOptions: NextAuthOptions = { }, }, providers: [ - CredentialsProvider({ + ...(ldapEnabled ? [ + CredentialsProvider({ name: "ASL-Account", credentials: { username: { label: "ASL-Username", type: "text", placeholder: "sally.ride" }, password: { label: "Password", type: "password", placeholder: "sUper $ecr3t" }, }, - async authorize(credentials, req) { + async authorize(credentials, _req) { if (!credentials || credentials.username.length <= 1 || credentials.password.length <= 1) { return null } return manageLdapLogin(credentials?.username, credentials?.password) }, - }), + }) + ] : []), + ...(keycloakEnabled + ? [ + KeycloakProvider({ + clientId: env.KEYCLOAK_CLIENT_ID!, + clientSecret: env.KEYCLOAK_CLIENT_SECRET!, + issuer: env.KEYCLOAK_ISSUER!, + allowDangerousEmailAccountLinking: true, + }), + ] + : []), ...(env.NODE_ENV === "development" ? [ EmailProvider({ From 8e06cf1acc09e29bc8d720bd595639701f9df9ee Mon Sep 17 00:00:00 2001 From: 13 Bytes Date: Sun, 15 Mar 2026 22:35:37 +0100 Subject: [PATCH 2/5] Refactor authentication tabs and improve Keycloak profile handling --- README.md | 1 - src/pages/index.tsx | 20 ++++++------ src/server/auth.ts | 75 +++++++++++++++++++++++++++------------------ 3 files changed, 56 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 6d032f6..9408793 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ Create DB Migrations (for production) `npx prisma migrate dev` ### Open ToDos -- OAuth-Accounts not the same as LDAP ones - Automatisches abmelden aller Accounts nach update (via changes) - Android Zahlentastatur Komma ausgeblendet #### Nice Improvements: diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 7b33b8e..6910b76 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -38,7 +38,7 @@ const Home: NextPage = ({ isProduction, keycloakEnabled, ldapEnabled const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm() const [error, setError] = useState(null) const [success, setSuccess] = useState(null) - const [activeTab, setActiveTab] = useState<"credentials" | "email" | "keycloak">("credentials") + const [activeTab, setActiveTab] = useState<"credentials" | "email" | "keycloak">("keycloak") const router = useRouter() // Handle NextAuth errors from query parameters const authError = router.query.error const queryErrorMessage = @@ -123,14 +123,6 @@ const Home: NextPage = ({ isProduction, keycloakEnabled, ldapEnabled
{/* Tab Navigation (only show if both methods are available) */}
- {ldapEnabled && ( - - )} {keycloakEnabled && ( )} + {ldapEnabled && ( + + )} {isDevelopment && (
)} diff --git a/src/server/auth.ts b/src/server/auth.ts index 27cd0b8..7c6cf5a 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -5,14 +5,15 @@ import { type DefaultSession, type DefaultUser, type NextAuthOptions, + type User, } from "next-auth" +import type { Adapter, AdapterAccount } from "next-auth/adapters" import CredentialsProvider from "next-auth/providers/credentials" import EmailProvider from "next-auth/providers/email" import KeycloakProvider from "next-auth/providers/keycloak" import { env } from "~/env.mjs" import { prisma } from "~/server/db" import { manageLdapLogin } from "./ldap" -import { Adapter, AdapterAccount } from "next-auth/adapters" export const keycloakEnabled = !!env.KEYCLOAK_ISSUER && @@ -105,17 +106,17 @@ export const authOptions: NextAuthOptions = { providers: [ ...(ldapEnabled ? [ CredentialsProvider({ - name: "ASL-Account", - credentials: { - username: { label: "ASL-Username", type: "text", placeholder: "sally.ride" }, - password: { label: "Password", type: "password", placeholder: "sUper $ecr3t" }, - }, + name: "ASL-Account", + credentials: { + username: { label: "ASL-Username", type: "text", placeholder: "sally.ride" }, + password: { label: "Password", type: "password", placeholder: "sUper $ecr3t" }, + }, async authorize(credentials, _req) { - if (!credentials || credentials.username.length <= 1 || credentials.password.length <= 1) { - return null - } - return manageLdapLogin(credentials?.username, credentials?.password) - }, + if (!credentials || credentials.username.length <= 1 || credentials.password.length <= 1) { + return null + } + return manageLdapLogin(credentials?.username, credentials?.password) + }, }) ] : []), ...(keycloakEnabled @@ -124,31 +125,47 @@ export const authOptions: NextAuthOptions = { clientId: env.KEYCLOAK_CLIENT_ID!, clientSecret: env.KEYCLOAK_CLIENT_SECRET!, issuer: env.KEYCLOAK_ISSUER!, - allowDangerousEmailAccountLinking: true, + profile(profile) { + const uidNumber = String(profile.uidNumber) + const isAdmin = Array.isArray(profile.groups) && + profile.groups.some( + (group) => typeof group === "string" && group.toUpperCase() === "LABEATS_ADMIN", + ) + const user = { + id: uidNumber ?? String(profile.sub), + name: profile.name ?? profile.preferred_username ?? null, + email: profile.email, + image: null, + is_admin: isAdmin, + } as User + console.log("Keycloak profile:", JSON.stringify(profile)) + return user + }, + // allowDangerousEmailAccountLinking: true, }), ] : []), ...(env.NODE_ENV === "development" ? [ - EmailProvider({ - server: { - host: env.EMAIL_SERVER_HOST, - port: env.EMAIL_SERVER_PORT, - auth: { - user: env.EMAIL_SERVER_USER, - pass: env.EMAIL_SERVER_PASSWORD, - }, + EmailProvider({ + server: { + host: env.EMAIL_SERVER_HOST, + port: env.EMAIL_SERVER_PORT, + auth: { + user: env.EMAIL_SERVER_USER, + pass: env.EMAIL_SERVER_PASSWORD, + }, + }, + ...(env.EMAIL_DEV_PRINT_TOKEN === "true" && env.NODE_ENV === "development" && { + sendVerificationRequest(params) { + console.log("\n", "=".repeat(40)) + console.log(`🔗 Verification URL: ${params.url}`) + console.log("=".repeat(40), "\n") }, - ...(env.EMAIL_DEV_PRINT_TOKEN === "true" && env.NODE_ENV === "development" && { - sendVerificationRequest(params) { - console.log("\n", "=".repeat(40)) - console.log(`🔗 Verification URL: ${params.url}`) - console.log("=".repeat(40), "\n") - }, - }), - from: env.EMAIL_FROM, }), - ] + from: env.EMAIL_FROM, + }), + ] : []), ], } From e3a1cc4662c75d1a9cd7dcdf6ed1becdc403f6c8 Mon Sep 17 00:00:00 2001 From: Louis <12069002+13Bytes@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:43:50 +0100 Subject: [PATCH 3/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/server/auth.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index 7c6cf5a..11d39be 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -59,7 +59,7 @@ const extendedPrismaAdapter: Adapter = { ...prismaAdapter, async linkAccount(account: AdapterAccount) { if (!prismaAdapter.linkAccount) - throw new Error("NextAuth: prismaAdapter.linkAccount not implemented"); + throw new Error("NextAuth: prismaAdapter.linkAccount not implemented") // Keycloak returns incompatible data with the nextjs-auth schema // (refresh_expires_in and not-before-policy). @@ -67,10 +67,10 @@ const extendedPrismaAdapter: Adapter = { // So, we need to remove this data from the payload before linking an account. // https://github.com/nextauthjs/next-auth/issues/7655 if (account.provider === "keycloak") { - delete account["not-before-policy"]; + delete account["not-before-policy"] } - await prismaAdapter.linkAccount(account); + await prismaAdapter.linkAccount(account) }, } From ed552081a6698d75dee803df73f89fcc01f6f339 Mon Sep 17 00:00:00 2001 From: 13 Bytes Date: Sun, 15 Mar 2026 22:51:07 +0100 Subject: [PATCH 4/5] Make LDAP optional and fix some errors --- src/env.mjs | 10 +++++----- src/pages/index.tsx | 6 +++--- src/server/auth.ts | 5 ++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/env.mjs b/src/env.mjs index c544b4a..6c7986a 100644 --- a/src/env.mjs +++ b/src/env.mjs @@ -19,11 +19,11 @@ const server = z.object({ DISABLE_PROCUREMENT_ACCOUNT_BACKING_CHECK: z.string().default("false"), - LDAP_URL: z.string(), - LDAP_BIND_USER: z.string(), - LDAP_BIND_PASSWORT: z.string(), - LDAP_SEARCH_BASE: z.string(), - LDAP_ADMIN_GROUP: z.string(), + LDAP_URL: z.string().optional(), + LDAP_BIND_USER: z.string().optional(), + LDAP_BIND_PASSWORT: z.string().optional(), + LDAP_SEARCH_BASE: z.string().optional(), + LDAP_ADMIN_GROUP: z.string().optional(), KEYCLOAK_ISSUER: z.string().url().optional(), KEYCLOAK_CLIENT_ID: z.string().optional(), diff --git a/src/pages/index.tsx b/src/pages/index.tsx index 6910b76..e388629 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -38,7 +38,7 @@ const Home: NextPage = ({ isProduction, keycloakEnabled, ldapEnabled const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm() const [error, setError] = useState(null) const [success, setSuccess] = useState(null) - const [activeTab, setActiveTab] = useState<"credentials" | "email" | "keycloak">("keycloak") + const [activeTab, setActiveTab] = useState<"credentials" | "email" | "keycloak">(keycloakEnabled ? "keycloak" : "credentials") const router = useRouter() // Handle NextAuth errors from query parameters const authError = router.query.error const queryErrorMessage = @@ -164,7 +164,7 @@ const Home: NextPage = ({ isProduction, keycloakEnabled, ldapEnabled )} {/* LDAP/Credentials Login Form */} - {(activeTab === "credentials" || isProduction) && ( + {activeTab === "credentials" && (