Skip to content

Commit 1f6fa38

Browse files
committed
refactor: improved login flow (#56)
* style: update GitHubButton styles for improved layout - Added a margin of 1px to the GitHubButton for better spacing. - Reduced font size from 1.05rem to 0.8rem for a more compact appearance. * feat: integrate Redis for session management - Added Redis service to docker-compose for session storage. - Implemented Redis connection in config.py and updated session management functions. - Refactored dependencies to utilize Redis for session retrieval and deletion. - Updated requirements.txt to include Redis library. * feat: implement token expiration handling and refresh mechanism - Added functions to check if the access token is expired and to refresh the token using the refresh token. - Updated dependencies to utilize the new token management functions in the authentication flow. - Enhanced the login endpoint to support session management with token state. * feat: add endpoint to retrieve active session count from Redis - Implemented a new GET endpoint `/count` to return the number of active sessions stored in Redis. - The endpoint requires authentication and returns a dictionary with the active session count and a message. - Integrated Redis client to facilitate session counting. * feat: enhance session management with token expiration handling - Updated the session management to include token expiration by passing the expiry time to the set_session function. - Improved the authentication callback to handle token data more effectively. * feat: enhance authentication flow and session management - Added frontend URL to OIDC configuration for improved redirect handling. - Updated session management to pass token expiration to the set_session function. - Modified logout endpoint to return a logout URL for Keycloak, enhancing user experience. - Adjusted CORS settings to restrict allowed origins for better security. * feat: implement logout functionality with Keycloak iframe handling - Added a new handleLogout function to manage user logout via an iframe for Keycloak. - Removed the previous inline logout logic from the MainMenu.Item component. - Enhanced error handling and session invalidation upon logout completion. * feat: add Redis configuration and update docker-compose for password authentication - Added Redis password configuration to the .env.template file. - Updated the Redis service command in docker-compose to require the Redis password. - Enhanced README to include Redis setup instructions and password usage. * feat: add API workers configuration to environment and startup script - Introduced API_WORKERS variable in .env.template for configurable worker count. - Updated startup.sh to utilize the API_WORKERS variable when starting the application with uvicorn. * feat: add PostHog configuration to runtime settings - Updated startup script to include PostHog key and host in runtime configuration. - Enhanced global TypeScript definitions to accommodate new PostHog properties. - Modified PostHog initialization to utilize runtime configuration values, improving flexibility. * feat: add Redis password configuration to Redis client in config.py - Updated the Redis client initialization to include password retrieval from environment variables, enhancing security and flexibility in Redis connections.
1 parent 0514a4a commit 1f6fa38

File tree

14 files changed

+293
-60
lines changed

14 files changed

+293
-60
lines changed

.env.template

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ KEYCLOAK_PORT=8080
44
CODER_PORT=7080
55
APP_PORT=8000
66

7+
# API Configuration
8+
API_WORKERS=4
9+
710
# Database Configuration
811
POSTGRES_USER=admin
912
POSTGRES_PASSWORD=admin123
1013
POSTGRES_DB=pad
1114

15+
# Redis Configuration
16+
REDIS_PASSWORD=redis123
17+
1218
# Keycloak Configuration
1319
KEYCLOAK_ADMIN=admin
1420
KEYCLOAK_ADMIN_PASSWORD=admin123

README.md

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,16 @@ This simplified example lets you host pad on `localhost` but is not safe for rea
5050
docker compose up -d postgres
5151
```
5252

53-
### 3️⃣ Keycloak 🔑
53+
### 3️⃣ Redis 🔄
54+
> In-memory data store for caching and session management with password authentication
55+
56+
* Run the Redis container with password authentication
57+
```bash
58+
docker compose up -d redis
59+
```
60+
* The Redis password is configured in your `.env` file using the `REDIS_PASSWORD` variable
61+
62+
### 4️⃣ Keycloak 🔑
5463
> OIDC provider for access and user management (within coder and pad app)
5564
* Run the Keycloak container
5665
```bash
@@ -78,7 +87,7 @@ This simplified example lets you host pad on `localhost` but is not safe for rea
7887
* **Important:** Tick `Email verified`
7988
* Go to the `Credentials` tab for the new user and set a password
8089

81-
### 4️⃣ Coder 🧑‍💻
90+
### 5️⃣ Coder 🧑‍💻
8291

8392
* **Find Docker Group ID:** You'll need this to grant necessary permissions
8493
```bash
@@ -119,7 +128,7 @@ This simplified example lets you host pad on `localhost` but is not safe for rea
119128
CODER_DEFAULT_ORGANIZATION=your_organization_id # Example: 70f6af06-ef3a-4b4c-a663-c03c9ee423bb
120129
```
121130
122-
### 5️⃣ Pad App 📝
131+
### 6️⃣ Pad App 📝
123132
> The fastAPI app that both serves the build frontend and the backend API to interface with Coder
124133
125134
* **Run the Application:**
@@ -139,8 +148,3 @@ This simplified example lets you host pad on `localhost` but is not safe for rea
139148
## 🚀 Project Growth
140149
141150
[![Star History Chart](https://api.star-history.com/svg?repos=pad-ws/pad.ws&type=Date)](https://star-history.com/#pad-ws/pad.ws&Date)
142-
143-
144-
145-
146-

docker-compose.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,17 @@ services:
1212
- postgres_data:/var/lib/postgresql/data
1313
restart: unless-stopped
1414
network_mode: host
15+
16+
redis:
17+
image: redis:alpine
18+
container_name: redis
19+
ports:
20+
- "6379:6379"
21+
volumes:
22+
- redis_data:/data
23+
restart: unless-stopped
24+
command: redis-server --requirepass ${REDIS_PASSWORD} --save 60 1 --loglevel warning
25+
network_mode: host
1526

1627
keycloak:
1728
image: quay.io/keycloak/keycloak:25.0
@@ -73,6 +84,9 @@ services:
7384
- POSTGRES_DB=${POSTGRES_DB}
7485
- POSTGRES_HOST=localhost
7586
- POSTGRES_PORT=${POSTGRES_PORT}
87+
- REDIS_HOST=localhost
88+
- REDIS_PORT=6379
89+
- REDIS_PASSWORD=${REDIS_PASSWORD}
7690
- CODER_API_KEY=${CODER_API_KEY}
7791
- CODER_URL=http://localhost:${CODER_PORT}
7892
- CODER_TEMPLATE_ID=${CODER_TEMPLATE_ID}
@@ -81,3 +95,4 @@ services:
8195

8296
volumes:
8397
postgres_data:
98+
redis_data:

scripts/startup.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ mkdir -p /app/frontend/dist/assets
66
cat > /app/frontend/dist/assets/runtime-config.js <<EOL
77
window.RUNTIME_CONFIG = {
88
CODER_URL: "${CODER_URL}"
9+
VITE_PUBLIC_POSTHOG_KEY: "${VITE_PUBLIC_POSTHOG_KEY}"
10+
VITE_PUBLIC_POSTHOG_HOST: "${VITE_PUBLIC_POSTHOG_HOST}"
911
};
1012
EOL
1113

1214
# Start the application
13-
exec uvicorn main:app --host 0.0.0.0 --port 8000
15+
exec uvicorn main:app --host 0.0.0.0 --port 8000 --workers $API_WORKERS

src/backend/config.py

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
import os
2+
import json
3+
import time
4+
import httpx
5+
import redis
6+
import jwt
7+
from typing import Optional, Dict, Any, Tuple
28
from dotenv import load_dotenv
39

410
load_dotenv()
@@ -11,10 +17,39 @@
1117
'client_secret': os.getenv('OIDC_CLIENT_SECRET'),
1218
'server_url': os.getenv('OIDC_SERVER_URL'),
1319
'realm': os.getenv('OIDC_REALM'),
14-
'redirect_uri': os.getenv('REDIRECT_URI')
20+
'redirect_uri': os.getenv('REDIRECT_URI'),
21+
'frontend_url': os.getenv('FRONTEND_URL')
1522
}
1623

17-
sessions = {}
24+
# Redis connection
25+
redis_client = redis.Redis(
26+
host=os.getenv('REDIS_HOST', 'localhost'),
27+
password=os.getenv('REDIS_PASSWORD', None),
28+
port=int(os.getenv('REDIS_PORT', 6379)),
29+
db=0,
30+
decode_responses=True
31+
)
32+
33+
# Session management functions
34+
def get_session(session_id: str) -> Optional[Dict[str, Any]]:
35+
"""Get session data from Redis"""
36+
session_data = redis_client.get(f"session:{session_id}")
37+
if session_data:
38+
return json.loads(session_data)
39+
return None
40+
41+
def set_session(session_id: str, data: Dict[str, Any], expiry: int) -> None:
42+
"""Store session data in Redis with expiry in seconds"""
43+
redis_client.setex(
44+
f"session:{session_id}",
45+
expiry,
46+
json.dumps(data)
47+
)
48+
49+
def delete_session(session_id: str) -> None:
50+
"""Delete session data from Redis"""
51+
redis_client.delete(f"session:{session_id}")
52+
1853
provisioning_times = {}
1954

2055
def get_auth_url() -> str:
@@ -23,10 +58,81 @@ def get_auth_url() -> str:
2358
params = {
2459
'client_id': OIDC_CONFIG['client_id'],
2560
'response_type': 'code',
26-
'redirect_uri': OIDC_CONFIG['redirect_uri']
61+
'redirect_uri': OIDC_CONFIG['redirect_uri'],
62+
'scope': 'openid profile email'
2763
}
2864
return f"{auth_url}?{'&'.join(f'{k}={v}' for k,v in params.items())}"
2965

3066
def get_token_url() -> str:
3167
"""Get the token endpoint URL"""
3268
return f"{OIDC_CONFIG['server_url']}/realms/{OIDC_CONFIG['realm']}/protocol/openid-connect/token"
69+
70+
def is_token_expired(token_data: Dict[str, Any], buffer_seconds: int = 30) -> bool:
71+
"""
72+
Check if the access token is expired or about to expire
73+
74+
Args:
75+
token_data: The token data containing the access token
76+
buffer_seconds: Buffer time in seconds to refresh token before it actually expires
77+
78+
Returns:
79+
bool: True if token is expired or about to expire, False otherwise
80+
"""
81+
if not token_data or 'access_token' not in token_data:
82+
return True
83+
84+
try:
85+
# Decode the JWT token without verification to get expiration time
86+
decoded = jwt.decode(token_data['access_token'], options={"verify_signature": False})
87+
88+
# Get expiration time from token
89+
exp_time = decoded.get('exp', 0)
90+
91+
# Check if token is expired or about to expire (with buffer)
92+
current_time = time.time()
93+
return current_time + buffer_seconds >= exp_time
94+
except Exception as e:
95+
print(f"Error checking token expiration: {str(e)}")
96+
return True
97+
98+
async def refresh_token(session_id: str, token_data: Dict[str, Any]) -> Tuple[bool, Dict[str, Any]]:
99+
"""
100+
Refresh the access token using the refresh token
101+
102+
Args:
103+
session_id: The session ID
104+
token_data: The current token data containing the refresh token
105+
106+
Returns:
107+
Tuple[bool, Dict[str, Any]]: Success status and updated token data
108+
"""
109+
if not token_data or 'refresh_token' not in token_data:
110+
return False, token_data
111+
112+
try:
113+
async with httpx.AsyncClient() as client:
114+
refresh_response = await client.post(
115+
get_token_url(),
116+
data={
117+
'grant_type': 'refresh_token',
118+
'client_id': OIDC_CONFIG['client_id'],
119+
'client_secret': OIDC_CONFIG['client_secret'],
120+
'refresh_token': token_data['refresh_token']
121+
}
122+
)
123+
124+
if refresh_response.status_code != 200:
125+
print(f"Token refresh failed: {refresh_response.text}")
126+
return False, token_data
127+
128+
# Get new token data
129+
new_token_data = refresh_response.json()
130+
131+
# Update session with new tokens
132+
expiry = new_token_data['expires_in']
133+
set_session(session_id, new_token_data, expiry)
134+
135+
return True, new_token_data
136+
except Exception as e:
137+
print(f"Error refreshing token: {str(e)}")
138+
return False, token_data

src/backend/dependencies.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from typing import Optional
22
from fastapi import Request, HTTPException, Depends
33

4-
from config import sessions
4+
from config import get_session, is_token_expired, refresh_token
55

66
class SessionData:
77
def __init__(self, access_token: str, token_data: dict):
@@ -15,7 +15,7 @@ def __init__(self, auto_error: bool = True):
1515
async def __call__(self, request: Request) -> Optional[SessionData]:
1616
session_id = request.cookies.get('session_id')
1717

18-
if not session_id or session_id not in sessions:
18+
if not session_id:
1919
if self.auto_error:
2020
raise HTTPException(
2121
status_code=401,
@@ -24,7 +24,32 @@ async def __call__(self, request: Request) -> Optional[SessionData]:
2424
)
2525
return None
2626

27-
session = sessions[session_id]
27+
session = get_session(session_id)
28+
if not session:
29+
if self.auto_error:
30+
raise HTTPException(
31+
status_code=401,
32+
detail="Not authenticated",
33+
headers={"WWW-Authenticate": "Bearer"},
34+
)
35+
return None
36+
37+
# Check if token is expired and refresh if needed
38+
if is_token_expired(session):
39+
# Try to refresh the token
40+
success, new_session = await refresh_token(session_id, session)
41+
if not success:
42+
# Token refresh failed, user needs to re-authenticate
43+
if self.auto_error:
44+
raise HTTPException(
45+
status_code=401,
46+
detail="Session expired",
47+
headers={"WWW-Authenticate": "Bearer"},
48+
)
49+
return None
50+
# Use the refreshed token data
51+
session = new_session
52+
2853
return SessionData(
2954
access_token=session.get('access_token'),
3055
token_data=session

src/backend/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ async def lifespan(_: FastAPI):
3737
# CORS middleware setup
3838
app.add_middleware(
3939
CORSMiddleware,
40-
allow_origins=["*"],
40+
allow_origins=["https://kc.pad.ws", "https://alex.pad.ws"],
4141
allow_credentials=True,
4242
allow_methods=["*"],
4343
allow_headers=["*"],

src/backend/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ PyJWT
88
requests
99
sqlalchemy
1010
posthog
11+
redis

src/backend/routers/auth.py

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
import jwt
33
import httpx
44
from fastapi import APIRouter, Request, HTTPException, Depends
5-
from fastapi.responses import RedirectResponse, FileResponse
5+
from fastapi.responses import RedirectResponse, FileResponse, JSONResponse
66
import os
77

8-
from config import get_auth_url, get_token_url, OIDC_CONFIG, sessions, STATIC_DIR
8+
from config import get_auth_url, get_token_url, OIDC_CONFIG, set_session, delete_session, STATIC_DIR, get_session
99
from dependencies import SessionData, require_auth
1010
from coder import CoderAPI
1111

@@ -14,13 +14,18 @@
1414

1515
@auth_router.get("/login")
1616
async def login(request: Request, kc_idp_hint: str = None, popup: str = None):
17+
1718
session_id = secrets.token_urlsafe(32)
19+
1820
auth_url = get_auth_url()
1921
state = "popup" if popup == "1" else "default"
22+
2023
if kc_idp_hint:
2124
auth_url = f"{auth_url}&kc_idp_hint={kc_idp_hint}"
25+
2226
# Add state param to OIDC URL
2327
auth_url = f"{auth_url}&state={state}"
28+
2429
response = RedirectResponse(auth_url)
2530
response.set_cookie('session_id', session_id)
2631

@@ -48,8 +53,10 @@ async def callback(request: Request, code: str, state: str = "default"):
4853
if token_response.status_code != 200:
4954
raise HTTPException(status_code=400, detail="Auth failed")
5055

51-
sessions[session_id] = token_response.json()
52-
access_token = token_response.json()['access_token']
56+
token_data = token_response.json()
57+
expiry = token_data['expires_in']
58+
set_session(session_id, token_data, expiry)
59+
access_token = token_data['access_token']
5360
user_info = jwt.decode(access_token, options={"verify_signature": False})
5461

5562
try:
@@ -65,24 +72,26 @@ async def callback(request: Request, code: str, state: str = "default"):
6572
return FileResponse(os.path.join(STATIC_DIR, "auth/popup-close.html"))
6673
else:
6774
return RedirectResponse('/')
68-
75+
6976
@auth_router.get("/logout")
7077
async def logout(request: Request):
7178
session_id = request.cookies.get('session_id')
72-
if session_id in sessions:
73-
del sessions[session_id]
7479

75-
# Create a response that doesn't redirect but still clears the cookie
76-
from fastapi.responses import JSONResponse
77-
response = JSONResponse({"status": "success", "message": "Logged out successfully"})
80+
session_data = get_session(session_id)
81+
if not session_data:
82+
return RedirectResponse('/')
83+
84+
id_token = session_data.get('id_token', '')
85+
86+
# Delete the session from Redis
87+
delete_session(session_id)
88+
89+
# Create the Keycloak logout URL with redirect back to our app
90+
logout_url = f"{OIDC_CONFIG['server_url']}/realms/{OIDC_CONFIG['realm']}/protocol/openid-connect/logout"
91+
redirect_uri = OIDC_CONFIG['frontend_url'] # Match the frontend redirect URI
92+
full_logout_url = f"{logout_url}?id_token_hint={id_token}&post_logout_redirect_uri={redirect_uri}"
7893

79-
# Clear the session_id cookie with all necessary parameters
80-
response.delete_cookie(
81-
key="session_id",
82-
path="/",
83-
domain=None, # Use None to match the current domain
84-
secure=request.url.scheme == "https",
85-
httponly=True
86-
)
94+
# Create a redirect response to Keycloak's logout endpoint
95+
response = JSONResponse({"status": "success", "logout_url": full_logout_url})
8796

8897
return response

0 commit comments

Comments
 (0)