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:
parent
90b427c5b7
commit
3e28d115e1
22
package-lock.json
generated
22
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
16
src/App.js
16
src/App.js
@ -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
82
src/AuthBootstrap.js
Normal 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
24
src/auth/authFetch.js
Normal 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
37
src/auth/userManager.js
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
<App />
|
<AuthBootstrap>
|
||||||
|
<App />
|
||||||
|
</AuthBootstrap>
|
||||||
// </React.StrictMode>
|
// </React.StrictMode>
|
||||||
);
|
);
|
||||||
Loading…
Reference in New Issue
Block a user