This commit is contained in:
Thomas Ramé
2026-03-25 19:17:36 +01:00
parent ca3502ee4d
commit e6403be62e
13 changed files with 82 additions and 90 deletions
+26
View File
@@ -845,6 +845,32 @@
"offline_access",
"microprofile-jwt"
]
},
{
"clientId": "encryption",
"name": "Encryption",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"publicClient": true,
"protocol": "openid-connect",
"redirectUris": [
"http://localhost:7202/auth/callback"
],
"webOrigins": [
"http://localhost:7202"
],
"frontchannelLogout": true,
"attributes": {},
"defaultClientScopes": [
"web-origins",
"profile",
"roles",
"email"
],
"optionalClientScopes": []
}
],
"clientScopes": [
+3 -2
View File
@@ -29,11 +29,12 @@ class UserSerializer(serializers.ModelSerializer):
full_name = serializers.SerializerMethodField(read_only=True)
short_name = serializers.SerializerMethodField(read_only=True)
suite_user_id = serializers.CharField(source='sub', read_only=True)
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "language"]
read_only_fields = ["id", "email", "full_name", "short_name"]
fields = ["id", "email", "full_name", "short_name", "language", "suite_user_id"]
read_only_fields = ["id", "email", "full_name", "short_name", "suite_user_id"]
def get_full_name(self, instance):
"""Return the full name of the user."""
+5 -32
View File
@@ -252,35 +252,6 @@ class UserViewSet(
self.serializer_class(request.user, context=context).data
)
@drf.decorators.action(
detail=False,
methods=["get"],
url_name="get-access-token",
url_path="get-access-token",
permission_classes=[permissions.IsAuthenticated],
)
def get_access_token(self, request):
"""
WARNING: Temporary endpoint — to be removed.
TODO: Find a better way to propagate the OIDC access token to the frontend
so it can be passed to the VaultClient encryption library.
We should also consider having ProConnect flow directly on interface.encryption,
so it can obtain its own valid token when opened in a new tab (not as an iframe).
Returns the OIDC access token stored in the Django session.
Requires OIDC_STORE_ACCESS_TOKEN=True in settings.
"""
access_token = request.session.get("oidc_access_token")
if not access_token:
return drf.response.Response(
{"detail": "No access token available in session."},
status=404,
)
return drf.response.Response({"access_token": access_token})
class ResourceAccessViewsetMixin:
"""Mixin with methods common to all access viewsets."""
@@ -2127,7 +2098,8 @@ class DocumentViewSet(
})
# Validate that we have encrypted symmetric keys for all users with access
users_with_access = {str(access.user_id) for access in document_accesses}
# Keys in encryptedSymmetricKeyPerUser are keyed by the user's OIDC `sub`
users_with_access = {str(access.user.sub) for access in document_accesses}
# Check that encryptedSymmetricKeyPerUser contains all required users
provided_user_ids = set(encryptedSymmetricKeyPerUser.keys())
@@ -2178,10 +2150,11 @@ class DocumentViewSet(
transaction.on_commit(_cleanup_old_attachments)
# Store the encrypted symmetric keys in DocumentAccess for each user
for user_id, encrypted_key in encryptedSymmetricKeyPerUser.items():
# Keys are keyed by the user's OIDC `sub`, so look up by user__sub
for sub, encrypted_key in encryptedSymmetricKeyPerUser.items():
try:
# Find the DocumentAccess record for this user and document
access = models.DocumentAccess.objects.get(document=document, user_id=user_id)
access = models.DocumentAccess.objects.get(document=document, user__sub=sub)
access.encrypted_document_symmetric_key_for_user = encrypted_key
access.save()
except models.DocumentAccess.DoesNotExist:
+4 -4
View File
@@ -746,7 +746,7 @@ class Document(MP_Node, BaseModel):
return list(
DocumentAccess.objects
.filter(document=self, user__isnull=False)
.values_list('user_id', flat=True)
.values_list('user__sub', flat=True)
.distinct()
)
@@ -760,12 +760,12 @@ class Document(MP_Node, BaseModel):
accesses = (
DocumentAccess.objects
.filter(document=self, user__isnull=False, encryption_public_key_fingerprint__isnull=False)
.values_list('user_id', 'encryption_public_key_fingerprint')
.values_list('user__sub', 'encryption_public_key_fingerprint')
)
return {
str(user_id): fingerprint
for user_id, fingerprint in accesses
str(sub): fingerprint
for sub, fingerprint in accesses
if fingerprint
}
@@ -8,6 +8,7 @@
*/
export interface User {
id: string;
suite_user_id: string | null;
email: string;
full_name: string;
short_name: string;
@@ -63,10 +63,10 @@ export const UserEncryptionProvider = ({
null;
let encryptionError: EncryptionError = null;
if (isReady && user?.id) {
if (isReady && user?.suite_user_id) {
if (hasKeys) {
encryptionSettings = {
userId: user.id,
userId: user.suite_user_id,
userPrivateKey: null, // Keys are in the vault — use VaultClient for crypto
userPublicKey: null,
};
@@ -22,6 +22,7 @@ import {
import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { useAuth } from '@/features/auth';
// Environment configuration
@@ -98,6 +99,7 @@ export function VaultClientProvider({
}) {
const { user, authenticated } = useAuth();
const { i18n } = useTranslation();
const { theme: cunninghamTheme } = useCunninghamTheme();
const clientRef = useRef<VaultClient | null>(null);
const [clientInitialized, setClientInitialized] = useState(false);
const [isReady, setIsReady] = useState(false);
@@ -123,7 +125,7 @@ export function VaultClientProvider({
const client = new window.EncryptionClient.VaultClient({
vaultUrl: VAULT_URL,
interfaceUrl: INTERFACE_URL,
theme: 'light',
theme: cunninghamTheme,
lang: i18n.language,
});
@@ -189,38 +191,24 @@ export function VaultClientProvider({
useEffect(() => {
const client = clientRef.current;
if (!client || !clientInitialized || !authenticated || !user?.id) {
if (
!client ||
!clientInitialized ||
!authenticated ||
!user?.id ||
!user?.suite_user_id
) {
return;
}
let cancelled = false;
async function setupAuth() {
// Fetch the OIDC access token from the Django session.
// WARNING: This uses a temporary endpoint (get-access-token) — see the
// backend TODO for finding a better way to propagate the access token.
let token = user!.id; // fallback: userId (vault dev mode without JWKS)
if (cancelled || !client) return;
try {
const response = await fetch(
`${process.env.NEXT_PUBLIC_API_ORIGIN || ''}/api/v1.0/users/get-access-token/`,
{ credentials: 'include' },
);
if (response.ok) {
const data = await response.json();
if (data.access_token) {
token = data.access_token;
}
}
} catch {
// Endpoint not available — fall back to userId for dev mode
}
if (cancelled) return;
client.setAuthContext({ token, userId: user!.id });
client.setAuthContext({
suiteUserId: user!.suite_user_id!,
});
setIsLoading(true);
@@ -246,7 +234,7 @@ export function VaultClientProvider({
return () => {
cancelled = true;
};
}, [clientInitialized, authenticated, user?.id]);
}, [clientInitialized, authenticated, user?.id, user?.suite_user_id]);
const refreshKeyState = useCallback(async () => {
const client = clientRef.current;
@@ -5,8 +5,8 @@ declare global {
interface VaultClient {
init(): Promise<void>;
destroy(): void;
setTheme(theme: 'light' | 'dark' | 'system'): void;
setAuthContext(context: { token: string; userId: string }): void;
setTheme(theme: string): void;
setAuthContext(context: { suiteUserId: string }): void;
hasKeys(): Promise<{ hasKeys: boolean }>;
getPublicKey(): Promise<{ publicKey: ArrayBuffer }>;
encrypt(data: ArrayBuffer): Promise<{ encryptedData: ArrayBuffer }>;
@@ -36,7 +36,7 @@ declare global {
vaultUrl: string;
interfaceUrl: string;
timeout?: number;
theme?: 'light' | 'dark' | 'system';
theme?: string;
lang?: string;
}) => VaultClient;
};
@@ -161,8 +161,8 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
if (!accesses || !vaultClient) return;
const userIds = accesses
.filter((a) => a.user)
.map((a) => a.user.id);
.filter((a) => a.user?.suite_user_id)
.map((a) => a.user.suite_user_id!);
if (userIds.length === 0) return;
@@ -177,7 +177,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
}
return accesses.filter(
(access) => access.user && !publicKeysMap[access.user.id],
(access) => access.user?.suite_user_id && !publicKeysMap[access.user.suite_user_id],
);
}, [accesses, publicKeysMap]);
@@ -231,7 +231,7 @@ export const ModalEncryptDoc = ({ doc, onClose }: ModalEncryptDocProps) => {
}
// Get the current user's encrypted key for attachment encryption
const currentUserEncryptedKey = encryptedKeys[user.id];
const currentUserEncryptedKey = user.suite_user_id ? encryptedKeys[user.suite_user_id] : undefined;
// Encrypt existing attachments using the same symmetric key via vault
let attachmentKeyMapping: Record<string, string> = {};
@@ -95,8 +95,8 @@ export const DocShareAddMemberList = ({
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
const memberUserIds = selectedUsers
.filter((user) => user.id !== user.email)
.map((user) => user.id);
.filter((user) => user.id !== user.email && user.suite_user_id)
.map((user) => user.suite_user_id!);
if (memberUserIds.length > 0) {
const { publicKeys } = await vaultClient.fetchPublicKeys(memberUserIds);
@@ -141,7 +141,7 @@ export const DocShareAddMemberList = ({
let memberEncryptedSymmetricKey: string | null = null;
if (doc.is_encrypted && documentEncryptionSettings && vaultClient) {
const userPublicKey = publicKeysMap[user.id];
const userPublicKey = user.suite_user_id ? publicKeysMap[user.suite_user_id] : undefined;
if (userPublicKey) {
const { rewrappedKey } = await vaultClient.rewrapKey(
@@ -31,6 +31,7 @@ export const DocShareInvitationItem = ({
const { spacingsTokens } = useCunninghamTheme();
const invitedUser: User = {
id: invitation.email,
suite_user_id: null,
full_name: invitation.email,
email: invitation.email,
short_name: invitation.email,
@@ -143,10 +143,11 @@ export const QuickSearchGroupMember = ({
<QuickSearchGroup
group={membersData}
renderElement={(access) => {
const hasMismatch = keyMismatchUserIds?.has(access.user.id);
const uid = access.user.suite_user_id;
const hasMismatch = uid ? keyMismatchUserIds?.has(uid) : false;
const hasNoEncryptionKey =
doc.is_encrypted &&
!doc.accesses_fingerprints_per_user?.[access.user.id];
(!uid || !doc.accesses_fingerprints_per_user?.[uid]);
let suffix: string | undefined;
if (hasMismatch) {
@@ -163,12 +164,12 @@ export const QuickSearchGroupMember = ({
access={access}
suffix={suffix}
onSuffixClick={
hasMismatch
? () => setMismatchUserId(access.user.id)
hasMismatch && uid
? () => setMismatchUserId(uid)
: undefined
}
fingerprintKey={
doc.accesses_fingerprints_per_user?.[access.user.id]
uid ? doc.accesses_fingerprints_per_user?.[uid] : undefined
}
/>
);
@@ -105,7 +105,7 @@ export const DocShareModal = ({
const { mismatches: keyMismatches, acceptNewKey } = usePublicKeyRegistry(
undefined,
user?.id,
user?.suite_user_id ?? undefined,
);
const keyMismatchUserIds = useMemo(
() => new Set(keyMismatches.map((m) => m.userId)),
@@ -470,11 +470,11 @@ const QuickSearchInviteInputSection = ({
const handleSelect = useCallback(
(user: User) => {
if (isEncrypted && !doc.accesses_fingerprints_per_user?.[user.id]) {
if (isEncrypted && (!user.suite_user_id || !doc.accesses_fingerprints_per_user?.[user.suite_user_id])) {
setShowNoKeyModal(true);
return;
}
if (keyMismatchUserIds?.has(user.id)) {
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
setMismatchUser(user);
return;
}
@@ -488,6 +488,7 @@ const QuickSearchInviteInputSection = ({
const isEmail = isValidEmail(userQuery);
const newUser: User = {
id: userQuery,
suite_user_id: null,
full_name: '',
email: userQuery,
short_name: '',
@@ -516,10 +517,10 @@ const QuickSearchInviteInputSection = ({
const getUserSuffix = useCallback(
(user: User): string | undefined => {
if (keyMismatchUserIds?.has(user.id)) {
if (user.suite_user_id && keyMismatchUserIds?.has(user.suite_user_id)) {
return t('DIFFERENT PUBLIC KEY, PLEASE VERIFY');
}
if (isEncrypted && !doc.accesses_fingerprints_per_user?.[user.id]) {
if (isEncrypted && (!user.suite_user_id || !doc.accesses_fingerprints_per_user?.[user.suite_user_id])) {
return t(`(encryption not enabled)`);
}
return undefined;
@@ -539,7 +540,7 @@ const QuickSearchInviteInputSection = ({
<DocShareModalInviteUserRow
user={user}
suffix={getUserSuffix(user)}
fingerprintKey={doc.accesses_fingerprints_per_user?.[user.id]}
fingerprintKey={user.suite_user_id ? doc.accesses_fingerprints_per_user?.[user.suite_user_id] : undefined}
/>
)}
/>
@@ -596,7 +597,7 @@ const QuickSearchInviteInputSection = ({
{mismatchUser &&
(() => {
const mismatch = keyMismatches?.find(
(m) => m.userId === mismatchUser.id,
(m) => m.userId === mismatchUser.suite_user_id,
);
return (
<ModalKeyMismatch
@@ -604,7 +605,7 @@ const QuickSearchInviteInputSection = ({
onAcceptKey={
acceptNewKey
? () => {
void acceptNewKey(mismatchUser.id).then(() => {
void acceptNewKey(mismatchUser.suite_user_id!).then(() => {
onSelect(mismatchUser);
});
}