From 9440708f1697be490aa0dda3287186a10fabe3d7 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 11 Aug 2026 14:48:12 -0700 Subject: [PATCH 1/3] fix: allow bare custom-scheme redirects for public clients isValidCustomScheme required a literal "." in the scheme for a public client's redirect URI, so vscode://, jetbrains://, and cursor:// all 400'd while the identical schemes passed for a confidential client through the separate, more permissive validateScheme. Native and CLI apps, the population public clients exist for, register those exact schemes with their OS. Removed the extra restriction: validateScheme already blocks the schemes that are actually dangerous in a redirect context, and RFC 8252 section 7.1 only recommends reverse-domain notation rather than requiring it. PKCE, not the scheme's spelling, is what secures a public client's redirect. That removal also stopped rejecting mailto, tel, and sms for public clients specifically, since validateScheme's dangerous-scheme blocklist never covered them either. Those three hand off to a mail client, dialer, or SMS app rather than returning control to the client, so unlike vscode:// or jetbrains://, none of them can deliver an authorization code. A public client's redirect URI scheme is its only mechanism for regaining control, so they are rejected again here, scoped specifically to public clients rather than folded into validateScheme's blocklist, since they are harmless for a confidential client's redirect. --- coderd/oauth2_security_test.go | 61 ++++++++++++++++++++++++++++++++++ codersdk/oauth2_validation.go | 47 +++++++++++--------------- 2 files changed, 80 insertions(+), 28 deletions(-) diff --git a/coderd/oauth2_security_test.go b/coderd/oauth2_security_test.go index 17c092fd7aaf1..4c9780911267a 100644 --- a/coderd/oauth2_security_test.go +++ b/coderd/oauth2_security_test.go @@ -267,6 +267,19 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) { ClientName: fmt.Sprintf("native-app-3-%d", time.Now().UnixNano()), TokenEndpointAuthMethod: "none", // Required for public clients }, + { + // Bare custom schemes (no reverse-domain notation) are the + // schemes real native apps register with the OS, and PKCE, + // not the scheme's spelling, is what secures the redirect. + RedirectURIs: []string{"vscode://coder.authenticate"}, + ClientName: fmt.Sprintf("native-app-vscode-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + { + RedirectURIs: []string{"jetbrains://coder-callback"}, + ClientName: fmt.Sprintf("native-app-jetbrains-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, } for i, req := range validCustomSchemeRequests { @@ -312,6 +325,54 @@ func TestOAuth2PrivilegeEscalation(t *testing.T) { require.Contains(t, err.Error(), "dangerous scheme") }) } + + // mailto, tel, and sms are not in the dangerous-scheme blocklist + // above: they hand off to a mail client, dialer, or SMS app rather + // than injecting content, so they are harmless for a confidential + // client's redirect. A public client has no secret, so the redirect + // URI's scheme is its only mechanism for regaining control, and + // none of these three return control to it the way a real redirect + // scheme does. They are rejected for public clients specifically, + // with a distinct error from the dangerous-scheme case above. + publicClientDisallowedSchemeRequests := []struct { + req codersdk.OAuth2ClientRegistrationRequest + scheme string + }{ + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"mailto:user@example.com"}, + ClientName: fmt.Sprintf("native-app-mailto-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "mailto", + }, + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"tel:+15555550100"}, + ClientName: fmt.Sprintf("native-app-tel-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "tel", + }, + { + req: codersdk.OAuth2ClientRegistrationRequest{ + RedirectURIs: []string{"sms:+15555550100"}, + ClientName: fmt.Sprintf("native-app-sms-%d", time.Now().UnixNano()), + TokenEndpointAuthMethod: "none", + }, + scheme: "sms", + }, + } + + for _, test := range publicClientDisallowedSchemeRequests { + t.Run(fmt.Sprintf("PublicClientDisallowedScheme_%s", test.scheme), func(t *testing.T) { + t.Parallel() + + _, err := client.PostOAuth2ClientRegistration(ctx, test.req) + require.Error(t, err) + require.Contains(t, err.Error(), "public clients may not use the "+test.scheme+" scheme") + }) + } }) } diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 4c6ca0faa855e..2a80f9228b26f 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -163,17 +163,27 @@ func validateRedirectURIs(uris []string, tokenEndpointAuthMethod OAuth2TokenEndp } } } - } else { - // Custom scheme validation for public clients (RFC 8252 section 7.1) - if isPublicClient { - // For public clients, custom schemes should follow RFC 8252 recommendations - // Should be reverse domain notation based on domain under their control - if !isValidCustomScheme(uri.Scheme) { - return xerrors.Errorf("redirect URI at index %d: custom scheme %s should use reverse domain notation (e.g. com.example.app)", i, uri.Scheme) - } + } else if isPublicClient { + // mailto, tel, and sms hand off to a mail client, dialer, or SMS + // app rather than returning control to the client, so they + // cannot deliver an authorization code the way a real redirect + // scheme does. validateScheme does not reject them, since + // they're harmless for a confidential client's redirect, which + // is never reached through a scheme like this; blocking them + // here is specific to public clients, which use the redirect + // URI's scheme as their only mechanism for regaining control. + switch uri.Scheme { + case "mailto", "tel", "sms": + return xerrors.Errorf("redirect URI at index %d: public clients may not use the %s scheme", i, uri.Scheme) } - // For confidential clients, custom schemes are less common but allowed } + // Beyond that, custom schemes need no further check: validateScheme + // already blocked the ones that are dangerous in a redirect context, + // and RFC 8252 ยง7.1 only recommends reverse-domain notation rather + // than requiring it. Rejecting bare schemes such as vscode:// or + // jetbrains:// would penalize the native and CLI apps this client + // type exists for; PKCE, not the scheme's spelling, is what secures + // the redirect. // Prevent URI fragments (RFC 6749 section 3.1.2) if uri.Fragment != "" || strings.Contains(uriStr, "#") { @@ -295,22 +305,3 @@ func isLoopbackAddress(hostname string) bool { hostname == "127.0.0.1" || hostname == "::1" } - -// isValidCustomScheme validates custom schemes for public clients (RFC 8252) -func isValidCustomScheme(scheme string) bool { - // For security and RFC compliance, require reverse domain notation - // Should contain at least one period and not be a well-known scheme - if !strings.Contains(scheme, ".") { - return false - } - - // Block schemes that look like well-known protocols - wellKnownSchemes := []string{"http", "https", "ftp", "mailto", "tel", "sms"} - for _, wellKnown := range wellKnownSchemes { - if strings.EqualFold(scheme, wellKnown) { - return false - } - } - - return true -} From 450d0377839565e63ee654d1ce14569645c09455 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 11 Aug 2026 17:24:21 -0700 Subject: [PATCH 2/3] fix(codersdk/oauth2_validation): state the real reason for the mailto/tel/sms scope, not an invented one The previous comment claimed mailto, tel, and sms are harmless for a confidential client's redirect specifically. That is not true: the client_secret only matters at token exchange, not at redirect delivery, so nothing about being confidential changes what happens when the browser is sent to one of these schemes. The actual reason they are checked only in the isPublicClient branch is that custom-scheme validation was already scoped there before this PR; confidential clients were never subject to any scheme-shape check here, independent of any judgment about these three schemes. --- codersdk/oauth2_validation.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/codersdk/oauth2_validation.go b/codersdk/oauth2_validation.go index 2a80f9228b26f..34d0e37665ec5 100644 --- a/codersdk/oauth2_validation.go +++ b/codersdk/oauth2_validation.go @@ -165,13 +165,17 @@ func validateRedirectURIs(uris []string, tokenEndpointAuthMethod OAuth2TokenEndp } } else if isPublicClient { // mailto, tel, and sms hand off to a mail client, dialer, or SMS - // app rather than returning control to the client, so they - // cannot deliver an authorization code the way a real redirect - // scheme does. validateScheme does not reject them, since - // they're harmless for a confidential client's redirect, which - // is never reached through a scheme like this; blocking them - // here is specific to public clients, which use the redirect - // URI's scheme as their only mechanism for regaining control. + // app rather than returning control to the application that + // started the flow. A public client has no other way to obtain + // its authorization code, so registering one of these would + // produce a client that can never complete authorization. + // + // This check runs only for public clients because that is how + // custom-scheme validation was scoped before this change, not + // because these three schemes are known to be safe for a + // confidential client's redirect; confidential clients were + // never subject to any scheme-shape check beyond validateScheme + // and remain so here. switch uri.Scheme { case "mailto", "tel", "sms": return xerrors.Errorf("redirect URI at index %d: public clients may not use the %s scheme", i, uri.Scheme) From 7e616a3fd6e70539c135096adc6760dac9882839 Mon Sep 17 00:00:00 2001 From: Bobby Ho Date: Tue, 11 Aug 2026 20:34:49 -0700 Subject: [PATCH 3/3] docs(oauth2-provider): document mailto/tel/sms rejection for public clients The mailto, tel, and sms scheme rejection for public clients had no documentation, flagged by the doc-check bot on this PR. Note the restriction in the Callback URL schemes section and the Security Considerations list, and add a troubleshooting entry for the error. --- docs/admin/integrations/oauth2-provider.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/admin/integrations/oauth2-provider.md b/docs/admin/integrations/oauth2-provider.md index 1cbb17a3e1d0f..73f69725474eb 100644 --- a/docs/admin/integrations/oauth2-provider.md +++ b/docs/admin/integrations/oauth2-provider.md @@ -333,12 +333,25 @@ application's callback URL to a valid scheme (see Verify that the `code_verifier` used in the token request matches the one used to generate the `code_challenge`. +### "public clients may not use the mailto/tel/sms scheme" + +This error appears during client registration when a public client +(`token_endpoint_auth_method: none`) registers a redirect URI using the +`mailto:`, `tel:`, or `sms:` scheme. These schemes hand off to a mail +client, dialer, or SMS app instead of returning control to the +application that started the flow, so a public client registered with +one of them could never complete authorization. Register a redirect URI +the client can actually receive control on instead, such as a custom +scheme (`myapp://callback`) or a loopback HTTP address. + ## Callback URL schemes Custom URI schemes (`myapp://`, `vscode://`, `jetbrains://`, etc.) are fully supported for native and desktop applications. The OS routes the redirect back to the registered application without requiring a running HTTP server. The following schemes are blocked for security reasons: `javascript:`, `data:`, `file:`, `ftp:`. +Public clients (`token_endpoint_auth_method: none`) additionally cannot register `mailto:`, `tel:`, or `sms:` redirect URIs, since those schemes hand off to another app rather than returning an authorization code to the client. Confidential clients are not subject to this restriction. + ## Security Considerations - **Use HTTPS**: Always use HTTPS in production to protect tokens in transit @@ -346,7 +359,8 @@ The following schemes are blocked for security reasons: `javascript:`, `data:`, (public and confidential) - **Validate redirect URLs**: Only register trusted redirect URIs. Dangerous schemes (`javascript:`, `data:`, `file:`, `ftp:`) are blocked by the server, - but custom URI schemes for native apps (`myapp://`) are permitted + custom URI schemes for native apps (`myapp://`) are permitted, and public + clients additionally cannot use `mailto:`, `tel:`, or `sms:` - **Rotate secrets**: Periodically rotate client secrets using the management API ## Limitations