Skip to content
31 changes: 26 additions & 5 deletions apps/iframe-app/src/components/IframeWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,31 @@ export function IframeWrapper({ config }: IframeWrapperProps) {
}

try {
const { isAuthenticated, username } = await checkAuthStatus(baseUrl);
setNeedsLogin(!isAuthenticated);
if (isAuthenticated && username) {
setUserName(username);
const { isAuthenticated, isEasyAuthConfigured, username } = await checkAuthStatus(baseUrl);

// Easy Auth is NOT configured (404 from /.auth/me)
// Let it fail naturally - the API calls will fail without apiKey
if (!isEasyAuthConfigured) {
setNeedsLogin(false);
return;
}

// Easy Auth IS configured
// Check if identity providers are configured
const hasIdentityProviders = props.identityProviders && Object.keys(props.identityProviders).length > 0;

if (!hasIdentityProviders) {
// No identity providers configured - skip login, show chat directly
setNeedsLogin(false);
} else if (isAuthenticated) {
// User is already authenticated
setNeedsLogin(false);
if (username) {
setUserName(username);
}
} else {
// Identity providers configured but user not authenticated - show login
setNeedsLogin(true);
}
} catch (error) {
console.error('[Auth] Failed to check authentication status:', error);
Expand All @@ -109,7 +130,7 @@ export function IframeWrapper({ config }: IframeWrapperProps) {
};

checkAuth();
}, [baseUrl, inPortal, apiKey]);
}, [baseUrl, inPortal, apiKey, props.identityProviders]);

// Frame Blade integration
const { isReady: isFrameBladeReady } = useFrameBlade({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock('../MultiSessionChat/MultiSessionChat', () => ({
vi.mock('../../lib/authHandler', () => ({
createUnauthorizedHandler: vi.fn(() => vi.fn()),
getBaseUrl: vi.fn((agentCard) => `https://base.url.from/${agentCard}`),
checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, error: null })),
checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, isEasyAuthConfigured: true, error: null })),
openLoginPopup: vi.fn(),
}));

Expand Down Expand Up @@ -67,7 +67,7 @@ describe('IframeWrapper - contextId support', () => {
vi.clearAllMocks();

// Reset checkAuthStatus to return authenticated
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, error: null });
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, isEasyAuthConfigured: true, error: null });
});

afterEach(() => {
Expand Down
68 changes: 54 additions & 14 deletions apps/iframe-app/src/components/__tests__/IframeWrapper.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ vi.mock('../MultiSessionChat/MultiSessionChat', () => ({
vi.mock('../../lib/authHandler', () => ({
createUnauthorizedHandler: vi.fn(() => vi.fn()),
getBaseUrl: vi.fn((agentCard) => `https://base.url.from/${agentCard}`),
checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, error: null })),
checkAuthStatus: vi.fn(() => Promise.resolve({ isAuthenticated: true, isEasyAuthConfigured: true, error: null })),
openLoginPopup: vi.fn(),
}));

Expand Down Expand Up @@ -66,8 +66,8 @@ describe('IframeWrapper', () => {
// Reset mocks - this clears call history
vi.clearAllMocks();

// Reset checkAuthStatus to default (authenticated)
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, error: null });
// Reset checkAuthStatus to default (authenticated with Easy Auth)
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: true, isEasyAuthConfigured: true, error: null });

