Skip to content

Commit 220c12a

Browse files
committed
update redis configuration and README.md
1 parent b41b616 commit 220c12a

File tree

10 files changed

+325
-228
lines changed

10 files changed

+325
-228
lines changed

.github/workflows/build-docker-proxy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ jobs:
4141
- name: Build and push docker image
4242
uses: docker/build-push-action@v6
4343
with:
44-
context: nginx
44+
context: proxy
4545
push: true
4646
tags: ${{ steps.meta.outputs.tags }}
4747
labels: ${{ steps.meta.outputs.labels }}

README.md

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,91 @@
55
---
66

77
## 🚀 Quick Start
8+
9+
If you simply want to test the application without modifying code, use the production compose file. This pulls official images and runs them behind a reverse proxy.
10+
11+
1. **Run the Stack**
12+
```bash
13+
docker-compose up -d
14+
```
15+
16+
2. **Access the App**
17+
* **App URL:** `http://localhost:80`
18+
* **User:** `test` / `test`
19+
20+
---
21+
22+
## Development
23+
24+
This section covers setting up the local environment for coding. You need **PostgreSQL**, **Redis**, and **Keycloak** running to support the backend.
25+
26+
### Environment Configuration
27+
28+
The application relies on the following services. Ensure your environment variables are set (or use the provided `.env.example`):
29+
30+
```bash
31+
export DATABASE_URL="postgresql+asyncpg://postgres:password@localhost:5432/postgres"
32+
export REDIS_URL="redis://localhost:6379"
33+
export ENV=development
34+
```
35+
36+
### Option A: Manual Setup (Docker Compose)
37+
Use this if you prefer managing your own Python and Node versions.
38+
39+
1. **Start Infrastructure**
40+
Start Postgres, Redis, and Keycloak:
41+
```bash
42+
docker-compose -f docker-compose.dev.yml up -d postgres redis keycloak
43+
```
44+
45+
2. **Run Backend**
46+
```bash
47+
cd backend
48+
python -m venv venv
49+
source venv/bin/activate
50+
pip install -r requirements.txt
51+
52+
# Run migrations and start server
53+
alembic upgrade head
54+
uvicorn main:app --reload
55+
```
56+
57+
3. **Run Frontend**
58+
In a new terminal:
59+
```bash
60+
cd web
61+
npm install
62+
npm run dev
63+
```
64+
65+
### Option B: Automated Setup (Nix)
66+
Use this to let Nix handle dependencies, environment variables, and helper commands automatically.
67+
68+
1. **Enter Shell**
69+
```bash
70+
nix-shell
71+
```
72+
73+
2. **Start Everything**
74+
```bash
75+
run-dev-all
76+
# Starts Docker infra, migrates DB, and runs both Backend & Frontend
77+
```
78+
79+
### Access & Credentials
80+
81+
Once the development environment is running:
82+
83+
| Service | URL | Description |
84+
| :--- | :--- | :--- |
85+
| **Web Frontend** | `http://localhost:3000` | The user interface (Next.js/React). |
86+
| **Backend API** | `http://localhost:8000/graphql` | The GraphQL Playground (Strawberry). |
87+
| **Keycloak** | `http://localhost:8080` | Identity Provider. |
88+
89+
**Keycloak Realms & Users:**
90+
* **User Realm:** `http://localhost:8080/realms/tasks` (Redirects automatically from app login)
91+
* User: `test`
92+
* Password: `test`
93+
* **Admin Console:** `http://localhost:8080/admin`
94+
* User: `admin`
95+
* Password: `admin`

backend/auth.py

Lines changed: 78 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,62 @@
1+
import logging
12
from typing import Any
23

34
import requests
4-
from config import CLIENT_ID, ISSUER_URI
5-
from fastapi import HTTPException, status
5+
from config import CLIENT_ID, ISSUER_URI, LOGGER, PUBLIC_ISSUER_URI
6+
from fastapi import HTTPException, Request, status
7+
from fastapi.responses import RedirectResponse
68
from jose import jwk, jwt
9+
from starlette.requests import HTTPConnection
10+
11+
logger = logging.getLogger(LOGGER)
12+
13+
AUTH_COOKIE_NAME = "access_token"
714

