Skip to content

Commit 844328c

Browse files
committed
feat(oauth2): support custom URI schemes for Native clients across redirect_uris, allowed_origins, post_logout_redirect_uris
Native (mobile/desktop) OAuth2 clients can now register custom app schemes (myapp://callback) in allowed_origins and post_logout_redirect_uris via the admin API and React UI, matching the support redirect_uris already had. The OIDC end-session flow honors a registered custom-scheme post-logout URI at runtime. Security hardening (found via adversarial code review): - Deny-list for dangerous/launch pseudo-schemes (javascript:, data:, intent:, etc.) and plain http, centralized in HttpUtils and shared by write-time validation (ClientService) and runtime allow-gates (Client::isUriAllowed/isPostLogoutUriAllowed). - RFC 8252 loopback carve-out: http://127.0.0.1|localhost redirect URIs remain allowed for Native clients (the standard native-app pattern), only non-loopback http is blocked. - Cross-client custom-scheme uniqueness check extended to all three URI fields (was redirect_uris only), preventing OS-level scheme interception between clients. - Defense-in-depth: runtime gates independently re-check the scheme deny-list rather than relying solely on write-time validation. - Fixed a pre-existing crash (missing array key "host") in URLUtils::canonicalUrl/Client::isPostLogoutUriAllowed for host-less custom-scheme URIs (mailto:, file:///x). - Fixed a pre-existing substring false-positive in the cross-client scheme collision check (e.g. "roipapp" matching inside "androipapp://..."). Also fixes an unrelated pre-existing bug in UserLoginTurnstileTest where assigning null (from an unset env var) to a typed string property threw a TypeError before the intended skip-guard could run. Plan: docs/plans/2026-07-14-native-clients-custom-schemes.md
1 parent 24c00ca commit 844328c

12 files changed

Lines changed: 506 additions & 29 deletions

File tree

‎app/Http/Controllers/Api/ClientApiController.php‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -699,8 +699,8 @@ protected function getUpdatePayloadValidationRules(): array
699699
'tos_uri' => 'nullable|url',
700700
'redirect_uris' => 'nullable|custom_url_set:application_type',
701701
'policy_uri' => 'nullable|url',
702-
'post_logout_redirect_uris' => 'nullable|ssl_url_set',
703-
'allowed_origins' => 'nullable|ssl_url_set',
702+
'post_logout_redirect_uris' => 'nullable|custom_url_set:application_type',
703+
'allowed_origins' => 'nullable|custom_url_set:application_type',
704704
'logout_uri' => 'nullable|url',
705705
'logout_session_required' => 'sometimes|required|boolean',
706706
'logout_use_iframe' => 'sometimes|required|boolean',

‎app/Models/OAuth2/Client.php‎

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
use App\libs\Utils\URLUtils;
1616
use Auth\User;
17+
use Utils\Http\HttpUtils;
1718
use Doctrine\Common\Collections\Criteria;
1819
use Illuminate\Support\Facades\Config;
1920
use Illuminate\Support\Facades\Log;
@@ -629,13 +630,34 @@ public function isScopeAllowed(string $scope):bool
629630
return $res;
630631
}
631632

