diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 354cfec7..9d107f88 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,6 +1,5 @@ import NextAuth, { NextAuthOptions } from "next-auth"; -import { prisma } from '@/lib/prisma'; -import CredentialsProvider from 'next-auth/providers/credentials'; +import KeycloakProvider from "next-auth/providers/keycloak"; declare module "next-auth" { interface Session { @@ -28,73 +27,116 @@ declare module "next-auth" { } } -const authOptions: NextAuthOptions = { +function getRequiredEnvVar(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +export const authOptions: NextAuthOptions = { providers: [ - CredentialsProvider({ - name: 'Credentials', - credentials: { - email: { label: 'Email', type: 'email' }, - password: { label: 'Password', type: 'password' } - }, - async authorize(credentials) { - if (!credentials?.email || !credentials?.password) { - return null; + KeycloakProvider({ + clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + profile(profile) { + return { + id: profile.sub, + name: profile.name ?? profile.preferred_username, + email: profile.email, + first_name: profile.given_name ?? '', + last_name: profile.family_name ?? '', + username: profile.preferred_username ?? profile.email?.split('@')[0] ?? '', + role: profile.groups ?? [], } + }, + }), + ], + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + callbacks: { + async jwt({ token, account, profile }) { + if (account && profile) { + token.accessToken = account.access_token ?? ''; + token.refreshToken = account.refresh_token ?? ''; + token.accessTokenExpires = (account.expires_at ?? 0) * 1000; + token.role = (profile as any).groups ?? []; + token.username = (profile as any).preferred_username ?? profile.email?.split('@')[0] ?? ''; + token.first_name = (profile as any).given_name ?? ''; + token.last_name = (profile as any).family_name ?? ''; + } - const user = await prisma.user.findUnique({ - where: { email: credentials.email }, - }); + // Return previous token if not expired + if (Date.now() < (token.accessTokenExpires as number)) { + return token; + } - if (!user) { - return null; + try { + const clientId = getRequiredEnvVar("KEYCLOAK_CLIENT_ID"); + const clientSecret = getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"); + + const response = await fetch( + `${process.env.KEYCLOAK_BASE_URL}/realms/${process.env.KEYCLOAK_REALM}/protocol/openid-connect/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: clientId, + client_secret: clientSecret, + refresh_token: token.refreshToken as string, + }), + } + ); + + const tokens = await response.json(); + + if (!response.ok) { + throw new Error("RefreshAccessTokenError"); } return { - id: user.id, - email: user.email, - username: user.username || user.email.split('@')[0], - first_name: user.first_name || '', - last_name: user.last_name || '', - role: user.role || [], + ...token, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token ?? token.refreshToken, + accessTokenExpires: Date.now() + tokens.expires_in * 1000, + }; + } catch (error) { + return { + ...token, + error: "RefreshAccessTokenError", }; } - }) - ], - session: { - strategy: 'jwt' as const, - }, - pages: { - signIn: '/login', - }, - callbacks: { - async jwt({ token, user }: { token: any; user: any }) { - if (user) { - token.id = user.id; - token.email = user.email; - token.username = user.username; - token.first_name = user.first_name; - token.last_name = user.last_name; - token.role = user.role; - } - return token; }, - async session({ session, token }: { session: any; token: any }) { - if (token) { - session.user = { - id: token.id as string, - email: token.email as string | null, - name: token.name as string | null, - image: token.picture as string | null, - username: token.username as string, - first_name: token.first_name as string, - last_name: token.last_name as string, - role: token.role as string[], - }; - session.accessToken = token.accessToken as string; + async session({ session, token }) { + if (token.error) { + throw new Error("RefreshAccessTokenError"); } + + session.accessToken = token.accessToken; + session.user = { + ...session.user, + id: token.sub as string, + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + username: token.username ?? '', + role: token.role ?? [], + }; + return session; } - } + }, + pages: { + signIn: '/signin', + error: '/signin', + }, + debug: process.env.NODE_ENV === 'development', }; const handler = NextAuth(authOptions); diff --git a/lib/env.ts b/lib/env.ts index 4228dbc4..5a9829bc 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -3,7 +3,7 @@ import { z } from "zod"; const envSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), DATABASE_URL: z.string().url(), - NEWSDB_URL: z.string().url(), + NEWSDB_URL: z.string().url().optional(), KEYCLOAK_CLIENT_ID: z.string(), KEYCLOAK_CLIENT_SECRET: z.string(), KEYCLOAK_REALM: z.string(),