feat(editor): Make SSO the primary option on the sign-in page when SSO is enabled (#39268)

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Savelii 2026-09-23 10:16:51 +00:00 committed by GitHub
parent 27627bc793
commit 0827eb8eda
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1058 additions and 177 deletions

View File

@ -0,0 +1,7 @@
/**
* Error code of the `POST /login` response when password sign-in is refused
* because single sign-on (SAML or OIDC) is the active authentication method.
* The sign-in page uses it to point the user to the SSO flow instead of
* showing a generic login failure.
*/
export const SSO_LOGIN_REQUIRED_ERROR_CODE = 996;

View File

@ -853,6 +853,7 @@ export {
SSO_ERROR_QUERY_PARAM,
SSO_ERROR_LOGIN_FAILED,
} from './constants/role-mapping';
export { SSO_LOGIN_REQUIRED_ERROR_CODE } from './constants/auth';
export {
instanceAiApprovalDetailsSchema,

View File

@ -1,5 +1,5 @@
import type { LoginRequestDto } from '@n8n/api-types';
import { ResolveSignupTokenQueryDto } from '@n8n/api-types';
import { ResolveSignupTokenQueryDto, SSO_LOGIN_REQUIRED_ERROR_CODE } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { AuthenticatedRequest, User } from '@n8n/db';
@ -145,6 +145,9 @@ describe('AuthController', () => {
const execution = controller.login(req, res, body);
await expect(execution).rejects.toThrow(AuthError);
await expect(execution).rejects.toThrow('SSO is enabled, please log in with SSO');
await expect(execution).rejects.toMatchObject({
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
});
// Assert
@ -177,6 +180,9 @@ describe('AuthController', () => {
const execution = controller.login(req, res, body);
await expect(execution).rejects.toThrow(AuthError);
await expect(execution).rejects.toThrow('SSO is enabled, please log in with SSO');
await expect(execution).rejects.toMatchObject({
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
});
expect(eventsService.emit).toHaveBeenCalledWith('user-login-failed', {
authenticationMethod: 'email',

View File

@ -1,4 +1,8 @@
import { LoginRequestDto, ResolveSignupTokenQueryDto } from '@n8n/api-types';
import {
LoginRequestDto,
ResolveSignupTokenQueryDto,
SSO_LOGIN_REQUIRED_ERROR_CODE,
} from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
import type { User, PublicUser, AuthProviderType } from '@n8n/db';
@ -124,7 +128,7 @@ export class AuthController {
userEmail,
reason: 'SSO is enabled, please log in with SSO',
});
throw new AuthError('SSO is enabled, please log in with SSO');
throw new AuthError('SSO is enabled, please log in with SSO', SSO_LOGIN_REQUIRED_ERROR_CODE);
}
}

View File

@ -106,6 +106,48 @@ describe('OidcController', () => {
expect(res.redirect).toHaveBeenCalledWith('/');
});
test('Should redirect to the destination stored in the state after login', async () => {
const req = mock<AuthlessRequest>({
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
browserId: 'browser-id-123',
cookies: {
[OIDC_STATE_COOKIE_NAME]: 'state_value',
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
},
});
const res = mock<Response>();
oidcService.verifyState.mockReturnValue({
state: 'n8n_state:uuid',
redirectUrl: '/workflow/abc?tab=1',
});
oidcService.loginUser.mockResolvedValueOnce({ user });
await controller.callbackHandler(req, res);
expect(res.redirect).toHaveBeenCalledWith('/workflow/abc?tab=1');
});
test('Should fall back to the home page when the stored destination is not a local path', async () => {
const req = mock<AuthlessRequest>({
originalUrl: '/sso/oidc/callback?code=auth_code&state=state_value',
browserId: 'browser-id-123',
cookies: {
[OIDC_STATE_COOKIE_NAME]: 'state_value',
[OIDC_NONCE_COOKIE_NAME]: 'nonce_value',
},
});
const res = mock<Response>();
oidcService.verifyState.mockReturnValue({
state: 'n8n_state:uuid',
redirectUrl: '//evil.example/phish',
});
oidcService.loginUser.mockResolvedValueOnce({ user });
await controller.callbackHandler(req, res);
expect(res.redirect).toHaveBeenCalledWith('/');
});
test('Should handle callback URL with different query parameters', async () => {
const req = mock<AuthlessRequest>({
originalUrl:
@ -549,6 +591,34 @@ describe('OidcController', () => {
});
});
test('Should carry a safe in-app destination into the login state', async () => {
const req = mock<Request>({ query: { redirect: '/workflow/abc?tab=1' } });
const res = mock<Response>();
oidcService.generateLoginUrl.mockResolvedValueOnce({
url: new URL('https://provider.com/auth'),
state: 'state_value',
nonce: 'nonce_value',
});
await controller.redirectToAuthProvider(req, res);
expect(oidcService.generateLoginUrl).toHaveBeenCalledWith('/workflow/abc?tab=1');
});
test('Should replace an external destination with the home page', async () => {
const req = mock<Request>({ query: { redirect: 'https://evil.example/phish' } });
const res = mock<Response>();
oidcService.generateLoginUrl.mockResolvedValueOnce({
url: new URL('https://provider.com/auth'),
state: 'state_value',
nonce: 'nonce_value',
});
await controller.redirectToAuthProvider(req, res);
expect(oidcService.generateLoginUrl).toHaveBeenCalledWith('/');
});
test('Should propagate errors from OIDC service during URL generation', async () => {
const req = mock<Request>();
const res = mock<Response>();

View File

@ -394,6 +394,37 @@ describe('OidcService', () => {
});
});
describe('state redirect target', () => {
beforeEach(() => {
vi.mocked(jwtService.sign).mockImplementation((payload) => JSON.stringify(payload));
vi.mocked(jwtService.verify).mockImplementation((token) => JSON.parse(token));
});
it('round-trips the in-app destination through the signed state', () => {
const { signed } = oidcService.generateState(false, '/workflow/abc?tab=1');
expect(oidcService.verifyState(signed)).toEqual({
state: expect.stringMatching(/^n8n_state:/),
redirectUrl: '/workflow/abc?tab=1',
});
});
it('omits the destination for the home page', () => {
const { signed } = oidcService.generateState(false, '/');
expect(oidcService.verifyState(signed)).not.toHaveProperty('redirectUrl');
});
it('ignores a destination that is not a string', () => {
const signed = JSON.stringify({
state: 'n8n_state:5d3a3f6e-1d2b-4c8a-9e1f-0a1b2c3d4e5f',
redirectUrl: 42,
});
expect(oidcService.verifyState(signed)).not.toHaveProperty('redirectUrl');
});
});
describe('loginUser', () => {
it('throws an error if authorizationCodeGrant throws an error', async () => {
oidcService.verifyState = vi.fn().mockReturnValue('valid-state');

View File

@ -19,6 +19,7 @@ import { SsoAccessDeniedError } from '@/modules/provisioning.ee/errors/sso-acces
import { AuthlessRequest } from '@/requests';
import { UrlService } from '@/services/url.service';
import { isOidcCurrentAuthenticationMethod } from '@/sso.ee/sso-helpers';
import { validateRedirectUrl } from '@/utils/validate-redirect-url';
import {
OIDC_CLIENT_SECRET_REDACTED_VALUE,
@ -94,8 +95,10 @@ export class OidcController {
@Get('/login', { skipAuth: true })
@Licensed('feat:oidc')
async redirectToAuthProvider(_req: Request, res: Response) {
const authorization = await this.oidcService.generateLoginUrl();
async redirectToAuthProvider(req: Request<{}, {}, {}, { redirect?: string }>, res: Response) {
// Same in-app destination the sign-in page received; the callback redirects there.
const redirectUrl = validateRedirectUrl(req.query.redirect ?? '');
const authorization = await this.oidcService.generateLoginUrl(redirectUrl);
const { samesite, secure } = this.globalConfig.auth.cookie;
res.cookie(OIDC_STATE_COOKIE_NAME, authorization.state, {
@ -188,7 +191,7 @@ export class OidcController {
authenticationMethod: 'oidc',
});
return res.redirect('/');
return res.redirect(validateRedirectUrl(stateInfo.redirectUrl ?? ''));
} catch (error) {
if (error instanceof SsoAccessDeniedError) {
this.eventService.emit('user-login-failed', {

View File

@ -131,25 +131,34 @@ export class OidcService {
};
}
generateState(testMode = false) {
/**
* The signed state also carries the in-app destination the user asked for
* before the login, so the callback can send them there instead of `/`.
*/
generateState(testMode = false, redirectUrl?: string) {
const state = `n8n_state:${randomUUID()}`;
const payload: Record<string, unknown> = { state };
if (testMode) {
payload.testMode = true;
}
if (redirectUrl && redirectUrl !== '/') {
payload.redirectUrl = redirectUrl;
}
return {
signed: this.jwtService.sign(payload, { expiresIn: '15m' }),
plaintext: state,
};
}
verifyState(signedState: string): { state: string; testMode?: boolean } {
verifyState(signedState: string): { state: string; testMode?: boolean; redirectUrl?: string } {
let state: string;
let testMode: boolean | undefined;
let redirectUrl: unknown;
try {
const decodedState = this.jwtService.verify(signedState);
state = decodedState?.state;
testMode = decodedState?.testMode;
redirectUrl = decodedState?.redirectUrl;
} catch (error) {
this.logger.error('Failed to verify state', { error });
throw new BadRequestError('Invalid state');
@ -175,7 +184,11 @@ export class OidcService {
this.logger.error('Provided state is not formatted correctly');
throw new BadRequestError('Invalid state');
}
return { state, testMode };
return {
state,
testMode,
...(typeof redirectUrl === 'string' && { redirectUrl }),
};
}
generateNonce() {
@ -225,12 +238,14 @@ export class OidcService {
}
}
async generateLoginUrl(): Promise<{ url: URL; state: string; nonce: string }> {
async generateLoginUrl(
redirectUrl?: string,
): Promise<{ url: URL; state: string; nonce: string }> {
this.assertOidcLoginEnabled();
await this.loadOpenIdClient();
const configuration = await this.getOidcConfiguration();
const state = this.generateState();
const state = this.generateState(false, redirectUrl);
const nonce = this.generateNonce();
const prompt = this.oidcConfig.prompt;

View File

@ -8,6 +8,8 @@ describe('validateRedirectUrl', () => {
['/path?query=1', '/path?query=1'],
['/path#fragment', '/path#fragment'],
[' /trimmed ', '/trimmed'],
['/workflow/abc?tab=1&x=2', '/workflow/abc?tab=1&x=2'],
['/%5Cexample.com', '/%5Cexample.com'],
])('allows valid relative path %s → %s', (input, expected) => {
expect(validateRedirectUrl(input)).toBe(expected);
});
@ -21,6 +23,12 @@ describe('validateRedirectUrl', () => {
['%2F%2Fevil.com/phishing', '/'],
['workflows/123', '/'],
['data:text/html,<h1>hi</h1>', '/'],
['/\\example.com/path', '/'],
['/\\\\example.com', '/'],
['/\t/example.com', '/'],
['/\r\n/example.com', '/'],
['/\n\\example.com', '/'],
['/\\/example.com', '/'],
])('rejects unsafe redirect %s → %s', (input, expected) => {
expect(validateRedirectUrl(input)).toBe(expected);
});

View File

@ -1,3 +1,6 @@
/** Any origin works here; the check is only that the path resolves back onto it. */
const PLACEHOLDER_ORIGIN = 'https://n8n.invalid';
/**
* Validates that a redirect URL is safe (relative path only, no external redirects).
* Returns '/' if the URL is invalid or unsafe.
@ -17,6 +20,20 @@ export function validateRedirectUrl(redirectUrl: string): string {
if (trimmed.startsWith('//')) {
return '/';
}
// Browsers read a backslash as a slash and drop tabs and newlines while they
// parse a URL, so `/\example.com` and `/<tab>/example.com` also leave the instance.
if (/[\\\t\n\r]/.test(trimmed)) {
return '/';
}
// Let the URL parser have the final word: a relative path resolved against a
// placeholder origin has to stay on that origin.
try {
if (new URL(trimmed, PLACEHOLDER_ORIGIN).origin !== PLACEHOLDER_ORIGIN) {
return '/';
}
} catch {
return '/';
}
return trimmed;
}

View File

@ -1,11 +1,41 @@
import { mount } from '@vue/test-utils';
import { describe, it, expect } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import FormInputs from './FormInputs.vue';
import type { IFormInput } from '../../types';
import { createFormEventBus } from '../../utils';
import N8nFormInput from '../N8nFormInput';
describe('FormInputs', () => {
describe('event bus', () => {
const inputs: IFormInput[] = [
{ name: 'email', properties: { type: 'text', label: 'Email' }, initialValue: 'a@b.c' },
];
it('should submit the current values when the bus emits submit', async () => {
const eventBus = createFormEventBus();
const wrapper = mount(FormInputs, { props: { inputs, eventBus } });
await wrapper.vm.$nextTick();
eventBus.emit('submit');
expect(wrapper.emitted('submit')).toEqual([[{ email: 'a@b.c' }]]);
});
it('should stop listening to the bus when unmounted', async () => {
const eventBus = createFormEventBus();
const onSpy = vi.spyOn(eventBus, 'on');
const offSpy = vi.spyOn(eventBus, 'off');
const wrapper = mount(FormInputs, { props: { inputs, eventBus } });
await wrapper.vm.$nextTick();
wrapper.unmount();
const [, registeredHandler] = onSpy.mock.calls[0];
expect(offSpy).toHaveBeenCalledWith('submit', registeredHandler);
});
});
describe('dangling field removal', () => {
it('should remove values for fields that are no longer in the inputs list', async () => {
const initialInputs: IFormInput[] = [

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue';
import type { FormFieldValue, IFormInput, FormFieldValueUpdate, FormValues } from '../../types';
import type { FormEventBus } from '../../utils';
@ -145,6 +145,12 @@ onMounted(() => {
props.eventBus.on('submit', onSubmit);
}
});
// The bus outlives this component when the parent toggles the form, so drop the
// listener or every remount would submit once more with stale values.
onBeforeUnmount(() => {
props.eventBus?.off('submit', onSubmit);
});
</script>
<template>

View File

@ -253,6 +253,9 @@
"auth.signin.ssoLoginFailed": "Your identity provider could not sign you in. Please try again, or contact your administrator if the problem continues.",
"auth.signin.sessionExpired.title": "Session expired",
"auth.signin.sessionExpired": "Your session has expired. Please log in again to continue using n8n.",
"auth.signin.passwordDisclosure": "Sign in with email and password",
"auth.signin.ssoRequired.title": "Your account uses single sign-on (SSO)",
"auth.signin.ssoRequired": "Password sign-in isn't available for this account.",
"auth.signout": "Sign out",
"auth.signout.error": "Could not sign out",
"auth.signup.finishAccountSetup": "Finish account setup",
@ -6277,6 +6280,7 @@
"settings.personal.mfa.enforce.disabled.message": "Two-factor authentication is no longer mandatory for users on this instance authenticating with email and password logins.",
"settings.mfa.toast.noRecoveryCodeLeft.title": "No 2FA recovery codes remaining",
"settings.mfa.toast.noRecoveryCodeLeft.message": "You have used all of your recovery codes. Disable then re-enable two-factor authentication to generate new codes. <a href='/settings/personal' target='_blank' >Open settings</a>",
"sso.login.subtitle": "Your organization uses single sign-on (SSO).",
"sso.login.divider": "or",
"sso.login.button": "Continue with SSO",
"executionUsage.currentUsage": "{text} {count}",

View File

@ -0,0 +1,33 @@
import type { IRestApiContext } from '../../types';
import * as utils from '../../utils';
import { initSSO } from '../sso';
vi.mock('../../utils');
const context = {} as IRestApiContext;
describe('initSSO', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('encodes the destination as a query parameter', async () => {
vi.mocked(utils.makeRestApiRequest).mockResolvedValueOnce('https://idp.example.com/saml');
await initSSO(context, '/workflow/abc?tab=1&x=2');
expect(utils.makeRestApiRequest).toHaveBeenCalledWith(
context,
'GET',
'/sso/saml/initsso?redirect=%2Fworkflow%2Fabc%3Ftab%3D1%26x%3D2',
);
});
it('omits the query when there is no destination', async () => {
vi.mocked(utils.makeRestApiRequest).mockResolvedValueOnce('https://idp.example.com/saml');
await initSSO(context);
expect(utils.makeRestApiRequest).toHaveBeenCalledWith(context, 'GET', '/sso/saml/initsso');
});
});

View File

@ -14,7 +14,8 @@ export type SamlPreferencesExtractedData = {
};
export const initSSO = async (context: IRestApiContext, redirectUrl = ''): Promise<string> => {
return await makeRestApiRequest(context, 'GET', `/sso/saml/initsso?redirect=${redirectUrl}`);
const query = redirectUrl ? `?${new URLSearchParams({ redirect: redirectUrl })}` : '';
return await makeRestApiRequest(context, 'GET', `/sso/saml/initsso${query}`);
};
export const getSamlMetadata = async (context: IRestApiContext): Promise<SamlPreferences> => {

View File

@ -6,6 +6,12 @@ export const MFA_FORM = {
MFA_RECOVERY_CODE: 'MFA_RECOVERY_CODE',
} as const;
/**
* Sign-in page query parameter that reveals the email/password form up front
* on SSO instances, so an admin can still sign in when the provider is down.
*/
export const INTERNAL_AUTH_QUERY_PARAM = 'internalAuth';
export const MFA_AUTHENTICATION_REQUIRED_ERROR_CODE = 998;
export const MFA_AUTHENTICATION_CODE_WINDOW_EXPIRED = 997;

View File

@ -0,0 +1,161 @@
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
import SsoSigninCard from './SsoSigninCard.vue';
import type { IFormBoxConfig } from '@/Interface';
vi.mock('vue-router', () => ({
RouterLink: {
template: '<a><slot /></a>',
},
}));
const form: IFormBoxConfig = {
title: 'Sign in',
buttonText: 'Sign in',
redirectText: 'Forgot my password',
redirectLink: '/forgot-password',
inputs: [
{
name: 'emailOrLdapLoginId',
properties: { label: 'Email', type: 'email', required: true },
},
{
name: 'password',
properties: { label: 'Password', type: 'password', required: true },
},
],
};
const renderComponent = createComponentRenderer(SsoSigninCard, {
pinia: createTestingPinia(),
props: { form },
});
const getEmailInput = (container: Element) =>
container.querySelector<HTMLInputElement>('input[type="email"]');
describe('SsoSigninCard', () => {
afterEach(() => {
vi.clearAllMocks();
});
it('should lead with the SSO button and keep the password form collapsed', () => {
const { getByRole, getByText, container } = renderComponent();
expect(getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
expect(getByText('Your organization uses single sign-on (SSO).')).toBeVisible();
expect(getByRole('button', { name: /sign in with email and password/i })).toHaveAttribute(
'aria-expanded',
'false',
);
expect(getEmailInput(container)).not.toBeVisible();
});
it('should reveal the password form when the disclosure is clicked', async () => {
const { getByRole, getByText, container } = renderComponent();
const disclosure = getByRole('button', { name: /sign in with email and password/i });
await userEvent.click(disclosure);
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
const controlledContent = document.getElementById(
disclosure.getAttribute('aria-controls') ?? '',
);
expect(controlledContent).toContainElement(getEmailInput(container));
expect(getEmailInput(container)).toBeVisible();
expect(container.querySelector('input[type="password"]')).toBeVisible();
expect(getByRole('button', { name: 'Sign in' })).toBeVisible();
expect(getByText('Forgot my password')).toBeVisible();
});
it('should focus the email field when the disclosure opens', async () => {
const { getByRole, container } = renderComponent();
await userEvent.click(getByRole('button', { name: /sign in with email and password/i }));
await waitFor(() => expect(getEmailInput(container)).toHaveFocus());
});
it('should keep the entered credentials when the disclosure is closed and reopened', async () => {
const { getByRole, emitted, container } = renderComponent({
props: { defaultExpanded: true },
});
const disclosure = getByRole('button', { name: /sign in with email and password/i });
const emailInput = getEmailInput(container);
const passwordInput = container.querySelector('input[type="password"]');
if (!emailInput || !passwordInput) {
throw new Error('Inputs not found');
}
await userEvent.type(emailInput, 'test@n8n.io');
await userEvent.type(passwordInput, 'password');
await userEvent.click(disclosure);
expect(emailInput).not.toBeVisible();
await userEvent.click(disclosure);
expect(emailInput).toBeVisible();
expect(emailInput).toHaveValue('test@n8n.io');
await userEvent.click(getByRole('button', { name: 'Sign in' }));
expect(emitted('submit')).toEqual([
[{ emailOrLdapLoginId: 'test@n8n.io', password: 'password' }],
]);
});
it('should start with the password form revealed and focused when defaultExpanded is set', async () => {
const { container } = renderComponent({ props: { defaultExpanded: true } });
expect(getEmailInput(container)).toBeVisible();
await waitFor(() => expect(getEmailInput(container)).toHaveFocus());
});
it('should emit ssoLogin from the primary button', async () => {
const { getByRole, emitted } = renderComponent();
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
expect(emitted('ssoLogin')).toHaveLength(1);
});
it('should emit submit with the entered credentials', async () => {
const { getByRole, emitted, container } = renderComponent({
props: { defaultExpanded: true },
});
const emailInput = getEmailInput(container);
const passwordInput = container.querySelector('input[type="password"]');
if (!emailInput || !passwordInput) {
throw new Error('Inputs not found');
}
await userEvent.type(emailInput, 'test@n8n.io');
await userEvent.type(passwordInput, 'password');
await userEvent.click(getByRole('button', { name: 'Sign in' }));
expect(emitted('submit')).toEqual([
[{ emailOrLdapLoginId: 'test@n8n.io', password: 'password' }],
]);
});
it('should show the SSO-required callout with its own SSO action', async () => {
const { getByTestId, queryByTestId, rerender, emitted } = renderComponent({
props: { defaultExpanded: true },
});
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
await rerender({ form, defaultExpanded: true, ssoRequired: true });
const callout = getByTestId('sso-required-callout');
expect(callout).toBeVisible();
await waitFor(() => expect(callout).toHaveFocus());
expect(within(callout).getByText('Your account uses single sign-on (SSO)')).toBeVisible();
expect(
within(callout).getByText("Password sign-in isn't available for this account."),
).toBeVisible();
await userEvent.click(within(callout).getByRole('button', { name: 'Continue with SSO' }));
expect(emitted('ssoLogin')).toHaveLength(1);
});
});

View File

@ -0,0 +1,345 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { CollapsibleRoot, CollapsibleTrigger } from 'reka-ui';
import {
createFormEventBus,
N8nAnimatedCollapsibleContent,
N8nButton,
N8nCallout,
N8nFormInputs,
N8nHeading,
N8nIcon,
N8nLink,
N8nText,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { IFormBoxConfig } from '@/Interface';
import type { EmailOrLdapLoginIdAndPassword } from '../views/SigninView.vue';
/**
* The sign-in card for instances where SSO is the active login method.
* "Continue with SSO" is the primary action; the email/password form sits
* behind a disclosure so it stays one click away without inviting users to
* type credentials that SSO would reject.
*/
const props = withDefaults(
defineProps<{
form: IFormBoxConfig;
formLoading?: boolean;
ssoLoading?: boolean;
/** Show the inline "your account uses SSO" callout above the password fields. */
ssoRequired?: boolean;
/** Start with the password form revealed, for example on `?internalAuth=true`. */
defaultExpanded?: boolean;
}>(),
{
formLoading: false,
ssoLoading: false,
ssoRequired: false,
defaultExpanded: false,
},
);
const emit = defineEmits<{
submit: [values: EmailOrLdapLoginIdAndPassword];
ssoLogin: [];
}>();
const i18n = useI18n();
const formBus = createFormEventBus();
const isPasswordFormOpen = ref(props.defaultExpanded);
const passwordFormRef = ref<HTMLElement | null>(null);
const calloutRef = ref<{ $el?: unknown } | null>(null);
// Reka assigns the collapsible content id when the content mounts, after the
// trigger has already rendered an empty `aria-controls`, so read the id from the
// rendered content element and bind it explicitly.
const passwordFormContentId = ref<string>();
// The form stays mounted while collapsed so typed credentials survive a
// close/reopen, so the card decides when the first field gets focus instead of
// the inputs focusing themselves on mount.
const inputs = computed(() =>
props.form.inputs.map((input) => ({
...input,
properties: { ...input.properties, focusInitially: false },
})),
);
const nextFrame = async () =>
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Reka drops the `hidden` attribute one render after the open state flips, and
// a field inside hidden content cannot take focus, so wait for it to show.
const focusFirstInput = async () => {
await nextTick();
for (let frame = 0; frame < 10 && passwordFormRef.value?.parentElement?.hidden; frame++) {
await nextFrame();
}
passwordFormRef.value?.querySelector('input')?.focus({ preventScroll: true });
};
onMounted(async () => {
passwordFormContentId.value = passwordFormRef.value?.parentElement?.id || undefined;
if (props.defaultExpanded) await focusFirstInput();
});
watch(isPasswordFormOpen, async (isOpen) => {
if (isOpen) await focusFirstInput();
});
// Focus the callout when it appears so keyboard and screen-reader users land on
// the explanation right after a refused password sign-in.
watch(
() => props.ssoRequired,
async (ssoRequired) => {
if (!ssoRequired) return;
await nextTick();
const element = calloutRef.value?.$el;
if (element instanceof HTMLElement) element.focus({ preventScroll: true });
},
);
const onSubmit = (values: unknown) => {
emit('submit', values as EmailOrLdapLoginIdAndPassword);
};
</script>
<template>
<div :class="['n8n-form-box', $style.container]" data-test-id="sso-signin-card">
<div :class="$style.heading">
<N8nHeading size="xlarge">{{ form.title }}</N8nHeading>
<N8nText tag="p" size="medium" color="text-base" align="center">
{{ i18n.baseText('sso.login.subtitle') }}
</N8nText>
</div>
<N8nButton
size="large"
:class="$style.ssoButton"
:label="i18n.baseText('sso.login.button')"
:loading="ssoLoading"
data-test-id="sso-login-button"
@click="emit('ssoLogin')"
/>
<div :class="$style.divider" aria-hidden="true">
<span>{{ i18n.baseText('sso.login.divider') }}</span>
</div>
<CollapsibleRoot
v-model:open="isPasswordFormOpen"
:unmount-on-hide="false"
:class="$style.passwordSection"
>
<CollapsibleTrigger as-child>
<N8nButton
variant="ghost"
size="large"
:class="$style.revealTrigger"
:aria-controls="passwordFormContentId"
data-test-id="reveal-password-login"
>
{{ i18n.baseText('auth.signin.passwordDisclosure') }}
<N8nIcon
icon="chevron-down"
size="small"
:class="[$style.chevron, isPasswordFormOpen && $style.chevronOpen]"
/>
</N8nButton>
</CollapsibleTrigger>
<N8nAnimatedCollapsibleContent :class="$style.passwordFormContent" blur>
<div ref="passwordFormRef" :class="$style.passwordForm">
<N8nCallout
v-if="ssoRequired"
ref="calloutRef"
theme="warning"
:class="$style.callout"
tabindex="-1"
data-test-id="sso-required-callout"
>
<div :class="$style.calloutContent">
<N8nText tag="p" size="small" color="text-base" bold>
{{ i18n.baseText('auth.signin.ssoRequired.title') }}
</N8nText>
<N8nText tag="p" size="small" color="text-base">
{{ i18n.baseText('auth.signin.ssoRequired') }}
</N8nText>
<N8nButton
variant="subtle"
size="small"
:class="$style.calloutAction"
:label="i18n.baseText('sso.login.button')"
:loading="ssoLoading"
data-test-id="sso-required-callout-action"
@click="emit('ssoLogin')"
/>
</div>
</N8nCallout>
<div :class="$style.inputsContainer">
<N8nFormInputs
:inputs="inputs"
:event-bus="formBus"
:column-view="true"
@submit="onSubmit"
/>
</div>
<div :class="$style.buttonsContainer">
<N8nButton
variant="outline"
size="large"
:label="form.buttonText"
:loading="formLoading"
data-test-id="form-submit-button"
@click="formBus.emit('submit')"
/>
</div>
<div :class="$style.actionContainer">
<N8nLink v-if="form.redirectText && form.redirectLink" :to="form.redirectLink">
{{ form.redirectText }}
</N8nLink>
</div>
</div>
</N8nAnimatedCollapsibleContent>
</CollapsibleRoot>
</div>
</template>
<style lang="scss" module>
@use '@n8n/design-system/css/mixins/motion';
// Same chrome as N8nFormBox, so the card matches the other auth pages.
.container {
background-color: var(--color--background--light-3);
padding: var(--spacing--lg);
border: var(--border);
border-radius: var(--radius--lg);
box-shadow: 0 4px 16px rgba(99, 77, 255, 0.06);
}
.heading {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--2xs);
margin-bottom: var(--spacing--xl);
p {
margin: 0;
}
}
.ssoButton {
width: 100%;
}
.divider {
position: relative;
width: 100%;
text-align: center;
text-transform: uppercase;
font-size: var(--font-size--2xs);
color: var(--text-color--subtle);
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 1px;
background-color: var(--color--foreground);
}
span {
position: relative;
display: inline-block;
margin: var(--spacing--xs) auto;
padding: 0 var(--spacing--sm);
background: var(--color--background--light-3);
}
}
.passwordSection {
display: flex;
flex-direction: column;
align-items: stretch;
}
.revealTrigger {
width: 100%;
color: var(--text-color--subtle);
}
.chevron {
margin-left: var(--spacing--4xs);
transition: transform var(--duration--snappy) var(--easing--ease-out);
@include motion.reduced-motion;
}
.chevronOpen {
transform: rotate(180deg);
}
// The collapsible clips its overflow while it animates, which cut the focus
// ring of the inputs at the card edges. Widen the clip box past the ring and
// pull the content back in with matching padding.
.passwordFormContent {
margin: 0 calc(-1 * var(--spacing--3xs));
padding: 0 var(--spacing--3xs);
}
.passwordForm {
padding-top: var(--spacing--sm);
}
.callout {
margin-bottom: var(--spacing--sm);
@include motion.fade-in-down;
&:focus {
outline: none;
}
// Top-align the icon with the callout title instead of its vertical middle.
> div:first-child {
align-items: flex-start;
}
}
.calloutContent {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing--4xs);
p {
margin: 0;
}
}
.calloutAction {
margin-top: var(--spacing--4xs);
}
.inputsContainer {
margin-bottom: var(--spacing--xl);
}
.actionContainer {
display: flex;
justify-content: center;
}
.buttonsContainer {
composes: actionContainer;
margin-bottom: var(--spacing--sm);
}
</style>

View File

@ -4,13 +4,6 @@ import { createComponentRenderer } from '@/__tests__/render';
const renderComponent = createComponentRenderer(AuthView, {
pinia: createTestingPinia(),
global: {
stubs: {
SSOLogin: {
template: '<div data-test-id="sso-login"></div>',
},
},
},
});
describe('AuthView', () => {
@ -23,17 +16,26 @@ describe('AuthView', () => {
expect(getByText('Some text')).toBeInTheDocument();
});
it('should render without SSO component', () => {
const { queryByTestId } = renderComponent();
expect(queryByTestId('sso-login')).not.toBeInTheDocument();
});
it('should render with SSO component', () => {
const { getByTestId } = renderComponent({
it('should render the form box when a form is passed', () => {
const { getByTestId, getByText } = renderComponent({
props: {
withSso: true,
form: { title: 'Form title', inputs: [] },
},
});
expect(getByTestId('sso-login')).toBeInTheDocument();
expect(getByTestId('auth-form')).toBeInTheDocument();
expect(getByText('Form title')).toBeInTheDocument();
});
it('should render the default slot in place of the form box', () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
form: { title: 'Form title', inputs: [] },
},
slots: {
default: '<div data-test-id="custom-card"></div>',
},
});
expect(getByTestId('custom-card')).toBeInTheDocument();
expect(queryByTestId('auth-form')).not.toBeInTheDocument();
});
});

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import { N8nLogo } from '@n8n/design-system';
import SSOLogin from '@/features/settings/sso/components/SSOLogin.vue';
import type { FormFieldValueUpdate, IFormBoxConfig } from '@/Interface';
import { useSettingsStore } from '@n8n/stores/settings.store';
import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue';
@ -8,14 +7,15 @@ import type { EmailOrLdapLoginIdAndPassword } from './SigninView.vue';
import { N8nFormBox, N8nText } from '@n8n/design-system';
withDefaults(
defineProps<{
form: IFormBoxConfig;
/** The standard form box. Omit it when the default slot renders the card instead. */
form?: IFormBoxConfig;
formLoading?: boolean;
subtitle?: string;
withSso?: boolean;
}>(),
{
form: undefined,
formLoading: false,
withSso: false,
subtitle: undefined,
},
);
@ -49,16 +49,17 @@ const {
<N8nText size="large">{{ subtitle }}</N8nText>
</div>
<div :class="$style.formContainer">
<N8nFormBox
v-bind="form"
data-test-id="auth-form"
:button-loading="formLoading"
@secondary-click="onSecondaryClick"
@submit="onSubmit"
@update="onUpdate"
>
<SSOLogin v-if="withSso" />
</N8nFormBox>
<slot>
<N8nFormBox
v-if="form"
v-bind="form"
data-test-id="auth-form"
:button-loading="formLoading"
@secondary-click="onSecondaryClick"
@submit="onSubmit"
@update="onUpdate"
/>
</slot>
</div>
</div>
</template>

View File

@ -8,7 +8,9 @@ import { useUsersStore } from '@n8n/stores/users.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useNotificationsStore } from '@n8n/stores/notifications.store';
import { useSSOStore } from '@/features/settings/sso/sso.store';
import { VIEWS } from '@/app/constants';
import { SSO_LOGIN_REQUIRED_ERROR_CODE } from '@n8n/api-types';
vi.mock('vue-router', () => {
const push = vi.fn();
@ -49,6 +51,7 @@ const renderComponent = createComponentRenderer(SigninView);
let usersStore: ReturnType<typeof mockedStore<typeof useUsersStore>>;
let settingsStore: ReturnType<typeof mockedStore<typeof useSettingsStore>>;
let notificationsStore: ReturnType<typeof mockedStore<typeof useNotificationsStore>>;
let ssoStore: ReturnType<typeof mockedStore<typeof useSSOStore>>;
let router: ReturnType<typeof useRouter>;
let telemetry: ReturnType<typeof useTelemetry>;
@ -89,6 +92,7 @@ describe('SigninView', () => {
usersStore = mockedStore(useUsersStore);
settingsStore = mockedStore(useSettingsStore);
notificationsStore = mockedStore(useNotificationsStore);
ssoStore = mockedStore(useSSOStore);
router = useRouter();
telemetry = useTelemetry();
@ -158,6 +162,13 @@ describe('SigninView', () => {
expect(notificationsStore.setNotificationsSuppressed.mock.calls).toEqual([[false], [true]]);
});
it('should not render the SSO card when SSO is not the login method', () => {
const { queryByTestId, getByTestId } = renderComponent();
expect(getByTestId('auth-form')).toBeInTheDocument();
expect(queryByTestId('sso-signin-card')).not.toBeInTheDocument();
});
it('should show and submit email/password form (happy path)', async () => {
await signInWithValidUser();
@ -273,4 +284,110 @@ describe('SigninView', () => {
expect(router.push).toHaveBeenCalledWith({ name: VIEWS.HOMEPAGE });
});
});
describe('when SSO is the active login method', () => {
let route: ReturnType<typeof useRoute>;
const getEmailInput = (container: Element) => container.querySelector('input[type="email"]');
const submitPasswordLogin = async (
container: Element,
getByRole: ReturnType<typeof renderComponent>['getByRole'],
) => {
const emailInput = getEmailInput(container);
const passwordInput = container.querySelector('input[type="password"]');
if (!emailInput || !passwordInput) {
throw new Error('Inputs not found');
}
await userEvent.type(emailInput, 'member@n8n.io');
await userEvent.type(passwordInput, 'password');
await userEvent.click(getByRole('button', { name: 'Sign in' }));
};
beforeEach(() => {
route = useRoute();
ssoStore.showSsoLoginButton = true;
settingsStore.isCloudDeployment = false;
settingsStore.activeModules = [];
Object.defineProperty(window, 'location', {
value: { href: '', origin: 'https://n8n.local' },
writable: true,
});
});
it('should lead with the SSO button and keep the password form collapsed', () => {
const { getByTestId, getByRole, queryByTestId, container } = renderComponent();
expect(getByTestId('sso-signin-card')).toBeInTheDocument();
expect(queryByTestId('auth-form')).not.toBeInTheDocument();
expect(getByRole('button', { name: 'Continue with SSO' })).toBeVisible();
expect(getEmailInput(container)).not.toBeVisible();
});
it('should reveal the password form when internalAuth=true is in the URL', () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
const { container } = renderComponent();
expect(getEmailInput(container)).toBeVisible();
});
it('should redirect to the SSO login URL for the requested destination', async () => {
ssoStore.getSsoLoginUrl.mockResolvedValue('https://idp.example.com/saml');
const hrefSpy = vi.spyOn(window.location, 'href', 'set');
const { getByRole } = renderComponent();
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
expect(ssoStore.getSsoLoginUrl).toHaveBeenCalledWith('/home/workflows');
expect(hrefSpy).toHaveBeenCalledWith('https://idp.example.com/saml');
});
it('should show a toast when the SSO login URL cannot be resolved', async () => {
ssoStore.getSsoLoginUrl.mockRejectedValue(new Error('SAML is not configured'));
const { getByRole } = renderComponent();
await userEvent.click(getByRole('button', { name: 'Continue with SSO' }));
// Lifted so the error is not swallowed after a session-expiry redirect.
expect(notificationsStore.setNotificationsSuppressed).toHaveBeenCalledWith(false);
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Problem logging in');
});
it('should show an inline callout instead of a toast when password sign-in requires SSO', async () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
usersStore.loginWithCreds.mockRejectedValueOnce(
Object.assign(new Error('SSO is enabled, please log in with SSO'), {
errorCode: SSO_LOGIN_REQUIRED_ERROR_CODE,
}),
);
const { getByRole, getByTestId, queryByTestId, container } = renderComponent();
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
await submitPasswordLogin(container, getByRole);
expect(getByTestId('sso-required-callout')).toBeVisible();
expect(showError).not.toHaveBeenCalled();
expect(telemetry.track).toHaveBeenCalledWith('User attempted to login', {
result: 'credentials_error',
});
});
it('should still show a toast for other login errors', async () => {
vi.spyOn(route, 'query', 'get').mockReturnValue({ internalAuth: 'true' });
usersStore.loginWithCreds.mockRejectedValueOnce(
Object.assign(new Error('Wrong username or password'), { errorCode: 401 }),
);
const { getByRole, queryByTestId, container } = renderComponent();
await submitPasswordLogin(container, getByRole);
expect(queryByTestId('sso-required-callout')).not.toBeInTheDocument();
expect(showError).toHaveBeenCalledWith(expect.any(Error), 'Problem logging in');
});
});
});

View File

@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import AuthView from './AuthView.vue';
import MfaView from './MfaView.vue';
import SsoSigninCard from '../components/SsoSigninCard.vue';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
@ -15,12 +16,18 @@ import { useSettingsStore } from '@n8n/stores/settings.store';
import { useSSOStore } from '@/features/settings/sso/sso.store';
import type { IFormBoxConfig } from '@/Interface';
import { MFA_AUTHENTICATION_REQUIRED_ERROR_CODE, VIEWS, MFA_FORM } from '@/app/constants';
import {
INTERNAL_AUTH_QUERY_PARAM,
MFA_AUTHENTICATION_REQUIRED_ERROR_CODE,
VIEWS,
MFA_FORM,
} from '@/app/constants';
import type { LoginRequestDto } from '@n8n/api-types';
import {
SSO_ERROR_ACCESS_DENIED,
SSO_ERROR_LOGIN_FAILED,
SSO_ERROR_QUERY_PARAM,
SSO_LOGIN_REQUIRED_ERROR_CODE,
} from '@n8n/api-types';
export type EmailOrLdapLoginIdAndPassword = Pick<
@ -47,6 +54,14 @@ const emailOrLdapLoginId = ref('');
const password = ref('');
const reportError = ref(false);
// SSO (SAML or OIDC) is the active login method: lead with it and keep the
// password form behind a disclosure. `?internalAuth=true` reveals the form up
// front so an admin can still sign in when the identity provider is down.
const isSsoLogin = computed(() => ssoStore.showSsoLoginButton);
const isInternalAuthRequested = computed(() => route.query[INTERNAL_AUTH_QUERY_PARAM] === 'true');
const ssoLoading = ref(false);
const ssoRequired = ref(false);
const notificationsStore = useNotificationsStore();
// Notifications are suppressed on the auth views, so lift the suppression just
@ -157,6 +172,21 @@ const onEmailPasswordSubmitted = async (form: EmailOrLdapLoginIdAndPassword) =>
await login(form);
};
const onSsoLogin = async () => {
// The user acted, so an error about the SSO URL must be visible even after a
// session-expiry redirect suppressed notifications.
notificationsStore.setNotificationsSuppressed(false);
ssoLoading.value = true;
try {
window.location.href = await ssoStore.getSsoLoginUrl(
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
);
} catch (error) {
ssoLoading.value = false;
toast.showError(error, locale.baseText('auth.signin.error'));
}
};
const isRedirectSafe = () => {
const redirect = getRedirectQueryParameter();
@ -184,6 +214,7 @@ const getRedirectQueryParameter = () => {
const login = async (form: LoginRequestDto) => {
notificationsStore.setNotificationsSuppressed(false);
ssoRequired.value = false;
try {
loading.value = true;
await usersStore.loginWithCreds({
@ -229,6 +260,18 @@ const login = async (form: LoginRequestDto) => {
result: showMfaView.value ? 'mfa_token_rejected' : 'credentials_error',
});
// The account has to sign in through SSO: explain that inline, next to the
// SSO button, instead of surfacing it as a generic login failure.
if (
error.errorCode === SSO_LOGIN_REQUIRED_ERROR_CODE &&
isSsoLogin.value &&
!showMfaView.value
) {
ssoRequired.value = true;
loading.value = false;
return;
}
if (!showMfaView.value) {
toast.showError(error, locale.baseText('auth.signin.error'));
loading.value = false;
@ -259,14 +302,26 @@ const cacheCredentials = (form: EmailOrLdapLoginIdAndPassword) => {
<template>
<div>
<AuthView
v-if="!showMfaView"
:form="formConfig"
:form-loading="loading"
:with-sso="true"
data-test-id="signin-form"
@submit="onEmailPasswordSubmitted"
/>
<template v-if="!showMfaView">
<AuthView v-if="isSsoLogin" data-test-id="signin-form">
<SsoSigninCard
:form="formConfig"
:form-loading="loading"
:sso-loading="ssoLoading"
:sso-required="ssoRequired"
:default-expanded="isInternalAuthRequested"
@submit="onEmailPasswordSubmitted"
@sso-login="onSsoLogin"
/>
</AuthView>
<AuthView
v-else
:form="formConfig"
:form-loading="loading"
data-test-id="signin-form"
@submit="onEmailPasswordSubmitted"
/>
</template>
<MfaView
v-if="showMfaView"
:report-error="reportError"

View File

@ -1,49 +0,0 @@
import { createTestingPinia } from '@pinia/testing';
import merge from 'lodash/merge';
import SSOLogin from './SSOLogin.vue';
import { STORES } from '@n8n/stores';
import { useSSOStore } from '../sso.store';
import { SETTINGS_STORE_DEFAULT_STATE } from '@/__tests__/utils';
import { afterEach } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
let pinia: ReturnType<typeof createTestingPinia>;
let ssoStore: ReturnType<typeof useSSOStore>;
const renderComponent = createComponentRenderer(SSOLogin, {
global: {
stubs: {
N8nButton: {
template: '<button data-test-id="sso-button"></button>',
},
},
},
});
describe('SSOLogin', () => {
beforeEach(() => {
pinia = createTestingPinia({
initialState: {
[STORES.SETTINGS]: {
settings: merge({}, SETTINGS_STORE_DEFAULT_STATE.settings),
},
},
});
ssoStore = useSSOStore();
});
afterEach(() => {
vi.clearAllMocks();
});
it('should not render button if conditions are not met', () => {
const { queryByRole } = renderComponent({ pinia });
expect(queryByRole('button')).not.toBeInTheDocument();
});
it('should render button if the store returns true for the conditions', () => {
vi.spyOn(ssoStore, 'showSsoLoginButton', 'get').mockReturnValue(true);
const { queryByRole } = renderComponent({ pinia });
expect(queryByRole('button')).toBeInTheDocument();
});
});

View File

@ -1,73 +0,0 @@
<script lang="ts" setup>
import { useSSOStore } from '../sso.store';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { useRoute } from 'vue-router';
import { N8nButton } from '@n8n/design-system';
const i18n = useI18n();
const ssoStore = useSSOStore();
const toast = useToast();
const route = useRoute();
const onSSOLogin = async () => {
try {
const redirectUrl = ssoStore.isDefaultAuthenticationSaml
? await ssoStore.getSSORedirectUrl(
typeof route.query?.redirect === 'string' ? route.query.redirect : '',
)
: ssoStore.oidc.loginUrl;
window.location.href = redirectUrl ?? '';
} catch (error) {
toast.showError(error, 'Error', { message: error.message });
}
};
</script>
<template>
<div v-if="ssoStore.showSsoLoginButton" :class="$style.ssoLogin">
<div :class="$style.divider">
<span>{{ i18n.baseText('sso.login.divider') }}</span>
</div>
<N8nButton
variant="outline"
size="large"
:label="i18n.baseText('sso.login.button')"
@click="onSSOLogin"
/>
</div>
</template>
<style lang="scss" module>
.ssoLogin {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
text-align: center;
}
.divider {
width: 100%;
position: relative;
text-transform: uppercase;
&::before {
content: '';
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 1px;
background-color: var(--color--foreground);
}
span {
position: relative;
display: inline-block;
margin: var(--spacing--2xs) auto;
padding: var(--spacing--lg);
background: var(--color--background--light-3);
}
}
</style>

View File

@ -35,6 +35,26 @@ export const useSSOStore = defineStore('sso', () => {
const getSSORedirectUrl = async (existingRedirect?: string) =>
await ssoApi.initSSO(rootStore.restApiContext, existingRedirect);
/**
* Browser URL that starts the login with the active SSO protocol. SAML asks the
* backend for its redirect; OIDC has a static login endpoint that takes the
* in-app destination as a query parameter and returns there after the callback.
*/
const getSsoLoginUrl = async (existingRedirect = ''): Promise<string> => {
if (isDefaultAuthenticationSaml.value) {
return await getSSORedirectUrl(existingRedirect);
}
const oidcLoginUrl = oidc.value.loginUrl;
if (!oidcLoginUrl) {
throw new Error('The OIDC login URL is not configured');
}
if (!existingRedirect) return oidcLoginUrl;
const separator = oidcLoginUrl.includes('?') ? '&' : '?';
return `${oidcLoginUrl}${separator}${new URLSearchParams({ redirect: existingRedirect })}`;
};
const initialize = (options: {
authenticationMethod: AuthenticationMethod;
managedByEnv?: boolean;
@ -211,6 +231,7 @@ export const useSSOStore = defineStore('sso', () => {
return {
showSsoLoginButton,
getSSORedirectUrl,
getSsoLoginUrl,
initialize,
selectedAuthProtocol,
initializeSelectedProtocol,

View File

@ -40,6 +40,54 @@ describe('SSO store', () => {
},
);
describe('getSsoLoginUrl', () => {
const oidcLoginUrl = 'http://localhost:5678/rest/sso/oidc/login';
beforeEach(() => {
vi.clearAllMocks();
});
it('should ask the backend for the SAML redirect with the destination', async () => {
vi.mocked(ssoApi.initSSO).mockResolvedValue('https://idp.example.com/saml?SAMLRequest=x');
ssoStore.initialize({
authenticationMethod: 'saml' as AuthenticationMethod,
config: { saml: { loginEnabled: true } },
features: { saml: true, ldap: false, oidc: false },
});
await expect(ssoStore.getSsoLoginUrl('/workflow/abc')).resolves.toBe(
'https://idp.example.com/saml?SAMLRequest=x',
);
expect(ssoApi.initSSO).toHaveBeenCalledWith(expect.anything(), '/workflow/abc');
});
it('should append the destination to the OIDC login URL', async () => {
ssoStore.initialize({
authenticationMethod: 'oidc' as AuthenticationMethod,
config: { oidc: { loginEnabled: true, loginUrl: oidcLoginUrl } },
features: { saml: false, ldap: false, oidc: true },
});
await expect(ssoStore.getSsoLoginUrl('/workflow/abc?tab=1&x=2')).resolves.toBe(
`${oidcLoginUrl}?redirect=%2Fworkflow%2Fabc%3Ftab%3D1%26x%3D2`,
);
await expect(ssoStore.getSsoLoginUrl()).resolves.toBe(oidcLoginUrl);
expect(ssoApi.initSSO).not.toHaveBeenCalled();
});
it('should reject when the OIDC login URL is missing', async () => {
ssoStore.initialize({
authenticationMethod: 'oidc' as AuthenticationMethod,
config: { oidc: { loginEnabled: true } },
features: { saml: false, ldap: false, oidc: true },
});
await expect(ssoStore.getSsoLoginUrl('/home')).rejects.toThrow(
'The OIDC login URL is not configured',
);
});
});
describe('OIDC callbackUrl after re-initialization', () => {
it('should populate callbackUrl when re-initialized with authenticated settings', () => {
// Simulate public settings (before login) — no callbackUrl

View File

@ -12,11 +12,17 @@ export class SignInPage extends BasePage {
}
getSubmitButton(): Locator {
return this.page.getByRole('button', { name: 'Sign in' });
// `exact` keeps the "Sign in with email and password" disclosure out of the match.
return this.page.getByRole('button', { name: 'Sign in', exact: true });
}
getSsoButton(): Locator {
return this.page.getByRole('button', { name: /continue with sso/i });
return this.page.getByTestId('sso-login-button');
}
/** Disclosure that reveals the email/password form when SSO is the active login method. */
getPasswordLoginToggle(): Locator {
return this.page.getByTestId('reveal-password-login');
}
async goto(): Promise<void> {
@ -47,6 +53,11 @@ export class SignInPage extends BasePage {
waitForWorkflow = false,
): Promise<void> {
await this.goto();
// On SSO instances the password form starts collapsed behind a disclosure.
await this.getEmailField().or(this.getPasswordLoginToggle()).first().waitFor();
if (await this.getPasswordLoginToggle().isVisible()) {
await this.getPasswordLoginToggle().click();
}
await this.fillEmail(email);
await this.fillPassword(password);
await this.clickSubmit();