Skip to content

feat(oauth2): support custom URI schemes for Native clients across redirect_uris, allowed_origins, post_logout_redirect_uris - #147

Open
smarcet wants to merge 14 commits into
mainfrom
hotfix/native-app-custom-schemas
Open

feat(oauth2): support custom URI schemes for Native clients across redirect_uris, allowed_origins, post_logout_redirect_uris#147
smarcet wants to merge 14 commits into
mainfrom
hotfix/native-app-custom-schemas

Conversation

@smarcet

@smarcet smarcet commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

ApplicationType_Native (mobile/desktop) OAuth2 clients can now register custom app URI 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.

allowed_origins remains admin-API-only for Native clients (the UI input stays hidden) — no runtime path enforces it for Native today, so exposing a control for a value nothing currently checks wasn't worth the surface.

See ADR: docs/adr/0001-native-client-custom-uri-schemes.md · Plan: docs/plans/2026-07-14-native-clients-custom-schemes.md

Security hardening

Five rounds of adversarial code review surfaced that naively allowing "any scheme" was unsafe once these fields become live 302 redirect targets — and that the gap actually predated this PR in redirect_uris too (the field that carries the authorization code, more critical than the two new ones):

  • Deny-list for dangerous/launch pseudo-schemes (javascript:, data:, intent:, itms-services:, etc.) and plain http, applied consistently to all three URI fields.
  • RFC 8252 loopback carve-out: http://127.0.0.1|::1|localhost remains allowed for Native clients (the standard native-app redirect pattern) — only non-loopback http is blocked. Without this carve-out, the initial hardening pass would have broken every existing Native client using the standard pattern.
  • Cross-client custom-scheme uniqueness extended to all three URI fields (was redirect_uris-only before), preventing OS-level scheme interception between two clients registered with this IDP.
  • Defense-in-depth: the runtime allow-gates (Client::isUriAllowed/isPostLogoutUriAllowed) independently re-check the scheme deny-list rather than relying solely on write-time validation — a row reaching storage via a path other than ClientService (e.g. a seeder calling ClientFactory::build() directly) still can't produce a live redirect to a dangerous scheme.
  • 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 (roipapp matching inside androipapp://...) — an unanchored LIKE '%scheme://%', now anchored to real list-item boundaries.
  • Fixed a case-sensitive scheme comparison inconsistent with the rest of the new matching logic.
  • create() had zero request-level validation for the three URI fields, independent of the deny-list/uniqueness logic above: getCreatePayloadValidationRules() never declared redirect_uris/allowed_origins/post_logout_redirect_uris, so create() relied solely on the service-layer assertNativeCustomSchemesAllowed(). Two consequences: (1) a list submitted with a space after the comma (e.g. "https://a.com, myapp://cb") reached storage verbatim — ClientFactory::populate() only trims the whole payload string, not each item — silently defeating the anchored cross-client uniqueness match above for that entry; (2) a non-string value (e.g. a JSON array) reached explode() inside assertNativeCustomSchemesAllowed() untouched, throwing an uncaught TypeError (not caught by any Exception handler in APICRUDController::create()) instead of a clean 412. Fixed by adding the same custom_url_set:application_type rules update() already used to getCreatePayloadValidationRules(), plus an is_string() guard in CustomValidator::validateCustomUrlSet() — declaring the rule alone doesn't stop the TypeError, since Laravel invokes the rule callback with the raw value regardless of rule order.
    • Both regression tests were written and confirmed failing (testCreateNativeClientRejectsCustomSchemeWithLeadingSpaceInList, testCreateNativeClientRejectsArrayValueForRedirectUrisCleanly) before the fix, per TDD. One self-correction along the way: the space-after-comma bug was initially suspected reachable via update() too, but that path was already protected by validateCustomUrl's pre-existing untrimmed per-item scheme check — the real gap was create()-only, sharing one root cause with the TypeError bug, so one fix closes both.

Design: deny-list ownership

Per review, the deny-list (DISALLOWED_NATIVE_URI_SCHEMES) moved from Utils\Http\HttpUtils (a generic HTTP helper — wrong dependency direction for OAuth2-Native-client domain policy) to IClient (data, alongside ApplicationType_Native and friends) + Client::isDisallowedNativeUriScheme() (the predicate, since interfaces can't hold method bodies). The admin UI no longer hand-duplicates the list in JS — AdminController now injects window.DISALLOWED_NATIVE_URI_SCHEMES/window.NATIVE_LOOPBACK_HOSTS from the IClient constants (same mechanism already used for window.APP_TYPES), so the backend is the single source of truth.

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.

Testing

  • 154 PHPUnit tests, 0 failures (sequential run — the suite reseeds the DB per test, so parallel runs against the shared dev DB race), plus 2 new regression tests added in the fifth review round (see Security hardening above) — full Application Test Suite re-verified afterward at 158 tests / 527 assertions, 0 failures.
  • Live-verified against the running IDP: registered custom scheme → 302 redirect at /oauth2/end-session; unregistered/dangerous scheme → clean 400; loopback http://127.0.0.1 allowed, non-loopback http:// rejected; browser-driven admin UI save/reload round-trip for a Native client's Post Logout Uris.

Summary by CodeRabbit

  • New Features

    • Native OAuth clients can register custom URI schemes for redirect URIs, post-logout redirect URIs, and allowed origins, with HTTP allowed for approved loopback hosts.
    • Frontend validation now adapts to the selected application type using server-provided native scheme restrictions.
  • Bug Fixes

    • Stricter runtime enforcement: rejects dangerous schemes and host-less URIs, requires exact matches (after normalization) for redirect URIs and post-logout redirect URIs, and hardens origin matching.
    • More reliable cross-client scheme conflict detection across all relevant URI fields.
  • Tests

    • Expanded create/update and unit tests for native/non-native matching and scheme edge cases.
  • Documentation

    • Added an ADR describing the native custom URI scheme policy and matching rules.

…direct_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
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Native OAuth2 URI validation now uses shared deny-list and loopback policies across API writes, runtime URI checks, repository collision detection, and the admin editor. URL-set validation, host-less URI handling, locking, and regression coverage are also updated.

Changes

Native URI validation

Layer / File(s) Summary
Shared URI scheme policy
app/libs/OAuth2/Models/IClient.php, app/libs/Utils/URLUtils.php, app/Validators/CustomValidator.php, docs/adr/...
Native disallowed schemes and loopback hosts are centralized, host-less URLs return null during canonicalization, and non-string custom URL sets fail validation.
Cross-client scheme collision checks
app/libs/OAuth2/Repositories/IClientRepository.php, app/Repositories/DoctrineOAuth2ClientRepository.php
Custom-scheme uniqueness checks cover redirect URIs, post-logout redirect URIs, and allowed origins using boundary-aware matching.
API write-time validation and locking
app/Http/Controllers/Api/ClientApiController.php, app/Services/OAuth2/ClientService.php, tests/ClientApiTest.php
Create and update payloads apply application-specific URL-set rules and centralized native scheme validation under a lock, with coverage for dangerous schemes, collisions, whitespace, invalid types, and lock contention.
Runtime and editor URI checks
app/Models/OAuth2/Client.php, app/Http/Controllers/AdminController.php, resources/views/..., resources/js/..., tests/unit/ClientMappingTest.php
Runtime gates support native custom schemes and loopback HTTP while rejecting dangerous or host-less URIs and enforcing exact matches; the editor receives backend policy data and validates native post-logout URLs.

Test environment guard

Layer / File(s) Summary
Credential initialization guard
tests/UserLoginTurnstileTest.php
Environment credentials are checked before assignment to typed test properties.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminEditor
  participant ClientApiController
  participant ClientService
  participant ILockManagerService
  participant Client
  participant ClientRepository
  AdminEditor->>ClientApiController: submit URI fields
  ClientApiController->>ClientService: validate create or update payload
  ClientService->>ILockManagerService: acquire native scheme lock
  ClientService->>Client: evaluate scheme and host policy
  ClientService->>ClientRepository: check custom scheme ownership
  ClientRepository-->>ClientService: collision result
  ClientService-->>ClientApiController: validation response
  ClientApiController-->>AdminEditor: success or validation error
Loading

Suggested reviewers: romanetar

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.69% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: Native OAuth2 clients gaining custom URI scheme support across redirect_uris, allowed_origins, and post_logout_redirect_uris.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch hotfix/native-app-custom-schemas

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Repositories/DoctrineOAuth2ClientRepository.php (1)

193-207: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Enforce scheme claims atomically

This count-then-write check has a TOCTOU race: concurrent registrations can both pass before either write lands. Enforce the claim at the database layer with a unique constraint or equivalent locking.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Repositories/DoctrineOAuth2ClientRepository.php` around lines 193 - 207,
Replace the count-based duplicate check in the repository method containing the
shown query with database-enforced atomic uniqueness for redirect URI,
post-logout URI, and allowed-origin scheme claims. Add or use a unique
constraint or equivalent locking mechanism, and make the registration write
handle constraint conflicts so concurrent registrations cannot both succeed.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Models/OAuth2/Client.php`:
- Around line 1138-1148: Update the post-logout URI matching in the surrounding
OAuth2 client method to split the registered values on comma boundaries and
compare each complete URI against the requested URI, preserving path, query, and
other case-sensitive components while normalizing only scheme, host, and port as
appropriate. Remove the substring-based checks using stored_post_logout_uris,
logout_without_port, and logout_with_port, and add a regression test covering
identical scheme/host values with different paths.

In `@app/Services/OAuth2/ClientService.php`:
- Around line 275-279: Extend assertNativeCustomSchemesAllowed to validate
redirect_uris in addition to allowed_origins and post_logout_redirect_uris, and
invoke this shared helper during native client creation and update. Remove or
replace the duplicated redirect validation in update() so both paths enforce the
same scheme allow-list and duplicate checks.

In `@resources/js/oauth2/profile/edit_client/components/logout_options.js`:
- Around line 14-30: Update validatePostLogoutRedirectURI in the native-client
branch to allow http: URLs only when their host is a permitted loopback address,
matching HttpUtils and the backend’s RFC 8252 exception; continue rejecting
non-loopback HTTP and all disallowed schemes while preserving HTTPS and valid
custom schemes.

---

Outside diff comments:
In `@app/Repositories/DoctrineOAuth2ClientRepository.php`:
- Around line 193-207: Replace the count-based duplicate check in the repository
method containing the shown query with database-enforced atomic uniqueness for
redirect URI, post-logout URI, and allowed-origin scheme claims. Add or use a
unique constraint or equivalent locking mechanism, and make the registration
write handle constraint conflicts so concurrent registrations cannot both
succeed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 644ca4c9-a306-4e85-988a-fbc8613df5c7

📥 Commits

Reviewing files that changed from the base of the PR and between 24c00ca and 844328c.

📒 Files selected for processing (12)
  • app/Http/Controllers/Api/ClientApiController.php
  • app/Models/OAuth2/Client.php
  • app/Repositories/DoctrineOAuth2ClientRepository.php
  • app/Services/OAuth2/ClientService.php
  • app/libs/OAuth2/Repositories/IClientRepository.php
  • app/libs/Utils/Http/HttpUtils.php
  • app/libs/Utils/URLUtils.php
  • resources/js/oauth2/profile/edit_client/components/logout_options.js
  • resources/js/oauth2/profile/edit_client/components/security_settings_panel.js
  • tests/ClientApiTest.php
  • tests/UserLoginTurnstileTest.php
  • tests/unit/ClientMappingTest.php

Comment thread app/Models/OAuth2/Client.php Outdated
Comment thread app/Services/OAuth2/ClientService.php Outdated
Comment thread resources/js/oauth2/profile/edit_client/components/logout_options.js Outdated
smarcet added 2 commits July 14, 2026 21:15
…ils to IClient/Client; stop duplicating it in the frontend

PR review feedback: the scheme deny-list (DISALLOWED_NATIVE_URI_SCHEMES)
belonged on IClient (OAuth2 domain policy for Native clients), not
HttpUtils (a generic HTTP scheme classifier unrelated to any particular
client type). And logout_options.js was hand-duplicating the list in
JS with a "keep in sync" comment - an unenforced, easily-drifting
contract.

- IClient: new DISALLOWED_NATIVE_URI_SCHEMES / NATIVE_LOOPBACK_HOSTS
  consts (interfaces can't hold method bodies, so the data lives here).
- Client: new public static isDisallowedNativeUriScheme() - the
  predicate that interprets those consts, callable from ClientService
  without an instantiated entity. isNativeDangerousScheme() now
  delegates to it.
- HttpUtils: reverted to a pure generic scheme classifier
  (isCustomSchema/isHttpSchema/isHttpsSchema only) - no OAuth2/Native
  domain knowledge.
- AdminController -> edit-client.blade.php -> window.*: the admin UI
  now reads window.DISALLOWED_NATIVE_URI_SCHEMES /
  window.NATIVE_LOOPBACK_HOSTS, injected server-side from the IClient
  constants (same mechanism already used for window.APP_TYPES).
  logout_options.js has zero hardcoded scheme knowledge left.
- Side effect: the frontend never had an RFC 8252 http-loopback
  carve-out before (the old hardcoded list unconditionally rejected
  http:); now correctly mirrors the backend's loopback exception.

Verified: 154 tests / 0 failures; live browser check confirms
window.DISALLOWED_NATIVE_URI_SCHEMES / window.NATIVE_LOOPBACK_HOSTS
are populated from IClient and the validator behaves identically
(dangerous scheme rejected, loopback http accepted, custom scheme
accepted).

Plan: docs/plans/2026-07-14-native-clients-custom-schemes.md (Task 8)
ADR: docs/adr/0001-native-client-custom-uri-schemes.md
Documents the decision, the security findings from 4 review passes
(RFC 8252 loopback carve-out, cross-client scheme uniqueness,
defense-in-depth, deny-list vs allow-list trade-off), and the
post-review correction to deny-list ownership (IClient/Client instead
of HttpUtils; backend-served to the frontend instead of duplicated).
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

1 similar comment
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

…e, not just update

CodeRabbit PR review (#147): ClientService::create() validated
allowed_origins/post_logout_redirect_uris for Native clients but never
redirect_uris - a create payload could register a dangerous scheme
(javascript://, etc.) or a scheme already claimed by another client
through redirect_uris, only ever caught later by the runtime
isUriAllowed() gate rather than at write time.

assertNativeCustomSchemesAllowed() already ran both the deny-list
check and the cross-client uniqueness check generically per field
(since the earlier hardening pass); extending its field list to
include redirect_uris closes the gap for both create() and update()
via one shared method. This also let update()'s separate, by-then
fully-duplicate inline redirect_uris validation loop be deleted.

Verified: 156 tests / 0 failures.

Plan: docs/plans/2026-07-14-native-clients-custom-schemes.md (Task 9)
ADR: docs/adr/0001-native-client-custom-uri-schemes.md
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@smarcet
smarcet requested review from Copilot and romanetar July 15, 2026 00:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

getCreatePayloadValidationRules() did not declare redirect_uris,
allowed_origins, or post_logout_redirect_uris, so create() applied zero
request-level validation to them. Two consequences:

- A list with a space after the comma (e.g. "https://a.com,
  myapp://cb") reached storage verbatim (ClientFactory::populate only
  trims the whole payload string, not each item), silently defeating
  hasCustomSchemeRegisteredOnAnotherClientThan()'s comma-boundary
  anchored match and letting a second client register the same custom
  scheme unopposed.
- A non-string value (e.g. a JSON array) reached
  assertNativeCustomSchemesAllowed() untouched, where explode() threw
  an uncaught TypeError (not an Exception) instead of a clean 412.

Add the same custom_url_set:application_type rules update() already
uses to getCreatePayloadValidationRules(), closing both gaps at the
shared root cause. Also harden CustomValidator::validateCustomUrlSet()
with an is_string() guard, since Laravel invokes the rule callback
with the raw value regardless of rule order, so declaring the rule
alone doesn't stop the TypeError.

Regression tests added first (confirmed red before the fix):
testCreateNativeClientRejectsCustomSchemeWithLeadingSpaceInList and
testCreateNativeClientRejectsArrayValueForRedirectUrisCleanly.

Full suite: 158 tests, 527 assertions, 0 failures.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Http/Controllers/Api/ClientApiController.php (1)

702-703: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Require application_type to prevent 500 errors and ensure URL validation.

The custom_url_set:application_type validation rule relies on application_type being present in the request data. If application_type is omitted from the PUT payload, the rule silently returns true (skipping the URI validation entirely). The request then reaches ClientService::update(), which throws an HTTP 500 ErrorException due to an undefined array key when unconditionally checking $payload['application_type'] (line 331).

To fix this and ensure a clean HTTP 412 response, uncomment and enforce the application_type validation rule. For consistency with the create validation rules, consider also explicitly adding the string constraint to the URI fields.

🛠️ Proposed changes to `getUpdatePayloadValidationRules`

First, uncomment application_type near the top of the array:

-            //'application_type'                => 'required|application_type',
+            'application_type'                => 'required|applicationtype',

Then, update the URI fields:

-            'redirect_uris'                   => 'nullable|custom_url_set:application_type',
             'policy_uri'                      => 'nullable|url',
-            'post_logout_redirect_uris'       => 'nullable|custom_url_set:application_type',
-            'allowed_origins'                 => 'nullable|custom_url_set:application_type',
+            'redirect_uris'                   => 'nullable|string|custom_url_set:application_type',
+            'post_logout_redirect_uris'       => 'nullable|string|custom_url_set:application_type',
+            'allowed_origins'                 => 'nullable|string|custom_url_set:application_type',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Http/Controllers/Api/ClientApiController.php` around lines 702 - 703,
Update getUpdatePayloadValidationRules to require application_type using the
existing application type validation rule, preventing ClientService::update from
receiving an incomplete payload. Add the string constraint to
post_logout_redirect_uris and allowed_origins to match the create validation
rules while preserving their custom_url_set:application_type validation.
🧹 Nitpick comments (1)
tests/ClientApiTest.php (1)

290-295: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused local variables.

The $response variable is assigned but never used, as the internal response state is verified separately via $this->assertResponseStatus(). Removing the assignment cleans up static analysis warnings.

  • tests/ClientApiTest.php#L290-L295: remove the unused $response = assignment.
  • tests/ClientApiTest.php#L304-L312: remove the unused $response = assignment.
  • tests/ClientApiTest.php#L316-L326: remove the unused $response = assignment.
  • tests/ClientApiTest.php#L376-L386: remove the unused $response = assignment.
  • tests/ClientApiTest.php#L401-L411: remove the unused $response = assignment.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ClientApiTest.php` around lines 290 - 295, Remove the unused $response
assignment from each $this->action("POST", "Api\\ClientApiController@create",
...) call in tests/ClientApiTest.php at lines 290-295, 304-312, 316-326,
376-386, and 401-411, while preserving the existing calls and separate
$this->assertResponseStatus() verification.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/Http/Controllers/Api/ClientApiController.php`:
- Around line 702-703: Update getUpdatePayloadValidationRules to require
application_type using the existing application type validation rule, preventing
ClientService::update from receiving an incomplete payload. Add the string
constraint to post_logout_redirect_uris and allowed_origins to match the create
validation rules while preserving their custom_url_set:application_type
validation.

---

Nitpick comments:
In `@tests/ClientApiTest.php`:
- Around line 290-295: Remove the unused $response assignment from each
$this->action("POST", "Api\\ClientApiController@create", ...) call in
tests/ClientApiTest.php at lines 290-295, 304-312, 316-326, 376-386, and
401-411, while preserving the existing calls and separate
$this->assertResponseStatus() verification.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 46153607-d804-48fa-85da-1164176fa8ee

📥 Commits

Reviewing files that changed from the base of the PR and between 844328c and a0e83b1.

📒 Files selected for processing (10)
  • app/Http/Controllers/AdminController.php
  • app/Http/Controllers/Api/ClientApiController.php
  • app/Models/OAuth2/Client.php
  • app/Services/OAuth2/ClientService.php
  • app/Validators/CustomValidator.php
  • app/libs/OAuth2/Models/IClient.php
  • docs/adr/0001-native-client-custom-uri-schemes.md
  • resources/js/oauth2/profile/edit_client/components/logout_options.js
  • resources/views/oauth2/profile/edit-client.blade.php
  • tests/ClientApiTest.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • resources/js/oauth2/profile/edit_client/components/logout_options.js
  • app/Models/OAuth2/Client.php

…Client

isUriAllowed() matched a registered redirect_uri via str_contains() against
the requested URI - a prefix/substring check, not an exact match. Registering
"myapp://callback" therefore also permitted "myapp://callback/<anything>":
any path appended after the registered value passed, on the field that
carries the OAuth2 authorization code. Both sides now go through the same
canonicalUrl()+normalizeUrl() pipeline and are compared with strict equality;
query strings remain tolerated exactly as before (canonicalUrl already drops
them from both sides).

Also:
- Declare isDisallowedNativeUriScheme() on IClient alongside the constants it
  interprets, matching the interface-first convention every other predicate
  on Client already follows.
- Add a regression test for the path-suffix bypass.
- Correct ADR 0001 decision item 6, which still claimed create() never
  validates redirect_uris - stale relative to its own Consequences section
  and the actual assertNativeCustomSchemesAllowed() field list.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Models/OAuth2/Client.php (1)

854-861: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Exact match required for allowed origins.

The origin verification uses str_contains against the comma-separated allowed_origins list. This permits dangerous substring bypasses. For example, if a registered origin is https://my-app.example.com, an attacker who registers the domain https://my-app.example.co (or https://my-app) will be granted CORS access because the registered string contains the attacker's requested origin.

Split the allowed_origins list and perform an exact match against each registered origin to prevent this authorization bypass.

🔒️ Proposed fix to require exact origin matches
     public function isOriginAllowed(string $origin):bool
     {
         $originWithoutPort = URLUtils::canonicalUrl($origin, false);
         if(empty($originWithoutPort)) return false;
-        if(str_contains($this->allowed_origins, URLUtils::normalizeUrl($originWithoutPort) )) return true;
         $originWithPort = URLUtils::canonicalUrl($origin);
-        return str_contains($this->allowed_origins, URLUtils::normalizeUrl($originWithPort));
+
+        $normalizedWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
+        $normalizedWithPort = $originWithPort ? URLUtils::normalizeUrl($originWithPort) : null;
+
+        $allowed = explode(',', $this->allowed_origins);
+        foreach ($allowed as $allowed_origin) {
+            $allowed_origin = trim($allowed_origin);
+            if (empty($allowed_origin)) continue;
+
+            $canonical_allowed = URLUtils::canonicalUrl($allowed_origin);
+            if (empty($canonical_allowed)) continue;
+            $canonical_allowed = URLUtils::normalizeUrl($canonical_allowed);
+
+            if ($normalizedWithoutPort === $canonical_allowed || ($normalizedWithPort && $normalizedWithPort === $canonical_allowed)) {
+                return true;
+            }
+        }
+        return false;
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Models/OAuth2/Client.php` around lines 854 - 861, Update
OAuth2\Client::isOriginAllowed to split the comma-separated allowed_origins
value and compare the normalized requested origin against each registered origin
using exact equality, rather than str_contains. Apply this exact-match behavior
for both canonical origin variants while preserving the existing empty-origin
rejection.
♻️ Duplicate comments (1)
app/Models/OAuth2/Client.php (1)

1162-1174: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Match the complete registered post-logout URI.

The check authorizes only by substring-matching scheme://host[:port], ignoring the path, query, and list-item boundaries. For example, registering myapp://callback/safe also permits myapp://callback/other.

Parse the comma-separated registrations and compare each complete URI, normalizing only case-insensitive components. Add a regression test with identical scheme/host but a different path.

🔒️ Proposed fix to securely match the complete post-logout URI
-        // scheme/host are case-insensitive (RFC 3986); the write path normally lowercases the stored value,
-        // but match case-insensitively regardless so a bypassing write path can't silently break matching.
-        $stored_post_logout_uris = strtolower($this->post_logout_redirect_uris);
-        $logout_without_port = strtolower($parts['scheme'].'://'.$parts['host']);
-
-        if(str_contains($stored_post_logout_uris, $logout_without_port )) return true;
-
-        if(isset($parts['port']))
-        {
-            $logout_with_port    = $logout_without_port.':'.$parts['port'];
-            return str_contains($stored_post_logout_uris, $logout_with_port );
-        }
-        return false;
+        // Parse the comma-separated registrations and require an exact match using the same 
+        // canonicalize+normalize pipeline used for redirect_uris.
+        $canonical_requested = URLUtils::canonicalUrl($post_logout_uri);
+        if (empty($canonical_requested)) return false;
+        $canonical_requested = URLUtils::normalizeUrl($canonical_requested);
+        if (empty($canonical_requested)) return false;
+
+        $stored_uris = explode(',', $this->post_logout_redirect_uris);
+        foreach ($stored_uris as $stored_uri) {
+            $stored_uri = trim($stored_uri);
+            if (empty($stored_uri)) continue;
+
+            $canonical_stored = URLUtils::canonicalUrl($stored_uri);
+            if (empty($canonical_stored)) continue;
+            $canonical_stored = URLUtils::normalizeUrl($canonical_stored);
+
+            if ($canonical_requested === $canonical_stored) {
+                return true;
+            }
+        }
+        return false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Models/OAuth2/Client.php` around lines 1162 - 1174, Replace the substring
checks in the post-logout matching logic with parsing of each comma-separated
registered URI and comparison against the complete requested URI. Normalize only
scheme and host (and port as appropriate), while preserving path, query, and
list-item boundaries; update the relevant OAuth2 client test with a
same-scheme/host, different-path regression case.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@app/Models/OAuth2/Client.php`:
- Around line 854-861: Update OAuth2\Client::isOriginAllowed to split the
comma-separated allowed_origins value and compare the normalized requested
origin against each registered origin using exact equality, rather than
str_contains. Apply this exact-match behavior for both canonical origin variants
while preserving the existing empty-origin rejection.

---

Duplicate comments:
In `@app/Models/OAuth2/Client.php`:
- Around line 1162-1174: Replace the substring checks in the post-logout
matching logic with parsing of each comma-separated registered URI and
comparison against the complete requested URI. Normalize only scheme and host
(and port as appropriate), while preserving path, query, and list-item
boundaries; update the relevant OAuth2 client test with a same-scheme/host,
different-path regression case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fb0b1850-8dea-4c4f-b61d-70ccca90ccac

📥 Commits

Reviewing files that changed from the base of the PR and between a0e83b1 and bc5d118.

📒 Files selected for processing (4)
  • app/Models/OAuth2/Client.php
  • app/libs/OAuth2/Models/IClient.php
  • docs/adr/0001-native-client-custom-uri-schemes.md
  • tests/unit/ClientMappingTest.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/adr/0001-native-client-custom-uri-schemes.md
  • tests/unit/ClientMappingTest.php

…abbit-flagged gap

isPostLogoutUriAllowed() matched a registered value via str_contains() of only
scheme://host[:port] against the whole registered CSV string - the path was
never part of the comparison at all. Registering "myapp://callback/safe"
therefore also permitted "myapp://callback/other" or "myapp://callback/safe/
extra". This mirrors the fix already applied to isUriAllowed() (bc5d118):
both sides now go through the same canonicalUrl()+normalizeUrl() pipeline and
are compared with strict equality per registered entry. Query strings remain
tolerated - canonicalUrl() drops them from both sides, so dynamic per-request
?session=/?state=... params still match.

Adds regression tests for the path-suffix bypass and for continued dynamic
query-string acceptance.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

…g-bypass gap

isOriginAllowed() (CORS origin check for JS_Client, gated by
OAuth2BearerAccessTokenRequestValidator) still compared via
str_contains($this->allowed_origins, $normalizedOrigin) - a registered
origin like https://my-app.example.com incorrectly matched a requested
https://my-app.example.co, since the latter is a literal string prefix
of the former. This is the same bypass class already fixed on
isUriAllowed()/isPostLogoutUriAllowed() earlier in this PR, flagged
Critical by CodeRabbit but left unaddressed on this sibling method
(surfaced by /review-pr-deep on PR #147).

Rewritten to the same explode+trim+canonicalize+normalize+exact-match
pipeline as the two sibling methods. As a side effect this also fixes
an asymmetry bug where the registered side was never normalized, so an
exact-value registration could fail to match itself once normalizeUrl()
appended a trailing slash to the request side only.

Regression tests added to ClientMappingTest: the substring-prefix bypass
(RED before this fix), and the pre-existing with/without-port matching
semantics (a registered origin with no port matches any request port; one
with an explicit port only matches that port).

Verified: ClientMappingTest 14/14, and full "Application Test Suite"
163/163 (0 failures/errors) after a clean doctrine:migrations rebuild.

Also documents the accepted trade-off (docs/adr/0001): the exact-match
rewrite on isUriAllowed()/isPostLogoutUriAllowed() changes matching
behavior for every application type, not only Native, with no pre-deploy
audit of existing registrations - accepted given the small client base.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/Models/OAuth2/Client.php (1)

690-706: 🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Critical authorization bypass: Ignoring query strings and path casing in URI validation.

Both isUriAllowed and isPostLogoutUriAllowed rely on URLUtils::canonicalUrl() for their exact equality checks. Because this helper explicitly drops query strings and lowercases paths, the URI validation ignores these components.

While the inline comments and test (testIsPostLogoutUriAllowedNativeClientAcceptsDynamicQueryString) indicate this was intentionally done to tolerate dynamic ?state= or ?session= parameters in the redirect URI, this fundamentally violates OAuth 2.0 exact-matching requirements (RFC 6749 Section 3.1.2.2). Allowing clients to dynamically append query strings introduces a severe open-redirect and token-leakage vulnerability. If multiple applications share a callback domain but isolate tenants via query parameters (e.g., https://shared.com/callback?tenant=victim vs tenant=attacker), an attacker can supply malicious query parameters that the server ignores during validation but blindly follows during redirection. Dynamic parameters like state must always be sent as separate, top-level authorization request parameters, never dynamically embedded by the client into the redirect_uri.

To fix this securely, enforce exact string matching on the path and query string:

  • app/Models/OAuth2/Client.php#L690-L706: Retain the original requested $uri (before it is overwritten by canonicalUrl() at line 675). Update the loop to require that ($requested_parts['path'] ?? '') === ($registered_parts['path'] ?? '') and ($requested_parts['query'] ?? '') === ($registered_parts['query'] ?? ''), alongside the base canonical URL equality. Also, ensure ClientMappingTest.php is updated to reject dynamic query strings.
  • app/Models/OAuth2/Client.php#L1182-L1203: Apply the exact same strict, case-sensitive path and query string equality requirement when matching the original $post_logout_uri against each $registered_uri.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Models/OAuth2/Client.php` around lines 690 - 706, The URI validation in
Client::isUriAllowed and Client::isPostLogoutUriAllowed must preserve exact,
case-sensitive path and query matching instead of relying solely on
URLUtils::canonicalUrl(). Retain each original requested URI before
canonicalization, parse requested and registered URIs, and require matching base
canonical URLs plus identical path and query components; update
ClientMappingTest.php to reject dynamic query-string variants.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/Models/OAuth2/Client.php`:
- Around line 858-880: Add empty-value guards after each
URLUtils::normalizeUrl() call in the origin-matching flow, including the
requested origin values and canonical_allowed_origin, before performing strict
equality comparisons. Ensure malformed origins with null normalization results
are skipped or rejected, matching the established behavior in
isPostLogoutUriAllowed.

---

Outside diff comments:
In `@app/Models/OAuth2/Client.php`:
- Around line 690-706: The URI validation in Client::isUriAllowed and
Client::isPostLogoutUriAllowed must preserve exact, case-sensitive path and
query matching instead of relying solely on URLUtils::canonicalUrl(). Retain
each original requested URI before canonicalization, parse requested and
registered URIs, and require matching base canonical URLs plus identical path
and query components; update ClientMappingTest.php to reject dynamic
query-string variants.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2dc1414d-e523-48f6-8e63-21338f08355a

📥 Commits

Reviewing files that changed from the base of the PR and between bc5d118 and 2d091b2.

📒 Files selected for processing (3)
  • app/Models/OAuth2/Client.php
  • docs/adr/0001-native-client-custom-uri-schemes.md
  • tests/unit/ClientMappingTest.php
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/adr/0001-native-client-custom-uri-schemes.md
  • tests/unit/ClientMappingTest.php

Comment on lines +858 to +880
$originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);

$originWithPort = URLUtils::canonicalUrl($origin);
return str_contains($this->allowed_origins, URLUtils::normalizeUrl($originWithPort));
$originWithPort = empty($originWithPort) ? null : URLUtils::normalizeUrl($originWithPort);

// exact match against each registered value, through the same canonicalize+normalize pipeline on
// both sides (mirrors isUriAllowed()/isPostLogoutUriAllowed()) - a registered origin must no longer
// match merely because the requested origin is a string prefix of it (e.g. registered
// "https://my-app.example.com" incorrectly matching a requested "https://my-app.example.co" under
// the old str_contains($this->allowed_origins, $origin) check).
foreach(explode(',', $this->allowed_origins) as $allowed_origin){
$allowed_origin = trim($allowed_origin);
if(empty($allowed_origin)) continue;

$canonical_allowed_origin = URLUtils::canonicalUrl($allowed_origin);
if(empty($canonical_allowed_origin)) continue;
$canonical_allowed_origin = URLUtils::normalizeUrl($canonical_allowed_origin);

if($originWithoutPort === $canonical_allowed_origin) return true;
if($originWithPort !== null && $originWithPort === $canonical_allowed_origin) return true;
}

return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing null checks after normalization can cause unintended matches.

If URLUtils::normalizeUrl() fails and returns null for a malformed requested origin, $originWithoutPort evaluates to null. If a registered origin also causes normalizeUrl() to fail, the strict equality check ($originWithoutPort === $canonical_allowed_origin) evaluates to null === null and incorrectly returns true.

Add empty() checks after normalization to ensure null values are safely rejected, consistent with the pattern used in isPostLogoutUriAllowed.

🛡️ Proposed fix
-        $originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
+        $originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
+        if(empty($originWithoutPort)) return false;
 
         $originWithPort = URLUtils::canonicalUrl($origin);
         $originWithPort = empty($originWithPort) ? null : URLUtils::normalizeUrl($originWithPort);
 
         // exact match against each registered value, through the same canonicalize+normalize pipeline on
         // both sides (mirrors isUriAllowed()/isPostLogoutUriAllowed()) - a registered origin must no longer
         // match merely because the requested origin is a string prefix of it (e.g. registered
         // "https://my-app.example.com" incorrectly matching a requested "https://my-app.example.co" under
         // the old str_contains($this->allowed_origins, $origin) check).
         foreach(explode(',', $this->allowed_origins) as $allowed_origin){
             $allowed_origin = trim($allowed_origin);
             if(empty($allowed_origin)) continue;
 
             $canonical_allowed_origin = URLUtils::canonicalUrl($allowed_origin);
             if(empty($canonical_allowed_origin)) continue;
             $canonical_allowed_origin = URLUtils::normalizeUrl($canonical_allowed_origin);
+            if(empty($canonical_allowed_origin)) continue;
 
             if($originWithoutPort === $canonical_allowed_origin) return true;
             if($originWithPort !== null && $originWithPort === $canonical_allowed_origin) return true;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
$originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
$originWithPort = URLUtils::canonicalUrl($origin);
return str_contains($this->allowed_origins, URLUtils::normalizeUrl($originWithPort));
$originWithPort = empty($originWithPort) ? null : URLUtils::normalizeUrl($originWithPort);
// exact match against each registered value, through the same canonicalize+normalize pipeline on
// both sides (mirrors isUriAllowed()/isPostLogoutUriAllowed()) - a registered origin must no longer
// match merely because the requested origin is a string prefix of it (e.g. registered
// "https://my-app.example.com" incorrectly matching a requested "https://my-app.example.co" under
// the old str_contains($this->allowed_origins, $origin) check).
foreach(explode(',', $this->allowed_origins) as $allowed_origin){
$allowed_origin = trim($allowed_origin);
if(empty($allowed_origin)) continue;
$canonical_allowed_origin = URLUtils::canonicalUrl($allowed_origin);
if(empty($canonical_allowed_origin)) continue;
$canonical_allowed_origin = URLUtils::normalizeUrl($canonical_allowed_origin);
if($originWithoutPort === $canonical_allowed_origin) return true;
if($originWithPort !== null && $originWithPort === $canonical_allowed_origin) return true;
}
return false;
$originWithoutPort = URLUtils::normalizeUrl($originWithoutPort);
if(empty($originWithoutPort)) return false;
$originWithPort = URLUtils::canonicalUrl($origin);
$originWithPort = empty($originWithPort) ? null : URLUtils::normalizeUrl($originWithPort);
// exact match against each registered value, through the same canonicalize+normalize pipeline on
// both sides (mirrors isUriAllowed()/isPostLogoutUriAllowed()) - a registered origin must no longer
// match merely because the requested origin is a string prefix of it (e.g. registered
// "https://my-app.example.com" incorrectly matching a requested "https://my-app.example.co" under
// the old str_contains($this->allowed_origins, $origin) check).
foreach(explode(',', $this->allowed_origins) as $allowed_origin){
$allowed_origin = trim($allowed_origin);
if(empty($allowed_origin)) continue;
$canonical_allowed_origin = URLUtils::canonicalUrl($allowed_origin);
if(empty($canonical_allowed_origin)) continue;
$canonical_allowed_origin = URLUtils::normalizeUrl($canonical_allowed_origin);
if(empty($canonical_allowed_origin)) continue;
if($originWithoutPort === $canonical_allowed_origin) return true;
if($originWithPort !== null && $originWithPort === $canonical_allowed_origin) return true;
}
return false;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/Models/OAuth2/Client.php` around lines 858 - 880, Add empty-value guards
after each URLUtils::normalizeUrl() call in the origin-matching flow, including
the requested origin values and canonical_allowed_origin, before performing
strict equality comparisons. Ensure malformed origins with null normalization
results are skipped or rejected, matching the established behavior in
isPostLogoutUriAllowed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@smarcet Following up on both points raised in this review round for Client.php:

Missing null checks after normalizeUrl() (this thread, isOriginAllowed() line 880): Investigated by fuzzing URLUtils::normalizeUrl() against a wide range of malformed inputs (invalid UTF-8, malformed IPv6/ports, control characters, bracket/backslash paths, overlong percent-encoding, etc.). Every malformed case already fails earlier, at canonicalUrl()'s own filter_var(FILTER_VALIDATE_URL)/parse_url() guard, which is already checked. Could not find an input where canonicalUrl() succeeds but the subsequent normalizeUrl() call on that output returns null/empty. The gap is real on inspection (the underlying glenscott/url-normalizer library's mbParseUrl() can diverge from PHP's native parse_url() and reset to an empty state - see Normalizer::setUrl()), and adding the guard would match the pattern already used on the requested side in isPostLogoutUriAllowed(). Leaving open as a low-priority defensive-hardening item rather than a confirmed exploitable bug, since no reproduction was found despite the attempt.

Query string / path casing dropped by canonicalUrl() (outside-diff comment, lines 690-706): Not acting on this. canonicalUrl() never reads the query component and lowercases the path; both were true before this PR's changes and remain true after. The exact-match rewrite in this PR exists to close a prefix/substring bypass (a registered value matching as a substring of a different, longer URI) - that's the property RFC 6749 SS3.1.2.2 exact-matching is protecting here, and it's fully closed. Query-string tolerance is a pre-existing, deliberate choice already documented and tested for isPostLogoutUriAllowed() (testIsPostLogoutUriAllowedNativeClientAcceptsDynamicQueryString - dynamic state/session params appended by the client), and doesn't reopen that bypass: it doesn't change the destination authority or path, only the client's own trailing query params on their own already-validated endpoint. Path-case-insensitivity is similarly self-referential - it can only make a registered value match a differently-cased variant of itself, never a different client's URI or a different authority/path, so it doesn't reintroduce cross-client or cross-origin risk either. Both behaviors are now additionally covered by regression tests (testIsUriAllowedIgnoresPathCasingDifferences, testIsPostLogoutUriAllowedIgnoresPathCasingDifferences, testIsUriAllowedAcceptsAnyQueryStringNotJustDynamicOnes) so this stays a deliberate, pinned behavior rather than an unreviewed implicit one.

…nd a Redis lock

Closes the TOCTOU race in
IClientRepository::hasCustomSchemeRegisteredOnAnotherClientThan(): it's
a count-then-write check with no DB-level uniqueness constraint or
locking. Two concurrent create()/update() requests registering the same
custom scheme (e.g. two different developers self-registering clients on
this IDP) could both pass the "is this scheme already claimed" check
before either transaction commits, letting both end up with the same
scheme - defeating the OS-level interception-prevention guarantee the
check exists for (flagged Major by CodeRabbit on PR #147, left
unaddressed; raised again by /review-pr-deep as Finding 3).

Fix: ClientService::create()/update() now acquire a single global lock
(ILockManagerService, Redis-backed via LockManagerService - the same
distributed-lock abstraction TokenService already uses) around the
entire transaction for Native-client payloads, so the uniqueness check
and the write it gates are no longer split across two unsynchronized
transactions. Non-Native payloads are unaffected (no lock, no behavior
change).

SHORTCUT: one lock name serializes ALL Native client scheme
create/update calls, not just ones that would actually collide on the
same scheme. Acceptable given self-service client registration is
low-throughput. Upgrade trigger: move to per-scheme locks (sorted
acquisition order to avoid deadlock) if registration volume makes this
a bottleneck.

Residual gap (pre-existing, not introduced or worsened here): a row
reaching storage via ClientFactory::build() directly (e.g. a seeder)
still bypasses this lock, same as it already bypasses
assertNativeCustomSchemesAllowed() - unchanged by this fix.

Regression test added to ClientApiTest: pre-acquires the same lock name
the service uses to simulate contention, asserts a clean 412 instead of
a successful registration. True concurrent-transaction races aren't
reproducible in this synchronous test harness, so this proves the
locking mechanism is wired in and fails closed rather than proving the
race itself is gone.

Verified: ClientApiTest 17/17, full "Application Test Suite" 164/164
(0 failures/errors).
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
tests/ClientApiTest.php (1)

432-440: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove unused $response assignment.

The $response variable is assigned but never used here, as $this->assertResponseStatus(412) checks the internal state from the request directly. Removing the assignment resolves static analysis warnings.

Note: This same pattern appears in several other tests added in this file (e.g., lines 137, 157, 172, 202, 269, 307, 344, 379, 404). You can safely remove the $response = assignment in those locations as well.

♻️ Proposed fix
-            $response = $this->action("PUT", "Api\\ClientApiController@update",
+            $this->action("PUT", "Api\\ClientApiController@update",
                 array(
                     'id'                => $client->id,
                     'application_type'  => IClient::ApplicationType_Native,
                     'redirect_uris'     => 'lockcontention://callback',
                 ),
                 [],
                 [],
                 []);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/ClientApiTest.php` around lines 432 - 440, Remove the unused $response
assignment from this update test and the other matching test cases in the file,
while preserving the existing $this->action(...) calls and subsequent
assertions.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/ClientApiTest.php`:
- Around line 432-440: Remove the unused $response assignment from this update
test and the other matching test cases in the file, while preserving the
existing $this->action(...) calls and subsequent assertions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6e032c63-c8bc-4a10-be7a-ed229252c08b

📥 Commits

Reviewing files that changed from the base of the PR and between 2d091b2 and 65e467e.

📒 Files selected for processing (2)
  • app/Services/OAuth2/ClientService.php
  • tests/ClientApiTest.php

…review Finding 2)

Adds coverage for a gap identified by /review-pr-deep and independently by
CodeRabbit: URLUtils::canonicalUrl() drops the query string and lowercases
the full path before comparison, so the "exact match" isUriAllowed()/
isPostLogoutUriAllowed() advertise is weaker than RFC 6749 SS3.1.2.2's
byte-for-byte requirement on path/query. These tests pin today's actual
(permissive) behavior - they pass now, not RED tests for a fix - so a
future change to this area can't silently alter the behavior without a
test noticing, and so the gap is visible in the suite rather than only in
a review comment.

testIsUriAllowedIgnoresPathCasingDifferences /
testIsPostLogoutUriAllowedIgnoresPathCasingDifferences: registered path
casing doesn't have to match the requested URI's casing.

testIsUriAllowedAcceptsAnyQueryStringNotJustDynamicOnes: fills the same
gap already covered for isPostLogoutUriAllowed by the pre-existing
testIsPostLogoutUriAllowedNativeClientAcceptsDynamicQueryString (which
documents a deliberate choice for end-session's session/state params) -
isUriAllowed had no equivalent test, and unlike post-logout, redirect_uri
at /oauth2/authorize isn't expected to carry a client-appended dynamic
query string, so this coverage gap looks closer to unintended than
deliberate.

No test added for review Finding 1 (isOriginAllowed() missing an
empty-check after normalizeUrl(), independently flagged by CodeRabbit):
extensive fuzzing of URLUtils::normalizeUrl() (invalid UTF-8, malformed
IPv6/ports, control characters, bracket/backslash paths, etc.) found no
input where canonicalUrl() succeeds but the subsequent normalizeUrl()
call returns null - every malformed case already fails at the
canonicalUrl() stage, which is already guarded. Not writing a test for
an unconfirmed reproduction.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

…touch URI fields

ClientService::create()/update() acquired the TOCTOU lock
(NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK) whenever application_type was
Native, regardless of whether the payload touched redirect_uris,
allowed_origins, or post_logout_redirect_uris. A plain app_name rename
on one Native client therefore contended with an unrelated tenant's
scheme registration, producing spurious "please retry" 412s.

Extract NATIVE_CUSTOM_SCHEME_URI_FIELDS as the single source of truth
for which fields matter, shared by assertNativeCustomSchemesAllowed()
and the new payloadTouchesNativeCustomSchemeFields() gate. The lock is
now taken only when the payload actually registers a custom scheme -
matching what the SHORTCUT comment on the lock constant already
claimed.

Found during /review-pr-deep review of PR #147.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

smarcet added 3 commits July 15, 2026 15:45
…y Throwable

LockManagerService::acquireLock() passed the absolute expiry timestamp
(time()+lifetime+1) to Redis EXPIRE, which expects relative seconds: an
unreleased lock (owner crashed or killed mid-callback) stayed held for
~55 years instead of auto-recovering after lifetime. For the
NATIVE_CUSTOM_SCHEME_REGISTRATION_LOCK this meant every Native client
create()/update() touching URI fields returned 412 "please retry"
forever, until the Redis key was deleted by hand - contradicting the
30s recovery the lock's own doc comment promises.

lock() also caught only Exception, so a Throwable that does not extend
it (TypeError, Error) skipped both release paths and leaked the lock
until that TTL expired. Release now happens in a finally block;
acquisition stays outside the try so a failed acquireLock() still
propagates without releasing a lock we don't own.

Callers using acquireLock() as a single-use replay marker (nonce, auth
code, private association) are unaffected: their artifacts' own
lifetimes (360s/240s defaults) are shorter than the lock lifetimes, so
the now-honored relative TTL opens no replay window.

Found during /review-pr-deep review of PR #147.
… setUp

The test swaps in a minimal Container as facade root but never cleared
Facade::$resolvedInstance, so any previously-run BrowserKitTestCase
(which boots the full Laravel app) left resolved facades that shadowed
the mocks bound on the minimal container - Redirect::action() hit the
real redirector and the once() expectations failed at Mockery::close().
And since Mockery::close() is tearDown's first line, the facade cleanup
below it never ran, cascading the failure across all three tests.

Only surfaced now because the new tests/unit/LockManagerServiceTest.php
changed which test precedes it in the suite; running the unchanged
ClientMappingTest before it reproduces the same 3 errors, proving the
isolation gap predates this branch's changes.
…) throws

tearDown() called Mockery::close() first with no try/finally, so an
unmet mock expectation there threw before Facade::clearResolvedInstances()/
setFacadeApplication(null) could run - leaking this test's facade root
(the minimal Container with mocked bindings) to whatever test runs next
in the same PHP process. Same bug class the recent setUp() fix guards
against, but in the opposite direction: leaking outward instead of
receiving a leak from a prior test.

Found during adversarial re-review of the setUp() fix.
@github-actions

Copy link
Copy Markdown

📘 OpenAPI / Swagger preview

➡️ https://OpenStackweb.github.io/openstackid/openapi/pr-147/

This page is automatically updated on each push to this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants