mirror of
https://github.com/prowler-cloud/prowler.git
synced 2025-12-19 05:17:47 +00:00
362 lines
9.4 KiB
TypeScript
362 lines
9.4 KiB
TypeScript
import { jwtDecode, type JwtPayload } from "jwt-decode";
|
|
import NextAuth, {
|
|
type DefaultSession,
|
|
type NextAuthConfig,
|
|
type Session,
|
|
User,
|
|
} from "next-auth";
|
|
import type { JWT } from "next-auth/jwt";
|
|
import Credentials from "next-auth/providers/credentials";
|
|
import { z } from "zod";
|
|
|
|
import { getToken, getUserByMe } from "./actions/auth";
|
|
import { apiBaseUrl } from "./lib";
|
|
import type { RolePermissionAttributes } from "./types/users";
|
|
|
|
interface CustomJwtPayload extends JwtPayload {
|
|
user_id: string;
|
|
tenant_id: string;
|
|
}
|
|
|
|
type DefaultSessionUser = NonNullable<DefaultSession["user"]>;
|
|
|
|
type TokenUser = DefaultSessionUser & {
|
|
companyName?: string;
|
|
dateJoined?: string;
|
|
permissions: RolePermissionAttributes;
|
|
};
|
|
|
|
type AuthToken = JWT & {
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
accessTokenExpires?: number;
|
|
user_id?: string;
|
|
tenant_id?: string;
|
|
user?: TokenUser;
|
|
error?: string;
|
|
};
|
|
|
|
type ExtendedSession = Session & {
|
|
user?: TokenUser;
|
|
userId?: string;
|
|
tenantId?: string;
|
|
accessToken?: string;
|
|
refreshToken?: string;
|
|
error?: string;
|
|
};
|
|
|
|
const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
|
|
manage_users: false,
|
|
manage_account: false,
|
|
manage_providers: false,
|
|
manage_scans: false,
|
|
manage_integrations: false,
|
|
manage_billing: false,
|
|
unlimited_visibility: false,
|
|
};
|
|
|
|
type TokenUserInput = Partial<TokenUser> & { company?: string };
|
|
|
|
const toTokenUser = (user?: TokenUserInput): TokenUser =>
|
|
({
|
|
name: user?.name ?? undefined,
|
|
email: user?.email ?? undefined,
|
|
companyName: user?.companyName ?? user?.company,
|
|
dateJoined: user?.dateJoined,
|
|
permissions: user?.permissions ?? { ...DEFAULT_PERMISSIONS },
|
|
}) as TokenUser;
|
|
|
|
type UserMeResponse = Awaited<ReturnType<typeof getUserByMe>>;
|
|
|
|
const tokenUserFromApi = (user: UserMeResponse) =>
|
|
toTokenUser({
|
|
name: user.name,
|
|
email: user.email,
|
|
companyName: user.company,
|
|
dateJoined: user.dateJoined,
|
|
permissions: user.permissions,
|
|
});
|
|
|
|
const applyDecodedClaims = (
|
|
target: AuthToken,
|
|
accessToken?: string,
|
|
logContext = "access token",
|
|
) => {
|
|
if (!accessToken) return;
|
|
|
|
try {
|
|
const decodedToken = jwtDecode<CustomJwtPayload>(accessToken);
|
|
target.accessTokenExpires = decodedToken.exp
|
|
? decodedToken.exp * 1000
|
|
: target.accessTokenExpires;
|
|
target.user_id = decodedToken.user_id ?? target.user_id;
|
|
target.tenant_id = decodedToken.tenant_id ?? target.tenant_id;
|
|
} catch (decodeError) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`Unable to decode ${logContext}`, decodeError);
|
|
}
|
|
};
|
|
|
|
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
|
|
|
|
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
|
|
const refreshToken = token.refreshToken;
|
|
|
|
if (!refreshToken) {
|
|
return {
|
|
...token,
|
|
error: "MissingRefreshToken",
|
|
};
|
|
}
|
|
|
|
const existingPromise = refreshTokenPromises.get(refreshToken);
|
|
|
|
if (existingPromise) {
|
|
return existingPromise;
|
|
}
|
|
|
|
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
|
|
|
|
const bodyData = {
|
|
data: {
|
|
type: "tokens-refresh",
|
|
attributes: {
|
|
refresh: refreshToken,
|
|
},
|
|
},
|
|
};
|
|
|
|
const refreshPromise = (async () => {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/vnd.api+json",
|
|
Accept: "application/vnd.api+json",
|
|
},
|
|
body: JSON.stringify(bodyData),
|
|
});
|
|
|
|
const payload = await response.json().catch(() => undefined);
|
|
|
|
if (!response.ok) {
|
|
const detail = payload?.errors?.[0]?.detail;
|
|
// eslint-disable-next-line no-console
|
|
console.warn(
|
|
"Failed to refresh access token:",
|
|
detail || `HTTP error ${response.status}`,
|
|
);
|
|
return {
|
|
...token,
|
|
error: "RefreshAccessTokenError",
|
|
};
|
|
}
|
|
|
|
const newAccessToken = payload?.data?.attributes?.access as
|
|
| string
|
|
| undefined;
|
|
const nextRefreshToken =
|
|
(payload?.data?.attributes?.refresh as string | undefined) ??
|
|
refreshToken;
|
|
|
|
if (!newAccessToken) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Missing access token in refresh response");
|
|
return {
|
|
...token,
|
|
error: "RefreshAccessTokenError",
|
|
};
|
|
}
|
|
|
|
const nextToken: AuthToken = {
|
|
...token,
|
|
accessToken: newAccessToken,
|
|
refreshToken: nextRefreshToken,
|
|
error: undefined,
|
|
};
|
|
|
|
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
|
|
|
|
return nextToken;
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn("Error refreshing access token:", error);
|
|
return {
|
|
...token,
|
|
error: "RefreshAccessTokenError",
|
|
};
|
|
}
|
|
})();
|
|
|
|
refreshTokenPromises.set(refreshToken, refreshPromise);
|
|
|
|
try {
|
|
return await refreshPromise;
|
|
} finally {
|
|
refreshTokenPromises.delete(refreshToken);
|
|
}
|
|
};
|
|
|
|
export const authConfig = {
|
|
session: {
|
|
strategy: "jwt",
|
|
// The session will be valid for 24 hours
|
|
maxAge: 24 * 60 * 60,
|
|
},
|
|
pages: {
|
|
signIn: "/sign-in",
|
|
newUser: "/sign-up",
|
|
},
|
|
|
|
providers: [
|
|
Credentials({
|
|
name: "credentials",
|
|
credentials: {
|
|
email: { label: "email", type: "text" },
|
|
password: { label: "password", type: "password" },
|
|
},
|
|
async authorize(credentials) {
|
|
const parsedCredentials = z
|
|
.object({
|
|
email: z.email(),
|
|
password: z.string().min(12),
|
|
})
|
|
.safeParse(credentials);
|
|
|
|
if (!parsedCredentials.success) return null;
|
|
|
|
const { email, password } = parsedCredentials.data;
|
|
const tokenResponse = await getToken({
|
|
email,
|
|
password,
|
|
});
|
|
if (!tokenResponse) return null;
|
|
|
|
const userMeResponse = await getUserByMe(tokenResponse.accessToken);
|
|
|
|
const user = tokenUserFromApi(userMeResponse);
|
|
|
|
return {
|
|
...user,
|
|
accessToken: tokenResponse.accessToken,
|
|
refreshToken: tokenResponse.refreshToken,
|
|
};
|
|
},
|
|
}),
|
|
Credentials({
|
|
id: "social-oauth",
|
|
name: "social-oauth",
|
|
credentials: {
|
|
accessToken: { label: "Access Token", type: "text" },
|
|
refreshToken: { label: "Refresh Token", type: "text" },
|
|
},
|
|
async authorize(credentials) {
|
|
const accessToken = credentials?.accessToken;
|
|
|
|
if (!accessToken) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const userMeResponse = await getUserByMe(accessToken as string);
|
|
|
|
const user = tokenUserFromApi(userMeResponse);
|
|
|
|
return {
|
|
...user,
|
|
accessToken: credentials.accessToken,
|
|
refreshToken: credentials.refreshToken,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error in authorize:", error);
|
|
return null;
|
|
}
|
|
},
|
|
}),
|
|
],
|
|
callbacks: {
|
|
authorized({ auth, request: { nextUrl } }) {
|
|
const isLoggedIn = !!auth?.user;
|
|
const isSignUpPage = nextUrl.pathname === "/sign-up";
|
|
const isSignInPage = nextUrl.pathname === "/sign-in";
|
|
|
|
// Allow access to sign-up and sign-in pages
|
|
if (isSignUpPage || isSignInPage) return true;
|
|
|
|
// For all other routes, require authentication
|
|
if (!isLoggedIn) {
|
|
return false; // Will redirect to signIn page defined in pages config
|
|
}
|
|
|
|
return true;
|
|
},
|
|
|
|
jwt: async ({ token, account, user }) => {
|
|
const authToken = token as AuthToken;
|
|
|
|
applyDecodedClaims(authToken, authToken.accessToken);
|
|
|
|
if (account && user) {
|
|
const signedInUser = user as User &
|
|
TokenUserInput & {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
};
|
|
|
|
const nextAuthToken: AuthToken = {
|
|
...authToken,
|
|
accessToken: signedInUser.accessToken,
|
|
refreshToken: signedInUser.refreshToken,
|
|
user: toTokenUser(signedInUser),
|
|
error: undefined,
|
|
};
|
|
|
|
applyDecodedClaims(
|
|
nextAuthToken,
|
|
signedInUser.accessToken,
|
|
"access token on sign-in",
|
|
);
|
|
|
|
return nextAuthToken;
|
|
}
|
|
|
|
if (
|
|
typeof authToken.accessTokenExpires === "number" &&
|
|
Date.now() < authToken.accessTokenExpires
|
|
) {
|
|
return authToken;
|
|
}
|
|
|
|
return refreshAccessToken(authToken);
|
|
},
|
|
|
|
session: async ({ session, token }) => {
|
|
const authToken = token as AuthToken;
|
|
const nextSession = { ...session } as ExtendedSession;
|
|
|
|
if (authToken?.error) {
|
|
nextSession.error = authToken.error;
|
|
nextSession.user = undefined;
|
|
nextSession.userId = undefined;
|
|
nextSession.tenantId = undefined;
|
|
nextSession.accessToken = undefined;
|
|
nextSession.refreshToken = undefined;
|
|
return nextSession;
|
|
}
|
|
|
|
nextSession.error = undefined;
|
|
nextSession.userId = authToken.user_id ?? nextSession.userId;
|
|
nextSession.tenantId = authToken.tenant_id ?? nextSession.tenantId;
|
|
nextSession.accessToken =
|
|
authToken.accessToken ?? nextSession.accessToken;
|
|
nextSession.refreshToken =
|
|
authToken.refreshToken ?? nextSession.refreshToken;
|
|
nextSession.user = authToken.user ?? nextSession.user;
|
|
|
|
return nextSession;
|
|
},
|
|
},
|
|
} satisfies NextAuthConfig;
|
|
|
|
export const { signIn, signOut, auth, handlers } = NextAuth(authConfig);
|