feat(auth): make auth works with desktop, web and embedded apps

This commit is contained in:
Yann Amsellem
2025-10-27 17:55:52 +01:00
parent 26f6b2306d
commit bcc7632008
25 changed files with 1840 additions and 1474 deletions

View File

@@ -16,7 +16,7 @@ jobs:
- name: Use Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
node-version: '22'
cache: 'npm'
- name: Install dependencies

776
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,43 +19,43 @@
"@agnosticeng/dv": "^0.0.14",
"@agnosticeng/migrate": "^0.0.2",
"@agnosticeng/sqlite": "^0.0.5",
"@auth0/auth0-spa-js": "^2.1.3",
"@floating-ui/dom": "^1.7.0",
"@auth0/auth0-spa-js": "^2.7.0",
"@floating-ui/dom": "^1.7.4",
"@observablehq/plot": "^0.6.17",
"@rich_harris/svelte-split-pane": "^2.0.0",
"@tauri-apps/api": "^2.5.0",
"@tauri-apps/plugin-deep-link": "^2.3.0",
"@tauri-apps/plugin-dialog": "^2.2.2",
"@tauri-apps/plugin-fs": "^2.3.0",
"@tauri-apps/plugin-opener": "^2.2.7",
"@tauri-apps/plugin-shell": "^2.2.1",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/plugin-deep-link": "^2.4.3",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.2",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-shell": "^2.3.1",
"d3": "^7.9.0",
"dayjs": "^1.11.13",
"dayjs": "^1.11.18",
"highlight.js": "^11.11.1",
"lodash": "^4.17.21",
"marked": "^15.0.12",
"marked-highlight": "^2.2.1",
"marked": "^16.4.1",
"marked-highlight": "^2.2.2",
"mitt": "^3.0.1",
"monaco-editor": "^0.52.2",
"monaco-editor": "^0.54.0",
"normalize.css": "^8.0.1",
"p-debounce": "^4.0.0",
"sql-formatter": "^15.6.2"
"p-debounce": "^5.0.0",
"sql-formatter": "^15.6.10"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.21.1",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tauri-apps/cli": "^2.5.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.47.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tauri-apps/cli": "^2.9.1",
"@types/d3": "^7.4.3",
"@types/lodash": "^4.17.17",
"@types/lodash": "^4.17.20",
"@types/node": "^22.15.21",
"prettier": "^3.5.3",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"svelte": "^5.33.0",
"svelte-check": "^4.2.1",
"svelte": "^5.41.2",
"svelte-check": "^4.3.3",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"vite": "^6.3.5",
"vite-plugin-devtools-json": "^0.1.0"
"typescript": "^5.9.3",
"vite": "^7.1.11",
"vite-plugin-devtools-json": "^1.0.0"
}
}

2062
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
import { type Auth0Client, createAuth0Client } from '@auth0/auth0-spa-js';
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import { openUrl as openUrlWithBrowser } from '@tauri-apps/plugin-opener';
import mitt from 'mitt';
if (!AUTH0_DOMAIN) throw new Error('AUTH0_DOMAIN is not defined');
if (!AUTH0_CLIENT_ID) throw new Error('AUTH0_CLIENT_ID is not defined');
const emitter = mitt<{ 'auth:changed': boolean }>();
const noop = () => {};
let client: Auth0Client;
async function init() {
client = await createAuth0Client({
domain: AUTH0_DOMAIN,
clientId: AUTH0_CLIENT_ID,
authorizationParams: {
redirect_uri: AUTH0_REDIRECT_URI || window.location.origin
},
cacheLocation: 'localstorage',
useRefreshTokens: true
});
emitter.emit('auth:changed', await client.isAuthenticated());
}
export async function checkLoginState() {
await init();
if (PLATFORM === 'WEB') {
if (window.location.search.includes('code=') && window.location.search.includes('state=')) {
await client.handleRedirectCallback();
window.history.replaceState({}, document.title, '/');
emitter.emit('auth:changed', true);
}
}
if (PLATFORM === 'NATIVE') {
await onOpenUrl(async (urls) => {
const url = urls
.map((u) => new URL(u))
.find((u) => u.searchParams.has('code') && u.searchParams.has('state'));
if (url) {
await client.handleRedirectCallback(url.toString());
emitter.emit('auth:changed', true);
}
});
}
}
export function onStateChange(cb: (authenticated: boolean) => unknown) {
emitter.on('auth:changed', cb);
return () => emitter.off('auth:changed', cb);
}
export async function isAuthenticated() {
if (client) return await client.isAuthenticated();
return false;
}
export async function getToken() {
if (client) {
const tokens = await client.getTokenSilently({ detailedResponse: true, cacheMode: 'off' });
return tokens.id_token;
}
}
export async function login() {
if (client) await client.loginWithRedirect({ openUrl });
}
export async function logout(silently = true) {
if (client) {
await client.logout({
logoutParams: { returnTo: AUTH0_REDIRECT_URI || window.location.origin },
openUrl: silently ? noop : openUrl
});
emitter.emit('auth:changed', false);
}
}
async function openUrl(url: string) {
if (PLATFORM === 'WEB') window.location.assign(url);
if (PLATFORM === 'NATIVE') await openUrlWithBrowser(url);
}

