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.
This commit is contained in:
Alexandre Bove 2025-07-29 08:55:33 +02:00
parent b59fa5e267
commit 726a9adf1f
8 changed files with 205 additions and 127 deletions

View File

@ -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<string, string>;
get?: (header: string) => string | undefined;
header?: (header: string) => string | undefined;
}
): Promise<AuthTypes & { roles?: Role[]; permissions?: string[]; resources?: Resource[] }> => {
// 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<string, string>)["authorization"] ||
(req.headers as Record<string, string>)["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 { return {
isAuthenticated: !!auth?.accessToken, accessToken: undefined,
personalAccessToken: undefined,
userId: undefined,
user: undefined,
}; };
}; };
export default initGuard; export default setAuth;

View File

@ -1,105 +1,18 @@
import { AuthTypes } from "@/graphql/context"; import { GuardFunction } from "@/graphql/context/types";
import { patContextCache } from "@/graphql/context/features/patCache";
// Fonction pour décoder un JWT (basique, sans vérification de signature) const initGuard: GuardFunction = auth => {
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<string, string>;
get?: (header: string) => string | undefined;
header?: (header: string) => string | undefined;
}
): Promise<AuthTypes> => {
// 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<string, string>)["authorization"] ||
(req.headers as Record<string, string>)["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
return { return {
accessToken: undefined, isAuthenticated: !!auth?.accessToken,
personalAccessToken: undefined, hasRole: (roleName: string) => {
userId: undefined, return Array.isArray(auth?.roles) && auth.roles.some(r => r.name === roleName);
user: undefined, },
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;

View File

@ -1,3 +1,6 @@
import type { Resource } from "@/graphql/features/Resource/resourceService";
import type { Role } from "@/graphql/features/Role/roleService";
export interface DB { export interface DB {
// Define your database types here // Define your database types here
} }
@ -19,6 +22,9 @@ export interface AuthTypes {
accessToken?: string; accessToken?: string;
personalAccessToken?: string; // Token d'accès personnel Logto personalAccessToken?: string; // Token d'accès personnel Logto
userId?: string; // ID utilisateur pour les requêtes authentifiées userId?: string; // ID utilisateur pour les requêtes authentifiées
roles?: Role[];
permissions?: string[];
resources?: Resource[];
} }
export interface PATCache { export interface PATCache {

View File

@ -42,10 +42,10 @@ export const resourceResolvers = {
}, },
assignScopeToResource: async ( assignScopeToResource: async (
_parent: unknown, _parent: unknown,
{ resourceId, scope }: { resourceId: string; scope: string }, { resourceId, name, description }: { resourceId: string; name: string; description?: string },
context: GraphQLContext context: GraphQLContext
) => { ) => {
return assignScopeToResource(resourceId, scope, context); return assignScopeToResource(resourceId, name, description ?? null, context);
}, },
removeScopeFromResource: async ( removeScopeFromResource: async (
_parent: unknown, _parent: unknown,

View File

@ -101,3 +101,5 @@ export async function deleteResource(id: string, context: GraphQLContext): Promi
}); });
} }
} }
export type { Resource };

View File

@ -27,7 +27,7 @@ export async function createResourceScope(
name: string, name: string,
description: string | null, description: string | null,
context: GraphQLContext context: GraphQLContext
): Promise<any> { ): Promise<ResourceScope> {
const token = getAccessTokenFromContext(context) || (await getLogtoAccessToken()); const token = getAccessTokenFromContext(context) || (await getLogtoAccessToken());
try { try {
// LOG AVANT REQUETE // LOG AVANT REQUETE
@ -40,8 +40,13 @@ export async function createResourceScope(
// LOG APRES REQUETE // LOG APRES REQUETE
console.error("[DEBUG createResourceScope] Logto response:", response.data); console.error("[DEBUG createResourceScope] Logto response:", response.data);
return response.data; return response.data;
} catch (e: any) { } catch (e: unknown) {
console.error("[ERROR createResourceScope]", e?.response?.data || e); 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", { throw new GraphQLError("Erreur lors de la création de la permission de ressource", {
extensions: { code: "CREATE_RESOURCE_SCOPE_FAILED" }, extensions: { code: "CREATE_RESOURCE_SCOPE_FAILED" },
}); });

View File

@ -3,6 +3,17 @@ import { GraphQLError } from "graphql";
import { GraphQLContext } from "../../context/types"; import { GraphQLContext } from "../../context/types";
import { getLogtoAccessToken } from "../User/resolver"; 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 { function getAccessTokenFromContext(context: GraphQLContext): string | null {
return context.accessToken || null; return context.accessToken || null;
} }
@ -14,7 +25,7 @@ export async function listPermissions(context: GraphQLContext) {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
return response.data; return response.data;
} catch (e) { } catch {
throw new GraphQLError("Erreur lors de la récupération des permissions", { throw new GraphQLError("Erreur lors de la récupération des permissions", {
extensions: { code: "PERMISSIONS_FETCH_FAILED" }, extensions: { code: "PERMISSIONS_FETCH_FAILED" },
}); });
@ -30,7 +41,7 @@ export async function createPermission(name: string, description: string | null,
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: `Bearer ${token}` } }
); );
return response.data; return response.data;
} catch (e) { } catch {
throw new GraphQLError("Erreur lors de la création de la permission", { throw new GraphQLError("Erreur lors de la création de la permission", {
extensions: { code: "CREATE_PERMISSION_FAILED" }, extensions: { code: "CREATE_PERMISSION_FAILED" },
}); });
@ -44,7 +55,7 @@ export async function deletePermission(id: string, context: GraphQLContext) {
headers: { Authorization: `Bearer ${token}` }, headers: { Authorization: `Bearer ${token}` },
}); });
return true; return true;
} catch (e) { } catch {
throw new GraphQLError("Erreur lors de la suppression de la permission", { throw new GraphQLError("Erreur lors de la suppression de la permission", {
extensions: { code: "DELETE_PERMISSION_FAILED" }, extensions: { code: "DELETE_PERMISSION_FAILED" },
}); });
@ -64,12 +75,23 @@ export async function assignPermissionToRole(roleId: string, permissionId: strin
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log("[DEBUG assignPermissionToRole] Logto response:", res.data); console.log("[DEBUG assignPermissionToRole] Logto response:", res.data);
return true; return true;
} catch (e: any) { } catch (e: unknown) {
// Si l'erreur est que la permission existe déjà, on considère comme succès const error = e as AxiosErrorResponse;
if (e?.response?.data?.code === "role.scope_exists") { 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; 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", { throw new GraphQLError("Erreur lors de l'assignation du scope au rôle", {
extensions: { code: "ASSIGN_SCOPE_TO_ROLE_FAILED" }, extensions: { code: "ASSIGN_SCOPE_TO_ROLE_FAILED" },
}); });

View File

@ -18,7 +18,7 @@ async function fetchRolePermissions(
}); });
// Les scopes sont des permissions // Les scopes sont des permissions
return Array.isArray(response.data) return Array.isArray(response.data)
? response.data.map((scope: any) => ({ ? response.data.map((scope: { id: string; name: string; description?: string }) => ({
id: scope.id, id: scope.id,
name: scope.name, name: scope.name,
description: scope.description, description: scope.description,
@ -79,18 +79,25 @@ export async function assignRoleToUser(userId: string, roleId: string, context:
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: `Bearer ${token}` } }
); );
return true; return true;
} catch (err: any) { } catch (err: unknown) {
// Log détaillé côté serveur // Log détaillé côté serveur
console.error("Erreur assignRoleToUser:", err?.response?.data || err); if (axios.isAxiosError(err)) {
let message = "Erreur lors de l'assignation du rôle"; console.error("Erreur assignRoleToUser:", err.response?.data || err);
if (err?.response?.data?.message) { let message = "Erreur lors de l'assignation du rôle";
message += `: ${err.response.data.message}`; if (err.response?.data?.message) {
} else if (err?.message) { message += `: ${err.response.data.message}`;
message += `: ${err.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 };