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.
- RFC 8252 §7.3: “The authorization server MUST allow any port to be specified at the time of the request for loopback IP redirect URIs, to accommodate clients that obtain an available ephemeral port from the operating system at the time of the request.”
RFC 8252 - OAuth 2.0 for Native Apps - RFC 3986 §6.2.3: an empty path is equivalent to “/” for http URIs.
RFC 3986 - Uniform Resource Identifier (URI): Generic Syntax
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
- Create an OAuth client: clientId “OpenCloudDesktop”, no secret, redirect URIs 127.0.0.1 and 127.0.0.1/ (scheme http).
- Set OidcProvider requireClientRegistration=true, anonymousClientRegistration=false.
- 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 - Sign in with a valid account → the login page is shown again, no redirect.
- 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