NeahNew/app/api/auth/options.ts
2026-01-01 18:52:01 +01:00

287 lines
8.6 KiB
TypeScript

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;
idToken?: string;
}
interface JWT {
sub?: string;
accessToken?: string;
refreshToken?: string;
idToken?: 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;
}
type ExtendedJWT = {
accessToken?: string;
refreshToken?: string;
idToken?: string;
accessTokenExpires?: number;
sub?: string;
role?: string[];
username?: string;
first_name?: string;
last_name?: string;
email?: string | null;
name?: string | null;
error?: string;
[key: string]: any;
};
async function refreshAccessToken(token: ExtendedJWT) {
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) {
// Check if the error is due to invalid session (e.g., user logged out from iframe)
if (refreshedTokens.error === 'invalid_grant' ||
refreshedTokens.error_description?.includes('Session not active') ||
refreshedTokens.error_description?.includes('Token is not active')) {
console.log("Keycloak session invalidated (likely logged out from iframe), marking token for removal");
// Return token with specific error to trigger session invalidation
return {
...token,
error: "SessionNotActive",
};
}
throw refreshedTokens;
}
return {
...token,
accessToken: refreshedTokens.access_token,
refreshToken: refreshedTokens.refresh_token ?? token.refreshToken,
// Keep existing ID token (Keycloak doesn't return new ID token on refresh)
idToken: token.idToken,
accessTokenExpires: Date.now() + refreshedTokens.expires_in * 1000,
};
} catch (error: any) {
console.error("Error refreshing access token:", error);
// Check if it's an invalid_grant error (session invalidated)
if (error?.error === 'invalid_grant' ||
error?.error_description?.includes('Session not active') ||
error?.error_description?.includes('Token is not active')) {
return {
...token,
error: "SessionNotActive",
};
}
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.idToken = account.id_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<DecodedToken>(token.accessToken as string);
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);
}
}
// Check if token is expired and needs refresh
if (Date.now() < (token.accessTokenExpires as number) * 1000) {
return token;
}
// Token expired, try to refresh
const refreshedToken = await refreshAccessToken(token);
// If refresh failed due to invalid session, clear the token to force re-authentication
if (refreshedToken.error === "SessionNotActive") {
console.log("Keycloak session invalidated, clearing token to force re-authentication");
// Return a token that will cause session callback to return null
return {
...refreshedToken,
accessToken: undefined,
refreshToken: undefined,
idToken: undefined,
};
}
return refreshedToken;
},
async session({ session, token }) {
// If session was invalidated or tokens are missing, return null to sign out
if (token.error === "SessionNotActive" || !token.accessToken) {
console.log("Session invalidated or tokens missing, user will be signed out");
// Return null to make NextAuth treat user as unauthenticated
// This will trigger automatic redirect to sign-in page
return null as any;
}
// For other errors, throw to trigger error handling
if (token.error) {
throw new Error(token.error as string);
}
const userRoles = Array.isArray(token.role) ? token.role : [];
session.user = {
id: (token.sub ?? '') as string,
email: (token.email ?? null) as string | null,
name: (token.name ?? null) as string | null,
image: null,
username: (token.username ?? '') as string,
first_name: (token.first_name ?? '') as string,
last_name: (token.last_name ?? '') as string,
role: userRoles,
nextcloudInitialized: false,
};
session.accessToken = token.accessToken as string | undefined;
session.idToken = token.idToken as string | undefined;
return session;
}
},
pages: {
signIn: '/signin',
error: '/signin',
},
debug: process.env.NODE_ENV === 'development',
};
// JWT interface is declared in the module declaration above
interface Profile {
sub?: string;
email?: string;
name?: string;
roles?: string[];
}