From 726a9adf1fbc148918a40e314c0e929c743e3cbb Mon Sep 17 00:00:00 2001 From: Alexandre Bove Date: Tue, 29 Jul 2025 08:55:33 +0200 Subject: [PATCH] Enhance authentication and authorization features with Logto integration - Enrich context with user roles, permissions, and resources from Logto. - Update setAuth function to handle JWT decoding and user data retrieval. - Refactor initGuard to utilize new auth structure. - Modify resource and role services to support new data types and error handling. --- src/graphql/context/features/auth.ts | 129 +++++++++++++++++- src/graphql/context/features/guard.ts | 113 ++------------- src/graphql/context/types.ts | 6 + src/graphql/features/Resource/resolver.ts | 4 +- .../features/Resource/resourceService.ts | 2 + src/graphql/features/Resource/scopeService.ts | 11 +- .../features/Role/permissionService.ts | 36 ++++- src/graphql/features/Role/roleService.ts | 31 +++-- 8 files changed, 205 insertions(+), 127 deletions(-) diff --git a/src/graphql/context/features/auth.ts b/src/graphql/context/features/auth.ts index 8a882d6..7893304 100644 --- a/src/graphql/context/features/auth.ts +++ b/src/graphql/context/features/auth.ts @@ -1,9 +1,130 @@ -import { GuardFunction } from "@/graphql/context/types"; +import { AuthTypes } from "@/graphql/context"; +import { patContextCache } from "@/graphql/context/features/patCache"; +import { listResources, Resource } from "@/graphql/features/Resource/resourceService"; +import { getUserRoles, Role } from "@/graphql/features/Role/roleService"; -const initGuard: GuardFunction = auth => { +// Fonction pour décoder un JWT (basique, sans vérification de signature) +function decodeJWT(token: string): { sub?: string; [key: string]: unknown } | null { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + + const payload = parts[1]; + const decoded = Buffer.from(payload, "base64url").toString("utf8"); + return JSON.parse(decoded) as { sub?: string; [key: string]: unknown }; + } catch (error) { + console.warn("Impossible de décoder le JWT:", error); + return null; + } +} + +const setAuth = async ( + req: + | Request + | { + headers?: Headers | Record; + get?: (header: string) => string | undefined; + header?: (header: string) => string | undefined; + } +): Promise => { + // Extraire le token d'autorisation des headers + let authHeader: string | null = null; + + // GraphQL Yoga utilise la structure Request Web API + if (req.headers) { + // Si c'est un objet Headers (Web API) + if (typeof req.headers.get === "function") { + authHeader = (req.headers as Headers).get("Authorization"); + } + // Si c'est un objet record (GraphQL Yoga parfois) + else if (typeof req.headers === "object") { + authHeader = + (req.headers as Record)["authorization"] || + (req.headers as Record)["Authorization"]; + } + } else { + // Essayer d'accéder aux headers via une autre méthode + try { + // Tenter d'utiliser req comme un objet Request ou custom + if (typeof (req as { get?: (header: string) => string | undefined }).get === "function") { + const val = (req as { get: (header: string) => string | undefined }).get("Authorization"); + authHeader = val ?? null; + } else if (typeof (req as { header?: (header: string) => string | undefined }).header === "function") { + const val = (req as { header: (header: string) => string | undefined }).header("Authorization"); + authHeader = val ?? null; + } + } catch { + // Ignorer les erreurs d'accès aux headers + } + } + + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.substring(7); // Retirer "Bearer " + + // Décoder le JWT pour récupérer l'userId (seulement si c'est un JWT) + let userId: string | undefined = undefined; + let user: { id: string; name?: string; email?: string; lastName?: string; isActive?: boolean } | undefined = + undefined; + + if (token.includes(".") && token.split(".").length === 3) { + // C'est un JWT + const decoded = decodeJWT(token); + userId = decoded?.sub as string | undefined; + if (userId) { + user = { + id: userId, + name: typeof decoded?.name === "string" ? decoded.name : undefined, + email: typeof decoded?.email === "string" ? decoded.email : undefined, + lastName: typeof decoded?.family_name === "string" ? decoded.family_name : undefined, + isActive: typeof decoded?.isActive === "boolean" ? decoded.isActive : undefined, + }; + } + } else { + // PAT : tenter de récupérer l'userId via le cache + userId = patContextCache.getUserId(token) || undefined; + if (userId) { + user = { id: userId }; + } + } + + let roles: Role[] = []; + let permissions: string[] = []; + let resources: Resource[] = []; + if (userId) { + try { + const minimalContext = { + accessToken: token, + request: req as Request, + db: {}, + patCache: patContextCache, + }; + roles = await getUserRoles(userId, minimalContext); + permissions = roles.flatMap(r => + Array.isArray(r.permissions) ? r.permissions.map((p: { name: string }) => p.name) : [] + ); + resources = await listResources(minimalContext); + } catch { + // ignore fetch errors, fallback to empty + } + } + return { + accessToken: token, + personalAccessToken: token, + userId: userId, + user: user, + roles, + permissions, + resources, + }; + } + + // Pas d'authentification return { - isAuthenticated: !!auth?.accessToken, + accessToken: undefined, + personalAccessToken: undefined, + userId: undefined, + user: undefined, }; }; -export default initGuard; +export default setAuth; diff --git a/src/graphql/context/features/guard.ts b/src/graphql/context/features/guard.ts index 4d6fe0f..82b5876 100644 --- a/src/graphql/context/features/guard.ts +++ b/src/graphql/context/features/guard.ts @@ -1,105 +1,18 @@ -import { AuthTypes } from "@/graphql/context"; -import { patContextCache } from "@/graphql/context/features/patCache"; +import { GuardFunction } from "@/graphql/context/types"; -// Fonction pour décoder un JWT (basique, sans vérification de signature) -function decodeJWT(token: string): { sub?: string; [key: string]: unknown } | null { - try { - const parts = token.split("."); - if (parts.length !== 3) return null; - - const payload = parts[1]; - const decoded = Buffer.from(payload, "base64url").toString("utf8"); - return JSON.parse(decoded) as { sub?: string; [key: string]: unknown }; - } catch (error) { - console.warn("Impossible de décoder le JWT:", error); - return null; - } -} - -const setAuth = async ( - req: - | Request - | { - headers?: Headers | Record; - get?: (header: string) => string | undefined; - header?: (header: string) => string | undefined; - } -): Promise => { - // Extraire le token d'autorisation des headers - let authHeader: string | null = null; - - // GraphQL Yoga utilise la structure Request Web API - if (req.headers) { - // Si c'est un objet Headers (Web API) - if (typeof req.headers.get === "function") { - authHeader = (req.headers as Headers).get("Authorization"); - } - // Si c'est un objet record (GraphQL Yoga parfois) - else if (typeof req.headers === "object") { - authHeader = - (req.headers as Record)["authorization"] || - (req.headers as Record)["Authorization"]; - } - } else { - // Essayer d'accéder aux headers via une autre méthode - try { - // Tenter d'utiliser req comme un objet Request ou custom - if (typeof (req as { get?: (header: string) => string | undefined }).get === "function") { - const val = (req as { get: (header: string) => string | undefined }).get("Authorization"); - authHeader = val ?? null; - } else if (typeof (req as { header?: (header: string) => string | undefined }).header === "function") { - const val = (req as { header: (header: string) => string | undefined }).header("Authorization"); - authHeader = val ?? null; - } - } catch { - // Ignorer les erreurs d'accès aux headers - } - } - - if (authHeader && authHeader.startsWith("Bearer ")) { - const token = authHeader.substring(7); // Retirer "Bearer " - - // Décoder le JWT pour récupérer l'userId (seulement si c'est un JWT) - let userId: string | undefined = undefined; - let user: { id: string; name?: string; email?: string; lastName?: string; isActive?: boolean } | undefined = - undefined; - - if (token.includes(".") && token.split(".").length === 3) { - // C'est un JWT - const decoded = decodeJWT(token); - userId = decoded?.sub as string | undefined; - if (userId) { - user = { - id: userId, - name: typeof decoded?.name === "string" ? decoded.name : undefined, - email: typeof decoded?.email === "string" ? decoded.email : undefined, - lastName: typeof decoded?.family_name === "string" ? decoded.family_name : undefined, - isActive: typeof decoded?.isActive === "boolean" ? decoded.isActive : undefined, - }; - } - } else { - // PAT : tenter de récupérer l'userId via le cache - userId = patContextCache.getUserId(token) || undefined; - if (userId) { - user = { id: userId }; - } - } - - return { - accessToken: token, - personalAccessToken: token, - userId: userId, - user: user, // <-- Correction ici - }; - } - - // Pas d'authentification +const initGuard: GuardFunction = auth => { return { - accessToken: undefined, - personalAccessToken: undefined, - userId: undefined, - user: undefined, + isAuthenticated: !!auth?.accessToken, + hasRole: (roleName: string) => { + return Array.isArray(auth?.roles) && auth.roles.some(r => r.name === roleName); + }, + hasPermission: (permissionName: string) => { + return Array.isArray(auth?.permissions) && auth.permissions.includes(permissionName); + }, + hasResource: (resourceName: string) => { + return Array.isArray(auth?.resources) && auth.resources.some(r => r.name === resourceName); + }, }; }; -export default setAuth; +export default initGuard; diff --git a/src/graphql/context/types.ts b/src/graphql/context/types.ts index cf97be9..e857368 100644 --- a/src/graphql/context/types.ts +++ b/src/graphql/context/types.ts @@ -1,3 +1,6 @@ +import type { Resource } from "@/graphql/features/Resource/resourceService"; +import type { Role } from "@/graphql/features/Role/roleService"; + export interface DB { // Define your database types here } @@ -19,6 +22,9 @@ export interface AuthTypes { accessToken?: string; personalAccessToken?: string; // Token d'accès personnel Logto userId?: string; // ID utilisateur pour les requêtes authentifiées + roles?: Role[]; + permissions?: string[]; + resources?: Resource[]; } export interface PATCache { diff --git a/src/graphql/features/Resource/resolver.ts b/src/graphql/features/Resource/resolver.ts index bdd0910..5c2ef04 100644 --- a/src/graphql/features/Resource/resolver.ts +++ b/src/graphql/features/Resource/resolver.ts @@ -42,10 +42,10 @@ export const resourceResolvers = { }, assignScopeToResource: async ( _parent: unknown, - { resourceId, scope }: { resourceId: string; scope: string }, + { resourceId, name, description }: { resourceId: string; name: string; description?: string }, context: GraphQLContext ) => { - return assignScopeToResource(resourceId, scope, context); + return assignScopeToResource(resourceId, name, description ?? null, context); }, removeScopeFromResource: async ( _parent: unknown, diff --git a/src/graphql/features/Resource/resourceService.ts b/src/graphql/features/Resource/resourceService.ts index 627473a..318bb88 100644 --- a/src/graphql/features/Resource/resourceService.ts +++ b/src/graphql/features/Resource/resourceService.ts @@ -101,3 +101,5 @@ export async function deleteResource(id: string, context: GraphQLContext): Promi }); } } + +export type { Resource }; diff --git a/src/graphql/features/Resource/scopeService.ts b/src/graphql/features/Resource/scopeService.ts index f33da0d..1e36100 100644 --- a/src/graphql/features/Resource/scopeService.ts +++ b/src/graphql/features/Resource/scopeService.ts @@ -27,7 +27,7 @@ export async function createResourceScope( name: string, description: string | null, context: GraphQLContext -): Promise { +): Promise { const token = getAccessTokenFromContext(context) || (await getLogtoAccessToken()); try { // LOG AVANT REQUETE @@ -40,8 +40,13 @@ export async function createResourceScope( // LOG APRES REQUETE console.error("[DEBUG createResourceScope] Logto response:", response.data); return response.data; - } catch (e: any) { - console.error("[ERROR createResourceScope]", e?.response?.data || e); + } catch (e: unknown) { + if (typeof e === "object" && e !== null && "response" in e) { + // @ts-expect-error: response might exist on error object + console.error("[ERROR createResourceScope]", e.response?.data || e); + } else { + console.error("[ERROR createResourceScope]", e); + } throw new GraphQLError("Erreur lors de la création de la permission de ressource", { extensions: { code: "CREATE_RESOURCE_SCOPE_FAILED" }, }); diff --git a/src/graphql/features/Role/permissionService.ts b/src/graphql/features/Role/permissionService.ts index eea72f2..0698453 100644 --- a/src/graphql/features/Role/permissionService.ts +++ b/src/graphql/features/Role/permissionService.ts @@ -3,6 +3,17 @@ import { GraphQLError } from "graphql"; import { GraphQLContext } from "../../context/types"; import { getLogtoAccessToken } from "../User/resolver"; +interface AxiosErrorResponse { + response?: { + data?: { + code?: string; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + [key: string]: unknown; +} + function getAccessTokenFromContext(context: GraphQLContext): string | null { return context.accessToken || null; } @@ -14,7 +25,7 @@ export async function listPermissions(context: GraphQLContext) { headers: { Authorization: `Bearer ${token}` }, }); return response.data; - } catch (e) { + } catch { throw new GraphQLError("Erreur lors de la récupération des permissions", { extensions: { code: "PERMISSIONS_FETCH_FAILED" }, }); @@ -30,7 +41,7 @@ export async function createPermission(name: string, description: string | null, { headers: { Authorization: `Bearer ${token}` } } ); return response.data; - } catch (e) { + } catch { throw new GraphQLError("Erreur lors de la création de la permission", { extensions: { code: "CREATE_PERMISSION_FAILED" }, }); @@ -44,7 +55,7 @@ export async function deletePermission(id: string, context: GraphQLContext) { headers: { Authorization: `Bearer ${token}` }, }); return true; - } catch (e) { + } catch { throw new GraphQLError("Erreur lors de la suppression de la permission", { extensions: { code: "DELETE_PERMISSION_FAILED" }, }); @@ -64,12 +75,23 @@ export async function assignPermissionToRole(roleId: string, permissionId: strin // eslint-disable-next-line no-console console.log("[DEBUG assignPermissionToRole] Logto response:", res.data); return true; - } catch (e: any) { - // Si l'erreur est que la permission existe déjà, on considère comme succès - if (e?.response?.data?.code === "role.scope_exists") { + } catch (e: unknown) { + const error = e as AxiosErrorResponse; + if ( + typeof e === "object" && + e !== null && + "response" in error && + typeof error.response === "object" && + error.response !== null && + "data" in error.response && + typeof error.response.data === "object" && + error.response.data !== null && + "code" in error.response.data && + error.response.data.code === "role.scope_exists" + ) { return true; } - console.error("[ERROR assignPermissionToRole]", e?.response?.data || e); + console.error("[ERROR assignPermissionToRole]", error?.response?.data || e); throw new GraphQLError("Erreur lors de l'assignation du scope au rôle", { extensions: { code: "ASSIGN_SCOPE_TO_ROLE_FAILED" }, }); diff --git a/src/graphql/features/Role/roleService.ts b/src/graphql/features/Role/roleService.ts index 608e5d5..ed9b463 100644 --- a/src/graphql/features/Role/roleService.ts +++ b/src/graphql/features/Role/roleService.ts @@ -18,7 +18,7 @@ async function fetchRolePermissions( }); // Les scopes sont des permissions return Array.isArray(response.data) - ? response.data.map((scope: any) => ({ + ? response.data.map((scope: { id: string; name: string; description?: string }) => ({ id: scope.id, name: scope.name, description: scope.description, @@ -79,18 +79,25 @@ export async function assignRoleToUser(userId: string, roleId: string, context: { headers: { Authorization: `Bearer ${token}` } } ); return true; - } catch (err: any) { + } catch (err: unknown) { // Log détaillé côté serveur - console.error("Erreur assignRoleToUser:", err?.response?.data || err); - let message = "Erreur lors de l'assignation du rôle"; - if (err?.response?.data?.message) { - message += `: ${err.response.data.message}`; - } else if (err?.message) { - message += `: ${err.message}`; + if (axios.isAxiosError(err)) { + console.error("Erreur assignRoleToUser:", err.response?.data || err); + let message = "Erreur lors de l'assignation du rôle"; + if (err.response?.data?.message) { + message += `: ${err.response.data.message}`; + } else if (err.message) { + message += `: ${err.message}`; + } + throw new GraphQLError(message, { + extensions: { code: "ASSIGN_ROLE_FAILED", logto: err.response?.data }, + }); + } else { + console.error("Erreur assignRoleToUser:", err); + throw new GraphQLError("Erreur lors de l'assignation du rôle", { + extensions: { code: "ASSIGN_ROLE_FAILED" }, + }); } - throw new GraphQLError(message, { - extensions: { code: "ASSIGN_ROLE_FAILED", logto: err?.response?.data }, - }); } } @@ -166,3 +173,5 @@ export async function deleteRole(id: string, context: GraphQLContext) { }); } } + +export type { Role };