godcrm/backend/routes/v3/auth/telegramOAuth.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

254 lines
10 KiB
JavaScript

/**
* Telegram OAuth (OIDC) routes: /telegram/config, /telegram/auth-url,
* /telegram/callback (ADR-0078).
*
* Mirror of the Google provider for config plumbing + token/cookie issuance,
* but the authorization flow itself is Telegram's OIDC variant
* (oauth.telegram.org): PKCE + id_token instead of access_token + userinfo.
* Email is the mapping key to an existing users row (no auto-registration);
* users.telegram_user_id is a write-once binding for future deep-links.
*/
import axios from 'axios';
import crypto from 'crypto';
import {
respondSuccess, respondError,
REFRESH_COOKIE_NAME, ACCESS_COOKIE_NAME,
getRefreshCookieOptions, getAccessCookieOptions,
createAccessToken, createRefreshToken,
requireAuth,
saveTelegramOAuthConfig,
telegramOAuthConfig as _telegramOAuthConfig, setTelegramOAuthConfig,
dbGet, dbRun, authLogger
} from './authShared.js';
// Local reference that stays in sync via the shared module's getter.
// We read from the shared module each time so POST /telegram/config updates are visible.
function getConfig() {
return _telegramOAuthConfig;
}
// Telegram-specific redirect URI (Google's getDynamicRedirectUri hardcodes the
// /auth/google/callback path, so we build our own here).
function getTelegramRedirectUri(req, telegramOAuthConfig) {
const proto = req.get('x-forwarded-proto') || req.protocol || 'https';
const host = req.get('host');
if (host) {
return `${proto}://${host}/auth/telegram/callback`;
}
return telegramOAuthConfig.redirectUri;
}
// ---------------------------------------------------------------------------
// PKCE state store — module-scope, in-memory, 5-minute TTL (alpha-grade).
// Post-alpha this moves to the _app_locks table (ADR-0078 Phase 6) so it
// survives restarts and works across multiple backend processes.
// ---------------------------------------------------------------------------
const PKCE_TTL_MS = 5 * 60 * 1000;
const pkceStore = new Map(); // state -> { codeVerifier, createdAt }
function prunePkce() {
const now = Date.now();
for (const [state, entry] of pkceStore) {
if (now - entry.createdAt > PKCE_TTL_MS) pkceStore.delete(state);
}
}
function base64url(buf) {
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// id_token payload decode WITHOUT signature verification (alpha).
// TODO ADR-0078 Phase 6: verify id_token signature via JWKS once Telegram publishes JWKS endpoint
function decodeIdTokenPayload(idToken) {
const parts = String(idToken).split('.');
if (parts.length !== 3) throw new Error('Malformed id_token');
const payload = Buffer.from(
parts[1].replace(/-/g, '+').replace(/_/g, '/'),
'base64'
).toString('utf8');
return JSON.parse(payload);
}
/**
* @param {import('express').Router} router
*/
export default function registerTelegramOAuthRoutes(router) {
// GET /api/v3/auth/telegram/config - Get Telegram OAuth config (public info only)
router.get('/telegram/config', (req, res) => {
const telegramOAuthConfig = getConfig();
return respondSuccess(res, {
clientId: telegramOAuthConfig.clientId,
redirectUri: getTelegramRedirectUri(req, telegramOAuthConfig),
enabled: telegramOAuthConfig.enabled,
hasClientSecret: !!telegramOAuthConfig.clientSecret
});
});
// POST /api/v3/auth/telegram/auth-url - Build Telegram OAuth authorization URL (PKCE)
router.post('/telegram/auth-url', (req, res) => {
const telegramOAuthConfig = getConfig();
if (!telegramOAuthConfig.enabled || !telegramOAuthConfig.clientId) {
return respondError(res, 400, 'TELEGRAM_OAUTH_NOT_CONFIGURED', 'Telegram OAuth is not configured');
}
// Support custom redirect_uri for desktop apps (localhost), otherwise use dynamic host-based URI
const redirectUri = req.body.redirect_uri || getTelegramRedirectUri(req, telegramOAuthConfig);
prunePkce();
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(crypto.createHash('sha256').update(codeVerifier).digest());
const state = base64url(crypto.randomBytes(16));
pkceStore.set(state, { codeVerifier, createdAt: Date.now() });
// google_signin_allowed / apple_signin_allowed let users verify their email
// via Google/Apple inside Telegram's flow.
const authUrl = `https://oauth.telegram.org/auth?` +
`client_id=${encodeURIComponent(telegramOAuthConfig.clientId)}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=code&` +
`scope=${encodeURIComponent('openid profile email')}&` +
`code_challenge=${encodeURIComponent(codeChallenge)}&` +
`code_challenge_method=S256&` +
`state=${encodeURIComponent(state)}&` +
`google_signin_allowed=true&` +
`apple_signin_allowed=true`;
return respondSuccess(res, { url: authUrl, state, redirectUri });
});
// POST /api/v3/auth/telegram/callback - Exchange code for id_token and login
router.post('/telegram/callback', async (req, res) => {
const telegramOAuthConfig = getConfig();
const { code, state, redirect_uri } = req.body;
if (!code) {
return respondError(res, 400, 'NO_CODE', 'Authorization code is required');
}
if (!state) {
return respondError(res, 400, 'NO_STATE', 'OAuth state is required');
}
prunePkce();
const pkce = pkceStore.get(state);
if (!pkce) {
return respondError(res, 400, 'INVALID_STATE', 'PKCE state is missing or expired');
}
pkceStore.delete(state);
try {
authLogger.debug('[Telegram OAuth] Exchanging code for token...');
// Use provided redirect_uri or dynamic host-based URI (must match auth-url)
const redirectUri = redirect_uri || getTelegramRedirectUri(req, telegramOAuthConfig);
authLogger.debug('[Telegram OAuth] Using redirect_uri:', redirectUri);
// Exchange code for token (Telegram returns user data inside id_token)
const tokenResponse = await axios.post('https://oauth.telegram.org/token', {
code,
client_id: telegramOAuthConfig.clientId,
client_secret: telegramOAuthConfig.clientSecret,
redirect_uri: redirectUri,
grant_type: 'authorization_code',
code_verifier: pkce.codeVerifier
});
const { id_token } = tokenResponse.data;
if (!id_token) {
return respondError(res, 502, 'NO_ID_TOKEN', 'Telegram did not return an id_token');
}
// TODO ADR-0078 Phase 6: verify id_token signature via JWKS once Telegram publishes JWKS endpoint.
// For alpha we trust TLS + client_secret and decode the claims only.
const claims = decodeIdTokenPayload(id_token);
const telegramEmail = claims.email;
const telegramSub = claims.sub;
if (!telegramEmail) {
return respondError(res, 400, 'NO_EMAIL', 'Telegram id_token did not include an email claim');
}
authLogger.debug('[Telegram OAuth] User email:', telegramEmail);
// Check if user exists (email is the mapping key; no auto-registration)
const user = await dbGet(
'SELECT id, email, name, role, telegram_user_id FROM users WHERE email = ?',
[telegramEmail]
);
if (!user) {
authLogger.debug('[Telegram OAuth] User not found:', telegramEmail);
return respondError(res, 401, 'USER_NOT_FOUND', `User with email ${telegramEmail} is not registered in the system. Please contact administrator.`);
}
// Write-once binding of users.telegram_user_id
if (user.telegram_user_id === null || user.telegram_user_id === undefined) {
await dbRun('UPDATE users SET telegram_user_id = ? WHERE id = ?', [telegramSub, user.id]);
} else if (String(user.telegram_user_id) !== String(telegramSub)) {
authLogger.warn('[Telegram OAuth] telegram_user_id mismatch for user:', user.id);
return respondError(res, 409, 'TELEGRAM_ID_MISMATCH', 'This account is already linked to a different Telegram identity');
}
authLogger.debug('[Telegram OAuth] User found:', telegramEmail);
// Create tokens
const accessToken = createAccessToken(user);
const refreshToken = createRefreshToken(user);
// Set refresh + access token cookies
res.cookie(REFRESH_COOKIE_NAME, refreshToken, getRefreshCookieOptions());
res.cookie(ACCESS_COOKIE_NAME, accessToken, getAccessCookieOptions());
// Include refreshToken in body for mobile clients (they can't use httpOnly cookies)
return respondSuccess(res, {
user: {
id: user.id,
email: user.email,
name: user.name,
role: user.role
},
accessToken,
refreshToken
});
} catch (error) {
authLogger.error({ err: error }, '[Telegram OAuth] Error:', error.response?.data || error.message);
return respondError(res, 500, 'TELEGRAM_AUTH_FAILED', 'Telegram authentication failed', error.response?.data?.error_description || error.message);
}
});
// POST /api/v3/auth/telegram/config - Update Telegram OAuth config (owner only)
router.post('/telegram/config', requireAuth, async (req, res) => {
try {
// Check if user is owner
const user = await dbGet('SELECT role FROM users WHERE id = ?', [req.user.id]);
if (user?.role !== 'owner') {
return respondError(res, 403, 'FORBIDDEN', 'Only owner can configure Telegram OAuth');
}
const { clientId, clientSecret, redirectUri, enabled } = req.body;
const telegramOAuthConfig = getConfig();
const newConfig = {
clientId: clientId || telegramOAuthConfig.clientId,
clientSecret: clientSecret || telegramOAuthConfig.clientSecret,
redirectUri: redirectUri || telegramOAuthConfig.redirectUri,
enabled: enabled !== undefined ? enabled : telegramOAuthConfig.enabled
};
setTelegramOAuthConfig(newConfig);
if (saveTelegramOAuthConfig(newConfig)) {
return respondSuccess(res, {
message: 'Telegram OAuth configuration updated',
enabled: newConfig.enabled
});
} else {
return respondError(res, 500, 'SAVE_FAILED', 'Failed to save configuration');
}
} catch (error) {
authLogger.error({ err: error }, '[Telegram OAuth] Config update error:', error);
return respondError(res, 500, 'CONFIG_UPDATE_FAILED', 'Failed to update Telegram OAuth configuration');
}
});
}