815
jwks_cache: dict[str, Any] = {}
9-
openid_config_cache: dict[str, Any] = {}
1016

1117

12-
def get_openid_config() -> dict[str, Any]:
13-
global openid_config_cache
14-
if openid_config_cache:
15-
return openid_config_cache
18+
def delete_auth_cookie(response):
19+
response.delete_cookie(
20+
AUTH_COOKIE_NAME,
21+
path="/",
22+
secure=True,
23+
httponly=True,
24+
samesite="none",
25+
)
26+
27+
28+
async def authenticate_connection(connection, token: str | None):
29+
user_payload = None
30+
31+
if token:
32+
try:
33+
user_payload = verify_token(token)
34+
except Exception as e:
35+
logger.warning(f"Token validation failed: {e}")
36+
37+
if user_payload:
38+
return user_payload
39+
40+
if connection.scope["type"] == "http":
41+
response = RedirectResponse("/login", status_code=302)
42+
delete_auth_cookie(response)
43+
44+
accept_header = connection.headers.get("accept", "")
45+
46+
if "text/html" in accept_header:
47+
raise UnauthenticatedRedirect(response=response)
1648

17-
config_url = f"{ISSUER_URI.rstrip('/')}/.well-known/openid-configuration"
18-
try:
19-
response = requests.get(config_url, timeout=5)
20-
response.raise_for_status()
21-
config = response.json()
22-
openid_config_cache = config
23-
return config
24-
except Exception as e:
25-
print(f"OIDC Config Error: {e}")
2649
raise HTTPException(
27-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
28-
detail="Could not fetch OpenID Configuration",
50+
status_code=401,
51+
detail="Not authenticated",
52+
headers={"WWW-Authenticate": "Bearer"},
2953
)
3054

55+
if connection.scope["type"] == "websocket":
56+
raise HTTPException(status_code=403, detail="Not authenticated")
57+
58+
raise RuntimeError("Unsupported connection type")
59+
3160

3261
def get_public_key(token: str) -> Any:
3362
global jwks_cache
@@ -100,3 +129,34 @@ def verify_token(token: str) -> dict:
100129
raise HTTPException(status_code=401, detail=f"Invalid token: {e!s}")
101130
except Exception:
102131
raise HTTPException(status_code=401, detail="Authentication failed")
132+
133+
134+
async def get_token_source(
135+
connection: HTTPConnection,
136+
) -> str | None:
137+
auth_header = connection.headers.get("authorization")
138+
if auth_header and auth_header.startswith("Bearer "):
139+
return auth_header.split(" ")[1]
140+
141+
return connection.cookies.get(AUTH_COOKIE_NAME)
142+
143+
144+
class UnauthenticatedRedirect(Exception):
145+
def __init__(self, response=None):
146+
self.response = response
147+
super().__init__("Unauthenticated - redirect required")
148+
149+
150+
async def unauthenticated_redirect_handler(
151+
request: Request,
152+
_: UnauthenticatedRedirect,
153+
):
154+
redirect_uri = f"{request.base_url}callback"
155+
login_url = (
156+
f"{PUBLIC_ISSUER_URI}/protocol/openid-connect/auth"
157+
f"?client_id={CLIENT_ID}"
158+
f"&response_type=code"
159+
f"&scope=openid profile email"
160+
f"&redirect_uri={redirect_uri}"
161+
)
162+
return RedirectResponse(url=login_url)

backend/config.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,16 @@
1111
f"postgresql+asyncpg://{_db_username}:{_db_password}@{_db_hostname}:{_db_port}/{_db_name}",
1212
)
1313

14+
_redis_host = os.getenv("REDIS_HOST", "localhost")
15+
_redis_port = int(os.getenv("REDIS_PORT", 6379))
16+
_redis_password = os.getenv("REDIS_PASSWORD", None)
1417

15-
REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379")
18+
if _redis_password:
19+
REDIS_URL = f"redis://:{_redis_password}@{_redis_host}:{_redis_port}/"
20+
else:
21+
REDIS_URL = f"redis://{_redis_host}:{_redis_port}/"
22+
23+
REDIS_URL = os.getenv("REDIS_URL", REDIS_URL)
1624

