Auth: Show OIDC sign-in errors persistently and arm the loop guard

An OIDC sign-in failure (e.g. a cluster 'no access to this instance'
denial) was shown only as a transient toast the user could miss and
retry into a denial loop. Render it as a persistent, dismissible alert
on the login form instead, driven by the existing session.error bridge.
Also mark the redirect-loop guard when the user clicks the OIDC button,
so a bounce-back is recognized like the /login route guard's auto-redirect.
This commit is contained in:
Michael Mayer 2026-06-10 16:22:42 +00:00
parent 0379692a46
commit 4b7565b3ac
2 changed files with 54 additions and 1 deletions

View file

@ -25,6 +25,16 @@
<v-card-text>
<p-auth-header></p-auth-header>
<v-spacer></v-spacer>
<v-alert
v-if="loginError"
type="error"
variant="tonal"
density="compact"
closable
class="auth-error mb-3"
@click:close="loginError = ''"
>{{ loginError }}</v-alert
>
<v-row align="start" dense>
<template v-if="enterCode">
<v-col cols="12" class="text-body-2 text-center pb-1">
@ -218,6 +228,7 @@ export default {
useRecoveryCode: false,
code: "",
enterCode: false,
loginError: "",
sponsor: this.$config.isSponsor(),
config: this.$config.values,
siteDescription: this.$config.getSiteDescription(),
@ -267,9 +278,12 @@ export default {
created() {
this.staySignedIn = this.currentStaySignedInState();
// Surface an OIDC sign-in failure (e.g. "no access to this instance")
// as a persistent, dismissible alert on the form rather than a transient
// toast the user can miss and retry into a denial loop.
const authError = getAppStorage().getItem("session.error");
if (authError) {
this.$notify.error(authError);
this.loginError = authError;
getAppStorage().removeItem("session.error");
}
},
@ -377,6 +391,10 @@ export default {
if (this.config.ext?.oidc?.loginUri) {
this.applySessionPersistence();
this.loading = true;
// Mark the redirect follow so a bounce-back is recognized as a loop by
// the /login route guard (common/session.js loginRedirectLooping) the
// manual OIDC button must participate in the guard like the auto-redirect.
this.$session.markLoginRedirectAttempt();
this.$session.followRedirect(this.config.ext.oidc.loginUri);
} else {
this.$notify.warn(this.$gettext("Missing or invalid configuration"));

View file

@ -22,6 +22,7 @@ function mountLogin({ oidcEnabled = false, oidcRedirect = false, sessionOverride
getDefaultRoute: vi.fn(() => "browse"),
isAuthenticated: vi.fn(() => false),
consumeLogoutSignal: vi.fn(() => false),
markLoginRedirectAttempt: vi.fn(),
...baseSession,
...sessionOverrides,
};
@ -137,6 +138,40 @@ describe("page/auth/login", () => {
expect(session.useSessionStorage).toHaveBeenCalledTimes(1);
expect(session.useLocalStorage).not.toHaveBeenCalled();
expect(session.followRedirect).toHaveBeenCalledWith("/api/v1/oidc/login");
// The manual OIDC button must arm the redirect-loop guard, like the
// auto-redirect in the /login route guard does.
expect(session.markLoginRedirectAttempt).toHaveBeenCalledTimes(1);
});
it("reset clears the inputs and code-entry state", () => {
const { wrapper } = mountLogin();
Object.assign(wrapper.vm, {
username: "x",
password: "y",
showPassword: true,
useRecoveryCode: true,
code: "123456",
enterCode: true,
});
wrapper.vm.reset();
expect(wrapper.vm.username).toBe("");
expect(wrapper.vm.password).toBe("");
expect(wrapper.vm.showPassword).toBe(false);
expect(wrapper.vm.useRecoveryCode).toBe(false);
expect(wrapper.vm.code).toBe("");
expect(wrapper.vm.enterCode).toBe(false);
});
it("surfaces a stored OIDC sign-in error as a persistent login error and consumes it", () => {
window.localStorage.setItem(`${storagePrefix}session.error`, "You do not have access to this instance.");
const { wrapper } = mountLogin({ oidcEnabled: true });
expect(wrapper.vm.loginError).toBe("You do not have access to this instance.");
// Consumed so it does not reappear on the next reload.
expect(window.localStorage.getItem(`${storagePrefix}session.error`)).toBeNull();
});
// Auto-OIDC redirect for unauthenticated visitors now lives in the