633+
/**
634+
* Single source of truth for "is this scheme dangerous for a Native client" across the runtime allow-gates
635+
* (isUriAllowed for redirect_uris, isPostLogoutUriAllowed for post_logout_redirect_uris). Delegates the
636+
* actual deny-list to HttpUtils, which ClientService's write-time validation also uses.
637+
*
638+
* @param string $scheme
639+
* @param string|null $host enables the RFC 8252 http-loopback carve-out (see HttpUtils::isDisallowedNativeUriScheme)
640+
* @return bool
641+
*/
642+
private function isNativeDangerousScheme(string $scheme, ?string $host = null): bool
643+
{
644+
return $this->application_type === IClient::ApplicationType_Native && HttpUtils::isDisallowedNativeUriScheme($scheme, $host);
645+
}
646+
632647
/**
633648
* @param string $uri
634649
* @return bool
635650
*/
636651
public function isUriAllowed(string $uri):bool
637652
{
638653
Log::debug(sprintf("Client::isUriAllowed client %s original uri %s", $this->client_id, $uri));
654+
655+
$original_parts = @parse_url($uri);
656+
if ($original_parts !== false && isset($original_parts['scheme']) && $this->isNativeDangerousScheme($original_parts['scheme'], $original_parts['host'] ?? null)) {
657+
Log::debug(sprintf("Client::isUriAllowed url %s scheme is not allowed for native client %s", $uri, $this->client_id));
658+
return false;
659+
}
660+
639661
$uri = URLUtils::canonicalUrl($uri);
640662
if(empty($uri)) {
641663
Log::debug(sprintf("Client::isUriAllowed url %s is not valid", $uri));
@@ -1097,17 +1119,33 @@ public function isPostLogoutUriAllowed($post_logout_uri)
10971119
if ($parts == false) {
10981120
return false;
10991121
}
1100-
if($parts['scheme']!=='https')
1122+
// native clients may register custom schemes (myapp://...); every other app type requires https
1123+
if($this->application_type !== IClient::ApplicationType_Native && strtolower($parts['scheme'])!=='https')
11011124
return false;
11021125

1103-
$logout_without_port = $parts['scheme'].'://'.$parts['host'];
1126+
// defense-in-depth: re-check the scheme deny-list at the runtime allow-gate, not just at write time
1127+
// (ClientService::assertNativeCustomSchemesAllowed). A row can reach storage through a path other than
1128+
// ClientService (e.g. ClientFactory::build() called directly by a seeder or a future write path), so
1129+
// the gate that actually authorizes the live 302 redirect must not be the only enforcement point.
1130+
if($this->isNativeDangerousScheme($parts['scheme'], $parts['host'] ?? null))
1131+
return false;
1132+
1133+
// host-less URIs (e.g. mailto:, file:///x, myapp:///cb) pass FILTER_VALIDATE_URL but have no
1134+
// authority to match against; without this guard the concatenation below raises an
1135+
// "Undefined array key host" warning (converted to ErrorException) on the public end-session endpoint.
1136+
if(!isset($parts['host'])) return false;
1137+
1138+
// scheme/host are case-insensitive (RFC 3986); the write path normally lowercases the stored value,
1139+
// but match case-insensitively regardless so a bypassing write path can't silently break matching.
1140+
$stored_post_logout_uris = strtolower($this->post_logout_redirect_uris);
1141+
$logout_without_port = strtolower($parts['scheme'].'://'.$parts['host']);
11041142

1105-
if(str_contains($this->post_logout_redirect_uris, $logout_without_port )) return true;
1143+
if(str_contains($stored_post_logout_uris, $logout_without_port )) return true;
11061144

11071145
if(isset($parts['port']))
11081146
{
1109-
$logout_with_port = $parts['scheme'].'://'.$parts['host'].':'.$parts['port'];
1110-
return str_contains($this->post_logout_redirect_uris, $logout_with_port );
1147+
$logout_with_port = $logout_without_port.':'.$parts['port'];
1148+
return str_contains($stored_post_logout_uris, $logout_with_port );
11111149
}
11121150
return false;
11131151
}

‎app/Repositories/DoctrineOAuth2ClientRepository.php‎

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,19 +163,44 @@ public function getByOrigin(string $origin):?Client
163163
}
164164

