From 4c6b1e38b1b832a0deb42d22683b4e07138af26e Mon Sep 17 00:00:00 2001 From: William Mantly Date: Wed, 15 Jul 2026 00:42:32 -0400 Subject: [PATCH] Support wildcard redirect_uri patterns for OAuth clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit theta42/proxy fronts an arbitrary number of hosts behind SSO, each with its own callback URL (https:///__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 --- API.md | 4 +- docs/oauth.md | 6 ++- nodejs/routes/oauth.js | Bin 14825 -> 15968 bytes nodejs/tests/redirect_uri.test.js | 66 ++++++++++++++++++++++++++++++ nodejs/views/oauth_clients.ejs | 9 ++++ 5 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 nodejs/tests/redirect_uri.test.js diff --git a/API.md b/API.md index 6814c41..f6cc00f 100644 --- a/API.md +++ b/API.md @@ -905,7 +905,9 @@ Returns the OIDC discovery document with endpoint URLs, supported scopes, and si **Query Parameters:** - `response_type` — Must be `code` - `client_id` — Registered OAuth client ID -- `redirect_uri` — Must exactly match a URI registered for the client +- `redirect_uri` — Must match a URI registered for the client, either exactly + or against a registered wildcard pattern (`*` = one hostname label, `**` = + any number of labels) - `scope` — Space-separated: `openid`, `profile`, `email` - `state` — Opaque value returned unchanged in the redirect - `code_challenge` — PKCE challenge (SHA-256 of code_verifier, base64url-encoded) diff --git a/docs/oauth.md b/docs/oauth.md index d57795a..88e6944 100644 --- a/docs/oauth.md +++ b/docs/oauth.md @@ -36,7 +36,11 @@ An OAuth client represents an app that authenticates against the SSO. Each has: - `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is shown once** when the client is created or rotated — save it immediately). - `name`, `description`, `created_by` (the admin uid that created it). -- `redirect_uris` — allowed callback URLs (must match exactly). +- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may + use `*` (one hostname label) / `**` (any number of labels) as a wildcard — + e.g. `https://*.example.com/__proxy_auth/callback` covers every host + theta42/proxy fronts under `example.com`, so you don't have to register + each proxied host's callback individually. - `scopes` — requested scopes (default `openid profile email groups`). - `allowed_groups` — restrict the client to members of specific SSO groups (empty = any valid user). diff --git a/nodejs/routes/oauth.js b/nodejs/routes/oauth.js index ec75cb37907f04d80f0bb2424c46fbda651961c6..2a0b8a1ef53972102b44fa673cbd38fd8d0e885d 100644 GIT binary patch delta 1208 zcmZ{k-%b-j6vl~iA&nQr#Kg-(Lfk(sv%z>JA*KpZ2#SFaFDSIb?rA%+o!QLHLMt@Y z_y~CAQ<(5V>YY!aXLeg!H71*#&CHqK`R1H&_NV!F`E`DN6h%-6jU8buYE0-e5gKFL z%(Z|XA3$ypT44)(B@w#HSjl?`DL=uq22z2?JWv|E$O~w!5U&-wa4rY7D`Y1CZh*tN zNRycB1b%$@49GeRT3u_ik$DnDA;UiJWht^)^`d4o)2csc^4xZ#n5XFpk55|;9IG?b z29Vx^LJHU}TK@P^RP2DZR?-^CWr7-JkToz0REC;cMf(#aJ?nT~el|j|>gN1_Jnn*n z2`YsTl{`afg^~qrKp1r{;Uy87!$MnN zngCBn8hKI_*|17GmvOOY(u4yo>S?OZG4V5QsX@}zfEp9PMP)&>!C$It%?C`FV!R0o z;PMidia?Dv*Am)1HMmw;Xj7|vQb!UB;)vE%yZk140WQene6YU0US=GFtj-;$h_QnV zr<-AkLOo+OHQjRp#t24r!~SmD_d>FW)lOqL2uVn_{pH&R0SWsZPN zR@{P{FR8&aHJX@bCG1Z;(^w?*qeB)}pB^t?4E^BpuyNFA97UZPc#X#LLvMyeHxB#z zR*&3ik3Fcum^~ITkyPmLnA2LS8*3^BdWXj>I;v7xtKcnDji^YL?#2a;MTaDw!ya}v z`*`pz5y=r^T3CS4tp+bvxIADy|$4G@L&7f8uAJv#TeqjaB6MMjo~VP%1y yS&v=+y1jDu-oFOE`d+<%s|MHGi(laX%U9crAFn^Hp4@p*yZIAMF98hKD!&0cosM+? delta 93 zcmaD*^RjpY3(Mv}4hn+a diff --git a/nodejs/tests/redirect_uri.test.js b/nodejs/tests/redirect_uri.test.js new file mode 100644 index 0000000..43cfd68 --- /dev/null +++ b/nodejs/tests/redirect_uri.test.js @@ -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); + }); +}); diff --git a/nodejs/views/oauth_clients.ejs b/nodejs/views/oauth_clients.ejs index 78374b4..eeaefe6 100644 --- a/nodejs/views/oauth_clients.ejs +++ b/nodejs/views/oauth_clients.ejs @@ -22,6 +22,9 @@
+ + * matches one hostname label, ** matches any number of labels. +
@@ -271,6 +274,12 @@ + + * matches one hostname label and ** matches any + number of labels, e.g. https://*.example.com/__proxy_auth/callback + covers every host theta42/proxy fronts under example.com without registering + each one individually. +