-
Notifications
You must be signed in to change notification settings - Fork 66
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1742 from vmware-tanzu/idp_chooser_ui
show interstitial web page to allow user to choose IDP when multiple IDPs are configured and authorize endpoint query param to choose IDP is not used
- Loading branch information
Showing
20 changed files
with
922 additions
and
33 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
77 changes: 77 additions & 0 deletions
77
internal/federationdomain/endpoints/chooseidp/choose_idp_handler.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,77 @@ | ||
// Copyright 2023 the Pinniped contributors. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package chooseidp | ||
|
||
import ( | ||
"fmt" | ||
"net/http" | ||
"net/url" | ||
"sort" | ||
|
||
"go.pinniped.dev/generated/latest/apis/supervisor/oidc" | ||
"go.pinniped.dev/internal/federationdomain/endpoints/chooseidp/chooseidphtml" | ||
"go.pinniped.dev/internal/federationdomain/federationdomainproviders" | ||
"go.pinniped.dev/internal/httputil/httperr" | ||
"go.pinniped.dev/internal/httputil/securityheader" | ||
) | ||
|
||
// NewHandler returns a http.Handler that serves an IDP chooser web page. The authorization endpoint may redirect | ||
// to this page, copying all the same parameters from the original authorization request. Each button on this page | ||
// simply adds the IDP's name as an additional request parameter to the original authorization request's parameters, | ||
// and sends the user back to the authorization endpoint, where the authorization flow can start from scratch using | ||
// the original params with the extra pinniped_idp_name param added. | ||
func NewHandler(authURL string, upstreamIDPs federationdomainproviders.FederationDomainIdentityProvidersListerI) http.Handler { | ||
handler := httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error { | ||
if r.Method != http.MethodGet { | ||
return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET)", r.Method) | ||
} | ||
|
||
// This is just a sanity check that it appears to be an authorize request. | ||
// Actual enforcement of parameters will happen at the authorization endpoint. | ||
query := r.URL.Query() | ||
if !(query.Has("client_id") && query.Has("redirect_uri") && query.Has("scope") && query.Has("response_type")) { | ||
return httperr.New(http.StatusBadRequest, "missing required query params (must include client_id, redirect_uri, scope, and response_type)") | ||
} | ||
|
||
newIDPForPageData := func(displayName string) chooseidphtml.IdentityProvider { | ||
return chooseidphtml.IdentityProvider{ | ||
DisplayName: displayName, | ||
URL: fmt.Sprintf("%s?%s&%s=%s", | ||
authURL, r.URL.Query().Encode(), oidc.AuthorizeUpstreamIDPNameParamName, url.QueryEscape(displayName)), | ||
} | ||
} | ||
|
||
var idps []chooseidphtml.IdentityProvider | ||
for _, p := range upstreamIDPs.GetOIDCIdentityProviders() { | ||
idps = append(idps, newIDPForPageData(p.DisplayName)) | ||
} | ||
for _, p := range upstreamIDPs.GetLDAPIdentityProviders() { | ||
idps = append(idps, newIDPForPageData(p.DisplayName)) | ||
} | ||
for _, p := range upstreamIDPs.GetActiveDirectoryIdentityProviders() { | ||
idps = append(idps, newIDPForPageData(p.DisplayName)) | ||
} | ||
|
||
sort.SliceStable(idps, func(i, j int) bool { | ||
return idps[i].DisplayName < idps[j].DisplayName | ||
}) | ||
|
||
if len(idps) == 0 { | ||
// This shouldn't normally happen in practice because the auth endpoint would not have redirected to here. | ||
return httperr.New(http.StatusInternalServerError, | ||
"please check the server's configuration: no valid identity providers found for this FederationDomain") | ||
} | ||
|
||
return chooseidphtml.Template().Execute(w, &chooseidphtml.PageData{IdentityProviders: idps}) | ||
}) | ||
|
||
return wrapSecurityHeaders(handler) | ||
} | ||
|
||
func wrapSecurityHeaders(handler http.Handler) http.Handler { | ||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
wrapped := securityheader.WrapWithCustomCSP(handler, chooseidphtml.ContentSecurityPolicy()) | ||
wrapped.ServeHTTP(w, r) | ||
}) | ||
} |
150 changes: 150 additions & 0 deletions
150
internal/federationdomain/endpoints/chooseidp/choose_idp_handler_test.go
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,150 @@ | ||
// Copyright 2023 the Pinniped contributors. All Rights Reserved. | ||
// SPDX-License-Identifier: Apache-2.0 | ||
|
||
package chooseidp | ||
|
||
import ( | ||
"net/http" | ||
"net/http/httptest" | ||
"net/url" | ||
"testing" | ||
|
||
"github.com/stretchr/testify/require" | ||
|
||
"go.pinniped.dev/internal/federationdomain/endpoints/chooseidp/chooseidphtml" | ||
"go.pinniped.dev/internal/federationdomain/federationdomainproviders" | ||
"go.pinniped.dev/internal/federationdomain/oidc" | ||
"go.pinniped.dev/internal/testutil" | ||
"go.pinniped.dev/internal/testutil/oidctestutil" | ||
) | ||
|
||
func TestChooseIDPHandler(t *testing.T) { | ||
const testIssuer = "https://pinniped.dev/issuer" | ||
|
||
testReqQuery := url.Values{ | ||
"client_id": []string{"foo"}, | ||
"redirect_uri": []string{"bar"}, | ||
"scope": []string{"baz"}, | ||
"response_type": []string{"bat"}, | ||
} | ||
testIssuerWithTestReqQuery := testIssuer + "?" + testReqQuery.Encode() | ||
|
||
tests := []struct { | ||
name string | ||
|
||
method string | ||
reqTarget string | ||
idps federationdomainproviders.FederationDomainIdentityProvidersListerI | ||
|
||
wantStatus int | ||
wantContentType string | ||
wantBodyString string | ||
}{ | ||
{ | ||
name: "happy path", | ||
method: http.MethodGet, | ||
reqTarget: "/some/path" + oidc.ChooseIDPEndpointPath + "?" + testReqQuery.Encode(), | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("oidc2").Build()). | ||
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("ldap1").Build()). | ||
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("z-ad1").Build()). | ||
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("ldap2").Build()). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("oidc1").Build()). | ||
WithActiveDirectory(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName("ad2").Build()). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusOK, | ||
wantContentType: "text/html; charset=utf-8", | ||
wantBodyString: testutil.ExpectedChooseIDPPageHTML(chooseidphtml.CSS(), chooseidphtml.JS(), []testutil.ChooseIDPPageExpectedValue{ | ||
// Should be sorted alphabetically by displayName. | ||
{DisplayName: "ad2", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=ad2"}, | ||
{DisplayName: "ldap1", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=ldap1"}, | ||
{DisplayName: "ldap2", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=ldap2"}, | ||
{DisplayName: "oidc1", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=oidc1"}, | ||
{DisplayName: "oidc2", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=oidc2"}, | ||
{DisplayName: "z-ad1", URL: testIssuerWithTestReqQuery + "&pinniped_idp_name=z-ad1"}, | ||
}), | ||
}, | ||
{ | ||
name: "happy path when there are special characters in the IDP name", | ||
method: http.MethodGet, | ||
reqTarget: "/some/path" + oidc.ChooseIDPEndpointPath + "?" + testReqQuery.Encode(), | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName(`This is Ryan's IDP 👍\~!@#$%^&*()-+[]{}\|;'"<>,.?`).Build()). | ||
WithLDAP(oidctestutil.NewTestUpstreamLDAPIdentityProviderBuilder().WithName(`This is Josh's IDP 🦭`).Build()). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusOK, | ||
wantContentType: "text/html; charset=utf-8", | ||
wantBodyString: testutil.ExpectedChooseIDPPageHTML(chooseidphtml.CSS(), chooseidphtml.JS(), []testutil.ChooseIDPPageExpectedValue{ | ||
// Should be sorted alphabetically by displayName. | ||
{ | ||
DisplayName: `This is Josh's IDP 🦭`, | ||
URL: testIssuerWithTestReqQuery + `&pinniped_idp_name=` + url.QueryEscape(`This is Josh's IDP 🦭`), | ||
}, | ||
{ | ||
DisplayName: `This is Ryan's IDP 👍\~!@#$%^&*()-+[]{}\|;'"<>,.?`, | ||
URL: testIssuerWithTestReqQuery + `&pinniped_idp_name=` + url.QueryEscape(`This is Ryan's IDP 👍\~!@#$%^&*()-+[]{}\|;'"<>,.?`), | ||
}, | ||
}), | ||
}, | ||
{ | ||
name: "no valid IDPs are configured on the FederationDomain", | ||
method: http.MethodGet, | ||
reqTarget: "/some/path" + oidc.ChooseIDPEndpointPath + "?" + testReqQuery.Encode(), | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusInternalServerError, | ||
wantContentType: "text/plain; charset=utf-8", | ||
wantBodyString: "Internal Server Error: please check the server's configuration: no valid identity providers found for this FederationDomain\n", | ||
}, | ||
{ | ||
name: "no query params on the request", | ||
method: http.MethodGet, | ||
reqTarget: "/some/path" + oidc.ChooseIDPEndpointPath, | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-idp").Build()). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusBadRequest, | ||
wantContentType: "text/plain; charset=utf-8", | ||
wantBodyString: "Bad Request: missing required query params (must include client_id, redirect_uri, scope, and response_type)\n", | ||
}, | ||
{ | ||
name: "missing required query param(s) on the request", | ||
method: http.MethodGet, | ||
reqTarget: "/some/path" + oidc.ChooseIDPEndpointPath + "?client_id=foo", | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-idp").Build()). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusBadRequest, | ||
wantContentType: "text/plain; charset=utf-8", | ||
wantBodyString: "Bad Request: missing required query params (must include client_id, redirect_uri, scope, and response_type)\n", | ||
}, | ||
{ | ||
name: "bad request method", | ||
method: http.MethodPost, | ||
reqTarget: oidc.ChooseIDPEndpointPath, | ||
idps: oidctestutil.NewUpstreamIDPListerBuilder(). | ||
WithOIDC(oidctestutil.NewTestUpstreamOIDCIdentityProviderBuilder().WithName("x-some-idp").Build()). | ||
BuildFederationDomainIdentityProvidersListerFinder(), | ||
wantStatus: http.StatusMethodNotAllowed, | ||
wantContentType: "text/plain; charset=utf-8", | ||
wantBodyString: "Method Not Allowed: POST (try GET)\n", | ||
}, | ||
} | ||
for _, test := range tests { | ||
test := test | ||
t.Run(test.name, func(t *testing.T) { | ||
t.Parallel() | ||
|
||
handler := NewHandler(testIssuer, test.idps) | ||
|
||
req := httptest.NewRequest(test.method, test.reqTarget, nil) | ||
rsp := httptest.NewRecorder() | ||
handler.ServeHTTP(rsp, req) | ||
|
||
require.Equal(t, test.wantStatus, rsp.Code) | ||
require.Equal(t, test.wantContentType, rsp.Header().Get("Content-Type")) | ||
require.Equal(t, test.wantBodyString, rsp.Body.String()) | ||
testutil.RequireSecurityHeadersWithIDPChooserPageCSPs(t, rsp) | ||
}) | ||
} | ||
} |
71 changes: 71 additions & 0 deletions
71
internal/federationdomain/endpoints/chooseidp/chooseidphtml/choose_idp.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,71 @@ | ||
/* Copyright 2023 the Pinniped contributors. All Rights Reserved. */ | ||
/* SPDX-License-Identifier: Apache-2.0 */ | ||
|
||
html { | ||
height: 100%; | ||
} | ||
|
||
/* The form for this page is styled to be the same as the form from login_form.css */ | ||
body { | ||
font-family: "Metropolis-Light", Helvetica, sans-serif; | ||
display: flex; | ||
flex-flow: column wrap; | ||
justify-content: flex-start; | ||
align-items: center; | ||
/* subtle gradient make the login box stand out */ | ||
background: linear-gradient(to top, #f8f8f8, white); | ||
min-height: 100%; | ||
} | ||
|
||
h1 { | ||
font-size: 20px; | ||
margin: 0; | ||
} | ||
|
||
.box { | ||
display: flex; | ||
flex-direction: column; | ||
flex-wrap: nowrap; | ||
border-radius: 4px; | ||
border-color: #ddd; | ||
border-width: 1px; | ||
border-style: solid; | ||
width: 400px; | ||
padding:30px 30px 0; | ||
margin: 60px 20px 0; | ||
background: white; | ||
font-size: 14px; | ||
} | ||
|
||
/* Buttons for this page are styled to be the same as the form submit button in login_form.css */ | ||
button { | ||
color: inherit; | ||
font: inherit; | ||
border: 0; | ||
margin: 0; | ||
outline: 0; | ||
padding: 0; | ||
} | ||
|
||
.form-field { | ||
display: flex; | ||
margin-bottom: 30px; | ||
} | ||
|
||
.form-field button { | ||
width: 100%; | ||
padding: 1em; | ||
background-color: #218fcf; /* this is a color from the Pinniped logo :) */ | ||
color: #eee; | ||
font-weight: bold; | ||
cursor: pointer; | ||
transition: all .3s; | ||
} | ||
|
||
.form-field button:focus, .form-field button:hover { | ||
background-color: #1abfd3; /* this is a color from the Pinniped logo :) */ | ||
} | ||
|
||
.form-field button:active { | ||
transform: scale(.99); | ||
} |
Oops, something went wrong.