165165
/**
166+
* Interception-prevention rule checked across all three URI-bearing fields (redirect_uris,
167+
* post_logout_redirect_uris, allowed_origins): whichever field a scheme was first claimed in, another
168+
* client re-registering it in ANY of the three fields creates the same OS-level scheme-collision risk
169+
* (the OS routes a custom-scheme redirect to whichever installed app claims it, regardless of which
170+
* field of which client this server thinks it belongs to).
171+
*
166172
* @param int $id
167173
* @param string $custom_scheme
168174
* @return bool
169175
*/
170-
public function hasCustomSchemeRegisteredForRedirectUrisOnAnotherClientThan(int $id, string $custom_scheme): bool
176+
public function hasCustomSchemeRegisteredOnAnotherClientThan(int $id, string $custom_scheme): bool
171177
{
172-
return $this->getEntityManager()
173-
->createQueryBuilder()
178+
$scheme = trim($custom_scheme);
179+
// fields are comma-separated URI lists; a plain '%scheme://%' substring match false-positives on any
180+
// longer scheme ending in this one (e.g. 'roipapp' matching inside 'androipapp://...'). Anchor the
181+
// match to a real list-item boundary: the scheme starts the field, or immediately follows a comma.
182+
$starts_with = $scheme . '://%';
183+
$after_comma = '%,' . $scheme . '://%';
184+
185+
$qb = $this->getEntityManager()->createQueryBuilder();
186+
$matches_field = function (string $field) use ($qb) {
187+
return $qb->expr()->orX(
188+
$qb->expr()->like($field, ':starts_with'),
189+
$qb->expr()->like($field, ':after_comma')
190+
);
191+
};
192+
193+
return $qb
174194
->select("count(e.id)")
175195
->from($this->getBaseEntity(), "e")
176-
->where("e.redirect_uris like :custom_scheme")
196+
->where($qb->expr()->orX(
197+
$matches_field("e.redirect_uris"),
198+
$matches_field("e.post_logout_redirect_uris"),
199+
$matches_field("e.allowed_origins")
200+
))
177201
->andWhere("e.id <> :id")
178-
->setParameter("custom_scheme", '%' . trim($custom_scheme). '://%')
202+
->setParameter("starts_with", $starts_with)
203+
->setParameter("after_comma", $after_comma)
179204
->setParameter("id", $id)
180205
->setMaxResults(1)
181206
->getQuery()

‎app/Services/OAuth2/ClientService.php‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,40 @@ public function getCurrentClientAuthInfo()
220220
throw new InvalidClientAuthMethodException;
221221
}
222222

223+
/**
224+
* Native clients may register genuine custom app URI schemes (e.g. myapp://, com.example.app://) in
225+
* allowed_origins and post_logout_redirect_uris. They may NOT register plain http:// outside the RFC 8252
226+
* loopback carve-out, nor dangerous/launch pseudo-schemes (javascript:, data:, intent:, ...): at
227+
* end-session these fields become live 302 redirect targets. See HttpUtils::DISALLOWED_NATIVE_URI_SCHEMES
228+
* for the deny-list (shared with the runtime allow-gates in Client::isUriAllowed/isPostLogoutUriAllowed).
229+
* Also enforces the same cross-client scheme-uniqueness rule redirect_uris already has, since a scheme
230+
* claimed by another client here creates the identical OS-level interception risk.
231+
*
232+
* @param array $payload
233+
* @param int $exclude_client_id the client being written; -1 (never a real id) when creating a new one
234+
* @throws ValidationException
235+
*/
236+
private function assertNativeCustomSchemesAllowed(array $payload, int $exclude_client_id = -1): void
237+
{
238+
foreach (['allowed_origins', 'post_logout_redirect_uris'] as $field) {
239+
if (empty($payload[$field])) continue;
240+
foreach (explode(',', $payload[$field]) as $uri) {
241+
$parts = @parse_url(trim($uri));
242+
if (!isset($parts['scheme'])) {
243+
throw new ValidationException(sprintf('invalid scheme on %s uri.', $field));
244+
}
245+
$scheme = strtolower($parts['scheme']);
246+
if (HttpUtils::isDisallowedNativeUriScheme($scheme, $parts['host'] ?? null)) {
247+
throw new ValidationException(sprintf('scheme %s:// is not allowed.', $scheme));
248+
}
249+
if (HttpUtils::isCustomSchema($scheme)
250+
&& $this->client_repository->hasCustomSchemeRegisteredOnAnotherClientThan($exclude_client_id, $scheme)) {
251+
throw new ValidationException(sprintf('schema %s:// already registered for another client.', $scheme));
252+
}
253+
}
254+
}
255+
}
256+
223257
/**
224258
* @param array $payload
225259
* @return IEntity
@@ -238,6 +272,12 @@ public function create(array $payload):IEntity
238272
throw new ValidationException('there is already another application with that name, please choose another one.');
239273
}
240274

275+
// close the create-path bypass: the same scheme allow-list update() enforces (only reachable
276+
// for native clients, where the runtime https gate is relaxed).
277+
if (($payload['application_type'] ?? null) === IClient::ApplicationType_Native) {
278+
$this->assertNativeCustomSchemesAllowed($payload);
279+
}
280+
241281
$client = ClientFactory::build($payload);
242282
$client = $this->client_credential_generator->generate($client);
243283

@@ -307,20 +347,22 @@ public function update(int $id, array $payload):IEntity
307347
if (!isset($uri['scheme'])) {
308348
throw new ValidationException('invalid scheme on redirect uri.');
309349
}
310-
if (HttpUtils::isCustomSchema($uri['scheme'])) {
311-
if ($this->client_repository->hasCustomSchemeRegisteredForRedirectUrisOnAnotherClientThan($id, $uri['scheme'])) {
312-
throw new ValidationException(sprintf('schema %s:// already registered for another client.',
313-
$uri['scheme']));
314-
}
315-
} else {
316-
if (!HttpUtils::isHttpSchema($uri['scheme'])) {
317-
throw new ValidationException(sprintf('scheme %s:// is invalid.',
318-
$uri['scheme']));
319-
}
350+
if (HttpUtils::isDisallowedNativeUriScheme($uri['scheme'], $uri['host'] ?? null)) {
351+
throw new ValidationException(sprintf('scheme %s:// is not allowed.', $uri['scheme']));
352+
}
353+
// the else branch previously here (rejecting non-http(s) "non-custom" schemes)
354+
// is unreachable now: ftp/file and non-loopback http are already rejected by
355+
// the deny-list check above, and https/loopback-http both satisfy isHttpSchema.
356+
if (HttpUtils::isCustomSchema($uri['scheme'])
357+
&& $this->client_repository->hasCustomSchemeRegisteredOnAnotherClientThan($id, $uri['scheme'])) {
358+
throw new ValidationException(sprintf('schema %s:// already registered for another client.',
359+
$uri['scheme']));
320360
}
321361
}
322362
}
323363
}
364+
365+
$this->assertNativeCustomSchemesAllowed($payload, $id);
324366
}
325367
break;
326368
case IClient::ApplicationType_Web_App:

‎app/libs/OAuth2/Repositories/IClientRepository.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,5 +55,5 @@ public function getByOrigin(string $origin):?Client;
5555
* @param string $custom_scheme
5656
* @return bool
5757
*/
58-
public function hasCustomSchemeRegisteredForRedirectUrisOnAnotherClientThan(int $id, string $custom_scheme):bool;
58+
public function hasCustomSchemeRegisteredOnAnotherClientThan(int $id, string $custom_scheme):bool;
5959
}

