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.
This commit is contained in:
M3gA-Mind
2026-02-19 20:26:54 +05:30
parent 4a6f0ec992
commit 98895bc038
4 changed files with 234 additions and 2 deletions
+1 -1
View File
@@ -535,7 +535,7 @@ async fn handle_message(
}
SkillMessage::LoadParams { params } => {
let params_str = serde_json::to_string(&params).unwrap_or_else(|_| "{}".to_string());
if let Err(e) = handle_js_void_call(ctx, "onLoad", &params_str).await {
if let Err(e) = handle_js_void_call(rt, ctx, "onLoad", &params_str).await {
log::warn!("[skill:{}] onLoad failed (skill may not export it): {}", skill_id, e);
}
}
+21
View File
@@ -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<string> {
}
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<IntegrationTokensResponse> {
return apiClient.post<IntegrationTokensResponse>(
`/auth/integrations/${encodeURIComponent(integrationId)}/tokens`,
{ key }
);
}
+161 -1
View File
@@ -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<string> {
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<string, unknown> | undefined),
[integrationId]: decryptedTokens,
},
},
})
);
await skillManager.notifyOAuthComplete(skillId, integrationId);
} catch (err) {
console.error('[DeepLink] Failed to notify OAuth complete:', err);
+51
View File
@@ -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==