Use console connect flow for desktop auth

This commit is contained in:
Sreeram Sreedhar 2026-06-22 22:51:38 -07:00
parent bb2da655ca
commit d5fddf5611
4 changed files with 118 additions and 58 deletions

View file

@ -9,7 +9,8 @@ use std::{
const KEYCHAIN_SERVICE: &str = "ai.supermemory.desktop";
const KEYCHAIN_USER: &str = "supermemory-api-token";
const KEYCHAIN_API_URL_USER: &str = "supermemory-api-url";
const DEFAULT_WEB_URL: &str = "https://app.supermemory.ai";
const DEFAULT_WEB_URL: &str = "https://console.supermemory.ai";
const DEFAULT_BROWSER_API_URL: &str = "https://api.supermemory.ai";
#[cfg(debug_assertions)]
const DEFAULT_API_URL: &str = "http://localhost:8787";
@ -121,6 +122,18 @@ pub fn web_url() -> String {
std::env::var("SUPERMEMORY_DESKTOP_WEB_URL").unwrap_or_else(|_| DEFAULT_WEB_URL.to_string())
}
fn browser_auth_api_url() -> String {
if let Ok(api_url) = std::env::var("SUPERMEMORY_DESKTOP_API_URL") {
return api_url;
}
if std::env::var("SUPERMEMORY_DESKTOP_WEB_URL").is_ok() {
return configured_api_url();
}
DEFAULT_BROWSER_API_URL.to_string()
}
pub fn store_token(token: String) -> Result<(), String> {
store_token_with_api_url(token, Some(configured_api_url()))
}
@ -217,10 +230,20 @@ pub fn handle_deep_link(url: &str) -> Result<AuthChangedEvent, String> {
.iter()
.find_map(|(key, value)| (key == "state").then(|| value.to_string()))
.ok_or_else(|| "Auth callback did not include state".to_string())?;
let token = params
let api_key = params
.iter()
.find_map(|(key, value)| (key == "token").then(|| value.to_string()))
.ok_or_else(|| "Auth callback did not include token".to_string())?;
.find_map(|(key, value)| (key == "apikey").then(|| value.to_string()))
.or_else(|| {
params
.iter()
.find_map(|(key, value)| (key == "apiKey").then(|| value.to_string()))
})
.or_else(|| {
params
.iter()
.find_map(|(key, value)| (key == "token").then(|| value.to_string()))
})
.ok_or_else(|| "Auth callback did not include API key".to_string())?;
let callback_api_url = params
.iter()
.find_map(|(key, value)| (key == "apiUrl").then(|| value.to_string()))
@ -231,7 +254,10 @@ pub fn handle_deep_link(url: &str) -> Result<AuthChangedEvent, String> {
});
verify_browser_state(&state)?;
store_token_with_api_url(token, callback_api_url)?;
store_token_with_api_url(
api_key,
callback_api_url.or_else(|| Some(browser_auth_api_url())),
)?;
Ok(AuthChangedEvent {
authenticated: true,
@ -297,10 +323,19 @@ fn build_browser_login_url(state: &str) -> Result<String, String> {
let mut url = url::Url::parse(&base)
.or_else(|_| url::Url::parse(&format!("{}/", base.trim_end_matches('/'))))
.map_err(|error| format!("Invalid Supermemory web URL: {error}"))?;
url.set_path("login");
url.set_path("auth/connect");
let mut callback = url::Url::parse("supermemory://auth-callback")
.map_err(|error| format!("Invalid desktop callback URL: {error}"))?;
callback
.query_pairs_mut()
.append_pair("state", state)
.append_pair("api_url", &browser_auth_api_url());
url.query_pairs_mut()
.append_pair("desktop-auth", "1")
.append_pair("state", state);
.append_pair("callback", callback.as_str())
.append_pair("client", "desktop")
.append_pair("name", "Supermemory Desktop");
Ok(url.to_string())
}
@ -367,3 +402,56 @@ fn first_string(value: &Value, paths: &[&[&str]]) -> Option<String> {
found.as_str().map(ToString::to_string)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn browser_auth_url_uses_console_connect_flow() {
std::env::remove_var("SUPERMEMORY_DESKTOP_WEB_URL");
std::env::remove_var("SUPERMEMORY_DESKTOP_API_URL");
let url = url::Url::parse(&build_browser_login_url("state-123").unwrap()).unwrap();
assert_eq!(
url.as_str().split('?').next().unwrap(),
"https://console.supermemory.ai/auth/connect"
);
assert_eq!(
url.query_pairs()
.find(|(key, _)| key == "client")
.unwrap()
.1,
"desktop"
);
assert_eq!(
url.query_pairs().find(|(key, _)| key == "name").unwrap().1,
"Supermemory Desktop"
);
let callback = url
.query_pairs()
.find_map(|(key, value)| (key == "callback").then(|| value.to_string()))
.unwrap();
let callback = url::Url::parse(&callback).unwrap();
assert_eq!(callback.scheme(), "supermemory");
assert_eq!(callback.host_str(), Some("auth-callback"));
assert_eq!(
callback
.query_pairs()
.find(|(key, _)| key == "state")
.unwrap()
.1,
"state-123"
);
assert_eq!(
callback
.query_pairs()
.find(|(key, _)| key == "api_url")
.unwrap()
.1,
DEFAULT_BROWSER_API_URL
);
}
}

View file

@ -149,27 +149,6 @@ export default function NewPage() {
}
}, [user, session])
// Desktop auth: hand the browser session token back to the native app via deep link.
useEffect(() => {
const url = new URL(window.location.href)
if (!url.searchParams.get("desktop-auth")) return
const state = url.searchParams.get("state")
const sessionToken = session?.token
if (state && sessionToken) {
const callback = new URL("supermemory://auth-callback")
callback.searchParams.set("token", sessionToken)
callback.searchParams.set("state", state)
callback.searchParams.set(
"apiUrl",
process.env.NEXT_PUBLIC_BACKEND_URL ?? "https://api.supermemory.ai",
)
window.location.assign(callback.toString())
url.searchParams.delete("desktop-auth")
url.searchParams.delete("state")
window.history.replaceState({}, "", url.toString())
}
}, [session])
// URL-driven modal states
const [addDoc, setAddDoc] = useQueryState("add", addDocumentParam)
const [isSearchOpen, setIsSearchOpen] = useQueryState("search", searchParam)

View file

@ -126,9 +126,6 @@ export default function LoginPage() {
const { data: sessionData, isPending: sessionPending } = useSession()
const oauthQueryForResume = params.toString()
const desktopAuthState = params.get("desktop-auth")
? params.get("state")
: null
const isRedirecting = !sessionPending && Boolean(sessionData?.session)
const isAuthResolving = sessionPending || isRedirecting
const loadingMessage = isAuthResolving
@ -151,32 +148,16 @@ export default function LoginPage() {
}
const redirectUrl = params.get("redirect")
if (redirectUrl) {
const dest = resolveAuthRedirectUrl(redirectUrl, window.location.origin)
if (desktopAuthState) {
dest.searchParams.set("desktop-auth", "1")
dest.searchParams.set("state", desktopAuthState)
}
window.location.assign(dest.toString())
return
}
if (desktopAuthState) {
const dest = new URL("/", window.location.origin)
dest.searchParams.set("desktop-auth", "1")
dest.searchParams.set("state", desktopAuthState)
window.location.assign(dest.toString())
window.location.assign(
resolveAuthRedirectUrl(redirectUrl, window.location.origin).toString(),
)
return
}
// Carry the flag so the dashboard posts the session token to the extension (else: sign-in loop).
const dest = new URL("/", window.location.origin)
dest.searchParams.set("extension-auth-success", "true")
window.location.assign(dest.toString())
}, [
sessionPending,
sessionData?.session,
oauthQueryForResume,
params,
desktopAuthState,
])
}, [sessionPending, sessionData?.session, oauthQueryForResume, params])
// Get redirect URL from query params
const redirectUrl = params.get("redirect")
@ -191,12 +172,7 @@ export default function LoginPage() {
const finalUrl = resolveAuthRedirectUrl(redirectUrl, origin)
if (desktopAuthState) {
finalUrl.searchParams.set("desktop-auth", "1")
finalUrl.searchParams.set("state", desktopAuthState)
} else {
finalUrl.searchParams.set("extension-auth-success", "true")
}
finalUrl.searchParams.set("extension-auth-success", "true")
return finalUrl.toString()
}

View file

@ -18,6 +18,12 @@ const API_URL =
function isValidLocalhostCallback(callback: string): boolean {
try {
const url = new URL(callback)
const isDesktopCallback =
url.protocol === "supermemory:" &&
url.hostname === "auth-callback" &&
url.searchParams.has("state")
if (isDesktopCallback) return true
const isLocalhost =
url.hostname === "localhost" || url.hostname === "127.0.0.1"
const isHttp = url.protocol === "http:"
@ -101,6 +107,17 @@ const PLUGIN_INFO: Record<string, PluginInfo> = {
],
icon: "/images/plugins/codex.png",
},
desktop: {
name: "Supermemory Desktop",
description:
"Native access to your Supermemory account from the desktop app.",
features: [
"Secure browser-based sign-in",
"Stores your access key in the system keychain",
"Syncs search, MCP, and local memory features",
],
icon: "/icon.png",
},
}
function getPluginName(client: string): string {