View File

@@ -0,0 +1,74 @@
import { openUrl } from '$lib/env/open';
import { Auth0Client, createAuth0Client } from '@auth0/auth0-spa-js';
import { Notifier, type AuthService, type AuthSession } from './service';
export class Auth0AuthService extends Notifier<AuthSession> implements AuthService {
private client: Auth0Client | null = null;
private async getClient() {
if (!this.client)
this.client = await createAuth0Client({
domain: AUTH0_DOMAIN,
clientId: AUTH0_CLIENT_ID,
authorizationParams: {
redirect_uri: AUTH0_REDIRECT_URI || window.location.origin
},
cacheLocation: 'localstorage',
useRefreshTokens: true
});
return this.client;
}
async getSession() {
const client = await this.getClient();
try {
const { id_token: token, expires_in } = await client.getTokenSilently({
detailedResponse: true,
cacheMode: 'off'
});
const user = await client.getUser();
const expiresAt = Date.now() + expires_in * 1000;
const session: AuthSession = { idToken: token, user, expiresAt };
await this.setSession(session);
return session;
} catch {
return null;
}
}
async setSession(session: AuthSession) {
this.notify(session);
}
async clearSession() {
const client = await this.getClient();
await client.logout({
logoutParams: { returnTo: AUTH0_REDIRECT_URI || window.location.origin },
openUrl: () => {}
});
this.notify(null);
}
onStateChange(callback: (session: AuthSession | null) => void) {
return this.register(callback);
}
async login() {
const client = await this.getClient();
await client.loginWithRedirect({ openUrl });
}
async handleRedirectCallback(url?: string) {
const client = await this.getClient();
const _result = await client.handleRedirectCallback(url);
const user = await client.getUser();
const tokens = await client.getTokenSilently({ detailedResponse: true, cacheMode: 'off' });
const expiresAt = Date.now() + tokens.expires_in * 1000;
const session: AuthSession = { idToken: tokens.id_token, user, expiresAt };
await this.setSession(session);
}
}

View File

@@ -0,0 +1,16 @@
import { onOpenUrl } from '@tauri-apps/plugin-deep-link';
import type { AuthService } from './service';
export class DesktopAuthHandler {
constructor(private service: AuthService) {
onOpenUrl(this.handleOpenUrls.bind(this));
}
private async handleOpenUrls(urls: string[]) {
const url = urls
.map((u) => new URL(u))
.find((u) => u.searchParams.has('code') && u.searchParams.has('state'));
if (url) await this.service.handleRedirectCallback?.(url.toString());
}
}

View File

@@ -0,0 +1,18 @@
import type { AuthService, AuthSession } from './service';
export class EmbeddedAuthHandler {
constructor(private service: AuthService) {
this.handleURL();
}
private async handleURL() {
const url = new URL(window.location.href);
const token = url.searchParams.get('token');
if (token) {
const decoded = JSON.parse(atob(token.split('.')[1]));
const expiresAt = +(decoded.exp ?? Date.now() + 3600 * 1000);
const session: AuthSession = { idToken: token, expiresAt };
await this.service.setSession(session);
}
}
}