// Clear localStorage
localStorage.clear();
Expand Down Expand Up @@ -343,8 +343,8 @@ describe('IframeWrapper', () => {
describe('Authentication', () => {
it('should show loading state during authentication check', async () => {
// Create a promise that we can control
let resolveAuth: (value: { isAuthenticated: boolean; error: null }) => void;
const authPromise = new Promise<{ isAuthenticated: boolean; error: null }>((resolve) => {
let resolveAuth: (value: { isAuthenticated: boolean; isEasyAuthConfigured: boolean; error: null }) => void;
const authPromise = new Promise<{ isAuthenticated: boolean; isEasyAuthConfigured: boolean; error: null }>((resolve) => {
resolveAuth = resolve;
});

Expand All @@ -358,15 +358,15 @@ describe('IframeWrapper', () => {

// Resolve auth check
await act(async () => {
resolveAuth!({ isAuthenticated: true, error: null });
resolveAuth!({ isAuthenticated: true, isEasyAuthConfigured: true, error: null });
});

// Should now show the chat widget
await screen.findByTestId('chat-widget');
});

it('should show LoginPrompt when checkAuthStatus returns false', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null });
it('should show LoginPrompt when checkAuthStatus returns false and Easy Auth is configured', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });

const configWithProviders: IframeConfig = {
...defaultConfig,
Expand All @@ -389,7 +389,7 @@ describe('IframeWrapper', () => {
expect(screen.getByRole('button', { name: 'Microsoft account' })).toBeInTheDocument();
});

it('should show LoginPrompt when checkAuthStatus throws an error', async () => {
it('should show login when checkAuthStatus throws an error and identity providers are configured', async () => {
vi.mocked(authHandler.checkAuthStatus).mockRejectedValue(new Error('Network error'));

const configWithProviders: IframeConfig = {
Expand All @@ -407,10 +407,50 @@ describe('IframeWrapper', () => {

render(<IframeWrapper config={configWithProviders} />);

// Should show login prompt after error
// Should show login UI on error when identity providers are configured
await screen.findByText('Sign in required');
});

it('should skip login when Easy Auth is not configured (404)', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: false, error: null });

const configWithProviders: IframeConfig = {
...defaultConfig,
props: {
...defaultConfig.props,
identityProviders: {
aad: {
signInEndpoint: '/.auth/login/aad',
name: 'Microsoft',
},
},
},
};

render(<IframeWrapper config={configWithProviders} />);

// Should go directly to chat widget (let it fail naturally without apiKey)
await screen.findByTestId('chat-widget');
});

it('should skip login when Easy Auth is configured but no identity providers', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });

// No identity providers configured
const configWithoutProviders: IframeConfig = {
...defaultConfig,
props: {
...defaultConfig.props,
identityProviders: undefined,
},
};

render(<IframeWrapper config={configWithoutProviders} />);

// Should go directly to chat widget (no sign-in required when no providers)
await screen.findByTestId('chat-widget');
});

it('should skip auth check when in portal mode', async () => {
vi.mocked(authHandler.checkAuthStatus).mockClear();

Expand Down Expand Up @@ -443,7 +483,7 @@ describe('IframeWrapper', () => {
});

it('should call openLoginPopup when login button is clicked', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null });
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });

const configWithProviders: IframeConfig = {
...defaultConfig,
Expand Down Expand Up @@ -478,7 +518,7 @@ describe('IframeWrapper', () => {
});

it('should show chat widget after successful login', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null });
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });

// Capture the onSuccess callback
let onSuccessCallback: ((authInfo: authHandler.AuthInformation) => void) | undefined;
Expand Down Expand Up @@ -513,7 +553,7 @@ describe('IframeWrapper', () => {
// Simulate successful login callback with auth info
await act(async () => {
if (onSuccessCallback) {
onSuccessCallback({ isAuthenticated: true, error: null, username: 'Test User' });
onSuccessCallback({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: 'Test User' });
}
});

Expand All @@ -522,7 +562,7 @@ describe('IframeWrapper', () => {
});

it('should show error message when login fails', async () => {
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, error: null });
vi.mocked(authHandler.checkAuthStatus).mockResolvedValue({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });

// Capture the onFailed callback
let onFailedCallback: ((error: Error) => void) | undefined;
Expand Down
86 changes: 76 additions & 10 deletions apps/iframe-app/src/lib/__tests__/authHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,11 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: true, error: null, username: 'John Doe' });
expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: 'John Doe' });
expect(mockFetch).toHaveBeenCalledWith('https://example.com/.auth/me', {
method: 'GET',
credentials: 'include',
redirect: 'manual',
});
});

Expand All @@ -162,7 +163,7 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined });
expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined });
});

it('should return false when user is not authenticated (empty array)', async () => {
Expand All @@ -173,7 +174,7 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: false, error: null, username: undefined });
expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null, username: undefined });
});

it('should handle invalid JWT token gracefully', async () => {
Expand All @@ -184,7 +185,7 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined });
expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined });
});

it('should handle missing access_token gracefully', async () => {
Expand All @@ -195,20 +196,43 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: true, error: null, username: undefined });
expect(result).toEqual({ isAuthenticated: true, isEasyAuthConfigured: true, error: null, username: undefined });
});

it('should return false when response is not ok', async () => {
it('should return isEasyAuthConfigured true and not authenticated when 401', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 401,
type: 'basic',
});

const result = await checkAuthStatus('https://example.com');

expect(result.isAuthenticated).toBe(false);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('Failed to fetch authentication status');
expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });
});

it('should return isEasyAuthConfigured true and not authenticated when 403', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
type: 'basic',
});

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });
});

it('should return isEasyAuthConfigured true and not authenticated on opaqueredirect (302)', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 0,
type: 'opaqueredirect',
});

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: true, error: null });
});

it('should return false on network error', async () => {
Expand All @@ -217,7 +241,49 @@ describe('authHandler', () => {

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: false, error: networkError });
expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: false, error: networkError });
});

it('should return isEasyAuthConfigured false when /.auth/me returns 404', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
type: 'basic',
});

const result = await checkAuthStatus('https://example.com');

expect(result).toEqual({ isAuthenticated: false, isEasyAuthConfigured: false, error: null });
});

it('should return isEasyAuthConfigured true with error when 500 Internal Server Error', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 500,
type: 'basic',
});

const result = await checkAuthStatus('https://example.com');

expect(result.isAuthenticated).toBe(false);
expect(result.isEasyAuthConfigured).toBe(true);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('Failed to fetch authentication status');
});

it('should return isEasyAuthConfigured true with error when 503 Service Unavailable', async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 503,
type: 'basic',
});

const result = await checkAuthStatus('https://example.com');

expect(result.isAuthenticated).toBe(false);
expect(result.isEasyAuthConfigured).toBe(true);
expect(result.error).toBeInstanceOf(Error);
expect(result.error?.message).toBe('Failed to fetch authentication status');
});
});

Expand Down
Loading
Loading