1725
ENV = os.getenv("ENV", "production")
1826
IS_DEV = ENV == "development"

backend/main.py

Lines changed: 19 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,86 +1,47 @@
1+
import logging
2+
13
import requests
4+
from starlette.status import HTTP_400_BAD_REQUEST
25
from api.context import Context
36
from api.resolvers import Mutation, Query, Subscription
4-
from auth import verify_token
5-
from config import CLIENT_ID, CLIENT_SECRET, IS_DEV, PUBLIC_ISSUER_URI, ISSUER_URI
7+
from auth import UnauthenticatedRedirect, authenticate_connection, get_token_source, unauthenticated_redirect_handler, verify_token
8+
from config import (
9+
CLIENT_ID,
10+
CLIENT_SECRET,
11+
IS_DEV,
12+
ISSUER_URI,
13+
LOGGER,
14+
)
615
from database.models.user import User
716
from database.session import get_db_session
817
from fastapi import Depends, FastAPI, HTTPException, Request
918
from fastapi.responses import RedirectResponse
10-
from fastapi.security import OAuth2PasswordBearer
1119
from sqlalchemy import select
20+
from starlette.requests import HTTPConnection
1221
from strawberry import Schema
1322
from strawberry.fastapi import GraphQLRouter
1423

15-
oauth2_scheme = OAuth2PasswordBearer(
16-
tokenUrl=f"{ISSUER_URI}/protocol/openid-connect/token",
17-
auto_error=False,
18-
)
19-
20-
21-
async def get_token_source(
22-
request: Request,
23-
header_token: str | None = Depends(oauth2_scheme),
24-
) -> str | None:
25-
if header_token:
26-
return header_token
27-
28-
return request.cookies.get("access_token")
29-
30-
31-
class UnauthenticatedRedirect(Exception):
32-
pass
33-
34-
35-
async def unauthenticated_redirect_handler(
36-
request: Request,
37-
_: UnauthenticatedRedirect,
38-
):
39-
redirect_uri = f"{request.base_url}callback"
40-
41-
login_url = (
42-
f"{PUBLIC_ISSUER_URI}/protocol/openid-connect/auth"
43-
f"?client_id={CLIENT_ID}"
44-
f"&response_type=code"
45-
f"&scope=openid profile email"
46-
f"&redirect_uri={redirect_uri}"
47-
)
48-
return RedirectResponse(url=login_url)
24+
logger = logging.getLogger(LOGGER)
4925

5026

5127
async def get_context(
52-
request: Request,
28+
connection: HTTPConnection,
5329
token: str | None = Depends(get_token_source),
5430
session=Depends(get_db_session),
5531
) -> Context:
56-
user_payload = None
57-
58-
if token:
59-
try:
60-
user_payload = verify_token(token)
61-
except Exception as e:
62-
raise e
63-
user_payload = None
64-
65-
if not user_payload:
66-
accept_header = request.headers.get("accept", "")
67-
if "text/html" in accept_header:
68-
raise UnauthenticatedRedirect
69-
raise HTTPException(
70-
status_code=401,
71-
detail="Not authenticated",
72-
headers={"WWW-Authenticate": "Bearer"},
73-
)
32+
user_payload = await authenticate_connection(connection, token)
7433

7534
user_id = user_payload.get("sub")
7635
username = (
7736
user_payload.get("preferred_username")
7837
or user_payload.get("name")
79-
or "Unknown"
8038
)
8139
firstname = user_payload.get("given_name")
8240
lastname = user_payload.get("family_name")
8341

42+
if not (user_id and username and firstname and lastname):
43+
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail="Missing required user details.")
44+
8445
result = await session.execute(select(User).where(User.id == user_id))
8546
db_user = result.scalars().first()
8647

@@ -93,7 +54,7 @@ async def get_context(
9354
title="User",
9455
)
9556
session.add(db_user)
96-
else:
57+
elif db_user.name != username or db_user.firstname != firstname:
9758
db_user.name = username
9859
db_user.firstname = firstname
9960
db_user.lastname = lastname

0 commit comments

Comments
 (0)