From c2887732f13c2d6da3f1b4db90279a2bb6cd2464 Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 12 Mar 2026 14:10:09 +0100 Subject: [PATCH 1/5] feat: add health check API endpoint Add /api/health endpoint returning JSON status for Kubernetes liveness and readiness probes in standalone deployment mode. Co-Authored-By: Claude Opus 4.6 --- app/api/health/route.ts | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 app/api/health/route.ts diff --git a/app/api/health/route.ts b/app/api/health/route.ts new file mode 100644 index 0000000..e0e63aa --- /dev/null +++ b/app/api/health/route.ts @@ -0,0 +1,3 @@ +export async function GET() { + return Response.json({ status: "ok" }); +} From 1b1a27e55a1cdf19002f5ceda2605a0f8705857c Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 12 Mar 2026 14:10:15 +0100 Subject: [PATCH 2/5] build: support NEXT_PUBLIC env vars and prisma generate at build time - Add ARG/ENV for NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD, NEXT_PUBLIC_OIDC_PROVIDER_ID, and NEXT_PUBLIC_BETTER_AUTH_URL so Next.js can inline them during the build stage - Run prisma generate before next build to ensure the Prisma client is up-to-date with the schema Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 8876606..5cad5a1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,18 @@ FROM base AS builder # Set environment variables for build (moved earlier for better caching) ENV NEXT_TELEMETRY_DISABLED=1 +# NEXT_PUBLIC_ vars must be present at build time for Next.js to inline them +ARG NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD +ARG NEXT_PUBLIC_OIDC_PROVIDER_ID +ARG NEXT_PUBLIC_BETTER_AUTH_URL +ENV NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD=${NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD} +ENV NEXT_PUBLIC_OIDC_PROVIDER_ID=${NEXT_PUBLIC_OIDC_PROVIDER_ID} +ENV NEXT_PUBLIC_BETTER_AUTH_URL=${NEXT_PUBLIC_BETTER_AUTH_URL} + COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN pnpm build +RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" pnpm prisma generate && pnpm build # Production image FROM node:24.10.0-alpine AS production From 921cc4f4a0b3821e167ed9af7f0973916132c3ed Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 12 Mar 2026 14:10:23 +0100 Subject: [PATCH 3/5] feat(auth): add OIDC support with split endpoints and Minecraft UUID - Support explicit OIDC_AUTHORIZATION_URL and OIDC_TOKEN_URL as alternative to OIDC_DISCOVERY_URL for split-horizon DNS setups (external auth URL for browser, internal token URL for server) - Make email/password login configurable via DISABLE_EMAIL_PASSWORD - Add preferred_username fallback for email/name fields to support Minecraft-linked accounts without a traditional email - Extract and persist minecraft_uuid from OIDC token claims to the user table (new minecraftUuid field in Prisma schema) - Simplify mapProfileToUser by consolidating duplicate update paths Co-Authored-By: Claude Opus 4.6 --- lib/auth.ts | 123 +++++++++++++++++++------------------------ prisma/schema.prisma | 1 + 2 files changed, 54 insertions(+), 70 deletions(-) diff --git a/lib/auth.ts b/lib/auth.ts index c52a7f9..8e6013e 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -158,23 +158,11 @@ function getOidcConfig() { const clientId = process.env.OIDC_CLIENT_ID; const clientSecret = process.env.OIDC_CLIENT_SECRET; const discoveryUrl = process.env.OIDC_DISCOVERY_URL; - if (!providerId || !clientId || !clientSecret || !discoveryUrl) { - return null; - } - if (discoveryUrl.trim() === "") { - return null; - } - try { - const url = new URL(discoveryUrl); - if (url.protocol !== "https:") { - console.error("[Better Auth] OIDC_DISCOVERY_URL must use HTTPS protocol"); - return null; - } - } catch (error) { - console.error( - "[Better Auth] OIDC_DISCOVERY_URL is not a valid URL:", - discoveryUrl - ); + const authorizationUrl = process.env.OIDC_AUTHORIZATION_URL; + const tokenUrl = process.env.OIDC_TOKEN_URL; + + // Either discoveryUrl or explicit endpoints are required + if (!providerId || !clientId || !clientSecret || (!discoveryUrl && !authorizationUrl)) { return null; } const scopes = process.env.OIDC_SCOPES?.split(",").map((s) => s.trim()) || [ @@ -191,7 +179,9 @@ function getOidcConfig() { providerId, clientId, clientSecret, - discoveryUrl, + discoveryUrl: discoveryUrl || undefined, + authorizationUrl: authorizationUrl || undefined, + tokenUrl: tokenUrl || undefined, scopes, rolesClaim, roleMapping, @@ -210,7 +200,7 @@ export const auth = betterAuth({ }) : undefined, emailAndPassword: { - enabled: true, + enabled: process.env.DISABLE_EMAIL_PASSWORD !== "true", disableSignUp: true, password: { hash: hashPassword, @@ -267,7 +257,9 @@ export const auth = betterAuth({ providerId: oidcConfig.providerId, clientId: oidcConfig.clientId, clientSecret: oidcConfig.clientSecret, - discoveryUrl: oidcConfig.discoveryUrl, + ...(oidcConfig.discoveryUrl ? { discoveryUrl: oidcConfig.discoveryUrl } : {}), + ...(oidcConfig.authorizationUrl ? { authorizationUrl: oidcConfig.authorizationUrl } : {}), + ...(oidcConfig.tokenUrl ? { tokenUrl: oidcConfig.tokenUrl } : {}), scopes: oidcConfig.scopes, overrideUserInfo: false, getUserInfo: async (tokens) => { @@ -291,16 +283,22 @@ export const auth = betterAuth({ const idToken = tokens.idToken; const decodedIdToken = idToken ? decodeJwtToken(idToken) : null; + const email = decodedIdToken?.email || decodedAccessToken?.email || decodedAccessToken?.preferred_username || decodedIdToken?.preferred_username; + const name = decodedIdToken?.name || decodedAccessToken?.name || decodedAccessToken?.preferred_username; + + const minecraftUuid = decodedAccessToken?.minecraft_uuid || decodedIdToken?.minecraft_uuid; + // User-Info zurückgeben mit Rollen return { id: decodedIdToken?.sub || decodedAccessToken?.sub, - email: decodedIdToken?.email || decodedAccessToken?.email, - name: decodedIdToken?.name || decodedAccessToken?.name, + email, + name, emailVerified: decodedIdToken?.email_verified || - decodedAccessToken?.email_verified, - // Rollen als Custom-Feld hinzufügen, damit sie in mapProfileToUser verfügbar sind + decodedAccessToken?.email_verified || + true, _roles: roles, + _minecraftUuid: minecraftUuid, }; }, mapProfileToUser: async (profile) => { @@ -310,18 +308,20 @@ export const auth = betterAuth({ throw new Error("Email is required for OIDC authentication"); } - // Rollen aus profile extrahieren (wurden in getUserInfo gesetzt) + // Extract roles from profile (set in getUserInfo) let oidcRoles: string[] = []; if (profile._roles && Array.isArray(profile._roles)) { oidcRoles = profile._roles; } else { - // Fallback: Versuche Rollen aus ID Token zu extrahieren oidcRoles = extractRolesFromToken( profile, oidcConfig.rolesClaim ); } + // Extract Minecraft UUID from profile (set in getUserInfo) + const minecraftUuid = profile._minecraftUuid as string | undefined; + // Prüfe, ob bereits ein User mit dieser Email existiert const existingUser = await prisma.user.findUnique({ where: { email }, @@ -348,60 +348,43 @@ export const auth = betterAuth({ acc.providerId === oidcConfig.providerId ); - if (hasOidcAccount) { - // Bestehendes OIDC-Account: Setze Rolle direkt in DB, falls sie überschrieben wurde - // Setze Rolle direkt in DB, um sicherzustellen, dass sie nicht überschrieben wird - await prisma.user - .update({ - where: { id: existingUser.id }, - data: { role: cockpitRole }, - }) - .catch(() => { - // Error updating role, continue anyway - }); - const returnValue = { - role: cockpitRole, - } as any; - return returnValue; - } else { - // Erstmaliges OIDC-Account Linking: Setze Rolle direkt in DB - // Setze Rolle direkt in DB, um sicherzustellen, dass sie nicht überschrieben wird - await prisma.user - .update({ - where: { id: existingUser.id }, - data: { role: cockpitRole }, - }) - .catch(() => { - // Error updating role, continue anyway - }); - const returnValue = { - role: cockpitRole, - } as any; - return returnValue; - } + // Update role and minecraftUuid in DB + await prisma.user + .update({ + where: { id: existingUser.id }, + data: { + role: cockpitRole, + ...(minecraftUuid ? { minecraftUuid } : {}), + }, + }) + .catch(() => {}); + return { role: cockpitRole } as any; } else { - // Neuer User: Verwende OIDC-Rollen - const returnValue = { - role: cockpitRole, - } as any; - // Für neue User wird die Rolle von Better Auth gesetzt, aber wir fügen einen Post-Processing-Schritt hinzu - // Der User wird nach dem Erstellen aktualisiert, falls die Rolle überschrieben wurde + // New user: post-process to ensure role and minecraftUuid are set setTimeout(async () => { try { const createdUser = await prisma.user.findUnique({ where: { email }, }); - if (createdUser && createdUser.role !== cockpitRole) { - await prisma.user.update({ - where: { id: createdUser.id }, - data: { role: cockpitRole }, - }); + if (createdUser) { + const needsUpdate = + createdUser.role !== cockpitRole || + (minecraftUuid && createdUser.minecraftUuid !== minecraftUuid); + if (needsUpdate) { + await prisma.user.update({ + where: { id: createdUser.id }, + data: { + role: cockpitRole, + ...(minecraftUuid ? { minecraftUuid } : {}), + }, + }); + } } } catch (err) { - // Error in post-processing, continue anyway + // Post-processing failed, continue anyway } }, 1000); - return returnValue; + return { role: cockpitRole } as any; } }, }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 88f3a45..f2d16ac 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -21,6 +21,7 @@ model User { createdAt DateTime @default(now()) @map("createdAt") updatedAt DateTime @updatedAt @map("updatedAt") role String? + minecraftUuid String? @unique @map("minecraftUuid") banned Boolean? banReason String? @map("banReason") banExpires DateTime? @map("banExpires") From ad713eecf73e41c8265fdd85c66c00f86bfc03fa Mon Sep 17 00:00:00 2001 From: Hendrik Brombeer Date: Thu, 12 Mar 2026 14:10:34 +0100 Subject: [PATCH 4/5] feat(auth): hide email/password login when OIDC-only mode enabled Conditionally render the email/password form and divider based on NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD. When set to "true", only the OIDC provider button is shown on the login page. Co-Authored-By: Claude Opus 4.6 --- app/[lang]/login/login-client.tsx | 116 ++++++++++++++++-------------- 1 file changed, 62 insertions(+), 54 deletions(-) diff --git a/app/[lang]/login/login-client.tsx b/app/[lang]/login/login-client.tsx index 76e8486..15acdcd 100644 --- a/app/[lang]/login/login-client.tsx +++ b/app/[lang]/login/login-client.tsx @@ -94,66 +94,74 @@ export function LoginPageClient() { {dict.title} -
-
- - setEmail(e.target.value)} - required - disabled={isLoading} - suppressHydrationWarning + {process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD !== "true" && ( + +
+ + setEmail(e.target.value)} + required + disabled={isLoading} + suppressHydrationWarning + className="w-full" + placeholder={dict.emailLabel} + /> +
+
+ + setPassword(e.target.value)} + required + disabled={isLoading} + suppressHydrationWarning + className="w-full" + placeholder={dict.passwordLabel} + /> +
+ {error && } + -
-
- - setPassword(e.target.value)} - required - disabled={isLoading} - suppressHydrationWarning - className="w-full" - placeholder={dict.passwordLabel} - /> -
- {error && } - - - {dict.signInButton} - - + + {dict.signInButton} + + + )} + + {error && process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD === "true" && ( + + )} {process.env.NEXT_PUBLIC_OIDC_PROVIDER_ID && ( <> -
-
- + {process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD !== "true" && ( +
+
+ +
+
+ + {dict.signInWith} + +
-
- - {dict.signInWith} - -
-
+ )}