diff --git a/docker/auth/realm.json b/docker/auth/realm.json index 96677ac5..62ff86c0 100644 --- a/docker/auth/realm.json +++ b/docker/auth/realm.json @@ -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": [ diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 3a8a37d5..5b1724fe 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -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.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 8592b035..ada33a1f 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -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: diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f930e03e..31f824a4 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -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 } diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 93301576..65bcf7b8 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -8,6 +8,7 @@ */ export interface User { id: string; + suite_user_id: string | null; email: string; full_name: string; short_name: string; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx index fe192802..11607f14 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/UserEncryptionProvider.tsx @@ -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, }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx index 18e6e07b..43099c76 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/VaultClientProvider.tsx @@ -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(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; diff --git a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts index a1c6da52..7a6e5250 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts +++ b/src/frontend/apps/impress/src/features/docs/doc-collaboration/vault/global.d.ts @@ -5,8 +5,8 @@ declare global { interface VaultClient { init(): Promise; 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; }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx index cf8ca123..dfd4fa47 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-management/components/ModalEncryptDoc.tsx @@ -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 = {}; diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx index 9a6262f0..04f8d5ae 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareAddMemberList.tsx @@ -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( diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx index 37dbdee7..cddaf85c 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareInvitation.tsx @@ -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, diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx index 97370b54..9e6d0b90 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareMember.tsx @@ -143,10 +143,11 @@ export const QuickSearchGroupMember = ({ { - 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 } /> ); diff --git a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx index fe4983d5..6e354f57 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-share/components/DocShareModal.tsx @@ -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 = ({ )} /> @@ -596,7 +597,7 @@ const QuickSearchInviteInputSection = ({ {mismatchUser && (() => { const mismatch = keyMismatches?.find( - (m) => m.userId === mismatchUser.id, + (m) => m.userId === mismatchUser.suite_user_id, ); return ( { - void acceptNewKey(mismatchUser.id).then(() => { + void acceptNewKey(mismatchUser.suite_user_id!).then(() => { onSelect(mismatchUser); }); }