MovixOpenSource/API/Mainapi/utils/oauthStorage.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

479 lines
15 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const crypto = require('crypto');
const { getPool } = require('../mysqlPool');
const AUTHORIZATION_REQUEST_TTL_MS = 10 * 60 * 1000;
const AUTHORIZATION_CODE_TTL_MS = 10 * 60 * 1000;
const ACCESS_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
function createOAuthStorageError(message, statusCode = 400, oauthError = 'invalid_request') {
const error = new Error(message);
error.statusCode = statusCode;
error.oauthError = oauthError;
return error;
}
function toBase64Url(buffer) {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/g, '');
}
function generateOpaqueToken(prefix) {
return `${prefix}_${toBase64Url(crypto.randomBytes(32))}`;
}
function hashOpaqueToken(value) {
return crypto.createHash('sha256').update(String(value || '')).digest('hex');
}
function serializeScopes(scopes) {
return JSON.stringify(Array.isArray(scopes) ? scopes : []);
}
function normalizeAuthorizationRequest(payload = {}) {
const clientId = String(payload.clientId || payload.client_id || '').trim();
const redirectUri = String(payload.redirectUri || payload.redirect_uri || '').trim();
const responseType = String(payload.responseType || payload.response_type || 'code').trim() || 'code';
const state = typeof payload.state === 'string'
? payload.state
: payload.state != null
? String(payload.state)
: '';
const scopes = Array.isArray(payload.scopes)
? payload.scopes.map((scope) => String(scope || '').trim()).filter(Boolean).sort()
: [];
const codeChallenge = typeof payload.codeChallenge === 'string' && payload.codeChallenge.trim()
? payload.codeChallenge.trim()
: null;
const codeChallengeMethod = typeof payload.codeChallengeMethod === 'string' && payload.codeChallengeMethod.trim()
? payload.codeChallengeMethod.trim().toUpperCase()
: null;
return {
clientId,
redirectUri,
responseType,
scopes,
state,
codeChallenge,
codeChallengeMethod,
};
}
function hashAuthorizationRequest(payload = {}) {
const normalized = normalizeAuthorizationRequest(payload);
return crypto.createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
}
function parseScopes(rawScopes) {
if (!rawScopes) {
return [];
}
try {
const parsed = JSON.parse(rawScopes);
return Array.isArray(parsed)
? parsed.map((scope) => String(scope || '').trim()).filter(Boolean)
: [];
} catch {
return [];
}
}
function normalizeDate(value) {
if (value == null) {
return null;
}
const date = value instanceof Date ? value : new Date(value);
return Number.isFinite(date.getTime()) ? date : null;
}
function mapAuthorizationCodeRow(row) {
if (!row) {
return null;
}
return {
codeHash: row.code_hash,
clientId: row.client_id,
userId: row.user_id,
userType: row.user_type,
sessionId: row.session_id,
scopes: parseScopes(row.scopes),
redirectUri: row.redirect_uri,
codeChallenge: row.code_challenge || null,
codeChallengeMethod: row.code_challenge_method || null,
expiresAt: normalizeDate(row.expires_at),
usedAt: normalizeDate(row.used_at),
createdAt: normalizeDate(row.created_at),
};
}
function mapAuthorizationRequestRow(row) {
if (!row) {
return null;
}
return {
requestHash: row.request_hash,
clientId: row.client_id,
redirectUri: row.redirect_uri,
responseType: row.response_type,
scopes: parseScopes(row.scopes),
state: row.state || '',
codeChallenge: row.code_challenge || null,
codeChallengeMethod: row.code_challenge_method || null,
expiresAt: normalizeDate(row.expires_at),
consumedAt: normalizeDate(row.consumed_at),
decision: row.decision || null,
createdAt: normalizeDate(row.created_at),
lastSeenAt: normalizeDate(row.last_seen_at),
};
}
function mapAccessTokenRow(row) {
if (!row) {
return null;
}
return {
tokenHash: row.token_hash,
clientId: row.client_id,
userId: row.user_id,
userType: row.user_type,
sessionId: row.session_id,
scopes: parseScopes(row.scopes),
expiresAt: normalizeDate(row.expires_at),
revokedAt: normalizeDate(row.revoked_at),
createdAt: normalizeDate(row.created_at),
lastUsedAt: normalizeDate(row.last_used_at),
authorizationCodeHash: row.authorization_code_hash || null,
};
}
async function ensureOAuthStorage(pool = getPool()) {
if (!pool) {
throw new Error('MySQL pool not ready for OAuth storage');
}
await pool.execute(`
CREATE TABLE IF NOT EXISTS oauth_authorization_codes (
code_hash CHAR(64) PRIMARY KEY,
client_id VARCHAR(191) NOT NULL,
user_id VARCHAR(255) NOT NULL,
user_type ENUM('oauth', 'bip39') NOT NULL,
session_id VARCHAR(255) DEFAULT NULL,
scopes TEXT NOT NULL,
redirect_uri TEXT NOT NULL,
code_challenge VARCHAR(255) DEFAULT NULL,
code_challenge_method VARCHAR(20) DEFAULT NULL,
expires_at DATETIME NOT NULL,
used_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_oauth_codes_client (client_id),
INDEX idx_oauth_codes_user (user_id, user_type),
INDEX idx_oauth_codes_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS oauth_authorization_requests (
request_hash CHAR(64) PRIMARY KEY,
client_id VARCHAR(191) NOT NULL,
redirect_uri TEXT NOT NULL,
response_type VARCHAR(20) NOT NULL DEFAULT 'code',
scopes TEXT NOT NULL,
state TEXT DEFAULT '',
code_challenge VARCHAR(255) DEFAULT NULL,
code_challenge_method VARCHAR(20) DEFAULT NULL,
expires_at DATETIME NOT NULL,
consumed_at DATETIME DEFAULT NULL,
decision ENUM('approved', 'rejected') DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_seen_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_oauth_requests_client (client_id),
INDEX idx_oauth_requests_expiry (expires_at),
INDEX idx_oauth_requests_consumed (consumed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.execute(`
CREATE TABLE IF NOT EXISTS oauth_access_tokens (
token_hash CHAR(64) PRIMARY KEY,
client_id VARCHAR(191) NOT NULL,
user_id VARCHAR(255) NOT NULL,
user_type ENUM('oauth', 'bip39') NOT NULL,
session_id VARCHAR(255) DEFAULT NULL,
scopes TEXT NOT NULL,
expires_at DATETIME NOT NULL,
revoked_at DATETIME DEFAULT NULL,
authorization_code_hash CHAR(64) DEFAULT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_oauth_tokens_client (client_id),
INDEX idx_oauth_tokens_user (user_id, user_type),
INDEX idx_oauth_tokens_expiry (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
async function registerAuthorizationRequest(pool, payload) {
if (!pool || typeof pool.execute !== 'function') {
throw new Error('MySQL pool not ready for OAuth authorization request registration');
}
const normalized = normalizeAuthorizationRequest(payload);
const requestHash = hashAuthorizationRequest(normalized);
const expiresAt = new Date(Date.now() + AUTHORIZATION_REQUEST_TTL_MS);
await pool.execute(
`INSERT INTO oauth_authorization_requests
(request_hash, client_id, redirect_uri, response_type, scopes, state, code_challenge, code_challenge_method, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
client_id = VALUES(client_id),
redirect_uri = VALUES(redirect_uri),
response_type = VALUES(response_type),
scopes = VALUES(scopes),
state = VALUES(state),
code_challenge = VALUES(code_challenge),
code_challenge_method = VALUES(code_challenge_method),
expires_at = VALUES(expires_at),
last_seen_at = NOW()`,
[
requestHash,
normalized.clientId,
normalized.redirectUri,
normalized.responseType,
serializeScopes(normalized.scopes),
normalized.state,
normalized.codeChallenge,
normalized.codeChallengeMethod,
expiresAt,
]
);
return getAuthorizationRequestRecord(pool, normalized);
}
async function getAuthorizationRequestRecord(pool, payload) {
if (!pool || !payload) {
return null;
}
const requestHash = hashAuthorizationRequest(payload);
const [rows] = await pool.execute(
'SELECT * FROM oauth_authorization_requests WHERE request_hash = ? LIMIT 1',
[requestHash]
);
return mapAuthorizationRequestRow(rows[0] || null);
}
async function claimAuthorizationRequest(connection, payload, decision) {
if (!connection || typeof connection.execute !== 'function') {
throw new Error('MySQL connection not ready for OAuth authorization request claim');
}
const normalized = normalizeAuthorizationRequest(payload);
const requestHash = hashAuthorizationRequest(normalized);
const [rows] = await connection.execute(
'SELECT * FROM oauth_authorization_requests WHERE request_hash = ? LIMIT 1 FOR UPDATE',
[requestHash]
);
const requestRecord = mapAuthorizationRequestRow(rows[0] || null);
if (!requestRecord) {
throw createOAuthStorageError('Demande OAuth introuvable', 400, 'invalid_request');
}
if (!requestRecord.expiresAt || requestRecord.expiresAt.getTime() <= Date.now()) {
throw createOAuthStorageError('Cette demande OAuth a expiré', 400, 'invalid_request');
}
if (requestRecord.consumedAt) {
throw createOAuthStorageError('Cette demande OAuth a déjà été utilisée. Regénérez une nouvelle demande depuis Movix Translate.', 400, 'invalid_grant');
}
const [updateResult] = await connection.execute(
'UPDATE oauth_authorization_requests SET consumed_at = NOW(), decision = ?, last_seen_at = NOW() WHERE request_hash = ? AND consumed_at IS NULL',
[decision, requestHash]
);
if (!updateResult || updateResult.affectedRows !== 1) {
throw createOAuthStorageError('Cette demande OAuth a déjà été utilisée. Regénérez une nouvelle demande depuis Movix Translate.', 400, 'invalid_grant');
}
return requestRecord;
}
async function createAuthorizationCode(pool, payload) {
if (!pool) {
throw new Error('MySQL pool not ready for OAuth authorization code creation');
}
const code = generateOpaqueToken('movix_code');
const codeHash = hashOpaqueToken(code);
const expiresAt = new Date(Date.now() + AUTHORIZATION_CODE_TTL_MS);
await pool.execute(
`INSERT INTO oauth_authorization_codes
(code_hash, client_id, user_id, user_type, session_id, scopes, redirect_uri, code_challenge, code_challenge_method, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
codeHash,
payload.clientId,
payload.userId,
payload.userType,
payload.sessionId || null,
serializeScopes(payload.scopes),
payload.redirectUri,
payload.codeChallenge || null,
payload.codeChallengeMethod || null,
expiresAt,
]
);
return {
code,
codeHash,
expiresAt,
};
}
async function exchangeAuthorizationCode(pool, payload) {
if (!pool) {
throw new Error('MySQL pool not ready for OAuth token exchange');
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const codeHash = hashOpaqueToken(payload.code);
const [rows] = await connection.execute(
'SELECT * FROM oauth_authorization_codes WHERE code_hash = ? LIMIT 1 FOR UPDATE',
[codeHash]
);
const authorizationCode = mapAuthorizationCodeRow(rows[0] || null);
if (!authorizationCode) {
throw createOAuthStorageError('Code dautorisation invalide', 400, 'invalid_grant');
}
if (authorizationCode.clientId !== payload.clientId) {
throw createOAuthStorageError('Client OAuth invalide pour ce code', 400, 'invalid_grant');
}
if (authorizationCode.redirectUri !== payload.redirectUri) {
throw createOAuthStorageError('redirect_uri invalide pour ce code', 400, 'invalid_grant');
}
if (authorizationCode.usedAt) {
throw createOAuthStorageError('Code dautorisation déjà utilisé', 400, 'invalid_grant');
}
if (!authorizationCode.expiresAt || authorizationCode.expiresAt.getTime() <= Date.now()) {
throw createOAuthStorageError('Code dautorisation expiré', 400, 'invalid_grant');
}
await connection.execute(
'UPDATE oauth_authorization_codes SET used_at = NOW() WHERE code_hash = ? AND used_at IS NULL',
[authorizationCode.codeHash]
);
const accessToken = generateOpaqueToken('movix_oat');
const tokenHash = hashOpaqueToken(accessToken);
const expiresAt = new Date(Date.now() + ACCESS_TOKEN_TTL_MS);
await connection.execute(
`INSERT INTO oauth_access_tokens
(token_hash, client_id, user_id, user_type, session_id, scopes, expires_at, authorization_code_hash, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())`,
[
tokenHash,
authorizationCode.clientId,
authorizationCode.userId,
authorizationCode.userType,
authorizationCode.sessionId || null,
serializeScopes(authorizationCode.scopes),
expiresAt,
authorizationCode.codeHash,
]
);
await connection.commit();
return {
accessToken,
accessTokenHash: tokenHash,
expiresAt,
scopes: authorizationCode.scopes,
clientId: authorizationCode.clientId,
userId: authorizationCode.userId,
userType: authorizationCode.userType,
sessionId: authorizationCode.sessionId,
authorizationCode,
};
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
async function getAccessTokenRecord(pool, accessToken, options = {}) {
if (!pool || !accessToken) {
return null;
}
const tokenHash = hashOpaqueToken(accessToken);
const [rows] = await pool.execute(
'SELECT * FROM oauth_access_tokens WHERE token_hash = ? LIMIT 1',
[tokenHash]
);
const record = mapAccessTokenRow(rows[0] || null);
if (!record) {
return null;
}
if (record.revokedAt) {
return null;
}
if (!record.expiresAt || record.expiresAt.getTime() <= Date.now()) {
return null;
}
if (options.touch === true) {
pool.execute(
'UPDATE oauth_access_tokens SET last_used_at = NOW() WHERE token_hash = ?',
[tokenHash]
).catch((error) => {
console.error('[OAuth Storage] Failed to update token usage:', error.message || error);
});
}
return record;
}
module.exports = {
AUTHORIZATION_REQUEST_TTL_MS,
AUTHORIZATION_CODE_TTL_MS,
ACCESS_TOKEN_TTL_MS,
createOAuthStorageError,
hashOpaqueToken,
hashAuthorizationRequest,
ensureOAuthStorage,
registerAuthorizationRequest,
getAuthorizationRequestRecord,
claimAuthorizationRequest,
createAuthorizationCode,
exchangeAuthorizationCode,
getAccessTokenRecord,
};