Skip to content

Commit 837cb67

Browse files
committed
Init traefik auth proxy
1 parent edfdd8e commit 837cb67

File tree

6 files changed

+323
-0
lines changed

6 files changed

+323
-0
lines changed

traefik-authproxy/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_authproxy.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_authproxy:app", "--host", "0.0.0.0", "--port", "8081"]

traefik-authproxy/README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
- `KEYCLOAK_DISCOVERY_URL`: The URL to the Keycloak discovery endpoint (e.g., http://keycloak.default.svc.cluster.local:8080/realms/labs64io/.well-known/openid-configuration).
31+
- `ROLE_MAPPING_FILE`: YAML file defining the path-to-role mapping. This can be passed as a ConfigMap in a production environment.
32+
33+
## Usage
34+
35+
- Once deployed, Traefik will intercept any request to *whoami.example.com* and forward it to the auth-middleware for authentication.
36+
- 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.
37+
- 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.
38+
39+
### For example:
40+
41+
- A request to `/api/admin` would require the `admin` role.
42+
- A request to `/api/users` would require either the `user` or `admin` role.
43+
44+
## License
45+
46+
This project is licensed under the MIT License.

traefik-authproxy/justfile

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
APP_NAME := "traefik-authproxy"
2+
3+
LOCAL_KEYCLOAK := "http://keycloak.localhost"
4+
LOCAL_KEYCLOAK_DOCKER := "http://host.docker.internal:8080"
5+
LOCAL_CLIENT_ID := "labs64io-api-gateway"
6+
LOCAL_CLIENT_SECRET := "mTEqlt1dDzcVyEOzFjBZV4X8jvEkaQnc"
7+
8+
# build application
9+
docker:
10+
docker build -t {{APP_NAME}}:latest .
11+
docker tag {{APP_NAME}}:latest localhost:5005/{{APP_NAME}}:latest
12+
docker push localhost:5005/{{APP_NAME}}:latest
13+
docker images | grep "{{APP_NAME}}"
14+
15+
# run docker image
16+
run: docker
17+
docker run -p 8081:8081 \
18+
-e KEYCLOAK_DISCOVERY_URL="{{LOCAL_KEYCLOAK_DOCKER}}/realms/labs64io/.well-known/openid-configuration" \
19+
-e KEYCLOAK_URL="{{LOCAL_KEYCLOAK_DOCKER}}" \
20+
-e KEYCLOAK_REALM="labs64io" \
21+
-e KEYCLOAK_AUDIENCE="account" \
22+
-e ROLE_MAPPING_FILE="/home/l64user/role_mapping.yaml" \
23+
-v $(pwd)/sample_role_mapping.yaml:/home/l64user/role_mapping.yaml \
24+
{{APP_NAME}}:latest
25+
26+
# open documentation
27+
docu:
28+
open "http://localhost:8081/redoc"
29+
open "http://localhost:8081/docs"
30+
31+
32+
# open Keycloak well-known configuration
33+
test-show-wellknown:
34+
open "{{LOCAL_KEYCLOAK}}/realms/labs64io/.well-known/openid-configuration"
35+
36+
# generate JWT token
37+
test-generate-jwt-token:
38+
curl --location --request POST '{{LOCAL_KEYCLOAK}}/realms/labs64io/protocol/openid-connect/token' \
39+
--header 'Content-Type: application/x-www-form-urlencoded' \
40+
--data-urlencode 'grant_type=client_credentials' \
41+
--data-urlencode 'client_id={{LOCAL_CLIENT_ID}}' \
42+
--data-urlencode 'client_secret={{LOCAL_CLIENT_SECRET}}'

traefik-authproxy/requirements.txt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
fastapi==0.116.1
2+
uvicorn==0.35.0
3+
python-jose==3.5.0
4+
requests==2.32.4
5+
PyYAML==6.0.2
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/api/v1/admin:
2+
- admin-role
3+
4+
/api/v1/auditflow:
5+
- admin-role
6+
- auditflow-role
7+
- default-roles-labs64io
8+
9+
/api/v1/ecommerce:
10+
- admin-role
11+
- ecommerce-role
12+
13+
/public: []
14+
/v3/api-docs: []
15+
/actuator: []
16+
/health: []
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import os
2+
import logging
3+
from typing import Dict, Any, List, Tuple
4+
5+
import yaml
6+
import requests
7+
from fastapi import FastAPI, Request, HTTPException, status
8+
from jose import jwt
9+
from jose.exceptions import JWTError, ExpiredSignatureError
10+
11+
# --- Configuration ---
12+
KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "http://keycloak.tools.svc.cluster.local")
13+
KEYCLOAK_REALM = os.getenv("KEYCLOAK_REALM", "default")
14+
KEYCLOAK_DISCOVERY_URL = os.getenv(
15+
"KEYCLOAK_DISCOVERY_URL",
16+
f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}/.well-known/openid-configuration"
17+
)
18+
19+
KEYCLOAK_AUDIENCE = os.getenv("KEYCLOAK_AUDIENCE", "account")
20+
ROLE_MAPPING_FILE = os.getenv("ROLE_MAPPING_FILE", "role_mapping.yaml")
21+
22+
# --- Caches ---
23+
DISCOVERY_CACHE: Dict[str, Any] = {}
24+
JWKS_CACHE: Dict[str, Any] = {}
25+
26+
# --- Logging ---
27+
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s")
28+
app_logger = logging.getLogger("forwardauth")
29+
app_logger.setLevel(logging.DEBUG)
30+
31+
# --- App Initialization ---
32+
app = FastAPI(
33+
title="Traefik Auth (M2M) Middleware",
34+
description="ForwardAuth service to verify Keycloak JWTs and enforce RBAC based on URI-to-role mapping",
35+
version="1.0.0"
36+
)
37+
38+
# --- Load Role Mapping and Public Paths ---
39+
def load_role_mapping(file_path: str) -> Tuple[Dict[str, List[str]], List[str]]:
40+
try:
41+
with open(file_path, "r") as f:
42+
raw_mapping = yaml.safe_load(f)
43+
44+
if not isinstance(raw_mapping, dict):
45+
raise ValueError("Role mapping file must contain a dictionary")
46+
47+
protected_paths = {}
48+
public_paths = []
49+
50+
for path, roles in raw_mapping.items():
51+
if roles in (None, [], ["public"]):
52+
public_paths.append(path)
53+
app_logger.debug(f"load_role_mapping::Detected public path: {path}")
54+
else:
55+
protected_paths[path] = roles
56+
57+
app_logger.info(
58+
f"Role mapping loaded: {len(protected_paths)} protected paths, {len(public_paths)} public paths"
59+
)
60+
return protected_paths, public_paths
61+
62+
except Exception as e:
63+
app_logger.warning(f"load_role_mapping::Skipping path check – failed to load mapping: {e}")
64+
return {}, []
65+
66+
PROTECTED_PATHS, PUBLIC_PATHS = load_role_mapping(ROLE_MAPPING_FILE)
67+
68+
# --- JWKS Loader with Discovery ---
69+
def get_jwks() -> Dict[str, Any]:
70+
if JWKS_CACHE:
71+
app_logger.debug("get_jwks::Using cached JWKS")
72+
return JWKS_CACHE
73+
74+
try:
75+
if "jwks_uri" not in DISCOVERY_CACHE:
76+
app_logger.info(f"get_jwks::Fetching discovery doc from {KEYCLOAK_DISCOVERY_URL}")
77+
resp = requests.get(KEYCLOAK_DISCOVERY_URL)
78+
resp.raise_for_status()
79+
jwks_uri = resp.json().get("jwks_uri")
80+
if not jwks_uri:
81+
raise ValueError("Discovery document missing 'jwks_uri'")
82+
DISCOVERY_CACHE["jwks_uri"] = jwks_uri
83+
84+
jwks_uri = DISCOVERY_CACHE["jwks_uri"]
85+
app_logger.info(f"get_jwks::Fetching JWKS from {jwks_uri}")
86+
resp = requests.get(jwks_uri)
87+
resp.raise_for_status()
88+
JWKS_CACHE.update(resp.json())
89+
return JWKS_CACHE
90+
91+
except (requests.RequestException, ValueError) as e:
92+
app_logger.error(f"get_jwks::Error: {e}")
93+
raise HTTPException(status_code=500, detail="Failed to retrieve JWKS")
94+
95+
# --- JWT Token Verifier ---
96+
def verify_token(token: str) -> Dict[str, Any]:
97+
try:
98+
kid = jwt.get_unverified_header(token).get("kid")
99+
if not kid:
100+
raise HTTPException(status_code=401, detail="Missing 'kid' in token header")
101+
102+
payload = jwt.decode(
103+
token,
104+
get_jwks(),
105+
algorithms=["RS256"],
106+
audience=KEYCLOAK_AUDIENCE
107+
)
108+
app_logger.debug(f"verify_token::Decoded payload: {payload}")
109+
return payload
110+
111+
except ExpiredSignatureError:
112+
raise HTTPException(status_code=401, detail="Token expired")
113+
except JWTError as e:
114+
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
115+
except Exception as e:
116+
app_logger.error("verify_token::Unexpected error", exc_info=True)
117+
raise HTTPException(status_code=500, detail="Token verification failed")
118+
119+
# --- Role Extractor ---
120+
def extract_token_roles(payload: Dict[str, Any]) -> List[str]:
121+
roles = set()
122+
123+
realm_roles = payload.get("realm_access", {}).get("roles", [])
124+
if isinstance(realm_roles, list):
125+
roles.update(realm_roles)
126+
127+
client_roles = payload.get("resource_access", {}).get(KEYCLOAK_AUDIENCE, {}).get("roles", [])
128+
if isinstance(client_roles, list):
129+
roles.update(client_roles)
130+
131+
return list(roles)
132+
133+
# --- Path Role Matcher ---
134+
def get_required_roles(path: str) -> List[str]:
135+
longest_match = ""
136+
required_roles = []
137+
138+
for prefix, roles in PROTECTED_PATHS.items():
139+
if path.startswith(prefix) and len(prefix) > len(longest_match):
140+
longest_match = prefix
141+
required_roles = roles
142+
143+
return required_roles
144+
145+
def is_public_path(path: str) -> bool:
146+
return any(path.startswith(pub) for pub in PUBLIC_PATHS)
147+
148+
# --- Authentication Endpoint ---
149+
@app.get("/auth")
150+
@app.post("/auth")
151+
async def authenticate(request: Request):
152+
forwarded_uri = request.headers.get("X-Forwarded-Uri", "/")
153+
154+
if is_public_path(forwarded_uri):
155+
app_logger.info(f"Public access granted to: {forwarded_uri}")
156+
return {"message": "Public access granted", "user_id": None, "roles": []}
157+
158+
auth_header = request.headers.get("Authorization")
159+
if not auth_header or not auth_header.startswith("Bearer "):
160+
raise HTTPException(status_code=401, detail="Missing or malformed Authorization header")
161+
162+
token = auth_header.split(" ")[1]
163+
payload = verify_token(token)
164+
user_roles = extract_token_roles(payload)
165+
166+
if not user_roles:
167+
raise HTTPException(status_code=403, detail="Token contains no roles")
168+
169+
required_roles = get_required_roles(forwarded_uri)
170+
if not required_roles:
171+
raise HTTPException(status_code=403, detail=f"No access control configured for: {forwarded_uri}")
172+
173+
if not set(user_roles).intersection(required_roles):
174+
raise HTTPException(status_code=403, detail=f"Insufficient roles. Required: {required_roles}")
175+
176+
app_logger.info(f"Access granted to user {payload.get('sub')} for path {forwarded_uri}")
177+
return {
178+
"message": "Authentication successful",
179+
"user_id": payload.get("sub"),
180+
"roles": user_roles
181+
}

0 commit comments

Comments
 (0)