From ed863a3f385f2777db21c71d6a7343f6436db1d4 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Sun, 1 Nov 2026 15:26:50 -0800 Subject: [PATCH] feat: added OAUTH_ALLOWED_REDIRECT_HOSTS whitelist for oauth redirects --- .env-cmdrc-template | 1 + README.md | 5 +++++ config/.env.dev | 1 + docker-compose.yml | 1 + src/services/oauth.js | 13 +++++++++++++ tests/oauth.test.js | 41 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 62 insertions(+) diff --git a/.env-cmdrc-template b/.env-cmdrc-template index 6751629..18a2871 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -33,6 +33,7 @@ "OAUTH_ISSUER_URL": "http://localhost:3000", "OAUTH_CONSENT_URL": "http://localhost:4200", + "OAUTH_ALLOWED_REDIRECT_HOSTS": "localhost", "SWITCHER_API_LOGGER": true, "SWITCHER_API_LOGGER_LEVEL": "debug", diff --git a/README.md b/README.md index 770403c..c4173da 100644 --- a/README.md +++ b/README.md @@ -108,6 +108,11 @@ to this API. Set `OAUTH_CONSENT_URL` to your Switcher Management public URL so t `authorization_endpoint` advertised at `/.well-known/oauth-authorization-server` points there and `OAUTH_ISSUER_URL` to the public URL of your Switcher API. +Optionally, set `OAUTH_ALLOWED_REDIRECT_HOSTS` to a comma-separated list of hostnames (e.g. +`claude.ai,vscode.dev`) to restrict the https `redirect_uri` hosts accepted by `/oauth/register`, +`/oauth/authorize` and `/oauth/token`. These are the hosts of the OAuth clients' callbacks. +Loopback callbacks (`127.0.0.1`/`localhost`) are always allowed. When unset, any https host is accepted. + ### Running Switcher API from Docker Composer manifest file This option leverages Switcher API and Switcher Management with minimum settings required. diff --git a/config/.env.dev b/config/.env.dev index 4b0eeb5..c701bdb 100644 --- a/config/.env.dev +++ b/config/.env.dev @@ -32,6 +32,7 @@ SESSION_SECRET=SESSION_SECRET ### OAuth Configuration (public URL callback) OAUTH_CONSENT_URL=http://localhost:4200 OAUTH_ISSUER_URL=http://localhost:3000 +OAUTH_ALLOWED_REDIRECT_HOSTS= ### Switcher Management SSL_ENABLED=false diff --git a/docker-compose.yml b/docker-compose.yml index 256d355..adfd8f4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,7 @@ services: - OAUTH_CONSENT_URL=${OAUTH_CONSENT_URL} - OAUTH_ISSUER_URL=${OAUTH_ISSUER_URL} + - OAUTH_ALLOWED_REDIRECT_HOSTS=${OAUTH_ALLOWED_REDIRECT_HOSTS} - SWITCHER_API_LOGGER=${SWITCHER_API_LOGGER} - SWITCHER_API_LOGGER_LEVEL=${SWITCHER_API_LOGGER_LEVEL} diff --git a/src/services/oauth.js b/src/services/oauth.js index 0c422d0..5871606 100644 --- a/src/services/oauth.js +++ b/src/services/oauth.js @@ -37,6 +37,15 @@ function validateSupportedValues(values, supportedValues, field) { } } +function isAllowedRedirectHost(hostname) { + const allowedHosts = (process.env.OAUTH_ALLOWED_REDIRECT_HOSTS || '') + .split(',') + .map((host) => host.trim().toLowerCase()) + .filter(Boolean); + + return !allowedHosts.length || allowedHosts.includes(hostname.toLowerCase()); +} + function normalizeRedirectUri(redirectUri) { let url; @@ -57,6 +66,10 @@ function normalizeRedirectUri(redirectUri) { throw new Error('Invalid redirect_uri'); } + if (!isLoopback && !isAllowedRedirectHost(url.hostname)) { + throw new Error('Invalid redirect_uri'); + } + return url.toString(); } diff --git a/tests/oauth.test.js b/tests/oauth.test.js index c253603..8981489 100644 --- a/tests/oauth.test.js +++ b/tests/oauth.test.js @@ -209,6 +209,47 @@ describe('Testing OAuth Dynamic Client Registration', () => { }); }); +describe('Testing OAuth redirect host allow-list', () => { + beforeAll(setupDatabase); + + afterEach(() => { + delete process.env.OAUTH_ALLOWED_REDIRECT_HOSTS; + }); + + test('OAUTH_SUITE - Should NOT register a client - host not allowed', async () => { + process.env.OAUTH_ALLOWED_REDIRECT_HOSTS = 'claude.ai'; + + await request(app) + .post('/oauth/register') + .send({ redirect_uris: ['https://evil.example.com/callback'] }) + .expect(400); + }); + + test('OAUTH_SUITE - Should register a client - allowed host and loopback', async () => { + process.env.OAUTH_ALLOWED_REDIRECT_HOSTS = ' Claude.ai ,vscode.dev'; + + await registerClient({ redirect_uris: ['https://claude.ai/callback', REDIRECT_URI] }); + }); + + test('OAUTH_SUITE - Should NOT authorize - registered host no longer allowed', async () => { + const client = await registerClient({ redirect_uris: ['https://legacy.example.com/callback'] }); + process.env.OAUTH_ALLOWED_REDIRECT_HOSTS = 'claude.ai'; + + await request(app) + .get('/oauth/authorize') + .query({ + response_type: 'code', + client_id: client.client_id, + redirect_uri: 'https://legacy.example.com/callback', + code_challenge: 'challenge', + code_challenge_method: 'S256', + consent: 'true' + }) + .set('Authorization', `Bearer ${adminMasterAccountToken}`) + .send().expect(400); + }); +}); + describe('Testing OAuth Authorization Code flow', () => { let clientId;