godcrm/backend/services/oidc/oidcService.js
GOD CRM Release f89e074dd1
Some checks failed
CI / Lint / Typecheck / Test / Build (push) Has been cancelled
CI / PostgreSQL Integration Tests (push) Has been cancelled
GOD CRM — public scrubbed snapshot
Governed substrate for autonomous agents: scoped identity (passports),
audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
2026-08-10 04:01:45 +03:00

289 lines
8.5 KiB
JavaScript

/**
* OIDC Service - ADR-063: WorkAdventure Integration
* Handles OIDC authentication flow for WorkAdventure
*/
import crypto from 'crypto';
import jwt from 'jsonwebtoken';
import { dbGet, dbRun, dbAll } from '../../database/connection.js';
import { authLogger } from '../../utils/logger.js';
const JWT_SECRET = process.env.JWT_SECRET || 'dev_jwt_secret_change_in_production';
const OIDC_ISSUER = process.env.OIDC_ISSUER || 'https://crm.hltrn.cc';
const AUTH_CODE_TTL = 10 * 60 * 1000; // 10 minutes
const ACCESS_TOKEN_TTL = 60 * 60 * 1000; // 1 hour
/**
* Generate a secure random string
* @param {number} length - Length of the string
* @returns {string} Random string
*/
function generateSecureToken(length = 32) {
return crypto.randomBytes(length).toString('base64url');
}
/**
* Hash a token for storage
* @param {string} token - Token to hash
* @returns {string} Hashed token
*/
function hashToken(token) {
return crypto.createHash('sha256').update(token).digest('hex');
}
/**
* Get OIDC client by client_id
* @param {string} clientId - Client ID
* @returns {Promise<Object|null>} Client object or null
*/
export async function getClient(clientId) {
const client = await dbGet(
'SELECT * FROM oidc_clients WHERE client_id = ? AND is_active = 1',
[clientId]
);
if (client) {
client.redirect_uris = JSON.parse(client.redirect_uris || '[]');
client.allowed_scopes = JSON.parse(client.allowed_scopes || '["openid", "profile", "email"]');
}
return client;
}
/**
* Validate redirect URI against client's allowed URIs
* @param {Object} client - Client object
* @param {string} redirectUri - Redirect URI to validate
* @returns {boolean} True if valid
*/
export function validateRedirectUri(client, redirectUri) {
return client.redirect_uris.includes(redirectUri);
}
/**
* Validate client credentials
* @param {string} clientId - Client ID
* @param {string} clientSecret - Client secret
* @returns {Promise<Object|null>} Client object if valid, null otherwise
*/
export async function validateClientCredentials(clientId, clientSecret) {
const client = await dbGet(
'SELECT * FROM oidc_clients WHERE client_id = ? AND client_secret = ? AND is_active = 1',
[clientId, clientSecret]
);
if (client) {
client.redirect_uris = JSON.parse(client.redirect_uris || '[]');
client.allowed_scopes = JSON.parse(client.allowed_scopes || '["openid", "profile", "email"]');
}
return client;
}
/**
* Create authorization code
* @param {string} clientId - Client ID
* @param {number} userId - User ID
* @param {string} redirectUri - Redirect URI
* @param {string} scope - Requested scope
* @param {string} state - State parameter
* @param {string} nonce - Nonce parameter
* @returns {Promise<string>} Authorization code
*/
export async function createAuthorizationCode(clientId, userId, redirectUri, scope, state, nonce) {
const code = generateSecureToken(32);
const expiresAt = new Date(Date.now() + AUTH_CODE_TTL).toISOString();
await dbRun(
`INSERT INTO oidc_auth_codes (code, client_id, user_id, redirect_uri, scope, state, nonce, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[code, clientId, userId, redirectUri, scope, state, nonce, expiresAt]
);
authLogger.debug({ clientId, userId }, 'Created authorization code');
return code;
}
/**
* Exchange authorization code for tokens
* @param {string} code - Authorization code
* @param {string} clientId - Client ID
* @param {string} redirectUri - Redirect URI
* @returns {Promise<Object|null>} Token response or null if invalid
*/
export async function exchangeAuthorizationCode(code, clientId, redirectUri) {
// Find and validate the code
const authCode = await dbGet(
`SELECT * FROM oidc_auth_codes
WHERE code = ? AND client_id = ? AND redirect_uri = ? AND used = 0 AND expires_at > datetime('now')`,
[code, clientId, redirectUri]
);
if (!authCode) {
authLogger.warn({ clientId }, 'Invalid or expired authorization code');
return null;
}
// Mark code as used
await dbRun('UPDATE oidc_auth_codes SET used = 1 WHERE id = ?', [authCode.id]);
// Get user info including role
const user = await dbGet(
'SELECT id, email, name, avatar, role FROM users WHERE id = ?',
[authCode.user_id]
);
if (!user) {
authLogger.error({ userId: authCode.user_id }, 'User not found for auth code');
return null;
}
// Build tags for WorkAdventure Map Editor
// Global admins/owners get editor tag; space-specific tags are added via member API
const tags = [];
if (user.role === 'admin' || user.role === 'owner') {
tags.push('admin', 'editor');
}
if (user.role === 'user') {
tags.push('member');
}
// Generate access token
const accessToken = generateSecureToken(32);
const accessTokenHash = hashToken(accessToken);
const accessTokenExpiresAt = new Date(Date.now() + ACCESS_TOKEN_TTL).toISOString();
await dbRun(
`INSERT INTO oidc_access_tokens (token_hash, client_id, user_id, scope, expires_at)
VALUES (?, ?, ?, ?, ?)`,
[accessTokenHash, clientId, user.id, authCode.scope, accessTokenExpiresAt]
);
// Generate ID token (JWT)
const idToken = jwt.sign(
{
iss: OIDC_ISSUER,
sub: String(user.id),
aud: clientId,
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
nonce: authCode.nonce,
email: user.email,
name: user.name,
picture: user.avatar,
// WorkAdventure Map Editor tags (ADR-063)
// Global tags here; space-specific editor tag added via /member API
tags: tags
},
JWT_SECRET,
{ algorithm: 'HS256' } // Using HS256 for simplicity; RS256 would require key management
);
authLogger.info({ clientId, userId: user.id }, 'Exchanged authorization code for tokens');
return {
access_token: accessToken,
token_type: 'Bearer',
expires_in: Math.floor(ACCESS_TOKEN_TTL / 1000),
id_token: idToken,
scope: authCode.scope
};
}
/**
* Validate access token and get user info
* @param {string} accessToken - Access token
* @returns {Promise<Object|null>} User info or null if invalid
*/
export async function validateAccessToken(accessToken) {
const tokenHash = hashToken(accessToken);
const tokenRecord = await dbGet(
`SELECT * FROM oidc_access_tokens
WHERE token_hash = ? AND revoked = 0 AND expires_at > datetime('now')`,
[tokenHash]
);
if (!tokenRecord) {
return null;
}
const user = await dbGet(
'SELECT id, email, name, avatar, role FROM users WHERE id = ?',
[tokenRecord.user_id]
);
if (!user) {
return null;
}
return {
sub: String(user.id),
email: user.email,
email_verified: true,
name: user.name,
picture: user.avatar,
role: user.role
};
}
/**
* Revoke access token
* @param {string} accessToken - Access token to revoke
* @returns {Promise<boolean>} True if revoked
*/
export async function revokeAccessToken(accessToken) {
const tokenHash = hashToken(accessToken);
const result = await dbRun(
'UPDATE oidc_access_tokens SET revoked = 1 WHERE token_hash = ?',
[tokenHash]
);
return result.changes > 0;
}
/**
* Clean up expired codes and tokens
* @returns {Promise<void>}
*/
export async function cleanupExpiredTokens() {
await dbRun("DELETE FROM oidc_auth_codes WHERE expires_at < datetime('now')");
await dbRun("DELETE FROM oidc_access_tokens WHERE expires_at < datetime('now')");
authLogger.debug('Cleaned up expired OIDC tokens');
}
/**
* Get OIDC discovery document
* @param {string} baseUrl - Base URL of the server
* @returns {Object} Discovery document
*/
export function getDiscoveryDocument(baseUrl) {
return {
issuer: OIDC_ISSUER,
authorization_endpoint: `${baseUrl}/oauth/authorize`,
token_endpoint: `${baseUrl}/oauth/token`,
userinfo_endpoint: `${baseUrl}/oauth/userinfo`,
jwks_uri: `${baseUrl}/oauth/jwks`,
response_types_supported: ['code'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['RS256', 'HS256'],
scopes_supported: ['openid', 'profile', 'email'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
claims_supported: ['sub', 'email', 'email_verified', 'name', 'picture'],
grant_types_supported: ['authorization_code']
};
}
export default {
getClient,
validateRedirectUri,
validateClientCredentials,
createAuthorizationCode,
exchangeAuthorizationCode,
validateAccessToken,
revokeAccessToken,
cleanupExpiredTokens,
getDiscoveryDocument
};