Support wildcard redirect_uri patterns for OAuth clients

theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its
own callback URL (https://<host>/__proxy_auth/callback) — proxy's own code
comment already assumed "a wildcard redirect URI covers all", but no
wildcard matching existed here, so every proxied host's callback had to be
registered on the shared OAuth client individually or /oauth/authorize
would reject it with InvalidRedirectURI.

Add `*` (one hostname label) / `**` (any number of labels) wildcard support
to redirect_uri matching, e.g. `https://**.example.com/__proxy_auth/callback`
now covers every host proxy fronts under example.com. Exact matches still
work exactly as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 00:42:32 -04:00
parent a52f5ce53d
commit 4c6b1e38b1
5 changed files with 83 additions and 2 deletions
Binary file not shown.
+66
View File
@@ -0,0 +1,66 @@
'use strict';
const { redirectUriAllowed } = require('../routes/oauth');
// Pure logic, no LDAP/Redis needed -- regression coverage for the bug where
// theta42/proxy's per-host SSO callback (a different URL per proxied host,
// e.g. https://site.example.com/__proxy_auth/callback) could never match a
// single OAuth client's redirect_uris list without registering every host's
// callback individually. `*`/`**` wildcard support lets one registered
// pattern (e.g. https://*.example.com/__proxy_auth/callback) cover a whole
// domain's worth of proxied hosts.
describe('redirectUriAllowed', () => {
test('exact match still works with no wildcard present', () => {
expect(redirectUriAllowed(
['https://app.example.com/cb'],
'https://app.example.com/cb'
)).toBe(true);
});
test('rejects a uri that is not registered', () => {
expect(redirectUriAllowed(
['https://app.example.com/cb'],
'https://app.example.com/cb2'
)).toBe(false);
});
test('* matches exactly one hostname label', () => {
expect(redirectUriAllowed(
['https://*.example.com/__proxy_auth/callback'],
'https://site.example.com/__proxy_auth/callback'
)).toBe(true);
});
test('* does not span multiple labels', () => {
expect(redirectUriAllowed(
['https://*.example.com/__proxy_auth/callback'],
'https://site.nl.example.com/__proxy_auth/callback'
)).toBe(false);
});
test('** spans multiple labels', () => {
expect(redirectUriAllowed(
['https://**.example.com/__proxy_auth/callback'],
'https://site.nl.example.com/__proxy_auth/callback'
)).toBe(true);
});
test('scheme mismatch is not allowed even with a wildcard', () => {
expect(redirectUriAllowed(
['https://*.example.com/__proxy_auth/callback'],
'http://site.example.com/__proxy_auth/callback'
)).toBe(false);
});
test('a wildcard pattern does not match an unrelated domain', () => {
expect(redirectUriAllowed(
['https://**.example.com/__proxy_auth/callback'],
'https://evil.com/__proxy_auth/callback'
)).toBe(false);
});
test('empty/missing patterns list rejects everything', () => {
expect(redirectUriAllowed([], 'https://app.example.com/cb')).toBe(false);
expect(redirectUriAllowed(undefined, 'https://app.example.com/cb')).toBe(false);
});
});
+9
View File
@@ -22,6 +22,9 @@
<div class="mb-3">
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea id="edit-redirect_uris" class="form-control shadow font-monospace" rows="3"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label, <code>**</code> matches any number of labels.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>
@@ -271,6 +274,12 @@
<label class="form-label">Redirect URIs <small class="text-muted">(one per line)</small></label>
<textarea class="form-control shadow font-monospace" name="redirect_uris" rows="3"
placeholder="https://ha.example.com/auth/external/callback" validate=":1"></textarea>
<small class="field-help text-muted d-block">
<code>*</code> matches one hostname label and <code>**</code> matches any
number of labels, e.g. <code>https://*.example.com/__proxy_auth/callback</code>
covers every host theta42/proxy fronts under example.com without registering
each one individually.
</small>
</div>
<div class="mb-3">
<label class="form-label">Scopes</label>