28
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { detectRuntime, type Runtime } from '$lib/env/runtime';
import { Auth0AuthService } from './auth0-service';
import { DesktopAuthHandler } from './desktop-handler';
import { EmbeddedAuthHandler } from './embedded-handler';
import { InMemoryAuthService } from './inmemory-service';
import type { AuthService, AuthSession } from './service';
import { WebAuthHandler } from './web-handler';
export function createAuthService(runtime: Runtime): AuthService {
if (runtime === 'embedded') {
return new InMemoryAuthService();
}
return new Auth0AuthService();
}
export function checkLoginState(runtime: Runtime, service: AuthService) {
if (runtime === 'embedded') new EmbeddedAuthHandler(service);
if (runtime === 'web') new WebAuthHandler(service);
if (runtime === 'desktop') new DesktopAuthHandler(service);
}
export function isAuthEnabled() {
const runtime = detectRuntime();
return runtime === 'embedded' || Boolean(AUTH0_CLIENT_ID && AUTH0_DOMAIN);
}
export type { AuthService, AuthSession };

View File

@@ -0,0 +1,23 @@
import { Notifier, type AuthService, type AuthSession } from './service';
export class InMemoryAuthService extends Notifier<AuthSession> implements AuthService {
#session: AuthSession | null = null;
getSession() {
return this.#session;
}
setSession(session: AuthSession) {
this.#session = session;
this.notify(session);
}
clearSession(): MaybePromise<void> {
this.#session = null;
this.notify(null);
}
onStateChange(callback: (session: AuthSession | null) => void): () => void {
return this.register(callback);
}
}

27
src/lib/auth/service.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface AuthSession {
idToken: string;
expiresAt: number;
user?: { sub?: string; name?: string; email?: string };
}
export interface AuthService {
getSession(): MaybePromise<AuthSession | null>;
setSession(session: AuthSession): MaybePromise<void>;
clearSession(): MaybePromise<void>;
onStateChange(callback: (session: AuthSession | null) => void): () => void;
login?(): MaybePromise<void>;
handleRedirectCallback?(url?: string): MaybePromise<void>;
}
export abstract class Notifier<T> {
private callbacks = new Set<(value: T | null) => void>();
protected register(callback: (value: T | null) => void) {
this.callbacks.add(callback);
return () => this.callbacks.delete(callback);
}
protected notify(value: T | null) {
this.callbacks.forEach((callback) => callback(value));
}
}

View File

@@ -0,0 +1,18 @@
import type { AuthService } from './service';
export class WebAuthHandler {
constructor(private service: AuthService) {
this.handle();
}
private async handle() {
const url = new URL(window.location.href);
if (url.searchParams.has('code') && url.searchParams.has('state')) {
await this.service.handleRedirectCallback?.();
url.searchParams.delete('code');
url.searchParams.delete('state');
window.history.replaceState({}, document.title, url);
}
}
}

View File

