Files
pinniped/internal/federationdomain/endpoints/token/token_handler.go
Ryan Richard 54b35c30da rename tokenIdentifier to tokenID in the audit logs
Because `tokenID` is more consistent with the names of
the other correlation keys.
2024-11-27 13:53:03 -06:00

446 lines
17 KiB
Go

// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package token provides a handler for the OIDC token endpoint.
package token
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net/http"
"slices"
"time"
"github.com/ory/fosite"
errorsx "github.com/pkg/errors"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/warning"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/auditevent"
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
"go.pinniped.dev/internal/federationdomain/idtokenlifespan"
"go.pinniped.dev/internal/federationdomain/oidc"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/federationdomain/timeouts"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/idtransform"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
)
func paramsSafeToLog() sets.Set[string] {
return sets.New(
// Standard params from https://openid.net/specs/openid-connect-core-1_0.html for authcode and refresh grants.
// Redacting code, client_secret, refresh_token, and PKCE code_verifier params.
"grant_type", "client_id", "redirect_uri", "scope",
// Token exchange params from https://datatracker.ietf.org/doc/html/rfc8693#section-2.1.
// Redact subject_token and actor_token.
// We don't allow all of these, but they should be safe to log.
// "scope" is already included from the authcode grant.
"audience", "resource", "requested_token_type", "actor_token_type", "subject_token_type",
)
}
func NewHandler(
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
oauthHelper fosite.OAuth2Provider,
overrideAccessTokenLifespan timeouts.OverrideLifespan,
overrideIDTokenLifespan timeouts.OverrideLifespan,
auditLogger plog.AuditLogger,
) http.Handler {
return httperr.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
if err := auditLogger.AuditRequestParams(r, paramsSafeToLog()); err != nil {
oauthHelper.WriteAccessError(r.Context(), w, nil, err)
return nil
}
auditLogBasicAuthClientID(r, auditLogger)
session := psession.NewPinnipedSession()
accessRequest, err := oauthHelper.NewAccessRequest(r.Context(), r, session)
if err != nil {
plog.Info("token request error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
return nil
}
// Log sessionID for cross-request correlation purposes.
auditLogger.Audit(auditevent.SessionFound, &plog.AuditParams{
ReqCtx: r.Context(),
Session: accessRequest,
})
// Check if we are performing a refresh grant.
if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeRefreshToken) {
// The above call to NewAccessRequest has loaded the session from storage into the accessRequest variable.
// The session, requested scopes, and requested audience from the original authorize request was retrieved
// from the Kube storage layer and added to the accessRequest. Additionally, the audience and scopes may
// have already been granted on the accessRequest.
err = upstreamRefresh(r.Context(), accessRequest, idpLister, auditLogger)
if err != nil {
plog.Info("upstream refresh error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
return nil
}
}
// When we are in the authorization code flow, check if we have any warnings that previous handlers want us
// to send to the client to be printed on the CLI.
if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeAuthorizationCode) {
storedSession := accessRequest.GetSession().(*psession.PinnipedSession)
customSessionData := storedSession.Custom
if customSessionData != nil {
for _, warningText := range customSessionData.Warnings {
warning.AddWarning(r.Context(), "", warningText)
}
}
}
// Lifetimes of the access and refresh tokens are determined by the above call to NewAccessRequest.
// Depending on the request, sometimes override the default access token lifespan.
maybeOverrideDefaultAccessTokenLifetime(overrideAccessTokenLifespan, accessRequest)
// Create the token response.
// The lifetime of the ID token will be determined inside the call NewAccessResponse.
// Depending on the request, sometimes override the default ID token lifespan by putting
// the override value onto the context.
accessResponse, err := oauthHelper.NewAccessResponse(
maybeOverrideDefaultIDTokenLifetime(r.Context(), overrideIDTokenLifespan, accessRequest),
accessRequest)
if err != nil {
plog.Info("token response error", oidc.FositeErrorForLog(err)...)
oauthHelper.WriteAccessError(r.Context(), w, accessRequest, err)
return nil
}
// Allow cross-referencing the token with the Concierge's audit logs.
auditLogIDToken(r.Context(), auditLogger, accessRequest, accessResponse)
oauthHelper.WriteAccessResponse(r.Context(), w, accessRequest, accessResponse)
return nil
})
}
func maybeOverrideDefaultAccessTokenLifetime(overrideAccessTokenLifespan timeouts.OverrideLifespan, accessRequest fosite.AccessRequester) {
if newLifespan, doOverride := overrideAccessTokenLifespan(accessRequest); doOverride {
accessRequest.GetSession().SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(newLifespan).Round(time.Second))
}
}
func maybeOverrideDefaultIDTokenLifetime(baseCtx context.Context, overrideIDTokenLifespan timeouts.OverrideLifespan, accessRequest fosite.AccessRequester) context.Context {
if newLifespan, doOverride := overrideIDTokenLifespan(accessRequest); doOverride {
return idtokenlifespan.OverrideIDTokenLifespanInContext(baseCtx, newLifespan)
}
return baseCtx
}
func errMissingUpstreamSessionInternalError() *fosite.RFC6749Error {
return &fosite.RFC6749Error{
ErrorField: "error",
DescriptionField: "There was an internal server error.",
HintField: "Required upstream data not found in session.",
CodeField: http.StatusInternalServerError,
}
}
func errUpstreamRefreshError() *fosite.RFC6749Error {
return &fosite.RFC6749Error{
ErrorField: "error",
DescriptionField: "Error during upstream refresh.",
CodeField: http.StatusUnauthorized,
}
}
func upstreamRefresh(
ctx context.Context,
accessRequest fosite.AccessRequester,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
auditLogger plog.AuditLogger,
) error {
session := accessRequest.GetSession().(*psession.PinnipedSession)
customSessionData := session.Custom
if customSessionData == nil {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
providerName := customSessionData.ProviderName
providerType := customSessionData.ProviderType
providerUID := customSessionData.ProviderUID
if providerUID == "" || providerName == "" {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
skipGroups := !slices.Contains(accessRequest.GetGrantedScopes(), oidcapi.ScopeGroups)
if session.IDTokenClaims().AuthTime.IsZero() {
return errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
err := validateSessionHasUsername(session)
if err != nil {
return err
}
var oldTransformedGroups []string
if !skipGroups {
// Only validate the groups in the session if the groups scope was granted.
oldTransformedGroups, err = validateAndGetDownstreamGroupsFromSession(session)
if err != nil {
return err
}
}
idp, err := findProviderByNameAndType(providerName, customSessionData.ProviderType, providerUID, idpLister)
if err != nil {
return err
}
cloneOfIDPSpecificSessionData := idp.CloneIDPSpecificSessionDataFromSession(session.Custom)
if cloneOfIDPSpecificSessionData == nil {
return errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
oldUntransformedUsername := session.Custom.UpstreamUsername
oldUntransformedGroups := session.Custom.UpstreamGroups
oldTransformedUsername := session.Custom.Username
previousIdentity := &resolvedprovider.Identity{
UpstreamUsername: oldUntransformedUsername,
UpstreamGroups: oldUntransformedGroups,
DownstreamSubject: session.Fosite.Claims.Subject,
IDPSpecificSessionData: cloneOfIDPSpecificSessionData,
}
// Perform the upstream refresh.
refreshedIdentity, err := idp.UpstreamRefresh(ctx, previousIdentity)
if err != nil {
return err
}
auditLogger.Audit(auditevent.IdentityRefreshedFromUpstreamIDP, &plog.AuditParams{
ReqCtx: ctx,
Session: accessRequest,
PIIKeysAndValues: []any{
"upstreamUsername", refreshedIdentity.UpstreamUsername,
"upstreamGroups", refreshedIdentity.UpstreamGroups,
},
})
// If the idp wants to update the session with new information from the refresh, then update it.
if refreshedIdentity.IDPSpecificSessionData != nil {
idp.ApplyIDPSpecificSessionDataToSession(session.Custom, refreshedIdentity.IDPSpecificSessionData)
}
if refreshedIdentity.UpstreamGroups == nil {
// If we could not get a new list of groups, then we still need the untransformed groups list to be able to
// run the transformations again, so fetch the original untransformed groups list from the session.
// We should also run the transformations on the original groups even when the groups scope was not granted,
// because a transformation policy may want to reject the authentication based on the group memberships, even
// though the group memberships will not be shared with the client (in the code below) due to the groups scope
// not being granted.
refreshedIdentity.UpstreamGroups = oldUntransformedGroups
}
refreshedTransformedUsername, refreshedTransformedGroups, fositeErr := applyIdentityTransformationsDuringRefresh(ctx,
idp.GetTransforms(),
refreshedIdentity.UpstreamUsername,
refreshedIdentity.UpstreamGroups,
providerName,
providerType,
)
if fositeErr != nil {
// The HintField is always populated by applyIdentityTransformationsDuringRefresh,
// and more descriptive than fositeErr.Error() which is just "error".
auditLogger.Audit(auditevent.AuthenticationRejectedByTransforms, &plog.AuditParams{
ReqCtx: ctx,
Session: accessRequest,
KeysAndValues: []any{"reason", fositeErr.HintField},
})
return fositeErr
}
if oldTransformedUsername != refreshedTransformedUsername {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").
WithTrace(errors.New("username in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
}
if !skipGroups {
warnIfGroupsChanged(ctx, oldTransformedGroups, refreshedTransformedGroups, oldTransformedUsername, accessRequest.GetClient().GetID())
// Replace the old value for the downstream groups in the user's session with the new value.
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedTransformedGroups
}
auditLogger.Audit(auditevent.SessionRefreshed, &plog.AuditParams{
ReqCtx: ctx,
Session: accessRequest,
PIIKeysAndValues: []any{
"username", oldTransformedUsername, // not allowed to change above so must be the same as old
"groups", refreshedTransformedGroups,
"subject", previousIdentity.DownstreamSubject},
})
return nil
}
// findProviderByNameAndType finds the IDP by its resource name and IDP type,
// and validates that its resource UID matches the expected UID.
func findProviderByNameAndType(
providerResourceName string,
providerType psession.ProviderType,
mustHaveResourceUID types.UID,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
) (resolvedprovider.FederationDomainResolvedIdentityProvider, error) {
for _, p := range idpLister.GetIdentityProviders() {
if p.GetSessionProviderType() == providerType && p.GetProvider().GetResourceName() == providerResourceName {
if p.GetProvider().GetResourceUID() != mustHaveResourceUID {
return nil, errorsx.WithStack(errUpstreamRefreshError().WithHint(
"Provider from upstream session data has changed its resource UID since authentication."))
}
return p, nil
}
}
return nil, errorsx.WithStack(errUpstreamRefreshError().
WithHint("Provider from upstream session data was not found.").
WithDebugf("provider name: %q, provider type: %q", providerResourceName, providerType))
}
func validateSessionHasUsername(session *psession.PinnipedSession) error {
downstreamUsername := session.Custom.Username
if len(downstreamUsername) == 0 {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
return nil
}
// applyIdentityTransformationsDuringRefresh is similar to downstreamsession.applyIdentityTransformations
// but with slightly different error messaging.
func applyIdentityTransformationsDuringRefresh(
ctx context.Context,
transforms *idtransform.TransformationPipeline,
upstreamUsername string,
upstreamGroups []string,
providerName string,
providerType psession.ProviderType,
) (string, []string, *fosite.RFC6749Error) {
transformationResult, err := transforms.Evaluate(ctx, upstreamUsername, upstreamGroups)
if err != nil {
return "", nil, errUpstreamRefreshError().WithHintf(
"Upstream refresh error while applying configured identity transformations.").
WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
}
if !transformationResult.AuthenticationAllowed {
return "", nil, errUpstreamRefreshError().WithHintf(
"Upstream refresh rejected by configured identity policy: %s.", transformationResult.RejectedAuthenticationMessage).
WithDebugf("provider name: %q, provider type: %q", providerName, providerType)
}
return transformationResult.Username, transformationResult.Groups, nil
}
func validateAndGetDownstreamGroupsFromSession(session *psession.PinnipedSession) ([]string, error) {
extra := session.Fosite.Claims.Extra
if extra == nil {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamGroupsInterface := extra[oidcapi.IDTokenClaimGroups]
if downstreamGroupsInterface == nil {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamGroupsInterfaceList, ok := downstreamGroupsInterface.([]any)
if !ok {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamGroups := make([]string, 0, len(downstreamGroupsInterfaceList))
for _, downstreamGroupInterface := range downstreamGroupsInterfaceList {
downstreamGroup, ok := downstreamGroupInterface.(string)
if !ok || len(downstreamGroup) == 0 {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
downstreamGroups = append(downstreamGroups, downstreamGroup)
}
return downstreamGroups, nil
}
func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, username string, clientID string) {
if clientID != oidcapi.ClientIDPinnipedCLI {
// Only send these warnings to the CLI client. They are intended for kubectl to print to the screen.
// A webapp using a dynamic client wouldn't know to look for these special warning headers, and
// if the dynamic client lacked the username scope, then these warning messages would be leaking
// the user's username to the client within the text of the warning.
return
}
added, removed := diffSortedGroups(oldGroups, newGroups)
if len(added) > 0 {
warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been added to the following groups: %q", username, added))
}
if len(removed) > 0 {
warning.AddWarning(ctx, "", fmt.Sprintf("User %q has been removed from the following groups: %q", username, removed))
}
}
func diffSortedGroups(oldGroups, newGroups []string) ([]string, []string) {
oldGroupsAsSet := sets.NewString(oldGroups...)
newGroupsAsSet := sets.NewString(newGroups...)
added := newGroupsAsSet.Difference(oldGroupsAsSet) // groups in newGroups that are not in oldGroups i.e. added
removed := oldGroupsAsSet.Difference(newGroupsAsSet) // groups in oldGroups that are not in newGroups i.e. removed
return added.List(), removed.List()
}
func auditLogBasicAuthClientID(r *http.Request, auditLogger plog.AuditLogger) {
// For dynamic clients, the client ID is from basic auth, not from the request parameters.
clientIDFromBasicAuth, _, basicAuthUsed := r.BasicAuth()
if basicAuthUsed {
auditLogger.Audit(auditevent.HTTPRequestBasicAuthUsed, &plog.AuditParams{
ReqCtx: r.Context(),
KeysAndValues: []any{"clientID", clientIDFromBasicAuth},
})
}
}
func auditLogIDToken(
reqCtx context.Context,
auditLogger plog.AuditLogger,
accessRequest fosite.AccessRequester,
accessResponse fosite.AccessResponder,
) {
var idToken string
if accessRequest.GetGrantTypes().ExactOne(oidcapi.GrantTypeTokenExchange) {
// Token exchanges return the ID token in the access token field of the response.
idToken = accessResponse.GetAccessToken()
} else {
// For other grant types, there may not be an access token, e.g. when the openid scope was not granted.
tok := accessResponse.GetExtra("id_token")
if tok != nil {
// This should always be a string. Checking just to be safe.
tokAsStr, ok := tok.(string)
if ok {
idToken = tokAsStr
}
}
}
if len(idToken) == 0 {
return
}
auditLogger.Audit(auditevent.IDTokenIssued, &plog.AuditParams{
ReqCtx: reqCtx,
Session: accessRequest,
KeysAndValues: []any{
"tokenID", fmt.Sprintf("%x", sha256.Sum256([]byte(idToken))),
},
})
}