Skip to content

Commit a365651

Browse files
Merge pull request #25 from SyntaxArc/keycloak
Keycloak
2 parents 2a2116d + 556ed81 commit a365651

File tree

7 files changed

+325
-73
lines changed

7 files changed

+325
-73
lines changed

archipy/adapters/keycloak/adapters.py

Lines changed: 18 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -352,19 +352,16 @@ def has_role(self, token: str, role_name: str) -> bool:
352352
"""
353353
# Not caching this result as token validation is time-sensitive
354354
try:
355-
token_info = self.openid_adapter.decode_token(
356-
token,
357-
key=self.get_public_key(),
358-
)
355+
user_info = self.get_userinfo(token)
359356

360357
# Check realm roles
361-
realm_access = token_info.get("realm_access", {})
358+
realm_access = user_info.get("realm_access", {})
362359
roles = realm_access.get("roles", [])
363360
if role_name in roles:
364361
return True
365362

366363
# Check client roles
367-
resource_access = token_info.get("resource_access", {})
364+
resource_access = user_info.get("resource_access", {})
368365
for client in resource_access.values():
369366
client_roles = client.get("roles", [])
370367
if role_name in client_roles:
@@ -387,19 +384,16 @@ def has_any_of_roles(self, token: str, role_names: set[str]) -> bool:
387384
True if user has any of the roles, False otherwise
388385
"""
389386
try:
390-
token_info = self.openid_adapter.decode_token(
391-
token,
392-
key=self.get_public_key(),
393-
)
387+
user_info = self.get_userinfo(token)
394388

395389
# Check realm roles
396-
realm_access = token_info.get("realm_access", {})
390+
realm_access = user_info.get("realm_access", {})
397391
roles = set(realm_access.get("roles", []))
398392
if role_names.intersection(roles):
399393
return True
400394

401395
# Check client roles
402-
resource_access = token_info.get("resource_access", {})
396+
resource_access = user_info.get("resource_access", {})
403397
for client in resource_access.values():
404398
client_roles = set(client.get("roles", []))
405399
if role_names.intersection(client_roles):
@@ -422,20 +416,17 @@ def has_all_roles(self, token: str, role_names: set[str]) -> bool:
422416
True if user has all of the roles, False otherwise
423417
"""
424418
try:
425-
token_info = self.openid_adapter.decode_token(
426-
token,
427-
key=self.get_public_key(),
428-
)
419+
user_info = self.get_userinfo(token)
429420

430421
# Get all user roles
431422
all_roles = set()
432423

433424
# Add realm roles
434-
realm_access = token_info.get("realm_access", {})
425+
realm_access = user_info.get("realm_access", {})
435426
all_roles.update(realm_access.get("roles", []))
436427

437428
# Add client roles
438-
resource_access = token_info.get("resource_access", {})
429+
resource_access = user_info.get("resource_access", {})
439430
for client in resource_access.values():
440431
all_roles.update(client.get("roles", []))
441432

@@ -1349,19 +1340,16 @@ async def has_role(self, token: str, role_name: str) -> bool:
13491340
"""
13501341
# Not caching this result as token validation is time-sensitive
13511342
try:
1352-
token_info = await self.openid_adapter.a_decode_token(
1353-
token,
1354-
key=await self.get_public_key(),
1355-
)
1343+
user_info = await self.get_userinfo(token)
13561344

13571345
# Check realm roles
1358-
realm_access = token_info.get("realm_access", {})
1346+
realm_access = user_info.get("realm_access", {})
13591347
roles = realm_access.get("roles", [])
13601348
if role_name in roles:
13611349
return True
13621350

13631351
# Check client roles
1364-
resource_access = token_info.get("resource_access", {})
1352+
resource_access = user_info.get("resource_access", {})
13651353
for client in resource_access.values():
13661354
client_roles = client.get("roles", [])
13671355
if role_name in client_roles:
@@ -1384,19 +1372,16 @@ async def has_any_of_roles(self, token: str, role_names: set[str]) -> bool:
13841372
True if user has any of the roles, False otherwise
13851373
"""
13861374
try:
1387-
token_info = await self.openid_adapter.a_decode_token(
1388-
token,
1389-
key=await self.get_public_key(),
1390-
)
1375+
user_info = await self.get_userinfo(token)
13911376

13921377
# Check realm roles
1393-
realm_access = token_info.get("realm_access", {})
1378+
realm_access = user_info.get("realm_access", {})
13941379
roles = set(realm_access.get("roles", []))
13951380
if role_names.intersection(roles):
13961381
return True
13971382

13981383
# Check client roles
1399-
resource_access = token_info.get("resource_access", {})
1384+
resource_access = user_info.get("resource_access", {})
14001385
for client in resource_access.values():
14011386
client_roles = set(client.get("roles", []))
14021387
if role_names.intersection(client_roles):
@@ -1419,20 +1404,17 @@ async def has_all_roles(self, token: str, role_names: set[str]) -> bool:
14191404
True if user has all of the roles, False otherwise
14201405
"""
14211406
try:
1422-
token_info = await self.openid_adapter.a_decode_token(
1423-
token,
1424-
key=await self.get_public_key(),
1425-
)
1407+
user_info = await self.get_userinfo(token)
14261408

