login and tokens - test

This commit is contained in:
Ricardo Zylbergeld 2025-04-14 19:51:06 +03:00
parent 99c0ea9752
commit c75dc399d5
8 changed files with 210 additions and 9 deletions

1
.gitignore vendored
View file

@ -46,3 +46,4 @@ vitest.config.*.timestamp*
# Env Files
.env
.env.local

View file

@ -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<string>();
const refreshTokens = new Set<string>(); // 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);
}
});

103
apps/frontend/src/App.tsx Normal file
View file

@ -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 <div>Loading...</div>;
return (
<div>
<h1>Hybrid Auth Flow</h1>
{!accessToken ? (
<>
<LoginArea />
<Protected />
</>
) : (
<>
<LogoutButton />
<Protected />
</>
)}
</div>
);
}
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 (
<>
<div>
<input
type="text"
name="email"
value={email}
onChange={(e) => _email(e.target.value)}
/>
</div>
<div>
<input
type="password"
name="password"
value={password}
onChange={(e) => _password(e.target.value)}
/>
</div>
<div>
<button onClick={login}>Login</button>
</div>
</>
);
}
function LogoutButton() {
return <button onClick={logout}>Logout</button>;
}
function Protected() {
const callAPI = async () => {
try {
const res = await api.get('/protected');
alert(res.data.message);
} catch {
alert('Unauthorized');
}
};
return <button onClick={callAPI}>Call Protected Route</button>;
}

View file

@ -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<string> {
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);
}

View file

@ -0,0 +1,11 @@
import { create } from 'zustand';
type AuthState = {
accessToken: string | null;
setAccessToken: (token: string | null) => void;
};
export const useAuthStore = create<AuthState>((set) => ({
accessToken: null,
setAccessToken: (token) => set({ accessToken: token }),
}));

View file

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

View file

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