Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 69 additions & 53 deletions app/[lang]/login/login-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,66 +94,82 @@ export function LoginPageClient() {
{dict.title}
</h2>

<form onSubmit={handleEmailSignIn} className="space-y-4 mb-6">
<div>
<label htmlFor="email" className="block text-sm font-medium mb-2">
{dict.emailLabel}
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
suppressHydrationWarning
{process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD !== "true" && (
<form onSubmit={handleEmailSignIn} className="space-y-4 mb-6">
<div>
<label
htmlFor="email"
className="block text-sm font-medium mb-2"
>
{dict.emailLabel}
</label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
suppressHydrationWarning
className="w-full"
placeholder={dict.emailLabel}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-2"
>
{dict.passwordLabel}
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
suppressHydrationWarning
className="w-full"
placeholder={dict.passwordLabel}
/>
</div>
{error && <Alert variant="destructive" description={error} />}
<LoadingButton
type="submit"
className="w-full"
placeholder={dict.emailLabel}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium mb-2"
isLoading={isLoading}
loadingText="Signing in..."
>
{dict.passwordLabel}
</label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
suppressHydrationWarning
className="w-full"
placeholder={dict.passwordLabel}
<LogIn className="h-5 w-5" />
{dict.signInButton}
</LoadingButton>
</form>
)}

{error &&
process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD === "true" && (
<Alert
variant="destructive"
description={error}
className="mb-6"
/>
</div>
{error && <Alert variant="destructive" description={error} />}
<LoadingButton
type="submit"
className="w-full"
isLoading={isLoading}
loadingText="Signing in..."
>
<LogIn className="h-5 w-5" />
{dict.signInButton}
</LoadingButton>
</form>
)}

{process.env.NEXT_PUBLIC_OIDC_PROVIDER_ID && (
<>
<div className="relative mb-6 mt-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
{process.env.NEXT_PUBLIC_DISABLE_EMAIL_PASSWORD !== "true" && (
<div className="relative mb-6 mt-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{dict.signInWith}
</span>
</div>
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
{dict.signInWith}
</span>
</div>
</div>
)}

<Button
onClick={handleOidcSignIn}
Expand Down
3 changes: 3 additions & 0 deletions app/api/health/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function GET() {
return Response.json({ status: "ok" });
}
145 changes: 75 additions & 70 deletions lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,23 +158,16 @@ 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()) || [
Expand All @@ -191,7 +184,9 @@ function getOidcConfig() {
providerId,
clientId,
clientSecret,
discoveryUrl,
discoveryUrl: discoveryUrl || undefined,
authorizationUrl: authorizationUrl || undefined,
tokenUrl: tokenUrl || undefined,
scopes,
rolesClaim,
roleMapping,
Expand All @@ -210,7 +205,7 @@ export const auth = betterAuth({
})
: undefined,
emailAndPassword: {
enabled: true,
enabled: process.env.DISABLE_EMAIL_PASSWORD !== "true",
disableSignUp: true,
password: {
hash: hashPassword,
Expand Down Expand Up @@ -267,7 +262,13 @@ 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) => {
Expand All @@ -291,16 +292,31 @@ 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) => {
Expand All @@ -310,18 +326,22 @@ 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 },
Expand All @@ -348,60 +368,45 @@ 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 as any).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;
}
},
},
Expand Down
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading