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 {
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 { 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<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
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;

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 {
// 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 {

View File

@ -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,

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,
description: string | null,
context: GraphQLContext
): Promise<any> {
): Promise<ResourceScope> {
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" },
});

View File

@ -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" },
});

View File

@ -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 };