Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 35 additions & 2 deletions backend/app/routers/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from fastapi import Request, APIRouter
from fastapi import Request, APIRouter, HTTPException
from fastapi.responses import RedirectResponse
from ..crud.keycloak import oauth
from ..config import get_settings
import httpx2
from urllib.parse import urlencode


Expand Down Expand Up @@ -36,4 +37,36 @@ async def logout(id_token: str | None = None):
else:
params["client_id"] = settings.keycloak_client_id

return RedirectResponse(f"{end_session_endpoint}?{urlencode(params)}")
return RedirectResponse(f"{end_session_endpoint}?{urlencode(params)}")


@auth_router.post("/auth/refresh")
async def refresh(request: Request):
"""Refresh access token using Keycloak refresh token.

Expects JSON body: {"refresh_token": "..."}
Returns new tokens (access_token, refresh_token, id_token).
"""
body = await request.json()
refresh_token = body.get("refresh_token")
if not refresh_token:
raise HTTPException(status_code=400, detail="refresh_token missing")
token_url = f"{settings.keycloak_internal_url}/realms/{settings.keycloak_realm}/protocol/openid-connect/token"
async with httpx2.AsyncClient() as client:
resp = await client.post(
token_url,
data={
"grant_type": "refresh_token",
"client_id": settings.keycloak_client_id,
"client_secret": settings.keycloak_client_secret,
"refresh_token": refresh_token,
},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
resp.raise_for_status()
token = resp.json()
return {
"access_token": token.get("access_token"),
"refresh_token": token.get("refresh_token", ""),
"id_token": token.get("id_token", ""),
}
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
services:
keycloak:
image: quay.io/keycloak/keycloak:latest
command: start-dev --import-realm
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
Expand All @@ -14,7 +14,7 @@ services:
- "8080:8080"
volumes:
- keycloak_data:/opt/keycloak/data
- ./dev-realm.json:/opt/keycloak/data/import/dev-realm.json
- ./knowledge-hub-dev-realm.json:/opt/keycloak/data/import/knowledge-hub-dev-realm.json

db:
image: postgres:16
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, {...opts, headers});

if (!res.ok) {
// Attempt automatic token refresh on 401
if (res.status === 401) {
const refresh = localStorage.getItem('refresh_token');
if (refresh) {
try {
const newTokens = await api.refreshToken(refresh);
// Update stored tokens
localStorage.setItem('access_token', newTokens.access_token);
if (newTokens.refresh_token) localStorage.setItem('refresh_token', newTokens.refresh_token);
if (newTokens.id_token) localStorage.setItem('id_token', newTokens.id_token);
// Retry the original request with new access token
const retryHeaders = new Headers(opts.headers);
retryHeaders.set('Authorization', `Bearer ${newTokens.access_token}`);
const retryRes = await fetch(`${BASE}${path}`, {...opts, headers: retryHeaders});
if (retryRes.ok) {
if (retryRes.status === 204) return null as T;
return retryRes.json() as Promise<T>;
}
// If retry also fails, fall through to error handling below
let retryDetail: unknown;
try {
retryDetail = await retryRes.json();
} catch {
retryDetail = await retryRes.text();
}
throw new ApiError(retryRes.status, retryDetail);
} catch (refreshErr) {
// Refresh failed – proceed to original error handling
console.error('Token refresh failed:', refreshErr);
}
}
}
// Original error handling
let detail: unknown;
try {
detail = await res.json();
Expand All @@ -66,6 +99,7 @@ async function request<T>(path: string, opts: RequestInit = {}): Promise<T> {
export const api = {
// ---Auth---
login: (username: string, password: string) => {
// existing login remains
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
Expand All @@ -75,6 +109,13 @@ export const api = {
body: formData,
});
},
// Refresh token using stored refresh token
refreshToken: (refreshToken: string) =>
request<{ access_token: string; refresh_token: string; id_token: string }>("/auth/refresh", {
method: "POST",
body: JSON.stringify({ refresh_token: refreshToken }),
headers: { "Content-Type": "application/json" },
}),

register: (data: UserCreate) =>
request<User>("/register", {
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
}
}
initializeAuth();
}, []);

Check warning on line 24 in frontend/src/auth/AuthProvider.tsx

View workflow job for this annotation

GitHub Actions / test-frontend

React Hook useEffect has a missing dependency: 'initializeAuth'. Either include it or remove the dependency array

const initializeAuth = async () => {
const token = localStorage.getItem('access_token');
Expand All @@ -38,6 +38,23 @@
setUser(userData);
} catch (error) {
console.error('Fehler beim Laden des Benutzers:', error);
// Try to refresh token if possible
const refresh = localStorage.getItem('refresh_token');
if (refresh) {
try {
const newTokens = await api.refreshToken(refresh);
localStorage.setItem('access_token', newTokens.access_token);
if (newTokens.refresh_token) localStorage.setItem('refresh_token', newTokens.refresh_token);
if (newTokens.id_token) localStorage.setItem('id_token', newTokens.id_token);
// Retry fetching user
const userData = await api.getCurrentUser();
setUser(userData);
setIsLoading(false);
return;
} catch (refreshError) {
console.error('Refresh failed:', refreshError);
}
}
logout();
} finally {
setIsLoading(false);
Expand Down
File renamed without changes.
Loading