Self-hosted desktop SSO (#5265)

# Description of Changes
Support SSO in self-hosted desktop app.

---------

Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
James Brunton
2026-01-09 18:21:16 +00:00
committed by GitHub
co-authored by Anthony Stirling
parent dd09f7b7cf
commit 18be8f4692
40 changed files with 1877 additions and 135 deletions
@@ -8,6 +8,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
import { connectionModeService } from '@desktop/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import { authService } from '@app/services/authService';
/**
* Desktop application providers
@@ -18,12 +19,26 @@ import { tauriBackendService } from '@app/services/tauriBackendService';
export function AppProviders({ children }: { children: ReactNode }) {
const { isFirstLaunch, setupComplete } = useFirstLaunchCheck();
const [connectionMode, setConnectionMode] = useState<'saas' | 'selfhosted' | null>(null);
const [authChecked, setAuthChecked] = useState(false);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Load connection mode on mount
useEffect(() => {
void connectionModeService.getCurrentMode().then(setConnectionMode);
}, []);
useEffect(() => {
if (!isFirstLaunch && setupComplete) {
authService.isAuthenticated()
.then(setIsAuthenticated)
.catch(() => setIsAuthenticated(false))
.finally(() => setAuthChecked(true));
} else if (isFirstLaunch && !setupComplete) {
setAuthChecked(true);
setIsAuthenticated(false);
}
}, [isFirstLaunch, setupComplete]);
// Initialize backend health monitoring for self-hosted mode
useEffect(() => {
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
@@ -59,6 +74,29 @@ export function AppProviders({ children }: { children: ReactNode }) {
);
}
// Show setup wizard when not authenticated (desktop login flow).
if (authChecked && !isAuthenticated) {
return (
<ProprietaryAppProviders
appConfigRetryOptions={{
maxRetries: 5,
initialDelay: 1000,
}}
appConfigProviderProps={{
initialConfig: DESKTOP_DEFAULT_APP_CONFIG,
bootstrapMode: 'non-blocking',
autoFetch: false,
}}
>
<SetupWizard
onComplete={() => {
window.location.reload();
}}
/>
</ProprietaryAppProviders>
);
}
// Normal app flow
return (
<ProprietaryAppProviders
@@ -3,16 +3,25 @@ import { useTranslation } from 'react-i18next';
import { authService, UserInfo } from '@app/services/authService';
import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml';
import { BASE_PATH } from '@app/constants/app';
import { STIRLING_SAAS_URL } from '@desktop/constants/connection';
import '@app/routes/authShared/auth.css';
export type OAuthProvider = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
type KnownProviderId = 'google' | 'github' | 'keycloak' | 'azure' | 'apple' | 'oidc';
export type OAuthProviderId = KnownProviderId | string;
export interface DesktopSSOProvider {
id: OAuthProviderId;
path?: string;
label?: string;
}
interface DesktopOAuthButtonsProps {
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
onError: (error: string) => void;
isDisabled: boolean;
serverUrl: string;
providers: OAuthProvider[];
providers: DesktopSSOProvider[];
mode?: 'saas' | 'selfHosted';
}
export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
@@ -21,11 +30,12 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
isDisabled,
serverUrl,
providers,
mode = 'saas',
}) => {
const { t } = useTranslation();
const [oauthLoading, setOauthLoading] = useState(false);
const handleOAuthLogin = async (provider: OAuthProvider) => {
const handleOAuthLogin = async (provider: DesktopSSOProvider) => {
// Prevent concurrent OAuth attempts
if (oauthLoading || isDisabled) {
return;
@@ -48,7 +58,12 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
errorPlaceholder: true, // {error} will be replaced by Rust
});
const userInfo = await authService.loginWithOAuth(provider, serverUrl, successHtml, errorHtml);
const normalizedServer = serverUrl.replace(/\/+$/, '');
const usingSupabaseFlow =
mode === 'saas' || normalizedServer === STIRLING_SAAS_URL.replace(/\/+$/, '');
const userInfo = usingSupabaseFlow
? await authService.loginWithOAuth(provider.id, serverUrl, successHtml, errorHtml)
: await authService.loginWithSelfHostedOAuth(provider.path || provider.id, serverUrl);
// Call the onOAuthSuccess callback to complete setup
await onOAuthSuccess(userInfo);
@@ -64,7 +79,7 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
}
};
const providerConfig: Record<OAuthProvider, { label: string; file: string }> = {
const providerConfig: Record<KnownProviderId, { label: string; file: string }> = {
google: { label: 'Google', file: 'google.svg' },
github: { label: 'GitHub', file: 'github.svg' },
keycloak: { label: 'Keycloak', file: 'keycloak.svg' },
@@ -72,6 +87,9 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
apple: { label: 'Apple', file: 'apple.svg' },
oidc: { label: 'OpenID', file: 'oidc.svg' },
};
const isKnownProvider = (id: OAuthProviderId): id is KnownProviderId =>
(id as KnownProviderId) in providerConfig;
const GENERIC_PROVIDER_ICON = 'oidc.svg';
if (providers.length === 0) {
return null;
@@ -80,23 +98,31 @@ export const DesktopOAuthButtons: React.FC<DesktopOAuthButtonsProps> = ({
return (
<div className="oauth-container-vertical">
{providers
.filter((providerId) => providerId in providerConfig)
.map((providerId) => {
const provider = providerConfig[providerId];
.filter((providerConfigEntry) => providerConfigEntry && providerConfigEntry.id)
.map((providerEntry) => {
const iconConfig = isKnownProvider(providerEntry.id)
? providerConfig[providerEntry.id]
: undefined;
const label =
providerEntry.label ||
iconConfig?.label ||
(providerEntry.id
? providerEntry.id.charAt(0).toUpperCase() + providerEntry.id.slice(1)
: t('setup.login.sso', 'Single Sign-On'));
return (
<button
key={providerId}
onClick={() => handleOAuthLogin(providerId)}
key={providerEntry.id}
onClick={() => handleOAuthLogin(providerEntry)}
disabled={isDisabled || oauthLoading}
className="oauth-button-vertical"
title={provider.label}
title={label}
>
<img
src={`${BASE_PATH}/Login/${provider.file}`}
alt={provider.label}
src={`${BASE_PATH}/Login/${iconConfig?.file || GENERIC_PROVIDER_ICON}`}
alt={label}
className="oauth-icon-tiny"
/>
{provider.label}
{label}
</button>
);
})}
@@ -66,7 +66,11 @@ export const SaaSLoginScreen: React.FC<SaaSLoginScreenProps> = ({
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
providers={['google', 'github']}
mode="saas"
providers={[
{ id: 'google' },
{ id: 'github' },
]}
/>
<DividerWithText
@@ -5,13 +5,14 @@ import LoginHeader from '@app/routes/login/LoginHeader';
import ErrorMessage from '@app/routes/login/ErrorMessage';
import EmailPasswordForm from '@app/routes/login/EmailPasswordForm';
import DividerWithText from '@app/components/shared/DividerWithText';
import { DesktopOAuthButtons, OAuthProvider } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { DesktopOAuthButtons } from '@app/components/SetupWizard/DesktopOAuthButtons';
import { UserInfo } from '@app/services/authService';
import { SSOProviderConfig } from '@app/services/connectionModeService';
import '@app/routes/authShared/auth.css';
interface SelfHostedLoginScreenProps {
serverUrl: string;
enabledOAuthProviders?: string[];
enabledOAuthProviders?: SSOProviderConfig[];
onLogin: (username: string, password: string) => Promise<void>;
onOAuthSuccess: (userInfo: UserInfo) => Promise<void>;
loading: boolean;
@@ -74,7 +75,8 @@ export const SelfHostedLoginScreen: React.FC<SelfHostedLoginScreenProps> = ({
onError={handleOAuthError}
isDisabled={loading}
serverUrl={serverUrl}
providers={enabledOAuthProviders as OAuthProvider[]}
mode="selfHosted"
providers={enabledOAuthProviders}
/>
<DividerWithText
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Stack, Button, TextInput, Alert, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { ServerConfig } from '@app/services/connectionModeService';
import { ServerConfig, SSOProviderConfig } from '@app/services/connectionModeService';
import { connectionModeService } from '@app/services/connectionModeService';
import LocalIcon from '@app/components/shared/LocalIcon';
@@ -43,7 +43,7 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
}
// Fetch OAuth providers and check if login is enabled
let enabledProviders: string[] = [];
const enabledProviders: SSOProviderConfig[] = [];
try {
const response = await fetch(`${url}/api/v1/proprietary/ui-data/login`);
@@ -76,9 +76,19 @@ export const ServerSelection: React.FC<ServerSelectionProps> = ({ onSelect, load
// Extract provider IDs from authorization URLs
// Example: "/oauth2/authorization/google" → "google"
enabledProviders = Object.keys(data.providerList || {})
.map(key => key.split('/').pop())
.filter((id): id is string => id !== undefined);
const providerEntries = Object.entries(data.providerList || {});
providerEntries.forEach(([path, label]) => {
const id = path.split('/').pop();
if (!id) {
return;
}
enabledProviders.push({
id,
path,
label: typeof label === 'string' ? label : undefined,
});
});
console.log('[ServerSelection] Detected OAuth providers:', enabledProviders);
} catch (err) {
@@ -155,6 +155,28 @@ export const SetupWizard: React.FC<SetupWizardProps> = ({ onComplete }) => {
const params = new URLSearchParams(hash);
const accessToken = params.get('access_token');
const type = params.get('type') || parsed.searchParams.get('type');
const accessTokenFromHash = params.get('access_token');
const accessTokenFromQuery = parsed.searchParams.get('access_token');
const serverFromQuery = parsed.searchParams.get('server');
// Handle self-hosted SSO deep link
if (type === 'sso' || type === 'sso-selfhosted') {
const token = accessTokenFromHash || accessTokenFromQuery;
const serverUrl = serverFromQuery || serverConfig?.url || STIRLING_SAAS_URL;
if (!token || !serverUrl) {
console.error('[SetupWizard] Deep link missing token or server for SSO completion');
return;
}
setLoading(true);
setError(null);
await authService.completeSelfHostedSession(serverUrl, token);
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
onComplete();
return;
}
if (!type || (type !== 'signup' && type !== 'recovery' && type !== 'magiclink')) {
return;
@@ -0,0 +1,31 @@
import { connectionModeService } from '@app/services/connectionModeService';
import { STIRLING_SAAS_URL } from '@app/constants/connection';
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Desktop-specific logout: mirrors Connection Settings flow to avoid stale state.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
try {
await signOut();
await connectionModeService.switchToSaaS(STIRLING_SAAS_URL);
await connectionModeService.resetSetupCompletion().catch(() => {});
window.history.replaceState({}, '', '/');
window.location.reload();
return;
} catch (err) {
console.warn('[Desktop AccountLogout] Desktop-specific logout failed, falling back to redirect', err);
}
redirectToLogin();
};
}
@@ -0,0 +1,25 @@
/**
* Desktop-specific OAuth callback handling for self-hosted SSO flows.
*/
export async function handleAuthCallbackSuccess(token: string): Promise<void> {
// Notify desktop popup listeners (self-hosted SSO flow)
const isDesktopPopup = typeof window !== 'undefined' && window.opener && window.name === 'stirling-desktop-sso';
if (isDesktopPopup) {
try {
window.opener.postMessage({ type: 'stirling-desktop-sso', token }, '*');
} catch (postError) {
console.error('[AuthCallback] Failed to notify desktop window:', postError);
}
// Give the message a moment to flush before attempting to close
setTimeout(() => {
try {
window.close();
} catch {
// ignore close errors
}
}, 150);
}
// No-op beyond popup notification; deep link flow handles desktop completion.
}
@@ -0,0 +1,20 @@
import { authService } from '@app/services/authService';
/**
* Desktop-specific auth cleanup hooks.
*/
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
try {
await authService.localClearAuth();
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data after sign out', err);
}
}
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
try {
await authService.localClearAuth();
} catch (err) {
console.warn('[AuthCleanup] Failed to clear desktop auth data on login init', err);
}
}
@@ -0,0 +1,23 @@
import { authService } from '@app/services/authService';
import { connectionModeService } from '@app/services/connectionModeService';
/**
* Desktop-specific OAuth navigation: prefer popup/system browser, avoid hijacking main webview.
*/
export async function startOAuthNavigation(redirectUrl: string): Promise<boolean> {
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
if (!serverUrl) {
return false;
}
const providerUrl = new URL(redirectUrl, serverUrl);
const providerPath = `${providerUrl.pathname}${providerUrl.search}`;
await authService.loginWithSelfHostedOAuth(providerPath, serverUrl);
return true;
} catch (error) {
console.warn('[Desktop OAuthNavigation] Failed to start OAuth flow', error);
return false;
}
}
+260 -3
View File
@@ -1,4 +1,8 @@
import { invoke } from '@tauri-apps/api/core';
import { invoke, isTauri } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';
import { open as shellOpen } from '@tauri-apps/plugin-shell';
import { connectionModeService } from '@app/services/connectionModeService';
import { tauriBackendService } from '@app/services/tauriBackendService';
import axios from 'axios';
import { DESKTOP_DEEP_LINK_CALLBACK, STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection';
@@ -108,8 +112,34 @@ export class AuthService {
this.cachedToken = null;
console.log('[Desktop AuthService] Cache invalidated');
await invoke('clear_auth_token');
localStorage.removeItem('stirling_jwt');
// Best effort: clear Tauri keyring
try {
await invoke('clear_auth_token');
console.log('[Desktop AuthService] Cleared Tauri keyring token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear Tauri keyring token', error);
}
// Best effort: clear web storage
try {
localStorage.removeItem('stirling_jwt');
console.log('[Desktop AuthService] Cleared localStorage token');
} catch (error) {
console.warn('[Desktop AuthService] Failed to clear localStorage token', error);
}
}
/**
* Local clear only (no backend calls) to reset auth state in desktop contexts
*/
async localClearAuth(): Promise<void> {
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info', err);
}
this.setAuthStatus('unauthenticated', null);
}
subscribeToAuth(listener: (status: AuthStatus, userInfo: UserInfo | null) => void): () => void {
@@ -253,6 +283,41 @@ export class AuthService {
try {
console.log('Logging out');
// Best-effort backend logout so any server-side session/cookies are cleared
try {
const currentConfig = await connectionModeService.getCurrentConfig().catch(() => null);
const serverUrl = currentConfig?.server_config?.url;
const token = await this.getAuthToken();
if (serverUrl && token) {
const base = serverUrl.replace(/\/+$/, '');
const headers: Record<string, string> = { Authorization: `Bearer ${token}` };
// Treat 401/403 as benign (session already expired)
const safePost = async (url: string) => {
try {
const resp = await axios.post(url, null, {
headers,
withCredentials: true,
validateStatus: () => true, // handle status manually
});
if (resp.status >= 400 && ![401, 403].includes(resp.status)) {
console.warn(`[Desktop AuthService] Logout call to ${url} failed: ${resp.status}`);
}
} catch (err) {
console.warn(`[Desktop AuthService] Backend logout failed via ${url}`, err);
}
};
await safePost(`${base}/api/v1/auth/logout`);
// Also attempt framework logout endpoint to clear cookies/sessions
await safePost(`${base}/logout`);
}
} catch (err) {
console.warn('[Desktop AuthService] Failed to call backend logout endpoint', err);
}
// Clear token from all storage locations
await this.clearTokenEverywhere();
@@ -367,6 +432,21 @@ export class AuthService {
async initializeAuthState(): Promise<void> {
console.log('[Desktop AuthService] Initializing auth state...');
// If we are on the login/setup screen, don't auto-restore a previous session; clear instead
const path = typeof window !== 'undefined' ? window.location.pathname : '';
if (path.startsWith('/login') || path.startsWith('/setup')) {
console.log('[Desktop AuthService] On login/setup path, clearing any cached auth');
// Local clear only; avoid backend logout to prevent noisy errors when already unauthenticated
await this.clearTokenEverywhere().catch(() => {});
try {
await invoke('clear_user_info');
} catch (err) {
console.warn('[Desktop AuthService] Failed to clear user info on login/setup init', err);
}
this.setAuthStatus('unauthenticated', null);
return;
}
const token = await this.getAuthToken();
const userInfo = await this.getUserInfo();
@@ -382,6 +462,9 @@ export class AuthService {
console.log('[Desktop AuthService] No token or user info found');
this.setAuthStatus('unauthenticated', null);
console.log('[Desktop AuthService] Auth state initialized as unauthenticated');
// Defensive: ensure any partial tokens are purged to prevent auto-login loops
await this.clearTokenEverywhere().catch(() => {});
}
}
@@ -436,6 +519,180 @@ export class AuthService {
}
}
/**
* Self-hosted SSO/OAuth2 flow for the desktop app.
1 * Opens the system browser and waits for a deep link callback with the JWT.
*/
async loginWithSelfHostedOAuth(providerPath: string, serverUrl: string): Promise<UserInfo> {
// Generate and store nonce for CSRF protection
const nonce = crypto.randomUUID();
sessionStorage.setItem('oauth_nonce', nonce);
console.log('[Desktop AuthService] Generated OAuth nonce for CSRF protection');
const trimmedServer = serverUrl.replace(/\/+$/, '');
const fullUrl = providerPath.startsWith('http')
? providerPath
: `${trimmedServer}${providerPath.startsWith('/') ? providerPath : `/${providerPath}`}`;
let authUrl = fullUrl;
try {
const parsed = new URL(fullUrl);
parsed.searchParams.set('tauri', '1');
parsed.searchParams.set('nonce', nonce);
authUrl = parsed.toString();
} catch {
// ignore URL parsing failures
}
// Open in system browser and wait for deep link callback
if (await this.openInSystemBrowser(authUrl)) {
return this.waitForDeepLinkCompletion(trimmedServer);
}
throw new Error('Unable to open system browser for SSO. Please check your system settings.');
}
/**
* Wait for a deep-link event to complete self-hosted SSO after system browser OAuth
*/
private async waitForDeepLinkCompletion(serverUrl: string): Promise<UserInfo> {
if (!isTauri()) {
throw new Error('Deep link authentication is only supported in Tauri desktop app.');
}
return new Promise<UserInfo>((resolve, reject) => {
let completed = false;
let unlisten: (() => void) | null = null;
const timeoutId = window.setTimeout(() => {
if (!completed) {
completed = true;
if (unlisten) unlisten();
sessionStorage.removeItem('oauth_nonce');
reject(new Error('SSO login timed out. Please try again.'));
}
}, 120_000);
listen<string>('deep-link', async (event) => {
const url = event.payload;
if (!url || completed) return;
try {
const parsed = new URL(url);
const hash = parsed.hash.replace(/^#/, '');
const params = new URLSearchParams(hash);
const type = params.get('type') || parsed.searchParams.get('type');
const error = params.get('error') || parsed.searchParams.get('error');
if (type === 'sso-error' || error) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
reject(new Error(error || 'Authentication was not successful.'));
return;
}
if (type !== 'sso' && type !== 'sso-selfhosted') {
return;
}
const token = params.get('access_token') || parsed.searchParams.get('access_token');
if (!token) {
return;
}
// CSRF Protection: Validate nonce before accepting token
const nonceFromUrl = params.get('nonce') || parsed.searchParams.get('nonce');
const storedNonce = sessionStorage.getItem('oauth_nonce');
if (!nonceFromUrl || !storedNonce || nonceFromUrl !== storedNonce) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
console.error('[Desktop AuthService] Nonce validation failed - potential CSRF attack');
reject(new Error('Invalid authentication state. Nonce validation failed.'));
return;
}
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
console.log('[Desktop AuthService] Nonce validated successfully');
const userInfo = await this.completeSelfHostedSession(serverUrl, token);
// Ensure connection mode is set and backend is ready (in case caller doesn't)
try {
await connectionModeService.switchToSelfHosted({ url: serverUrl });
await tauriBackendService.initializeExternalBackend();
} catch (e) {
console.warn('[Desktop AuthService] Failed to initialize backend after deep link:', e);
}
resolve(userInfo);
} catch (err) {
completed = true;
if (unlisten) unlisten();
clearTimeout(timeoutId);
sessionStorage.removeItem('oauth_nonce');
reject(err instanceof Error ? err : new Error('Failed to complete SSO'));
}
}).then((fn) => {
unlisten = fn;
});
});
}
private async openInSystemBrowser(url: string): Promise<boolean> {
if (!isTauri()) {
return false;
}
try {
// Prefer plugin-shell (2.x) if available
await shellOpen(url);
return true;
} catch (err) {
console.error('Failed to open system browser for SSO:', err);
return false;
}
}
/**
* Save JWT + user info for self-hosted SSO logins
*/
async completeSelfHostedSession(serverUrl: string, token: string): Promise<UserInfo> {
const userInfo = await this.fetchSelfHostedUserInfo(serverUrl, token);
await this.saveTokenEverywhere(token);
await invoke('save_user_info', {
username: userInfo.username,
email: userInfo.email || null,
});
this.setAuthStatus('authenticated', userInfo);
return userInfo;
}
private async fetchSelfHostedUserInfo(serverUrl: string, token: string): Promise<UserInfo> {
try {
const response = await axios.get(
`${serverUrl.replace(/\/+$/, '')}/api/v1/auth/me`,
{
headers: {
Authorization: `Bearer ${token}`,
},
}
);
const data = response.data;
const user = data.user || data;
return {
username: user.username || user.email || 'User',
email: user.email || undefined,
};
} catch (error) {
console.error('[Desktop AuthService] Failed to fetch user info after SSO:', error);
throw error;
}
}
/**
* Fetch user info from Supabase using access token
*/
@@ -3,9 +3,15 @@ import { fetch } from '@tauri-apps/plugin-http';
export type ConnectionMode = 'saas' | 'selfhosted';
export interface SSOProviderConfig {
id: string;
path: string;
label?: string;
}
export interface ServerConfig {
url: string;
enabledOAuthProviders?: string[];
enabledOAuthProviders?: SSOProviderConfig[];
}
export interface ConnectionConfig {
@@ -1,5 +1,6 @@
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { springAuth } from '@app/auth/springAuthClient';
import { clearPlatformAuthOnLoginInit } from '@app/extensions/authSessionCleanup';
import type { Session, User, AuthError, AuthChangeEvent } from '@app/auth/springAuthClient';
/**
@@ -79,6 +80,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
console.debug('[Auth] Signed out successfully');
setSession(null);
}
} catch (err) {
console.error('[Auth] Unexpected error during sign out:', err);
setError(err as AuthError);
@@ -94,6 +96,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const initializeAuth = async () => {
try {
console.debug('[Auth] Initializing auth...');
// Clear any platform-specific cached auth on login page init.
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/login')) {
await clearPlatformAuthOnLoginInit();
}
// Skip config check entirely - let the app handle login state
// The config will be fetched by useAppConfig when needed
@@ -0,0 +1,34 @@
/**
* Helper utilities for clearing cached OAuth redirect/session state
*/
const OAUTH_REDIRECT_COOKIE = 'stirling_redirect_path';
/**
* Clear any persisted OAuth redirect path/cached state so the app
* does not automatically resume a previous OAuth session after logout.
*/
export function resetOAuthState(): void {
try {
// Remove redirect cookie
if (typeof document !== 'undefined') {
document.cookie = `${OAUTH_REDIRECT_COOKIE}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;SameSite=Lax`;
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear redirect cookie', err);
}
// Remove any related localStorage entries we might have used
try {
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.removeItem(OAUTH_REDIRECT_COOKIE);
window.localStorage.removeItem('oauth_redirect_path');
}
} catch (err) {
console.warn('[OAuthStorage] Failed to clear OAuth localStorage', err);
}
}
export default {
resetOAuthState,
};
@@ -1,10 +1,14 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { springAuth } from '@app/auth/springAuthClient';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
// Mock apiClient
vi.mock('@app/services/apiClient');
vi.mock('@app/extensions/oauthNavigation', () => ({
startOAuthNavigation: vi.fn().mockResolvedValue(false),
}));
describe('SpringAuthClient', () => {
beforeEach(() => {
@@ -342,13 +346,35 @@ describe('SpringAuthClient', () => {
writable: true,
});
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(false);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(mockAssign).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(result.error).toBeNull();
});
it('should skip redirect when handled by extension', async () => {
const mockAssign = vi.fn();
Object.defineProperty(window, 'location', {
value: { assign: mockAssign },
writable: true,
});
vi.mocked(startOAuthNavigation).mockResolvedValueOnce(true);
const result = await springAuth.signInWithOAuth({
provider: '/oauth2/authorization/github',
options: { redirectTo: '/auth/callback' },
});
expect(startOAuthNavigation).toHaveBeenCalledWith('/oauth2/authorization/github');
expect(mockAssign).not.toHaveBeenCalled();
expect(result.error).toBeNull();
});
});
});
@@ -11,6 +11,9 @@ import apiClient from '@app/services/apiClient';
import { AxiosError } from 'axios';
import { BASE_PATH } from '@app/constants/app';
import { type OAuthProvider } from '@app/auth/oauthTypes';
import { resetOAuthState } from '@app/auth/oauthStorage';
import { clearPlatformAuthAfterSignOut } from '@app/extensions/authSessionCleanup';
import { startOAuthNavigation } from '@app/extensions/oauthNavigation';
// Helper to extract error message from axios error
function getErrorMessage(error: unknown, fallback: string): string {
@@ -267,6 +270,10 @@ class SpringAuthClient {
// Use the full path provided by the backend
// This supports both OAuth2 (/oauth2/authorization/...) and SAML2 (/saml2/authenticate/...)
const redirectUrl = params.provider;
const handled = await startOAuthNavigation(redirectUrl);
if (handled) {
return { error: null };
}
// console.log('[SpringAuth] Redirecting to SSO:', redirectUrl);
// Use window.location.assign for full page navigation
window.location.assign(redirectUrl);
@@ -296,6 +303,35 @@ class SpringAuthClient {
// Clean up local storage
localStorage.removeItem('stirling_jwt');
try {
Object.keys(localStorage)
.filter((key) => key.startsWith('sb-') || key.includes('supabase'))
.forEach((key) => localStorage.removeItem(key));
// Clear any cached OAuth redirect/session state
resetOAuthState();
} catch (err) {
console.warn('[SpringAuth] Failed to clear Supabase/local auth tokens', err);
}
// Clear cookies that might hold refresh/session tokens
try {
document.cookie.split(';').forEach(cookie => {
const eqPos = cookie.indexOf('=');
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
if (name) {
document.cookie = `${name}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;`;
}
});
} catch (err) {
console.warn('[SpringAuth] Failed to clear cookies on sign out', err);
}
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn('[SpringAuth] Failed to run platform auth cleanup', cleanupError);
}
// Notify listeners
this.notifyListeners('SIGNED_OUT', null);
@@ -305,6 +341,11 @@ class SpringAuthClient {
console.error('[SpringAuth] signOut error:', error);
// Still remove token even if backend call fails
localStorage.removeItem('stirling_jwt');
try {
await clearPlatformAuthAfterSignOut();
} catch (cleanupError) {
console.warn('[SpringAuth] Failed to run platform auth cleanup after error', cleanupError);
}
return {
error: { message: getErrorMessage(error, 'Logout failed') },
};
@@ -6,10 +6,12 @@ import { alert as showToast } from '@app/components/toast';
import { useAuth } from '@app/auth/UseSession';
import { accountService } from '@app/services/accountService';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { useAccountLogout } from '@app/extensions/accountLogout';
const AccountSection: React.FC = () => {
const { t } = useTranslation();
const { user, signOut } = useAuth();
const accountLogout = useAccountLogout();
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
const [usernameModalOpen, setUsernameModalOpen] = useState(false);
@@ -42,12 +44,8 @@ const AccountSection: React.FC = () => {
}, []);
const handleLogout = useCallback(async () => {
try {
await signOut();
} finally {
redirectToLogin();
}
}, [redirectToLogin, signOut]);
await accountLogout({ signOut, redirectToLogin });
}, [accountLogout, redirectToLogin, signOut]);
const handlePasswordSubmit = async (event: React.FormEvent) => {
event.preventDefault();
@@ -0,0 +1,20 @@
type SignOutFn = () => Promise<void>;
interface AccountLogoutDeps {
signOut: SignOutFn;
redirectToLogin: () => void;
}
/**
* Default (web/proprietary) logout handler: sign out and redirect to /login.
* Desktop builds override this file via path resolution.
*/
export function useAccountLogout() {
return async ({ signOut, redirectToLogin }: AccountLogoutDeps): Promise<void> => {
try {
await signOut();
} finally {
redirectToLogin();
}
};
}
@@ -0,0 +1,7 @@
/**
* Extension hook for platform-specific OAuth callback handling.
* Proprietary/web builds are no-op.
*/
export async function handleAuthCallbackSuccess(_token: string): Promise<void> {
// no-op for web builds
}
@@ -0,0 +1,11 @@
/**
* Extension hooks for platform-specific auth cleanup.
* Proprietary/web builds are no-op.
*/
export async function clearPlatformAuthAfterSignOut(): Promise<void> {
// no-op for web builds
}
export async function clearPlatformAuthOnLoginInit(): Promise<void> {
// no-op for web builds
}
@@ -0,0 +1,7 @@
/**
* Extension hook for platform-specific OAuth navigation.
* Proprietary/web builds default to in-window navigation.
*/
export async function startOAuthNavigation(_redirectUrl: string): Promise<boolean> {
return false;
}
@@ -0,0 +1,129 @@
.page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 50px 20px;
background: #f5f5f5;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.card {
background: #ffffff;
border-radius: 12px;
padding: 40px;
max-width: 420px;
width: 100%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
text-align: center;
color: #1a1a1a;
border: 1px solid #e5e7eb;
}
.icon {
font-size: 48px;
margin-bottom: 16px;
}
.iconSuccess {
color: #2e7d32;
}
.iconError {
color: #d32f2f;
}
.iconNeutral {
color: #4b5563;
}
.title {
font-size: 24px;
font-weight: 600;
margin-bottom: 12px;
color: #1a1a1a;
}
.message {
color: #666;
line-height: 1.6;
font-size: 15px;
}
.loadingExtra {
color: #6b7280;
margin-top: 12px;
font-size: 14px;
}
.errorBox {
margin-top: 20px;
background: #ffebee;
border: 1px solid #ffcdd2;
border-radius: 8px;
padding: 16px;
color: #c62828;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
text-align: left;
}
@media (prefers-color-scheme: dark) {
.page {
background: #1a1a1a;
color: #e0e0e0;
}
.card {
background: #2d2d2d;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
color: #e5e7eb;
border-color: #374151;
}
.iconSuccess {
color: #66bb6a;
}
.iconError {
color: #ef5350;
}
.iconNeutral {
color: #9ca3af;
}
.title {
color: #f5f5f5;
}
.message,
.loadingExtra {
color: #b0b0b0;
}
.errorBox {
background: #3d2020;
border: 1px solid #5d3030;
color: #ef9a9a;
}
}
@media (max-width: 480px) {
.page {
padding: 20px 16px;
}
.card {
padding: 32px 24px;
}
.title {
font-size: 20px;
}
.icon {
font-size: 40px;
}
}
@@ -172,6 +172,6 @@ describe('AuthCallback', () => {
</BrowserRouter>
);
expect(getByText('Completing authentication...')).toBeInTheDocument();
expect(getByText('Completing authentication')).toBeInTheDocument();
});
});
@@ -1,6 +1,8 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { springAuth } from '@app/auth/springAuthClient';
import { handleAuthCallbackSuccess } from '@app/extensions/authCallback';
import styles from '@app/routes/AuthCallback.module.css';
/**
* OAuth Callback Handler
@@ -52,6 +54,8 @@ export default function AuthCallback() {
return;
}
await handleAuthCallbackSuccess(token);
console.log('[AuthCallback] Token validated, redirecting to home');
// Clear the hash from URL and redirect to home page
@@ -69,17 +73,12 @@ export default function AuthCallback() {
}, [navigate]);
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh'
}}>
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-3"></div>
<div className="text-gray-600">
Completing authentication...
</div>
<div className={styles.page}>
<div className={styles.card}>
<div className={`${styles.icon} ${styles.iconNeutral}`}>...</div>
<div className={styles.title}>Completing authentication</div>
<div className={styles.message}>Please wait while we finish signing you in.</div>
<div className={styles.loadingExtra}>You can close this window once it completes.</div>
</div>
</div>
);