mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-27 09:12:07 +00:00
479 lines
15 KiB
JavaScript
479 lines
15 KiB
JavaScript
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 d’autorisation 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 d’autorisation déjà utilisé', 400, 'invalid_grant');
|
||
}
|
||
|
||
if (!authorizationCode.expiresAt || authorizationCode.expiresAt.getTime() <= Date.now()) {
|
||
throw createOAuthStorageError('Code d’autorisation 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,
|
||
};
|