Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/meet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,10 @@ jobs:
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
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:
- name: Checkout repository
Expand Down
10 changes: 10 additions & 0 deletions compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ services:
- createwebhook
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
networks:
- resource-server
- default

celery-dev:
user: ${DOCKER_USER:-1000}
Expand Down Expand Up @@ -145,6 +148,9 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
networks:
- resource-server
- default

frontend:
user: "${DOCKER_USER:-1000}"
Expand Down Expand Up @@ -298,3 +304,7 @@ services:
watch:
- action: rebuild
path: ./src/summary

networks:
default:
resource-server:
23 changes: 23 additions & 0 deletions docker/resource-server/compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
version: '3'

# You can add any necessary service here that will join the same docker network
# sharing keycloak. Services added to the 'meet_resource-server' network will be
# able to communicate with keycloak and the backend on that network.
services:
# busybox service is only used for testing purposes. It provides curl to test
# connectivity to the backend and keycloak services. Replace this with your
# relevant application services that need to communicate with keycloak.
busybox:
image: alpine:latest
privileged: true
command: sh -c "apk add --no-cache curl && sleep infinity"
stdin_open: true
tty: true
networks:
- default
- meet_resource-server

networks:
default: {}
meet_resource-server:
external: true
5 changes: 5 additions & 0 deletions env.d/development/common.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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_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_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_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
Expand All @@ -45,6 +47,9 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}

OIDC_RS_CLIENT_ID=meet
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly

# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
Expand Down
76 changes: 67 additions & 9 deletions src/backend/core/external_api/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

from django.conf import settings
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

User = get_user_model()
Expand All @@ -25,9 +27,11 @@ def authenticate(self, request):
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":
# Defer to next authentication backend
return None

if len(auth_header) != 2:
Expand All @@ -45,36 +49,38 @@ def authenticate(self, request):
def authenticate_credentials(self, token):
"""Validate JWT token and return authenticated user.

If token is invalid, defer to next authentication backend.

Comment on lines +52 to +53
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Revisit behavior on generic InvalidTokenError and add logging

Right now any pyJwt.InvalidTokenError (e.g. bad signature, malformed token) silently returns None, deferring to the next backend, while only specific errors raise AuthenticationFailed. This can make debugging harder and slightly blur the distinction between “no credentials” and “invalid credentials”.

I suggest:

  • At least log these invalid tokens (debug or warning) before returning None.
  • Double‑check that, for endpoints where this backend is the expected mechanism (e.g. external app JWTs), deferring instead of raising doesn’t weaken your intended security/UX semantics.

Also applies to: 65-83

🤖 Prompt for AI Agents
In src/backend/core/external_api/authentication.py around lines 52-53 (and
similarly for the block covering lines 65-83), update the exception handling for
pyJwt.InvalidTokenError to log the incident before returning None: catch
InvalidTokenError, write a structured log entry (warning or debug) that includes
the exception message and minimal token/context info (avoid logging full
sensitive token), then return None to defer to the next backend; additionally,
add a TODO or comment noting to re-evaluate raising AuthenticationFailed for
endpoints that rely solely on this backend.

Args:
token: JWT token string

Returns:
Tuple of (user, payload)

Raises:
AuthenticationFailed: If token is invalid, expired, or user not found
AuthenticationFailed: If token is expired, or user not found
"""
# Decode and validate JWT
try:
payload = jwt.decode(
payload = pyJwt.decode(
token,
settings.APPLICATION_JWT_SECRET_KEY,
algorithms=[settings.APPLICATION_JWT_ALG],
issuer=settings.APPLICATION_JWT_ISSUER,
audience=settings.APPLICATION_JWT_AUDIENCE,
)
except jwt.ExpiredSignatureError as e:
except pyJwt.ExpiredSignatureError as e:
logger.warning("Token expired")
raise exceptions.AuthenticationFailed("Token expired.") from e
except jwt.InvalidIssuerError as e:
except pyJwt.InvalidIssuerError as e:
logger.warning("Invalid JWT issuer: %s", 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)
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
except pyJwt.InvalidTokenError:
# Invalid JWT token - defer to next authentication backend
return None

user_id = payload.get("user_id")
client_id = payload.get("client_id")
Expand Down Expand Up @@ -107,3 +113,55 @@ def authenticate_credentials(self, token):
def authenticate_header(self, request):
"""Return authentication scheme for WWW-Authenticate header."""
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
6 changes: 5 additions & 1 deletion src/backend/core/external_api/viewsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from django.core.validators import validate_email

import jwt
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework import decorators, mixins, viewsets
from rest_framework import (
exceptions as drf_exceptions,
Expand Down Expand Up @@ -149,7 +150,10 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
"""

authentication_classes = [authentication.ApplicationJWTAuthentication]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
ResourceServerAuthentication,
]
permission_classes = [
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
]
Expand Down
Loading
Loading