Implement upstream LDAP support in auth_handler.go

- When the upstream IDP is an LDAP IDP and the user's LDAP username and
  password are received as new custom headers, then authenticate the
  user and, if authentication was successful, return a redirect with
  an authcode. Handle errors according to the OAuth/OIDC specs.
- Still does not support having multiple upstream IDPs defined at the
  same time, which was an existing limitation of this endpoint.
- Does not yet include the actual LDAP authentication, which is
  hidden behind an interface from the point of view of auth_handler.go
- Move the oidctestutil package to the testutil directory.
- Add an interface for Fosite storage to avoid a cyclical test
  dependency.
- Add GetURL() to the UpstreamLDAPIdentityProviderI interface.
- Extract test helpers to be shared between callback_handler_test.go
  and auth_handler_test.go because the authcode and fosite storage
  assertions should be identical.
- Backfill Content-Type assertions in callback_handler_test.go.

Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
Ryan Richard
2021-04-08 17:28:01 -07:00
committed by Andrew Keesler
parent 064e3144a2
commit f6ded84f07
15 changed files with 1360 additions and 702 deletions
+212 -105
View File
@@ -13,6 +13,7 @@ import (
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
"github.com/pkg/errors"
"golang.org/x/oauth2"
"go.pinniped.dev/internal/httputil/httperr"
@@ -25,11 +26,16 @@ import (
"go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
CustomUsernameHeaderName = "X-Pinniped-Upstream-Username"
CustomPasswordHeaderName = "X-Pinniped-Upstream-Password" //nolint:gosec // this is not a credential
)
func NewHandler(
downstreamIssuer string,
idpLister oidc.UpstreamIdentityProvidersLister,
oauthHelperWithNullStorage fosite.OAuth2Provider,
oauthHelperWithRealStorage fosite.OAuth2Provider,
oauthHelperWithoutStorage fosite.OAuth2Provider,
oauthHelperWithStorage fosite.OAuth2Provider,
generateCSRF func() (csrftoken.CSRFToken, error),
generatePKCE func() (pkce.Code, error),
generateNonce func() (nonce.Nonce, error),
@@ -44,109 +50,203 @@ func NewHandler(
return httperr.Newf(http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method)
}
csrfFromCookie := readCSRFCookie(r, cookieCodec)
authorizeRequester, err := oauthHelperWithNullStorage.NewAuthorizeRequest(r.Context(), r)
if err != nil {
plog.Info("authorize request error", oidc.FositeErrorForLog(err)...)
oauthHelperWithNullStorage.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
upstreamIDP, err := chooseUpstreamIDP(idpLister)
oidcUpstream, ldapUpstream, err := chooseUpstreamIDP(idpLister)
if err != nil {
plog.WarningErr("authorize upstream config", err)
return err
}
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
// Grant the pinniped:request-audience scope if requested.
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
now := time.Now()
_, err = oauthHelperWithNullStorage.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
// Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations.
Subject: "none",
AuthTime: now,
RequestedAt: now,
},
})
if err != nil {
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelperWithNullStorage.WriteAuthorizeError(w, authorizeRequester, err)
return nil
if oidcUpstream != nil {
return handleAuthRequestForOIDCUpstream(r, w,
oauthHelperWithoutStorage,
generateCSRF, generateNonce, generatePKCE,
oidcUpstream,
downstreamIssuer,
upstreamStateEncoder,
cookieCodec,
)
}
csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE)
if err != nil {
plog.Error("authorize generate error", err)
return err
}
if csrfFromCookie != "" {
csrfValue = csrfFromCookie
}
upstreamOAuthConfig := oauth2.Config{
ClientID: upstreamIDP.GetClientID(),
Endpoint: oauth2.Endpoint{
AuthURL: upstreamIDP.GetAuthorizationURL().String(),
},
RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer),
Scopes: upstreamIDP.GetScopes(),
}
encodedStateParamValue, err := upstreamStateParam(
authorizeRequester,
upstreamIDP.GetName(),
nonceValue,
csrfValue,
pkceValue,
upstreamStateEncoder,
return handleAuthRequestForLDAPUpstream(r, w,
oauthHelperWithStorage,
ldapUpstream,
)
if err != nil {
plog.Error("authorize upstream state param error", err)
return err
}
if csrfFromCookie == "" {
// We did not receive an incoming CSRF cookie, so write a new one.
err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec)
if err != nil {
plog.Error("error setting CSRF cookie", err)
return err
}
}
authCodeOptions := []oauth2.AuthCodeOption{
oauth2.AccessTypeOffline,
nonceValue.Param(),
pkceValue.Challenge(),
pkceValue.Method(),
}
promptParam := r.Form.Get("prompt")
if promptParam != "" && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("prompt", promptParam))
}
http.Redirect(w, r,
upstreamOAuthConfig.AuthCodeURL(
encodedStateParamValue,
authCodeOptions...,
),
302,
)
return nil
}))
}
func handleAuthRequestForLDAPUpstream(
r *http.Request,
w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider,
ldapUpstream provider.UpstreamLDAPIdentityProviderI,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
if !created {
return nil
}
username := r.Header.Get(CustomUsernameHeaderName)
password := r.Header.Get(CustomPasswordHeaderName)
if username == "" || password == "" {
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err := errors.WithStack(fosite.ErrAccessDenied.WithHintf("Missing or blank username or password."))
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
authenticateResponse, authenticated, err := ldapUpstream.AuthenticateUser(r.Context(), username, password)
if err != nil {
plog.WarningErr("unexpected error during upstream authentication", err, "upstreamName", ldapUpstream.GetName())
return httperr.New(http.StatusBadGateway, "unexpected error during upstream authentication")
}
if !authenticated {
// Return an error according to OIDC spec 3.1.2.6 (second paragraph).
err = errors.WithStack(fosite.ErrAccessDenied.WithHintf("Username/password not accepted by LDAP provider."))
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
subject := fmt.Sprintf("%s?%s=%s", ldapUpstream.GetURL(), oidc.IDTokenSubjectClaim, authenticateResponse.User.GetUID())
now := time.Now().UTC()
openIDSession := &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subject,
RequestedAt: now,
AuthTime: now,
},
}
openIDSession.Claims.Extra = map[string]interface{}{
oidc.DownstreamUsernameClaim: authenticateResponse.User.GetName(),
oidc.DownstreamGroupsClaim: authenticateResponse.User.GetGroups(),
}
authorizeResponder, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, openIDSession)
if err != nil {
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
oauthHelper.WriteAuthorizeResponse(w, authorizeRequester, authorizeResponder)
return nil
}
func handleAuthRequestForOIDCUpstream(
r *http.Request,
w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider,
generateCSRF func() (csrftoken.CSRFToken, error),
generateNonce func() (nonce.Nonce, error),
generatePKCE func() (pkce.Code, error),
oidcUpstream provider.UpstreamOIDCIdentityProviderI,
downstreamIssuer string,
upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec,
) error {
authorizeRequester, created := newAuthorizeRequest(r, w, oauthHelper)
if !created {
return nil
}
now := time.Now()
_, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
// Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations.
Subject: "none",
AuthTime: now,
RequestedAt: now,
},
})
if err != nil {
plog.Info("authorize response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil
}
csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE)
if err != nil {
plog.Error("authorize generate error", err)
return err
}
csrfFromCookie := readCSRFCookie(r, cookieCodec)
if csrfFromCookie != "" {
csrfValue = csrfFromCookie
}
upstreamOAuthConfig := oauth2.Config{
ClientID: oidcUpstream.GetClientID(),
Endpoint: oauth2.Endpoint{
AuthURL: oidcUpstream.GetAuthorizationURL().String(),
},
RedirectURL: fmt.Sprintf("%s/callback", downstreamIssuer),
Scopes: oidcUpstream.GetScopes(),
}
encodedStateParamValue, err := upstreamStateParam(
authorizeRequester,
oidcUpstream.GetName(),
nonceValue,
csrfValue,
pkceValue,
upstreamStateEncoder,
)
if err != nil {
plog.Error("authorize upstream state param error", err)
return err
}
if csrfFromCookie == "" {
// We did not receive an incoming CSRF cookie, so write a new one.
err := addCSRFSetCookieHeader(w, csrfValue, cookieCodec)
if err != nil {
plog.Error("error setting CSRF cookie", err)
return err
}
}
authCodeOptions := []oauth2.AuthCodeOption{
oauth2.AccessTypeOffline,
nonceValue.Param(),
pkceValue.Challenge(),
pkceValue.Method(),
}
promptParam := r.Form.Get("prompt")
if promptParam != "" && oidc.ScopeWasRequested(authorizeRequester, coreosoidc.ScopeOpenID) {
authCodeOptions = append(authCodeOptions, oauth2.SetAuthURLParam("prompt", promptParam))
}
http.Redirect(w, r,
upstreamOAuthConfig.AuthCodeURL(
encodedStateParamValue,
authCodeOptions...,
),
302,
)
return nil
}
func newAuthorizeRequest(r *http.Request, w http.ResponseWriter, oauthHelper fosite.OAuth2Provider) (fosite.AuthorizeRequester, bool) {
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r)
if err != nil {
plog.Info("authorize request error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAuthorizeError(w, authorizeRequester, err)
return nil, false
}
grantScopes(authorizeRequester)
return authorizeRequester, true
}
func grantScopes(authorizeRequester fosite.AuthorizeRequester) {
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID)
// There don't seem to be any validations inside `NewAuthorizeResponse` related to the offline_access scope
// at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite.
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
// Grant the pinniped:request-audience scope if requested.
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped:request-audience")
}
func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken {
receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName)
if err != nil {
@@ -166,27 +266,34 @@ func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken {
return csrfFromCookie
}
func chooseUpstreamIDP(idpLister oidc.UpstreamOIDCIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, error) {
allUpstreamIDPs := idpLister.GetOIDCIdentityProviders()
if len(allUpstreamIDPs) == 0 {
return nil, httperr.New(
// Select either an OIDC or an LDAP IDP, or return an error.
func chooseUpstreamIDP(idpLister oidc.UpstreamIdentityProvidersLister) (provider.UpstreamOIDCIdentityProviderI, provider.UpstreamLDAPIdentityProviderI, error) {
oidcUpstreams := idpLister.GetOIDCIdentityProviders()
ldapUpstreams := idpLister.GetLDAPIdentityProviders()
switch {
case len(oidcUpstreams)+len(ldapUpstreams) == 0:
return nil, nil, httperr.New(
http.StatusUnprocessableEntity,
"No upstream providers are configured",
)
} else if len(allUpstreamIDPs) > 1 {
case len(oidcUpstreams)+len(ldapUpstreams) > 1:
var upstreamIDPNames []string
for _, idp := range allUpstreamIDPs {
for _, idp := range oidcUpstreams {
upstreamIDPNames = append(upstreamIDPNames, idp.GetName())
}
for _, idp := range ldapUpstreams {
upstreamIDPNames = append(upstreamIDPNames, idp.GetName())
}
plog.Warning("Too many upstream providers are configured (found: %s)", upstreamIDPNames)
return nil, httperr.New(
return nil, nil, httperr.New(
http.StatusUnprocessableEntity,
"Too many upstream providers are configured (support for multiple upstreams is not yet implemented)",
)
case len(oidcUpstreams) == 1:
return oidcUpstreams[0], nil, nil
default:
return nil, ldapUpstreams[0], nil
}
return allUpstreamIDPs[0], nil
}
func generateValues(