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
1 change: 1 addition & 0 deletions .env-cmdrc-template
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions config/.env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
13 changes: 13 additions & 0 deletions src/services/oauth.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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();
}

Expand Down
41 changes: 41 additions & 0 deletions tests/oauth.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down