auth-guardian 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth_guardian-0.0.1/PKG-INFO +158 -0
- auth_guardian-0.0.1/README.md +139 -0
- auth_guardian-0.0.1/pyproject.toml +61 -0
- auth_guardian-0.0.1/setup.cfg +4 -0
- auth_guardian-0.0.1/src/auth_guardian/__init__.py +38 -0
- auth_guardian-0.0.1/src/auth_guardian/config.py +33 -0
- auth_guardian-0.0.1/src/auth_guardian/core.py +113 -0
- auth_guardian-0.0.1/src/auth_guardian/exceptions.py +16 -0
- auth_guardian-0.0.1/src/auth_guardian/oidc_client.py +226 -0
- auth_guardian-0.0.1/src/auth_guardian/roles.py +7 -0
- auth_guardian-0.0.1/src/auth_guardian/router.py +146 -0
- auth_guardian-0.0.1/src/auth_guardian/state.py +24 -0
- auth_guardian-0.0.1/src/auth_guardian/token_validator.py +89 -0
- auth_guardian-0.0.1/src/auth_guardian.egg-info/PKG-INFO +158 -0
- auth_guardian-0.0.1/src/auth_guardian.egg-info/SOURCES.txt +20 -0
- auth_guardian-0.0.1/src/auth_guardian.egg-info/dependency_links.txt +1 -0
- auth_guardian-0.0.1/src/auth_guardian.egg-info/requires.txt +14 -0
- auth_guardian-0.0.1/src/auth_guardian.egg-info/top_level.txt +1 -0
- auth_guardian-0.0.1/tests/test_oidc_client.py +66 -0
- auth_guardian-0.0.1/tests/test_roles.py +23 -0
- auth_guardian-0.0.1/tests/test_state.py +17 -0
- auth_guardian-0.0.1/tests/test_token_validator.py +121 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth-guardian
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Reusable Keycloak SDK for auth-service and microservices
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: httpx<0.29,>=0.28
|
|
8
|
+
Requires-Dist: python-jose[cryptography]<4,>=3.5
|
|
9
|
+
Provides-Extra: fastapi
|
|
10
|
+
Requires-Dist: fastapi<0.119,>=0.118; extra == "fastapi"
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: build<2,>=1.3; extra == "dev"
|
|
13
|
+
Requires-Dist: mypy<2,>=1.10; extra == "dev"
|
|
14
|
+
Requires-Dist: pytest<9,>=8.3; extra == "dev"
|
|
15
|
+
Requires-Dist: pytest-asyncio<2,>=1.2; extra == "dev"
|
|
16
|
+
Requires-Dist: respx<0.23,>=0.22; extra == "dev"
|
|
17
|
+
Requires-Dist: ruff<0.15,>=0.14; extra == "dev"
|
|
18
|
+
Requires-Dist: twine<7,>=6.2; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# Auth Guardian
|
|
21
|
+
|
|
22
|
+
**Auth Guardian** es un SDK extremadamente simple, *plug-and-play* y agnóstico, diseñado para proteger aplicaciones y microservicios en Python utilizando Keycloak (u otros proveedores OIDC compatibles).
|
|
23
|
+
|
|
24
|
+
Ha sido diseñado para evitar configuraciones redundantes: con un par de variables de entorno, tu aplicación tendrá flujos de login, logout, callbacks y validación de roles y permisos automáticamente.
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Instalación
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install auth-guardian==0.0.1
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Configuración (Plug-and-Play)
|
|
37
|
+
|
|
38
|
+
`AuthGuardian` es lo suficientemente inteligente como para configurarse por sí mismo. Solo necesitas definir las siguientes variables de entorno:
|
|
39
|
+
|
|
40
|
+
```env
|
|
41
|
+
AUTH_BASE_URL=https://auth.tu-dominio.com
|
|
42
|
+
AUTH_REALM=tu-realm
|
|
43
|
+
AUTH_CLIENT_ID=tu-cliente
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
> **Nota:** También soporta las variables legacy `KEYCLOAK_BASE_URL`, `KEYCLOAK_REALM` y `KEYCLOAK_CLIENT_ID` de forma automática.
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## Uso en FastAPI
|
|
51
|
+
|
|
52
|
+
### 1. Inyectando Rutas Automáticas (Login, Logout, Callback)
|
|
53
|
+
Para aplicaciones frontend/Fullstack, `AuthGuardian` puede inyectar los endpoints completos del flujo OIDC (`/login`, `/logout`, `/oidc/callback`) para que no tengas que escribirlos:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
from fastapi import FastAPI
|
|
57
|
+
from auth_guardian import AuthGuardian, create_auth_router
|
|
58
|
+
|
|
59
|
+
app = FastAPI()
|
|
60
|
+
|
|
61
|
+
# 1. Instancia el guardián
|
|
62
|
+
auth = AuthGuardian()
|
|
63
|
+
|
|
64
|
+
# 2. Crea el router que manejará automáticamente login, callback y logout
|
|
65
|
+
auth_router = create_auth_router(
|
|
66
|
+
auth=auth,
|
|
67
|
+
oidc_state_secret="tu_secreto_super_seguro_para_estado",
|
|
68
|
+
login_redirect_url="/dashboard", # Dónde enviar al usuario tras loguearse
|
|
69
|
+
logout_redirect_url="/login" # Dónde enviar al usuario tras desloguearse
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# 3. Registra el router en tu app
|
|
73
|
+
app.include_router(auth_router)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**¡Eso es todo!** Automáticamente tienes:
|
|
77
|
+
- `GET /login` -> Redirige al login de Keycloak.
|
|
78
|
+
- `GET /oidc/callback` -> Maneja el intercambio de código, genera las cookies seguras e inicia la sesión local.
|
|
79
|
+
- `GET /logout` -> Elimina cookies y redirige al cierre de sesión de Keycloak.
|
|
80
|
+
|
|
81
|
+
### 2. Protegiendo tus Endpoints (Para Principiantes)
|
|
82
|
+
En FastAPI, "proteger" una ruta significa que si alguien entra sin haber iniciado sesión, el sistema le bloqueará el acceso automáticamente.
|
|
83
|
+
|
|
84
|
+
Para lograr esto, FastAPI usa algo llamado `Depends` (Dependencias). Si agregas `Depends(auth.get_current_user)` en tu función, AuthGuardian se encargará de verificar si el usuario tiene sesión activa antes de ejecutar el código.
|
|
85
|
+
|
|
86
|
+
**Ejemplo 1: Bloquear acceso a usuarios no logueados**
|
|
87
|
+
```python
|
|
88
|
+
from fastapi import Depends
|
|
89
|
+
|
|
90
|
+
@app.get("/recurso-seguro")
|
|
91
|
+
async def get_seguro(user: dict = Depends(auth.get_current_user)):
|
|
92
|
+
# Si llega aquí, es porque el usuario SÍ está logueado.
|
|
93
|
+
# 'user' contendrá la información de su perfil.
|
|
94
|
+
return {
|
|
95
|
+
"mensaje": f"Hola {user.get('preferred_username')}",
|
|
96
|
+
"email": user.get('email')
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
**Ejemplo 2: Bloquear acceso según el Rol del usuario**
|
|
101
|
+
Si tienes zonas exclusivas (como un panel de administrador), puedes usar `auth.require_role("nombre_del_rol")`.
|
|
102
|
+
|
|
103
|
+
```python
|
|
104
|
+
@app.get("/zona-admin")
|
|
105
|
+
async def get_admin(user: dict = Depends(auth.require_role("admin"))):
|
|
106
|
+
# Solo entrarán usuarios que se hayan logueado y que tengan el rol "admin"
|
|
107
|
+
return {"mensaje": "¡Bienvenido, administrador!"}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## Opciones Avanzadas
|
|
113
|
+
|
|
114
|
+
### Validación Manual de Tokens
|
|
115
|
+
Si no usas FastAPI o necesitas validar un token manualmente, el SDK expone internamente el validador:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
payload = await auth.validator.decode_access_token("eyJhbGciOiJIUz...")
|
|
119
|
+
print(payload)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
### Hook Personalizado de Login
|
|
123
|
+
Al usar `create_auth_router`, puedes inyectar un hook que se ejecute justo cuando el usuario hace login exitosamente (por ejemplo, para guardar/actualizar el usuario en tu base de datos):
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
async def sync_user_in_db(payload: dict):
|
|
127
|
+
email = payload.get("email")
|
|
128
|
+
print(f"Sincronizando usuario {email} en base de datos...")
|
|
129
|
+
|
|
130
|
+
auth_router = create_auth_router(
|
|
131
|
+
auth=auth,
|
|
132
|
+
oidc_state_secret="secreto",
|
|
133
|
+
on_login_success=sync_user_in_db # Se ejecuta después del callback, antes de redirigir
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Contrato Público API
|
|
140
|
+
|
|
141
|
+
El contrato público exportado es estable y mantenido a través del `__init__.py`.
|
|
142
|
+
|
|
143
|
+
- `AuthGuardian`: Clase principal y Facade de autenticación.
|
|
144
|
+
- `create_auth_router`: Constructor automático de endpoints OIDC para FastAPI.
|
|
145
|
+
- `extract_client_roles(payload, client_id)`: Función helper para extraer los roles.
|
|
146
|
+
- `AuthConfig`, `AuthTokenValidator`, `AuthOIDCClient`: Accesibles para usos complejos o manuales.
|
|
147
|
+
- Manejo de Errores: `KeycloakAuthError`, `TokenValidationError`, `KeycloakAPIError`.
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Desarrollo Local
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
pip install .[dev]
|
|
155
|
+
pytest tests/
|
|
156
|
+
ruff check src/ tests/
|
|
157
|
+
mypy src/auth_guardian
|
|
158
|
+
```
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Auth Guardian
|
|
2
|
+
|
|
3
|
+
**Auth Guardian** es un SDK extremadamente simple, *plug-and-play* y agnóstico, diseñado para proteger aplicaciones y microservicios en Python utilizando Keycloak (u otros proveedores OIDC compatibles).
|
|
4
|
+
|
|
5
|
+
Ha sido diseñado para evitar configuraciones redundantes: con un par de variables de entorno, tu aplicación tendrá flujos de login, logout, callbacks y validación de roles y permisos automáticamente.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Instalación
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install auth-guardian==0.0.1
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Configuración (Plug-and-Play)
|
|
18
|
+
|
|
19
|
+
`AuthGuardian` es lo suficientemente inteligente como para configurarse por sí mismo. Solo necesitas definir las siguientes variables de entorno:
|
|
20
|
+
|
|
21
|
+
```env
|
|
22
|
+
AUTH_BASE_URL=https://auth.tu-dominio.com
|
|
23
|
+
AUTH_REALM=tu-realm
|
|
24
|
+
AUTH_CLIENT_ID=tu-cliente
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
> **Nota:** También soporta las variables legacy `KEYCLOAK_BASE_URL`, `KEYCLOAK_REALM` y `KEYCLOAK_CLIENT_ID` de forma automática.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Uso en FastAPI
|
|
32
|
+
|
|
33
|
+
### 1. Inyectando Rutas Automáticas (Login, Logout, Callback)
|
|
34
|
+
Para aplicaciones frontend/Fullstack, `AuthGuardian` puede inyectar los endpoints completos del flujo OIDC (`/login`, `/logout`, `/oidc/callback`) para que no tengas que escribirlos:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fastapi import FastAPI
|
|
38
|
+
from auth_guardian import AuthGuardian, create_auth_router
|
|
39
|
+
|
|
40
|
+
app = FastAPI()
|
|
41
|
+
|
|
42
|
+
# 1. Instancia el guardián
|
|
43
|
+
auth = AuthGuardian()
|
|
44
|
+
|
|
45
|
+
# 2. Crea el router que manejará automáticamente login, callback y logout
|
|
46
|
+
auth_router = create_auth_router(
|
|
47
|
+
auth=auth,
|
|
48
|
+
oidc_state_secret="tu_secreto_super_seguro_para_estado",
|
|
49
|
+
login_redirect_url="/dashboard", # Dónde enviar al usuario tras loguearse
|
|
50
|
+
logout_redirect_url="/login" # Dónde enviar al usuario tras desloguearse
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# 3. Registra el router en tu app
|
|
54
|
+
app.include_router(auth_router)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**¡Eso es todo!** Automáticamente tienes:
|
|
58
|
+
- `GET /login` -> Redirige al login de Keycloak.
|
|
59
|
+
- `GET /oidc/callback` -> Maneja el intercambio de código, genera las cookies seguras e inicia la sesión local.
|
|
60
|
+
- `GET /logout` -> Elimina cookies y redirige al cierre de sesión de Keycloak.
|
|
61
|
+
|
|
62
|
+
### 2. Protegiendo tus Endpoints (Para Principiantes)
|
|
63
|
+
En FastAPI, "proteger" una ruta significa que si alguien entra sin haber iniciado sesión, el sistema le bloqueará el acceso automáticamente.
|
|
64
|
+
|
|
65
|
+
Para lograr esto, FastAPI usa algo llamado `Depends` (Dependencias). Si agregas `Depends(auth.get_current_user)` en tu función, AuthGuardian se encargará de verificar si el usuario tiene sesión activa antes de ejecutar el código.
|
|
66
|
+
|
|
67
|
+
**Ejemplo 1: Bloquear acceso a usuarios no logueados**
|
|
68
|
+
```python
|
|
69
|
+
from fastapi import Depends
|
|
70
|
+
|
|
71
|
+
@app.get("/recurso-seguro")
|
|
72
|
+
async def get_seguro(user: dict = Depends(auth.get_current_user)):
|
|
73
|
+
# Si llega aquí, es porque el usuario SÍ está logueado.
|
|
74
|
+
# 'user' contendrá la información de su perfil.
|
|
75
|
+
return {
|
|
76
|
+
"mensaje": f"Hola {user.get('preferred_username')}",
|
|
77
|
+
"email": user.get('email')
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**Ejemplo 2: Bloquear acceso según el Rol del usuario**
|
|
82
|
+
Si tienes zonas exclusivas (como un panel de administrador), puedes usar `auth.require_role("nombre_del_rol")`.
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
@app.get("/zona-admin")
|
|
86
|
+
async def get_admin(user: dict = Depends(auth.require_role("admin"))):
|
|
87
|
+
# Solo entrarán usuarios que se hayan logueado y que tengan el rol "admin"
|
|
88
|
+
return {"mensaje": "¡Bienvenido, administrador!"}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Opciones Avanzadas
|
|
94
|
+
|
|
95
|
+
### Validación Manual de Tokens
|
|
96
|
+
Si no usas FastAPI o necesitas validar un token manualmente, el SDK expone internamente el validador:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
payload = await auth.validator.decode_access_token("eyJhbGciOiJIUz...")
|
|
100
|
+
print(payload)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
### Hook Personalizado de Login
|
|
104
|
+
Al usar `create_auth_router`, puedes inyectar un hook que se ejecute justo cuando el usuario hace login exitosamente (por ejemplo, para guardar/actualizar el usuario en tu base de datos):
|
|
105
|
+
|
|
106
|
+
```python
|
|
107
|
+
async def sync_user_in_db(payload: dict):
|
|
108
|
+
email = payload.get("email")
|
|
109
|
+
print(f"Sincronizando usuario {email} en base de datos...")
|
|
110
|
+
|
|
111
|
+
auth_router = create_auth_router(
|
|
112
|
+
auth=auth,
|
|
113
|
+
oidc_state_secret="secreto",
|
|
114
|
+
on_login_success=sync_user_in_db # Se ejecuta después del callback, antes de redirigir
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Contrato Público API
|
|
121
|
+
|
|
122
|
+
El contrato público exportado es estable y mantenido a través del `__init__.py`.
|
|
123
|
+
|
|
124
|
+
- `AuthGuardian`: Clase principal y Facade de autenticación.
|
|
125
|
+
- `create_auth_router`: Constructor automático de endpoints OIDC para FastAPI.
|
|
126
|
+
- `extract_client_roles(payload, client_id)`: Función helper para extraer los roles.
|
|
127
|
+
- `AuthConfig`, `AuthTokenValidator`, `AuthOIDCClient`: Accesibles para usos complejos o manuales.
|
|
128
|
+
- Manejo de Errores: `KeycloakAuthError`, `TokenValidationError`, `KeycloakAPIError`.
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Desarrollo Local
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
pip install .[dev]
|
|
136
|
+
pytest tests/
|
|
137
|
+
ruff check src/ tests/
|
|
138
|
+
mypy src/auth_guardian
|
|
139
|
+
```
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "auth-guardian"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Reusable Keycloak SDK for auth-service and microservices"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"httpx>=0.28,<0.29",
|
|
13
|
+
"python-jose[cryptography]>=3.5,<4",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
fastapi = [
|
|
18
|
+
"fastapi>=0.118,<0.119",
|
|
19
|
+
]
|
|
20
|
+
dev = [
|
|
21
|
+
"build>=1.3,<2",
|
|
22
|
+
"mypy>=1.10,<2",
|
|
23
|
+
"pytest>=8.3,<9",
|
|
24
|
+
"pytest-asyncio>=1.2,<2",
|
|
25
|
+
"respx>=0.22,<0.23",
|
|
26
|
+
"ruff>=0.14,<0.15",
|
|
27
|
+
"twine>=6.2,<7",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
package-dir = {"" = "src"}
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["src"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
asyncio_mode = "auto"
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
|
|
40
|
+
[tool.ruff]
|
|
41
|
+
line-length = 100
|
|
42
|
+
target-version = "py310"
|
|
43
|
+
|
|
44
|
+
[tool.ruff.lint]
|
|
45
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
46
|
+
|
|
47
|
+
[tool.ruff.lint.per-file-ignores]
|
|
48
|
+
"src/auth_guardian/fastapi.py" = ["B008"]
|
|
49
|
+
|
|
50
|
+
[tool.mypy]
|
|
51
|
+
python_version = "3.10"
|
|
52
|
+
warn_unused_ignores = true
|
|
53
|
+
warn_redundant_casts = true
|
|
54
|
+
warn_return_any = true
|
|
55
|
+
disallow_untyped_defs = true
|
|
56
|
+
check_untyped_defs = true
|
|
57
|
+
no_implicit_optional = true
|
|
58
|
+
|
|
59
|
+
[[tool.mypy.overrides]]
|
|
60
|
+
module = ["jose", "jose.*", "respx.*", "fastapi", "fastapi.*"]
|
|
61
|
+
ignore_missing_imports = true
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Public contract for auth-guardian.
|
|
2
|
+
|
|
3
|
+
Keep this module intentionally small and stable to avoid breaking microservices.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from auth_guardian.config import AuthConfig
|
|
12
|
+
from auth_guardian.core import AuthGuardian
|
|
13
|
+
from auth_guardian.exceptions import KeycloakAPIError, KeycloakAuthError, TokenValidationError
|
|
14
|
+
from auth_guardian.oidc_client import AuthOIDCClient
|
|
15
|
+
from auth_guardian.roles import extract_client_roles
|
|
16
|
+
from auth_guardian.router import create_auth_router
|
|
17
|
+
from auth_guardian.state import generate_signed_state, verify_signed_state
|
|
18
|
+
from auth_guardian.token_validator import AuthTokenValidator
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("auth-guardian")
|
|
22
|
+
except PackageNotFoundError:
|
|
23
|
+
__version__ = "0.0.0+local"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"__version__",
|
|
27
|
+
"AuthGuardian",
|
|
28
|
+
"AuthConfig",
|
|
29
|
+
"AuthTokenValidator",
|
|
30
|
+
"AuthOIDCClient",
|
|
31
|
+
"TokenValidationError",
|
|
32
|
+
"KeycloakAuthError",
|
|
33
|
+
"KeycloakAPIError",
|
|
34
|
+
"create_auth_router",
|
|
35
|
+
"extract_client_roles",
|
|
36
|
+
"generate_signed_state",
|
|
37
|
+
"verify_signed_state",
|
|
38
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@dataclass(frozen=True)
|
|
5
|
+
class AuthConfig:
|
|
6
|
+
base_url: str
|
|
7
|
+
realm: str
|
|
8
|
+
client_id: str
|
|
9
|
+
client_secret: str | None = None
|
|
10
|
+
issuer: str | None = None
|
|
11
|
+
internal_url: str | None = None
|
|
12
|
+
state_secret: str | None = None
|
|
13
|
+
verify_tls: bool = True
|
|
14
|
+
request_timeout_seconds: float = 20.0
|
|
15
|
+
jwks_cache_ttl_seconds: int = 300
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def frontend_realm_url(self) -> str:
|
|
19
|
+
return f"{self.base_url.rstrip('/')}/realms/{self.realm}"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def backend_realm_url(self) -> str:
|
|
23
|
+
base = (self.internal_url or self.base_url).rstrip("/")
|
|
24
|
+
return f"{base}/realms/{self.realm}"
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def admin_realm_url(self) -> str:
|
|
28
|
+
base = (self.internal_url or self.base_url).rstrip("/")
|
|
29
|
+
return f"{base}/admin/realms/{self.realm}"
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def expected_issuer(self) -> str:
|
|
33
|
+
return self.issuer or self.frontend_realm_url
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from collections.abc import Awaitable, Callable
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastapi import Depends, HTTPException, Request, Response, Security
|
|
6
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
7
|
+
|
|
8
|
+
from auth_guardian.config import AuthConfig
|
|
9
|
+
from auth_guardian.exceptions import KeycloakAPIError, TokenValidationError
|
|
10
|
+
from auth_guardian.oidc_client import AuthOIDCClient
|
|
11
|
+
from auth_guardian.roles import extract_client_roles
|
|
12
|
+
from auth_guardian.token_validator import AuthTokenValidator
|
|
13
|
+
|
|
14
|
+
security = HTTPBearer(auto_error=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AuthGuardian:
|
|
18
|
+
"""
|
|
19
|
+
A simple facade to integrate authentication into FastAPI applications.
|
|
20
|
+
It automatically reads from environment variables if not explicitly provided.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
base_url: str | None = None,
|
|
26
|
+
realm: str | None = None,
|
|
27
|
+
client_id: str | None = None,
|
|
28
|
+
state_secret: str | None = None,
|
|
29
|
+
**kwargs: Any,
|
|
30
|
+
):
|
|
31
|
+
base_url = base_url or os.getenv("AUTH_BASE_URL") or os.getenv("KEYCLOAK_BASE_URL") or ""
|
|
32
|
+
realm = realm or os.getenv("AUTH_REALM") or os.getenv("KEYCLOAK_REALM") or ""
|
|
33
|
+
client_id = client_id or os.getenv("AUTH_CLIENT_ID") or os.getenv("KEYCLOAK_CLIENT_ID") or ""
|
|
34
|
+
state_secret = state_secret or os.getenv("AUTH_STATE_SECRET") or os.getenv("KEYCLOAK_STATE_SECRET")
|
|
35
|
+
|
|
36
|
+
self.config = AuthConfig(
|
|
37
|
+
base_url=base_url,
|
|
38
|
+
realm=realm,
|
|
39
|
+
client_id=client_id,
|
|
40
|
+
state_secret=state_secret,
|
|
41
|
+
**kwargs,
|
|
42
|
+
)
|
|
43
|
+
self.validator = AuthTokenValidator(self.config)
|
|
44
|
+
self.oidc_client = AuthOIDCClient(self.config)
|
|
45
|
+
|
|
46
|
+
async def get_current_user(
|
|
47
|
+
self,
|
|
48
|
+
request: Request,
|
|
49
|
+
response: Response,
|
|
50
|
+
credentials: HTTPAuthorizationCredentials | None = Security(security),
|
|
51
|
+
) -> dict[str, Any]:
|
|
52
|
+
"""
|
|
53
|
+
FastAPI dependency to get the authenticated user's token payload.
|
|
54
|
+
Handles silent token refreshing if the token is expired and a valid refresh token exists.
|
|
55
|
+
"""
|
|
56
|
+
token = credentials.credentials if credentials else request.cookies.get("access_token")
|
|
57
|
+
cookie_name = "access_token"
|
|
58
|
+
if not token:
|
|
59
|
+
token = request.cookies.get("pfx_token")
|
|
60
|
+
if token:
|
|
61
|
+
cookie_name = "pfx_token"
|
|
62
|
+
|
|
63
|
+
if not token:
|
|
64
|
+
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
return await self.validator.decode_access_token(token)
|
|
68
|
+
except TokenValidationError as exc:
|
|
69
|
+
if "expirado" in exc.detail.lower() or "expired" in exc.detail.lower():
|
|
70
|
+
refresh_token = request.cookies.get("refresh_token")
|
|
71
|
+
if not refresh_token:
|
|
72
|
+
refresh_token = request.cookies.get("pfx_refresh_token")
|
|
73
|
+
|
|
74
|
+
if refresh_token:
|
|
75
|
+
try:
|
|
76
|
+
token_data = await self.oidc_client.refresh_access_token(refresh_token)
|
|
77
|
+
new_access_token = token_data["access_token"]
|
|
78
|
+
new_refresh_token = token_data.get("refresh_token", refresh_token)
|
|
79
|
+
|
|
80
|
+
# Set new cookies on the response object
|
|
81
|
+
is_prod = os.getenv("IS_PROD", "false").lower() == "true"
|
|
82
|
+
response.set_cookie(
|
|
83
|
+
cookie_name,
|
|
84
|
+
new_access_token,
|
|
85
|
+
httponly=True,
|
|
86
|
+
samesite="lax",
|
|
87
|
+
secure=is_prod,
|
|
88
|
+
)
|
|
89
|
+
response.set_cookie(
|
|
90
|
+
"refresh_token" if not request.cookies.get("pfx_refresh_token") else "pfx_refresh_token",
|
|
91
|
+
new_refresh_token,
|
|
92
|
+
httponly=True,
|
|
93
|
+
samesite="lax",
|
|
94
|
+
secure=is_prod,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return await self.validator.decode_access_token(new_access_token)
|
|
98
|
+
except (KeycloakAPIError, TokenValidationError):
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
|
|
102
|
+
|
|
103
|
+
def require_role(self, *required_roles: str) -> Callable[..., Awaitable[dict[str, Any]]]:
|
|
104
|
+
"""
|
|
105
|
+
FastAPI dependency factory to require specific roles.
|
|
106
|
+
"""
|
|
107
|
+
async def _role_guard(user: dict[str, Any] = Depends(self.get_current_user)) -> dict[str, Any]:
|
|
108
|
+
roles = set(extract_client_roles(user, self.config.client_id))
|
|
109
|
+
if not roles.intersection(required_roles):
|
|
110
|
+
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
|
111
|
+
return user
|
|
112
|
+
|
|
113
|
+
return _role_guard
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class KeycloakAuthError(Exception):
|
|
2
|
+
def __init__(self, detail: str, status_code: int = 401):
|
|
3
|
+
super().__init__(detail)
|
|
4
|
+
self.detail = detail
|
|
5
|
+
self.status_code = status_code
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TokenValidationError(KeycloakAuthError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class KeycloakAPIError(Exception):
|
|
13
|
+
def __init__(self, detail: str, status_code: int = 502):
|
|
14
|
+
super().__init__(detail)
|
|
15
|
+
self.detail = detail
|
|
16
|
+
self.status_code = status_code
|