Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions graphql/server/src/middleware/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache';

import errorPage50x from '../errors/50x';
import errorPage404Message from '../errors/404-message';
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types';
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types';
import './types';

const log = new Logger('api');
Expand Down Expand Up @@ -86,6 +86,36 @@ const RLS_MODULE_SQL = `
LIMIT 1
`;

/**
* Discover auth settings table location via public metaschema tables.
* Joins sessions_module with metaschema_public.schema to resolve
* the schema name + table name without touching private schemas.
*/
const AUTH_SETTINGS_DISCOVERY_SQL = `
SELECT s.schema_name, sm.auth_settings_table AS table_name
FROM metaschema_modules_public.sessions_module sm
JOIN metaschema_public.schema s ON s.id = sm.schema_id
LIMIT 1
`;

/**
* Query auth settings from the discovered table.
* Schema and table name are resolved dynamically from metaschema modules.
*/
const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => `
SELECT
cookie_secure,
cookie_samesite,
cookie_domain,
cookie_httponly,
cookie_max_age,
cookie_path,
enable_captcha,
captcha_site_key
FROM "${schemaName}"."${tableName}"
LIMIT 1
`;

// =============================================================================
// Types
// =============================================================================
Expand All @@ -111,6 +141,17 @@ interface RlsModuleData {
current_user_agent: string;
}

interface AuthSettingsRow {
cookie_secure: boolean;
cookie_samesite: string;
cookie_domain: string | null;
cookie_httponly: boolean;
cookie_max_age: string | null;
cookie_path: string;
enable_captcha: boolean;
captcha_site_key: string | null;
}

interface RlsModuleRow {
data: RlsModuleData | null;
}
Expand Down Expand Up @@ -208,7 +249,21 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => {
};
};

const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({
const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => {
if (!row) return undefined;
return {
cookieSecure: row.cookie_secure,
cookieSamesite: row.cookie_samesite,
cookieDomain: row.cookie_domain,
cookieHttponly: row.cookie_httponly,
cookieMaxAge: row.cookie_max_age,
cookiePath: row.cookie_path,
enableCaptcha: row.enable_captcha,
captchaSiteKey: row.captcha_site_key,
};
};

const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({
apiId: row.api_id,
dbname: row.dbname || opts.pg?.database || '',
anonRole: row.anon_role || 'anon',
Expand All @@ -219,6 +274,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleR
domains: [],
databaseId: row.database_id,
isPublic: row.is_public,
authSettings: toAuthSettings(authSettingsRow ?? null),
});

const createAdminStructure = (
Expand Down Expand Up @@ -278,6 +334,37 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise<RlsModuleRow |
return result.rows[0] ?? null;
};

/**
* Load server-relevant auth settings from the tenant DB.
* Discovers the auth settings table dynamically by joining
* metaschema_modules_public.sessions_module with metaschema_public.schema
* (both public schemas). Fails gracefully if modules or table don't exist yet.
*/
const queryAuthSettings = async (
opts: ApiOptions,
dbname: string
): Promise<AuthSettingsRow | null> => {
try {
const tenantPool = getPgPool({ ...opts.pg, database: dbname });

// Discover the auth settings schema + table name from public metaschema tables
const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_DISCOVERY_SQL);
const resolved = discovery.rows[0];
if (!resolved) {
log.debug('[auth-settings] No sessions_module row found in tenant DB');
return null;
}

// Query the discovered auth settings table
const result = await tenantPool.query<AuthSettingsRow>(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
return result.rows[0] ?? null;
} catch (e: any) {
// Table/module may not exist yet if the 2FA migration hasn't been applied
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
return null;
}
};

// =============================================================================
// Resolution Logic
// =============================================================================
Expand Down Expand Up @@ -337,8 +424,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise<ApiStructure |
}

const rlsModule = await queryRlsModule(pool, row.api_id);
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
return toApiStructure(row, opts, rlsModule);
const authSettings = await queryAuthSettings(opts, row.dbname);
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
return toApiStructure(row, opts, rlsModule, authSettings);
};

const resolveMetaSchemaHeader = (
Expand All @@ -362,8 +450,9 @@ const resolveDomainLookup = async (ctx: ResolveContext): Promise<ApiStructure |
}

const rlsModule = await queryRlsModule(pool, row.api_id);
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
return toApiStructure(row, opts, rlsModule);
const authSettings = await queryAuthSettings(opts, row.dbname);
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
return toApiStructure(row, opts, rlsModule, authSettings);
};