‎app/libs/Utils/Http/HttpUtils.php‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,44 @@
1818
*/
1919
final class HttpUtils
2020
{
21+
/**
22+
* Schemes native clients may NOT register in redirect_uris / allowed_origins / post_logout_redirect_uris,
23+
* even though they are otherwise allowed to register arbitrary custom app schemes there. https is always
24+
* allowed (checked separately); plain http is handled separately too (see isDisallowedNativeUriScheme -
25+
* RFC 8252 loopback redirection is a carve-out). Every scheme below, once handed to an OS/browser as a
26+
* live redirect target, can trigger an unintended action (script execution, app launch, install prompt,
27+
* local file/content access). Single source of truth for the write-time validator
28+
* (ClientService::assertNativeCustomSchemesAllowed) and the runtime allow-gates (Client::isUriAllowed,
29+
* Client::isPostLogoutUriAllowed).
30+
*/
31+
public const array DISALLOWED_NATIVE_URI_SCHEMES = [
32+
'javascript', 'data', 'vbscript', 'intent', 'file', 'ftp', 'blob', 'about', 'mailto', 'tel',
33+
'itms-services', 'market', 'sms', 'content', 'chrome-extension', 'filesystem', 'view-source',
34+
'ws', 'wss', 'googlechrome', 'applewebdata',
35+
];
36+
37+
/**
38+
* Loopback hosts exempted from the "plain http is disallowed" rule (RFC 8252 SS7.3): a native app
39+
* receiving its own redirect on 127.0.0.1/::1/localhost never sends the request over the network, so
40+
* there is no TLS downgrade to protect against.
41+
*/
42+
public const array NATIVE_LOOPBACK_HOSTS = ['127.0.0.1', '::1', '[::1]', 'localhost'];
43+
44+
/**
45+
* @param string $schema
46+
* @param string|null $host present when validating a full URI (e.g. redirect_uris); enables the
47+
* RFC 8252 http-loopback carve-out. Omit when only the scheme is known.
48+
* @return bool
49+
*/
50+
public static function isDisallowedNativeUriScheme(string $schema, ?string $host = null): bool
51+
{
52+
$schema = strtolower($schema);
53+
if ($schema === 'http') {
54+
return !in_array(strtolower((string)$host), self::NATIVE_LOOPBACK_HOSTS);
55+
}
56+
return in_array($schema, self::DISALLOWED_NATIVE_URI_SCHEMES);
57+
}
58+
2159
/**
2260
* @param string $schema
2361
* @return bool

‎app/libs/Utils/URLUtils.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ public static function canonicalUrl(string $url, bool $usePort = true):?string{
4040
{
4141
return null;
4242
}
43+
// host-less URIs (e.g. mailto:, file:///x) pass FILTER_VALIDATE_URL but have no authority to
44+
// canonicalize; without this guard the concatenation below raises an "Undefined array key host" warning.
45+
if (!isset($parts['host'])) {
46+
return null;
47+
}
4348
$canonical_url = $parts['scheme'].'://'.strtolower($parts['host']);
4449
if(isset($parts['port']) && $usePort) {
4550
$canonical_url .= ':'.strtolower($parts['port']);

‎resources/js/oauth2/profile/edit_client/components/logout_options.js‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,27 @@ import TagsInput, {getTags} from "../../../../components/tags_input";
1010

1111
import styles from "./common.module.scss";
1212

13-
const LogoutOptions = ({initialValues, onSavePromise}) => {
13+
// mirrors HttpUtils::$disallowed_native_uri_schemes on the backend; keep the two lists in sync.
14+
const DISALLOWED_NATIVE_SCHEMES = [
15+
'http:', 'javascript:', 'data:', 'vbscript:', 'intent:', 'file:', 'ftp:', 'blob:', 'about:', 'mailto:', 'tel:',
16+
'itms-services:', 'market:', 'sms:', 'content:', 'chrome-extension:', 'filesystem:', 'view-source:',
17+
'ws:', 'wss:', 'googlechrome:', 'applewebdata:',
18+
];
19+
20+
const LogoutOptions = ({appTypes, initialValues, onSavePromise}) => {
1421
const [loading, setLoading] = useState(false);
1522

1623
const validatePostLogoutRedirectURI = (value) => {
24+
// native clients may register genuine custom app schemes (myapp://...) or https, but not plain http
25+
// nor dangerous/launch pseudo-schemes (javascript:, data:, intent:, ...): matches the backend allow-list.
26+
if (initialValues.application_type === appTypes.Native) {
27+
try {
28+
const protocol = new URL(value).protocol.toLowerCase();
29+
return protocol === 'https:' || !DISALLOWED_NATIVE_SCHEMES.includes(protocol);
30+
} catch (err) {
31+
return false;
32+
}
33+
}
1734
const regex = /^https:\/\/([\w@][\w.:@]+)\/?[\w\.?=%&=\-@/$,]*$/ig;
1835
return regex.test(value);
1936
}
@@ -72,7 +89,6 @@ const LogoutOptions = ({initialValues, onSavePromise}) => {
7289
fullWidth
7390
size="small"
7491
variant="outlined"
75-
type="url"
7692
tags={getTags(formik.values.post_logout_redirect_uris)}
7793
errors={formik.errors.post_logout_redirect_uris}
7894
onChange={formik.handleChange}

‎resources/js/oauth2/profile/edit_client/components/security_settings_panel.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,7 @@ const SecuritySettingsPanel = (
295295
</>
296296
</Grid>
297297
<Grid item container>
298-
<LogoutOptions initialValues={initialValues} onSavePromise={onLogoutOptionsSavePromise}/>
298+
<LogoutOptions appTypes={appTypes} initialValues={initialValues} onSavePromise={onLogoutOptionsSavePromise}/>
299299
</Grid>
300300
</Grid>
301301
);

0 commit comments

Comments
 (0)