godcrm/backend/routes/oauth/index.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

606 lines
18 KiB
JavaScript

/**
* OAuth/OIDC Routes for GOD CRM
* ADR-063: WorkAdventure Integration
*
* Provides OIDC endpoints for external apps like WorkAdventure
*
* Endpoints:
* - GET /.well-known/openid-configuration - Discovery document
* - GET /authorize - Authorization endpoint
* - POST /token - Token endpoint
* - GET /userinfo - User info endpoint
* - GET /jwks - JSON Web Key Set
*/
import express from 'express';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dbGet, dbRun, dbAll } from '../../database/connection.js';
import { apiLogger } from '../../utils/logger.js';
import {
ISSUER,
TOKEN_TTL,
SUPPORTED_SCOPES,
getDiscoveryDocument
} from '../../config/oidc.config.js';
import {
getClient,
isRedirectUriAllowed,
verifyClientSecret,
} from '../../services/oidc/clientRegistry.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ADR-179 D1 — ring discipline: the fleet issuer must be Ring-0-anchored
// (id.godcrm.ai), never a foreign master. The cutover to id.godcrm.ai is a
// coordinated env flip (OIDC_ISSUER) done in lockstep with client reconfig —
// we only WARN here so a stray NL/Ring-1 issuer is loud in the logs, never
// silently baked into id_tokens.
if (/hltrn\.cc|94\.103\.81\.53|31\.130\.152\.12/.test(ISSUER)) {
apiLogger.warn(
{ issuer: ISSUER },
'OIDC_ISSUER points at a non-Ring-0 host — ADR-179 D1 requires https://id.godcrm.ai (Ring 0). ' +
'Set OIDC_ISSUER in the Ring-0 .env before onboarding fleet clients.'
);
}
// Load RSA keys for RS256 signing
let privateKey = null;
let publicKey = null;
let jwkPublicKey = null;
try {
const keysDir = path.join(__dirname, '../../config/keys');
privateKey = fs.readFileSync(path.join(keysDir, 'oidc-private.pem'), 'utf8');
publicKey = fs.readFileSync(path.join(keysDir, 'oidc-public.pem'), 'utf8');
// Convert public key to JWK format
const keyData = crypto.createPublicKey(publicKey);
const jwk = keyData.export({ format: 'jwk' });
jwkPublicKey = {
...jwk,
kid: 'godcrm-oidc-key-1',
use: 'sig',
alg: 'RS256',
};
apiLogger.info('OIDC RS256 keys loaded successfully');
} catch (err) {
apiLogger.warn({ err: err.message }, 'OIDC RS256 keys not found, falling back to HS256');
}
const router = express.Router();
// In-memory store for authorization codes (in production, use Redis or database)
const authorizationCodes = new Map();
// Get JWT secret dynamically to support test environment
function getJwtSecret() {
return process.env.JWT_SECRET || 'dev_jwt_secret_change_in_production';
}
// ============================================================
// Helper Functions
// ============================================================
/**
* Generate a random authorization code
* @returns {string} - Random code
*/
function generateAuthorizationCode() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Generate access token
* @param {Object} user - User object
* @param {string} clientId - Client ID
* @param {string[]} scopes - Requested scopes
* @returns {string} - JWT access token
*/
function generateAccessToken(user, clientId, scopes) {
return jwt.sign(
{
sub: String(user.id),
iss: ISSUER,
aud: clientId,
scope: scopes.join(' '),
type: 'access_token',
},
getJwtSecret(),
{ expiresIn: TOKEN_TTL.ACCESS_TOKEN }
);
}
/**
* Generate ID token
* @param {Object} user - User object
* @param {string} clientId - Client ID
* @param {string[]} scopes - Requested scopes
* @returns {string} - JWT ID token
*/
function generateIdToken(user, clientId, scopes) {
const claims = {
sub: String(user.id),
iss: ISSUER,
aud: clientId,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + TOKEN_TTL.ID_TOKEN,
};
// Add profile claims if scope includes 'profile'
if (scopes.includes('profile')) {
claims.name = user.name;
claims.picture = user.avatar;
claims.preferred_username = `${user.name} (ID:${user.id})`;
}
// Add email claims if scope includes 'email'
if (scopes.includes('email')) {
claims.email = user.email;
claims.email_verified = Boolean(user.email_verified);
}
// Use RS256 if private key is available, otherwise fallback to HS256
if (privateKey) {
return jwt.sign(claims, privateKey, {
algorithm: 'RS256',
header: { kid: 'godcrm-oidc-key-1' }
});
}
return jwt.sign(claims, getJwtSecret(), { algorithm: 'HS256' });
}
/**
* Extract bearer token from Authorization header
* @param {Object} req - Express request
* @returns {string|null} - Token or null
*/
function extractBearerToken(req) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
return authHeader.substring(7);
}
/**
* Extract token from cookie
* @param {Object} req - Express request
* @returns {string|null} - Token or null
*/
function extractCookieToken(req) {
// Check for refresh token cookie (used by GOD CRM frontend)
// Cookie name: godcrm_refresh (or REFRESH_COOKIE_NAME env var)
const cookieName = process.env.REFRESH_COOKIE_NAME || 'godcrm_refresh';
return req.cookies?.[cookieName] || null;
}
/**
* Get authenticated user from request (Bearer token or cookie)
* @param {Object} req - Express request
* @returns {Object|null} - Decoded token payload or null
*/
function getAuthenticatedUser(req) {
// Try Bearer token first
const bearerToken = extractBearerToken(req);
if (bearerToken) {
const decoded = verifyToken(bearerToken);
if (decoded) return decoded;
}
// Try cookie token
const cookieToken = extractCookieToken(req);
if (cookieToken) {
const decoded = verifyToken(cookieToken);
if (decoded) return decoded;
}
// Check if user is set by auth middleware (req.user)
if (req.user && req.user.id) {
return { id: req.user.id };
}
return null;
}
/**
* Verify and decode JWT token
* @param {string} token - JWT token
* @returns {Object|null} - Decoded token or null
*/
function verifyToken(token) {
try {
return jwt.verify(token, getJwtSecret());
} catch {
return null;
}
}
// ============================================================
// OIDC Endpoints
// ============================================================
/**
* GET /openid-configuration
* OpenID Connect Discovery Document
* Note: This is mounted at /.well-known in server.js, so full path is /.well-known/openid-configuration
*/
router.get('/openid-configuration', (req, res) => {
const baseUrl = ISSUER;
res.json(getDiscoveryDocument(baseUrl));
});
/**
* GET /authorize
* Authorization Endpoint - Initiates the OAuth flow
*/
router.get('/authorize', async (req, res) => {
const {
client_id,
redirect_uri,
response_type,
scope = 'openid',
state,
code_challenge,
code_challenge_method,
} = req.query;
apiLogger.debug({ client_id, redirect_uri, response_type, scope, code_challenge: !!code_challenge, code_challenge_method }, 'OIDC authorize request');
// Validate client_id (registry lookup — inactive clients resolve to null)
const client = await getClient(client_id);
if (!client) {
return res.status(400).json({
error: 'invalid_client',
error_description: 'Unknown client_id',
});
}
// Validate redirect_uri
if (!isRedirectUriAllowed(client, redirect_uri)) {
return res.status(400).json({
error: 'invalid_redirect_uri',
error_description: 'Redirect URI not registered for this client',
});
}
// Validate response_type
if (response_type !== 'code') {
return res.status(400).json({
error: 'unsupported_response_type',
error_description: 'Only "code" response type is supported',
});
}
// ADR-179 D4 — public clients (the Chrome extension, ADR-180) have no secret,
// so PKCE is the only proof-of-possession. Require code_challenge up front so
// a public client can never fall back to an unprotected code exchange.
if (client.is_public && !code_challenge) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'PKCE code_challenge is required for public clients',
});
}
// Check if user is authenticated (via Bearer token OR cookie)
const authUser = getAuthenticatedUser(req);
if (!authUser) {
// Redirect to login page with return URL
const returnUrl = encodeURIComponent(req.originalUrl);
apiLogger.debug({ returnUrl }, 'OIDC: User not authenticated, redirecting to login');
return res.redirect(302, `/auth/login?redirect=${returnUrl}`);
}
// Get user from database
const user = await dbGet('SELECT * FROM users WHERE id = ?', [authUser.id]);
if (!user) {
return res.status(400).json({
error: 'invalid_request',
error_description: 'User not found',
});
}
// Parse requested scopes
const requestedScopes = scope.split(' ').filter(s => SUPPORTED_SCOPES.includes(s));
// Generate authorization code
const code = generateAuthorizationCode();
// Store code with metadata (expires in 10 minutes)
authorizationCodes.set(code, {
userId: user.id,
clientId: client_id,
redirectUri: redirect_uri,
scopes: requestedScopes,
expiresAt: Date.now() + TOKEN_TTL.AUTHORIZATION_CODE * 1000,
codeChallenge: code_challenge || null,
codeChallengeMethod: code_challenge_method || null,
});
// Build redirect URL with code (normalize double slashes in path)
const redirectUrl = new URL(redirect_uri);
// Normalize path to remove double slashes (wa.hltrn.cc//openid-callback -> wa.hltrn.cc/openid-callback)
redirectUrl.pathname = redirectUrl.pathname.replace(/\/+/g, '/');
redirectUrl.searchParams.set('code', code);
if (state) {
redirectUrl.searchParams.set('state', state);
}
apiLogger.info({ userId: user.id, clientId: client_id }, 'OIDC authorization code issued');
return res.redirect(302, redirectUrl.toString());
});
/**
* POST /token
* Token Endpoint - Exchanges authorization code for tokens
*/
router.post('/token', async (req, res) => {
const {
grant_type,
code,
redirect_uri,
} = req.body;
// Support client_secret_basic (Authorization: Basic base64(client_id:client_secret))
// and client_secret_post (client_id + client_secret in POST body)
let client_id = req.body.client_id;
let client_secret = req.body.client_secret;
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
const decoded = Buffer.from(authHeader.substring(6), 'base64').toString('utf-8');
const colonIndex = decoded.indexOf(':');
if (colonIndex !== -1) {
client_id = decodeURIComponent(decoded.substring(0, colonIndex));
client_secret = decodeURIComponent(decoded.substring(colonIndex + 1));
}
}
apiLogger.debug({ grant_type, client_id }, 'OIDC token request');
// Validate grant_type
if (grant_type !== 'authorization_code' && grant_type !== 'refresh_token') {
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: 'Only "authorization_code" and "refresh_token" grant types are supported',
});
}
// Look up the client, then authenticate per its tier (ADR-179 D4):
// - confidential client → must present a matching secret (vault or legacy).
// verifyClientSecret closes the old bypass where omitting the secret
// skipped the check entirely.
// - public client → no secret; proves possession via PKCE below.
const client = await getClient(client_id);
if (!client) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Invalid client credentials',
});
}
if (!client.is_public) {
const authed = await verifyClientSecret(client, client_secret);
if (!authed) {
return res.status(401).json({
error: 'invalid_client',
error_description: 'Invalid client credentials',
});
}
}
if (grant_type === 'authorization_code') {
// Validate authorization code
const codeData = authorizationCodes.get(code);
if (!codeData) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Invalid or expired authorization code',
});
}
// Diagnostic logging for token exchange debugging
apiLogger.info({
grant_type,
client_id,
redirect_uri,
storedRedirectUri: codeData.redirectUri,
hasCodeVerifier: !!req.body.code_verifier,
hasCodeChallenge: !!codeData.codeChallenge,
codeChallengeMethod: codeData.codeChallengeMethod,
}, 'OIDC token exchange details');
// Check if code is expired
if (Date.now() > codeData.expiresAt) {
authorizationCodes.delete(code);
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Authorization code has expired',
});
}
// Normalize redirect URIs for comparison (handle double slashes like //openid-callback)
const normalizeUri = (uri) => {
try {
const url = new URL(uri);
url.pathname = url.pathname.replace(/\/+/g, '/');
return url.toString();
} catch {
return uri;
}
};
// Validate redirect_uri matches (normalized)
if (normalizeUri(codeData.redirectUri) !== normalizeUri(redirect_uri)) {
apiLogger.warn({
stored: codeData.redirectUri,
received: redirect_uri,
storedNormalized: normalizeUri(codeData.redirectUri),
receivedNormalized: normalizeUri(redirect_uri),
}, 'OIDC redirect_uri mismatch');
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Redirect URI mismatch',
});
}
// Validate client_id matches
if (codeData.clientId !== client_id) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Client ID mismatch',
});
}
// ADR-179 D4 — a public client MUST have bound a PKCE challenge at
// /authorize; refuse the exchange if one is somehow absent.
if (client.is_public && !codeData.codeChallenge) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'PKCE code_verifier required for public clients',
});
}
// PKCE validation
if (codeData.codeChallenge) {
const { code_verifier } = req.body;
if (!code_verifier) {
apiLogger.warn({ client_id }, 'OIDC PKCE: code_verifier required but not provided');
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Code verifier required for PKCE',
});
}
let expectedChallenge;
if (codeData.codeChallengeMethod === 'S256') {
expectedChallenge = crypto.createHash('sha256')
.update(code_verifier)
.digest('base64url');
} else {
// plain method
expectedChallenge = code_verifier;
}
if (expectedChallenge !== codeData.codeChallenge) {
apiLogger.warn({ client_id, method: codeData.codeChallengeMethod }, 'OIDC PKCE: code_verifier validation failed');
return res.status(400).json({
error: 'invalid_grant',
error_description: 'Invalid code verifier',
});
}
apiLogger.debug({ client_id, method: codeData.codeChallengeMethod }, 'OIDC PKCE: code_verifier validated successfully');
}
// Delete the code (one-time use)
authorizationCodes.delete(code);
// Get user
const user = await dbGet('SELECT * FROM users WHERE id = ?', [codeData.userId]);
if (!user) {
return res.status(400).json({
error: 'invalid_grant',
error_description: 'User not found',
});
}
// Generate tokens
const accessToken = generateAccessToken(user, client_id, codeData.scopes);
const idToken = generateIdToken(user, client_id, codeData.scopes);
apiLogger.info({ userId: user.id, clientId: client_id }, 'OIDC tokens issued');
return res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: TOKEN_TTL.ACCESS_TOKEN,
id_token: idToken,
scope: codeData.scopes.join(' '),
});
}
// Handle refresh_token grant (not implemented yet)
return res.status(400).json({
error: 'unsupported_grant_type',
error_description: 'Refresh token grant not yet implemented',
});
});
/**
* GET /userinfo
* UserInfo Endpoint - Returns user claims
*/
router.get('/userinfo', async (req, res) => {
const token = extractBearerToken(req);
if (!token) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Access token is required',
});
}
const decoded = verifyToken(token);
if (!decoded || decoded.type !== 'access_token') {
return res.status(401).json({
error: 'invalid_token',
error_description: 'Invalid or expired access token',
});
}
// Get user from database
const user = await dbGet('SELECT * FROM users WHERE id = ?', [decoded.sub]);
if (!user) {
return res.status(401).json({
error: 'invalid_token',
error_description: 'User not found',
});
}
// Parse scopes from token
const scopes = (decoded.scope || '').split(' ');
// Build userinfo response based on scopes
const userinfo = {
sub: String(user.id),
};
if (scopes.includes('profile')) {
userinfo.name = user.name;
userinfo.picture = user.avatar;
// preferred_username includes CRM user ID for identification in WorkAdventure
userinfo.preferred_username = `${user.name} (ID:${user.id})`;
}
if (scopes.includes('email')) {
userinfo.email = user.email;
userinfo.email_verified = Boolean(user.email_verified);
}
return res.json(userinfo);
});
/**
* GET /jwks
* JSON Web Key Set - Returns public keys for token verification
*/
router.get('/jwks', (req, res) => {
// Return public key in JWK format for RS256 verification
if (jwkPublicKey) {
return res.json({
keys: [jwkPublicKey],
});
}
// Fallback: no keys if RS256 not configured
res.json({
keys: [],
});
});
export default router;