diff --git a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx
index 7e3dfeb2e..df0b43271 100644
--- a/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx
+++ b/fluxer_app/src/features/app/components/setup/SelfHostedSetupWizardGate.tsx
@@ -3,6 +3,7 @@
import * as Modal from '@app/features/app/components/dialogs/Modal';
import styles from '@app/features/app/components/setup/SelfHostedSetupWizardGate.module.css';
import {
+ classifySetupUnauthorized,
fetchInstanceConfig,
type SetupBrandingAssetKind,
testSmtpConfig,
@@ -55,6 +56,7 @@ import {fileToBase64} from '@app/features/user/utils/AvatarUtils';
import * as FormUtils from '@app/lib/forms';
import {type ThemeType, ThemeTypes} from '@fluxer/constants/src/UserConstants';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
+import type {MessageDescriptor} from '@lingui/core';
import {msg} from '@lingui/core/macro';
import {useLingui} from '@lingui/react/macro';
import {ArrowLeftIcon, ArrowRightIcon, CheckIcon, WrenchIcon} from '@phosphor-icons/react';
@@ -92,6 +94,11 @@ const LOAD_ERROR_DESCRIPTOR = msg({
message: 'Could not load the instance configuration. Try reloading the page.',
comment: 'Error shown when the setup wizard fails to load the instance configuration.',
});
+const ORIGIN_MISMATCH_DESCRIPTOR = msg({
+ message:
+ 'The API is on a different origin than this page, so setup requests are sent without your session. Check the public origin and port this instance is configured with, then reload.',
+ comment: 'Error shown when the setup wizard cannot load because the API origin differs from the page origin.',
+});
const ASSET_UPLOAD_ERROR_DESCRIPTOR = msg({
message: 'That image could not be used. Try a different file.',
comment: 'Error shown when a branding image fails to upload in the setup wizard.',
@@ -425,7 +432,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
const stepNavigationUnlockTimerRef = useRef(null);
const [config, setConfig] = useState(null);
- const [loadError, setLoadError] = useState(false);
+ const [loadError, setLoadError] = useState(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const [stepNavigationLocked, setStepNavigationLocked] = useState(false);
@@ -531,11 +538,11 @@ export const SelfHostedSetupWizardGate = observer(() => {
}, [authStoreAuthenticated, forceUnauthenticatedSetup]);
const resetStaleSetupSession = useCallback(async () => {
- logger.warn('Instance config fetch returned 401 during setup; clearing stale local setup session');
+ logger.warn('The setup session token was rejected. Clearing the stale local setup session.');
setForceUnauthenticatedSetup(true);
registerFormDraftsRef.current.clear();
setConfig(null);
- setLoadError(false);
+ setLoadError(null);
setSubmitError(null);
setSubmitting(false);
setWizardSnapshot(createSetupWizardSnapshot());
@@ -605,7 +612,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
useEffect(() => {
if (!isAuthenticated || config) return;
let cancelled = false;
- setLoadError(false);
+ setLoadError(null);
void (async () => {
try {
const next = await fetchInstanceConfig();
@@ -613,12 +620,15 @@ export const SelfHostedSetupWizardGate = observer(() => {
hydrateFromConfig(next);
} catch (error) {
if (cancelled) return;
- if (error instanceof HttpError && error.status === 401) {
+ const cause =
+ error instanceof HttpError && error.status === 401 ? await classifySetupUnauthorized() : 'unknown';
+ if (cancelled) return;
+ if (cause === 'stale_session') {
await resetStaleSetupSession();
return;
}
logger.error('Failed to load instance configuration', error);
- setLoadError(true);
+ setLoadError(cause === 'origin_mismatch' ? ORIGIN_MISMATCH_DESCRIPTOR : LOAD_ERROR_DESCRIPTOR);
}
})();
return () => {
@@ -868,7 +878,7 @@ export const SelfHostedSetupWizardGate = observer(() => {
role="alert"
data-flx="app.self-hosted-setup-wizard-gate.load-error"
>
- {i18n._(LOAD_ERROR_DESCRIPTOR)}
+ {i18n._(loadError)}
) : (
diff --git a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts
index 82c0e4bf9..3484b4bff 100644
--- a/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts
+++ b/fluxer_app/src/features/app/components/setup/SetupWizardClient.ts
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {Endpoints} from '@app/features/app/constants/Endpoints';
+import SessionManager from '@app/features/platform/state/AuthSession';
import {http} from '@app/features/platform/transport/RestTransport';
+import {Logger} from '@app/features/platform/utils/AppLogger';
import type {
BrandingAssetUploadRequest,
InstanceConfigResponse,
@@ -10,6 +12,8 @@ import type {
InstanceEmailSmtpTestResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
+const logger = new Logger('SetupWizardClient');
+
export type SetupBrandingAssetKind = BrandingAssetUploadRequest['kind'];
export async function fetchInstanceConfig(): Promise {
@@ -35,3 +39,17 @@ export async function testSmtpConfig(body: InstanceEmailSmtpTestRequest): Promis
const response = await http.post(Endpoints.ADMIN_INSTANCE_CONFIG_SMTP_TESTS, {body});
return response.body;
}
+
+export type SetupUnauthorizedCause = 'stale_session' | 'origin_mismatch' | 'unknown';
+
+export async function classifySetupUnauthorized(): Promise {
+ if (!SessionManager.token) return 'unknown';
+ if (!http.carriesAuthorization()) return 'origin_mismatch';
+ try {
+ const response = await http.get(Endpoints.USER_ME, {mode: 'silent'});
+ return response.status === 401 ? 'stale_session' : 'unknown';
+ } catch (error) {
+ logger.warn('Could not confirm whether the setup session is still valid', error);
+ return 'unknown';
+ }
+}
diff --git a/fluxer_app/src/features/platform/transport/RestTransport.ts b/fluxer_app/src/features/platform/transport/RestTransport.ts
index edabf1ab4..d88242c1a 100644
--- a/fluxer_app/src/features/platform/transport/RestTransport.ts
+++ b/fluxer_app/src/features/platform/transport/RestTransport.ts
@@ -84,6 +84,8 @@ interface OnlineWaiter {
onAbort: () => void;
}
+const strippedAuthorizationOrigins = new Set();
+
const onlineWaiters = new Set();
let onlineListenerActive = false;
@@ -167,6 +169,10 @@ export class RestClient {
this.state.globalIntercept = hooks.intercept;
}
+ carriesAuthorization(): boolean {
+ return !isOffOrigin(resolveUrl(this.state, '/', undefined));
+ }
+
dispatch(method: HttpMethod, path: string, options: RestRequestOptions = {}): Promise> {
return runWithSudoEscalation(this.state, method, path, options, 'fresh');
}
@@ -319,7 +325,12 @@ function composePlan(
): Plan {
const url = resolveUrl(state, path, options.query);
const body = encodeBody(options);
- const sameOrigin = !looksAbsolute(path) && !isOffOrigin(url);
+ const targetsApiBase = !looksAbsolute(path);
+ const apiOrigin = targetsApiBase ? originOf(url) : null;
+ const sameOrigin = targetsApiBase && (apiOrigin === null || apiOrigin === window.location.origin);
+ if (apiOrigin !== null && !sameOrigin) {
+ reportStrippedAuthorization(state, apiOrigin, options.auth);
+ }
const headers = assembleHeaders({
state,
callerHeaders: options.headers,
@@ -365,14 +376,26 @@ function looksAbsolute(path: string): boolean {
return path.startsWith('//') || /^[a-z][a-z0-9+.-]*:\/\//i.test(path);
}
-function isOffOrigin(url: string): boolean {
+function originOf(url: string): string | null {
try {
- return new URL(url).origin !== window.location.origin;
+ return new URL(url).origin;
} catch {
- return false;
+ return null;
}
}
+function isOffOrigin(url: string): boolean {
+ const origin = originOf(url);
+ return origin !== null && origin !== window.location.origin;
+}
+
+function reportStrippedAuthorization(state: RuntimeState, apiOrigin: string, auth: RestAuthMode | undefined): void {
+ if (auth === 'none' || strippedAuthorizationOrigins.has(apiOrigin)) return;
+ if (!state.authProvider()) return;
+ strippedAuthorizationOrigins.add(apiOrigin);
+ log.warn(`authorization withheld from off-origin api base: ${apiOrigin} (page ${window.location.origin})`);
+}
+
function encodeBody(options: RestRequestOptions): BodyShape {
if (options.multipart) {
return {tag: 'form', payload: buildFormData(options.multipart)};