Fix OIDC auto-redirect looping until the provider rate-limits it

With oauth2-login-style redirect and OIDC_REDIRECTION_ENABLED, the browser
looped between WeKan and the identity provider until the provider started
rate-limiting the repeated /authorize requests.

Template.userFormsLayout.onCreated (client/components/main/layouts.js)
fired the auto-redirect unconditionally on every render. Meteor's
redirect-style OAuth has no dedicated callback route - the identity
provider's callback bounces the browser back to this SAME sign-in page -
and the login method that follows a successful callback completes
asynchronously, racing this template's own re-render. When the
auto-redirect check ran before that login had finished, it saw "not
logged in yet" and fired a brand new navigation straight back to the
provider, forever.

Fixed with a one-shot sessionStorage flag (client/lib/oidcAutoRedirect.js)
that survives the full-page round trip to the provider and back, so the
bounce-back render does not re-fire the redirect. Cleared on both login
success and login failure (config/accounts.js), so a later logout, or a
genuine error, can still trigger the auto-redirect again rather than
leaving the sign-in page permanently stuck the other way.

Thanks to Alishara and xet7 !

Fixes #6681,
This commit is contained in:
Lauri Ojansivu committed 2026-09-08 17:52:54 +03:00
1 parent a48819ae9a
commit 89682c251f
4 files changed
+173 -1

No files matched your search

