cffa353ffb
Also: - Add CSS to login page - Refactor login page HTML and CSS into a new package - New custom CSP headers for the login page, because the requirements are different from the form_post page
458 lines
16 KiB
Go
458 lines
16 KiB
Go
// Copyright 2022 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package login
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/gorilla/securecookie"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"go.pinniped.dev/internal/httputil/httperr"
|
|
"go.pinniped.dev/internal/oidc"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/internal/testutil/oidctestutil"
|
|
)
|
|
|
|
const (
|
|
htmlContentType = "text/html; charset=utf-8"
|
|
)
|
|
|
|
func TestLoginEndpoint(t *testing.T) {
|
|
const (
|
|
happyGetResult = "<p>get handler result</p>"
|
|
happyPostResult = "<p>post handler result</p>"
|
|
|
|
happyUpstreamIDPName = "upstream-idp-name"
|
|
happyUpstreamIDPType = "ldap"
|
|
happyDownstreamCSRF = "test-csrf"
|
|
happyDownstreamPKCE = "test-pkce"
|
|
happyDownstreamNonce = "test-nonce"
|
|
happyDownstreamStateVersion = "2"
|
|
|
|
downstreamClientID = "pinniped-cli"
|
|
happyDownstreamState = "8b-state"
|
|
downstreamNonce = "some-nonce-value"
|
|
downstreamPKCEChallenge = "some-challenge"
|
|
downstreamPKCEChallengeMethod = "S256"
|
|
downstreamRedirectURI = "http://127.0.0.1/callback"
|
|
)
|
|
|
|
happyDownstreamScopesRequested := []string{"openid"}
|
|
happyDownstreamRequestParamsQuery := url.Values{
|
|
"response_type": []string{"code"},
|
|
"scope": []string{strings.Join(happyDownstreamScopesRequested, " ")},
|
|
"client_id": []string{downstreamClientID},
|
|
"state": []string{happyDownstreamState},
|
|
"nonce": []string{downstreamNonce},
|
|
"code_challenge": []string{downstreamPKCEChallenge},
|
|
"code_challenge_method": []string{downstreamPKCEChallengeMethod},
|
|
"redirect_uri": []string{downstreamRedirectURI},
|
|
}
|
|
happyDownstreamRequestParams := happyDownstreamRequestParamsQuery.Encode()
|
|
|
|
expectedHappyDecodedUpstreamStateParam := func() *oidc.UpstreamStateParamData {
|
|
return &oidc.UpstreamStateParamData{
|
|
UpstreamName: happyUpstreamIDPName,
|
|
UpstreamType: happyUpstreamIDPType,
|
|
AuthParams: happyDownstreamRequestParams,
|
|
Nonce: happyDownstreamNonce,
|
|
CSRFToken: happyDownstreamCSRF,
|
|
PKCECode: happyDownstreamPKCE,
|
|
FormatVersion: happyDownstreamStateVersion,
|
|
}
|
|
}
|
|
|
|
expectedHappyDecodedUpstreamStateParamForActiveDirectory := func() *oidc.UpstreamStateParamData {
|
|
s := expectedHappyDecodedUpstreamStateParam()
|
|
s.UpstreamType = "activedirectory"
|
|
return s
|
|
}
|
|
|
|
happyUpstreamStateParam := func() *oidctestutil.UpstreamStateParamBuilder {
|
|
return &oidctestutil.UpstreamStateParamBuilder{
|
|
U: happyUpstreamIDPName,
|
|
T: happyUpstreamIDPType,
|
|
P: happyDownstreamRequestParams,
|
|
N: happyDownstreamNonce,
|
|
C: happyDownstreamCSRF,
|
|
K: happyDownstreamPKCE,
|
|
V: happyDownstreamStateVersion,
|
|
}
|
|
}
|
|
|
|
stateEncoderHashKey := []byte("fake-hash-secret")
|
|
stateEncoderBlockKey := []byte("0123456789ABCDEF") // block encryption requires 16/24/32 bytes for AES
|
|
cookieEncoderHashKey := []byte("fake-hash-secret2")
|
|
cookieEncoderBlockKey := []byte("0123456789ABCDE2") // block encryption requires 16/24/32 bytes for AES
|
|
require.NotEqual(t, stateEncoderHashKey, cookieEncoderHashKey)
|
|
require.NotEqual(t, stateEncoderBlockKey, cookieEncoderBlockKey)
|
|
|
|
happyStateCodec := securecookie.New(stateEncoderHashKey, stateEncoderBlockKey)
|
|
happyStateCodec.SetSerializer(securecookie.JSONEncoder{})
|
|
happyCookieCodec := securecookie.New(cookieEncoderHashKey, cookieEncoderBlockKey)
|
|
happyCookieCodec.SetSerializer(securecookie.JSONEncoder{})
|
|
|
|
happyState := happyUpstreamStateParam().Build(t, happyStateCodec)
|
|
happyPathWithState := newRequestPath().WithState(happyState).String()
|
|
|
|
happyActiveDirectoryState := happyUpstreamStateParam().WithUpstreamIDPType("activedirectory").Build(t, happyStateCodec)
|
|
|
|
encodedIncomingCookieCSRFValue, err := happyCookieCodec.Encode("csrf", happyDownstreamCSRF)
|
|
require.NoError(t, err)
|
|
happyCSRFCookie := "__Host-pinniped-csrf=" + encodedIncomingCookieCSRFValue
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
csrfCookie string
|
|
getHandlerErr error
|
|
postHandlerErr error
|
|
|
|
wantStatus int
|
|
wantContentType string
|
|
wantBody string
|
|
wantEncodedState string
|
|
wantDecodedState *oidc.UpstreamStateParamData
|
|
}{
|
|
{
|
|
name: "PUT method is invalid",
|
|
method: http.MethodPut,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: PUT (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "PATCH method is invalid",
|
|
method: http.MethodPatch,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: PATCH (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "DELETE method is invalid",
|
|
method: http.MethodDelete,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: DELETE (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "HEAD method is invalid",
|
|
method: http.MethodHead,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: HEAD (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "CONNECT method is invalid",
|
|
method: http.MethodConnect,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: CONNECT (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "OPTIONS method is invalid",
|
|
method: http.MethodOptions,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: OPTIONS (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "TRACE method is invalid",
|
|
method: http.MethodTrace,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusMethodNotAllowed,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Method Not Allowed: TRACE (try GET or POST)\n",
|
|
},
|
|
{
|
|
name: "state param was not included on GET request",
|
|
method: http.MethodGet,
|
|
path: newRequestPath().WithoutState().String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: state param not found\n",
|
|
},
|
|
{
|
|
name: "state param was not included on POST request",
|
|
method: http.MethodPost,
|
|
path: newRequestPath().WithoutState().String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: state param not found\n",
|
|
},
|
|
{
|
|
name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason on GET request",
|
|
method: http.MethodGet,
|
|
path: newRequestPath().WithState("this-will-not-decode").String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: error reading state\n",
|
|
},
|
|
{
|
|
name: "state param was not signed correctly, has expired, or otherwise cannot be decoded for any reason on POST request",
|
|
method: http.MethodPost,
|
|
path: newRequestPath().WithState("this-will-not-decode").String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: error reading state\n",
|
|
},
|
|
{
|
|
name: "the CSRF cookie does not exist on GET request",
|
|
method: http.MethodGet,
|
|
path: happyPathWithState,
|
|
csrfCookie: "",
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: CSRF cookie is missing\n",
|
|
},
|
|
{
|
|
name: "the CSRF cookie does not exist on POST request",
|
|
method: http.MethodPost,
|
|
path: happyPathWithState,
|
|
csrfCookie: "",
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: CSRF cookie is missing\n",
|
|
},
|
|
{
|
|
name: "the CSRF cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason on GET request",
|
|
method: http.MethodGet,
|
|
path: happyPathWithState,
|
|
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: error reading CSRF cookie\n",
|
|
},
|
|
{
|
|
name: "the CSRF cookie was not signed correctly, has expired, or otherwise cannot be decoded for any reason on POST request",
|
|
method: http.MethodPost,
|
|
path: happyPathWithState,
|
|
csrfCookie: "__Host-pinniped-csrf=this-value-was-not-signed-by-pinniped",
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: error reading CSRF cookie\n",
|
|
},
|
|
{
|
|
name: "cookie csrf value does not match state csrf value on GET request",
|
|
method: http.MethodGet,
|
|
path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: CSRF value does not match\n",
|
|
},
|
|
{
|
|
name: "cookie csrf value does not match state csrf value on POST request",
|
|
method: http.MethodPost,
|
|
path: newRequestPath().WithState(happyUpstreamStateParam().WithCSRF("wrong-csrf-value").Build(t, happyStateCodec)).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusForbidden,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Forbidden: CSRF value does not match\n",
|
|
},
|
|
{
|
|
name: "GET request when upstream IDP type in state param is not supported by this endpoint",
|
|
method: http.MethodGet,
|
|
path: newRequestPath().WithState(
|
|
happyUpstreamStateParam().WithUpstreamIDPType("oidc").Build(t, happyStateCodec),
|
|
).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: not a supported upstream IDP type for this endpoint: \"oidc\"\n",
|
|
},
|
|
{
|
|
name: "POST request when upstream IDP type in state param is not supported by this endpoint",
|
|
method: http.MethodPost,
|
|
path: newRequestPath().WithState(
|
|
happyUpstreamStateParam().WithUpstreamIDPType("oidc").Build(t, happyStateCodec),
|
|
).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusBadRequest,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Bad Request: not a supported upstream IDP type for this endpoint: \"oidc\"\n",
|
|
},
|
|
{
|
|
name: "valid GET request when GET endpoint handler returns an error",
|
|
method: http.MethodGet,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
getHandlerErr: httperr.Newf(http.StatusInternalServerError, "some get error"),
|
|
wantStatus: http.StatusInternalServerError,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Internal Server Error: some get error\n",
|
|
wantEncodedState: happyState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParam(),
|
|
},
|
|
{
|
|
name: "valid POST request when POST endpoint handler returns an error",
|
|
method: http.MethodPost,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
postHandlerErr: httperr.Newf(http.StatusInternalServerError, "some post error"),
|
|
wantStatus: http.StatusInternalServerError,
|
|
wantContentType: htmlContentType,
|
|
wantBody: "Internal Server Error: some post error\n",
|
|
wantEncodedState: happyState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParam(),
|
|
},
|
|
{
|
|
name: "happy GET request for LDAP upstream",
|
|
method: http.MethodGet,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: htmlContentType,
|
|
wantBody: happyGetResult,
|
|
wantEncodedState: happyState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParam(),
|
|
},
|
|
{
|
|
name: "happy POST request for LDAP upstream",
|
|
method: http.MethodPost,
|
|
path: happyPathWithState,
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: htmlContentType,
|
|
wantBody: happyPostResult,
|
|
wantEncodedState: happyState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParam(),
|
|
},
|
|
{
|
|
name: "happy GET request for ActiveDirectory upstream",
|
|
method: http.MethodGet,
|
|
path: newRequestPath().WithState(happyActiveDirectoryState).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: htmlContentType,
|
|
wantBody: happyGetResult,
|
|
wantEncodedState: happyActiveDirectoryState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(),
|
|
},
|
|
{
|
|
name: "happy POST request for ActiveDirectory upstream",
|
|
method: http.MethodPost,
|
|
path: newRequestPath().WithState(happyActiveDirectoryState).String(),
|
|
csrfCookie: happyCSRFCookie,
|
|
wantStatus: http.StatusOK,
|
|
wantContentType: htmlContentType,
|
|
wantBody: happyPostResult,
|
|
wantEncodedState: happyActiveDirectoryState,
|
|
wantDecodedState: expectedHappyDecodedUpstreamStateParamForActiveDirectory(),
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
tt := test
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
req := httptest.NewRequest(tt.method, tt.path, nil)
|
|
if tt.csrfCookie != "" {
|
|
req.Header.Set("Cookie", tt.csrfCookie)
|
|
}
|
|
rsp := httptest.NewRecorder()
|
|
|
|
testGetHandler := func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
encodedState string,
|
|
decodedState *oidc.UpstreamStateParamData,
|
|
) error {
|
|
require.Equal(t, req, r)
|
|
require.Equal(t, rsp, w)
|
|
require.Equal(t, tt.wantEncodedState, encodedState)
|
|
require.Equal(t, tt.wantDecodedState, decodedState)
|
|
if tt.getHandlerErr == nil {
|
|
_, err := w.Write([]byte(happyGetResult))
|
|
require.NoError(t, err)
|
|
}
|
|
return tt.getHandlerErr
|
|
}
|
|
|
|
testPostHandler := func(
|
|
w http.ResponseWriter,
|
|
r *http.Request,
|
|
encodedState string,
|
|
decodedState *oidc.UpstreamStateParamData,
|
|
) error {
|
|
require.Equal(t, req, r)
|
|
require.Equal(t, rsp, w)
|
|
require.Equal(t, tt.wantEncodedState, encodedState)
|
|
require.Equal(t, tt.wantDecodedState, decodedState)
|
|
if tt.postHandlerErr == nil {
|
|
_, err := w.Write([]byte(happyPostResult))
|
|
require.NoError(t, err)
|
|
}
|
|
return tt.postHandlerErr
|
|
}
|
|
|
|
subject := NewHandler(happyStateCodec, happyCookieCodec, testGetHandler, testPostHandler)
|
|
|
|
subject.ServeHTTP(rsp, req)
|
|
|
|
if tt.method == http.MethodPost {
|
|
testutil.RequireSecurityHeadersWithFormPostPageCSPs(t, rsp)
|
|
} else {
|
|
testutil.RequireSecurityHeadersWithLoginPageCSPs(t, rsp)
|
|
}
|
|
|
|
require.Equal(t, tt.wantStatus, rsp.Code)
|
|
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), tt.wantContentType)
|
|
require.Equal(t, tt.wantBody, rsp.Body.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
type requestPath struct {
|
|
state *string
|
|
}
|
|
|
|
func newRequestPath() *requestPath {
|
|
return &requestPath{}
|
|
}
|
|
|
|
func (r *requestPath) WithState(state string) *requestPath {
|
|
r.state = &state
|
|
return r
|
|
}
|
|
|
|
func (r *requestPath) WithoutState() *requestPath {
|
|
r.state = nil
|
|
return r
|
|
}
|
|
|
|
func (r *requestPath) String() string {
|
|
path := "/login?"
|
|
params := url.Values{}
|
|
if r.state != nil {
|
|
params.Add("state", *r.state)
|
|
}
|
|
return path + params.Encode()
|
|
}
|