mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Copilot Autofix powered by AI
parent
fa8693605f
commit
e94de0bdab
+35
-8
@@ -44,6 +44,7 @@ import (
|
|||||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||||
"github.com/coder/coder/v2/codersdk"
|
"github.com/coder/coder/v2/codersdk"
|
||||||
"github.com/coder/coder/v2/cryptorand"
|
"github.com/coder/coder/v2/cryptorand"
|
||||||
|
"github.com/coder/coder/v2/site"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MergedClaimsSource string
|
type MergedClaimsSource string
|
||||||
@@ -1343,12 +1344,21 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||||||
verified, ok := verifiedRaw.(bool)
|
verified, ok := verifiedRaw.(bool)
|
||||||
if ok && !verified {
|
if ok && !verified {
|
||||||
if !api.OIDCConfig.IgnoreEmailVerified {
|
if !api.OIDCConfig.IgnoreEmailVerified {
|
||||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||||
Message: fmt.Sprintf("Verify the %q email address on your OIDC provider to authenticate!", email),
|
Status: http.StatusForbidden,
|
||||||
|
HideStatus: true,
|
||||||
|
Title: "Email not verified",
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
"Verify the %q email address on your OIDC provider to authenticate!",
|
||||||
|
email,
|
||||||
|
),
|
||||||
|
Actions: []site.Action{
|
||||||
|
{URL: "/login", Text: "Back to login"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Warn(ctx, "allowing unverified oidc email %q")
|
logger.Warn(ctx, "allowing unverified oidc email", slog.F("email", email))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1370,8 +1380,17 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||||||
ok = false
|
ok = false
|
||||||
emailSp := strings.Split(email, "@")
|
emailSp := strings.Split(email, "@")
|
||||||
if len(emailSp) == 1 {
|
if len(emailSp) == 1 {
|
||||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||||
Message: fmt.Sprintf("Your email %q is not from an authorized domain! Please contact your administrator.", email),
|
Status: http.StatusForbidden,
|
||||||
|
HideStatus: true,
|
||||||
|
Title: "Unauthorized email",
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
"Your email %q is not from an authorized domain! Please contact your administrator.",
|
||||||
|
email,
|
||||||
|
),
|
||||||
|
Actions: []site.Action{
|
||||||
|
{URL: "/login", Text: "Back to login"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1385,8 +1404,17 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
site.RenderStaticErrorPage(rw, r, site.ErrorPageData{
|
||||||
Message: fmt.Sprintf("Your email %q is not from an authorized domain! Please contact your administrator.", email),
|
Status: http.StatusForbidden,
|
||||||
|
HideStatus: true,
|
||||||
|
Title: "Unauthorized email",
|
||||||
|
Description: fmt.Sprintf(
|
||||||
|
"Your email %q is not from an authorized domain! Please contact your administrator.",
|
||||||
|
email,
|
||||||
|
),
|
||||||
|
Actions: []site.Action{
|
||||||
|
{URL: "/login", Text: "Back to login"},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1406,7 +1434,6 @@ func (api *API) userOIDC(rw http.ResponseWriter, r *http.Request) {
|
|||||||
if ok {
|
if ok {
|
||||||
picture, _ = pictureRaw.(string)
|
picture, _ = pictureRaw.(string)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))
|
ctx = slog.With(ctx, slog.F("email", email), slog.F("username", username), slog.F("name", name))
|
||||||
|
|
||||||
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
|
user, link, err := findLinkedUser(ctx, api.Database, oidcLinkedID(idToken), email)
|
||||||
|
|||||||
+49
-3
@@ -1107,10 +1107,21 @@ func TestUserOIDC(t *testing.T) {
|
|||||||
},
|
},
|
||||||
AllowSignups: true,
|
AllowSignups: true,
|
||||||
StatusCode: http.StatusForbidden,
|
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",
|
Name: "EmailNotAString", IDTokenClaims: jwt.MapClaims{
|
||||||
IDTokenClaims: jwt.MapClaims{
|
|
||||||
"email": 3.14159,
|
"email": 3.14159,
|
||||||
"email_verified": false,
|
"email_verified": false,
|
||||||
"sub": uuid.NewString(),
|
"sub": uuid.NewString(),
|
||||||
@@ -1144,6 +1155,18 @@ func TestUserOIDC(t *testing.T) {
|
|||||||
"coder.com",
|
"coder.com",
|
||||||
},
|
},
|
||||||
StatusCode: http.StatusForbidden,
|
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",
|
Name: "EmailDomainWithLeadingAt",
|
||||||
@@ -1170,6 +1193,18 @@ func TestUserOIDC(t *testing.T) {
|
|||||||
"@coder.com",
|
"@coder.com",
|
||||||
},
|
},
|
||||||
StatusCode: http.StatusForbidden,
|
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",
|
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), "is not from an authorized domain")
|
||||||
require.Contains(t, string(data), "Please contact your administrator")
|
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 {
|
for _, domain := range allowedDomains {
|
||||||
require.NotContains(t, string(data), domain)
|
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), "is not from an authorized domain")
|
||||||
require.Contains(t, string(data), "Please contact your administrator")
|
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 {
|
for _, domain := range allowedDomains {
|
||||||
require.NotContains(t, string(data), domain)
|
require.NotContains(t, string(data), domain)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user