fix(auth): normalize v2 signin redirects (#9927)

* fix(auth): normalize v2 signin redirects

* fix(auth): check redirect sub-app path safely

* fix: detect pro plugin license paths

* refactor(client-v2): simplify auth redirect fallback

* refactor(client-v2): clarify auth redirect fallback

* fix(auth): support configured app-sso issuer
This commit is contained in:
YANG QIA
2026-06-29 21:18:04 +08:00
committed by GitHub
parent a19e2cca66
commit 2e780ffe7f
11 changed files with 264 additions and 6 deletions
@@ -10,6 +10,7 @@
import {
buildV2SigninHref,
getCurrentV2RedirectPath,
normalizeV2RedirectPath,
redirectToV2Signin,
resolveV2SigninRedirect,
} from '../authRedirect';
@@ -133,6 +134,22 @@ describe('auth redirect helpers', () => {
});
describe('v2 sub-app context (router basename contains /apps/<id>/)', () => {
it('should normalize signin redirect fallback under the current sub-app basename', () => {
const app = {
getPublicPath: () => '/v/',
router: {
getBasename: () => '/v/apps/test-app/',
},
} as any;
expect(normalizeV2RedirectPath(app, '')).toBe('/v/apps/test-app/admin/');
expect(normalizeV2RedirectPath(app, '/admin/?tab=overview#panel')).toBe(
'/v/apps/test-app/admin/?tab=overview#panel',
);
expect(normalizeV2RedirectPath(app, '/v/apps/test-app/admin/')).toBe('/v/apps/test-app/admin/');
expect(normalizeV2RedirectPath(app, '/v/admin/')).toBe('/v/apps/test-app/admin/');
});
it('should preserve sub-app segment when building current redirect path under simple public path', () => {
const app = {
getPublicPath: () => '/v2/',
@@ -204,6 +204,44 @@ function getDefaultV2AdminRedirectPath(app: AppLike) {
return joinRootRelativePath(getV2EffectiveBasePath(app), '/admin');
}
function isSafeRootRelativePath(value?: string | null) {
return !!value && value.startsWith('/') && !value.startsWith('//') && !value.startsWith('/\\');
}
function preserveTrailingSlash(originalPathname: string, value: string) {
if (originalPathname !== '/' && originalPathname.endsWith('/') && !value.endsWith('/')) {
return `${value}/`;
}
return value;
}
export function normalizeV2RedirectPath(app: AppLike, target?: string | null, fallbackPath = '/admin/') {
// In a v2 sub-app, publicPath can be `/v/` while the active router
// basename is `/v/apps/a/`. Redirects must resolve under the basename.
const basePath = trimTrailingSlashes(getV2EffectiveBasePath(app)) || '/';
const fallbackTarget = isSafeRootRelativePath(fallbackPath) ? fallbackPath : '/admin/';
const rawTarget = isSafeRootRelativePath(target) ? target : fallbackTarget;
let { pathname, search, hash } = splitPathLike(rawTarget);
let normalizedPathname = normalizePathname(pathname);
// Already under the current v2 runtime, e.g. `/v/apps/a/admin/`.
if (basePath === '/' || normalizedPathname === basePath || normalizedPathname.startsWith(`${basePath}/`)) {
return `${preserveTrailingSlash(pathname, normalizedPathname)}${normalizeSearch(search)}${normalizeHash(hash)}`;
}
const publicPath = trimTrailingSlashes(getV2PublicPath(app)) || '/';
// Under v2 publicPath but outside the current basename, e.g. `/v/admin/`
// inside `/v/apps/a/`; use fallback so it lands on `/v/apps/a/admin/`.
if (publicPath !== '/' && (normalizedPathname === publicPath || normalizedPathname.startsWith(`${publicPath}/`))) {
({ pathname, search, hash } = splitPathLike(fallbackTarget));
normalizedPathname = normalizePathname(pathname);
}
// Basename-relative target, e.g. `/admin/`, becomes `/v/apps/a/admin/`.
const joinedPathname = preserveTrailingSlash(pathname, joinRootRelativePath(basePath, normalizedPathname));
return `${joinedPathname}${normalizeSearch(search)}${normalizeHash(hash)}`;
}
/**
* 将当前 v2 页面地址转换为根相对 redirect 路径。
*
@@ -0,0 +1,33 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { AppSupervisor } from '../../app-supervisor';
describe('AppSupervisor app-sso issuer bridge', () => {
afterEach(async () => {
await AppSupervisor.getInstance().destroy();
});
it('stores normalized app-sso issuer values', () => {
const supervisor = AppSupervisor.getInstance();
supervisor.setAppSsoIssuer(' https://main.example.com/api/ ');
expect(supervisor.getAppSsoIssuer()).toBe('https://main.example.com/api');
});
it('clears blank app-sso issuer values', () => {
const supervisor = AppSupervisor.getInstance();
supervisor.setAppSsoIssuer('https://main.example.com/api');
supervisor.setAppSsoIssuer(' ');
expect(supervisor.getAppSsoIssuer()).toBeUndefined();
});
});
@@ -75,6 +75,7 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
private commandAdapterName: string;
private appDbCreator = new ConditionalRegistry<AppDbCreatorOptions, void>();
private appConditions = new Map<string, AppCondition>();
private appSsoIssuer?: string;
public appOptionsFactory: AppOptionsFactory = appOptionsFactory;
private environmentHeartbeatInterval = 2 * 60 * 1000;
@@ -296,6 +297,17 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
this.appOptionsFactory = factory ?? appOptionsFactory;
}
setAppSsoIssuer(issuer?: string) {
const normalized = String(issuer || '')
.trim()
.replace(/\/+$/, '');
this.appSsoIssuer = normalized || undefined;
}
getAppSsoIssuer() {
return this.appSsoIssuer;
}
async bootstrapApp(appName: string) {
return this.processAdapter.bootstrapApp(appName);
}
@@ -0,0 +1,79 @@
/**
* This file is part of the NocoBase (R) project.
* Copyright (c) 2020-2024 NocoBase Co., Ltd.
* Authors: NocoBase Team.
*
* This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License.
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import SignInPage from '../pages/SignInPage';
const navigateMock = vi.fn();
const mockApp = vi.hoisted(() => ({
publicPath: '/v/',
basename: '/v/apps/sub/',
}));
vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
};
});
vi.mock('@nocobase/client-v2', async (importOriginal) => {
const actual = await importOriginal<typeof import('@nocobase/client-v2')>();
return {
...actual,
useApp: () => ({
getPublicPath: () => mockApp.publicPath,
router: {
getBasename: () => mockApp.basename,
},
}),
usePlugin: () => ({
authTypes: {
getEntities: () => [],
},
}),
};
});
describe('SignInPage', () => {
beforeEach(() => {
navigateMock.mockReset();
mockApp.publicPath = '/v/';
mockApp.basename = '/v/apps/sub/';
});
it('normalizes empty redirect to the current v2 app admin path', () => {
render(
<MemoryRouter initialEntries={['/signin?redirect=']}>
<SignInPage />
</MemoryRouter>,
);
expect(navigateMock).toHaveBeenCalledWith(
{
pathname: '/signin',
search: '?redirect=%2Fv%2Fapps%2Fsub%2Fadmin%2F',
},
{ replace: true },
);
});
it('keeps redirect that is already under the current v2 app basename', () => {
render(
<MemoryRouter initialEntries={['/signin?redirect=%2Fv%2Fapps%2Fsub%2Fadmin%2F']}>
<SignInPage />
</MemoryRouter>,
);
expect(navigateMock).not.toHaveBeenCalled();
});
});
@@ -7,9 +7,10 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { usePlugin } from '@nocobase/client-v2';
import { normalizeV2RedirectPath, useApp, usePlugin } from '@nocobase/client-v2';
import { Empty, Space, Spin, Tabs } from 'antd';
import React, { lazy, Suspense, useContext, useMemo } from 'react';
import React, { lazy, Suspense, useContext, useEffect, useMemo } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { AuthenticatorsContext, type Authenticator } from '../authenticator';
import { useDocumentTitle } from '../hooks';
import { useAuthTranslation, useT } from '../locale';
@@ -43,6 +44,9 @@ function lazyByAuthType<P>(loaderMap: LoaderMap<() => Promise<{ default: React.C
}
export default function SignInPage() {
const app = useApp();
const location = useLocation();
const navigate = useNavigate();
const { t } = useAuthTranslation();
// `authTypeTitle` 从服务端来时是 `tval` 生成的原始模板字符串
// `{{t("Password", {"ns":"@nocobase/plugin-auth"})}}`),不展开就会直出到 tab label
@@ -57,6 +61,23 @@ export default function SignInPage() {
const resolveSignInButton = useMemo(() => lazyByAuthType(signInButtonLoaders), [signInButtonLoaders]);
useDocumentTitle(t('Signin'));
useEffect(() => {
const params = new URLSearchParams(location.search);
const redirect = params.get('redirect');
const normalized = normalizeV2RedirectPath(app, redirect);
if (redirect === normalized) {
return;
}
params.set('redirect', normalized);
navigate(
{
pathname: location.pathname,
search: `?${params}`,
},
{ replace: true },
);
}, [app, location.pathname, location.search, navigate]);
const tabs = useMemo(() => {
return authenticators
@@ -89,6 +89,21 @@ describe('buildRedirectPath', () => {
).toBe('/nocobase/v2/apps/a_u4940c6p189/admin/al5yj9t81of');
});
it('does NOT prepend sub-app segment when target already contains it', () => {
expect(buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/v/apps/sub/admin' })).toBe(
'/v/apps/sub/admin',
);
expect(
buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/v/apps/sub/admin?tab=x#panel' }),
).toBe('/v/apps/sub/admin?tab=x#panel');
});
it('does NOT treat query strings containing the sub-app segment as an existing sub-app path', () => {
expect(
buildRedirectPath({ appPublicPath: '', subAppSegment: '/apps/sub', target: '/admin?next=/apps/sub/foo' }),
).toBe('/apps/sub/admin?next=/apps/sub/foo');
});
it('does NOT touch a v2 main-app target even when a sub-app segment was supplied', () => {
// Detection is appPublicPath-only; supplying subAppSegment must not
// override the v2-detection branch.
@@ -53,6 +53,7 @@ export function buildRedirectPath({ appPublicPath, subAppSegment, target }: Buil
const normalizedAppPublicPath = (appPublicPath || '').replace(/\/+$/, '');
const normalizedSubAppSegment = (subAppSegment || '').replace(/\/+$/, '');
const resolvedTarget = target || '/admin';
const [resolvedPathname] = resolvedTarget.split(/[?#]/, 1);
if (
normalizedAppPublicPath &&
@@ -61,6 +62,17 @@ export function buildRedirectPath({ appPublicPath, subAppSegment, target }: Buil
return resolvedTarget;
}
const modernSubAppSegment = `/${getModernClientPrefix()}${normalizedSubAppSegment}`;
if (
normalizedSubAppSegment &&
(resolvedPathname === normalizedSubAppSegment ||
resolvedPathname.startsWith(`${normalizedSubAppSegment}/`) ||
resolvedPathname === modernSubAppSegment ||
resolvedPathname.startsWith(`${modernSubAppSegment}/`))
) {
return resolvedTarget;
}
return `${normalizedAppPublicPath}${normalizedSubAppSegment}${resolvedTarget}`;
}
@@ -96,6 +96,27 @@ describe('plugin-idp-oauth > IdpOauthService', () => {
);
});
test('should use the first forwarded header value when proxies append duplicates', () => {
const service = new IdpOauthService({} as any, {} as any);
vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({
runningMode: 'multiple',
} as any);
const providerContext = service.getProviderContext({
app: { name: 'subapp' },
path: '/api/app:getLang',
protocol: 'http',
host: '127.0.0.1:13001',
headers: {
'x-forwarded-proto': 'http,http',
'x-forwarded-host': 'app.noco.local',
},
});
expect(providerContext.origin).toBe('http://app.noco.local');
expect(providerContext.issuer).toBe('http://app.noco.local/api');
});
test('should preserve sub app issuer path when original url uses __app prefix', () => {
const service = new IdpOauthService({} as any, {} as any);
vi.spyOn(AppSupervisor, 'getInstance').mockReturnValue({
@@ -51,6 +51,14 @@ function getJoseModule() {
return joseModulePromise;
}
function getHeaderValue(value: unknown) {
const raw = Array.isArray(value) ? value[0] : value;
if (typeof raw !== 'string') {
return undefined;
}
return raw.split(',')[0]?.trim() || undefined;
}
export type ResourceServerConfig = {
path?: string;
identifier?: string;
@@ -255,8 +263,8 @@ export class IdpOauthService {
}
getOrigin(ctx: any) {
const protocol = ctx.headers?.['x-forwarded-proto'] || ctx.protocol || 'http';
const host = ctx.headers?.['x-forwarded-host'] || ctx.host || '';
const protocol = getHeaderValue(ctx.headers?.['x-forwarded-proto']) || ctx.protocol || 'http';
const host = getHeaderValue(ctx.headers?.['x-forwarded-host']) || ctx.host || '';
return process.env.APP_PUBLIC_ORIGIN || `${protocol}://${host}`;
}
@@ -644,7 +652,7 @@ export class IdpOauthService {
ctx.cookies?.set?.(provider.cookieName('session'), null, {
httpOnly: true,
sameSite: 'lax',
secure: (ctx.headers?.['x-forwarded-proto'] || ctx.protocol) === 'https',
secure: (getHeaderValue(ctx.headers?.['x-forwarded-proto']) || ctx.protocol) === 'https',
});
}
+3 -1
View File
@@ -1,5 +1,6 @@
const fs = require('fs/promises');
const { exec } = require('child_process');
const path = require('path');
const commercialLicense = `
/**
@@ -27,11 +28,12 @@ function getLicenseText(packageDir) {
}
async function addLicenseToFile(filePath) {
const licenseText = getLicenseText(filePath);
const licenseText = getLicenseText(path.resolve(filePath));
const data = await fs.readFile(filePath, 'utf8');
if (data.startsWith(licenseText)) return false;
if (data.startsWith(commercialLicense) || data.startsWith(openSourceLicense)) return false;
// 添加授权信息到文件内容的顶部
const newData = licenseText + '\n\n' + data;