Loopback redirect URI with port but without path is rejected (RFC 8252 §7.3)

Issue Description

Stalwart is used as the OpenID Connect provider for OpenCloud. The OpenCloud desktop client (a native app, public client with PKCE) starts a local listener on an ephemeral port and sends a loopback redirect_uri with scheme http, host 127.0.0.1 and that port, but WITHOUT a trailing slash (written below without scheme: 127.0.0.1:45678).

The OAuth client “OpenCloudDesktop” is registered in Stalwart with the redirect URIs “127.0.0.1” and “127.0.0.1/” (both with scheme http). Dynamic client registration is disabled (requireClientRegistration=true, anonymousClientRegistration=false), so the desktop client falls back to its fixed client_id.

The login is rejected with “Invalid client registration.” whenever the redirect URI contains a port but no path. With a trailing slash (127.0.0.1:45678/) the same login succeeds, so loopback port matching exists in principle.

Cause (from reading the source of v0.16.25): loopback_redirect_parts() in
crates/common/src/auth/oauth/registration.rs only recognises a loopback URI if a “/” follows the host or port (split_once(‘/’)). For “127.0.0.1:45678” it returns None, and redirect_uri_matches() falls back to an exact string comparison, which fails.

Expected Behavior

A redirect URI 127.0.0.1: (scheme http, no path) matches a registered loopback redirect URI 127.0.0.1 or 127.0.0.1/, and the authorization code is issued.

Actual Behavior

(all redirect URIs with scheme http)

  • 127.0.0.1:45678/ → accepted, the browser is redirected to 127.0.0.1:45678/?code=…&state=…
  • 127.0.0.1:45678 → rejected: after entering valid credentials the login page is shown again; log: auth.error “Invalid client registration.” (HTTP 401 from /api/auth)
  • 127.0.0.1 → accepted (exact match, but native apps cannot listen on port 80)

Deviation: the port is only treated as variable when the URI has a path. Native clients that omit the trailing slash (OpenCloud desktop) cannot sign in.

Reproduction Steps

  1. Create an OAuth client: clientId “OpenCloudDesktop”, no secret, redirect URIs 127.0.0.1 and 127.0.0.1/ (scheme http).
  2. Set OidcProvider requireClientRegistration=true, anonymousClientRegistration=false.
  3. Open in a browser (replace with the Stalwart host, scheme https):
    /login? client_id=OpenCloudDesktop&redirect_uri=http%3A%2F%2F127.0.0.1%3A45678
    &response_type=code&scope=openid+profile+email+offline_access&state=s1
    &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256
  4. Sign in with a valid account → the login page is shown again, no redirect.
  5. Repeat step 3 with redirect_uri=http%3A%2F%2F127.0.0.1%3A45678%2F (trailing slash)
    → sign-in succeeds and the browser is redirected with a code.

Relevant Log Output

DEBUG HTTP request URL (http.request-url) listenerId = “http”, localPort = 8080, url = “/login?client_id=OpenCloudDesktop&redirect_uri=http%3A%2F%2F127.0.0.1%3A45678&response_type=code&scope=openid+profile+email+offline_access&state=s1&code_challenge=…&code_challenge_method=S256”
DEBUG HTTP request URL (http.request-url) listenerId = “http”, localPort = 8080, url = “/api/auth”
INFO Authentication successful (auth.success) listenerId = “http”, localPort = 8080, accountName = “test@”, accountId = 9
ERROR Authentication error (auth.error) listenerId = “http”, localPort = 8080, details = “Invalid client registration.”
TRACE HTTP response body (http.response-body) listenerId = “http”, localPort = 8080, contents = “{“type”:“about:blank”,“status”:401,“title”:“Unauthorized”,“detail”:“You have to authenticate first.”}”, code = 401, size = 101

Stalwart Version

v0.16.x

Installation Method

Docker

Database Backend

RocksDB

Blob Storage

RocksDB

Search Engine

Internal

Directory Backend

Internal

Additional Context

  • Client: OpenCloud desktop client (GitHub: opencloud-eu/desktop). It builds the redirect_uri as “127.0.0.1:” without a trailing slash
    (src/libsync/creds/oauth.cpp: QStringLiteral(“%1:%2”).arg(redirectUrlC(), port)) and sends the same string to the token endpoint.
  • The token endpoint compares redirect_uri with the value stored with the code, so registering a URI with trailing slash or rewriting in the reverse proxy does not work around the issue.
  • Suggested fix: in loopback_redirect_parts(), treat a host or “:” followed by end of string as path “/”.
  • Reverse proxy: Caddy 2.11.7, forwards everything to Stalwart’s HTTP listener on 127.0.0.1:8080.

I have reviewed the documentation and FAQ and confirm that my issue is NOT addressed there.

on

I have searched this support forum (open and closed topics) and confirm this is not a duplicate.

on

I understand that topics in this category are triaged by a bot first but a human reply will follow up. If I’d prefer a human-only reply, I’ll add the no-ai tag to my topic.

on

This confirms the user’s own source analysis is accurate. This is a source-level bug report (confirmed by reading current code), not something addressed in docs, and the fix would need to come from the maintainers.

I’ve confirmed this against the current source code: loopback_redirect_parts() in crates/common/src/auth/oauth/registration.rs only matches when a / is found after the host or port via split_once('/'), so a bare 127.0.0.1:45678 with no trailing slash returns None and falls through to exact string comparison, exactly as you described. This is a genuine deviation from RFC 8252 §7.3, which requires any port to be accepted for loopback redirect URIs regardless of path presence.

This is a code-level issue rather than a configuration or documentation gap, so there’s no workaround available through settings. Your suggested fix (treating end-of-string after host/port as an implicit / path) looks correct and matches RFC 3986 §6.2.3’s empty-path-equals-/ rule.

This is an automated reply from the Stalwart Help Bot. Other community members may follow up if this answer is incomplete or wrong.

Fixed in v0.16.27 (yet to be released).