mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
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:
co-authored by
Anthony Stirling
parent
dd09f7b7cf
commit
18be8f4692
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user