Starting the implementation of an OIDC authorization endpoint handler

Does not validate incoming request parameters yet. Also is not
served on the http/https ports yet. Those will come in future commits.

Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
Ryan Richard 2020-11-03 16:17:38 -08:00 committed by Andrew Keesler
parent 0d8477ea8a
commit c34e5a727d
2 changed files with 414 additions and 0 deletions

View File

@ -0,0 +1,111 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package auth provides a handler for the OIDC authorization endpoint.
package auth
import (
"fmt"
"net/http"
"golang.org/x/oauth2"
"k8s.io/klog/v2"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidcclient/nonce"
"go.pinniped.dev/internal/oidcclient/pkce"
"go.pinniped.dev/internal/oidcclient/state"
)
type IDPListGetter interface {
GetIDPList() []provider.UpstreamOIDCIdentityProvider
}
func NewHandler(
issuer string,
idpListGetter IDPListGetter,
generateState func() (state.State, error),
generatePKCE func() (pkce.Code, error),
generateNonce func() (nonce.Nonce, error),
) http.Handler {
return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
if r.Method != http.MethodPost && r.Method != http.MethodGet {
// https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest
// Authorization Servers MUST support the use of the HTTP GET and POST methods defined in
// RFC 2616 [RFC2616] at the Authorization Endpoint.
return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method)
}
upstreamIDP, err := chooseUpstreamIDP(idpListGetter)
if err != nil {
return err
}
stateValue, nonceValue, pkceValue, err := generateParams(generateState, generateNonce, generatePKCE)
if err != nil {
return err
}
upstreamOAuthConfig := oauth2.Config{
ClientID: upstreamIDP.ClientID,
Endpoint: oauth2.Endpoint{
AuthURL: upstreamIDP.AuthorizationURL.String(),
},
RedirectURL: fmt.Sprintf("%s/callback/%s", issuer, upstreamIDP.Name),
Scopes: upstreamIDP.Scopes,
}
http.Redirect(w, r,
upstreamOAuthConfig.AuthCodeURL(
stateValue.String(),
oauth2.AccessTypeOffline,
nonceValue.Param(),
pkceValue.Challenge(),
pkceValue.Method(),
),
302,
)
return nil
})
}
func chooseUpstreamIDP(idpListGetter IDPListGetter) (*provider.UpstreamOIDCIdentityProvider, error) {
allUpstreamIDPs := idpListGetter.GetIDPList()
if len(allUpstreamIDPs) == 0 {
return nil, httperr.New(
http.StatusUnprocessableEntity,
"No upstream providers are configured",
)
} else if len(allUpstreamIDPs) > 1 {
return nil, httperr.New(
http.StatusUnprocessableEntity,
"Too many upstream providers are configured (support for multiple upstreams is not yet implemented)",
)
}
return &allUpstreamIDPs[0], nil
}
func generateParams(
generateState func() (state.State, error),
generateNonce func() (nonce.Nonce, error),
generatePKCE func() (pkce.Code, error),
) (state.State, nonce.Nonce, pkce.Code, error) {
stateValue, err := generateState()
if err != nil {
klog.InfoS("error generating state param", "err", err)
return "", "", "", httperr.Wrap(http.StatusInternalServerError, "error generating state param", err)
}
nonceValue, err := generateNonce()
if err != nil {
klog.InfoS("error generating nonce param", "err", err)
return "", "", "", httperr.Wrap(http.StatusInternalServerError, "error generating nonce param", err)
}
pkceValue, err := generatePKCE()
if err != nil {
klog.InfoS("error generating PKCE param", "err", err)
return "", "", "", httperr.Wrap(http.StatusInternalServerError, "error generating PKCE param", err)
}
return stateValue, nonceValue, pkceValue, nil
}

View File