+14 -1
View File
@@ -5,6 +5,7 @@ import Users from '/models/users';
import { EscapeActions } from '/client/lib/escapeActions';
import { enablePageDragscroll, disablePageDragscroll } from '/client/lib/pageDragscroll';
import { trapTabKey } from '/client/lib/accessibility';
import { OidcAutoRedirect } from '/client/lib/oidcAutoRedirect';
let alreadyCheck = 1;
let isCheckDone = false;
@@ -30,7 +31,19 @@ Template.userFormsLayout.onCreated(function () {
if (!ReactiveCache.getCurrentUser()?.profile) {
Meteor.call('isOidcRedirectionEnabled', (_, result) => {
if (result) {
// #6681: with `oauth2-login-style: redirect`, the identity provider's
// callback bounces the browser back to this SAME sign-in page (there is
// no dedicated callback route), and the login method that follows a
// successful callback completes asynchronously - racing this template's
// own re-render. Firing the redirect unconditionally on every render
// meant the bounce-back page could see "not logged in yet" and send the
// browser straight back to the provider, in a loop that only stopped
// when the provider started rate-limiting the repeated requests. Once
// per tab is enough: OidcAutoRedirect's flag survives the round trip to
// the provider and back, and is cleared on login success/failure
// (config/accounts.js) so a later logout can auto-redirect again.
if (result && !OidcAutoRedirect.hasAlreadyFired()) {
OidcAutoRedirect.markFired();
AccountsTemplates.options.socialLoginStyle = 'redirect';
// #5695: this is an ES module, so the body runs in strict mode and
// the previous undeclared `options = {...}` assignment threw a
+52
View File
@@ -0,0 +1,52 @@
// #6681: OIDC_REDIRECTION_ENABLED auto-login sent the browser to the identity
// provider on EVERY render of the sign-in page (userFormsLayout), with no
// guard against re-firing. With `oauth2-login-style: redirect`, the IdP's
// callback lands back on that same sign-in page (Meteor's redirect-style
// OAuth has no dedicated callback route of its own) - and the actual login
// method call that follows a successful callback completes asynchronously,
// racing against this page's own re-render. When the auto-redirect check ran
// before that login had finished, it saw "not logged in" and fired a BRAND
// new navigation straight back to the identity provider, forever - until the
// provider started rate-limiting the repeated /authorize hits (the reporter's
// exact symptom: Microsoft Entra ID blocking further attempts).
//
// The fix: a one-shot flag in sessionStorage (survives the full-page
// navigation to the IdP and back, cleared when the tab closes) marks that
// this tab already sent the browser once. A second render of the sign-in
// page - the callback bounce-back - sees the flag and does NOT navigate
// again, leaving Meteor's own oauth completion to finish the login
// undisturbed. The flag is cleared on both a successful login and a login
// failure (config/accounts.js), so a genuine later logout, or a real error,
// can still trigger the auto-redirect again rather than being stuck forever
// the other way.
const OIDC_AUTO_REDIRECT_FLAG = 'wekan-oidc-auto-redirect-pending';
// sessionStorage can throw (private browsing in some older browsers, storage
// disabled by policy). Failing OPEN (treat as "not yet redirected") matches
// this code's behavior before the flag existed at all, rather than turning a
// storage restriction into a second, different way to break the same login.
function readFlag() {
try {
return sessionStorage.getItem(OIDC_AUTO_REDIRECT_FLAG) === '1';
} catch (error) {
return false;
}
}
function setFlag() {
try {
sessionStorage.setItem(OIDC_AUTO_REDIRECT_FLAG, '1');
} catch (error) {
// Nothing to do: the auto-redirect will simply be able to fire again.
}
}
function clearFlag() {
try {
sessionStorage.removeItem(OIDC_AUTO_REDIRECT_FLAG);
} catch (error) {
// Nothing to do.
}
}
export const OidcAutoRedirect = { hasAlreadyFired: readFlag, markFired: setFlag, clear: clearFlag };
+15
View File
@@ -145,6 +145,21 @@ if (Meteor.isClient) {
});
}
// #6681: a successful login clears the OIDC auto-redirect one-shot flag
// (client/lib/oidcAutoRedirect.js), so a LATER logout can still trigger the
// auto-redirect again rather than being permanently stuck off after the
// first tab session. A failed login clears it too, so a genuine error
// (bad IdP config, id-map mismatch, ...) does not leave the sign-in page
// silently refusing to ever try again - the difference between a fixed
// loop and a new, different kind of stuck state.
const { OidcAutoRedirect } = require('/client/lib/oidcAutoRedirect');
if (Accounts && typeof Accounts.onLogin === 'function') {
Accounts.onLogin(() => OidcAutoRedirect.clear());
}
if (Accounts && typeof Accounts.onLoginFailure === 'function') {
Accounts.onLoginFailure(() => OidcAutoRedirect.clear());
}
const { Template } = require('meteor/templating');
const { T9n } = require('meteor/communitypackages:core');
// Fields WeKan translates itself (complete data/<lang>.i18n.json coverage), keyed
+92
View File
@@ -0,0 +1,92 @@
'use strict';
// #6681: "SSO with OIDC goes into loop" - with `oauth2-login-style: redirect`
// and `oidc-redirection-enabled: true`, the browser looped between WeKan and
// the identity provider until the provider started rate-limiting the
// repeated /authorize requests.
//
// Root cause: Template.userFormsLayout.onCreated (client/components/main/
// layouts.js) fired the OIDC auto-redirect unconditionally on every render.
// Meteor's redirect-style OAuth has no dedicated callback route - the
// identity provider's callback bounces the browser back to this SAME
// sign-in page - and the login method that follows a successful callback
// completes asynchronously, racing this template's own re-render. When the
// auto-redirect check ran before that login had finished, it saw "not
// logged in yet" and fired a brand new navigation straight back to the
// provider, forever.
//
// Fix: a one-shot sessionStorage flag (client/lib/oidcAutoRedirect.js) that
// survives the full-page round trip to the provider and back, so the
// bounce-back render does not re-fire the redirect; cleared on login
// success/failure (config/accounts.js) so a later logout/retry still works.
//
// Run: node tests/oidcAutoRedirectLoop.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
console.log('oidcAutoRedirectLoop:');
const helper = read('client/lib/oidcAutoRedirect.js');
const layouts = read('client/components/main/layouts.js');
const accounts = read('config/accounts.js');
test('the auto-redirect helper checks a one-shot flag before ever writing it', () => {
assert.match(helper, /function readFlag\(\)/);
assert.match(helper, /sessionStorage\.getItem\(OIDC_AUTO_REDIRECT_FLAG\) === '1'/);
assert.match(helper, /function setFlag\(\)/);
assert.match(helper, /sessionStorage\.setItem\(OIDC_AUTO_REDIRECT_FLAG, '1'\)/);
});
test('a sessionStorage exception fails OPEN, not closed (negative)', () => {
// A storage restriction (private browsing, policy) must not turn into a
// SECOND, different way to break the same login by refusing to ever
// redirect at all.
const readFn = helper.slice(helper.indexOf('function readFlag'), helper.indexOf('function setFlag'));
assert.match(readFn, /catch \(error\) \{\s*return false;/);
});
test('layouts.js imports the helper and gates the auto-redirect on it', () => {
assert.match(layouts, /import \{ OidcAutoRedirect \} from '\/client\/lib\/oidcAutoRedirect'/);
assert.match(layouts, /if \(result && !OidcAutoRedirect\.hasAlreadyFired\(\)\) \{/,
'the redirect only fires when the flag has not already been set this tab session');
assert.match(layouts, /OidcAutoRedirect\.markFired\(\);/,
'and the flag is set before navigating away, so the bounce-back sees it');
});
test('markFired happens BEFORE the navigating call, not after (negative)', () => {
// Meteor.loginWithOidc(...) triggers a full-page navigation for redirect
// style; code placed after it in the same callback would never run.
const at = layouts.indexOf('OidcAutoRedirect.markFired();');
const loginCallAt = layouts.indexOf('Meteor.loginWithOidc(options);');
assert.ok(at !== -1 && loginCallAt !== -1 && at < loginCallAt,
'markFired() must be called before Meteor.loginWithOidc(), not after');
});
test('a successful AND a failed login both clear the flag', () => {
assert.match(accounts, /require\('\/client\/lib\/oidcAutoRedirect'\)/);
assert.match(accounts, /Accounts\.onLogin\(\(\) => OidcAutoRedirect\.clear\(\)\)/);
assert.match(accounts, /Accounts\.onLoginFailure\(\(\) => OidcAutoRedirect\.clear\(\)\)/);
});
test('the clearing import stays inside the client-only guard (negative)', () => {
// config/accounts.js is loaded by BOTH client/imports.js and
// server/imports.js - a top-level `import` of a client/-pathed module
// would pull client-only code into the server bundle. It must only be
// required inside `if (Meteor.isClient)`.
assert.ok(!/^import \{ OidcAutoRedirect \}/m.test(accounts),
'no top-level (unconditional) import of the client-only helper');
const clientBlockAt = accounts.indexOf('if (Meteor.isClient)');
const requireAt = accounts.indexOf("require('/client/lib/oidcAutoRedirect')");
assert.ok(clientBlockAt !== -1 && requireAt > clientBlockAt,
'the require() call sits after the isClient guard opens');
});
console.log(`\noidcAutoRedirectLoop: ${passed} tests passed`);