diff --git a/.gitignore b/.gitignore index 8747d05..0a791e7 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ vitest.config.*.timestamp* # Env Files .env +.env.local diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index d9a11c0..cd1b5f5 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -21,19 +21,31 @@ const app = express(); app.use(express.json()); app.use(cookieParser()); -app.use(cors({ origin: 'http://localhost:3000', credentials: true })); // TODO: for dev purposes because it will be the same server on production? +app.use(cors({ origin: 'http://localhost:4200', credentials: true })); // TODO: for dev purposes because it will be the same server on production? app.use(express.json()); -const refreshTokens = new Set(); +const refreshTokens = new Set(); // TODO: ideally store in DB // TODO: simplify const generateAccessToken = (user: User): string => { - const cleanUser = { ...user, hashed_password: undefined }; - return jwt.sign(cleanUser, ACCESS_SECRET, { expiresIn: '15s' }); + return jwt.sign( + { + id: user.id, + name: user.name, + }, + ACCESS_SECRET, + { expiresIn: '15s' } + ); }; const generateRefreshToken = (user: User): string => { - const cleanUser = { ...user, hashed_password: undefined }; - return jwt.sign(cleanUser, REFRESH_SECRET, { expiresIn: '7d' }); + return jwt.sign( + { + id: user.id, + name: user.name, + }, + REFRESH_SECRET, + { expiresIn: '7d' } + ); }; // TODO: organize in services folder @@ -75,13 +87,17 @@ app.post('/login', async (req: Request, res: Response) => { app.post('/refresh', (req: Request, res: Response) => { const token = req.cookies?.refreshToken; - if (!token || !refreshTokens.has(token)) return res.sendStatus(403); + + // TODO: voltar a verificaço abaixo, quando refresh tokens estiver em DB + // estudar melhor (exemplo, verificar expiração de refresh token etc) + if (!token /*|| !refreshTokens.has(token)*/) return res.sendStatus(403); try { const user = jwt.verify(token, REFRESH_SECRET) as User; const accessToken = generateAccessToken(user); res.json({ accessToken }); - } catch { + } catch (e) { + console.log(e); res.sendStatus(403); } }); diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx new file mode 100644 index 0000000..1b342de --- /dev/null +++ b/apps/frontend/src/App.tsx @@ -0,0 +1,103 @@ +import { useEffect, useState } from 'react'; +import { useAuthStore } from './auth/authStore'; +import { refreshToken, logout } from './auth/auth-utils'; +import api from './auth/axiosInstance'; + +export default function App() { + const accessToken = useAuthStore((s) => s.accessToken); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const init = async () => { + try { + await refreshToken(); + } catch {} + setLoading(false); + }; + init(); + }, []); + + if (loading) return
Loading...
; + + return ( +
+

Hybrid Auth Flow

+ {!accessToken ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ); +} + +function LoginArea() { + const setAccessToken = useAuthStore((s) => s.setAccessToken); + + const [email, _email] = useState(''); + const [password, _password] = useState(''); + + const login = async () => { + const res = await fetch(`${import.meta.env.VITE_SERVER}/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ + email, + password, + }), + }); + const data = await res.json(); + setAccessToken(data.accessToken); + }; + + return ( + <> +
+ _email(e.target.value)} + /> +
+
+ _password(e.target.value)} + /> +
+ +
+ +
+ + ); +} + +function LogoutButton() { + return ; +} + +function Protected() { + const callAPI = async () => { + try { + const res = await api.get('/protected'); + alert(res.data.message); + } catch { + alert('Unauthorized'); + } + }; + + return ; +} diff --git a/apps/frontend/src/app/app.tsx b/apps/frontend/src/app/app_copy.tsx similarity index 100% rename from apps/frontend/src/app/app.tsx rename to apps/frontend/src/app/app_copy.tsx diff --git a/apps/frontend/src/auth/auth-utils.ts b/apps/frontend/src/auth/auth-utils.ts new file mode 100644 index 0000000..7d8c54c --- /dev/null +++ b/apps/frontend/src/auth/auth-utils.ts @@ -0,0 +1,32 @@ +import { useAuthStore } from './authStore'; + +// TODO: remover? +export function getAccessToken(): string | null { + return useAuthStore.getState().accessToken; +} + +// TODO: precisa exportar? +export function setAccessToken(token: string | null) { + useAuthStore.getState().setAccessToken(token); +} + +export async function refreshToken(): Promise { + const res = await fetch(`${import.meta.env.VITE_SERVER}/refresh`, { + method: 'POST', + credentials: 'include', + }); + + if (!res.ok) throw new Error('Unable to refresh token'); + + const data = await res.json(); + setAccessToken(data.accessToken); + return data.accessToken; +} + +export async function logout() { + await fetch(`${import.meta.env.VITE_SERVER}/logout`, { + method: 'POST', + credentials: 'include', + }); + setAccessToken(null); +} diff --git a/apps/frontend/src/auth/authStore.ts b/apps/frontend/src/auth/authStore.ts new file mode 100644 index 0000000..e5b96b5 --- /dev/null +++ b/apps/frontend/src/auth/authStore.ts @@ -0,0 +1,11 @@ +import { create } from 'zustand'; + +type AuthState = { + accessToken: string | null; + setAccessToken: (token: string | null) => void; +}; + +export const useAuthStore = create((set) => ({ + accessToken: null, + setAccessToken: (token) => set({ accessToken: token }), +})); diff --git a/apps/frontend/src/auth/axiosInstance.ts b/apps/frontend/src/auth/axiosInstance.ts new file mode 100644 index 0000000..823fd49 --- /dev/null +++ b/apps/frontend/src/auth/axiosInstance.ts @@ -0,0 +1,38 @@ +import axios, { AxiosError } from 'axios'; +import { getAccessToken, refreshToken } from './auth-utils'; + +const api = axios.create({ + baseURL: import.meta.env.VITE_SERVER, + withCredentials: true, +}); + +api.interceptors.request.use((config) => { + const token = getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +api.interceptors.response.use( + (res) => res, + async (err: AxiosError) => { + const originalRequest = err.config as any; + if ( + (err.response?.status === 401 || err.response?.status === 403) && + !originalRequest._retry + ) { + originalRequest._retry = true; + try { + const newToken = await refreshToken(); + originalRequest.headers.Authorization = `Bearer ${newToken}`; + return api(originalRequest); + } catch { + return Promise.reject(err); + } + } + return Promise.reject(err); + } +); + +export default api; diff --git a/apps/frontend/src/main.tsx b/apps/frontend/src/main.tsx index 215367c..c9e8430 100644 --- a/apps/frontend/src/main.tsx +++ b/apps/frontend/src/main.tsx @@ -1,7 +1,7 @@ import { StrictMode } from 'react'; import { BrowserRouter } from 'react-router-dom'; import * as ReactDOM from 'react-dom/client'; -import App from './app/app'; +import App from './App'; const root = ReactDOM.createRoot( document.getElementById('root') as HTMLElement