const buildDevFallbackError = async (
Expand Down
29 changes: 25 additions & 4 deletions graphql/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,20 @@ import './types'; // for Request type
const log = new Logger('auth');
const isDev = () => getNodeEnv() === 'development';

/** Default cookie name for session tokens. */
const SESSION_COOKIE_NAME = 'constructive_session';

/**
* Extract a named cookie value from the raw Cookie header.
* Avoids pulling in cookie-parser as a dependency.
*/
const parseCookieToken = (req: Request, cookieName: string): string | undefined => {
const header = req.headers.cookie;
if (!header) return undefined;
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
};

export const createAuthenticateMiddleware = (
opts: PgpmOptions
): RequestHandler => {
Expand Down Expand Up @@ -60,8 +74,15 @@ export const createAuthenticateMiddleware = (
`authType=${authType ?? 'none'}, hasToken=${!!authToken}`
);

if (authType?.toLowerCase() === 'bearer' && authToken) {
log.info('[auth] Processing bearer token authentication');
// Resolve the credential: prefer Bearer header, fall back to session cookie
const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
? authToken
: cookieToken;
const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');

if (effectiveToken) {
log.info(`[auth] Processing ${tokenSource} authentication`);
const context: Record<string, any> = {
'jwt.claims.ip_address': req.clientIp,
};
Expand All @@ -81,7 +102,7 @@ export const createAuthenticateMiddleware = (
client: pool,
context,
query: authQuery,
variables: [authToken],
variables: [effectiveToken],
});

log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
Expand Down Expand Up @@ -111,7 +132,7 @@ export const createAuthenticateMiddleware = (
return;
}
} else {
log.info('[auth] No bearer token provided, using anonymous auth');
log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
}

req.token = token;
Expand Down
128 changes: 128 additions & 0 deletions graphql/server/src/middleware/captcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { Logger } from '@pgpmjs/logger';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import './types'; // for Request type

const log = new Logger('captcha');

/** Google reCAPTCHA verification endpoint */
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';

/**
* Header name the client sends the CAPTCHA response token in.
* Follows the common pattern: X-Captcha-Token.
*/
const CAPTCHA_HEADER = 'x-captcha-token';

/**
* GraphQL mutation names that require CAPTCHA verification when enabled.
* Only sign-up and password-reset are gated; normal sign-in is not.
*/
const CAPTCHA_PROTECTED_OPERATIONS = new Set([
'signUp',
'signUpWithMagicLink',
'signUpWithSms',
'resetPassword',
'requestPasswordReset',
]);

interface RecaptchaResponse {
success: boolean;
'error-codes'?: string[];
}

/**
* Attempt to extract the GraphQL operation name from the request body.
* Works for both JSON and already-parsed bodies.
*/
const getOperationName = (req: Request): string | undefined => {
const body = (req as any).body;
if (!body) return undefined;
// Already parsed (express.json ran first)
if (typeof body === 'object' && body.operationName) {
return body.operationName;
}
return undefined;
};

/**
* Verify a reCAPTCHA token with Google's API.
*/
const verifyToken = async (token: string, secretKey: string): Promise<boolean> => {
try {
const params = new URLSearchParams({ secret: secretKey, response: token });
const res = await fetch(RECAPTCHA_VERIFY_URL, {
method: 'POST',
body: params,
});
const data = (await res.json()) as RecaptchaResponse;
if (!data.success) {
log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
}
return data.success;
} catch (e: any) {
log.error('[captcha] Error verifying token:', e.message);
return false;
}
};

/**
* Creates a CAPTCHA verification middleware.
*
* When `enable_captcha` is true in app_auth_settings, this middleware checks
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
* (the public site key is stored in app_auth_settings for the frontend).
*
* Skips verification when:
* - CAPTCHA is not enabled in auth settings
* - The request is not a protected mutation
* - No secret key is configured server-side
*/
export const createCaptchaMiddleware = (): RequestHandler => {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authSettings = req.api?.authSettings;

// Skip if CAPTCHA is not enabled
if (!authSettings?.enableCaptcha) {
return next();
}

// Only gate protected operations
const opName = getOperationName(req);
if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
return next();
}

// Secret key must be set server-side (env var, not stored in DB for security)
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
if (!secretKey) {
log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
return next();
}

const captchaToken = req.get(CAPTCHA_HEADER);
if (!captchaToken) {
res.status(200).json({
errors: [{
message: 'CAPTCHA verification required',
extensions: { code: 'CAPTCHA_REQUIRED' },
}],
});
return;
}

const valid = await verifyToken(captchaToken, secretKey);
if (!valid) {
res.status(200).json({
errors: [{
message: 'CAPTCHA verification failed',
extensions: { code: 'CAPTCHA_FAILED' },
}],
});
return;
}

log.info(`[captcha] Verified for operation=${opName}`);
next();
};
};
28 changes: 21 additions & 7 deletions graphql/server/src/middleware/graphile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,14 +182,28 @@ const buildPreset = (
}

if (req.token?.user_id) {
return {
pgSettings: {
role: roleName,
'jwt.claims.token_id': req.token.id,
'jwt.claims.user_id': req.token.user_id,
...context,
},
const pgSettings: Record<string, string> = {
role: roleName,
'jwt.claims.token_id': req.token.id,
'jwt.claims.user_id': req.token.user_id,
...context,
};

// Propagate credential metadata as JWT claims so PG functions
// can read them via current_setting('jwt.claims.access_level') etc.
if (req.token.access_level) {
pgSettings['jwt.claims.access_level'] = req.token.access_level;
}
if (req.token.kind) {
pgSettings['jwt.claims.kind'] = req.token.kind;
}

// Enforce read-only transactions for read_only credentials (API keys, etc.)
if (req.token.access_level === 'read_only') {
pgSettings['default_transaction_read_only'] = 'on';
}

return { pgSettings };
}
}

Expand Down
2 changes: 2 additions & 0 deletions graphql/server/src/middleware/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import type { ApiStructure } from '../types';
export type ConstructiveAPIToken = {
id?: string;
user_id?: string;
access_level?: string;
kind?: string;
[key: string]: unknown;
};

Expand Down
Loading
Loading