✨(backend) integrate ResourceServerAuthentication on the external api
Upgrade django-lasuite to v0.0.19 to benefit from the latest resource server authentication backend. Thanks @qbey for your work. For my needs, @qbey refactored the class in #46 on django-lasuite. Integrate ResourceServerAuthentication in the relevant viewset. The integration is straightforward since most heavy lifting was done in the external-api viewset when introducing the service account. Slightly modify the existing service account authentication backend to defer to ResourceServerAuthentication if a token is not recognized. Override user provisioning behavior in ResourceServerBackend: now, a user is automatically created if missing, based on the 'sub' claim (email is not yet present in the introspection response). Note: shared/common implementation currently only retrieves users, failing if the user does not exist.
This commit is contained in:
committed by
aleb_the_flash
parent
a642c6d9a2
commit
c7f5dabbad
4
.github/workflows/meet.yml
vendored
4
.github/workflows/meet.yml
vendored
@@ -183,6 +183,10 @@ jobs:
|
|||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
|
OIDC_RS_CLIENT_ID: meet
|
||||||
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||||
|
OIDC_OP_URL: https://oidc.example.com
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/cert
|
|||||||
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
OIDC_OP_AUTHORIZATION_ENDPOINT=http://localhost:8083/realms/meet/protocol/openid-connect/auth
|
||||||
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
OIDC_OP_TOKEN_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token
|
||||||
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
OIDC_OP_USER_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/userinfo
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/token/introspect
|
||||||
|
OIDC_OP_URL=http://localhost:8083/realms/meet
|
||||||
|
|
||||||
OIDC_RP_CLIENT_ID=meet
|
OIDC_RP_CLIENT_ID=meet
|
||||||
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RP_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
@@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
|||||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||||
|
|
||||||
|
OIDC_RS_CLIENT_ID=meet
|
||||||
|
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
|
||||||
# Livekit Token settings
|
# Livekit Token settings
|
||||||
LIVEKIT_API_SECRET=secret
|
LIVEKIT_API_SECRET=secret
|
||||||
LIVEKIT_API_KEY=devkey
|
LIVEKIT_API_KEY=devkey
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ import logging
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import get_user_model
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
import jwt
|
import jwt as pyJwt
|
||||||
|
from lasuite.oidc_resource_server.backend import ResourceServerBackend as LaSuiteBackend
|
||||||
from rest_framework import authentication, exceptions
|
from rest_framework import authentication, exceptions
|
||||||
|
|
||||||
User = get_user_model()
|
User = get_user_model()
|
||||||
@@ -25,9 +27,11 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (user, payload) if authentication successful, None otherwise
|
Tuple of (user, payload) if authentication successful, None otherwise
|
||||||
"""
|
"""
|
||||||
|
|
||||||
auth_header = authentication.get_authorization_header(request).split()
|
auth_header = authentication.get_authorization_header(request).split()
|
||||||
|
|
||||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||||
|
# Defer to next authentication backend
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if len(auth_header) != 2:
|
if len(auth_header) != 2:
|
||||||
@@ -45,6 +49,8 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
def authenticate_credentials(self, token):
|
def authenticate_credentials(self, token):
|
||||||
"""Validate JWT token and return authenticated user.
|
"""Validate JWT token and return authenticated user.
|
||||||
|
|
||||||
|
If token is invalid, defer to next authentication backend.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT token string
|
token: JWT token string
|
||||||
|
|
||||||
@@ -52,29 +58,29 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
Tuple of (user, payload)
|
Tuple of (user, payload)
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
AuthenticationFailed: If token is invalid, expired, or user not found
|
AuthenticationFailed: If token is expired, or user not found
|
||||||
"""
|
"""
|
||||||
# Decode and validate JWT
|
# Decode and validate JWT
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = pyJwt.decode(
|
||||||
token,
|
token,
|
||||||
settings.APPLICATION_JWT_SECRET_KEY,
|
settings.APPLICATION_JWT_SECRET_KEY,
|
||||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||||
)
|
)
|
||||||
except jwt.ExpiredSignatureError as e:
|
except pyJwt.ExpiredSignatureError as e:
|
||||||
logger.warning("Token expired")
|
logger.warning("Token expired")
|
||||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||||
except jwt.InvalidIssuerError as e:
|
except pyJwt.InvalidIssuerError as e:
|
||||||
logger.warning("Invalid JWT issuer: %s", e)
|
logger.warning("Invalid JWT issuer: %s", e)
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
except jwt.InvalidAudienceError as e:
|
except pyJwt.InvalidAudienceError as e:
|
||||||
logger.warning("Invalid JWT audience: %s", e)
|
logger.warning("Invalid JWT audience: %s", e)
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||||
except jwt.InvalidTokenError as e:
|
except pyJwt.InvalidTokenError:
|
||||||
logger.warning("Invalid JWT token: %s", e)
|
# Invalid JWT token - defer to next authentication backend
|
||||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
return None
|
||||||
|
|
||||||
user_id = payload.get("user_id")
|
user_id = payload.get("user_id")
|
||||||
client_id = payload.get("client_id")
|
client_id = payload.get("client_id")
|
||||||
@@ -107,3 +113,55 @@ class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
|||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return authentication scheme for WWW-Authenticate header."""
|
"""Return authentication scheme for WWW-Authenticate header."""
|
||||||
return "Bearer"
|
return "Bearer"
|
||||||
|
|
||||||
|
|
||||||
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|
||||||
|
def get_or_create_user(self, access_token, id_token, payload):
|
||||||
|
"""Get or create user from OIDC token claims.
|
||||||
|
|
||||||
|
Despite the LaSuiteBackend's method name suggesting "get_or_create",
|
||||||
|
its implementation only performs a GET operation.
|
||||||
|
Create new user from the sub claim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
access_token: The access token string
|
||||||
|
id_token: The ID token string (unused)
|
||||||
|
payload: Token payload dict (unused)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User instance
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
SuspiciousOperation: If user info validation fails
|
||||||
|
"""
|
||||||
|
|
||||||
|
sub = payload.get("sub")
|
||||||
|
|
||||||
|
if sub is None:
|
||||||
|
message = "User info contained no recognizable user identification"
|
||||||
|
logger.debug(message)
|
||||||
|
raise SuspiciousOperation(message)
|
||||||
|
|
||||||
|
user = self.get_user(access_token, id_token, payload)
|
||||||
|
|
||||||
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
|
user = self.create_user(sub)
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
def create_user(self, sub):
|
||||||
|
"""Create new user from subject claim.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
sub: Subject identifier from token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Newly created User instance
|
||||||
|
"""
|
||||||
|
user = self.UserModel(sub=sub)
|
||||||
|
user.set_unusable_password()
|
||||||
|
user.save()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.core.validators import validate_email
|
from django.core.validators import validate_email
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
|
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework import decorators, mixins, viewsets
|
from rest_framework import decorators, mixins, viewsets
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
@@ -149,7 +150,10 @@ class RoomViewSet(
|
|||||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
authentication_classes = [authentication.ApplicationJWTAuthentication]
|
authentication_classes = [
|
||||||
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
ResourceServerAuthentication,
|
||||||
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ from django.conf import settings
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
|
import responses
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core.factories import (
|
from core.factories import (
|
||||||
RoomFactory,
|
RoomFactory,
|
||||||
UserFactory,
|
UserFactory,
|
||||||
)
|
)
|
||||||
from core.models import ApplicationScope, RoleChoices, Room
|
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -90,13 +91,29 @@ def test_api_rooms_list_with_expired_token(settings):
|
|||||||
assert "expired" in str(response.data).lower()
|
assert "expired" in str(response.data).lower()
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_with_invalid_token():
|
@responses.activate
|
||||||
"""Listing rooms with invalid token should return 401."""
|
def test_api_rooms_list_with_invalid_token(settings):
|
||||||
|
"""Listing rooms with invalid token should return 400."""
|
||||||
|
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"active": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||||
response = client.get("/external-api/v1.0/rooms/")
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
assert response.status_code == 401
|
# Return 400 instead of 401 because ResourceServerAuthentication raises
|
||||||
|
# SuspiciousOperation when the introspected user is not active
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_list_missing_scope(settings):
|
def test_api_rooms_list_missing_scope(settings):
|
||||||
@@ -332,3 +349,219 @@ def test_api_rooms_token_missing_client_id(settings):
|
|||||||
|
|
||||||
assert response.status_code == 401
|
assert response.status_code == 401
|
||||||
assert "Invalid token claims." in str(response.data)
|
assert "Invalid token claims." in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_resource_server_creates_user_on_first_authentication(settings):
|
||||||
|
"""New user should be created during first authentication.
|
||||||
|
|
||||||
|
Verifies that the ResourceServerBackend.get_or_create_user() creates a user
|
||||||
|
in the database when authenticating with a token from an unknown subject (sub).
|
||||||
|
This tests the user creation workflow during the OIDC introspection process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
User.DoesNotExist,
|
||||||
|
match="User matching query does not exist.",
|
||||||
|
):
|
||||||
|
User.objects.get(sub="very-specific-sub")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.OIDC_RS_BACKEND_CLASS
|
||||||
|
== "core.external_api.authentication.ResourceServerBackend"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet rooms:list",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 0
|
||||||
|
|
||||||
|
db_user = User.objects.get(sub="very-specific-sub")
|
||||||
|
assert db_user is not None
|
||||||
|
assert db_user.email is None
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_resource_server_skips_user_creation_when_auto_creation_disabled(settings):
|
||||||
|
"""Verify that ResourceServerBackend respects the user auto-creation setting.
|
||||||
|
|
||||||
|
This ensures that the OIDC introspection process respects the configuration flag
|
||||||
|
that controls whether new users should be automatically provisioned during
|
||||||
|
authentication, preventing unwanted user proliferation when auto-creation is
|
||||||
|
explicitly disabled.
|
||||||
|
"""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
with pytest.raises(
|
||||||
|
User.DoesNotExist,
|
||||||
|
match="User matching query does not exist.",
|
||||||
|
):
|
||||||
|
User.objects.get(sub="very-specific-sub")
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.OIDC_RS_BACKEND_CLASS
|
||||||
|
== "core.external_api.authentication.ResourceServerBackend"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet rooms:list",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_resource_server_authentication_successful(settings):
|
||||||
|
"""Authenticated requests should be processed and user-specific data is returned.
|
||||||
|
|
||||||
|
Verifies that once a user is authenticated via OIDC token introspection,
|
||||||
|
the API correctly identifies the user and returns only data accessible to that user
|
||||||
|
(e.g., rooms with appropriate access levels).
|
||||||
|
"""
|
||||||
|
|
||||||
|
user = UserFactory(sub="very-specific-sub")
|
||||||
|
|
||||||
|
other_user = UserFactory()
|
||||||
|
|
||||||
|
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
room_user_accesses = RoomFactory(
|
||||||
|
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||||
|
)
|
||||||
|
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.OIDC_RS_BACKEND_CLASS
|
||||||
|
== "core.external_api.authentication.ResourceServerBackend"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet rooms:list",
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
results = response.json()["results"]
|
||||||
|
assert len(results) == 1
|
||||||
|
expected_ids = {
|
||||||
|
str(room_user_accesses.id),
|
||||||
|
}
|
||||||
|
results_id = {result["id"] for result in results}
|
||||||
|
assert expected_ids == results_id
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||||
|
"""Requests should be denied when the token lacks required scopes.
|
||||||
|
|
||||||
|
Verifies that the ResourceServerBackend validates token scopes during introspection
|
||||||
|
and returns 403 Forbidden when the token is missing required scopes for the endpoint.
|
||||||
|
"""
|
||||||
|
|
||||||
|
assert (
|
||||||
|
settings.OIDC_RS_BACKEND_CLASS
|
||||||
|
== "core.external_api.authentication.ResourceServerBackend"
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||||
|
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||||
|
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
settings.OIDC_VERIFY_SSL = False
|
||||||
|
settings.OIDC_TIMEOUT = 5
|
||||||
|
settings.OIDC_PROXY = None
|
||||||
|
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||||
|
"sub": "very-specific-sub",
|
||||||
|
"client_id": "some_service_provider",
|
||||||
|
"scope": "openid lasuite_meet", # missing rooms:list scope
|
||||||
|
"active": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ For the full list of settings and their values, see
|
|||||||
https://docs.djangoproject.com/en/3.1/ref/settings/
|
https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
@@ -404,6 +406,10 @@ class Base(Configuration):
|
|||||||
default=False,
|
default=False,
|
||||||
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
environ_name="OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION",
|
||||||
)
|
)
|
||||||
|
OIDC_TIMEOUT = values.IntegerValue(
|
||||||
|
5, environ_name="OIDC_TIMEOUT", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
|
||||||
OIDC_RP_SIGN_ALGO = values.Value(
|
OIDC_RP_SIGN_ALGO = values.Value(
|
||||||
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
"RS256", environ_name="OIDC_RP_SIGN_ALGO", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -427,12 +433,16 @@ class Base(Configuration):
|
|||||||
OIDC_OP_USER_ENDPOINT = values.Value(
|
OIDC_OP_USER_ENDPOINT = values.Value(
|
||||||
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
None, environ_name="OIDC_OP_USER_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
|
||||||
|
None, environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
|
||||||
|
)
|
||||||
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
OIDC_OP_USER_ENDPOINT_FORMAT = values.Value(
|
||||||
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
"AUTO", environ_name="OIDC_OP_USER_ENDPOINT_FORMAT", environ_prefix=None
|
||||||
)
|
)
|
||||||
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
OIDC_OP_LOGOUT_ENDPOINT = values.Value(
|
||||||
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
None, environ_name="OIDC_OP_LOGOUT_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
OIDC_OP_URL = values.Value(None, environ_name="OIDC_OP_URL", environ_prefix=None)
|
||||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
OIDC_AUTH_REQUEST_EXTRA_PARAMS = values.DictValue(
|
||||||
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
{}, environ_name="OIDC_AUTH_REQUEST_EXTRA_PARAMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -493,6 +503,42 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# OIDC Resource Server Backend
|
||||||
|
OIDC_RS_BACKEND_CLASS = "core.external_api.authentication.ResourceServerBackend"
|
||||||
|
OIDC_RS_CLIENT_ID = values.Value(
|
||||||
|
"meet", environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_CLIENT_SECRET = SecretFileValue(
|
||||||
|
None,
|
||||||
|
environ_name="OIDC_RS_CLIENT_SECRET",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_AUDIENCE_CLAIM = values.Value(
|
||||||
|
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
|
||||||
|
default="A256GCM",
|
||||||
|
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_ALGO = values.Value(
|
||||||
|
default="RSA-OAEP", environ_name="OIDC_RS_ENCRYPTION_ALGO", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_SIGNING_ALGO = values.Value(
|
||||||
|
default="ES256", environ_name="OIDC_RS_SIGNING_ALGO", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_SCOPES = values.ListValue(
|
||||||
|
default=["lasuite_meet"],
|
||||||
|
environ_name="OIDC_RS_SCOPES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
OIDC_RS_PRIVATE_KEY_STR = SecretFileValue(
|
||||||
|
environ_name="OIDC_RS_PRIVATE_KEY_STR", environ_prefix=None
|
||||||
|
)
|
||||||
|
OIDC_RS_ENCRYPTION_KEY_TYPE = values.Value(
|
||||||
|
default="RSA", environ_name="OIDC_RS_ENCRYPTION_KEY_TYPE", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Video conference configuration
|
# Video conference configuration
|
||||||
LIVEKIT_CONFIGURATION = {
|
LIVEKIT_CONFIGURATION = {
|
||||||
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from drf_spectacular.views import (
|
|||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("", include("core.urls")),
|
path("", include("core.urls")),
|
||||||
|
path("", include("lasuite.oidc_resource_server.urls")),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ dependencies = [
|
|||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.0.0",
|
"django-countries==8.0.0",
|
||||||
"django-lasuite[all]==0.0.17",
|
"django-lasuite[all]==0.0.19",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user