mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 20:42:08 +00:00
434 lines
12 KiB
JavaScript
434 lines
12 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const OAUTH_CLIENTS_FILE = path.join(__dirname, '..', 'data', 'oauth-clients.json');
|
|
const OAUTH_CLIENTS_ENV = 'MOVIX_OAUTH_CLIENTS_JSON';
|
|
const KNOWN_OAUTH_SCOPES = ['profile.read', 'profile.list', 'profile.manage', 'vip.read', 'vip.manage'];
|
|
const DEFAULT_SCOPE = 'profile.read';
|
|
const OAUTH_DEBUG_ENABLED = process.env.MOVIX_OAUTH_DEBUG === 'true';
|
|
|
|
let cache = {
|
|
fileMtimeMs: -1,
|
|
envRaw: null,
|
|
clients: [],
|
|
};
|
|
|
|
function safeJsonParse(rawValue, fallback) {
|
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
|
return fallback;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(rawValue);
|
|
} catch (error) {
|
|
console.error('[OAuth Clients] Invalid JSON payload:', error.message || error);
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function normalizeHttpUrl(rawValue) {
|
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(rawValue.trim());
|
|
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
return null;
|
|
}
|
|
|
|
parsed.hash = '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLoopbackHostname(hostname) {
|
|
const normalizedHostname = String(hostname || '').toLowerCase();
|
|
return normalizedHostname === 'localhost'
|
|
|| normalizedHostname === '127.0.0.1'
|
|
|| normalizedHostname === '::1'
|
|
|| normalizedHostname === '[::1]'
|
|
|| normalizedHostname.endsWith('.localhost');
|
|
}
|
|
|
|
function normalizeRedirectUri(rawValue) {
|
|
if (typeof rawValue !== 'string' || !rawValue.trim()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const parsed = new URL(rawValue.trim());
|
|
const isLoopbackHost = isLoopbackHostname(parsed.hostname);
|
|
|
|
if (parsed.protocol !== 'https:' && !(parsed.protocol === 'http:' && isLoopbackHost)) {
|
|
return null;
|
|
}
|
|
|
|
parsed.hash = '';
|
|
return parsed.toString();
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function normalizePathname(pathname) {
|
|
const normalizedPathname = String(pathname || '/').trim() || '/';
|
|
return normalizedPathname.replace(/\/+$/g, '') || '/';
|
|
}
|
|
|
|
function getLoopbackRedirectPaths(client) {
|
|
if (!client || !Array.isArray(client.redirectUris)) {
|
|
return [];
|
|
}
|
|
|
|
return uniqueStrings(
|
|
client.redirectUris
|
|
.map((redirectUri) => {
|
|
try {
|
|
const parsed = new URL(redirectUri);
|
|
return isLoopbackHostname(parsed.hostname) ? normalizePathname(parsed.pathname) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
);
|
|
}
|
|
|
|
function uniqueStrings(values) {
|
|
return Array.from(new Set(values.filter(Boolean)));
|
|
}
|
|
|
|
function logOauthDebug(message, payload) {
|
|
if (!OAUTH_DEBUG_ENABLED) {
|
|
return;
|
|
}
|
|
|
|
if (typeof payload === 'undefined') {
|
|
console.log(`[OAuth Debug] ${message}`);
|
|
return;
|
|
}
|
|
|
|
console.log(`[OAuth Debug] ${message}`, payload);
|
|
}
|
|
|
|
function normalizeScopes(rawScopes) {
|
|
const sourceValues = Array.isArray(rawScopes)
|
|
? rawScopes
|
|
: typeof rawScopes === 'string'
|
|
? rawScopes.split(/\s+/)
|
|
: [];
|
|
|
|
return uniqueStrings(
|
|
sourceValues
|
|
.map((scope) => String(scope || '').trim())
|
|
.filter((scope) => KNOWN_OAUTH_SCOPES.includes(scope))
|
|
);
|
|
}
|
|
|
|
function normalizeClient(rawClient) {
|
|
if (!rawClient || typeof rawClient !== 'object' || Array.isArray(rawClient)) {
|
|
return null;
|
|
}
|
|
|
|
const clientId = String(rawClient.clientId || '').trim();
|
|
const clientName = String(rawClient.clientName || '').trim();
|
|
const redirectUris = uniqueStrings(
|
|
(Array.isArray(rawClient.redirectUris) ? rawClient.redirectUris : [])
|
|
.map(normalizeRedirectUri)
|
|
.filter(Boolean)
|
|
);
|
|
|
|
if (!clientId || !clientName || redirectUris.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const clientSecret = typeof rawClient.clientSecret === 'string' && rawClient.clientSecret.trim()
|
|
? rawClient.clientSecret.trim()
|
|
: null;
|
|
const publicClient = rawClient.publicClient === true || !clientSecret;
|
|
const requirePkce = rawClient.requirePkce === true || publicClient;
|
|
const allowedScopes = normalizeScopes(rawClient.allowedScopes);
|
|
const homepageUrl = normalizeHttpUrl(rawClient.homepageUrl);
|
|
const logoUrl = normalizeHttpUrl(rawClient.logoUrl);
|
|
const description = typeof rawClient.description === 'string' && rawClient.description.trim()
|
|
? rawClient.description.trim()
|
|
: null;
|
|
|
|
return {
|
|
clientId,
|
|
clientName,
|
|
clientSecret,
|
|
publicClient,
|
|
requirePkce,
|
|
redirectUris,
|
|
allowedScopes: allowedScopes.length > 0 ? allowedScopes : [DEFAULT_SCOPE],
|
|
homepageUrl,
|
|
logoUrl,
|
|
description,
|
|
};
|
|
}
|
|
|
|
function readClientsFile() {
|
|
try {
|
|
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
|
|
return [];
|
|
}
|
|
|
|
const fileContent = fs.readFileSync(OAUTH_CLIENTS_FILE, 'utf8');
|
|
const parsed = safeJsonParse(fileContent, []);
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (error) {
|
|
console.error('[OAuth Clients] Failed to read oauth-clients.json:', error.message || error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function getClientsFileMtimeMs() {
|
|
try {
|
|
if (!fs.existsSync(OAUTH_CLIENTS_FILE)) {
|
|
return -1;
|
|
}
|
|
|
|
return fs.statSync(OAUTH_CLIENTS_FILE).mtimeMs || -1;
|
|
} catch {
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
function loadOAuthClients() {
|
|
const envRaw = process.env[OAUTH_CLIENTS_ENV] || '';
|
|
const fileMtimeMs = getClientsFileMtimeMs();
|
|
|
|
if (cache.envRaw === envRaw && cache.fileMtimeMs === fileMtimeMs) {
|
|
return cache.clients;
|
|
}
|
|
|
|
const fromEnv = safeJsonParse(envRaw, []);
|
|
const fromFile = readClientsFile();
|
|
const mergedSources = [
|
|
...(Array.isArray(fromEnv) ? fromEnv : []),
|
|
...(Array.isArray(fromFile) ? fromFile : []),
|
|
];
|
|
|
|
const byClientId = new Map();
|
|
mergedSources.forEach((entry) => {
|
|
const normalized = normalizeClient(entry);
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
byClientId.set(normalized.clientId, normalized);
|
|
});
|
|
|
|
cache = {
|
|
envRaw,
|
|
fileMtimeMs,
|
|
clients: Array.from(byClientId.values()),
|
|
};
|
|
|
|
return cache.clients;
|
|
}
|
|
|
|
function getOAuthClient(clientId) {
|
|
const normalizedClientId = String(clientId || '').trim();
|
|
if (!normalizedClientId) {
|
|
return null;
|
|
}
|
|
|
|
return loadOAuthClients().find((client) => client.clientId === normalizedClientId) || null;
|
|
}
|
|
|
|
function getOAuthClientPublicMetadata(client) {
|
|
if (!client) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
clientId: client.clientId,
|
|
clientName: client.clientName,
|
|
description: client.description,
|
|
homepageUrl: client.homepageUrl,
|
|
logoUrl: client.logoUrl,
|
|
publicClient: client.publicClient,
|
|
requirePkce: client.requirePkce,
|
|
allowedScopes: [...client.allowedScopes],
|
|
redirectOrigins: uniqueStrings(
|
|
client.redirectUris
|
|
.map((redirectUri) => {
|
|
try {
|
|
return new URL(redirectUri).origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})
|
|
.filter(Boolean)
|
|
),
|
|
};
|
|
}
|
|
|
|
function resolveClientRedirectUri(client, requestedRedirectUri) {
|
|
if (!client) {
|
|
const error = new Error('Client OAuth introuvable');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_client';
|
|
throw error;
|
|
}
|
|
|
|
if (!requestedRedirectUri) {
|
|
if (client.redirectUris.length === 1) {
|
|
logOauthDebug('redirect_uri absent, using the only registered redirect URI', {
|
|
clientId: client.clientId,
|
|
redirectUri: client.redirectUris[0],
|
|
});
|
|
return client.redirectUris[0];
|
|
}
|
|
|
|
const loopbackPaths = getLoopbackRedirectPaths(client);
|
|
const allLoopback = client.redirectUris.every((uri) => {
|
|
try {
|
|
return isLoopbackHostname(new URL(uri).hostname);
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
|
|
if (allLoopback && loopbackPaths.length === 1) {
|
|
logOauthDebug('redirect_uri absent, all registered URIs are loopback with same path, using first', {
|
|
clientId: client.clientId,
|
|
redirectUri: client.redirectUris[0],
|
|
loopbackPath: loopbackPaths[0],
|
|
});
|
|
return client.redirectUris[0];
|
|
}
|
|
}
|
|
|
|
const normalizedRedirectUri = normalizeRedirectUri(requestedRedirectUri);
|
|
logOauthDebug('Validating redirect_uri', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri,
|
|
normalizedRedirectUri,
|
|
registeredRedirectUris: client.redirectUris,
|
|
loopbackRedirectPaths: getLoopbackRedirectPaths(client),
|
|
});
|
|
|
|
if (!normalizedRedirectUri) {
|
|
logOauthDebug('redirect_uri rejected because normalization failed', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri,
|
|
});
|
|
const error = new Error('redirect_uri non autorisée');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_request';
|
|
throw error;
|
|
}
|
|
|
|
if (client.redirectUris.includes(normalizedRedirectUri)) {
|
|
logOauthDebug('redirect_uri matched an exact registered URI', {
|
|
clientId: client.clientId,
|
|
normalizedRedirectUri,
|
|
});
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
let isLoopbackPathAllowed = false;
|
|
try {
|
|
const parsedRequestedRedirectUri = new URL(normalizedRedirectUri);
|
|
if (isLoopbackHostname(parsedRequestedRedirectUri.hostname)) {
|
|
const requestedPathname = normalizePathname(parsedRequestedRedirectUri.pathname);
|
|
const loopbackRedirectPaths = getLoopbackRedirectPaths(client);
|
|
|
|
if (loopbackRedirectPaths.includes(requestedPathname)) {
|
|
isLoopbackPathAllowed = true;
|
|
logOauthDebug('redirect_uri accepted via loopback path match', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri: normalizedRedirectUri,
|
|
requestedPathname,
|
|
loopbackRedirectPaths,
|
|
});
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore malformed URLs already rejected above.
|
|
}
|
|
|
|
if (isLoopbackPathAllowed) {
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
if (!client.redirectUris.includes(normalizedRedirectUri)) {
|
|
logOauthDebug('redirect_uri rejected after validation', {
|
|
clientId: client.clientId,
|
|
requestedRedirectUri: normalizedRedirectUri,
|
|
registeredRedirectUris: client.redirectUris,
|
|
});
|
|
const error = new Error('redirect_uri non autorisée');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_request';
|
|
throw error;
|
|
}
|
|
|
|
return normalizedRedirectUri;
|
|
}
|
|
|
|
function normalizeRequestedScopes(requestedScopes, client) {
|
|
if (!client) {
|
|
const error = new Error('Client OAuth introuvable');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_client';
|
|
throw error;
|
|
}
|
|
|
|
const scopes = normalizeScopes(requestedScopes);
|
|
const normalizedScopes = scopes.length > 0
|
|
? scopes
|
|
: client.allowedScopes.includes(DEFAULT_SCOPE)
|
|
? [DEFAULT_SCOPE]
|
|
: client.allowedScopes.slice(0, 1);
|
|
|
|
if (normalizedScopes.length === 0) {
|
|
const error = new Error('Aucune permission OAuth disponible pour ce client');
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_scope';
|
|
throw error;
|
|
}
|
|
|
|
const invalidScopes = normalizedScopes.filter((scope) => !client.allowedScopes.includes(scope));
|
|
if (invalidScopes.length > 0) {
|
|
const error = new Error(`Scopes non autorisées: ${invalidScopes.join(', ')}`);
|
|
error.statusCode = 400;
|
|
error.oauthError = 'invalid_scope';
|
|
throw error;
|
|
}
|
|
|
|
return normalizedScopes;
|
|
}
|
|
|
|
function getOAuthAllowedCorsOrigins() {
|
|
const origins = new Set();
|
|
|
|
loadOAuthClients().forEach((client) => {
|
|
client.redirectUris.forEach((redirectUri) => {
|
|
try {
|
|
origins.add(new URL(redirectUri).origin);
|
|
} catch {
|
|
// Ignore malformed values already filtered during normalization.
|
|
}
|
|
});
|
|
});
|
|
|
|
return Array.from(origins);
|
|
}
|
|
|
|
module.exports = {
|
|
KNOWN_OAUTH_SCOPES,
|
|
DEFAULT_SCOPE,
|
|
loadOAuthClients,
|
|
getOAuthClient,
|
|
getOAuthClientPublicMetadata,
|
|
resolveClientRedirectUri,
|
|
normalizeRequestedScopes,
|
|
getOAuthAllowedCorsOrigins,
|
|
};
|