fix(coderd): render HTML error page for OIDC email validation failures (#23059)

## Summary

When the email address returned from an OIDC provider doesn't match the
configured allowed domain list (or isn't verified), users previously saw
raw JSON dumped directly in the browser — an ugly and confusing
experience during a browser-redirect flow.

This PR replaces those JSON responses with the same styled static HTML
error page already used for group allow-list errors, signups-disabled,
and wrong-login-type errors.

## Changes

### `coderd/userauth.go`
Replaced 3 `httpapi.Write` calls in `userOIDC` with
`site.RenderStaticErrorPage`:

| Error case | Title shown |
|---|---|
| Email domain not in allowed list | "Unauthorized email" |
| Malformed email (no `@`) with domain restrictions | "Unauthorized
email" |
| `email_verified` is `false` | "Email not verified" |

All render HTTP 403 with `HideStatus: true` and a "Back to login" action
button.

### `coderd/userauth_test.go`
- Updated `AssertResponse` callbacks on existing table-driven tests
(`EmailNotVerified`, `NotInRequiredEmailDomain`,
`EmailDomainForbiddenWithLeadingAt`) to verify HTML Content-Type and
page content.
- Extended `TestOIDCDomainErrorMessage` to additionally assert HTML
rendering.
- Added new `TestOIDCErrorPageRendering` with 3 subtests covering all
error scenarios, verifying: HTML doctype, expected title/description,
"Back to login" link, and absence of JSON markers.

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Charlie Voiselle
2026-03-16 11:56:59 -04:00
committed by GitHub
parent fa8693605f
commit e94de0bdab
2 changed files with 84 additions and 11 deletions
+49 -3
View File
@@ -1107,10 +1107,21 @@ func TestUserOIDC(t *testing.T) {
},
AllowSignups: true,
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Email not verified")
require.Contains(t, body, "Verify the")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailNotAString",
IDTokenClaims: jwt.MapClaims{
Name: "EmailNotAString", IDTokenClaims: jwt.MapClaims{
"email": 3.14159,
"email_verified": false,
"sub": uuid.NewString(),
@@ -1144,6 +1155,18 @@ func TestUserOIDC(t *testing.T) {
"coder.com",
},
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Unauthorized email")
require.Contains(t, body, "is not from an authorized domain")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailDomainWithLeadingAt",
@@ -1170,6 +1193,18 @@ func TestUserOIDC(t *testing.T) {
"@coder.com",
},
StatusCode: http.StatusForbidden,
AssertResponse: func(t testing.TB, resp *http.Response) {
data, err := io.ReadAll(resp.Body)
require.NoError(t, err)
body := string(data)
// Should be an HTML error page, not JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, body, "<!doctype html>")
require.Contains(t, body, "Unauthorized email")
require.Contains(t, body, "is not from an authorized domain")
require.Contains(t, body, "Back to login")
require.NotContains(t, body, `"message"`)
},
},
{
Name: "EmailDomainCaseInsensitive",
@@ -2062,6 +2097,12 @@ func TestOIDCDomainErrorMessage(t *testing.T) {
require.Contains(t, string(data), "is not from an authorized domain")
require.Contains(t, string(data), "Please contact your administrator")
// Verify the response is a rendered HTML error page, not raw JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, string(data), "<!doctype html>")
require.Contains(t, string(data), "Unauthorized email")
require.Contains(t, string(data), "Back to login")
require.NotContains(t, string(data), `"message"`)
for _, domain := range allowedDomains {
require.NotContains(t, string(data), domain)
@@ -2091,7 +2132,12 @@ func TestOIDCDomainErrorMessage(t *testing.T) {
require.Contains(t, string(data), "is not from an authorized domain")
require.Contains(t, string(data), "Please contact your administrator")
// Verify the response is a rendered HTML error page, not raw JSON.
require.Equal(t, "text/html; charset=utf-8", resp.Header.Get("Content-Type"))
require.Contains(t, string(data), "<!doctype html>")
require.Contains(t, string(data), "Unauthorized email")
require.Contains(t, string(data), "Back to login")
require.NotContains(t, string(data), `"message"`)
for _, domain := range allowedDomains {
require.NotContains(t, string(data), domain)
}