14271409
# Get all user roles
14281410
all_roles = set()
14291411

14301412
# Add realm roles
1431-
realm_access = token_info.get("realm_access", {})
1413+
realm_access = user_info.get("realm_access", {})
14321414
all_roles.update(realm_access.get("roles", []))
14331415

14341416
# Add client roles
1435-
resource_access = token_info.get("resource_access", {})
1417+
resource_access = user_info.get("resource_access", {})
14361418
for client in resource_access.values():
14371419
all_roles.update(client.get("roles", []))
14381420

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
from fastapi import Depends, Request, Security
2+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
3+
4+
from archipy.adapters.keycloak.adapters import AsyncKeycloakAdapter, KeycloakAdapter
5+
from archipy.models.errors import InvalidArgumentError, PermissionDeniedError, TokenExpiredError, UnauthenticatedError
6+
from archipy.models.types.language_type import LanguageType
7+
8+
# Enhanced security scheme with OpenAPI documentation
9+
security = HTTPBearer(scheme_name="OAuth2", description="OAuth2 Access Token", auto_error=False)
10+
11+
# Default language for errors
12+
DEFAULT_LANG = LanguageType.FA
13+
14+
15+
class KeycloakUtils:
16+
@staticmethod
17+
def _get_keycloak_adapter() -> KeycloakAdapter:
18+
return KeycloakAdapter()
19+
20+
@staticmethod
21+
def _get_async_keycloak_adapter() -> AsyncKeycloakAdapter:
22+
return AsyncKeycloakAdapter()
23+
24+
@classmethod
25+
# Synchronous decorator
26+
def fastapi_auth(
27+
cls,
28+
resource_type_param: str | None = None,
29+
resource_type: str | None = None,
30+
required_roles: set[str] | None = None,
31+
all_roles_required: bool = False,
32+
required_permissions: list[tuple[str, str]] | None = None,
33+
admin_roles: set[str] | None = None,
34+
lang: LanguageType = DEFAULT_LANG,
35+
):
36+
"""FastAPI decorator for Keycloak authentication and resource-based authorization.
37+
38+
Args:
39+
resource_type_param: The parameter name in the path (e.g., 'user_uuid', 'employee_uuid')
40+
resource_type: The type of resource being accessed (e.g., 'users', 'employees')
41+
required_roles: Set of role names that the user must have
42+
all_roles_required: If True, user must have all specified roles; if False, any role is sufficient
43+
required_permissions: List of (resource, scope) tuples to check
44+
admin_roles: Set of roles that grant administrative access to all resources
45+
lang: Language for error messages
46+
Raises:
47+
UnauthenticatedError: If no valid Authorization header is provided
48+
InvalidTokenError: If token is invalid
49+
TokenExpiredError: If token is expired
50+
PermissionDeniedError: If user lacks required roles, permissions, or resource access
51+
InvalidArgumentError: If resource_type_param is missing when resource_type is provided
52+
"""
53+
54+
def dependency(
55+
request: Request,
56+
token: HTTPAuthorizationCredentials | None = Security(security),
57+
keycloak: KeycloakAdapter = Depends(cls._get_keycloak_adapter),
58+
):
59+
if token is None:
60+
raise UnauthenticatedError()
61+
token_str = token.credentials # Extract the token string
62+
# Validate token
63+
if not keycloak.validate_token(token_str):
64+
token_info = keycloak.introspect_token(token_str)
65+
if not token_info.get("active", False):
66+
raise TokenExpiredError(lang=lang)
67+
68+
# Get user info from token
69+
user_info = keycloak.get_userinfo(token_str)
70+
token_info = keycloak.get_token_info(token_str)
71+
72+
# Resource-based authorization if resource type is provided
73+
if resource_type and resource_type_param:
74+
# Extract resource UUID from path parameters
75+
resource_uuid = request.path_params.get(resource_type_param)
76+
if not resource_uuid:
77+
raise InvalidArgumentError(argument_name=resource_type_param, lang=lang)
78+
79+
# Verify resource exists and user has access
80+
user_uuid = user_info.get("sub")
81+
82+
# Check if resource exists
83+
resource_user = keycloak.get_user_by_id(resource_uuid)
84+
if not resource_user:
85+
raise PermissionDeniedError(lang=lang)
86+
87+
# Authorization check: either owns the resource or has admin privileges
88+
has_admin_privileges = admin_roles and keycloak.has_any_of_roles(token_str, admin_roles)
89+
if user_uuid != resource_uuid and not has_admin_privileges:
90+
raise PermissionDeniedError(
91+
lang=lang,
92+
additional_data={"resource_type": resource_type, "resource_id": resource_uuid},
93+
)
94+
95+
# Check additional roles if specified
96+
if required_roles:
97+
if all_roles_required:
98+
if not keycloak.has_all_roles(token_str, required_roles):
99+
raise PermissionDeniedError(lang=lang)
100+
elif not keycloak.has_any_of_roles(token_str, required_roles):
101+
raise PermissionDeniedError(lang=lang)
102+
103+
# Check permissions if specified
104+
if required_permissions:
105+
for resource, scope in required_permissions:
106+
if not keycloak.check_permissions(token_str, resource, scope):
107+
raise PermissionDeniedError(
108+
lang=lang,
109+
additional_data={"required_permission": f"{resource}#{scope}"},
110+
)
111+
112+
# Add user info to request state
113+
request.state.user_info = user_info
114+
request.state.token_info = token_info
115+
return user_info
116+
117+
return dependency
118+
119+
@classmethod
120+
def async_fastapi_auth(
121+
cls,
122+
resource_type_param: str | None = None,
123+
resource_type: str | None = None,
124+
required_roles: set[str] | None = None,
125+
all_roles_required: bool = False,
126+
required_permissions: list[tuple[str, str]] | None = None,
127+
admin_roles: set[str] | None = None,
128+
lang: LanguageType = DEFAULT_LANG,
129+
):
130+
"""FastAPI async decorator for Keycloak authentication and resource-based authorization.
131+
132+
Args:
133+
resource_type_param: The parameter name in the path (e.g., 'user_uuid', 'employee_uuid')
134+
resource_type: The type of resource being accessed (e.g., 'users', 'employees')
135+
required_roles: Set of role names that the user must have
136+
all_roles_required: If True, user must have all specified roles; if False, any role is sufficient
137+
required_permissions: List of (resource, scope) tuples to check
138+
admin_roles: Set of roles that grant administrative access to all resources
139+
lang: Language for error messages
140+
Raises:
141+
UnauthenticatedError: If no valid Authorization header is provided
142+
InvalidTokenError: If token is invalid
143+
TokenExpiredError: If token is expired
144+
PermissionDeniedError: If user lacks required roles, permissions, or resource access
145+
InvalidArgumentError: If resource_type_param is missing when resource_type is provided
146+
"""
147+
148+
async def dependency(
149+
request: Request,
150+
token: HTTPAuthorizationCredentials = Security(security),
151+
keycloak: AsyncKeycloakAdapter = Depends(cls._get_async_keycloak_adapter),
152+
):
153+
if token is None:
154+
raise UnauthenticatedError()
155+
token_str = token.credentials # Extract the token string
156+
# Validate token
157+
if not await keycloak.validate_token(token_str):
158+
# TODO
159+
token_info = await keycloak.introspect_token(token_str)
160+
if not token_info.get("active", False):
161+
raise TokenExpiredError(lang=lang)
162+
163+
# Get user info from token
164+
user_info = await keycloak.get_userinfo(token_str)
165+
token_info = await keycloak.get_token_info(token_str)
166+
167+
# Resource-based authorization if resource type is provided
168+
if resource_type and resource_type_param:
169+
# Extract resource UUID from path parameters
170+
resource_uuid = request.path_params.get(resource_type_param)
171+
if not resource_uuid:
172+
raise InvalidArgumentError(argument_name=resource_type_param, lang=lang)
173+
174+
# Verify resource exists and user has access
175+
user_uuid = user_info.get("sub")
176+
177+
# Check if resource exists
178+
resource_user = await keycloak.get_user_by_id(resource_uuid)
179+
if not resource_user:
180+
raise PermissionDeniedError(lang=lang)
181+
182+
# Authorization check: either owns the resource or has admin privileges
183+
has_admin_privileges = admin_roles and await keycloak.has_any_of_roles(token_str, admin_roles)
184+
if user_uuid != resource_uuid and not has_admin_privileges:
185+
raise PermissionDeniedError(
186+
lang=lang,
187+
additional_data={"resource_type": resource_type, "resource_id": resource_uuid},
188+
)
189+
190+
# Check additional roles if specified
191+
if required_roles:
192+
if all_roles_required:
193+
if not await keycloak.has_all_roles(token_str, required_roles):
194+
raise PermissionDeniedError(lang=lang)
195+
elif not await keycloak.has_any_of_roles(token_str, required_roles):
196+
raise PermissionDeniedError(lang=lang)
197+
198+
# Check permissions if specified
199+
if required_permissions:
200+
for resource, scope in required_permissions:
201+
if not await keycloak.check_permissions(token_str, resource, scope):
202+
raise PermissionDeniedError(
203+
lang=lang,
204+
additional_data={"required_permission": f"{resource}#{scope}"},
205+
)
206+
207+
# Add user info to request state
208+
request.state.user_info = user_info
209+
request.state.token_info = token_info
210+
return user_info
211+
212+
return dependency

0 commit comments

Comments
 (0)