mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user