Compare commits

...

5 Commits

Author SHA1 Message Date
lebaudantoine dd943068c2 🚨(frontend) lint backend sources 2025-06-01 22:37:04 +02:00
lebaudantoine 6605eae6d2 🚨(frontend) fix image build 2025-06-01 22:33:20 +02:00
Quentin BEY ae0e8ade7b 💩(user-tokens) add back & front for Token auth
This provides:
 - a frontend to allow user to create/delete User Token
 - the authentication process to allow any API to be called when
   authenticating with a User Token.
2025-05-20 17:11:06 +02:00
Quentin BEY eeb8bd6142 💩(resource-server) open all APIs to RS
This provides a base configuration to allow to access all
API via OIDC resource server authentication.
2025-05-20 16:21:29 +02:00
Quentin BEY 558a331b71 🔒️(oidc) disable OIDC authentication on API
Our authentication flow uses the Django authentication which creates a
session for the User. Then the session is used to make API calls,
therefore we don't need to accept OIDC tokens directly on the API.

Accepting the OIDC token on the API can allow to bypass the "resource
server mode" which allows to restrict provided information according to
the Service Provider which makes the request.
2025-05-20 14:01:48 +02:00
16 changed files with 458 additions and 2 deletions
+8
View File
@@ -4,9 +4,11 @@ from django.conf import settings
from django.urls import include, path
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
from lasuite.oidc_resource_server.urls import urlpatterns as resource_server_urls
from rest_framework.routers import DefaultRouter
from core.api import get_frontend_configuration, viewsets
from core.user_token import viewsets as user_token_viewsets
# - Main endpoints
router = DefaultRouter()
@@ -16,6 +18,11 @@ router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
router.register(
"user-tokens",
user_token_viewsets.UserTokenViewset,
basename="user_tokens",
)
urlpatterns = [
path(
@@ -24,6 +31,7 @@ urlpatterns = [
[
*router.urls,
*oidc_urls,
*resource_server_urls,
path("config/", get_frontend_configuration, name="config"),
]
),
@@ -0,0 +1,29 @@
"""Wip."""
from knox.models import get_token_model
from rest_framework import serializers
class TokenReadSerializer(serializers.ModelSerializer):
"""Serialize token for list purpose."""
class Meta:
model = get_token_model()
fields = ["digest", "created", "expiry"]
read_only_fields = ["digest", "created", "expiry"]
class TokenCreateSerializer(serializers.ModelSerializer):
"""Serialize token for creation purpose."""
class Meta:
model = get_token_model()
fields = ["user", "digest", "token_key", "created", "expiry"]
read_only_fields = ["digest", "token_key", "created", "expiry"]
extra_kwargs = {"user": {"write_only": True}}
def create(self, validated_data):
"""The default knox token create manager returns a tuple."""
instance, token = super().create(validated_data)
instance.token_key = token # warning do not save this
return instance
+50
View File
@@ -0,0 +1,50 @@
"""API endpoints for user token management"""
from knox.models import get_token_model
from rest_framework import mixins, permissions, viewsets
from rest_framework.authentication import SessionAuthentication
from . import serializers
class UserTokenViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to document.
This view access is restricted to the session ie from frontend.
GET /api/v1.0/user-token/
Return list of existing tokens.
POST /api/v1.0/user-token/
Return newly created token.
DELETE /api/v1.0/user-token/<token_id>/
Delete targeted token.
"""
authentication_classes = [SessionAuthentication]
pagination_class = None
permission_classes = [permissions.IsAuthenticated]
queryset = get_token_model().objects.all()
serializer_class = serializers.TokenReadSerializer
def get_queryset(self):
"""Return the queryset restricted to the logged-in user."""
queryset = super().get_queryset()
queryset = queryset.filter(user_id=self.request.user.pk)
return queryset
def get_serializer_class(self):
if self.action == "create":
return serializers.TokenCreateSerializer
return super().get_serializer_class()
def create(self, request, *args, **kwargs):
"""Enforce request data to use current user."""
request.data["user"] = self.request.user.pk
return super().create(request, *args, **kwargs)
+67 -1
View File
@@ -10,6 +10,7 @@ For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import datetime
import json
import os
from socket import gethostbyname, gethostname
@@ -224,6 +225,7 @@ class Base(Configuration):
# Third party apps
"corsheaders",
"dockerflow.django",
"knox",
"rest_framework",
"parler",
"easy_thumbnails",
@@ -257,8 +259,9 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"knox.auth.TokenAuthentication",
"rest_framework.authentication.SessionAuthentication",
"lasuite.oidc_resource_server.authentication.ResourceServerAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
@@ -455,6 +458,69 @@ class Base(Configuration):
environ_prefix=None,
)
# OIDC - Meet as a resource server
OIDC_OP_URL = values.Value(
default=None, environ_name="OIDC_OP_URL", environ_prefix=None
)
OIDC_OP_INTROSPECTION_ENDPOINT = values.Value(
environ_name="OIDC_OP_INTROSPECTION_ENDPOINT", environ_prefix=None
)
OIDC_TIMEOUT = values.IntegerValue(
default=3, environ_name="OIDC_TIMEOUT", environ_prefix=None
)
OIDC_PROXY = values.Value(None, environ_name="OIDC_PROXY", environ_prefix=None)
OIDC_RS_BACKEND_CLASS = "lasuite.oidc_resource_server.backend.ResourceServerBackend"
OIDC_RS_AUDIENCE_CLAIM = values.Value( # The claim used to identify the audience
default="client_id", environ_name="OIDC_RS_AUDIENCE_CLAIM", environ_prefix=None
)
OIDC_RS_PRIVATE_KEY_STR = values.Value(
default=None,
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,
)
OIDC_RS_ENCRYPTION_ALGO = values.Value(
default="RSA-OAEP",
environ_name="OIDC_RS_ENCRYPTION_ALGO",
environ_prefix=None,
)
OIDC_RS_ENCRYPTION_ENCODING = values.Value(
default="A256GCM",
environ_name="OIDC_RS_ENCRYPTION_ENCODING",
environ_prefix=None,
)
OIDC_RS_CLIENT_ID = values.Value(
None, environ_name="OIDC_RS_CLIENT_ID", environ_prefix=None
)
OIDC_RS_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RS_CLIENT_SECRET",
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(
[], environ_name="OIDC_RS_SCOPES", environ_prefix=None
)
# User token (knox)
REST_KNOX = {
"SECURE_HASH_ALGORITHM": "hashlib.sha512",
"AUTH_TOKEN_CHARACTER_LENGTH": 64,
"TOKEN_TTL": datetime.timedelta(hours=24 * 7),
"TOKEN_LIMIT_PER_USER": None,
"AUTO_REFRESH": False,
"AUTO_REFRESH_MAX_TTL": None,
"MIN_REFRESH_INTERVAL": 60,
"AUTH_HEADER_PREFIX": "Token",
}
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": values.Value(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
+1
View File
@@ -36,6 +36,7 @@ dependencies = [
"django-parler==2.3",
"redis==5.2.1",
"django-redis==5.4.0",
"django-rest-knox==5.0.2",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.1.9",
+9
View File
@@ -15,6 +15,15 @@ export const fetchApi = async <T = Record<string, unknown>>(
...options?.headers,
},
})
// Handle empty responses (like for DELETE requests)
if (
response.status === 204 ||
response.headers.get('content-length') === '0'
) {
return {} as T
}
const result = await response.json()
if (!response.ok) {
throw new ApiError(response.status, result)
@@ -3,6 +3,7 @@ import { useLanguageLabels } from '@/i18n/useLanguageLabels'
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
import { useUser } from '@/features/auth'
import { ProConnectButton } from '@/components/ProConnectButton'
import { Button } from '@/primitives'
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
@@ -12,6 +13,12 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
const { languagesList, currentLanguage } = useLanguageLabels()
return (
<Dialog title={t('dialog.heading')} {...props}>
<Button
onClick={() => (window.location.href = '/user-tokens')}
aria-label={t('API Tokens', 'API Tokens')}
>
{t('API Tokens', 'API Tokens')}
</Button>
<H lvl={2}>{t('account.heading')}</H>
{isLoggedIn ? (
<>
@@ -0,0 +1,3 @@
export { useCreateUserToken, createUserToken } from './useCreateUserToken'
export { useDeleteUserToken, deleteUserToken } from './useDeleteUserToken'
export { useListUserTokens, listUserTokens } from './useListUserTokens'
@@ -0,0 +1,21 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { NewUserToken } from '../types'
export const createUserToken = async (): Promise<NewUserToken> => {
return fetchApi('user-tokens/', {
method: 'POST',
body: JSON.stringify({}),
})
}
export function useCreateUserToken(
options?: UseMutationOptions<NewUserToken, ApiError>
) {
return useMutation<NewUserToken, ApiError>({
mutationFn: createUserToken,
onSuccess: options?.onSuccess,
})
}
@@ -0,0 +1,24 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
export type DeleteUserTokenParams = {
digest: string
}
export const deleteUserToken = async ({
digest,
}: DeleteUserTokenParams): Promise<void> => {
return fetchApi(`user-tokens/${digest}/`, {
method: 'DELETE',
})
}
export function useDeleteUserToken(
options?: UseMutationOptions<void, ApiError, DeleteUserTokenParams>
) {
return useMutation<void, ApiError, DeleteUserTokenParams>({
mutationFn: deleteUserToken,
onSuccess: options?.onSuccess,
})
}
@@ -0,0 +1,18 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import { ApiError } from '@/api/ApiError'
import { UserToken } from '../types'
export const listUserTokens = async (): Promise<UserToken[]> => {
return fetchApi('user-tokens/')
}
export function useListUserTokens(
options?: UseQueryOptions<UserToken[], ApiError>
) {
return useQuery<UserToken[], ApiError>({
queryKey: ['userTokens'],
queryFn: listUserTokens,
...options,
})
}
@@ -0,0 +1 @@
export { UserTokens } from './routes/UserTokens'
@@ -0,0 +1,203 @@
import { useCallback, useEffect, useState } from 'react'
import { createUserToken, deleteUserToken, listUserTokens } from '../api/index'
import { NewUserToken, UserToken } from '../types'
import { H, Button } from '@/primitives'
// Add id to UserToken type for table compatibility
interface UserTokenWithId extends UserToken {
id: string
}
export const UserTokens = () => {
const [tokens, setTokens] = useState<UserTokenWithId[]>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(null)
const [newToken, setNewToken] = useState<NewUserToken | null>(null)
const [modal, setModal] = useState<{ open: boolean; message: string }>({
open: false,
message: '',
})
const fetchTokens = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
const fetchedTokens = await listUserTokens()
setTokens(fetchedTokens.map((token) => ({ ...token, id: token.digest })))
} catch (err) {
setError(
'Failed to fetch tokens. Please ensure you are logged in and have permissions.'
)
setModal({ open: true, message: 'Failed to fetch tokens' })
console.error(err)
}
setIsLoading(false)
}, [])
useEffect(() => {
void fetchTokens()
}, [fetchTokens])
const handleCreateToken = async () => {
setIsLoading(true)
setError(null)
setNewToken(null)
try {
const generatedToken = await createUserToken()
setNewToken(generatedToken)
setModal({
open: true,
message:
'Token created successfully! Store the token key safely, it will not be shown again.',
})
void fetchTokens()
} catch (err) {
setError('Failed to create token.')
setModal({ open: true, message: 'Failed to create token' })
console.error(err)
}
setIsLoading(false)
}
const handleDeleteToken = async (digest: string) => {
if (!window.confirm('Are you sure you want to delete this token?')) return
setIsLoading(true)
setError(null)
try {
await deleteUserToken({ digest })
setModal({ open: true, message: 'Token deleted successfully!' })
setNewToken(null)
await fetchTokens()
} catch (err) {
setError('Failed to delete token.')
setModal({ open: true, message: 'Failed to delete token' })
console.error(err)
}
setIsLoading(false)
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
padding: '16px 24px 24px 24px',
boxSizing: 'border-box',
background: '#fff',
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.04)',
}}
>
<div
style={{
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<H lvl={2} style={{ marginBottom: 16 }}>
User token management
</H>
<Button
onPress={handleCreateToken}
isDisabled={isLoading}
variant="primary"
>
{isLoading ? 'Generating...' : 'Generate New Token'}
</Button>
</div>
{newToken && (
<div
style={{
background: '#e6f4ea',
padding: 16,
borderRadius: 10,
marginBottom: 16,
marginTop: 16,
display: 'flex',
flexDirection: 'column',
}}
>
<span style={{ marginLeft: 16 }}>
<strong>New Token:</strong> <code>{newToken.token_key}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Digest:</strong> <code>{newToken.digest}</code>
</span>
<span style={{ marginLeft: 16 }}>
<strong>Expires:</strong>{' '}
<code>{new Date(newToken.expiry).toLocaleString()}</code>
</span>
</div>
)}
{isLoading && !tokens.length && (
<div style={{ marginBottom: 8 }}>Loading...</div>
)}
{error && <div style={{ color: 'red', marginBottom: 8 }}>{error}</div>}
<table
style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16 }}
>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: 8 }}>Name</th>
<th style={{ textAlign: 'left', padding: 8 }}>Updated at</th>
<th style={{ textAlign: 'left', padding: 8 }}>Expires at</th>
<th style={{ textAlign: 'left', padding: 8 }}></th>
</tr>
</thead>
<tbody>
{tokens.length === 0 && !isLoading ? (
<tr>
<td colSpan={4} style={{ textAlign: 'center', padding: 16 }}>
No tokens found.
</td>
</tr>
) : (
tokens.map((row) => (
<tr key={row.id}>
<td style={{ padding: 8 }}>{row.digest}</td>
<td style={{ padding: 8 }}>
{new Date(row.created).toLocaleString()}
</td>
<td style={{ padding: 8 }}>
{new Date(row.expiry).toLocaleString()}
</td>
<td style={{ padding: 8 }}>
<Button
onPress={() => handleDeleteToken(row.digest)}
variant="danger"
size="sm"
>
Delete
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
{modal.open && (
<div
style={{
marginTop: 16,
padding: 16,
background: '#eee',
borderRadius: 8,
}}
>
<span>{modal.message}</span>
<Button
onPress={() => setModal({ open: false, message: '' })}
variant="secondary"
size="sm"
style={{ marginLeft: 16 }}
>
Close
</Button>
</div>
)}
</div>
)
}
@@ -0,0 +1,9 @@
export interface UserToken {
digest: string
created: string // Assuming ISO date string
expiry: string // Assuming ISO date string
}
export interface NewUserToken extends UserToken {
token_key: string
}
+8 -1
View File
@@ -10,6 +10,7 @@ import { TermsOfServiceRoute } from '@/features/legalsTerms/TermsOfService'
import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
import { RecordingDownloadRoute } from '@/features/recording'
import { UserTokens } from '@/features/user-tokens/routes/UserTokens'
export const routes: Record<
| 'home'
@@ -20,7 +21,8 @@ export const routes: Record<
| 'termsOfService'
| 'sdkCreatePopup'
| 'sdkCreateButton'
| 'recordingDownload',
| 'recordingDownload'
| 'userTokens',
{
name: RouteName
path: RegExp | string
@@ -78,6 +80,11 @@ export const routes: Record<
to: (recordingId: string) => `/recording/${recordingId.trim()}`,
Component: RecordingDownloadRoute,
},
userTokens: {
name: 'userTokens',
path: '/user-tokens',
Component: UserTokens,
},
}
export type RouteName = keyof typeof routes