From 65d42f2526fc6e2ef8c50bb3d6917be3c176afcc Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 6 Feb 2026 05:48:10 +0530 Subject: [PATCH] Implement OAuth flow in SkillSetupWizard and enhance skill manager - Added support for OAuth in the SkillSetupWizard, allowing skills to handle OAuth login and state management. - Introduced new OAuthLoginView component for user interaction during OAuth processes. - Updated SkillManager to notify skills of successful OAuth completion and manage OAuth credentials. - Enhanced deep link handling to support OAuth success and error states. - Modified SkillManifest to include OAuth configuration details. - Updated runtime and backend to accommodate OAuth functionality, ensuring skills can utilize OAuth for authentication. --- skills | 2 +- src-tauri/src/commands/runtime.rs | 14 +- src-tauri/src/runtime/manifest.rs | 2 + src-tauri/src/runtime/qjs_skill_instance.rs | 30 +- src-tauri/src/services/tdlib_v8/bootstrap.js | 80 +++++ src/components/skills/SkillSetupWizard.tsx | 293 ++++++++++++++++++- src/lib/skills/manager.ts | 28 ++ src/lib/skills/runtime.ts | 23 ++ src/lib/skills/types.ts | 5 + src/utils/desktopDeepLinkListener.ts | 106 +++++-- 10 files changed, 542 insertions(+), 41 deletions(-) diff --git a/skills b/skills index 74d3cb1a2..9dc185554 160000 --- a/skills +++ b/skills @@ -1 +1 @@ -Subproject commit 74d3cb1a2e685652274e54efb65dd074f7181ab5 +Subproject commit 9dc1855541dcf952e1a19bd8b67033c51d4ce40a diff --git a/src-tauri/src/commands/runtime.rs b/src-tauri/src/commands/runtime.rs index a92190e48..98482c287 100644 --- a/src-tauri/src/commands/runtime.rs +++ b/src-tauri/src/commands/runtime.rs @@ -65,10 +65,16 @@ mod desktop { "autoStart": m.auto_start, "version": m.version, "description": m.description, - "setup": m.setup.as_ref().map(|s| serde_json::json!({ - "required": s.required, - "label": s.label, - })), + "setup": m.setup.as_ref().map(|s| { + let mut obj = serde_json::json!({ + "required": s.required, + "label": s.label, + }); + if let Some(oauth) = &s.oauth { + obj["oauth"] = oauth.clone(); + } + obj + }), "platforms": m.platforms, }) }) diff --git a/src-tauri/src/runtime/manifest.rs b/src-tauri/src/runtime/manifest.rs index 9897cbb17..b3e3d7833 100644 --- a/src-tauri/src/runtime/manifest.rs +++ b/src-tauri/src/runtime/manifest.rs @@ -13,6 +13,8 @@ pub struct SkillSetup { #[serde(default)] pub required: bool, pub label: Option, + /// OAuth configuration (provider, scopes, apiBaseUrl). + pub oauth: Option, } /// Raw manifest as it appears on disk. diff --git a/src-tauri/src/runtime/qjs_skill_instance.rs b/src-tauri/src/runtime/qjs_skill_instance.rs index a000c6857..d6866d466 100644 --- a/src-tauri/src/runtime/qjs_skill_instance.rs +++ b/src-tauri/src/runtime/qjs_skill_instance.rs @@ -410,8 +410,34 @@ async fn handle_message( let _ = reply.send(result); } SkillMessage::Rpc { method, params, reply } => { - let args = serde_json::json!({ "method": method, "params": params }); - let result = handle_js_call(ctx, "onRpc", &args.to_string()).await; + let result = match method.as_str() { + "oauth/complete" => { + // Set credential on the oauth bridge, then call onOAuthComplete + let set_cred_code = format!( + "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}", + serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string()) + ); + ctx.with(|js_ctx| { + let _ = js_ctx.eval::(set_cred_code.as_bytes()); + }).await; + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await + } + "oauth/revoked" => { + // Clear credential on the oauth bridge, then call onOAuthRevoked + let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }"; + ctx.with(|js_ctx| { + let _ = js_ctx.eval::(clear_code.as_bytes()); + }).await; + let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()); + handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await + .map(|()| serde_json::json!({ "ok": true })) + } + _ => { + let args = serde_json::json!({ "method": method, "params": params }); + handle_js_call(ctx, "onRpc", &args.to_string()).await + } + }; let _ = reply.send(result); } } diff --git a/src-tauri/src/services/tdlib_v8/bootstrap.js b/src-tauri/src/services/tdlib_v8/bootstrap.js index 2662db39f..d23a747d7 100644 --- a/src-tauri/src/services/tdlib_v8/bootstrap.js +++ b/src-tauri/src/services/tdlib_v8/bootstrap.js @@ -823,6 +823,86 @@ globalThis.data = { }, }; +// ============================================================================ +// OAuth Bridge API (credential management and authenticated proxy) +// ============================================================================ +(function () { + var __oauthCredential = null; + + globalThis.oauth = { + /** Get the current OAuth credential, or null if not connected. */ + getCredential: function () { + return __oauthCredential; + }, + + /** + * Make an authenticated API request proxied through the backend. + * Path is relative to manifest's apiBaseUrl. + */ + fetch: function (path, options) { + if (!__oauthCredential) { + return { + status: 401, + headers: {}, + body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }), + }; + } + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path; + var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath; + var method = (options && options.method) || 'GET'; + var headers = { 'Content-Type': 'application/json' }; + if (jwtToken) { + headers['Authorization'] = 'Bearer ' + jwtToken; + } + if (options && options.headers) { + for (var k in options.headers) { + headers[k] = options.headers[k]; + } + } + var fetchOpts = JSON.stringify({ + method: method, + headers: headers, + body: options ? options.body : undefined, + timeout: options ? options.timeout : undefined, + }); + var result = __net.fetch(proxyUrl, fetchOpts); + return JSON.parse(result); + }, + + /** Revoke the current OAuth credential server-side. */ + revoke: function () { + if (__oauthCredential) { + try { + var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz'; + var jwtToken = __platform.env('JWT_TOKEN') || ''; + var revokeOpts = JSON.stringify({ + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer ' + jwtToken, + }, + }); + __net.fetch( + backendUrl + '/auth/integrations/' + __oauthCredential.credentialId, + revokeOpts + ); + } catch (e) { + /* best effort */ + } + } + __oauthCredential = null; + return true; + }, + + /** Internal: set credential (called by runtime on oauth/complete). */ + __setCredential: function (cred) { + __oauthCredential = cred; + }, + }; +})(); + // ============================================================================ // Cron Bridge API (placeholder - requires integration with CronScheduler) // ============================================================================ diff --git a/src/components/skills/SkillSetupWizard.tsx b/src/components/skills/SkillSetupWizard.tsx index 577709f17..19197dcc0 100644 --- a/src/components/skills/SkillSetupWizard.tsx +++ b/src/components/skills/SkillSetupWizard.tsx @@ -1,12 +1,17 @@ /** * Multi-step setup wizard for a single skill. - * Manages the state machine: start → render form → submit → next/error/complete. + * Manages the state machine: start -> render form -> submit -> next/error/complete. + * For OAuth skills, shows a login button instead of form steps. * Ensures the skill is running (starts it if needed) before starting the setup flow. */ import { useState, useEffect, useCallback } from "react"; import { store } from "../../store"; +import { useAppSelector } from "../../store/hooks"; import { skillManager } from "../../lib/skills/manager"; +import { setSkillSetupComplete } from "../../store/skillsSlice"; +import { BACKEND_URL } from "../../utils/config"; +import { openUrl } from "../../utils/openUrl"; import type { SetupStep, SetupFieldError } from "../../lib/skills/types"; import SetupFormRenderer from "./SetupFormRenderer"; @@ -16,8 +21,15 @@ interface SkillSetupWizardProps { onCancel: () => void; } +interface OAuthConfig { + provider: string; + scopes: string[]; +} + type WizardState = | { phase: "loading" } + | { phase: "oauth"; oauth: OAuthConfig } + | { phase: "oauth_waiting"; oauth: OAuthConfig } | { phase: "step"; step: SetupStep; errors?: SetupFieldError[] | null } | { phase: "submitting"; step: SetupStep } | { phase: "complete"; message?: string } @@ -30,6 +42,22 @@ export default function SkillSetupWizard({ }: SkillSetupWizardProps) { const [state, setState] = useState({ phase: "loading" }); + // Watch skill state for OAuth completion (skill pushes connected: true) + const skillState = useAppSelector( + (s) => s.skills.skillStates[skillId], + ); + + // When skill state changes to connected during OAuth waiting, mark complete + useEffect(() => { + if ( + (state.phase === "oauth" || state.phase === "oauth_waiting") && + skillState?.connected === true + ) { + store.dispatch(setSkillSetupComplete({ skillId, complete: true })); + setState({ phase: "complete", message: "Successfully connected!" }); + } + }, [skillState?.connected, state.phase, skillId]); + // Start the skill (if not running) then start the setup flow on mount useEffect(() => { let cancelled = false; @@ -68,6 +96,20 @@ export default function SkillSetupWizard({ throw new Error(errMsg); } + // If the skill has OAuth config, show OAuth login instead of form steps + if (manifest.setup?.oauth) { + if (!cancelled) { + setState({ + phase: "oauth", + oauth: { + provider: manifest.setup.oauth.provider, + scopes: manifest.setup.oauth.scopes, + }, + }); + } + return; + } + console.log("[SkillSetupWizard] starting setup", skillId); const firstStep = await skillManager.startSetup(skillId); console.log("[SkillSetupWizard] setup started", skillId); @@ -89,6 +131,28 @@ export default function SkillSetupWizard({ }; }, [skillId]); + const handleOAuthLogin = useCallback(async () => { + if (state.phase !== "oauth") return; + + const { oauth } = state; + const token = store.getState().auth.token; + const params = new URLSearchParams({ + provider: oauth.provider, + skill_id: skillId, + }); + if (token) { + params.set("token", token); + } + if (oauth.scopes.length > 0) { + params.set("scopes", oauth.scopes.join(",")); + } + + const oauthUrl = `${BACKEND_URL}/oauth/authorize?${params.toString()}`; + await openUrl(oauthUrl); + + setState({ phase: "oauth_waiting", oauth }); + }, [state, skillId]); + const handleSubmit = useCallback( async (values: Record) => { if (state.phase !== "step") return; @@ -132,13 +196,15 @@ export default function SkillSetupWizard({ ); const handleCancel = useCallback(async () => { - try { - await skillManager.cancelSetup(skillId); - } catch { - // Ignore cancel errors + if (state.phase !== "oauth" && state.phase !== "oauth_waiting") { + try { + await skillManager.cancelSetup(skillId); + } catch { + // Ignore cancel errors + } } onCancel(); - }, [skillId, onCancel]); + }, [skillId, onCancel, state.phase]); // Render based on current wizard state switch (state.phase) { @@ -171,6 +237,26 @@ export default function SkillSetupWizard({ ); + case "oauth": + return ( + + ); + + case "oauth_waiting": + return ( + + ); + case "step": return ( = { + notion: "Notion", + google: "Google", + github: "GitHub", + slack: "Slack", + discord: "Discord", + twitter: "Twitter", + linear: "Linear", + }; + return names[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1); +} + +interface OAuthLoginViewProps { + provider: string; + onLogin: () => void; + onCancel: () => void; + waiting: boolean; +} + +function OAuthLoginView({ + provider, + onLogin, + onCancel, + waiting, +}: OAuthLoginViewProps) { + const providerName = formatProviderName(provider); + + return ( +
+ {/* Provider icon */} +
+
+ +
+
+ + {/* Title and description */} +
+

+ Connect to {providerName} +

+

+ {waiting + ? "Waiting for authorization. Complete the login in your browser..." + : `Sign in with your ${providerName} account to connect this skill.`} +

+
+ + {/* Login button or waiting state */} + {waiting ? ( +
+
+ + + + + + Waiting for {providerName} authorization... + +
+ + +
+ ) : ( + + )} + + {/* Cancel */} +
+ +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Provider Icons +// --------------------------------------------------------------------------- + +function ProviderIcon({ provider }: { provider: string }) { + switch (provider) { + case "notion": + return ( + + + + + ); + case "google": + return ( + + + + + + + ); + case "github": + return ( + + + + ); + default: + return ( + + + + ); + } +} diff --git a/src/lib/skills/manager.ts b/src/lib/skills/manager.ts index fd00fddbe..41a7a179c 100644 --- a/src/lib/skills/manager.ts +++ b/src/lib/skills/manager.ts @@ -233,6 +233,34 @@ class SkillManager { await this.activateSkill(skillId); } + /** + * Notify a skill that OAuth completed successfully. + * Called by the deep link handler after backend OAuth callback. + */ + async notifyOAuthComplete( + skillId: string, + integrationId: string, + provider?: string, + ): Promise { + const runtime = this.runtimes.get(skillId); + if (!runtime || !runtime.isRunning) { + console.warn(`[SkillManager] Cannot notify OAuth complete: skill ${skillId} not running`); + return; + } + + const manifest = store.getState().skills.skills[skillId]?.manifest; + + await runtime.oauthComplete({ + credentialId: integrationId, + provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown", + grantedScopes: manifest?.setup?.oauth?.scopes ?? [], + }); + + // Mark setup as complete and activate + store.dispatch(setSkillSetupComplete({ skillId, complete: true })); + await this.activateSkill(skillId); + } + /** * Forward session start to all ready skills. */ diff --git a/src/lib/skills/runtime.ts b/src/lib/skills/runtime.ts index d487b047b..acb766f2f 100644 --- a/src/lib/skills/runtime.ts +++ b/src/lib/skills/runtime.ts @@ -160,6 +160,29 @@ export class SkillRuntime { await this.transport.request("skill/sessionEnd", { sessionId }); } + /** + * Notify the skill that OAuth completed successfully. + * Sets the credential on the bridge and calls onOAuthComplete. + */ + async oauthComplete(args: { + credentialId: string; + provider: string; + grantedScopes?: string[]; + accountLabel?: string; + }): Promise { + await this.transport.request("oauth/complete", args as unknown as Record); + } + + /** + * Notify the skill that an OAuth credential was revoked. + */ + async oauthRevoked(args: { + credentialId: string; + reason: string; + }): Promise { + await this.transport.request("oauth/revoked", args as unknown as Record); + } + /** * Unload and stop the skill. */ diff --git a/src/lib/skills/types.ts b/src/lib/skills/types.ts index 1a2973294..5ae8f418b 100644 --- a/src/lib/skills/types.ts +++ b/src/lib/skills/types.ts @@ -22,6 +22,11 @@ export interface SkillManifest { setup?: { required: boolean; label?: string; + oauth?: { + provider: string; + scopes: string[]; + apiBaseUrl: string; + }; }; /** Platform filter. When present, only listed platforms load this skill. * When absent or empty, the skill is available on all platforms. */ diff --git a/src/utils/desktopDeepLinkListener.ts b/src/utils/desktopDeepLinkListener.ts index 4fd811ed1..66d0e7f00 100644 --- a/src/utils/desktopDeepLinkListener.ts +++ b/src/utils/desktopDeepLinkListener.ts @@ -1,15 +1,80 @@ 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 { store } from '../store'; import { setToken } from '../store/authSlice'; import { IS_DEV } from './config'; +/** + * Handle an `alphahuman://auth?token=...` deep link for login. + */ +const handleAuthDeepLink = async (parsed: URL) => { + const token = parsed.searchParams.get('token'); + if (!token) { + console.warn('[DeepLink] URL did not contain a token query parameter'); + return; + } + + console.log('[DeepLink] Received auth token'); + + try { + await invoke('show_window'); + } catch (err) { + console.warn('[DeepLink] Failed to show window:', err); + } + + const jwtToken = await consumeLoginToken(token); + store.dispatch(setToken(jwtToken)); + window.location.hash = '/onboarding'; +}; + +/** + * Handle `alphahuman://oauth/success?integrationId=...&skillId=...` + * and `alphahuman://oauth/error?error=...&provider=...` deep links. + */ +const handleOAuthDeepLink = async (parsed: URL) => { + // pathname is "/success" or "/error" (hostname is "oauth") + const path = parsed.pathname.replace(/^\/+/, ''); + + try { + await invoke('show_window'); + } catch { + // Not fatal + } + + if (path === 'success') { + const integrationId = parsed.searchParams.get('integrationId'); + const skillId = parsed.searchParams.get('skillId'); + + if (!integrationId || !skillId) { + console.error('[DeepLink] OAuth success missing integrationId or skillId', parsed.href); + return; + } + + console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`); + + try { + await skillManager.notifyOAuthComplete(skillId, integrationId); + } catch (err) { + console.error('[DeepLink] Failed to notify OAuth complete:', err); + } + } else if (path === 'error') { + const error = parsed.searchParams.get('error') ?? 'Unknown error'; + const provider = parsed.searchParams.get('provider') ?? 'unknown'; + console.error(`[DeepLink] OAuth error for provider=${provider}: ${error}`); + } else { + console.warn('[DeepLink] Unknown OAuth path:', path); + } +}; + /** * Handle a list of deep link URLs delivered by the Tauri deep-link plugin. - * Parses `alphahuman://auth?token=...` URLs and exchanges the token for a - * desktop session via the backend. + * Routes to the appropriate handler based on the URL hostname: + * - `alphahuman://auth?token=...` → login flow + * - `alphahuman://oauth/success?...` → OAuth completion + * - `alphahuman://oauth/error?...` → OAuth failure */ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { if (!urls || urls.length === 0) { @@ -23,33 +88,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { if (parsed.protocol !== 'alphahuman:') { return; } - // Harden: ensure this deep link is intended for auth handoff - if (parsed.hostname !== 'auth') { - return; + + switch (parsed.hostname) { + case 'auth': + await handleAuthDeepLink(parsed); + break; + case 'oauth': + await handleOAuthDeepLink(parsed); + break; + default: + console.warn('[DeepLink] Unknown deep link hostname:', parsed.hostname); + break; } - - const token = parsed.searchParams.get('token'); - if (!token) { - console.warn('[DeepLink] URL did not contain a token query parameter'); - return; - } - - console.log('[DeepLink] Received token', token); - - try { - // Bring app window to foreground so macOS users actually see completion. - // (In this app, the window can start hidden and live in the tray.) - await invoke('show_window'); - } catch (err) { - // Not fatal; we still continue the auth flow. - console.warn('[DeepLink] Failed to show window:', err); - } - - const jwtToken = await consumeLoginToken(token); - store.dispatch(setToken(jwtToken)); - - // Navigate to post-login flow. We use HashRouter, so update the hash route. - window.location.hash = '/onboarding'; } catch (error) { console.error('[DeepLink] Failed to handle deep link URL:', url, error); }