Merge pull request #593 from swissiety/idp-auto-redirect
feat: if the only auth method configured is SSO login: auto redirect to SSO Provider
This commit is contained in:
@@ -249,6 +249,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by onMount step 4 and onSetup: true + navigates away iff OIDC is
|
||||
// the only login method. Centralised so the guard can't drift between the
|
||||
// two call sites (only the `?error=` loop-guard, checked at onMount time,
|
||||
// doesn't apply post-setup — a freshly created admin can't have bounced
|
||||
// off the IdP yet).
|
||||
function tryAutoRedirectToIdp(): boolean {
|
||||
if (oidc.enabled && oidc.password_login_enabled === false && oidc.authorize_endpoint) {
|
||||
window.location.replace(oidc.authorize_endpoint);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function onSetup(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
setupError = '';
|
||||
@@ -260,10 +273,14 @@
|
||||
busy = true;
|
||||
try {
|
||||
await setupAdmin(setupEmail, setupPassword);
|
||||
setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.');
|
||||
setupEmail = setupPassword = setupConfirm = '';
|
||||
// Admin now exists — fold the setup affordance away and return to login.
|
||||
setupAvailable = false;
|
||||
// OIDC-only: the login page would immediately redirect on the next
|
||||
// visit anyway — skip the "you can now sign in" detour and forward
|
||||
// straight to the IdP instead of leaving a dead-end local form.
|
||||
if (tryAutoRedirectToIdp()) return;
|
||||
setupSuccess = t('auth.admin_success', 'Administrator created. You can now sign in.');
|
||||
setTimeout(() => {
|
||||
mode = 'login';
|
||||
setupSuccess = '';
|
||||
@@ -323,6 +340,12 @@
|
||||
setupAvailable = !status.initialized;
|
||||
if (setupAvailable) mode = 'setup';
|
||||
|
||||
// 4) Auto-redirect: when OIDC is the only auth method, skip the login page.
|
||||
// Guard against loops: if the IdP returned ?error=, fall through to the UI.
|
||||
if (!setupAvailable && !page.url.searchParams.has('error') && tryAutoRedirectToIdp()) {
|
||||
return;
|
||||
}
|
||||
|
||||
booting = false;
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { it, expect, vi, beforeEach } from 'vitest';
|
||||
import { it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
||||
|
||||
const { goto, pageState, session } = vi.hoisted(() => {
|
||||
@@ -53,6 +53,24 @@ beforeEach(() => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: true });
|
||||
});
|
||||
|
||||
// jsdom's `Location` can't be spied on in place (its setters trigger
|
||||
// "not implemented" navigation errors), so swap the whole object for a
|
||||
// stub around each test that needs to observe `window.location.replace`.
|
||||
const originalLocation = window.location;
|
||||
let replaceSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
replaceSpy = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { ...originalLocation, replace: replaceSpy }
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
|
||||
});
|
||||
|
||||
it('logs in and redirects', async () => {
|
||||
m(auth.login).mockResolvedValue({ user: { id: '1' } });
|
||||
render(LoginPage);
|
||||
@@ -179,3 +197,48 @@ it('renders an SSO sign-in link when an OIDC provider is configured', async () =
|
||||
const sso = await screen.findByTestId('login-oidc-btn');
|
||||
expect(sso.getAttribute('href')).toBe('https://idp.test/auth');
|
||||
});
|
||||
|
||||
it('auto-redirects to the IdP when OIDC is the only login method', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: false,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await waitFor(() => expect(replaceSpy).toHaveBeenCalledWith('/api/auth/oidc/authorize'));
|
||||
});
|
||||
|
||||
it('does not auto-redirect when password login is also enabled', async () => {
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: true,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-redirect after the IdP already returned an error (loop guard)', async () => {
|
||||
pageState.url = new URL('http://localhost/login?error=access_denied');
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: false,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-form');
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not auto-redirect during first-run setup', async () => {
|
||||
m(auth.getAuthStatus).mockResolvedValue({ initialized: false });
|
||||
m(auth.getOidcProviders).mockResolvedValue({
|
||||
enabled: true,
|
||||
password_login_enabled: false,
|
||||
authorize_endpoint: '/api/auth/oidc/authorize'
|
||||
});
|
||||
render(LoginPage);
|
||||
await screen.findByTestId('login-setup-form');
|
||||
expect(replaceSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user