@@ -3,7 +3,7 @@
interface Props {
models: Model[];
model: Model;
model?: Model;
onSelect?: (model: Model) => void;
}
@@ -14,7 +14,7 @@
<ul role="listbox">
{#each models as m (m.name + m.brand)}
{@const isSelected =
m.brand === model.brand && m.name === model.name && m.baseURL === model.baseURL}
m.brand === model?.brand && m.name === model?.name && m.baseURL === model?.baseURL}
<li role="option" aria-selected={isSelected}>
<button
title={[m.brand, m.name].filter(Boolean).join(' • ')}
@@ -27,6 +27,10 @@
</span>
</button>
</li>
{:else}
<li role="option" aria-selected="false">
<span>No model available</span>
</li>
{/each}
</ul>
</div>
@@ -60,6 +64,12 @@
padding-bottom: 2px;
}
& > span {
display: block;
padding: 4px 0;
text-align: center;
}
& > button {
height: 100%;
width: 100%;

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { autoresize } from '$lib/actions/autoresize.svelte';
import { scroll_to_bottom } from '$lib/actions/scrollToBottom.svelte';
import { getToken, logout } from '$lib/auth';
import Select from '$lib/components/Select.svelte';
import { getAppContext } from '$lib/context';
import ChevronDown from '$lib/icons/ChevronDown.svelte';
@@ -26,7 +25,7 @@
dataset?: Table;
onOpenInEditor?: (sql: string) => void;
models: Model[];
model: Model;
model?: Model;
onModelChange: (m: Model) => void;
}
@@ -50,8 +49,8 @@
let modelSelectbox = $state<ReturnType<typeof Select>>();
let form = $state<HTMLFormElement>();
const uid = $props.id();
const { isAuthenticated } = getAppContext();
const needToLogin = $derived(isAgnosticModel(model) && !isAuthenticated());
const { isAuthenticated, logout, getToken } = getAppContext();
const needToLogin = $derived(model && isAgnosticModel(model) && !isAuthenticated());
function getContextFromTable(table: Table): string {
const columns = table.columns.map((col) => `- ${col.name} (${col.type})`).join('\n');
@@ -63,12 +62,13 @@
});
onMount(() => form?.dispatchEvent(new SubmitEvent('submit')));
const client = $derived(new OpenAIClient(model.baseURL));
const client = $derived(model ? new OpenAIClient(model.baseURL) : null);
async function handleSubmit(
event: SubmitEvent & { currentTarget: EventTarget & HTMLFormElement }
) {
event.preventDefault();
if (!model) return;
const form = event.currentTarget;
let token: string | undefined;
@@ -93,7 +93,7 @@
try {
abortController = new AbortController();
const completion = await client.createChatCompletion(
const completion = await client?.createChatCompletion(
{
model: model.name,
messages: dataset
@@ -104,7 +104,7 @@
{ signal: abortController.signal, token }
);
messages = messages.concat(completion.choices[0].message);
if (completion) messages = messages.concat(completion.choices[0].message);
} catch (e) {
if (e === 'Canceled by user') {
const last = messages.at(-1);
@@ -194,7 +194,7 @@
name="message"
tabindex="0"
rows="1"
placeholder="Ask {model.name}"
placeholder="Ask {model?.name ?? 'Ai'}"
disabled={loading}
use:autoresize
bind:value={message}
@@ -240,7 +240,7 @@
disabled={models.length === 1}
class="select-trigger"
>
<span>{model.name}</span>
<span>{model?.name ?? 'No model selected'}</span>
{#if models.length > 1}
<ChevronDown size="12" />
{/if}
@@ -271,7 +271,7 @@
type="submit"
bind:this={submitter}
title="Send ⌘⏎"
disabled={needToLogin}
disabled={!model || needToLogin}
>
Send ⌘⏎
</button>

View File

@@ -14,7 +14,7 @@
onCloseAllTab?: () => void;
onOpenInEditor?: (sql: string) => void;
models: Model[];
selectedModel: Model;
selectedModel?: Model;
onModelChange: (m: Model) => void;
}

View File

@@ -13,14 +13,14 @@ export interface Chat {
export type { Model };
export const ArgnosticModel: Model = {
export const AgnosticModel: Model = {
brand: 'Agnostic',
name: 'Agnostic AI (v0)',
baseURL: 'https://ai.agx.app/'
};
export function isAgnosticModel(m: Model) {
return m.baseURL === ArgnosticModel.baseURL;
return m.baseURL === AgnosticModel.baseURL;
}
export function serializeModel(model: Model) {

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { login } from '$lib/auth';
import { getAppContext } from '$lib/context';
import Sparkles from '$lib/icons/Sparkles.svelte';
const { login } = getAppContext();
</script>
<div class="login-wrapper">

View File

@@ -1,5 +1,13 @@
import { getContext, setContext } from 'svelte';
import type { AppContext } from './types';
import type { ContextMenuState } from './components/ContextMenu';
export type AppContext = {
contextmenu: ContextMenuState;
isAuthenticated: () => boolean;
login(): Promise<void>;
logout(): Promise<void>;
getToken(): Promise<string | undefined>;
};
const key = Symbol('@app/context');

8
src/lib/env/open.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
import { openUrl as openUrlWithBrowser } from '@tauri-apps/plugin-opener';
import { detectRuntime } from './runtime';
export async function openUrl(url: string) {
const runtime = detectRuntime();
if (runtime === 'desktop') await openUrlWithBrowser(url);
else window.location.assign(url);
}

7
src/lib/env/runtime.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
export function detectRuntime() {
if (PLATFORM === 'NATIVE') return 'desktop';
if (window.self !== window.top) return 'embedded';
return 'web';
}
export type Runtime = 'desktop' | 'embedded' | 'web';

6
src/lib/types.d.ts vendored
View File

@@ -1,6 +0,0 @@
import type { ContextMenuState } from './components/ContextMenu';
export type AppContext = {
contextmenu: ContextMenuState;
isAuthenticated(): boolean;
};

View File

@@ -2,13 +2,16 @@
import '$lib/styles/main.css';
import { onMount } from 'svelte';
import { MIGRATIONS } from '$lib/migrations';
import { store } from '$lib/store';
import { MigrationManager } from '@agnosticeng/migrate';
import { checkLoginState, onStateChange } from '$lib/auth';
import { createAuthService, checkLoginState, type AuthService } from '$lib/auth';
import { detectRuntime, type Runtime } from '$lib/env/runtime';
import { ContextMenu, ContextMenuState } from '$lib/components/ContextMenu';
import { setAppContext } from '$lib/context';
import { MIGRATIONS } from '$lib/migrations';
import { EXAMPLES_TABS } from '$lib/onboarding';
let { children } = $props();
@@ -16,7 +19,28 @@
let authenticated = $state(false);
const contextmenu = new ContextMenuState();
setAppContext({ contextmenu, isAuthenticated: () => authenticated });
let authService = $state.raw<AuthService>();
setAppContext({
contextmenu,
isAuthenticated() {
return authenticated;
},
async login() {
await authService?.login?.();
},
async logout() {
await authService?.clearSession();
},
async getToken() {
const session = await authService?.getSession();
return session?.idToken;
}
});
async function displayOnboarding() {
for (const example of EXAMPLES_TABS) {
@@ -27,9 +51,15 @@
}
}
$effect(() => onStateChange((a) => (authenticated = a)));
onMount(async () => {
const runtime = detectRuntime();
authService = createAuthService(runtime);
authService.onStateChange((session) => (authenticated = !!session));
checkLoginState(runtime, authService);
authenticated = !!(await authService.getSession());
const m = new MigrationManager(store);
await m.migrate(MIGRATIONS);
@@ -39,8 +69,6 @@
await displayOnboarding();
}
await checkLoginState();
mounted = true;
});
</script>

View File

@@ -266,7 +266,7 @@
$effect(
() =>
void tabRepository.get().then(([t, active]) => {
if (t.length) (tabs = t), (selectedTabIndex = active);
if (t.length) ((tabs = t), (selectedTabIndex = active));
else tabs.push({ id: crypto.randomUUID(), content: '', name: 'Untitled' });
})
);
@@ -378,7 +378,7 @@ LIMIT 100;`;
$effect(
() =>
void chatsRepository.list().then(([c, active]) => {
if (c.length) (chats = c), (focusedChat = active);
if (c.length) ((chats = c), (focusedChat = active));
})
);

View File

@@ -1,4 +1,5 @@
import { ArgnosticModel, type Model } from '$lib/components/Ai';
import { isAuthEnabled } from '$lib/auth';
import { AgnosticModel, type Model } from '$lib/components/Ai';
import { engine } from '$lib/olap-engine';
import { getModels, isInstalled } from '$lib/ollama';
import type { PageLoad } from './$types';
@@ -7,7 +8,9 @@ export const load = (async () => {
await engine.init();
const isOllamaInstalled = await isInstalled();
const models: Model[] = [ArgnosticModel];
const models: Model[] = [];
if (isAuthEnabled()) models.push(AgnosticModel);
if (isOllamaInstalled) models.push(...(await getModels()));
return { models };

View File

@@ -31,10 +31,8 @@ export default defineConfig(async () => ({
(process.env.CF_PAGES_COMMIT_SHA || process.env.COMMIT_SHA || 'dev').slice(0, 7)
),
OLLAMA_BASE_URL: JSON.stringify(process.env.OLLAMA_BASE_URL || 'http://localhost:11434'),
AUTH0_DOMAIN: JSON.stringify(process.env.AUTH0_DOMAIN || 'agx.eu.auth0.com'),
AUTH0_CLIENT_ID: JSON.stringify(
process.env.AUTH0_CLIENT_ID || '12tfeh61h7wvyLXnJqf4X4YMKUc5j4Yq'
),
AUTH0_DOMAIN: JSON.stringify(process.env.AUTH0_DOMAIN || ''),
AUTH0_CLIENT_ID: JSON.stringify(process.env.AUTH0_CLIENT_ID || ''),
AUTH0_REDIRECT_URI: JSON.stringify(process.env.AUTH0_REDIRECT_URI || '')
}
}));