mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
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:
1 parent
a48819ae9a
commit
89682c251f
4 files changed
+173
-1
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user