Skip to content

Commit a8a23a7

Browse files
committed
Init traefik auth proxy
1 parent edfdd8e commit a8a23a7

File tree

5 files changed

+272
-0
lines changed

5 files changed

+272
-0
lines changed

traefik-auth-proxy/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM python:3-alpine
2+
3+
# Define build arguments
4+
ARG USER_ID=1064
5+
ARG GROUP_ID=1064
6+
ARG USER_NAME=l64user
7+
ARG GROUP_NAME=l64group
8+
9+
# Create group and user with the specified IDs
10+
RUN addgroup -g ${GROUP_ID} ${GROUP_NAME} && \
11+
adduser -D -u ${USER_ID} -G ${GROUP_NAME} ${USER_NAME}
12+
13+
# Set working directory
14+
WORKDIR /home/${USER_NAME}
15+
16+
# Copy the requirements file into the container at the working directory
17+
COPY --chown=${USER_NAME}:${GROUP_NAME} requirements.txt .
18+
19+
# Copy the main FastAPI application file
20+
COPY --chown=${USER_NAME}:${GROUP_NAME} traefik-auth-proxy.py .
21+
22+
# Install any needed packages specified in requirements.txt
23+
RUN python -m pip install --upgrade pip && \
24+
pip install --no-cache-dir -r requirements.txt
25+
26+
# Make port 8081 available outside this container
27+
EXPOSE 8081
28+
29+
# Set user context
30+
USER ${USER_NAME}
31+
32+
# Run the FastAPI application using Uvicorn
33+
CMD ["uvicorn", "traefik-auth-proxy:app", "--host", "0.0.0.0", "--port", "8081"]

