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:
parent
0d8477ea8a
commit
c34e5a727d
111
internal/oidc/auth/auth_handler.go
Normal file
111
internal/oidc/auth/auth_handler.go
Normal 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
|
||||
}
|
303
internal/oidc/auth/auth_handler_test.go
Normal file
303
internal/oidc/auth/auth_handler_test.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user