oidc: render HTML error pages for browser-facing failures
Add httpUserError() alongside httpError() for browser-facing error paths. It renders a styled HTML page using the AuthError template instead of returning plain text. Technical error details stay in server logs; the HTML page shows actionable messages derived from the HTTP status code: 401/403 → "You are not authorized. Please contact your administrator." 410 → "Your session has expired. Please try again." 400-499 → "The request could not be processed. Please try again." 500+ → "Something went wrong. Please try again later." Convert all httpError calls in oidc.go (OIDC callback, SSH check, registration confirm) to httpUserError. Machine-facing endpoints (noise, verify, key, health, debug) are unchanged. Fixes juanfont/headscale#3182
This commit is contained in:
parent
c15caff48c
commit
78990491da
@ -44,6 +44,54 @@ func httpError(w http.ResponseWriter, err error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// httpUserError logs an error and sends a styled HTML error page.
|
||||||
|
// Use this for browser-facing error paths (OIDC, registration confirm)
|
||||||
|
// where the user should see a branded page instead of plain text.
|
||||||
|
// Technical details go to the server log; the HTML page only shows
|
||||||
|
// an actionable message derived from the HTTP status code.
|
||||||
|
func httpUserError(w http.ResponseWriter, err error) {
|
||||||
|
code := http.StatusInternalServerError
|
||||||
|
|
||||||
|
if herr, ok := errors.AsType[HTTPError](err); ok {
|
||||||
|
if herr.Code != 0 {
|
||||||
|
code = herr.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Error().Err(herr.Err).Int("code", code).Msgf("user msg: %s", herr.Msg)
|
||||||
|
} else {
|
||||||
|
log.Error().Err(err).Int("code", code).Msg("http internal server error")
|
||||||
|
}
|
||||||
|
|
||||||
|
userMsg := userMessageForStatusCode(code)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
|
||||||
|
page := templates.AuthError(templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: http.StatusText(code),
|
||||||
|
Message: userMsg,
|
||||||
|
})
|
||||||
|
|
||||||
|
_, werr := w.Write([]byte(page.Render()))
|
||||||
|
if werr != nil {
|
||||||
|
log.Error().Err(werr).Msg("failed to write HTML error response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func userMessageForStatusCode(code int) string {
|
||||||
|
switch {
|
||||||
|
case code == http.StatusUnauthorized || code == http.StatusForbidden:
|
||||||
|
return "You are not authorized. Please contact your administrator."
|
||||||
|
case code == http.StatusGone:
|
||||||
|
return "Your session has expired. Please try again."
|
||||||
|
case code >= 400 && code < 500:
|
||||||
|
return "The request could not be processed. Please try again."
|
||||||
|
default:
|
||||||
|
return "Something went wrong. Please try again later."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// HTTPError represents an error that is surfaced to the user via web.
|
// HTTPError represents an error that is surfaced to the user via web.
|
||||||
type HTTPError struct {
|
type HTTPError struct {
|
||||||
Code int // HTTP response code to send to client; 0 means 500
|
Code int // HTTP response code to send to client; 0 means 500
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errTestUnexpected = errors.New("unexpected failure")
|
||||||
|
|
||||||
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
// TestHandleVerifyRequest_OversizedBodyRejected verifies that the
|
||||||
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
// /verify handler refuses POST bodies larger than verifyBodyLimit.
|
||||||
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
// The MaxBytesReader is applied in VerifyHandler, so we simulate
|
||||||
@ -55,3 +57,73 @@ func errorAsHTTPError(err error) (HTTPError, bool) {
|
|||||||
|
|
||||||
return HTTPError{}, false
|
return HTTPError{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHttpUserError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
wantCode int
|
||||||
|
wantContains string
|
||||||
|
wantNotContain string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "forbidden_renders_authorization_message",
|
||||||
|
err: NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil),
|
||||||
|
wantCode: http.StatusForbidden,
|
||||||
|
wantContains: "You are not authorized. Please contact your administrator.",
|
||||||
|
wantNotContain: "csrf token mismatch",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unauthorized_renders_authorization_message",
|
||||||
|
err: NewHTTPError(http.StatusUnauthorized, "unauthorised domain", nil),
|
||||||
|
wantCode: http.StatusUnauthorized,
|
||||||
|
wantContains: "You are not authorized. Please contact your administrator.",
|
||||||
|
wantNotContain: "unauthorised domain",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gone_renders_session_expired",
|
||||||
|
err: NewHTTPError(http.StatusGone, "login session expired, try again", nil),
|
||||||
|
wantCode: http.StatusGone,
|
||||||
|
wantContains: "Your session has expired. Please try again.",
|
||||||
|
wantNotContain: "login session expired",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bad_request_renders_generic_retry",
|
||||||
|
err: NewHTTPError(http.StatusBadRequest, "state not found", nil),
|
||||||
|
wantCode: http.StatusBadRequest,
|
||||||
|
wantContains: "The request could not be processed. Please try again.",
|
||||||
|
wantNotContain: "state not found",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "plain_error_renders_500",
|
||||||
|
err: errTestUnexpected,
|
||||||
|
wantCode: http.StatusInternalServerError,
|
||||||
|
wantContains: "Something went wrong. Please try again later.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "html_structure_present",
|
||||||
|
err: NewHTTPError(http.StatusGone, "session expired", nil),
|
||||||
|
wantCode: http.StatusGone,
|
||||||
|
wantContains: "<!DOCTYPE html>",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
httpUserError(rec, tt.err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.wantCode, rec.Code)
|
||||||
|
assert.Contains(t, rec.Header().Get("Content-Type"), "text/html")
|
||||||
|
assert.Contains(t, rec.Body.String(), tt.wantContains)
|
||||||
|
|
||||||
|
if tt.wantNotContain != "" {
|
||||||
|
assert.NotContains(t, rec.Body.String(), tt.wantNotContain)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -155,21 +155,21 @@ func (a *AuthProviderOIDC) authHandler(
|
|||||||
) {
|
) {
|
||||||
authID, err := authIDFromRequest(req)
|
authID, err := authIDFromRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the state and nonce cookies to protect against CSRF attacks
|
// Set the state and nonce cookies to protect against CSRF attacks
|
||||||
state, err := setCSRFCookie(writer, req, "state")
|
state, err := setCSRFCookie(writer, req, "state")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the state and nonce cookies to protect against CSRF attacks
|
// Set the state and nonce cookies to protect against CSRF attacks
|
||||||
nonce, err := setCSRFCookie(writer, req, "nonce")
|
nonce, err := setCSRFCookie(writer, req, "nonce")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -222,7 +222,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
) {
|
) {
|
||||||
code, state, err := extractCodeAndStateParamFromRequest(req)
|
code, state, err := extractCodeAndStateParamFromRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,29 +230,29 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
|
|
||||||
cookieState, err := req.Cookie(stateCookieName)
|
cookieState, err := req.Cookie(stateCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "state not found", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if state != cookieState.Value {
|
if state != cookieState.Value {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "state did not match", nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
oauth2Token, err := a.getOauth2Token(req.Context(), code, state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, err := a.extractIDToken(req.Context(), oauth2Token)
|
idToken, err := a.extractIDToken(req.Context(), oauth2Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if idToken.Nonce == "" {
|
if idToken.Nonce == "" {
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found in IDToken", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -260,12 +260,12 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
|
|
||||||
nonce, err := req.Cookie(nonceCookieName)
|
nonce, err := req.Cookie(nonceCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "nonce not found", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if idToken.Nonce != nonce.Value {
|
if idToken.Nonce != nonce.Value {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "nonce did not match", nil))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -273,7 +273,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
|
|
||||||
var claims types.OIDCClaims
|
var claims types.OIDCClaims
|
||||||
if err := idToken.Claims(&claims); err != nil { //nolint:noinlineerr
|
if err := idToken.Claims(&claims); err != nil { //nolint:noinlineerr
|
||||||
httpError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
httpUserError(writer, fmt.Errorf("decoding ID token claims: %w", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -310,26 +310,17 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
// against allowed emails, email domains, and groups.
|
// against allowed emails, email domains, and groups.
|
||||||
err = doOIDCAuthorization(a.cfg, &claims)
|
err = doOIDCAuthorization(a.cfg, &claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, _, err := a.createOrUpdateUserFromClaim(&claims)
|
user, _, err := a.createOrUpdateUserFromClaim(&claims)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().
|
httpUserError(writer, NewHTTPError(
|
||||||
Err(err).
|
http.StatusInternalServerError,
|
||||||
Caller().
|
"could not create or update user",
|
||||||
Msgf("could not create or update user")
|
err,
|
||||||
writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
))
|
||||||
writer.WriteHeader(http.StatusInternalServerError)
|
|
||||||
|
|
||||||
_, werr := writer.Write([]byte("Could not create or update user"))
|
|
||||||
if werr != nil {
|
|
||||||
log.Error().
|
|
||||||
Caller().
|
|
||||||
Err(werr).
|
|
||||||
Msg("Failed to write HTTP response")
|
|
||||||
}
|
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -341,7 +332,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
authInfo := a.getAuthInfoFromState(state)
|
authInfo := a.getAuthInfoFromState(state)
|
||||||
if authInfo == nil {
|
if authInfo == nil {
|
||||||
log.Debug().Caller().Str("state", state).Msg("state not found in cache, login session may have expired")
|
log.Debug().Caller().Str("state", state).Msg("state not found in cache, login session may have expired")
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -367,7 +358,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
|
authReq, ok := a.h.state.GetAuthCacheEntry(authInfo.AuthID)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed")
|
log.Debug().Caller().Str("auth_id", authInfo.AuthID.String()).Msg("auth session expired before authorization completed")
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -376,7 +367,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
log.Warn().Caller().
|
log.Warn().Caller().
|
||||||
Str("auth_id", authInfo.AuthID.String()).
|
Str("auth_id", authInfo.AuthID.String()).
|
||||||
Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding")
|
Msg("OIDC callback hit non-registration path with auth request that is not an SSH check binding")
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for SSH check", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -389,7 +380,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
Str("auth_id", authInfo.AuthID.String()).
|
Str("auth_id", authInfo.AuthID.String()).
|
||||||
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
Uint64("src_node_id", binding.SrcNodeID.Uint64()).
|
||||||
Msg("SSH check src node no longer exists")
|
Msg("SSH check src node no longer exists")
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "src node no longer exists", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -404,7 +395,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
Bool("src_is_tagged", srcNode.IsTagged()).
|
Bool("src_is_tagged", srcNode.IsTagged()).
|
||||||
Str("oidc_user", user.Username()).
|
Str("oidc_user", user.Username()).
|
||||||
Msg("SSH check rejected: src node has no user owner")
|
Msg("SSH check rejected: src node has no user owner")
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "src node has no user owner", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -417,7 +408,7 @@ func (a *AuthProviderOIDC) OIDCCallbackHandler(
|
|||||||
Uint("oidc_user_id", user.ID).
|
Uint("oidc_user_id", user.ID).
|
||||||
Str("oidc_user", user.Username()).
|
Str("oidc_user", user.Username()).
|
||||||
Msg("SSH check rejected: OIDC user is not the owner of src node")
|
Msg("SSH check rejected: OIDC user is not the owner of src node")
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "OIDC user is not the owner of the SSH source node", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -680,7 +671,7 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
|||||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
log.Debug().Caller().Str("auth_id", authID.String()).Msg("registration session expired before authorization completed")
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "login session expired, try again", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -689,14 +680,14 @@ func (a *AuthProviderOIDC) renderRegistrationConfirmInterstitial(
|
|||||||
log.Warn().Caller().
|
log.Warn().Caller().
|
||||||
Str("auth_id", authID.String()).
|
Str("auth_id", authID.String()).
|
||||||
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
Msg("OIDC callback hit registration path with auth request that is not a node registration")
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "auth session is not for node registration", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
csrf, err := util.GenerateRandomStringURLSafe(32)
|
csrf, err := util.GenerateRandomStringURLSafe(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, fmt.Errorf("generating csrf token: %w", err))
|
httpUserError(writer, fmt.Errorf("generating csrf token: %w", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -748,14 +739,14 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
|||||||
req *http.Request,
|
req *http.Request,
|
||||||
) {
|
) {
|
||||||
if req.Method != http.MethodPost {
|
if req.Method != http.MethodPost {
|
||||||
httpError(writer, errMethodNotAllowed)
|
httpUserError(writer, errMethodNotAllowed)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authID, err := authIDFromRequest(req)
|
authID, err := authIDFromRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -766,54 +757,54 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
|||||||
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
req.Body = http.MaxBytesReader(writer, req.Body, 4*1024)
|
||||||
|
|
||||||
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
if err := req.ParseForm(); err != nil { //nolint:noinlineerr,gosec // body is bounded above
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "invalid form", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
formCSRF := req.PostFormValue(registerConfirmCSRFCookie) //nolint:gosec // body is bounded above
|
||||||
if formCSRF == "" {
|
if formCSRF == "" {
|
||||||
httpError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
httpUserError(writer, NewHTTPError(http.StatusBadRequest, "missing csrf token", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
cookie, err := req.Cookie(registerConfirmCSRFCookie)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "missing csrf cookie", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if cookie.Value != formCSRF {
|
if cookie.Value != formCSRF {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token mismatch", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
authReq, ok := a.h.state.GetAuthCacheEntry(authID)
|
||||||
if !ok {
|
if !ok {
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pending := authReq.PendingConfirmation()
|
pending := authReq.PendingConfirmation()
|
||||||
if pending == nil {
|
if pending == nil {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "registration not OIDC-authorized", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if pending.CSRF != cookie.Value {
|
if pending.CSRF != cookie.Value {
|
||||||
httpError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
httpUserError(writer, NewHTTPError(http.StatusForbidden, "csrf token does not match cached registration", nil))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
user, err := a.h.state.GetUserByID(types.UserID(pending.UserID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(writer, fmt.Errorf("looking up user: %w", err))
|
httpUserError(writer, fmt.Errorf("looking up user: %w", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -821,12 +812,12 @@ func (a *AuthProviderOIDC) RegisterConfirmHandler(
|
|||||||
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
newNode, err := a.handleRegistration(user, authID, pending.NodeExpiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
if errors.Is(err, db.ErrNodeNotFoundRegistrationCache) {
|
||||||
httpError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
httpUserError(writer, NewHTTPError(http.StatusGone, "registration session expired", err))
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
httpError(writer, err)
|
httpUserError(writer, err)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,71 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestAuthErrorTemplate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
result templates.AuthErrorResult
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bad_request",
|
||||||
|
result: templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Bad Request",
|
||||||
|
Message: "The request could not be processed. Please try again.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "forbidden",
|
||||||
|
result: templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Forbidden",
|
||||||
|
Message: "You are not authorized. Please contact your administrator.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gone_expired",
|
||||||
|
result: templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Gone",
|
||||||
|
Message: "Your session has expired. Please try again.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "internal_server_error",
|
||||||
|
result: templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Internal Server Error",
|
||||||
|
Message: "Something went wrong. Please try again later.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
html := templates.AuthError(tt.result).Render()
|
||||||
|
|
||||||
|
// Verify the HTML contains expected structural elements
|
||||||
|
assert.Contains(t, html, "<!DOCTYPE html>")
|
||||||
|
assert.Contains(t, html, "<title>"+tt.result.Title+"</title>")
|
||||||
|
assert.Contains(t, html, tt.result.Heading)
|
||||||
|
assert.Contains(t, html, tt.result.Message)
|
||||||
|
|
||||||
|
// Verify Material for MkDocs design system CSS is present
|
||||||
|
assert.Contains(t, html, "Material for MkDocs")
|
||||||
|
assert.Contains(t, html, "Roboto")
|
||||||
|
assert.Contains(t, html, ".md-typeset")
|
||||||
|
|
||||||
|
// Verify SVG elements are present
|
||||||
|
assert.Contains(t, html, "<svg")
|
||||||
|
assert.Contains(t, html, "class=\"headscale-logo\"")
|
||||||
|
assert.Contains(t, html, "id=\"error-icon\"")
|
||||||
|
|
||||||
|
// Verify no success checkbox icon
|
||||||
|
assert.NotContains(t, html, "id=\"checkbox\"")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAuthSuccessTemplate(t *testing.T) {
|
func TestAuthSuccessTemplate(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@ -24,6 +24,14 @@ func TestTemplateHTMLConsistency(t *testing.T) {
|
|||||||
Message: "You can now close this window.",
|
Message: "You can now close this window.",
|
||||||
}).Render(),
|
}).Render(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Auth Error",
|
||||||
|
html: templates.AuthError(templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Forbidden",
|
||||||
|
Message: "You are not authorized. Please contact your administrator.",
|
||||||
|
}).Render(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Auth Web Register",
|
name: "Auth Web Register",
|
||||||
html: templates.AuthWeb(
|
html: templates.AuthWeb(
|
||||||
@ -98,6 +106,14 @@ func TestTemplateModernHTMLFeatures(t *testing.T) {
|
|||||||
Message: "You can now close this window.",
|
Message: "You can now close this window.",
|
||||||
}).Render(),
|
}).Render(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Auth Error",
|
||||||
|
html: templates.AuthError(templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Forbidden",
|
||||||
|
Message: "You are not authorized. Please contact your administrator.",
|
||||||
|
}).Render(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Auth Web Register",
|
name: "Auth Web Register",
|
||||||
html: templates.AuthWeb(
|
html: templates.AuthWeb(
|
||||||
@ -164,6 +180,15 @@ func TestTemplateExternalLinkSecurity(t *testing.T) {
|
|||||||
"https://tailscale.com/kb/",
|
"https://tailscale.com/kb/",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Auth Error",
|
||||||
|
html: templates.AuthError(templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Forbidden",
|
||||||
|
Message: "You are not authorized. Please contact your administrator.",
|
||||||
|
}).Render(),
|
||||||
|
externalURLs: []string{}, // No external links
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Auth Web Register",
|
name: "Auth Web Register",
|
||||||
html: templates.AuthWeb(
|
html: templates.AuthWeb(
|
||||||
@ -248,6 +273,14 @@ func TestTemplateAccessibilityAttributes(t *testing.T) {
|
|||||||
Message: "You can now close this window.",
|
Message: "You can now close this window.",
|
||||||
}).Render(),
|
}).Render(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Auth Error",
|
||||||
|
html: templates.AuthError(templates.AuthErrorResult{
|
||||||
|
Title: "Headscale - Error",
|
||||||
|
Heading: "Forbidden",
|
||||||
|
Message: "You are not authorized. Please contact your administrator.",
|
||||||
|
}).Render(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "Auth Web Register",
|
name: "Auth Web Register",
|
||||||
html: templates.AuthWeb(
|
html: templates.AuthWeb(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user