Skip to content

Commit

Permalink
chore: wip
Browse files Browse the repository at this point in the history
  • Loading branch information
devthejo committed Mar 3, 2025
1 parent 2b4337e commit b35c253
Show file tree
Hide file tree
Showing 4 changed files with 318 additions and 37 deletions.
2 changes: 2 additions & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@react-pdf/renderer": "^3.1.12",
"@sentry/nextjs": "^8.51.0",
"@socialgouv/matomo-next": "^1.8.0",
"argon2": "^0.41.1",
"chart.js": "^4.3.0",
"chartjs-plugin-datalabels": "^2.2.0",
"clsx": "^2.0.0",
Expand All @@ -35,6 +36,7 @@
"http-status-codes": "^2.3.0",
"i18next": "^23.5.1",
"immer": "^10.0.2",
"ioredis": "^5.5.0",
"js-xlsx": "^0.8.22",
"jsonwebtoken": "^9.0.1",
"lodash": "^4.17.21",
Expand Down
123 changes: 86 additions & 37 deletions packages/app/src/api/core-domain/infra/auth/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { type ProConnectProfile, ProConnectProvider } from "@api/core-domain/infra/auth/ProConnectProvider";
import { companiesUtils, type Company } from "@api/core-domain/infra/companies-store";
import { globalMailerService } from "@api/core-domain/infra/mail";
import { ownershipRepo } from "@api/core-domain/repo";
import { SyncOwnership } from "@api/core-domain/useCases/SyncOwnership";
Expand All @@ -20,12 +21,15 @@ declare module "next-auth" {
interface Session {
staff: {
impersonating?: boolean;
lastImpersonated?: Array<{ label: string | null; siren: string }>;
lastImpersonatedHash?: string;
};
user: {
companies: Array<{ label: string | null; siren: string }>;
companies?: Array<{ label: string | null; siren: string }>;
companiesHash: string;
// For backward compatibility
email: string;
firstname?: string;
lastImpersonated?: Array<{ label: string | null; siren: string }>;
lastname?: string;
phoneNumber?: string;
staff: boolean;
Expand All @@ -42,10 +46,7 @@ declare module "next-auth/jwt" {
}
}

const charonMcpUrl = new URL(
`fabriqueKeycloak/`,
config.api.security.auth.charonUrl,
);
const charonMcpUrl = new URL(`fabriqueKeycloak/`, config.api.security.auth.charonUrl);
const charonGithubUrl = new URL("github/", config.api.security.auth.charonUrl);
export const monCompteProProvider = ProConnectProvider({
...config.api.security.moncomptepro,
Expand All @@ -57,12 +58,12 @@ export const monCompteProProvider = ProConnectProvider({
});
export const authConfig: AuthOptions = {
logger: {
error:(code: any, ...message: any[]) => logger.error({ code }, ...message),
warn:(code: any, ...message: any[]) => logger.warn({ code }, ...message),
info:(code: any, ...message: any[]) => logger.info({ code }, ...message),
debug:(code: any, ...message: any[]) => {
error: (code: any, ...message: any[]) => logger.error({ code }, ...message),
warn: (code: any, ...message: any[]) => logger.warn({ code }, ...message),
info: (code: any, ...message: any[]) => logger.info({ code }, ...message),
debug: (code: any, ...message: any[]) => {
if (config.env === "dev") {
logger.info({code}, ...message);
logger.info({ code }, ...message);
} else {
logger.debug({ code }, ...message);
}
Expand Down Expand Up @@ -145,39 +146,59 @@ export const authConfig: AuthOptions = {
// staff starts impersonating
assertImpersonatedSession(session);
token.user.staff = session.user.staff;
token.user.companies = session.user.companies;

// Store companies in Redis if available
if (session.user.companies) {
// Create hash and store companies in Redis
token.user.companiesHash = await companiesUtils.hashCompanies(session.user.companies);
} else if ("companiesHash" in session.user && session.user.companiesHash) {
// Use existing hash
token.user.companiesHash = session.user.companiesHash as string;
}

token.staff.impersonating = true;
token.staff.lastImpersonated = [
// keep only unique companies
...new Map(
(token.staff.lastImpersonated ?? [])
.concat(token.user.companies)
.filter(c => !!c)
.map(c => [c.siren, c.label]),
).entries(),
].map(([siren, label]) => ({ siren, label }));

// If impersonating, store the current companies for later
if (session.user.companies) {
// Create last impersonated hash and store in Redis
const companiesList = session.user.companies as Company[];
token.staff.lastImpersonatedHash = await companiesUtils.hashCompanies(companiesList);
}
} else if (session.staff.impersonating === false) {
// staff stops impersonating
token.user.staff = true;
token.user.companies = [];
token.user.companiesHash = ""; // Empty hash for no companies
token.staff.impersonating = false;
}
}
if (trigger !== "signUp") return token;
token.user = {} as Session["user"];
token.staff = {} as Session["staff"];
token.user = {
companiesHash: "",
email: token.email,
staff: false,
tokenApiV1: "",
} as Session["user"];
token.staff = {
impersonating: false,
lastImpersonatedHash: "",
} as Session["staff"];
if (account?.provider === "github") {
const githubProfile = profile as unknown as GithubProfile;
token.user.staff = true;
token.user.companies = [];
token.user.companiesHash = ""; // Empty hash for no companies
const [firstname, lastname] = githubProfile.name?.split(" ") ?? [];
token.user.firstname = firstname;
token.user.lastname = lastname;
} else if (account?.provider === "email") {
token.user.staff = config.api.staff.includes(profile?.email ?? "");
if (token.email && !token.user.staff) {
const companies = await ownershipRepo.getAllSirenByEmail(new Email(token.email));
token.user.companies = companies.map(siren => ({ label: "", siren }));
const companiesList = companies.map(siren => ({ label: "", siren }));

// Create hash and store companies in Redis
token.user.companiesHash = await companiesUtils.hashCompanies(companiesList);
} else {
token.user.companiesHash = ""; // Empty hash for no companies
}
} else {
const sirenList = profile?.organizations.map(orga => orga.siret.substring(0, 9));
Expand All @@ -189,11 +210,16 @@ export const authConfig: AuthOptions = {
logger.error({ error }, "Error while syncing ownerships");
}
}
token.user.companies =

const companiesList =
profile?.organizations.map(orga => ({
siren: orga.siret.substring(0, 9),
label: orga.label,
})) ?? [];

// Create hash and store companies in Redis
token.user.companiesHash = await companiesUtils.hashCompanies(companiesList);

token.user.staff = config.api.staff.includes(profile?.email ?? "");
token.user.firstname = profile?.given_name ?? void 0;
token.user.lastname = profile?.family_name ?? void 0;
Expand All @@ -204,18 +230,15 @@ export const authConfig: AuthOptions = {
token.user.tokenApiV1 = createTokenApiV1(token.email);

try {
const companiesHash = token.user.companiesHash || "";

logger.info(
{
companies: token.user.companies,
},
"Companies in token",
);
logger.info(
{
companiesLength: token.user.companies.length,
companiesHash,
},
"Number of companies in token",
"Companies hash in token",
);

logger.info(
{
tokenSize: JSON.stringify(token).length,
Expand All @@ -233,14 +256,40 @@ export const authConfig: AuthOptions = {

return token;
},
// expose data from jwt to front
session({ session, token }) {
async session({ session, token }) {
session.user = token.user;
session.user.email = token.email;
session.staff = {};

// Load companies from Redis using the hash if it exists
if (token.user.companiesHash) {
try {
const companies = await companiesUtils.getCompaniesFromRedis(token.user.companiesHash);
if (companies.length > 0) {
session.user.companies = companies;
}
} catch (error) {
logger.error("Error loading companies from Redis", error);
}
}

if (token.user.staff || token.staff.impersonating) {
session.staff = token.staff;

// Load last impersonated companies if hash exists
if (token.staff.lastImpersonatedHash) {
try {
const lastImpersonated = await companiesUtils.getCompaniesFromRedis(token.staff.lastImpersonatedHash);
if (lastImpersonated.length > 0) {
// For backward compatibility, include the actual companies in the session
session.user.lastImpersonated = lastImpersonated;
}
} catch (error) {
logger.error("Error loading last impersonated companies from Redis", error);
}
}
}

return session;
},
},
Expand Down
114 changes: 114 additions & 0 deletions packages/app/src/api/core-domain/infra/companies-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { logger } from "@api/utils/pino";
import * as argon2 from "argon2";
import Redis from "ioredis";

export type Company = { label: string | null; siren: string };

// Configure Redis connection based on environment variables or defaults
const redisOptions = {
host: process.env.REDIS_HOST || "localhost",
port: parseInt(process.env.REDIS_PORT || "6379", 10),
password: process.env.REDIS_PASSWORD,
keyPrefix: "egapro:",
// Default TTL for keys (24 hours)
// This ensures we don't keep unused data permanently
maxTtl: 60 * 60 * 24,
};

// Create Redis client
const redisClient = new Redis(redisOptions);

// Log Redis connection errors but don't crash the application
redisClient.on("error", err => {
logger.error("Redis client error", err);
});

/**
* Utilities for hashing and storing company data using Redis
*/
export const companiesUtils = {
/**
* Generate a hash for companies array
* This hash will be stored in the JWT for reference
* The actual companies data will be stored in Redis using this hash as key
*/
async hashCompanies(companies: Company[]): Promise<string> {
try {
if (!companies.length) return "";

// Sort to ensure consistent hashing regardless of array order
const sortedCompanies = [...companies].sort((a, b) => a.siren.localeCompare(b.siren));
const companiesString = JSON.stringify(sortedCompanies);

const hash = await argon2.hash(companiesString, {
type: argon2.argon2id,
memoryCost: 2048, // minimize memory usage for this simple hash
timeCost: 3, // minimize time cost for this simple hash
parallelism: 1, // minimize parallelism for this simple hash
});

// Store the actual companies data in Redis using the hash as key
await this.storeCompaniesInRedis(hash, companies);

return hash;
} catch (error) {
logger.error("Failed to hash companies", error);
return "";
}
},

/**
* Store companies data in Redis using the hash as key
*/
async storeCompaniesInRedis(hash: string, companies: Company[]): Promise<void> {
try {
if (!companies.length || !hash) return;

const companiesString = JSON.stringify(companies);
await redisClient.set(`companies:${hash}`, companiesString, "EX", redisOptions.maxTtl);

logger.info({ hash }, "Companies data stored in Redis");
} catch (error) {
logger.error({ error, hash }, "Failed to store companies in Redis");
}
},

/**
* Get companies data from Redis using the hash
*/
async getCompaniesFromRedis(hash: string): Promise<Company[]> {
try {
if (!hash) return [];

const companiesString = await redisClient.get(`companies:${hash}`);
if (!companiesString) {
logger.warn({ hash }, "Companies data not found in Redis");
return [];
}

return JSON.parse(companiesString) as Company[];
} catch (error) {
logger.error({ error, hash }, "Failed to get companies from Redis");
return [];
}
},

/**
* Verify that provided companies data matches the hash from JWT
* This is used to verify client-submitted companies data
*/
async verifyCompaniesData(companies: Company[], hash: string): Promise<boolean> {
try {
if (!companies.length || !hash) return false;

// Sort to ensure consistent verification regardless of array order
const sortedCompanies = [...companies].sort((a, b) => a.siren.localeCompare(b.siren));
const companiesString = JSON.stringify(sortedCompanies);

return await argon2.verify(hash, companiesString);
} catch (error) {
logger.error("Failed to verify companies data", error);
return false;
}
},
};
Loading

0 comments on commit b35c253

Please sign in to comment.