Neah/app/api/auth/[...nextauth]/route.ts
2025-05-02 11:41:43 +02:00

246 lines
6.4 KiB
TypeScript

import NextAuth, { NextAuthOptions } from "next-auth";
import { JWT } from "next-auth/jwt";
import KeycloakProvider from "next-auth/providers/keycloak";
import { jwtDecode } from "jwt-decode";
import { DEFAULT_AUTH_COOKIE_OPTIONS } from "@/lib/cookies";
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 {
console.log('Refreshing access token...');
const response = await fetch(`${process.env.KEYCLOAK_ISSUER}/protocol/openid-connect/token`, {
method: "POST",
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!,
}),
});
const refreshedTokens = await response.json();
if (!response.ok) {
console.error('Token refresh failed:', refreshedTokens);
throw refreshedTokens;
}
console.log('Token refreshed successfully');
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:', {
email: profile.email,
name: profile.name,
sub: profile.sub,
roles: profile.realm_access?.roles || []
});
// Get roles from realm_access
const roles = profile.realm_access?.roles || [];
// Clean up roles by removing ROLE_ prefix and converting to lowercase
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
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
},
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
...DEFAULT_AUTH_COOKIE_OPTIONS,
httpOnly: true
}
},
callbackUrl: {
name: `next-auth.callback-url`,
options: DEFAULT_AUTH_COOKIE_OPTIONS
},
csrfToken: {
name: `next-auth.csrf-token`,
options: {
...DEFAULT_AUTH_COOKIE_OPTIONS,
httpOnly: true
}
}
},
callbacks: {
async jwt({ token, account, profile }) {
// Initial sign in
if (account && profile) {
const keycloakProfile = profile as KeycloakProfile;
const roles = keycloakProfile.realm_access?.roles || [];
const cleanRoles = roles.map((role: string) =>
role.replace(/^ROLE_/, '').toLowerCase()
);
return {
...token,
accessToken: account.access_token,
refreshToken: account.refresh_token,
accessTokenExpires: account.expires_at ? account.expires_at * 1000 : 0,
sub: keycloakProfile.sub,
role: cleanRoles,
username: keycloakProfile.preferred_username ?? '',
first_name: keycloakProfile.given_name ?? '',
last_name: keycloakProfile.family_name ?? '',
};
}
// Return the previous token if the access token has not expired
if (token.accessTokenExpires && Date.now() < token.accessTokenExpires) {
return token;
}
// Access token has expired, try to refresh it
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',
logger: {
error(code, metadata) {
console.error(`Auth error: ${code}`, metadata);
},
warn(code) {
console.warn(`Auth warning: ${code}`);
},
debug(code, metadata) {
if (process.env.NODE_ENV === 'development') {
console.debug(`Auth debug: ${code}`, metadata);
}
}
}
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };