From 3e28d115e1ac595bedeb805f871d0eaffcbf2955 Mon Sep 17 00:00:00 2001 From: Marcio Date: Wed, 14 Jan 2026 19:55:24 -0300 Subject: [PATCH] Integrate OIDC authentication with Authentik Added `oidc-client-ts` and `jwt-decode` dependencies to support OIDC authentication. Implemented `authFetch` for secure API calls and integrated user session management with `AuthBootstrap`. Updated `App` to utilize token-based authentication flows with protected endpoints. --- package-lock.json | 22 +++++++++++ package.json | 1 + src/App.js | 16 +++++--- src/AuthBootstrap.js | 82 +++++++++++++++++++++++++++++++++++++++++ src/auth/authFetch.js | 24 ++++++++++++ src/auth/userManager.js | 37 +++++++++++++++++++ src/index.js | 5 ++- 7 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/AuthBootstrap.js create mode 100644 src/auth/authFetch.js create mode 100644 src/auth/userManager.js diff --git a/package-lock.json b/package-lock.json index f03c3b0..b3632ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^13.5.0", "lucide-react": "^0.562.0", + "oidc-client-ts": "^3.1.0", "react": "^19.2.3", "react-dom": "^19.2.3", "react-scripts": "5.0.1", @@ -10949,6 +10950,15 @@ "node": ">=4.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -11757,6 +11767,18 @@ "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "license": "MIT" }, + "node_modules/oidc-client-ts": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.4.1.tgz", + "integrity": "sha512-jNdst/U28Iasukx/L5MP6b274Vr7ftQs6qAhPBCvz6Wt5rPCA+Q/tUmCzfCHHWweWw5szeMy2Gfrm1rITwUKrw==", + "license": "Apache-2.0", + "dependencies": { + "jwt-decode": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", diff --git a/package.json b/package.json index 08b9dd4..faf05c3 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", "@testing-library/user-event": "^13.5.0", + "oidc-client-ts": "^3.1.0", "lucide-react": "^0.562.0", "react": "^19.2.3", "react-dom": "^19.2.3", diff --git a/src/App.js b/src/App.js index 36d0bd5..11fb018 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,8 @@ import React, { useState, useEffect, useRef } from 'react'; import { Database, Globe, Download, Play, Pause, Trash2, Plus, RefreshCw, Activity, Clock, CircleX, CircleCheck, Grid, Calendar, Terminal } from 'lucide-react'; import * as signalR from '@microsoft/signalr'; +import { authFetch } from './auth/authFetch'; +import { getAccessToken } from './auth/userManager'; const API_BASE = 'http://localhost:5123'; @@ -36,7 +38,9 @@ const WebScraper = () => { // Conectar ao SignalR useEffect(() => { const connection = new signalR.HubConnectionBuilder() - .withUrl(`http://localhost:5123/ws/scrape`) + .withUrl(`${API_BASE}/ws/scrape`, { + accessTokenFactory: async () => (await getAccessToken()) ?? '' + }) .withAutomaticReconnect() .build(); @@ -208,7 +212,7 @@ const WebScraper = () => { const loadSessions = async () => { try { - const response = await fetch(`${API_BASE}/sessions`); + const response = await authFetch(`${API_BASE}/sessions`); const data = await response.json(); setSessions(data); } catch (err) { @@ -218,7 +222,7 @@ const WebScraper = () => { const loadSessionStatus = async (sessionId) => { try { - const response = await fetch(`${API_BASE}/sessions/${sessionId}/scrap/status`); + const response = await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/status`); const data = await response.json(); setSessionStatus(data); } catch (err) { @@ -228,7 +232,7 @@ const WebScraper = () => { const startScrapingById = async (sessionId, sessionName) => { try { - await fetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, { + await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, { method: 'POST' }); addLog(`Iniciando scraping da sessão: ${sessionName}`, 'info'); @@ -239,7 +243,7 @@ const WebScraper = () => { const stopScrapingById = async (sessionId, sessionName) => { try { - await fetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, { + await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, { method: 'POST' }); addLog(`Parando sessão: ${sessionName}`, 'warning'); @@ -262,7 +266,7 @@ const WebScraper = () => { if (!selectedSession || !newUrl) return; try { - const response = await fetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, { + const response = await authFetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: newUrl }) diff --git a/src/AuthBootstrap.js b/src/AuthBootstrap.js new file mode 100644 index 0000000..edf898b --- /dev/null +++ b/src/AuthBootstrap.js @@ -0,0 +1,82 @@ +import React, { useEffect, useState } from "react"; +import { userManager } from "./auth/userManager"; + +/** + * Responsável por: + * - tratar /callback (retorno do Authentik) + * - garantir que o usuário esteja logado antes de renderizar o app + */ +export default function AuthBootstrap({ children }) { + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + (async () => { + try { + const { pathname, search } = window.location; + + if (pathname === "/callback") { + // Finaliza o fluxo OIDC + await userManager.signinRedirectCallback(); + + // Volta para home (mantém a URL limpa) + window.history.replaceState({}, document.title, "/"); + setLoading(false); + return; + } + + const user = await userManager.getUser(); + + if (!user || user.expired) { + await userManager.signinRedirect(); + return; + } + + setLoading(false); + } catch (e) { + setError(e?.message ?? String(e)); + setLoading(false); + } + })(); + }, []); + + if (loading) { + return ( +
+ Carregando autenticação... +
+ ); + } + + if (error) { + return ( +
+
+

Erro na autenticação

+
{error}
+ +
+
+ ); + } + + return children; +} diff --git a/src/auth/authFetch.js b/src/auth/authFetch.js new file mode 100644 index 0000000..7a442e6 --- /dev/null +++ b/src/auth/authFetch.js @@ -0,0 +1,24 @@ +import { getAccessToken, userManager } from "./userManager"; + +/** + * fetch() que injeta automaticamente Authorization: Bearer + * Se não estiver logado, redireciona para o login. + */ +export async function authFetch(input, init = {}) { + const token = await getAccessToken(); + + if (!token) { + // Não está autenticado (ou token expirou) + await userManager.signinRedirect(); + // A linha abaixo nunca deve acontecer (vai redirecionar), mas mantém o contrato + throw new Error("Not authenticated"); + } + + const headers = new Headers(init.headers || {}); + headers.set("Authorization", `Bearer ${token}`); + + return fetch(input, { + ...init, + headers, + }); +} diff --git a/src/auth/userManager.js b/src/auth/userManager.js new file mode 100644 index 0000000..86f33d1 --- /dev/null +++ b/src/auth/userManager.js @@ -0,0 +1,37 @@ +import { UserManager, WebStorageStateStore, Log } from "oidc-client-ts"; + +// Opcional: habilite logs enquanto estiver configurando +// Log.setLogger(console); +// Log.setLevel(Log.DEBUG); + +// Configure estes valores via .env (recomendado) ou altere aqui. +// CRA só expõe variáveis que começam com REACT_APP_ +const AUTHENTIK_AUTHORITY = + process.env.REACT_APP_AUTHENTIK_AUTHORITY || + "https://auth.seu-dominio.com/application/o/seu-app/"; + +const AUTHENTIK_CLIENT_ID = + process.env.REACT_APP_AUTHENTIK_CLIENT_ID || "SEU_CLIENT_ID"; + +export const userManager = new UserManager({ + authority: AUTHENTIK_AUTHORITY, + client_id: AUTHENTIK_CLIENT_ID, + redirect_uri: `${window.location.origin}/callback`, + post_logout_redirect_uri: `${window.location.origin}/`, + response_type: "code", + scope: "openid profile email", + + // Persistência + userStore: new WebStorageStateStore({ store: window.localStorage }), + stateStore: new WebStorageStateStore({ store: window.localStorage }), + + // Em Authentik, normalmente funciona sem silent renew. + // Se quiser renovar automaticamente, você pode adicionar silent_redirect_uri + // e criar o public/silent-renew.html. +}); + +export async function getAccessToken() { + const user = await userManager.getUser(); + if (!user || user.expired) return null; + return user.access_token; +} diff --git a/src/index.js b/src/index.js index 77ef353..e591d76 100644 --- a/src/index.js +++ b/src/index.js @@ -2,10 +2,13 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; +import AuthBootstrap from './AuthBootstrap'; const root = ReactDOM.createRoot(document.getElementById('root')); root.render( // - + + + // ); \ No newline at end of file