feat: implement httpOnly cookie for JWT authentication and update related API calls

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
2026-04-28 14:36:06 -04:00
parent 2eb887c486
commit 567fa66428
27 changed files with 207 additions and 123 deletions
@@ -229,7 +229,7 @@ export default function GlobalAiControl() {
</a>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-4">
Token en localStorage: {localStorage.getItem('studio_token') ? '✓ Existe' : '✗ No existe'}
Por favor verifica que tu sesión esté activa.
</p>
</div>
</div>
+1 -3
View File
@@ -38,9 +38,7 @@ export default function AdminDashboard() {
cmsApi.getOrganization(),
cmsApi.getAllUsers(),
fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
},
credentials: 'include',
})
]);
+4 -22
View File
@@ -54,24 +54,14 @@ export default function AdminTokenTracking() {
const loadTokenUsage = async () => {
try {
const token = localStorage.getItem('studio_token');
if (!token) {
console.error('[TokenUsage] No authentication token found!');
alert('No authentication token found. Please login again.');
window.location.href = '/auth/login';
return;
}
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/token-usage`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
credentials: 'include',
});
if (response.status === 401) {
console.error('[TokenUsage] Unauthorized - Token may be expired');
alert('Session expired. Please login again.');
window.location.href = '/auth/login';
return;
@@ -88,11 +78,7 @@ export default function AdminTokenTracking() {
try {
const limitResp = await fetch(
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.user_id}/token-limit/check`,
{
headers: {
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
},
}
{ credentials: 'include' }
);
if (limitResp.ok) {
const limitData = await limitResp.json();
@@ -127,13 +113,13 @@ export default function AdminTokenTracking() {
{
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
monthly_token_limit: editValue,
token_limit_reset_day: 1,
}),
credentials: 'include',
}
);
@@ -141,11 +127,7 @@ export default function AdminTokenTracking() {
// Reload limits
const limitResp = await fetch(
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${userId}/token-limit/check`,
{
headers: {
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
},
}
{ credentials: 'include' }
);
if (limitResp.ok) {
const limitData = await limitResp.json();
+1 -5
View File
@@ -52,11 +52,7 @@ export default function UsersPage() {
try {
const resp = await fetch(
`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/admin/users/${user.id}/token-limit/check`,
{
headers: {
'Authorization': `Bearer ${localStorage.getItem('studio_token')}`,
},
}
{ credentials: 'include' }
);
if (resp.ok) {
const data = await resp.json();
+2 -4
View File
@@ -14,9 +14,8 @@ function CallbackHandler() {
useEffect(() => {
const token = searchParams.get("token");
if (token) {
// Temporarily store token so getMe can use it
localStorage.setItem('studio_token', token);
// El token JWT viene como cookie httpOnly del backend.
// Lo pasamos al AuthContext para el estado en memoria.
cmsApi.getMe()
.then((user) => {
login(user, token);
@@ -24,7 +23,6 @@ function CallbackHandler() {
})
.catch((err) => {
console.error("SSO Error:", err);
localStorage.removeItem('studio_token');
router.push("/auth/login?error=sso_failed");
});
} else {
@@ -85,13 +85,12 @@ export default function GradebookPage() {
const exportCSV = async () => {
try {
const token = localStorage.getItem('studio_token');
const selectedOrgId = localStorage.getItem('studio_selected_org_id');
const res = await fetch(lmsApi.exportGradesUrl(id), {
headers: {
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
...(selectedOrgId ? { 'X-Organization-Id': selectedOrgId } : {})
}
},
credentials: 'include'
});
if (!res.ok) throw new Error('Export failed');
const blob = await res.blob();
+2 -1
View File
@@ -20,7 +20,8 @@ function DeepLinkingPickerContent() {
useEffect(() => {
if (token) {
localStorage.setItem("studio_token", token);
// El token JWT llega como cookie httpOnly del backend SSO/LTI.
// No se persiste en localStorage.
}
loadCourses();
}, [token]);
@@ -73,10 +73,9 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
const uploadResponse = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/assets/upload`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
headers: {},
body: audioFormData,
credentials: 'include',
});
if (uploadResponse.ok) {
@@ -166,19 +165,17 @@ export default function QuestionBankEditor({ question, onSuccess, onCancel }: Qu
try {
setGeneratingAI(true);
// AI generation con el nuevo endpoint de question bank
const token = localStorage.getItem('studio_token');
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/question-bank/ai-generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
question_text: formData.question_text,
difficulty: formData.difficulty,
skill: randomSkill,
}),
credentials: 'include',
});
if (!response.ok) {
@@ -438,27 +438,20 @@ export default function TestTemplateForm({ templateId, onSuccess, onCancel }: Te
return;
}
const token = localStorage.getItem('studio_token');
if (!token) {
alert('No hay sesión activa. Por favor inicia sesión nuevamente.');
return;
}
try {
setGeneratingAI(true);
// Usar el endpoint RAG de generación de preguntas desde banco MySQL
const response = await fetch(`${process.env.NEXT_PUBLIC_CMS_API_URL || 'http://localhost:3001'}/test-templates/generate-with-rag`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({
topic: aiContext,
num_questions: aiQuestionCount,
question_type: aiQuestionType,
}),
credentials: 'include',
});
if (!response.ok) {
+4 -2
View File
@@ -39,8 +39,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setUser(newUser);
setToken(newToken);
setSelectedOrgId(newUser.organization_id || null);
// El token JWT se guarda en httpOnly cookie por el backend.
// Solo se persiste el usuario (sin datos sensibles de auth) y el orgId.
localStorage.setItem('studio_user', JSON.stringify(newUser));
localStorage.setItem('studio_token', newToken);
if (newUser.organization_id) {
localStorage.setItem('studio_selected_org_id', newUser.organization_id);
}
@@ -51,8 +52,9 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
setToken(null);
setSelectedOrgId(null);
localStorage.removeItem('studio_user');
localStorage.removeItem('studio_token');
localStorage.removeItem('studio_selected_org_id');
// Borrar la httpOnly cookie desde el backend
fetch('/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {});
};
const setOrganizationId = (id: string | null) => {
+3 -1
View File
@@ -979,7 +979,7 @@ export const apiFetch = (url: string, options: ApiFetchOptions = {}, isLms: bool
}
}
return fetch(finalUrl, { ...options, headers }).then(async res => {
return fetch(finalUrl, { ...options, headers, credentials: 'include' }).then(async res => {
if (!res.ok) {
const text = await res.text();
try {
@@ -1261,6 +1261,7 @@ export const cmsApi = {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/api/assets/import-zip`);
xhr.withCredentials = true;
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
@@ -1327,6 +1328,7 @@ export const cmsApi = {
const xhr = new XMLHttpRequest();
xhr.open('POST', `${API_BASE_URL}/api/assets/upload`);
xhr.withCredentials = true;
const token = getToken();
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);