-
Notifications
You must be signed in to change notification settings - Fork 74
Add external application api endpoints #734
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
4649248
ef49ea6
37f1d95
73273ad
66d7386
e20a0c2
bb4a818
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
"""Meet core external API endpoints""" |
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,109 @@ | ||||||||||||||||||||||||||||||||
"""Authentication Backends for external application to the Meet core app.""" | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
import logging | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
from django.conf import settings | ||||||||||||||||||||||||||||||||
from django.contrib.auth import get_user_model | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
import jwt | ||||||||||||||||||||||||||||||||
from rest_framework import authentication, exceptions | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
User = get_user_model() | ||||||||||||||||||||||||||||||||
logger = logging.getLogger(__name__) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication): | ||||||||||||||||||||||||||||||||
"""JWT authentication for application-delegated API access. | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
Validates JWT tokens issued to applications that are acting on behalf | ||||||||||||||||||||||||||||||||
of users. Tokens must include user_id, client_id, and delegation flag. | ||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
def authenticate(self, request): | ||||||||||||||||||||||||||||||||
"""Extract and validate JWT from Authorization header. | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
Returns: | ||||||||||||||||||||||||||||||||
Tuple of (user, payload) if authentication successful, None otherwise | ||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||
auth_header = authentication.get_authorization_header(request).split() | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if not auth_header or auth_header[0].lower() != b"bearer": | ||||||||||||||||||||||||||||||||
return None | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if len(auth_header) != 2: | ||||||||||||||||||||||||||||||||
logger.warning("Invalid token header format") | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token header.") | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||
token = auth_header[1].decode("utf-8") | ||||||||||||||||||||||||||||||||
except UnicodeError as e: | ||||||||||||||||||||||||||||||||
logger.warning("Token decode error: %s", e) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
return self.authenticate_credentials(token) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
def authenticate_credentials(self, token): | ||||||||||||||||||||||||||||||||
"""Validate JWT token and return authenticated user. | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
Args: | ||||||||||||||||||||||||||||||||
token: JWT token string | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
Returns: | ||||||||||||||||||||||||||||||||
Tuple of (user, payload) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
Raises: | ||||||||||||||||||||||||||||||||
AuthenticationFailed: If token is invalid, expired, or user not found | ||||||||||||||||||||||||||||||||
""" | ||||||||||||||||||||||||||||||||
# Decode and validate JWT | ||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||
payload = jwt.decode( | ||||||||||||||||||||||||||||||||
token, | ||||||||||||||||||||||||||||||||
settings.APPLICATION_JWT_SECRET_KEY, | ||||||||||||||||||||||||||||||||
algorithms=[settings.APPLICATION_JWT_ALG], | ||||||||||||||||||||||||||||||||
issuer=settings.APPLICATION_JWT_ISSUER, | ||||||||||||||||||||||||||||||||
audience=settings.APPLICATION_JWT_AUDIENCE, | ||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||
Comment on lines
+59
to
+65
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Require standard JWT claims to harden validation. Enforce presence of exp/iat to improve security posture. payload = jwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
+ options={"require": ["exp", "iat"]},
) 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||
except jwt.ExpiredSignatureError as e: | ||||||||||||||||||||||||||||||||
logger.warning("Token expired") | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Token expired.") from e | ||||||||||||||||||||||||||||||||
except jwt.InvalidIssuerError as e: | ||||||||||||||||||||||||||||||||
logger.warning("Invalid JWT issuer: %s", e) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e | ||||||||||||||||||||||||||||||||
except jwt.InvalidAudienceError as e: | ||||||||||||||||||||||||||||||||
logger.warning("Invalid JWT audience: %s", e) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e | ||||||||||||||||||||||||||||||||
except jwt.InvalidTokenError as e: | ||||||||||||||||||||||||||||||||
logger.warning("Invalid JWT token: %s", e) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
user_id = payload.get("user_id") | ||||||||||||||||||||||||||||||||
client_id = payload.get("client_id") | ||||||||||||||||||||||||||||||||
is_delegated = payload.get("delegated", False) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if not user_id: | ||||||||||||||||||||||||||||||||
logger.warning("Missing 'user_id' in JWT payload") | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token claims.") | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if not client_id: | ||||||||||||||||||||||||||||||||
logger.warning("Missing 'client_id' in JWT payload") | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token claims.") | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if not is_delegated: | ||||||||||||||||||||||||||||||||
logger.warning("Token is not marked as delegated") | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("Invalid token type.") | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
try: | ||||||||||||||||||||||||||||||||
user = User.objects.get(id=user_id) | ||||||||||||||||||||||||||||||||
except User.DoesNotExist as e: | ||||||||||||||||||||||||||||||||
logger.warning("User not found: %s", user_id) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("User not found.") from e | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
if not user.is_active: | ||||||||||||||||||||||||||||||||
logger.warning("Inactive user attempted authentication: %s", user_id) | ||||||||||||||||||||||||||||||||
raise exceptions.AuthenticationFailed("User account is disabled.") | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
return (user, payload) | ||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||
def authenticate_header(self, request): | ||||||||||||||||||||||||||||||||
"""Return authentication scheme for WWW-Authenticate header.""" | ||||||||||||||||||||||||||||||||
return "Bearer" |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
"""Permission handlers for application-delegated API access.""" | ||
|
||
import logging | ||
from typing import Dict | ||
|
||
from rest_framework import exceptions, permissions | ||
|
||
from .. import models | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
class BaseScopePermission(permissions.BasePermission): | ||
"""Base class for scope-based permission checking. | ||
|
||
Subclasses must define `scope_map` attribute mapping actions to required scopes. | ||
""" | ||
|
||
scope_map: Dict[str, str] = {} | ||
|
||
def has_permission(self, request, view): | ||
"""Check if the JWT token contains the required scope for this action. | ||
|
||
Args: | ||
request: DRF request object with authenticated user | ||
view: ViewSet instance | ||
|
||
Returns: | ||
bool: True if permission granted | ||
|
||
Raises: | ||
PermissionDenied: If required scope is missing from token | ||
""" | ||
# Get the current action (e.g., 'list', 'create') | ||
action = getattr(view, "action", None) | ||
if not action: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could you just add a comment to say why we need to manage this case ? (automatic scheme documentation if I remember correctly?) There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have updated this check to return false, if I cannot determine the action being made. |
||
raise exceptions.PermissionDenied( | ||
"Insufficient permissions. Unknown action." | ||
) | ||
|
||
required_scope = self.scope_map.get(action) | ||
if not required_scope: | ||
# Action not in scope_map, deny by default | ||
raise exceptions.PermissionDenied( | ||
f"Insufficient permissions. Required scope: {required_scope}" | ||
) | ||
|
||
token_payload = request.auth | ||
token_scopes = token_payload.get("scope") | ||
|
||
if not token_scopes: | ||
raise exceptions.PermissionDenied("Insufficient permissions.") | ||
|
||
# Ensure scopes is a list (handle both list and space-separated string) | ||
if isinstance(token_scopes, str): | ||
token_scopes = token_scopes.split() | ||
|
||
if required_scope not in token_scopes: | ||
raise exceptions.PermissionDenied( | ||
f"Insufficient permissions. Required scope: {required_scope}" | ||
) | ||
|
||
return True | ||
|
||
|
||
class HasRequiredRoomScope(BaseScopePermission): | ||
"""Permission class for Room-related operations.""" | ||
|
||
scope_map = { | ||
"list": models.ApplicationScope.ROOMS_LIST, | ||
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE, | ||
"create": models.ApplicationScope.ROOMS_CREATE, | ||
"update": models.ApplicationScope.ROOMS_UPDATE, | ||
"partial_update": models.ApplicationScope.ROOMS_UPDATE, | ||
"destroy": models.ApplicationScope.ROOMS_DELETE, | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
"""Serializers for the external API of the Meet core app.""" | ||
|
||
# pylint: disable=abstract-method | ||
|
||
from django.conf import settings | ||
lebaudantoine marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
from rest_framework import serializers | ||
|
||
from core import models, utils | ||
from core.api.serializers import BaseValidationOnlySerializer | ||
|
||
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials" | ||
|
||
|
||
class ApplicationJwtSerializer(BaseValidationOnlySerializer): | ||
"""Validate OAuth2 JWT token request data.""" | ||
|
||
client_id = serializers.CharField(write_only=True) | ||
client_secret = serializers.CharField(write_only=True) | ||
grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS]) | ||
scope = serializers.CharField(write_only=True) | ||
|
||
|
||
class RoomSerializer(serializers.ModelSerializer): | ||
"""External API serializer for room data exposed to applications. | ||
|
||
Provides limited, safe room information for third-party integrations: | ||
- Secure defaults for room creation (trusted access level) | ||
- Computed fields (url, telephony) for external consumption | ||
- Filtered data appropriate for delegation scenarios | ||
- Tracks creation source for auditing | ||
|
||
Intentionally exposes minimal information to external applications, | ||
following the principle of least privilege. | ||
""" | ||
|
||
class Meta: | ||
model = models.Room | ||
fields = ["id", "name", "slug", "pin_code", "access_level"] | ||
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"] | ||
|
||
def to_representation(self, instance): | ||
"""Enrich response with application-specific computed fields.""" | ||
output = super().to_representation(instance) | ||
request = self.context.get("request") | ||
pin_code = output.pop("pin_code", None) | ||
|
||
if not request: | ||
return output | ||
|
||
# Add room URL for direct access | ||
if settings.APPLICATION_BASE_URL: | ||
output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}" | ||
|
||
# Add telephony information if enabled | ||
if settings.ROOM_TELEPHONY_ENABLED: | ||
output["telephony"] = { | ||
"enabled": True, | ||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER, | ||
"pin_code": pin_code, | ||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY, | ||
} | ||
|
||
return output | ||
|
||
def create(self, validated_data): | ||
"""Create room with secure defaults for application delegation.""" | ||
|
||
# Set secure defaults | ||
validated_data["name"] = utils.generate_room_slug() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What if the slug is already existing? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have set this case aside for now. Why? With the current slug pattern, there are approximately 10^14 possible combinations. To put this in perspective: you would need to generate roughly 1.7 million slugs to have just a 1% chance of encountering at least one collision. For a 50% chance, the number rises to around 14 million slugs. It's relevant, however I think it can wait for later. |
||
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED | ||
|
||
return super().create(validated_data) |
Uh oh!
There was an error while loading. Please reload this page.