Files
pinniped/internal/federationdomain/endpoints/auth/auth_handler.go
2024-11-27 13:53:02 -06:00

539 lines
21 KiB
Go

// Copyright 2020-2024 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"
"net/url"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
fositejwt "github.com/ory/fosite/token/jwt"
"k8s.io/apimachinery/pkg/util/sets"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/auditevent"
"go.pinniped.dev/internal/federationdomain/csrftoken"
"go.pinniped.dev/internal/federationdomain/downstreamsession"
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
"go.pinniped.dev/internal/federationdomain/formposthtml"
"go.pinniped.dev/internal/federationdomain/oidc"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap"
"go.pinniped.dev/internal/federationdomain/stateparam"
"go.pinniped.dev/internal/httputil/responseutil"
"go.pinniped.dev/internal/httputil/securityheader"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/nonce"
"go.pinniped.dev/pkg/oidcclient/pkce"
)
const (
promptParamName = "prompt"
promptParamNone = "none"
)
func paramsSafeToLog() sets.Set[string] {
return sets.New[string](
// Standard params from https://openid.net/specs/openid-connect-core-1_0.html, some of which are ignored.
// Redacting state and nonce params, in case they contain any info that the client considers sensitive.
"scope", "response_type", "client_id", "redirect_uri", "response_mode", "display", "prompt",
"max_age", "ui_locales", "id_token_hint", "login_hint", "acr_values", "claims_locales", "claims",
"request", "request_uri", "registration",
// PKCE params from https://datatracker.ietf.org/doc/html/rfc7636. Let code_challenge be redacted.
"code_challenge_method",
// Custom Pinniped authorization params.
oidcapi.AuthorizeUpstreamIDPNameParamName, oidcapi.AuthorizeUpstreamIDPTypeParamName,
// Google-specific param that some client libraries will send anyway. Ignored by Pinniped but safe to log.
"access_type",
)
}
type authorizeHandler struct {
downstreamIssuerURL string
idpFinder federationdomainproviders.FederationDomainIdentityProvidersFinderI
oauthHelperWithoutStorage fosite.OAuth2Provider
oauthHelperWithStorage fosite.OAuth2Provider
generateCSRF func() (csrftoken.CSRFToken, error)
generatePKCE func() (pkce.Code, error)
generateNonce func() (nonce.Nonce, error)
upstreamStateEncoder oidc.Encoder
cookieCodec oidc.Codec
auditLogger plog.AuditLogger
}
func NewHandler(
downstreamIssuerURL string,
idpFinder federationdomainproviders.FederationDomainIdentityProvidersFinderI,
oauthHelperWithoutStorage fosite.OAuth2Provider,
oauthHelperWithStorage fosite.OAuth2Provider,
generateCSRF func() (csrftoken.CSRFToken, error),
generatePKCE func() (pkce.Code, error),
generateNonce func() (nonce.Nonce, error),
upstreamStateEncoder oidc.Encoder,
cookieCodec oidc.Codec,
auditLogger plog.AuditLogger,
) http.Handler {
h := &authorizeHandler{
downstreamIssuerURL: downstreamIssuerURL,
idpFinder: idpFinder,
oauthHelperWithoutStorage: oauthHelperWithoutStorage,
oauthHelperWithStorage: oauthHelperWithStorage,
generateCSRF: generateCSRF,
generatePKCE: generatePKCE,
generateNonce: generateNonce,
upstreamStateEncoder: upstreamStateEncoder,
cookieCodec: cookieCodec,
auditLogger: auditLogger,
}
// During a response_mode=form_post auth request using the browser flow, the custom form_post html page may
// be used to post certain errors back to the CLI from this handler's response, so allow the form_post
// page's CSS and JS to run.
return securityheader.WrapWithCustomCSP(h, formposthtml.ContentSecurityPolicy())
}
func (h *authorizeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// If the client set a username or password header, they are trying to log in without using a browser.
hadUsernameHeader := len(r.Header.Values(oidcapi.AuthorizeUsernameHeaderName)) > 0
hadPasswordHeader := len(r.Header.Values(oidcapi.AuthorizePasswordHeaderName)) > 0
requestedBrowserlessFlow := hadUsernameHeader || hadPasswordHeader
// Audit the request params. Also gives us access to the IDP name param for use below,
// before fosite would normally parse the params.
if err := h.auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil {
oidc.WriteAuthorizeError(r, w,
h.oauthHelperWithoutStorage, fosite.NewAuthorizeRequest(), err, requestedBrowserlessFlow)
return
}
// Log if these headers were present, but don't log the actual values. The password is obviously sensitive,
// and sometimes users use their password as their username by mistake.
h.auditLogger.Audit(auditevent.HTTPRequestCustomHeadersUsed, &plog.AuditParams{
ReqCtx: r.Context(),
KeysAndValues: []any{
oidcapi.AuthorizeUsernameHeaderName, hadUsernameHeader,
oidcapi.AuthorizePasswordHeaderName, hadPasswordHeader,
},
})
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.
responseutil.HTTPErrorf(w, http.StatusMethodNotAllowed, "%s (try GET or POST)", r.Method)
return
}
// Note that the client might have used oidcapi.AuthorizeUpstreamIDPNameParamName and
// oidcapi.AuthorizeUpstreamIDPTypeParamName query (or form) params to request a certain upstream IDP.
// The Pinniped CLI has been sending these params since v0.9.0.
idpNameQueryParamValue := r.Form.Get(oidcapi.AuthorizeUpstreamIDPNameParamName)
// Check if we are in a special case where we should inject an interstitial page to ask the user
// which IDP they would like to use.
if shouldShowIDPChooser(h.idpFinder, idpNameQueryParamValue, requestedBrowserlessFlow) {
// Redirect to the IDP chooser page with all the same query/form params. When the user chooses an IDP,
// it will redirect back to here with all the same params again, with the pinniped_idp_name param added.
http.Redirect(w, r,
fmt.Sprintf("%s%s?%s", h.downstreamIssuerURL, oidc.ChooseIDPEndpointPath, r.Form.Encode()),
http.StatusSeeOther,
)
return
}
idp, err := chooseUpstreamIDP(idpNameQueryParamValue, h.idpFinder)
if err != nil {
oidc.WriteAuthorizeError(r, w,
h.oauthHelperWithoutStorage,
fosite.NewAuthorizeRequest(),
fosite.ErrInvalidRequest.
WithHintf("%q param error: %s", oidcapi.AuthorizeUpstreamIDPNameParamName, err.Error()).
WithWrap(err).WithDebug(err.Error()),
requestedBrowserlessFlow)
return
}
h.auditLogger.Audit(auditevent.UsingUpstreamIDP, &plog.AuditParams{
ReqCtx: r.Context(),
KeysAndValues: []any{
"displayName", idp.GetDisplayName(),
"resourceName", idp.GetProvider().GetResourceName(),
"resourceUID", idp.GetProvider().GetResourceUID(),
"type", idp.GetSessionProviderType(),
},
})
h.authorize(w, r, requestedBrowserlessFlow, idp)
}
func (h *authorizeHandler) authorize(
w http.ResponseWriter,
r *http.Request,
requestedBrowserlessFlow bool,
idp resolvedprovider.FederationDomainResolvedIdentityProvider,
) {
// Browser flows do not need session storage at this step. For browser flows, the request parameters
// should be forwarded to the next step as upstream state parameters to avoid storing session state
// until the user successfully authenticates.
oauthHelper := h.oauthHelperWithoutStorage
if requestedBrowserlessFlow {
oauthHelper = h.oauthHelperWithStorage
}
authorizeRequester, err := oauthHelper.NewAuthorizeRequest(r.Context(), r)
if err != nil {
oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow)
return
}
// Automatically grant certain scopes, but only if they were requested.
// Grant the openid scope (for now) if they asked for it so that `NewAuthorizeResponse` will perform its OIDC validations.
// 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.
// This is instead of asking the user to approve these scopes. Note that `NewAuthorizeRequest` would have returned
// an error if the client requested a scope that they are not allowed to request, so we don't need to worry about that here.
downstreamsession.AutoApproveScopes(authorizeRequester)
if requestedBrowserlessFlow {
err = h.authorizeWithoutBrowser(r, w, oauthHelper, authorizeRequester, idp)
} else {
var authorizeID string
authorizeID, err = h.authorizeWithBrowser(r, w, oauthHelper, authorizeRequester, idp)
if err == nil {
h.auditLogger.Audit(auditevent.UpstreamAuthorizeRedirect, &plog.AuditParams{
ReqCtx: r.Context(),
KeysAndValues: []any{"authorizeID", authorizeID},
})
}
}
if err != nil {
// No specific audit event is emitted here in the case of an authorization error.
// There are currently seven possible cases:
// (1) OIDC with cli_password:
// - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong.
// - There's no way to determine why the OIDC provider rejected the request.
// (2) OIDC with browser_authcode: this endpoint only redirects upstream
// (3) LDAP with cli_password:
// - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong.
// - If we know that the LDAP provider rejected the request due to incorrect username or password,
// Pinniped will provide the "Incorrect Username Or Password" audit event.
// (4) LDAP with browser_authcode: this endpoint only redirects to the /login page
// (5) Active Directory with cli_password:
// - Rely on the "HTTP Request Completed" audit event with an error and error_description to indicate what went wrong.
// - If we know that the Active Directory provider rejected the request due to incorrect username or password,
// Pinniped will provide the "Incorrect Username Or Password" audit event.
// (6) Active Directory with browser_authcode: this endpoint only redirects to the /login page
// (7) GitHub with browser_authcode (cli_password is not supported): this endpoint only redirects upstream
oidc.WriteAuthorizeError(r, w, oauthHelper, authorizeRequester, err, requestedBrowserlessFlow)
}
}
func (h *authorizeHandler) authorizeWithoutBrowser(
r *http.Request,
w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider,
authorizeRequester fosite.AuthorizeRequester,
idp resolvedprovider.FederationDomainResolvedIdentityProvider,
) error {
if err := requireStaticClientForUsernameAndPasswordHeaders(authorizeRequester); err != nil {
return err
}
submittedUsername, submittedPassword, err := requireNonEmptyUsernameAndPasswordHeaders(r)
if err != nil {
return err
}
identity, loginExtras, err := idp.Login(r.Context(), submittedUsername, submittedPassword)
if err != nil {
if err == resolvedldap.ErrAccessDeniedDueToUsernamePasswordNotAccepted {
h.auditLogger.Audit(auditevent.IncorrectUsernameOrPassword, &plog.AuditParams{
ReqCtx: r.Context(),
})
}
return err
}
session, err := downstreamsession.NewPinnipedSession(r.Context(), h.auditLogger, &downstreamsession.SessionConfig{
UpstreamIdentity: identity,
UpstreamLoginExtras: loginExtras,
ClientID: authorizeRequester.GetClient().GetID(),
GrantedScopes: authorizeRequester.GetGrantedScopes(),
IdentityProvider: idp,
SessionIDGetter: authorizeRequester,
})
if err != nil {
return fosite.ErrAccessDenied.WithHintf("Reason: %s.", err.Error())
}
oidc.PerformAuthcodeRedirect(r, w, oauthHelper, authorizeRequester, session, true)
return nil
}
func (h *authorizeHandler) authorizeWithBrowser(
r *http.Request,
w http.ResponseWriter,
oauthHelper fosite.OAuth2Provider,
authorizeRequester fosite.AuthorizeRequester,
idp resolvedprovider.FederationDomainResolvedIdentityProvider,
) (string, error) {
authRequestState, err := generateUpstreamAuthorizeRequestState(r, w,
authorizeRequester,
oauthHelper,
h.generateCSRF,
h.generateNonce,
h.generatePKCE,
idp.GetDisplayName(),
idp.GetSessionProviderType(),
h.cookieCodec,
h.upstreamStateEncoder,
)
if err != nil {
return "", err
}
redirectURL, err := idp.UpstreamAuthorizeRedirectURL(authRequestState, h.downstreamIssuerURL)
if err != nil {
return "", err
}
http.Redirect(w, r, redirectURL,
http.StatusSeeOther, // match fosite and https://tools.ietf.org/id/draft-ietf-oauth-security-topics-18.html#section-4.11
)
return authRequestState.EncodedStateParam.AuthorizeID(), nil
}
func shouldShowIDPChooser(
idpFinder federationdomainproviders.FederationDomainIdentityProvidersFinderI,
idpNameQueryParamValue string,
requestedBrowserlessFlow bool,
) bool {
clientDidNotRequestSpecificIDP := len(idpNameQueryParamValue) == 0
clientRequestedBrowserBasedFlow := !requestedBrowserlessFlow
inBackwardsCompatMode := idpFinder.HasDefaultIDP()
federationDomainSpecHasSomeValidIDPs := idpFinder.IDPCount() > 0
return clientDidNotRequestSpecificIDP && clientRequestedBrowserBasedFlow &&
!inBackwardsCompatMode && federationDomainSpecHasSomeValidIDPs
}
func requireStaticClientForUsernameAndPasswordHeaders(authorizeRequester fosite.AuthorizeRequester) error {
if !(authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI) {
return fosite.ErrAccessDenied.WithHint("This client is not allowed to submit username or password headers to this endpoint.")
}
return nil
}
func requireNonEmptyUsernameAndPasswordHeaders(r *http.Request) (string, string, error) {
username := r.Header.Get(oidcapi.AuthorizeUsernameHeaderName)
password := r.Header.Get(oidcapi.AuthorizePasswordHeaderName)
if username == "" || password == "" {
return "", "", fosite.ErrAccessDenied.WithHint("Missing or blank username or password.")
}
return username, password, nil
}
func readCSRFCookie(r *http.Request, codec oidc.Decoder) csrftoken.CSRFToken {
receivedCSRFCookie, err := r.Cookie(oidc.CSRFCookieName)
if err != nil {
// Error means that the cookie was not found
return ""
}
var csrfFromCookie csrftoken.CSRFToken
err = codec.Decode(oidc.CSRFCookieEncodingName, receivedCSRFCookie.Value, &csrfFromCookie)
if err != nil {
// We can ignore any errors and just make a new cookie. Hopefully this will
// make the user experience better if, for example, the server rotated
// cookie signing keys and then a user submitted a very old cookie.
return ""
}
return csrfFromCookie
}
// chooseUpstreamIDP selects an upstream IDP, or returns an error.
func chooseUpstreamIDP(idpDisplayName string, idpLister federationdomainproviders.FederationDomainIdentityProvidersFinderI) (
resolvedprovider.FederationDomainResolvedIdentityProvider,
error,
) {
// When a request is made to the authorization endpoint which does not specify the IDP name, then it might
// be an old dynamic client (OIDCClient). We need to make this work, but only in the backwards compatibility case
// where there is exactly one IDP defined in the namespace and no IDPs listed on the FederationDomain.
// This backwards compatibility mode is handled by FindDefaultIDP().
if len(idpDisplayName) == 0 {
return idpLister.FindDefaultIDP()
}
return idpLister.FindUpstreamIDPByDisplayName(idpDisplayName)
}
// generateUpstreamAuthorizeRequestState performs the shared validations and setup between browser based
// auth requests regardless of IDP type.
// It generates the state param, sets the CSRF cookie, and validates the prompt param.
// It returns an error when it encounters an error without handling it, leaving it to
// the caller to decide how to handle it.
// It returns nil with no error when it encounters an error and also has already handled writing
// the error response to the ResponseWriter, in which case the caller should not also try to
// write the error response.
func generateUpstreamAuthorizeRequestState(
r *http.Request,
w http.ResponseWriter,
authorizeRequester fosite.AuthorizeRequester,
oauthHelper fosite.OAuth2Provider,
generateCSRF func() (csrftoken.CSRFToken, error),
generateNonce func() (nonce.Nonce, error),
generatePKCE func() (pkce.Code, error),
upstreamDisplayName string,
idpType psession.ProviderType,
cookieCodec oidc.Codec,
upstreamStateEncoder oidc.Encoder,
) (*resolvedprovider.UpstreamAuthorizeRequestState, error) {
now := time.Now()
_, err := oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &psession.PinnipedSession{
Fosite: &openid.DefaultSession{
Claims: &fositejwt.IDTokenClaims{
// Temporary claim values to allow `NewAuthorizeResponse` to perform other OIDC validations.
Subject: "none",
AuthTime: now,
RequestedAt: now,
},
},
})
if err != nil {
return nil, err
}
csrfValue, nonceValue, pkceValue, err := generateValues(generateCSRF, generateNonce, generatePKCE)
if err != nil {
plog.Error("authorize generate error", err)
return nil, fosite.ErrServerError.WithHint("Server could not generate necessary values.").WithWrap(err)
}
csrfFromCookie := readCSRFCookie(r, cookieCodec)
if csrfFromCookie != "" {
csrfValue = csrfFromCookie
}
encodedStateParamValue, err := upstreamStateParam(
authorizeRequester,
upstreamDisplayName,
string(idpType),
nonceValue,
csrfValue,
pkceValue,
upstreamStateEncoder,
)
if err != nil {
plog.Error("authorize upstream state param error", err)
return nil, fosite.ErrServerError.WithHint("Error encoding upstream state param.").WithWrap(err)
}
promptParam := authorizeRequester.GetRequestForm().Get(promptParamName)
if promptParam == promptParamNone && oidc.ScopeWasRequested(authorizeRequester, oidcapi.ScopeOpenID) {
return nil, fosite.ErrLoginRequired
}
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 nil, fosite.ErrServerError.WithHint("Error encoding CSRF cookie.").WithWrap(err)
}
}
return &resolvedprovider.UpstreamAuthorizeRequestState{
EncodedStateParam: encodedStateParamValue,
PKCE: pkceValue,
Nonce: nonceValue,
}, nil
}
func generateValues(
generateCSRF func() (csrftoken.CSRFToken, error),
generateNonce func() (nonce.Nonce, error),
generatePKCE func() (pkce.Code, error),
) (csrftoken.CSRFToken, nonce.Nonce, pkce.Code, error) {
csrfValue, err := generateCSRF()
if err != nil {
return "", "", "", fmt.Errorf("error generating CSRF token: %w", err)
}
nonceValue, err := generateNonce()
if err != nil {
return "", "", "", fmt.Errorf("error generating nonce param: %w", err)
}
pkceValue, err := generatePKCE()
if err != nil {
return "", "", "", fmt.Errorf("error generating PKCE param: %w", err)
}
return csrfValue, nonceValue, pkceValue, nil
}
func upstreamStateParam(
authorizeRequester fosite.AuthorizeRequester,
upstreamDisplayName string,
upstreamType string,
nonceValue nonce.Nonce,
csrfValue csrftoken.CSRFToken,
pkceValue pkce.Code,
encoder oidc.Encoder,
) (stateparam.Encoded, error) {
stateParamData := oidc.UpstreamStateParamData{
// The auth params might have included oidcapi.AuthorizeUpstreamIDPNameParamName and
// oidcapi.AuthorizeUpstreamIDPTypeParamName, but those can be ignored by other handlers
// that are reading from the encoded upstream state param being built here.
// The UpstreamName and UpstreamType struct fields can be used instead.
// Remove those params here to avoid potential confusion about which should be used later.
AuthParams: removeCustomIDPParams(authorizeRequester.GetRequestForm()).Encode(),
UpstreamName: upstreamDisplayName,
UpstreamType: upstreamType,
Nonce: nonceValue,
CSRFToken: csrfValue,
PKCECode: pkceValue,
FormatVersion: oidc.UpstreamStateParamFormatVersion,
}
encodedStateParamValue, err := encoder.Encode(oidc.UpstreamStateParamEncodingName, stateParamData)
if err != nil {
return "", fmt.Errorf("error encoding upstream state param: %w", err)
}
return stateparam.Encoded(encodedStateParamValue), nil
}
func removeCustomIDPParams(params url.Values) url.Values {
p := url.Values{}
// Copy all params.
for k, v := range params {
p[k] = v
}
// Remove the unnecessary params.
delete(p, oidcapi.AuthorizeUpstreamIDPNameParamName)
delete(p, oidcapi.AuthorizeUpstreamIDPTypeParamName)
return p
}
func addCSRFSetCookieHeader(w http.ResponseWriter, csrfValue csrftoken.CSRFToken, codec oidc.Encoder) error {
encodedCSRFValue, err := codec.Encode(oidc.CSRFCookieEncodingName, csrfValue)
if err != nil {
return fmt.Errorf("error encoding CSRF cookie: %w", err)
}
http.SetCookie(w, &http.Cookie{
Name: oidc.CSRFCookieName,
Value: encodedCSRFValue,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: true,
Path: "/",
})
return nil
}