@ -0,0 +1,303 @@
// Copyright 2020 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/require"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/oidcclient/nonce"
"go.pinniped.dev/internal/oidcclient/pkce"
"go.pinniped.dev/internal/oidcclient/state"
)
func TestAuthorizationEndpoint(t *testing.T) {
upstreamAuthURL, err := url.Parse("https://some-upstream-idp:8443/auth")
require.NoError(t, err)
upstreamOIDCIdentityProvider := provider.UpstreamOIDCIdentityProvider{
Name: "some-idp",
ClientID: "some-client-id",
AuthorizationURL: *upstreamAuthURL,
Scopes: []string{"scope1", "scope2"},
}
issuer := "https://my-issuer.com/some-path"
happyStateGenerator := func() (state.State, error) { return "test-state", nil }
happyPKCEGenerator := func() (pkce.Code, error) { return "test-pkce", nil }
happyNonceGenerator := func() (nonce.Nonce, error) { return "test-nonce", nil }
// This is the PKCE challenge which is calculated as base64(sha256("test-pkce")). For example:
// $ echo -n test-pkce | shasum -a 256 | cut -d" " -f1 | xxd -r -p | base64 | cut -d"=" -f1
expectedCodeChallenge := "VVaezYqum7reIhoavCHD1n2d-piN3r_mywoYj7fCR7g"
happyGetRequestPath := fmt.Sprintf(
"/some/path?response_type=code&scope=%s&client_id=pinniped-cli&state=some-state-value&redirect_uri=%s",
url.QueryEscape("openid profile email"),
url.QueryEscape("http://127.0.0.1/callback"),
)
type testCase struct {
name string
issuer string
idpListGetter provider.DynamicUpstreamIDPProvider
generateState func() (state.State, error)
generatePKCE func() (pkce.Code, error)
generateNonce func() (nonce.Nonce, error)
method string
path string
body string
wantStatus int
wantContentType string
wantBodyString string
wantLocationHeader string
}
tests := []testCase{
{
name: "happy path using GET",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateState: happyStateGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusFound,
wantContentType: "text/html; charset=utf-8",
wantBodyString: "",
wantLocationHeader: fmt.Sprintf("%s?%s",
upstreamAuthURL.String(),
url.Values{
"response_type": []string{"code"},
"access_type": []string{"offline"},
"scope": []string{"scope1 scope2"},
"client_id": []string{"some-client-id"},
"state": []string{"test-state"},
"nonce": []string{"test-nonce"},
"code_challenge": []string{expectedCodeChallenge},
"code_challenge_method": []string{"S256"},
"redirect_uri": []string{issuer + "/callback/some-idp"},
}.Encode(),
),
},
{
name: "happy path using POST",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateState: happyStateGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
method: http.MethodPost,
path: "/some/path",
body: url.Values{
"response_type": []string{"code"},
"scope": []string{"openid profile email"},
"client_id": []string{"pinniped-cli"},
"state": []string{"some-state-value"},
"redirect_uri": []string{"http://127.0.0.1/callback"},
}.Encode(),
wantStatus: http.StatusFound,
wantContentType: "",
wantBodyString: "",
wantLocationHeader: fmt.Sprintf("%s?%s",
upstreamAuthURL.String(),
url.Values{
"response_type": []string{"code"},
"access_type": []string{"offline"},
"scope": []string{"scope1 scope2"},
"client_id": []string{"some-client-id"},
"state": []string{"test-state"},
"nonce": []string{"test-nonce"},
"code_challenge": []string{expectedCodeChallenge},
"code_challenge_method": []string{"S256"},
"redirect_uri": []string{issuer + "/callback/some-idp"},
}.Encode(),
),
},
{
name: "error while generating state",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateState: func() (state.State, error) { return "", fmt.Errorf("some state generator error") },
generatePKCE: happyPKCEGenerator,
generateNonce: happyNonceGenerator,
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusInternalServerError,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Internal Server Error: error generating state param\n",
},
{
name: "error while generating nonce",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateState: happyStateGenerator,
generatePKCE: happyPKCEGenerator,
generateNonce: func() (nonce.Nonce, error) { return "", fmt.Errorf("some nonce generator error") },
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusInternalServerError,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Internal Server Error: error generating nonce param\n",
},
{
name: "error while generating PKCE",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
generateState: happyStateGenerator,
generatePKCE: func() (pkce.Code, error) { return "", fmt.Errorf("some PKCE generator error") },
generateNonce: happyNonceGenerator,
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusInternalServerError,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Internal Server Error: error generating PKCE param\n",
},
{
name: "no upstream providers are configured",
issuer: issuer,
idpListGetter: newIDPListGetter(),
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Unprocessable Entity: No upstream providers are configured\n",
},
{
name: "too many upstream providers are configured",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider, upstreamOIDCIdentityProvider),
method: http.MethodGet,
path: happyGetRequestPath,
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Unprocessable Entity: Too many upstream providers are configured (support for multiple upstreams is not yet implemented)\n",
},
{
name: "PUT is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
method: http.MethodPut,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Method Not Allowed: PUT (try GET or POST)\n",
},
{
name: "PATCH is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
method: http.MethodPatch,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Method Not Allowed: PATCH (try GET or POST)\n",
},
{
name: "DELETE is a bad method",
issuer: issuer,
idpListGetter: newIDPListGetter(upstreamOIDCIdentityProvider),
method: http.MethodDelete,
path: "/some/path",
wantStatus: http.StatusMethodNotAllowed,
wantContentType: "text/plain; charset=utf-8",
wantBodyString: "Method Not Allowed: DELETE (try GET or POST)\n",
},
}
runOneTestCase := func(t *testing.T, test testCase, subject http.Handler) {
req := httptest.NewRequest(test.method, test.path, strings.NewReader(test.body))
rsp := httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
require.Equal(t, test.wantStatus, rsp.Code)
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type"))
if test.wantBodyString != "" {
require.Equal(t, test.wantBodyString, rsp.Body.String())
}
if test.wantLocationHeader != "" {
actualLocation := rsp.Header().Get("Location")
requireEqualURLs(t, actualLocation, test.wantLocationHeader)
}
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
subject := NewHandler(test.issuer, test.idpListGetter, test.generateState, test.generatePKCE, test.generateNonce)
runOneTestCase(t, test, subject)
})
}
t.Run("allows upstream provider configuration to change between requests", func(t *testing.T) {
test := tests[0]
require.Equal(t, "happy path using GET", test.name) // re-use the happy path test case
subject := NewHandler(test.issuer, test.idpListGetter, test.generateState, test.generatePKCE, test.generateNonce)
runOneTestCase(t, test, subject)
// Call the setter to change the upstream IDP settings.
newProviderSettings := provider.UpstreamOIDCIdentityProvider{
Name: "some-other-idp",
ClientID: "some-other-client-id",
AuthorizationURL: *upstreamAuthURL,
Scopes: []string{"other-scope1", "other-scope2"},
}
test.idpListGetter.SetIDPList([]provider.UpstreamOIDCIdentityProvider{newProviderSettings})
// Update the expectations of the test case to match the new upstream IDP settings.
test.wantLocationHeader = fmt.Sprintf("%s?%s",
upstreamAuthURL.String(),
url.Values{
"response_type": []string{"code"},
"access_type": []string{"offline"},
"scope": []string{"other-scope1 other-scope2"},
"client_id": []string{"some-other-client-id"},
"state": []string{"test-state"},
"nonce": []string{"test-nonce"},
"code_challenge": []string{expectedCodeChallenge},
"code_challenge_method": []string{"S256"},
"redirect_uri": []string{issuer + "/callback/some-other-idp"},
}.Encode(),
)
// Run again on the same instance of the subject with the modified upstream IDP settings and the
// modified expectations. This should ensure that the implementation is using the in-memory cache
// of upstream IDP settings appropriately in terms of always getting the values from the cache
// on every request.
runOneTestCase(t, test, subject)
})
}
func requireEqualURLs(t *testing.T, actualURL string, expectedURL string) {
actualLocationURL, err := url.Parse(actualURL)
require.NoError(t, err)
expectedLocationURL, err := url.Parse(expectedURL)
require.NoError(t, err)
require.Equal(t, expectedLocationURL.Scheme, actualLocationURL.Scheme)
require.Equal(t, expectedLocationURL.User, actualLocationURL.User)
require.Equal(t, expectedLocationURL.Host, actualLocationURL.Host)
require.Equal(t, expectedLocationURL.Path, actualLocationURL.Path)
require.Equal(t, expectedLocationURL.Query(), actualLocationURL.Query())
}
func newIDPListGetter(upstreamOIDCIdentityProviders ...provider.UpstreamOIDCIdentityProvider) provider.DynamicUpstreamIDPProvider {
idpProvider := provider.NewDynamicUpstreamIDPProvider()
idpProvider.SetIDPList(upstreamOIDCIdentityProviders)
return idpProvider
}