Skip to content
Open
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
48 changes: 47 additions & 1 deletion graphql/env/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ConstructiveOptions } from '@constructive-io/graphql-types';
import type { ConstructiveOptions, OAuthProviderCredentials } from '@constructive-io/graphql-types';

/**
* Parse GraphQL-related environment variables.
Expand Down Expand Up @@ -27,8 +27,41 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
API_ANON_ROLE,
API_ROLE_NAME,
API_DEFAULT_DATABASE_ID,

// OAuth/SSO env vars
OAUTH_GOOGLE_CLIENT_ID,
OAUTH_GOOGLE_CLIENT_SECRET,
OAUTH_GITHUB_CLIENT_ID,
OAUTH_GITHUB_CLIENT_SECRET,
OAUTH_FACEBOOK_CLIENT_ID,
OAUTH_FACEBOOK_CLIENT_SECRET,
OAUTH_LINKEDIN_CLIENT_ID,
OAUTH_LINKEDIN_CLIENT_SECRET,
OAUTH_CALLBACK_BASE_URL,
OAUTH_BASE_URL,
OAUTH_SUCCESS_REDIRECT,
OAUTH_ERROR_REDIRECT,

// CAPTCHA env vars
RECAPTCHA_SECRET_KEY,
} = env;

// Build OAuth providers from paired CLIENT_ID / CLIENT_SECRET env vars
const oauthProviderEntries: [string, { clientId: string; clientSecret: string }][] = [
['google', OAUTH_GOOGLE_CLIENT_ID, OAUTH_GOOGLE_CLIENT_SECRET],
['github', OAUTH_GITHUB_CLIENT_ID, OAUTH_GITHUB_CLIENT_SECRET],
['facebook', OAUTH_FACEBOOK_CLIENT_ID, OAUTH_FACEBOOK_CLIENT_SECRET],
['linkedin', OAUTH_LINKEDIN_CLIENT_ID, OAUTH_LINKEDIN_CLIENT_SECRET],
]
.filter((entry): entry is [string, string, string] => !!(entry[1] && entry[2]))
.map(([name, clientId, clientSecret]) => [name, { clientId, clientSecret }] as [string, OAuthProviderCredentials]);

const oauthProviders = oauthProviderEntries.length > 0
? Object.fromEntries(oauthProviderEntries)
: undefined;

const oauthBaseUrl = OAUTH_CALLBACK_BASE_URL || OAUTH_BASE_URL || undefined;

return {
graphile: {
...(GRAPHILE_SCHEMA && {
Expand All @@ -51,5 +84,18 @@ export const getGraphQLEnvVars = (env: NodeJS.ProcessEnv = process.env): Partial
...(API_ROLE_NAME && { roleName: API_ROLE_NAME }),
...(API_DEFAULT_DATABASE_ID && { defaultDatabaseId: API_DEFAULT_DATABASE_ID }),
},
...((oauthProviders || oauthBaseUrl || OAUTH_SUCCESS_REDIRECT || OAUTH_ERROR_REDIRECT) && {
oauth: {
...(oauthProviders && { providers: oauthProviders }),
...(oauthBaseUrl && { baseUrl: oauthBaseUrl }),
...(OAUTH_SUCCESS_REDIRECT && { successRedirect: OAUTH_SUCCESS_REDIRECT }),
...(OAUTH_ERROR_REDIRECT && { errorRedirect: OAUTH_ERROR_REDIRECT }),
},
}),
...(RECAPTCHA_SECRET_KEY && {
captcha: {
recaptchaSecretKey: RECAPTCHA_SECRET_KEY,
},
}),
};
};
4 changes: 4 additions & 0 deletions graphql/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@
"backend"
],
"dependencies": {
"@constructive-io/csrf": "workspace:^",
"@constructive-io/graphql-env": "workspace:^",
"@constructive-io/graphql-types": "workspace:^",
"@constructive-io/oauth": "workspace:^",
"@constructive-io/s3-utils": "workspace:^",
"@constructive-io/upload-names": "workspace:^",
"@constructive-io/url-domains": "workspace:^",
Expand All @@ -53,6 +55,7 @@
"@pgpmjs/server-utils": "workspace:^",
"@pgpmjs/types": "workspace:^",
"@pgsql/quotes": "^17.1.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"deepmerge": "^4.3.1",
"express": "^5.2.1",
Expand All @@ -79,6 +82,7 @@
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.1009.0",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.6",
"@types/graphql-upload": "^8.0.12",
Expand Down
6 changes: 6 additions & 0 deletions graphql/server/src/middleware/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ const AUTH_SETTINGS_DISCOVERY_SQL = `
*/
const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => `
SELECT
enable_cookie_auth,
require_csrf_for_auth,
cookie_secure,
cookie_samesite,
cookie_domain,
Expand Down Expand Up @@ -142,6 +144,8 @@ interface RlsModuleData {
}

interface AuthSettingsRow {
enable_cookie_auth: boolean;
require_csrf_for_auth: boolean;
cookie_secure: boolean;
cookie_samesite: string;
cookie_domain: string | null;
Expand Down Expand Up @@ -252,6 +256,8 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => {
const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => {
if (!row) return undefined;
return {
enableCookieAuth: row.enable_cookie_auth,
requireCsrfForAuth: row.require_csrf_for_auth,
cookieSecure: row.cookie_secure,
cookieSamesite: row.cookie_samesite,
cookieDomain: row.cookie_domain,
Expand Down
1 change: 1 addition & 0 deletions graphql/server/src/middleware/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export const createAuthenticateMiddleware = (
}

req.token = token;
req.tokenSource = tokenSource as 'bearer' | 'cookie' | 'none';
} else {
log.info(
`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
Expand Down
10 changes: 6 additions & 4 deletions graphql/server/src/middleware/captcha.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Logger } from '@pgpmjs/logger';
import type { ConstructiveOptions } from '@constructive-io/graphql-types';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import './types'; // for Request type

Expand Down Expand Up @@ -78,7 +79,9 @@ const verifyToken = async (token: string, secretKey: string): Promise<boolean> =
* - The request is not a protected mutation
* - No secret key is configured server-side
*/
export const createCaptchaMiddleware = (): RequestHandler => {
export const createCaptchaMiddleware = (opts: ConstructiveOptions): RequestHandler => {
const secretKey = opts.captcha?.recaptchaSecretKey;

return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const authSettings = req.api?.authSettings;

Expand All @@ -93,10 +96,9 @@ export const createCaptchaMiddleware = (): RequestHandler => {
return next();
}

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

Expand Down
Loading
Loading