diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 30e0081b..dea65e4a 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,226 +1,6 @@ -import NextAuth, { NextAuthOptions } from "next-auth"; -import KeycloakProvider from "next-auth/providers/keycloak"; -import { jwtDecode } from "jwt-decode"; - -interface KeycloakProfile { - sub: string; - email?: string; - name?: string; - roles?: string[]; - preferred_username?: string; - given_name?: string; - family_name?: string; - realm_access?: { - roles: string[]; - }; -} - -interface DecodedToken { - realm_access?: { - roles: string[]; - }; - [key: string]: any; -} - -declare module "next-auth" { - interface Session { - user: { - id: string; - name?: string | null; - email?: string | null; - image?: string | null; - username: string; - first_name: string; - last_name: string; - role: string[]; - nextcloudInitialized?: boolean; - }; - accessToken?: string; - } - - interface JWT { - sub?: string; - accessToken?: string; - refreshToken?: string; - accessTokenExpires?: number; - role?: string[]; - username?: string; - first_name?: string; - last_name?: string; - error?: string; - email?: string | null; - name?: string | null; - } -} - -function getRequiredEnvVar(name: string): string { - const value = process.env[name]; - if (!value) { - throw new Error(`Missing required environment variable: ${name}`); - } - return value; -} - -async function refreshAccessToken(token: JWT) { - try { - const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - client_id: process.env.KEYCLOAK_CLIENT_ID!, - client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, - grant_type: "refresh_token", - refresh_token: token.refreshToken, - }), - method: "POST", - }); - - const refreshedTokens = await response.json(); - - if (!response.ok) { - throw refreshedTokens; - } - - return { - ...token, - accessToken: refreshedTokens.access_token, - refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, - accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, - }; - } catch (error) { - console.error("Error refreshing access token:", error); - return { - ...token, - error: "RefreshAccessTokenError", - }; - } -} - -export const authOptions: NextAuthOptions = { - providers: [ - KeycloakProvider({ - clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), - clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), - issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), - authorization: { - params: { - scope: "openid profile email roles" - } - }, - profile(profile) { - console.log('Keycloak profile callback:', { - rawProfile: profile, - rawRoles: profile.roles, - realmAccess: profile.realm_access, - groups: profile.groups - }); - - // Get roles from realm_access - const roles = profile.realm_access?.roles || []; - console.log('Profile callback raw roles:', roles); - - // Clean up roles by removing ROLE_ prefix and converting to lowercase - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); - - console.log('Profile callback cleaned roles:', cleanRoles); - - 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: cleanRoles, - } - }, - }), - ], - session: { - strategy: "jwt", - maxAge: 30 * 24 * 60 * 60, // 30 days - }, - callbacks: { - async jwt({ token, account, profile }) { - if (account && profile) { - const keycloakProfile = profile as KeycloakProfile; - const roles = keycloakProfile.realm_access?.roles || []; - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); - - token.accessToken = account.access_token ?? ''; - token.refreshToken = account.refresh_token ?? ''; - token.accessTokenExpires = account.expires_at ?? 0; - token.sub = keycloakProfile.sub; - token.role = cleanRoles; - token.username = keycloakProfile.preferred_username ?? ''; - token.first_name = keycloakProfile.given_name ?? ''; - token.last_name = keycloakProfile.family_name ?? ''; - } else if (token.accessToken) { - try { - const decoded = jwtDecode(token.accessToken); - if (decoded.realm_access?.roles) { - const roles = decoded.realm_access.roles; - const cleanRoles = roles.map((role: string) => - role.replace(/^ROLE_/, '').toLowerCase() - ); - token.role = cleanRoles; - } - } catch (error) { - console.error('Error decoding token:', error); - } - } - - if (Date.now() < (token.accessTokenExpires as number) * 1000) { - return token; - } - - return refreshAccessToken(token); - }, - async session({ session, token }) { - if (token.error) { - throw new Error(token.error); - } - - const userRoles = Array.isArray(token.role) ? token.role : []; - session.user = { - id: token.sub ?? '', - email: token.email ?? null, - name: token.name ?? null, - image: null, - username: token.username ?? '', - first_name: token.first_name ?? '', - last_name: token.last_name ?? '', - role: userRoles, - nextcloudInitialized: false, - }; - session.accessToken = token.accessToken; - - return session; - } - }, - pages: { - signIn: '/signin', - error: '/signin', - }, - debug: process.env.NODE_ENV === 'development', -}; +import NextAuth from "next-auth"; +import { authOptions } from "../options"; const handler = NextAuth(authOptions); export { handler as GET, handler as POST }; -interface JWT { - accessToken: string; - refreshToken: string; - accessTokenExpires: number; -} - -interface Profile { - sub?: string; - email?: string; - name?: string; - roles?: string[]; -} - diff --git a/app/api/auth/options.ts b/app/api/auth/options.ts new file mode 100644 index 00000000..331ea013 --- /dev/null +++ b/app/api/auth/options.ts @@ -0,0 +1,222 @@ +import NextAuth, { NextAuthOptions } from "next-auth"; +import KeycloakProvider from "next-auth/providers/keycloak"; +import { jwtDecode } from "jwt-decode"; + +interface KeycloakProfile { + sub: string; + email?: string; + name?: string; + roles?: string[]; + preferred_username?: string; + given_name?: string; + family_name?: string; + realm_access?: { + roles: string[]; + }; +} + +interface DecodedToken { + realm_access?: { + roles: string[]; + }; + [key: string]: any; +} + +declare module "next-auth" { + interface Session { + user: { + id: string; + name?: string | null; + email?: string | null; + image?: string | null; + username: string; + first_name: string; + last_name: string; + role: string[]; + nextcloudInitialized?: boolean; + }; + accessToken?: string; + } + + interface JWT { + sub?: string; + accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; + role?: string[]; + username?: string; + first_name?: string; + last_name?: string; + error?: string; + email?: string | null; + name?: string | null; + } +} + +function getRequiredEnvVar(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +async function refreshAccessToken(token: JWT) { + try { + const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, { + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: process.env.KEYCLOAK_CLIENT_ID!, + client_secret: process.env.KEYCLOAK_CLIENT_SECRET!, + grant_type: "refresh_token", + refresh_token: token.refreshToken || '', + }), + method: "POST", + }); + + const refreshedTokens = await response.json(); + + if (!response.ok) { + throw refreshedTokens; + } + + return { + ...token, + accessToken: refreshedTokens.access_token, + refreshToken: refreshedTokens.refresh_token ?? token.refreshToken, + accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000, + }; + } catch (error) { + console.error("Error refreshing access token:", error); + return { + ...token, + error: "RefreshAccessTokenError", + }; + } +} + +export const authOptions: NextAuthOptions = { + providers: [ + KeycloakProvider({ + clientId: getRequiredEnvVar("KEYCLOAK_CLIENT_ID"), + clientSecret: getRequiredEnvVar("KEYCLOAK_CLIENT_SECRET"), + issuer: getRequiredEnvVar("KEYCLOAK_ISSUER"), + authorization: { + params: { + scope: "openid profile email roles" + } + }, + profile(profile) { + console.log('Keycloak profile callback:', { + rawProfile: profile, + rawRoles: profile.roles, + realmAccess: profile.realm_access, + groups: profile.groups + }); + + // Get roles from realm_access + const roles = profile.realm_access?.roles || []; + console.log('Profile callback raw roles:', roles); + + // Clean up roles by removing ROLE_ prefix and converting to lowercase + const cleanRoles = roles.map((role: string) => + role.replace(/^ROLE_/, '').toLowerCase() + ); + + console.log('Profile callback cleaned roles:', cleanRoles); + + 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: cleanRoles, + } + }, + }), + ], + session: { + strategy: "jwt", + maxAge: 30 * 24 * 60 * 60, // 30 days + }, + callbacks: { + async jwt({ token, account, profile }) { + if (account && profile) { + const keycloakProfile = profile as KeycloakProfile; + const roles = keycloakProfile.realm_access?.roles || []; + const cleanRoles = roles.map((role: string) => + role.replace(/^ROLE_/, '').toLowerCase() + ); + + token.accessToken = account.access_token ?? ''; + token.refreshToken = account.refresh_token ?? ''; + token.accessTokenExpires = account.expires_at ?? 0; + token.sub = keycloakProfile.sub; + token.role = cleanRoles; + token.username = keycloakProfile.preferred_username ?? ''; + token.first_name = keycloakProfile.given_name ?? ''; + token.last_name = keycloakProfile.family_name ?? ''; + } else if (token.accessToken) { + try { + const decoded = jwtDecode(token.accessToken); + if (decoded.realm_access?.roles) { + const roles = decoded.realm_access.roles; + const cleanRoles = roles.map((role: string) => + role.replace(/^ROLE_/, '').toLowerCase() + ); + token.role = cleanRoles; + } + } catch (error) { + console.error('Error decoding token:', error); + } + } + + if (Date.now() < (token.accessTokenExpires as number) * 1000) { + return token; + } + + return refreshAccessToken(token); + }, + async session({ session, token }) { + if (token.error) { + throw new Error(token.error); + } + + const userRoles = Array.isArray(token.role) ? token.role : []; + session.user = { + id: token.sub ?? '', + email: token.email ?? null, + name: token.name ?? null, + image: null, + username: token.username ?? '', + first_name: token.first_name ?? '', + last_name: token.last_name ?? '', + role: userRoles, + nextcloudInitialized: false, + }; + session.accessToken = token.accessToken; + + return session; + } + }, + pages: { + signIn: '/signin', + error: '/signin', + }, + debug: process.env.NODE_ENV === 'development', +}; + +interface JWT { + accessToken?: string; + refreshToken?: string; + accessTokenExpires?: number; +} + +interface Profile { + sub?: string; + email?: string; + name?: string; + roles?: string[]; +} \ No newline at end of file diff --git a/update-imports.sh b/update-imports.sh new file mode 100755 index 00000000..74db781c --- /dev/null +++ b/update-imports.sh @@ -0,0 +1,7 @@ +#!/bin/bash + +# Find all TypeScript files in the app directory +find ./app -type f -name "*.ts*" -print0 | xargs -0 sed -i '' 's|import { authOptions } from "@/app/api/auth/\\[...nextauth\\]/route";|import { authOptions } from "@/app/api/auth/options";|g' +find ./app -type f -name "*.ts*" -print0 | xargs -0 sed -i '' 's|import { authOptions } from "../../auth/\\[...nextauth\\]/route";|import { authOptions } from "../../auth/options";|g' + +echo "Updated authOptions imports in all files." \ No newline at end of file