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
@@ -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') },
};