diff --git a/CONDITIONAL_AUTH.md b/CONDITIONAL_AUTH.md new file mode 100644 index 00000000..8b5a3989 --- /dev/null +++ b/CONDITIONAL_AUTH.md @@ -0,0 +1,222 @@ +# Conditional Authentication + +ObjectUI now supports conditional authentication based on server discovery information. This allows the console application to automatically detect whether the backend has authentication enabled and adapt accordingly. + +## Overview + +When the ObjectStack server discovery endpoint (`/.well-known/objectstack` or `/api`) indicates that authentication is disabled (`auth.enabled === false`), the console application will bypass the authentication flow and operate in "Guest Mode". + +This is useful for: +- **Development environments** where authentication is not configured +- **Demo environments** where users should access the system without credentials +- **Embedded scenarios** where authentication is handled externally + +## Implementation + +### 1. Discovery Response Structure + +The server's discovery endpoint should return a response with the following structure: + +```json +{ + "name": "ObjectStack API", + "version": "2.0.0", + "services": { + "auth": { + "enabled": false, + "status": "unavailable", + "message": "Authentication service not installed. Install an auth plugin to enable authentication." + }, + "data": { + "enabled": true, + "status": "available" + }, + "metadata": { + "enabled": true, + "status": "available" + } + } +} +``` + +### 2. Client-Side Detection + +The console application uses the `ConditionalAuthWrapper` component which: + +1. Connects to the server and fetches discovery information +2. Checks the `services.auth.enabled` flag +3. If `false`, wraps the application with `` (Guest Mode) +4. If `true` or undefined, wraps with normal `` (Standard Auth Mode) + +### 3. Guest Mode Behavior + +When authentication is disabled: + +- A virtual "Guest User" is automatically authenticated with: + - ID: `guest` + - Name: `Guest User` + - Email: `guest@local` + - Token: `guest-token` + +- All protected routes become accessible without login +- Login/Register pages are still accessible but not required +- The user menu shows the guest user + +## Usage + +### For Application Developers + +Simply wrap your application with `ConditionalAuthWrapper`: + +```tsx +import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper'; + +function App() { + return ( + + + + {/* Your routes */} + + + + ); +} +``` + +### For Backend Developers + +Ensure your discovery endpoint returns the correct `services.auth.enabled` flag: + +**With Auth Enabled:** +```typescript +{ + services: { + auth: { + enabled: true, + status: "available" + } + } +} +``` + +**With Auth Disabled:** +```typescript +{ + services: { + auth: { + enabled: false, + status: "unavailable", + message: "Auth service not configured" + } + } +} +``` + +### Using the useDiscovery Hook + +Components can also directly access discovery information: + +```tsx +import { useDiscovery } from '@object-ui/react'; + +function MyComponent() { + const { discovery, isLoading, isAuthEnabled } = useDiscovery(); + + if (isLoading) return ; + + return ( +
+ {isAuthEnabled + ? + : + } +
+ ); +} +``` + +## Security Considerations + +⚠️ **Important Security Note:** + +- The default behavior (when discovery fails or auth status is unknown) is to **enable authentication** for security. +- Guest mode should only be used in controlled environments (development, demos). +- In production, always configure proper authentication. + +## API Reference + +### ConditionalAuthWrapper + +**Props:** +- `authUrl` (string): The authentication endpoint URL +- `children` (ReactNode): Application content to wrap + +**Behavior:** +- Fetches discovery on mount +- Shows loading screen while checking auth status +- Wraps children with appropriate AuthProvider configuration + +### AuthProvider + +**New Props:** +- `enabled` (boolean, default: `true`): Whether authentication is enabled + - When `false`: Automatically authenticates as guest user + - When `true`: Normal authentication flow + +### useDiscovery Hook + +**Returns:** +- `discovery` (DiscoveryInfo | null): Raw discovery data from server +- `isLoading` (boolean): Whether discovery is being fetched +- `error` (Error | null): Any error that occurred during fetch +- `isAuthEnabled` (boolean): Convenience flag for `discovery?.services?.auth?.enabled ?? true` + +## Examples + +### Example 1: Development Server Without Auth + +**Discovery Response:** +```json +{ + "name": "Dev Server", + "services": { + "auth": { "enabled": false } + } +} +``` + +**Result:** Console loads without requiring login + +### Example 2: Production Server With Auth + +**Discovery Response:** +```json +{ + "name": "Production Server", + "services": { + "auth": { "enabled": true } + } +} +``` + +**Result:** Console requires user authentication + +### Example 3: Legacy Server (No Discovery) + +**Discovery Response:** 404 or connection error + +**Result:** Defaults to authentication enabled (secure by default) + +## Testing + +Tests are included in: +- `packages/auth/src/__tests__/AuthProvider.disabled.test.tsx` +- Tests verify guest mode behavior +- Tests verify normal auth mode behavior +- Tests verify session token creation in guest mode + +Run tests with: +```bash +pnpm test packages/auth/src/__tests__/AuthProvider.disabled.test.tsx +``` diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 65e78f64..251a8c43 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -6,7 +6,7 @@ import { SchemaRendererProvider } from '@object-ui/react'; import { ObjectStackAdapter } from './dataSource'; import type { ConnectionState } from './dataSource'; import appConfig from '../objectstack.shared'; -import { AuthProvider, AuthGuard, useAuth } from '@object-ui/auth'; +import { AuthGuard, useAuth } from '@object-ui/auth'; // Components import { ConsoleLayout } from './components/ConsoleLayout'; @@ -19,6 +19,7 @@ import { DashboardView } from './components/DashboardView'; import { PageView } from './components/PageView'; import { ReportView } from './components/ReportView'; import { ExpressionProvider } from './context/ExpressionProvider'; +import { ConditionalAuthWrapper } from './components/ConditionalAuthWrapper'; // Auth Pages import { LoginPage } from './pages/LoginPage'; @@ -291,7 +292,7 @@ function RootRedirect() { export function App() { return ( - + } /> @@ -305,7 +306,7 @@ export function App() { } /> - + ); } diff --git a/apps/console/src/components/ConditionalAuthWrapper.tsx b/apps/console/src/components/ConditionalAuthWrapper.tsx new file mode 100644 index 00000000..a6d824f3 --- /dev/null +++ b/apps/console/src/components/ConditionalAuthWrapper.tsx @@ -0,0 +1,94 @@ +/** + * ObjectUI Console - Conditional Auth Wrapper + * + * This component fetches discovery information from the server and conditionally + * enables/disables authentication based on the server's auth service status. + */ + +import { useState, useEffect, ReactNode } from 'react'; +import { ObjectStackAdapter } from './dataSource'; +import { AuthProvider } from '@object-ui/auth'; +import { LoadingScreen } from './components/LoadingScreen'; +import type { DiscoveryInfo } from '@object-ui/react'; + +interface ConditionalAuthWrapperProps { + children: ReactNode; + authUrl: string; +} + +/** + * Wrapper component that conditionally enables authentication based on server discovery. + * + * This component: + * 1. Creates a temporary data source connection + * 2. Fetches discovery information from the server + * 3. Checks if auth.enabled is true in the discovery response + * 4. Conditionally wraps children with AuthProvider if auth is enabled + * 5. Bypasses auth if discovery indicates auth is disabled (development/demo mode) + */ +export function ConditionalAuthWrapper({ children, authUrl }: ConditionalAuthWrapperProps) { + const [authEnabled, setAuthEnabled] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + async function checkAuthStatus() { + try { + // Create a temporary adapter to fetch discovery + // Empty baseUrl allows the adapter to use browser-relative paths + // This works because the console app is served from the same origin as the API + const adapter = new ObjectStackAdapter({ + baseUrl: '', + autoReconnect: false, + }); + + await adapter.connect(); + const discovery = await adapter.getDiscovery() as DiscoveryInfo | null; + + if (!cancelled) { + // Check if auth is enabled in discovery + // Default to true if discovery doesn't provide this information + const isAuthEnabled = discovery?.services?.auth?.enabled ?? true; + setAuthEnabled(isAuthEnabled); + } + } catch (error) { + if (!cancelled) { + // If discovery fails, default to auth enabled for security + console.warn('[ConditionalAuthWrapper] Failed to fetch discovery, defaulting to auth enabled:', error); + setAuthEnabled(true); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + checkAuthStatus(); + + return () => { + cancelled = true; + }; + }, []); + + if (isLoading) { + return ; + } + + // If auth is enabled, wrap with AuthProvider + if (authEnabled) { + return ( + + {children} + + ); + } + + // If auth is disabled, wrap with a disabled AuthProvider (guest mode) + return ( + + {children} + + ); +} diff --git a/packages/auth/src/AuthProvider.tsx b/packages/auth/src/AuthProvider.tsx index b5ec1af3..25aec6d5 100644 --- a/packages/auth/src/AuthProvider.tsx +++ b/packages/auth/src/AuthProvider.tsx @@ -13,6 +13,13 @@ import { createAuthClient } from './createAuthClient'; export interface AuthProviderProps extends AuthProviderConfig { children: React.ReactNode; + /** + * Whether authentication is enabled. + * When false, the provider will skip authentication checks and treat all users as authenticated. + * Useful for development or demo environments where the server doesn't have authentication enabled. + * @default true + */ + enabled?: boolean; } /** @@ -27,11 +34,19 @@ export interface AuthProviderProps extends AuthProviderConfig { * *
* ``` + * + * @example With disabled auth (development mode) + * ```tsx + * + * + * + * ``` */ export function AuthProvider({ authUrl, client: externalClient, onAuthStateChange, + enabled = true, children, }: AuthProviderProps) { const client = useMemo( @@ -44,10 +59,28 @@ export function AuthProvider({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const isAuthenticated = user !== null && session !== null; + // If auth is disabled, automatically set as authenticated with a guest user + const isAuthenticated = enabled + ? user !== null && session !== null + : true; - // Load session on mount + // Load session on mount (only if auth is enabled) useEffect(() => { + if (!enabled) { + // When auth is disabled, set a guest user and mark as loaded + setUser({ + id: 'guest', + email: 'guest@local', + name: 'Guest User', + }); + setSession({ + token: 'guest-token', + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + }); + setIsLoading(false); + return; + } + let cancelled = false; async function loadSession() { @@ -70,7 +103,7 @@ export function AuthProvider({ loadSession(); return () => { cancelled = true; }; - }, [client]); + }, [client, enabled]); // Notify on auth state changes useEffect(() => { diff --git a/packages/auth/src/__tests__/AuthProvider.disabled.test.tsx b/packages/auth/src/__tests__/AuthProvider.disabled.test.tsx new file mode 100644 index 00000000..7a74cc18 --- /dev/null +++ b/packages/auth/src/__tests__/AuthProvider.disabled.test.tsx @@ -0,0 +1,99 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { AuthProvider } from '../AuthProvider'; +import { useAuth } from '../useAuth'; +import { useContext } from 'react'; +import { AuthCtx } from '../AuthContext'; + +// Test component to access auth context +function AuthTestComponent() { + const auth = useAuth(); + return ( +
+
{String(auth.isAuthenticated)}
+
{String(auth.isLoading)}
+
{auth.user?.id || 'null'}
+
{auth.user?.name || 'null'}
+
+ ); +} + +describe('AuthProvider with enabled prop', () => { + it('should bypass authentication when enabled=false', async () => { + render( + + + + ); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByTestId('is-loading').textContent).toBe('false'); + }); + + // Should be authenticated with guest user + expect(screen.getByTestId('is-authenticated').textContent).toBe('true'); + expect(screen.getByTestId('user-id').textContent).toBe('guest'); + expect(screen.getByTestId('user-name').textContent).toBe('Guest User'); + }); + + it('should use normal auth flow when enabled=true (default)', async () => { + // Mock client that returns null session + const mockClient = { + getSession: async () => null, + signIn: async () => ({ user: { id: '1', name: 'Test', email: 'test@example.com' }, session: { token: 'token' } }), + signUp: async () => ({ user: { id: '1', name: 'Test', email: 'test@example.com' }, session: { token: 'token' } }), + signOut: async () => {}, + updateUser: async () => ({ id: '1', name: 'Test', email: 'test@example.com' }), + forgotPassword: async () => {}, + resetPassword: async () => {}, + }; + + render( + + + + ); + + // Wait for loading to complete + await waitFor(() => { + expect(screen.getByTestId('is-loading').textContent).toBe('false'); + }); + + // Should NOT be authenticated (no session) + expect(screen.getByTestId('is-authenticated').textContent).toBe('false'); + expect(screen.getByTestId('user-id').textContent).toBe('null'); + }); + + it('should create a guest session with token when auth is disabled', async () => { + function SessionTestComponent() { + const context = useContext(AuthCtx); + return ( +
+
{context?.session?.token || 'null'}
+
{String(!!context?.session?.expiresAt)}
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId('session-token').textContent).toBe('guest-token'); + }); + + expect(screen.getByTestId('has-expiry').textContent).toBe('true'); + }); +}); diff --git a/packages/data-objectstack/src/index.ts b/packages/data-objectstack/src/index.ts index 0f117af5..0cb4e0a0 100644 --- a/packages/data-objectstack/src/index.ts +++ b/packages/data-objectstack/src/index.ts @@ -584,6 +584,30 @@ export class ObjectStackAdapter implements DataSource { return this.client; } + /** + * Get the discovery information from the connected server. + * Returns the capabilities and service status of the ObjectStack server. + * + * Note: This accesses an internal property of the ObjectStackClient. + * The discovery data is populated during client.connect() and cached. + * + * @returns Promise resolving to discovery data, or null if not connected + */ + async getDiscovery(): Promise { + try { + // Ensure we're connected first + await this.connect(); + + // Access discovery data from the client + // The ObjectStackClient caches discovery during connect() + // This is an internal property, but documented for this use case + // @ts-expect-error - Accessing internal discovery property + return this.client.discovery || null; + } catch { + return null; + } + } + /** * Get a view definition for an object. * Attempts to fetch from the server metadata API. diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 8d4f58d4..41dedf00 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -12,4 +12,5 @@ export * from './useNavigationOverlay'; export * from './usePageVariables'; export * from './useViewData'; export * from './useDynamicApp'; +export * from './useDiscovery'; diff --git a/packages/react/src/hooks/useDiscovery.ts b/packages/react/src/hooks/useDiscovery.ts new file mode 100644 index 00000000..c34d80d9 --- /dev/null +++ b/packages/react/src/hooks/useDiscovery.ts @@ -0,0 +1,141 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useState, useEffect, useContext } from 'react'; +import { SchemaRendererContext } from '../context/SchemaRendererContext'; + +/** + * Discovery service information structure. + * Represents server capabilities and service status. + */ +export interface DiscoveryInfo { + /** Server name and version */ + name?: string; + version?: string; + + /** Service availability status */ + services?: { + /** Authentication service status */ + auth?: { + enabled: boolean; + status?: 'available' | 'unavailable'; + message?: string; + }; + /** Data access service status */ + data?: { + enabled: boolean; + status?: 'available' | 'unavailable'; + }; + /** Metadata service status */ + metadata?: { + enabled: boolean; + status?: 'available' | 'unavailable'; + }; + [key: string]: any; + }; + + /** API capabilities */ + capabilities?: string[]; + + /** Additional discovery metadata */ + [key: string]: any; +} + +/** + * Hook to access discovery information from the ObjectStack server. + * + * This hook retrieves server capabilities and service status, which can be used + * to conditionally enable/disable features based on server configuration. + * + * @example + * ```tsx + * function App() { + * const { discovery, isLoading } = useDiscovery(); + * + * if (isLoading) { + * return ; + * } + * + * // Check if auth is enabled on the server + * const authEnabled = discovery?.services?.auth?.enabled ?? true; + * + * return ( + *
+ * {authEnabled ? ... : } + *
+ * ); + * } + * ``` + * + * @returns Discovery information and loading state + */ +export function useDiscovery() { + const context = useContext(SchemaRendererContext); + const dataSource = context?.dataSource; + const [discovery, setDiscovery] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function fetchDiscovery() { + if (!dataSource) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + + // Check if dataSource has getDiscovery method + if (typeof (dataSource as any).getDiscovery === 'function') { + const discoveryData = await (dataSource as any).getDiscovery(); + + if (!cancelled) { + setDiscovery(discoveryData); + setError(null); + } + } else { + // DataSource doesn't support discovery + if (!cancelled) { + setDiscovery(null); + setError(null); + } + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err : new Error('Failed to fetch discovery')); + setDiscovery(null); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + } + + fetchDiscovery(); + + return () => { + cancelled = true; + }; + }, [dataSource]); + + return { + discovery, + isLoading, + error, + /** + * Check if authentication is enabled on the server. + * Defaults to true if discovery data is not available. + */ + isAuthEnabled: discovery?.services?.auth?.enabled ?? true, + }; +} +