mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-10 12:42:27 +09:00
184 lines
6.4 KiB
TypeScript
184 lines
6.4 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {domainToASCII} from 'node:url';
|
|
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
|
|
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
|
import type {RequestUrlPolicy} from '@pkgs/http_client/src/HttpClientTypes';
|
|
import {createPublicInternetRequestUrlPolicy} from '@pkgs/http_client/src/PublicInternetRequestUrlPolicy';
|
|
import {Config} from '../Config';
|
|
|
|
interface SsoConfigValidationInput {
|
|
enabled: boolean;
|
|
enforced: boolean;
|
|
issuer: string | null;
|
|
authorizationUrl: string | null;
|
|
tokenUrl: string | null;
|
|
userInfoUrl: string | null;
|
|
jwksUrl: string | null;
|
|
clientId: string | null;
|
|
allowedEmailDomains: Array<string>;
|
|
}
|
|
|
|
interface NormalizedSsoConfigValidationResult extends SsoConfigValidationInput {
|
|
ready: boolean;
|
|
}
|
|
|
|
let ssoRequestUrlPolicy: RequestUrlPolicy | null = null;
|
|
|
|
export function getSsoRequestUrlPolicy(): RequestUrlPolicy {
|
|
if (ssoRequestUrlPolicy === null) {
|
|
ssoRequestUrlPolicy = createPublicInternetRequestUrlPolicy({
|
|
allowPrivateAddresses: Config.auth.ssoAllowPrivateAddresses,
|
|
});
|
|
}
|
|
return ssoRequestUrlPolicy;
|
|
}
|
|
const DOMAIN_LABEL_REGEX = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
|
|
|
function normalizeOptionalSsoString(value: string | null): string | null {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
}
|
|
|
|
function throwInvalidDomain(): never {
|
|
throw InputValidationError.fromCode('allowed_domains', ValidationErrorCodes.INVALID_FORMAT);
|
|
}
|
|
|
|
export function deriveSsoRedirectUri(webAppEndpoint: string): string {
|
|
return `${webAppEndpoint.replace(/\/+$/, '')}/auth/sso/callback`;
|
|
}
|
|
|
|
export function isTestSsoProvider(
|
|
config: {
|
|
authorizationUrl: string | null;
|
|
tokenUrl: string | null;
|
|
},
|
|
testModeEnabled: boolean,
|
|
): boolean {
|
|
return (
|
|
config.authorizationUrl === 'test' ||
|
|
config.tokenUrl === 'test' ||
|
|
(testModeEnabled && (config.authorizationUrl?.startsWith('test-') ?? false))
|
|
);
|
|
}
|
|
|
|
export function normalizeSsoAllowedEmailDomains(domains: Array<string>): Array<string> {
|
|
const normalized = new Set<string>();
|
|
for (const rawDomain of domains) {
|
|
const trimmed = rawDomain.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
if (
|
|
trimmed.includes('@') ||
|
|
trimmed.includes('/') ||
|
|
trimmed.includes('\\') ||
|
|
trimmed.includes(':') ||
|
|
trimmed.startsWith('.') ||
|
|
trimmed.endsWith('.') ||
|
|
trimmed.startsWith('*.') ||
|
|
trimmed.includes('*')
|
|
) {
|
|
throwInvalidDomain();
|
|
}
|
|
const asciiDomain = domainToASCII(trimmed);
|
|
if (!asciiDomain || asciiDomain.length > 253 || !asciiDomain.includes('.')) {
|
|
throwInvalidDomain();
|
|
}
|
|
const labels = asciiDomain.split('.');
|
|
if (labels.some((label) => !DOMAIN_LABEL_REGEX.test(label)) || /^\d+$/.test(labels[labels.length - 1] ?? '')) {
|
|
throwInvalidDomain();
|
|
}
|
|
normalized.add(asciiDomain);
|
|
}
|
|
return Array.from(normalized);
|
|
}
|
|
|
|
export async function validateSsoPublicOutboundUrl(rawUrl: string, fieldName: string): Promise<string> {
|
|
let parsedUrl: URL;
|
|
try {
|
|
parsedUrl = new URL(rawUrl);
|
|
} catch {
|
|
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
|
|
}
|
|
if (parsedUrl.protocol !== 'https:' || parsedUrl.username || parsedUrl.password || parsedUrl.hash) {
|
|
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.INVALID_URL_FORMAT);
|
|
}
|
|
try {
|
|
await getSsoRequestUrlPolicy().validate(parsedUrl, {
|
|
phase: 'initial',
|
|
redirectCount: 0,
|
|
});
|
|
} catch {
|
|
throw InputValidationError.fromCode(fieldName, ValidationErrorCodes.URL_NOT_PUBLICLY_ROUTABLE);
|
|
}
|
|
// Return the caller's exact input rather than parsedUrl.toString(), which would normalize
|
|
// the URL (e.g. appending a trailing slash) and break exact-match comparisons such as the
|
|
// OIDC issuer check in jwtVerify. Invalid URLs have already thrown above.
|
|
return rawUrl;
|
|
}
|
|
|
|
async function normalizeOptionalSsoUrl(
|
|
rawUrl: string | null,
|
|
fieldName: string,
|
|
skipValidation: boolean,
|
|
): Promise<string | null> {
|
|
const normalized = normalizeOptionalSsoString(rawUrl);
|
|
if (!normalized || skipValidation) {
|
|
return normalized;
|
|
}
|
|
return validateSsoPublicOutboundUrl(normalized, fieldName);
|
|
}
|
|
|
|
function resolveSsoReadiness(config: SsoConfigValidationInput, isTestProvider: boolean): boolean {
|
|
if (!config.enabled) {
|
|
return false;
|
|
}
|
|
if (isTestProvider) {
|
|
return Boolean(config.clientId);
|
|
}
|
|
const canUseIssuerDiscovery = Boolean(config.issuer);
|
|
const hasAuthorizationEndpoint = Boolean(config.authorizationUrl) || canUseIssuerDiscovery;
|
|
const hasTokenEndpoint = Boolean(config.tokenUrl) || canUseIssuerDiscovery;
|
|
const canResolveClaims = Boolean(config.jwksUrl || config.userInfoUrl || canUseIssuerDiscovery);
|
|
return Boolean(hasAuthorizationEndpoint && hasTokenEndpoint && config.clientId && canResolveClaims);
|
|
}
|
|
|
|
export async function normalizeAndValidateSsoConfig(
|
|
config: SsoConfigValidationInput,
|
|
options: {testModeEnabled: boolean},
|
|
): Promise<NormalizedSsoConfigValidationResult> {
|
|
const baseConfig = {
|
|
...config,
|
|
issuer: normalizeOptionalSsoString(config.issuer),
|
|
authorizationUrl: normalizeOptionalSsoString(config.authorizationUrl),
|
|
tokenUrl: normalizeOptionalSsoString(config.tokenUrl),
|
|
userInfoUrl: normalizeOptionalSsoString(config.userInfoUrl),
|
|
jwksUrl: normalizeOptionalSsoString(config.jwksUrl),
|
|
clientId: normalizeOptionalSsoString(config.clientId),
|
|
allowedEmailDomains: normalizeSsoAllowedEmailDomains(config.allowedEmailDomains),
|
|
};
|
|
const isTestProvider = isTestSsoProvider(baseConfig, options.testModeEnabled);
|
|
const skipUrlValidation = isTestProvider || !baseConfig.enabled;
|
|
const normalized: SsoConfigValidationInput = {
|
|
...baseConfig,
|
|
issuer: await normalizeOptionalSsoUrl(baseConfig.issuer, 'issuer', skipUrlValidation),
|
|
authorizationUrl: await normalizeOptionalSsoUrl(
|
|
baseConfig.authorizationUrl,
|
|
'authorization_url',
|
|
skipUrlValidation,
|
|
),
|
|
tokenUrl: await normalizeOptionalSsoUrl(baseConfig.tokenUrl, 'token_url', skipUrlValidation),
|
|
userInfoUrl: await normalizeOptionalSsoUrl(baseConfig.userInfoUrl, 'userinfo_url', skipUrlValidation),
|
|
jwksUrl: await normalizeOptionalSsoUrl(baseConfig.jwksUrl, 'jwks_url', skipUrlValidation),
|
|
};
|
|
const ready = resolveSsoReadiness(normalized, isTestProvider);
|
|
if (normalized.enabled && normalized.enforced && !ready) {
|
|
throw InputValidationError.fromCode('sso', ValidationErrorCodes.SSO_MISCONFIGURED);
|
|
}
|
|
return {...normalized, ready};
|
|
}
|