From 98895bc0389228c4f1e041fdd6711132dc38fd2c Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 19 Feb 2026 20:26:54 +0530 Subject: [PATCH] feat: add integration token fetching and decryption functionality - Introduced `fetchIntegrationTokens` API call to retrieve encrypted OAuth tokens for integrations. - Implemented decryption logic for integration tokens using a provided key. - Enhanced deep link handling to support fetching and storing integration tokens upon successful OAuth login. - Added utility functions for hex and base64 conversions to facilitate token decryption. - Updated `authApi.ts` and `desktopDeepLinkListener.ts` to integrate new functionality. --- src-tauri/src/runtime/qjs_skill_instance.rs | 2 +- src/services/api/authApi.ts | 21 +++ src/utils/desktopDeepLinkListener.ts | 162 +++++++++++++++++++- yarn.lock | 51 ++++++ 4 files changed, 234 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index faf0dfb6a..e99e471f0 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -535,7 +535,7 @@ async fn handle_message( } SkillMessage::LoadParams { params } => { let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); - if let Err(e) = handle_js_void_call(ctx, "onLoad", ¶ms_str).await { + if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", ¶ms_str).await { log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e); } } diff --git a/src/services/api/authApi.ts b/src/services/api/authApi.ts index d4141daa3..0e9d76e74 100644 --- a/src/services/api/authApi.ts +++ b/src/services/api/authApi.ts @@ -5,6 +5,13 @@ interface ConsumeLoginTokenResponse { data: { jwtToken: string }; } +interface IntegrationTokensResponse { + success: boolean; + data?: { + encrypted: string; + }; +} + /** * Consume a verified login token and return the JWT. * POST /telegram/login-tokens/:token/consume (no auth required) @@ -21,3 +28,17 @@ export async function consumeLoginToken(loginToken: string): Promise { } return response.data.jwtToken; } + +/** + * Fetch encrypted OAuth tokens for an integration using a client-provided key. + * POST /auth/integrations/:integrationId/tokens (auth required) + */ +export async function fetchIntegrationTokens( + integrationId: string, + key: string +): Promise { + return apiClient.post( + `/auth/integrations/${encodeURIComponent(integrationId)}/tokens`, + { key } + ); +} diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 7b43910b1..d56657c58 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -2,9 +2,116 @@ import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { skillManager } from '../lib/skills/manager'; -import { consumeLoginToken } from '../services/api/authApi'; +import { consumeLoginToken, fetchIntegrationTokens } from '../services/api/authApi'; import { store } from '../store'; import { setToken } from '../store/authSlice'; +import { setSkillState } from '../store/skillsSlice'; + +type IntegrationTokensPayload = { + accessToken: string; + refreshToken: string; + /** ISO timestamp string */ + expiresAt: string; +}; + +function getCurrentUserId(): string | null { + const state = store.getState(); + const explicitId = state.user.user?._id; + if (explicitId) return explicitId; + + const token = state.auth.token; + if (!token) return null; + + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const payloadBase64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + const payloadJson = atob(payloadBase64); + const payload = JSON.parse(payloadJson); + return payload.tgUserId || payload.userId || payload.sub || null; + } catch { + return null; + } +} + +function hexToBase64(hex: string): string { + const bytes = hexToBytes(hex); + if (bytes.length === 0) return ''; + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} + +function hexToBytes(hex: string): Uint8Array { + const cleanHex = hex.trim().replace(/^0x/i, ''); + if (!cleanHex) return new Uint8Array(); + const bytes = new Uint8Array(cleanHex.length / 2); + for (let i = 0; i < cleanHex.length; i += 2) { + bytes[i / 2] = parseInt(cleanHex.slice(i, i + 2), 16); + } + return bytes; +} + +function base64ToBytes(b64: string): Uint8Array { + // Normalize potential URL-safe base64 and missing padding + let normalized = b64.replace(/-/g, '+').replace(/_/g, '/'); + const pad = normalized.length % 4; + if (pad === 2) normalized += '=='; + else if (pad === 3) normalized += '='; + + const binary = atob(normalized); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function decryptIntegrationTokensWithKey( + encryptedPayload: string, + keyHex: string +): Promise { + if (typeof crypto === 'undefined' || !crypto.subtle) { + throw new Error('Web Crypto API is not available for decryption'); + } + + const keyBytes = hexToBytes(keyHex); + if (keyBytes.length === 0) { + throw new Error('Invalid encryption key'); + } + + const combined = base64ToBytes(encryptedPayload); + // Backend format: IV (16 bytes) + AuthTag (16 bytes) + EncryptedData (rest) + if (combined.length <= 32) { + throw new Error('Encrypted payload too short'); + } + + const iv = combined.slice(0, 16); + const authTag = combined.slice(16, 32); + const encryptedData = combined.slice(32); + const ciphertextWithTag = new Uint8Array(encryptedData.length + authTag.length); + ciphertextWithTag.set(encryptedData, 0); + ciphertextWithTag.set(authTag, encryptedData.length); + + const cryptoKey = await crypto.subtle.importKey( + 'raw', + keyBytes as unknown as BufferSource, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv, tagLength: 128 }, + cryptoKey, + ciphertextWithTag as unknown as BufferSource + ); + + return new TextDecoder().decode(decrypted); +} + /** * Handle an `alphahuman://auth?token=...` deep link for login. @@ -55,6 +162,59 @@ const handleOAuthDeepLink = async (parsed: URL) => { console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); try { + + const state = store.getState(); + const userId = getCurrentUserId(); + if (!userId) { + console.warn('[DeepLink] Cannot fetch integration tokens: no current user id'); + return; + } + + const encryptionKeyHex = state.auth.encryptionKeyByUser[userId]; + if (!encryptionKeyHex) { + console.warn( + '[DeepLink] Cannot fetch integration tokens: no encryption key found for user', + userId + ); + return; + } + + const keyForBackend = hexToBase64(encryptionKeyHex); + const response = await fetchIntegrationTokens(integrationId, keyForBackend || encryptionKeyHex); + if (!response.success || !response.data?.encrypted) { + console.warn( + '[DeepLink] Integration tokens response missing encrypted payload for integration', + integrationId + ); + return; + } + + let decryptedTokens: IntegrationTokensPayload; + try { + const plaintext = await decryptIntegrationTokensWithKey( + response.data.encrypted, + encryptionKeyHex + ); + decryptedTokens = JSON.parse(plaintext) as IntegrationTokensPayload; + } catch (err) { + console.error('[DeepLink] Failed to decrypt integration tokens:', err); + return; + } + + const existingState = state.skills.skillStates[skillId] ?? {}; + store.dispatch( + setSkillState({ + skillId, + state: { + ...existingState, + oauthTokens: { + ...(existingState.oauthTokens as Record | undefined), + [integrationId]: decryptedTokens, + }, + }, + }) + ); + await skillManager.notifyOAuthComplete(skillId, integrationId); } catch (err) { console.error('[DeepLink] Failed to notify OAuth complete:', err); diff --git a/yarn.lock b/yarn.lock index cd442e40e..348c67722 100644 --- a/yarn.lock +++ b/yarn.lock @@ -923,6 +923,28 @@ outvariant "^1.4.3" strict-event-emitter "^0.5.1" +"@noble/curves@~1.9.0": + version "1.9.7" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.9.7.tgz#79d04b4758a43e4bca2cbdc62e7771352fa6b951" + integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== + dependencies: + "@noble/hashes" "1.8.0" + +"@noble/hashes@1.8.0", "@noble/hashes@~1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" + integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== + +"@noble/hashes@2.0.1", "@noble/hashes@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-2.0.1.tgz#fc1a928061d1232b0a52bb754393c37a5216c89e" + integrity sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw== + +"@noble/secp256k1@^2.0.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-2.3.0.tgz#ddfe6e853472fb88cba4d5e59b7067adc1e64adf" + integrity sha512-0TQed2gcBbIrh7Ccyw+y/uZQvbJwm7Ao4scBUxqpBCcsOlZG0O4KGfjtNAy/li4W8n1xt3dxrwJ0beZ2h2G6Kw== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1152,6 +1174,33 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== +"@scure/base@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-2.0.0.tgz#ba6371fddf92c2727e88ad6ab485db6e624f9a98" + integrity sha512-3E1kpuZginKkek01ovG8krQ0Z44E3DHPjc5S2rjJw9lZn3KSQOs8S7wqikF/AH7iRanHypj85uGyxk0XAyC37w== + +"@scure/base@~1.2.5": + version "1.2.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.2.6.tgz#ca917184b8231394dd8847509c67a0be522e59f6" + integrity sha512-g/nm5FgUa//MCj1gV09zTJTaM6KBAHqLN907YVQqf7zC49+DcO4B1so4ZX07Ef10Twr6nuqYEH9GEggFXA4Fmg== + +"@scure/bip32@^1.5.1": + version "1.7.0" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.7.0.tgz#b8683bab172369f988f1589640e53c4606984219" + integrity sha512-E4FFX/N3f4B80AKWp5dP6ow+flD1LQZo/w8UnLGYZO674jS6YnYeepycOOksv+vLPSpgN35wgKgy+ybfTb2SMw== + dependencies: + "@noble/curves" "~1.9.0" + "@noble/hashes" "~1.8.0" + "@scure/base" "~1.2.5" + +"@scure/bip39@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-2.0.1.tgz#47a6dc15e04faf200041239d46ae3bb7c3c96add" + integrity sha512-PsxdFj/d2AcJcZDX1FXN3dDgitDDTmwf78rKZq1a6c1P1Nan1X/Sxc7667zU3U+AN60g7SxxP0YCVw2H/hBycg== + dependencies: + "@noble/hashes" "2.0.1" + "@scure/base" "2.0.0" + "@sec-ant/readable-stream@^0.4.1": version "0.4.1" resolved "https://registry.yarnpkg.com/@sec-ant/readable-stream/-/readable-stream-0.4.1.tgz#60de891bb126abfdc5410fdc6166aca065f10a0c" @@ -7451,6 +7500,7 @@ stringify-entities@^4.0.0: character-entities-legacy "^3.0.0" "strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: + name strip-ansi-cjs version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -8296,6 +8346,7 @@ workerpool@^6.5.1: integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: + name wrap-ansi-cjs version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==