traefik-auth-proxy/README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<p align="center"><img src="https://raw.githubusercontent.com/Labs64/.github/refs/heads/master/assets/labs64-io-ecosystem.png"></p>
2+
3+
## Traefik Auth (M2M) Middleware
4+
5+
This repository contains a custom Traefik ForwardAuth middleware. The middleware is designed to verify M2M (Machine-to-Machine) JWT tokens issued by Keycloak and enforce path-based role-based access control (RBAC) for microservices deployed on Kubernetes.
6+
7+
It receives incoming requests from Traefik, validates the JWT token, extracts user roles, and checks them against a configurable path/role mapping. If the request is authorized, it allows Traefik to forward the request to the backend service. Otherwise, it returns a `401 Unauthorized` or `403 Forbidden` response.
8+
9+
## Features
10+
11+
- JWT Verification: Validates tokens issued by Keycloak using public keys from the `.well-known` endpoint.
12+
- Role-Based Access Control (RBAC): Enforces access based on roles assigned to the user/client.
13+
- Configurable Role Mapping: Allows administrators to define a mapping of URL paths to required roles.
14+
- FastAPI Backend: A lightweight and performant backend for handling authentication logic.
15+
16+
## Prerequisites
17+
18+
- A running Kubernetes cluster.
19+
- Traefik installed as an Ingress Controller in your cluster.
20+
- A configured Keycloak instance.
21+
- Docker for building the middleware container image.
22+
23+
## Configuration
24+
25+
The middleware is configured using environment variables.
26+
27+
- `KEYCLOAK_URL`: The base URL of your Keycloak instance (e.g., http://keycloak.default.svc.cluster.local:8080).
28+
- `KEYCLOAK_REALM`: The name of the realm in Keycloak (e.g., labs64io).
29+
- `KEYCLOAK_AUDIENCE`: The audience claim to verify in the JWT (e.g., labs64io_client).
30+
- `ROLE_MAPPING`: A JSON string defining the path-to-role mapping. This can be passed as a ConfigMap in a production environment.
31+
32+
## Usage
33+
34+
- Once deployed, Traefik will intercept any request to *whoami.example.com* and forward it to the auth-middleware for authentication.
35+
- For a request to be successful, it must include a valid JWT in the Authorization header with the format `Bearer <token>`. The roles contained in the JWT must match the required roles for the requested path as defined in your role mapping.
36+
- The role mapping is a key part of the middleware's logic. You would define a dictionary that maps a path prefix to a list of required roles.
37+
38+
### For example:
39+
40+
- A request to `/api/admin` would require the `admin` role.
41+
- A request to `/api/users` would require either the `user` or `admin` role.
42+
43+
## License
44+
45+
This project is licensed under the MIT License.

traefik-auth-proxy/justfile

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
APP_NAME := "traefik-auth-proxy"
2+
3+
# build application
4+
docker:
5+
docker build -t {{APP_NAME}}:latest .
6+
docker tag {{APP_NAME}}:latest localhost:5005/{{APP_NAME}}:latest
7+
docker push localhost:5005/{{APP_NAME}}:latest
8+
docker images | grep "{{APP_NAME}}"
9+
10+
# run docker image
11+
run: docker
12+
docker run -p 8081:8081 \
13+
-e KEYCLOAK_URL="http://host.docker.internal:8080" \
14+
-e KEYCLOAK_REALM="labs64io" \
15+
-e KEYCLOAK_AUDIENCE="account" \
16+
{{APP_NAME}}:latest
17+
18+
# open documentation
19+
docu:
20+
open "http://localhost:8081/redoc"
21+
open "http://localhost:8081/docs"
22+
23+
24+
# open Keycloak well-known configuration
25+
test_show_well_known:
26+
open "http://keycloak.localhost/realms/labs64io/.well-known/openid-configuration"
27+
28+
# generate JWT token
29+
test_generate_jwt_token:
30+
curl --location --request POST 'http://keycloak.localhost/realms/labs64io/protocol/openid-connect/token' \
31+
--header 'Content-Type: application/x-www-form-urlencoded' \
32+
--data-urlencode 'grant_type=client_credentials' \
33+
--data-urlencode 'client_id=labs64io-api-gateway' \
34+
--data-urlencode 'client_secret=mTEqlt1dDzcVyEOzFjBZV4X8jvEkaQnc'
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
fastapi==0.116.1
2+
uvicorn==0.35.0
3+
python-jose==3.5.0
4+
requests==2.32.4
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import os
2+
import logging
3+
from typing import Dict, Any, List
4+
5+
import requests
6+
from fastapi import FastAPI, Request, HTTPException, status
7+
from jose import jwt
8+
from jose.exceptions import JWTError, ExpiredSignatureError
9+
10+
# --- Configuration ---
11+
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "http://keycloak.localhost")
12+
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "labs64io")
13+
KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account")
14+
15+
# --- Path-to-role mapping ---
16+
ROLE_MAPPING: Dict[str, List[str]] = {
17+
"/api/v1/admin": ["admin-role"],
18+
"/api/v1/auditflow": ["auditflow-role", "admin-role", "default-roles-labs64io"],
19+
"/api/v1/ecommerce": ["ecommerce-role", "admin-role"],
20+
"/public": ["authenticated-user"]
21+
}
22+
23+
# JWKS Cache
24+
JWKS_CACHE: Dict[str, Any] = {}
25+
26+
# Logging configuration
27+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
28+
app_logger = logging.getLogger(__name__)
29+
app_logger.setLevel(logging.DEBUG)
30+
31+
# FastAPI app
32+
app = FastAPI(
33+
title="Traefik Auth (M2M) Middleware",
34+
description="Custom Traefik ForwardAuth service to verify Keycloak JWT tokens and enforce role-based access control.",
35+
version="1.0.0"
36+
)
37+
38+
# --- JWKS and JWT Validation Functions ---
39+
40+
def get_jwks() -> Dict[str, Any]:
41+
"""
42+
Fetches JWKS from Keycloak or returns it from cache.
43+
"""
44+
if JWKS_CACHE:
45+
app_logger.info("get_jwks::using cached JWKS")
46+
return JWKS_CACHE
47+
48+
jwks_url = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs"
49+
try:
50+
app_logger.info(f"get_jwks::fetching JWKS from: {jwks_url}")
51+
response = requests.get(jwks_url)
52+
response.raise_for_status()
53+
# Cache JWKS
54+
JWKS_CACHE.update(response.json())
55+
return JWKS_CACHE
56+
except requests.RequestException as e:
57+
app_logger.error(f"get_jwks::Failed to fetch JWKS: {e}")
58+
raise HTTPException(
59+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
60+
detail=f"Failed to retrieve public keys: {e}"
61+
)
62+
63+
def verify_token(token: str) -> Dict[str, Any]:
64+
"""
65+
Verifies the JWT token using JWKS and returns the payload.
66+
"""
67+
try:
68+
header = jwt.get_unverified_header(token)
69+
kid = header.get("kid")
70+
if not kid:
71+
raise HTTPException(status_code=401, detail="JWT header is missing 'kid'")
72+
73+
jwks = get_jwks()
74+
payload = jwt.decode(
75+
token,
76+
jwks,
77+
algorithms=["RS256"],
78+
audience=KEYCLOAK_AUDIENCE
79+
)
80+
return payload
81+
82+
except ExpiredSignatureError:
83+
app_logger.error("verify_token::Token has expired")
84+
raise HTTPException(status_code=401, detail="Token has expired")
85+
except JWTError as e:
86+
app_logger.error(f"verify_token::Invalid token: {e}")
87+
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
88+
except Exception as e:
89+
app_logger.error(f"verify_token::Unexpected error: {e}", exc_info=True)
90+
raise HTTPException(status_code=500, detail=f"Token verification failed: {e}")
91+
92+
93+
# --- FastAPI Endpoint for Traefik ForwardAuth ---
94+
95+
@app.get("/auth")
96+
@app.post("/auth")
97+
async def authenticate(request: Request):
98+
"""
99+
Traefik ForwardAuth endpoint. Validates JWT and enforces RBAC.
100+
"""
101+
auth_header = request.headers.get("Authorization")
102+
if not auth_header or not auth_header.startswith("Bearer "):
103+
raise HTTPException(status_code=401, detail="Authorization header is missing or malformed")
104+
105+
token = auth_header.split(" ")[1]
106+
payload = verify_token(token)
107+
app_logger.debug(f"JWT Decoded Payload: {payload}")
108+
109+
token_roles = set()
110+
realm_access = payload.get("realm_access", {})
111+
if isinstance(realm_access, dict):
112+
token_roles.update(realm_access.get("roles", []))
113+
114+
resource_access = payload.get("resource_access", {})
115+
client_roles = resource_access.get(KEYCLOAK_AUDIENCE, {}).get("roles", [])
116+
token_roles.update(client_roles)
117+
118+
if not token_roles:
119+
raise HTTPException(status_code=403, detail="Token does not contain any roles")
120+
121+
forwarded_uri = request.headers.get("X-Forwarded-Uri", "/")
122+
123+
required_roles: List[str] = []
124+
best_match = ""
125+
app_logger.info(f"Evaluating path: {forwarded_uri} with token roles: {token_roles}")
126+
for path_prefix, roles in ROLE_MAPPING.items():
127+
if forwarded_uri.startswith(path_prefix):
128+
app_logger.debug(f"Matched path prefix: {path_prefix} with roles: {roles}")
129+
if len(path_prefix) > len(best_match):
130+
best_match = path_prefix
131+
required_roles = roles
132+
133+
if not required_roles:
134+
app_logger.warning(f"No access control rules configured for path: {forwarded_uri}")
135+
raise HTTPException(
136+
status_code=403,
137+
detail=f"Path '{forwarded_uri}' is not configured for access control"
138+
)
139+
140+
app_logger.info(f"Path matched: {best_match}, required roles: {required_roles}")
141+
142+
if not token_roles.intersection(required_roles):
143+
app_logger.warning(
144+
f"Access denied for path '{forwarded_uri}'. "
145+
f"Token roles: {token_roles}, required: {required_roles}"
146+
)
147+
raise HTTPException(
148+
status_code=403,
149+
detail=f"Insufficient permissions. Required roles: {required_roles}"
150+
)
151+
152+
return {
153+
"message": "Authentication successful",
154+
"user_id": payload.get("sub"),
155+
"roles": list(token_roles)
156+
}

0 commit comments

Comments
 (0)