Skip to content

feat(passport): add oauth consent screen helpers for v1.5.0 - #24

Merged
dxnter merged 5 commits into
mainfrom
feat/oauth-consent-helpers
Oct 7, 2026
Merged

dxnter merged 5 commits into
mainfrom
feat/oauth-consent-helpers

Conversation

@dxnter

@dxnter dxnter commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Overview

  • DetectUnknownOAuthClient is a middleware for Passport's routes. It throws UnknownOAuthClientException (400) when a person reaches the authorization screen from a client that's been deleted or revoked, so the application can render a page. Today Passport answers that request with its 401 invalid_client JSON, and the person sees it in their browser. This happens most with self-registered clients such as MCP clients: one keeps its client ID after the server prunes or revokes it, and keeps sending people back with it.
    • It acts only on passport.authorizations.authorize, and only when the client is missing or revoked. The token endpoint still answers clients with JSON, which some of them act on by registering again. Passport also answers invalid_client for a redirect URI the client didn't register; that response is unchanged, because the client still exists.
    • It has to be middleware: Passport's OAuthServerException is an HttpResponseException, which the router returns before the exception handler sees it, so $exceptions->map() never runs.
  • OAuthRedirectTarget gives a consent screen the destination to show for a redirect URI. A client's name is only a claim; where it sends the person is the part they can check. It provides the scheme, host and port (https://bücher.example), the punycode form of an internationalized domain that could imitate another site's name (https://xn--bcher-kva.example), and whether the destination is on the person's own computer. It uses parse_url and the IDN functions, so it works on PHP 8.3.
  • OAuthRedirectTarget and OAuthRedirectUri refuse what they can't show truthfully. A consent screen must show the host the browser will actually go to, but PHP's parse_url() and a browser's URL parser disagree on some URIs: https://evil.example\@app.example.edu is app.example.edu to PHP and evil.example to a browser. Both now refuse those URIs instead of guessing: whitespace or control characters, a backslash, credentials, a port that isn't a number, a host a browser rewrites as IPv4 (0x7f.1), a right-to-left override, invalid punycode, or a host that decodes to / or @. Compared with PHP 8.5's WHATWG parser over 125,000 generated URIs, every URI it accepts shows the host a browser would go to. The rule change is under "Changed" in the CHANGELOG.
  • The Northwestern Laravel Starter will adopt all three once this is released: the consent screen shows the destination with the punycode warning, and a deleted or revoked client gets an "Application Not Registered" page.

Checklist

  • composer check passes: the repository has no check script; Rector (no changes), Pint, PHPStan and the Unit and Feature suites (460 tests) pass
  • Tests added/updated for behavioral changes
  • CHANGELOG.md updated (if user-facing): v1.5.0, dated 2026-10-07, with compare links

dxnter added 4 commits October 7, 2026 08:21
…n can't show

A self-registered OAuth client names itself, so the destination of its
redirect URI is the part a person can check before approving it.
OAuthRedirectTarget gives a consent screen that destination: the scheme,
host and port, the punycode form of an internationalized domain that
could imitate another site's name, and whether it is an application on
the person's own computer. It uses parse_url and the IDN functions, so it
works on PHP 8.3.

OAuthRedirectUri now refuses a redirect URI whose host isn't a valid
domain name or IP address: a right-to-left override, invalid punycode,
or a percent-encoded / or @ would make the host read as a different site.
…nsent screen

A self-registered client, such as an MCP client, keeps its client ID
after the server deletes or revokes it, and keeps sending people to the
authorization screen with it. Passport answers with its 401
invalid_client JSON, which the person sees in their browser.

DetectUnknownOAuthClient, added to Passport's routes, throws
UnknownOAuthClientException (400) in that case for the application to
render as a page. It acts only on the authorization screen and only when
the client is missing or revoked: the token endpoint still answers
clients with JSON, and Passport's answer to an unregistered redirect URI
for a client that exists is unchanged. Passport's OAuthServerException is
an HttpResponseException, which the router returns before the exception
handler sees it, so this has to be middleware rather than an exception
mapping.
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

📊 Test Results

    7 files  ±  0    357 suites  +24   1m 21s ⏱️ +2s
  502 tests + 48    502 ✅ + 48  0 💤 ±0  0 ❌ ±0 
2 802 runs  +288  2 802 ✅ +288  0 💤 ±0  0 ❌ ±0 

Results for commit bc092a1. ± Comparison against base commit 3ef7985.

♻️ This comment has been updated with latest results.

…rent hosts

OAuthRedirectTarget read hosts with parse_url(), which differs from a
browser's URL parser. For https://evil.example\@app.example.edu it showed
app.example.edu, while a browser reads the backslash as a slash and goes
to evil.example.

It now refuses, rather than guesses at, any URI the two could read
differently: whitespace or control characters, a backslash, credentials,
a port that isn't a number, and a host a browser rewrites as an IPv4
address (0x7f.1 is 127.0.0.1). IPv6 hosts are shown in their shortest
form, and the IDN conversion checks the bidi and joiner rules. Compared
with PHP 8.5's WHATWG parser over 125,000 generated URIs, every URI it
accepts shows the host a browser would go to. OAuthRedirectUri uses it,
so the rule refuses the same URIs at registration.
@github-actions

github-actions Bot commented Oct 7, 2026

Copy link
Copy Markdown

📊 Code Coverage Report

Metric Base Current Difference
Overall Coverage 🟢 98.68% 🟢 98.74% 🟢 0.06%
Coverage by File
File Base Coverage New Coverage Difference
Attributes/AutoSeed.php 🟢 100% 🟢 100% ⚪ 0%
Attributes/AutomaticallyOrdered.php 🟢 100% 🟢 100% ⚪ 0%
Attributes/ValidatesConfig.php 🟢 100% 🟢 100% ⚪ 0%
Console/Commands/AutoSeedListCommand.php 🟢 100% 🟢 100% ⚪ 0%
Console/Commands/RestoreLocalEnvironmentFilesCommand.php 🟢 100% 🟢 100% ⚪ 0%
Console/Commands/WakeDatabaseCommand.php 🟢 100% 🟢 100% ⚪ 0%
Database/DatabasePausedDetector.php 🟢 100% 🟢 100% ⚪ 0%
Database/ValueObjects/SchemaFileCollection.php 🟢 100% 🟢 100% ⚪ 0%
Database/ValueObjects/SchemaSnapshot.php 🟢 100% 🟢 100% ⚪ 0%
Database/ValueObjects/SnapshotListItem.php 🟢 100% 🟢 100% ⚪ 0%
Enums/ApiPrincipalType.php 🔴 0% 🔴 0% ⚪ 0%
Enums/ApiRequestFailure.php 🟢 100% 🟢 100% ⚪ 0%
Enums/OAuthGrantType.php 🔴 0% 🔴 0% ⚪ 0%
Exceptions/ProblemDetailsRenderer.php 🟢 100% 🟢 100% ⚪ 0%
Exceptions/SentryExceptionHandler.php 🟢 100% 🟢 100% ⚪ 0%
Exceptions/UnknownOAuthClientException.php 🔴 0% 🟢 100% 🟢 100%
Formatting/CountInWords.php 🟢 100% 🟢 100% ⚪ 0%
Formatting/NorthwesternDateTime.php 🟢 100% 🟢 100% ⚪ 0%
Formatting/TitleCase.php 🟢 100% 🟢 100% ⚪ 0%
Http/Controllers/SentryTunnelController.php 🟢 94.87% 🟢 94.87% ⚪ 0%
Http/Middleware/AuthenticatesAccessTokens.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/AuthenticatesPassportTokens.php 🟢 93.68% 🟢 93.68% ⚪ 0%
Http/Middleware/Concerns/ChecksAllowedIps.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/DetectUnknownOAuthClient.php 🔴 0% 🟢 100% 🟢 100%
Http/Middleware/EnsureFeatureEnabled.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/EnvironmentLockdown.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/LogsApiRequests.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/LogsPassportRequests.php 🟢 100% 🟢 100% ⚪ 0%
Http/Middleware/RequireSecretToken.php 🟢 100% 🟢 100% ⚪ 0%
Http/Responses/ProblemDetails.php 🟢 100% 🟢 100% ⚪ 0%
Markdown/ShiftHeadings.php 🟢 100% 🟢 100% ⚪ 0%
Models/Concerns/Auditable.php 🟢 100% 🟢 100% ⚪ 0%
Models/Concerns/HasAutomaticOrdering.php 🟢 100% 🟢 100% ⚪ 0%
Models/Concerns/PrunesAfterRetentionPeriod.php 🟢 100% 🟢 100% ⚪ 0%
Models/Concerns/RecordsCustomAudits.php 🟢 100% 🟢 100% ⚪ 0%
Models/Scopes/AutomaticallyOrderedScope.php 🟢 100% 🟢 100% ⚪ 0%
Passport/AccessRevoker.php 🟢 100% 🟢 100% ⚪ 0%
Passport/ExpiringAccessTokenRepository.php 🟢 100% 🟢 100% ⚪ 0%
Passport/OAuthClientRepository.php 🟢 100% 🟢 100% ⚪ 0%
Rules/OAuthRedirectUri.php 🟢 100% 🟢 100% ⚪ 0%
Rules/ValidIpOrCidrRule.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/Concerns/AuditsSeederChanges.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/Concerns/CleansUpOrphans.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/Concerns/PerformsIdempotentUpserts.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/IdempotentSeeder.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/IdempotentSeederResolver.php 🟢 100% 🟢 100% ⚪ 0%
Seeding/ValueObjects/SeederInfo.php 🟢 100% 🟢 100% ⚪ 0%
Services/ConfigValidatorResolver.php 🟢 100% 🟢 100% ⚪ 0%
Services/DateTimeFormatter.php 🟢 100% 🟢 100% ⚪ 0%
ValueObjects/OAuthRedirectTarget.php 🔴 0% 🟢 100% 🟢 100%

@dxnter
dxnter merged commit 88ef9fe into main Oct 7, 2026
16 checks passed
@dxnter
dxnter deleted the feat/oauth-consent-helpers branch October 7, 2026 13:30
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.

1 participant