1
0

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.
This commit is contained in:
Márcio Eric 2026-01-14 19:55:24 -03:00
parent 90b427c5b7
commit 3e28d115e1
7 changed files with 180 additions and 7 deletions

22
package-lock.json generated
View File

@ -14,6 +14,7 @@
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"oidc-client-ts": "^3.1.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
@ -10949,6 +10950,15 @@
"node": ">=4.0" "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": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@ -11757,6 +11767,18 @@
"integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==",
"license": "MIT" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",

View File

@ -8,6 +8,7 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.1", "@testing-library/react": "^16.3.1",
"@testing-library/user-event": "^13.5.0", "@testing-library/user-event": "^13.5.0",
"oidc-client-ts": "^3.1.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"react": "^19.2.3", "react": "^19.2.3",
"react-dom": "^19.2.3", "react-dom": "^19.2.3",

View File

@ -1,6 +1,8 @@
import React, { useState, useEffect, useRef } from 'react'; 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 { 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 * as signalR from '@microsoft/signalr';
import { authFetch } from './auth/authFetch';
import { getAccessToken } from './auth/userManager';
const API_BASE = 'http://localhost:5123'; const API_BASE = 'http://localhost:5123';
@ -36,7 +38,9 @@ const WebScraper = () => {
// Conectar ao SignalR // Conectar ao SignalR
useEffect(() => { useEffect(() => {
const connection = new signalR.HubConnectionBuilder() const connection = new signalR.HubConnectionBuilder()
.withUrl(`http://localhost:5123/ws/scrape`) .withUrl(`${API_BASE}/ws/scrape`, {
accessTokenFactory: async () => (await getAccessToken()) ?? ''
})
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
@ -208,7 +212,7 @@ const WebScraper = () => {
const loadSessions = async () => { const loadSessions = async () => {
try { try {
const response = await fetch(`${API_BASE}/sessions`); const response = await authFetch(`${API_BASE}/sessions`);
const data = await response.json(); const data = await response.json();
setSessions(data); setSessions(data);
} catch (err) { } catch (err) {
@ -218,7 +222,7 @@ const WebScraper = () => {
const loadSessionStatus = async (sessionId) => { const loadSessionStatus = async (sessionId) => {
try { 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(); const data = await response.json();
setSessionStatus(data); setSessionStatus(data);
} catch (err) { } catch (err) {
@ -228,7 +232,7 @@ const WebScraper = () => {
const startScrapingById = async (sessionId, sessionName) => { const startScrapingById = async (sessionId, sessionName) => {
try { try {
await fetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, { await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/start`, {
method: 'POST' method: 'POST'
}); });
addLog(`Iniciando scraping da sessão: ${sessionName}`, 'info'); addLog(`Iniciando scraping da sessão: ${sessionName}`, 'info');
@ -239,7 +243,7 @@ const WebScraper = () => {
const stopScrapingById = async (sessionId, sessionName) => { const stopScrapingById = async (sessionId, sessionName) => {
try { try {
await fetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, { await authFetch(`${API_BASE}/sessions/${sessionId}/scrap/stop`, {
method: 'POST' method: 'POST'
}); });
addLog(`Parando sessão: ${sessionName}`, 'warning'); addLog(`Parando sessão: ${sessionName}`, 'warning');
@ -262,7 +266,7 @@ const WebScraper = () => {
if (!selectedSession || !newUrl) return; if (!selectedSession || !newUrl) return;
try { try {
const response = await fetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, { const response = await authFetch(`${API_BASE}/sessions/${selectedSession.sessionId}/queue`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newUrl }) body: JSON.stringify({ url: newUrl })

82
src/AuthBootstrap.js Normal file
View File

@ -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 (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
}}>
Carregando autenticação...
</div>
);
}
if (error) {
return (
<div style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 24,
fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, sans-serif"
}}>
<div>
<h2 style={{ marginBottom: 12 }}>Erro na autenticação</h2>
<pre style={{ whiteSpace: "pre-wrap" }}>{error}</pre>
<button
onClick={() => userManager.signinRedirect()}
style={{ marginTop: 12, padding: "8px 12px" }}
>
Tentar novamente
</button>
</div>
</div>
);
}
return children;
}

24
src/auth/authFetch.js Normal file
View File

@ -0,0 +1,24 @@
import { getAccessToken, userManager } from "./userManager";
/**
* fetch() que injeta automaticamente Authorization: Bearer <token>
* 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,
});
}

37
src/auth/userManager.js Normal file
View File

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

View File

@ -2,10 +2,13 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import './index.css'; import './index.css';
import App from './App'; import App from './App';
import AuthBootstrap from './AuthBootstrap';
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
// <React.StrictMode> // <React.StrictMode>
<AuthBootstrap>
<App /> <App />
</AuthBootstrap>
// </React.StrictMode> // </React.StrictMode>
); );