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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user