Refactor token endpoint to add interface for IDP upstream refresh

This commit is contained in:
Ryan Richard
2024-02-20 09:26:34 -08:00
parent 1bc13e94f7
commit 1e8e7b948e
5 changed files with 342 additions and 346 deletions
@@ -6,13 +6,12 @@ package token
import (
"context"
"errors"
"fmt"
"net/http"
"github.com/ory/fosite"
errorsx "github.com/pkg/errors"
"golang.org/x/oauth2"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/warning"
"k8s.io/utils/strings/slices"
@@ -20,11 +19,8 @@ import (
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/federationdomain/federationdomainproviders"
"go.pinniped.dev/internal/federationdomain/oidc"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedldap"
"go.pinniped.dev/internal/federationdomain/resolvedprovider/resolvedoidc"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
"go.pinniped.dev/internal/federationdomain/resolvedprovider"
"go.pinniped.dev/internal/httputil/httperr"
"go.pinniped.dev/internal/idtransform"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
)
@@ -98,7 +94,11 @@ func errUpstreamRefreshError() *fosite.RFC6749Error {
}
}
func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester, idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI) error {
func upstreamRefresh(
ctx context.Context,
accessRequest fosite.AccessRequester,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
) error {
session := accessRequest.GetSession().(*psession.PinnipedSession)
customSessionData := session.Custom
@@ -111,387 +111,74 @@ func upstreamRefresh(ctx context.Context, accessRequest fosite.AccessRequester,
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
groupsScopeNotGranted := !slices.Contains(accessRequest.GetGrantedScopes(), oidcapi.ScopeGroups)
skipGroups := !slices.Contains(accessRequest.GetGrantedScopes(), oidcapi.ScopeGroups)
clientID := accessRequest.GetClient().GetID()
switch customSessionData.ProviderType {
case psession.ProviderTypeOIDC:
return upstreamOIDCRefresh(ctx, idpLister, session, groupsScopeNotGranted, clientID)
case psession.ProviderTypeLDAP, psession.ProviderTypeActiveDirectory:
return upstreamLDAPRefresh(ctx, idpLister, session, groupsScopeNotGranted, clientID)
default:
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
}
//nolint:funlen
func upstreamOIDCRefresh(
ctx context.Context,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
session *psession.PinnipedSession,
skipGroups bool,
clientID string,
) error {
s := session.Custom
if s.OIDC == nil {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
accessTokenStored := s.OIDC.UpstreamAccessToken != ""
refreshTokenStored := s.OIDC.UpstreamRefreshToken != ""
exactlyOneTokenStored := (accessTokenStored || refreshTokenStored) && !(accessTokenStored && refreshTokenStored)
if !exactlyOneTokenStored {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
p, err := findOIDCProviderByNameAndValidateUID(s, idpLister)
err := validateSessionHasUsername(session)
if err != nil {
return err
}
plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
var tokens *oauth2.Token
if refreshTokenStored {
tokens, err = p.Provider.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
if err != nil {
return errUpstreamRefreshError().WithHint(
"Upstream refresh failed.",
).WithTrace(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
} else {
tokens = &oauth2.Token{AccessToken: s.OIDC.UpstreamAccessToken}
}
// Upstream refresh may or may not return a new ID token. From the spec:
// "the response body is the Token Response of Section 3.1.3.3 except that it might not contain an id_token."
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
_, hasIDTok := tokens.Extra("id_token").(string)
// We may or may not have an ID token, and we may or may not have a userinfo endpoint to call for more claims.
// Use what we can (one, both, or neither) and return the union of their claims. If we stored an access token,
// then require that the userinfo endpoint exists and returns a successful response, or else we would have no
// way to check that the user's session was not revoked on the server.
// The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at
// least some providers do not include one, so we skip the nonce validation here (but not other validations).
validatedTokens, err := p.Provider.ValidateTokenAndMergeWithUserInfo(ctx, tokens, "", hasIDTok, accessTokenStored)
if err != nil {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh returned an invalid ID token or UserInfo response.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
mergedClaims := validatedTokens.IDToken.Claims
// To the extent possible, check that the user's basic identity hasn't changed. We check that their downstream
// username has not changed separately below, as part of reapplying the transformations.
err = validateSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims, session)
idp, err := findProviderByNameAndType(providerName, customSessionData.ProviderType, providerUID, idpLister)
if err != nil {
return err
}
var refreshedUntransformedGroups []string
if !skipGroups {
// If possible, update the user's group memberships. The configured groups claim name (if there is one) may or
// may not be included in the newly fetched and merged claims. It could be missing due to a misconfiguration of the
// claim name. It could also be missing because the claim was originally found in the ID token during login, but
// now we might not have a refreshed ID token.
// If the claim is found, then use it to update the user's group membership in the session.
// If the claim is not found, then we have no new information about groups, so skip updating the group membership
// and let any old groups memberships in the session remain.
refreshedUntransformedGroups, err = resolvedoidc.GetGroupsFromUpstreamIDToken(p.Provider, mergedClaims)
if err != nil {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh error while extracting groups claim.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
}
// It's possible that a username wasn't returned by the upstream provider during refresh,
// but if it is, verify that the transformed version of it hasn't changed.
refreshedUntransformedUsername, hasRefreshedUntransformedUsername := getString(mergedClaims, p.Provider.GetUsernameClaim())
oldUntransformedUsername := s.UpstreamUsername
oldUntransformedGroups := s.UpstreamGroups
if !hasRefreshedUntransformedUsername {
// If we could not get a new username, then we still need the untransformed username to be able to
// run the transformations again, so fetch the original untransformed username from the session.
refreshedUntransformedUsername = oldUntransformedUsername
}
if refreshedUntransformedGroups == 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.
refreshedUntransformedGroups = oldUntransformedGroups
}
oldTransformedUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
oldTransformedUsername := session.Custom.Username
var oldTransformedGroups []string
if !skipGroups {
oldTransformedGroups, err = getDownstreamGroupsFromPinnipedSession(session)
oldTransformedGroups, err = getDownstreamGroupsFromSession(session)
if err != nil {
return err
}
}
transformationResult, err := transformRefreshedIdentity(ctx,
p.Transforms,
oldTransformedUsername,
refreshedUntransformedUsername,
refreshedUntransformedGroups,
s.ProviderName,
s.ProviderType,
)
refreshedGroups, err := idp.UpstreamRefresh(ctx, session, skipGroups)
if err != nil {
return err
}
if !skipGroups {
warnIfGroupsChanged(ctx, oldTransformedGroups, transformationResult.Groups, transformationResult.Username, clientID)
warnIfGroupsChanged(ctx, oldTransformedGroups, refreshedGroups, oldTransformedUsername, clientID)
// Replace the old value for the downstream groups in the user's session with the new value.
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = transformationResult.Groups
}
// Upstream refresh may or may not return a new refresh token. If we got a new refresh token, then update it in
// the user's session. If we did not get a new refresh token, then keep the old one in the session by avoiding
// overwriting the old one.
if tokens.RefreshToken != "" {
plog.Debug("upstream refresh request returned a new refresh token",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
s.OIDC.UpstreamRefreshToken = tokens.RefreshToken
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = refreshedGroups
}
return nil
}
// print out the diff between two lists of sorted groups.
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 validateSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims map[string]interface{}, session *psession.PinnipedSession) error {
s := session.Custom
// If we have any claims at all, we better have a subject, and it better match the previous value.
// but it's possible that we don't because both returning a new id token on refresh and having a userinfo
// endpoint are optional.
if len(mergedClaims) == 0 {
return nil
}
newSub, hasSub := getString(mergedClaims, oidcapi.IDTokenClaimSubject)
if !hasSub {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh not found")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
if s.OIDC.UpstreamSubject != newSub {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
newIssuer, hasIssuer := getString(mergedClaims, oidcapi.IDTokenClaimIssuer)
// It's possible that an issuer wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasIssuer && s.OIDC.UpstreamIssuer != newIssuer {
return errUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("issuer in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
return nil
}
func getString(m map[string]interface{}, key string) (string, bool) {
val, ok := m[key].(string)
return val, ok
}
func findOIDCProviderByNameAndValidateUID(
s *psession.CustomSessionData,
// 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,
) (*resolvedoidc.FederationDomainResolvedOIDCIdentityProvider, error) {
) (resolvedprovider.FederationDomainResolvedIdentityProvider, error) {
for _, p := range idpLister.GetIdentityProviders() {
if p.GetSessionProviderType() == psession.ProviderTypeOIDC && p.GetProvider().GetName() == s.ProviderName {
if p.GetProvider().GetResourceUID() != s.ProviderUID {
if p.GetSessionProviderType() == providerType && p.GetProvider().GetName() == providerResourceName {
if p.GetProvider().GetResourceUID() != mustHaveResourceUID {
return nil, errorsx.WithStack(errUpstreamRefreshError().WithHint(
"Provider from upstream session data has changed its resource UID since authentication."))
}
// TODO: make a new interface method for refreshing rather than downcasting here
return p.(*resolvedoidc.FederationDomainResolvedOIDCIdentityProvider), nil
return p, nil
}
}
return nil, errorsx.WithStack(errUpstreamRefreshError().
WithHint("Provider from upstream session data was not found.").
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
WithDebugf("provider name: %q, provider type: %q", providerResourceName, providerType))
}
func upstreamLDAPRefresh(
ctx context.Context,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
session *psession.PinnipedSession,
skipGroups bool,
clientID string,
) error {
s := session.Custom
oldTransformedUsername, err := getDownstreamUsernameFromPinnipedSession(session)
if err != nil {
return err
}
var oldTransformedGroups []string
if !skipGroups {
oldTransformedGroups, err = getDownstreamGroupsFromPinnipedSession(session)
if err != nil {
return err
}
}
validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != ""
validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != ""
if !(validLDAP || validAD) {
func validateSessionHasUsername(session *psession.PinnipedSession) error {
downstreamUsername := session.Custom.Username
if len(downstreamUsername) == 0 {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
var additionalAttributes map[string]string
if s.ProviderType == psession.ProviderTypeLDAP {
additionalAttributes = s.LDAP.ExtraRefreshAttributes
} else {
additionalAttributes = s.ActiveDirectory.ExtraRefreshAttributes
}
p, dn, err := findLDAPProviderByNameAndValidateUID(s, idpLister)
if err != nil {
return err
}
if session.IDTokenClaims().AuthTime.IsZero() {
return errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
oldUntransformedUsername := s.UpstreamUsername
oldUntransformedGroups := s.UpstreamGroups
refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.RefreshAttributes{
Username: oldUntransformedUsername,
Subject: session.Fosite.Claims.Subject,
DN: dn,
Groups: oldUntransformedGroups,
AdditionalAttributes: additionalAttributes,
SkipGroups: skipGroups,
}, p.DisplayName)
if err != nil {
return errUpstreamRefreshError().WithHint(
"Upstream refresh failed.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
transformationResult, err := transformRefreshedIdentity(ctx,
p.Transforms,
oldTransformedUsername,
oldUntransformedUsername, // LDAP PerformRefresh validates that the username did not change, so this is also the refreshed upstream username
refreshedUntransformedGroups,
s.ProviderName,
s.ProviderType,
)
if err != nil {
return err
}
if !skipGroups {
warnIfGroupsChanged(ctx, oldTransformedGroups, transformationResult.Groups, transformationResult.Username, clientID)
// Replace the old value for the downstream groups in the user's session with the new value.
session.Fosite.Claims.Extra[oidcapi.IDTokenClaimGroups] = transformationResult.Groups
}
return nil
}
func transformRefreshedIdentity(
ctx context.Context,
transforms *idtransform.TransformationPipeline,
oldTransformedUsername string,
upstreamUsername string,
upstreamGroups []string,
providerName string,
providerType psession.ProviderType,
) (*idtransform.TransformationResult, error) {
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)
}
if oldTransformedUsername != transformationResult.Username {
return nil, 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)
}
return transformationResult, nil
}
func findLDAPProviderByNameAndValidateUID(
s *psession.CustomSessionData,
idpLister federationdomainproviders.FederationDomainIdentityProvidersListerI,
) (*resolvedldap.FederationDomainResolvedLDAPIdentityProvider, string, error) {
var dn string
if s.ProviderType == psession.ProviderTypeLDAP {
dn = s.LDAP.UserDN
} else if s.ProviderType == psession.ProviderTypeActiveDirectory {
dn = s.ActiveDirectory.UserDN
}
for _, p := range idpLister.GetIdentityProviders() {
if p.GetSessionProviderType() == s.ProviderType && p.GetProvider().GetName() == s.ProviderName {
if p.GetProvider().GetResourceUID() != s.ProviderUID {
return nil, "", errorsx.WithStack(errUpstreamRefreshError().WithHint(
"Provider from upstream session data has changed its resource UID since authentication.").
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
// TODO: make a new interface method for refreshing rather than downcasting here
return p.(*resolvedldap.FederationDomainResolvedLDAPIdentityProvider), dn, nil
}
}
return nil, "", errorsx.WithStack(errUpstreamRefreshError().
WithHint("Provider from upstream session data was not found.").
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType))
}
func getDownstreamUsernameFromPinnipedSession(session *psession.PinnipedSession) (string, error) {
downstreamUsername := session.Custom.Username
if len(downstreamUsername) == 0 {
return "", errorsx.WithStack(errMissingUpstreamSessionInternalError())
}
return downstreamUsername, nil
}
func getDownstreamGroupsFromPinnipedSession(session *psession.PinnipedSession) ([]string, error) {
func getDownstreamGroupsFromSession(session *psession.PinnipedSession) ([]string, error) {
extra := session.Fosite.Claims.Extra
if extra == nil {
return nil, errorsx.WithStack(errMissingUpstreamSessionInternalError())
@@ -534,3 +221,11 @@ func warnIfGroupsChanged(ctx context.Context, oldGroups, newGroups []string, use
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()
}
@@ -3479,6 +3479,7 @@ func TestRefreshGrant(t *testing.T) {
idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: &psession.CustomSessionData{
Username: goodUsername,
ProviderName: "this-name-will-not-be-found", // this could happen if the OIDCIdentityProvider was deleted since original login
ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType,
@@ -3487,6 +3488,7 @@ func TestRefreshGrant(t *testing.T) {
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{ // want the initial customSessionData to be unmodified
Username: goodUsername,
ProviderName: "this-name-will-not-be-found", // this could happen if the OIDCIdentityProvider was deleted since original login
ProviderUID: oidcUpstreamResourceUID,
ProviderType: oidcUpstreamType,
@@ -3511,6 +3513,7 @@ func TestRefreshGrant(t *testing.T) {
idps: testidplister.NewUpstreamIDPListerBuilder().WithOIDC(upstreamOIDCIdentityProviderBuilder().Build()),
authcodeExchange: authcodeExchangeInputs{
customSessionData: &psession.CustomSessionData{
Username: goodUsername,
ProviderName: oidcUpstreamName,
ProviderUID: "this is the wrong uid", // this could happen if the OIDCIdentityProvider was deleted and recreated at the same name since original login
ProviderType: oidcUpstreamType,
@@ -3519,6 +3522,7 @@ func TestRefreshGrant(t *testing.T) {
modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access username groups") },
want: happyAuthcodeExchangeTokenResponseForOpenIDAndOfflineAccess(
&psession.CustomSessionData{ // want the initial customSessionData to be unmodified
Username: goodUsername,
ProviderName: oidcUpstreamName,
ProviderUID: "this is the wrong uid", // this could happen if the OIDCIdentityProvider was deleted and recreated at the same name since original login
ProviderType: oidcUpstreamType,
@@ -5,6 +5,10 @@ package resolvedprovider
import (
"context"
"errors"
"net/http"
"github.com/ory/fosite"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/internal/federationdomain/upstreamprovider"
@@ -58,4 +62,64 @@ type FederationDomainResolvedIdentityProvider interface {
// identity, and transform it into their downstream identity.
// Returned errors should be from the httperr package.
HandleCallback(ctx context.Context, authCode string, pkce pkce.Code, nonce nonce.Nonce, redirectURI string) (*Identity, error)
// UpstreamRefresh performs a refresh with the upstream provider.
// The user's session information is passed in, and implementations should be careful mutating anything about the
// session because changes will be saved into the session.
// If possible, implementations should update the user's group memberships by fetching them from the
// upstream provider during the refresh, and returning them.
// The groupsWillBeIgnored parameter will be true when the returned groups are going to be ignored by the caller,
// in which case this function may be able to save some effort by avoiding getting the user's upstream groups.
// Returned errors should be of type fosite.RFC6749Error.
UpstreamRefresh(ctx context.Context, session *psession.PinnipedSession, groupsWillBeIgnored bool) (refreshedGroups []string, err error)
}
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 TransformRefreshedIdentity(
ctx context.Context,
transforms *idtransform.TransformationPipeline,
oldTransformedUsername string,
upstreamUsername string,
upstreamGroups []string,
providerName string,
providerType psession.ProviderType,
) (*idtransform.TransformationResult, error) {
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)
}
if oldTransformedUsername != transformationResult.Username {
return nil, 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)
}
return transformationResult, nil
}
@@ -8,6 +8,7 @@ import (
"net/http"
"github.com/ory/fosite"
errorsx "github.com/pkg/errors"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/internal/authenticators"
@@ -137,6 +138,73 @@ func (p *FederationDomainResolvedLDAPIdentityProvider) HandleCallback(
return nil, httperr.New(http.StatusInternalServerError, "not supported for this type of identity provider")
}
func (p *FederationDomainResolvedLDAPIdentityProvider) UpstreamRefresh(
ctx context.Context,
session *psession.PinnipedSession,
groupsWillBeIgnored bool,
) (refreshedGroups []string, err error) {
s := session.Custom
var dn string
if s.ProviderType == psession.ProviderTypeLDAP {
dn = s.LDAP.UserDN
} else if s.ProviderType == psession.ProviderTypeActiveDirectory {
dn = s.ActiveDirectory.UserDN
}
validLDAP := s.ProviderType == psession.ProviderTypeLDAP && s.LDAP != nil && s.LDAP.UserDN != ""
validAD := s.ProviderType == psession.ProviderTypeActiveDirectory && s.ActiveDirectory != nil && s.ActiveDirectory.UserDN != ""
if !(validLDAP || validAD) {
return nil, errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
var additionalAttributes map[string]string
if s.ProviderType == psession.ProviderTypeLDAP {
additionalAttributes = s.LDAP.ExtraRefreshAttributes
} else {
additionalAttributes = s.ActiveDirectory.ExtraRefreshAttributes
}
if session.IDTokenClaims().AuthTime.IsZero() {
return nil, errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
oldTransformedUsername := session.Custom.Username
oldUntransformedUsername := session.Custom.UpstreamUsername
oldUntransformedGroups := session.Custom.UpstreamGroups
plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
refreshedUntransformedGroups, err := p.Provider.PerformRefresh(ctx, upstreamprovider.RefreshAttributes{
Username: oldUntransformedUsername,
Subject: session.Fosite.Claims.Subject,
DN: dn,
Groups: oldUntransformedGroups,
AdditionalAttributes: additionalAttributes,
SkipGroups: groupsWillBeIgnored,
}, p.DisplayName)
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHint(
"Upstream refresh failed.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
transformationResult, err := resolvedprovider.TransformRefreshedIdentity(ctx,
p.Transforms,
oldTransformedUsername,
oldUntransformedUsername, // LDAP PerformRefresh validates that the username did not change, so this is also the refreshed upstream username
refreshedUntransformedGroups,
s.ProviderName,
s.ProviderType,
)
if err != nil {
return nil, err
}
return transformationResult.Groups, nil
}
func makeDownstreamLDAPOrADCustomSessionData(
ldapUpstream upstreamprovider.UpstreamLDAPIdentityProviderI,
idpType psession.ProviderType,
@@ -12,11 +12,13 @@ import (
"time"
"github.com/ory/fosite"
errorsx "github.com/pkg/errors"
"golang.org/x/oauth2"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"go.pinniped.dev/generated/latest/apis/supervisor/idpdiscovery/v1alpha1"
"go.pinniped.dev/generated/latest/apis/supervisor/oidc"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/federationdomain/downstreamsession"
"go.pinniped.dev/internal/federationdomain/downstreamsubject"
@@ -215,6 +217,169 @@ func (p *FederationDomainResolvedOIDCIdentityProvider) HandleCallback(
}, nil
}
func (p *FederationDomainResolvedOIDCIdentityProvider) UpstreamRefresh(
ctx context.Context,
session *psession.PinnipedSession,
groupsWillBeIgnored bool,
) (refreshedGroups []string, err error) {
s := session.Custom
if s.OIDC == nil {
return nil, errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
accessTokenStored := s.OIDC.UpstreamAccessToken != ""
refreshTokenStored := s.OIDC.UpstreamRefreshToken != ""
exactlyOneTokenStored := (accessTokenStored || refreshTokenStored) && !(accessTokenStored && refreshTokenStored)
if !exactlyOneTokenStored {
return nil, errorsx.WithStack(resolvedprovider.ErrMissingUpstreamSessionInternalError())
}
oldTransformedUsername := session.Custom.Username
oldUntransformedUsername := session.Custom.UpstreamUsername
oldUntransformedGroups := session.Custom.UpstreamGroups
plog.Debug("attempting upstream refresh request",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
var tokens *oauth2.Token
if refreshTokenStored {
tokens, err = p.Provider.PerformRefresh(ctx, s.OIDC.UpstreamRefreshToken)
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHint(
"Upstream refresh failed.",
).WithTrace(err).WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
} else {
tokens = &oauth2.Token{AccessToken: s.OIDC.UpstreamAccessToken}
}
// Upstream refresh may or may not return a new ID token. From the spec:
// "the response body is the Token Response of Section 3.1.3.3 except that it might not contain an id_token."
// https://openid.net/specs/openid-connect-core-1_0.html#RefreshTokenResponse
_, hasIDTok := tokens.Extra("id_token").(string)
// We may or may not have an ID token, and we may or may not have a userinfo endpoint to call for more claims.
// Use what we can (one, both, or neither) and return the union of their claims. If we stored an access token,
// then require that the userinfo endpoint exists and returns a successful response, or else we would have no
// way to check that the user's session was not revoked on the server.
// The spec is not 100% clear about whether an ID token from the refresh flow should include a nonce, and at
// least some providers do not include one, so we skip the nonce validation here (but not other validations).
validatedTokens, err := p.Provider.ValidateTokenAndMergeWithUserInfo(ctx, tokens, "", hasIDTok, accessTokenStored)
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh returned an invalid ID token or UserInfo response.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
mergedClaims := validatedTokens.IDToken.Claims
// To the extent possible, check that the user's basic identity hasn't changed. We check that their downstream
// username has not changed separately below, as part of reapplying the transformations.
err = validateSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims, session)
if err != nil {
return nil, err
}
var refreshedUntransformedGroups []string
if !groupsWillBeIgnored {
// If possible, update the user's group memberships. The configured groups claim name (if there is one) may or
// may not be included in the newly fetched and merged claims. It could be missing due to a misconfiguration of the
// claim name. It could also be missing because the claim was originally found in the ID token during login, but
// now we might not have a refreshed ID token.
// If the claim is found, then use it to update the user's group membership in the session.
// If the claim is not found, then we have no new information about groups, so skip updating the group membership
// and let any old groups memberships in the session remain.
refreshedUntransformedGroups, err = getGroupsFromUpstreamIDToken(p.Provider, mergedClaims)
if err != nil {
return nil, resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh error while extracting groups claim.").WithTrace(err).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
}
// It's possible that a username wasn't returned by the upstream provider during refresh,
// but if it is, verify that the transformed version of it hasn't changed.
refreshedUntransformedUsername, hasRefreshedUntransformedUsername := getString(mergedClaims, p.Provider.GetUsernameClaim())
if !hasRefreshedUntransformedUsername {
// If we could not get a new username, then we still need the untransformed username to be able to
// run the transformations again, so fetch the original untransformed username from the session.
refreshedUntransformedUsername = oldUntransformedUsername
}
if refreshedUntransformedGroups == 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.
refreshedUntransformedGroups = oldUntransformedGroups
}
transformationResult, err := resolvedprovider.TransformRefreshedIdentity(ctx,
p.Transforms,
oldTransformedUsername,
refreshedUntransformedUsername,
refreshedUntransformedGroups,
s.ProviderName,
s.ProviderType,
)
if err != nil {
return nil, err
}
// Upstream refresh may or may not return a new refresh token. If we got a new refresh token, then update it in
// the user's session. If we did not get a new refresh token, then keep the old one in the session by avoiding
// overwriting the old one.
if tokens.RefreshToken != "" {
plog.Debug("upstream refresh request returned a new refresh token",
"providerName", s.ProviderName, "providerType", s.ProviderType, "providerUID", s.ProviderUID)
s.OIDC.UpstreamRefreshToken = tokens.RefreshToken
}
return transformationResult.Groups, nil
}
func validateSubjectAndIssuerUnchangedSinceInitialLogin(mergedClaims map[string]interface{}, session *psession.PinnipedSession) error {
s := session.Custom
// If we have any claims at all, we better have a subject, and it better match the previous value.
// but it's possible that we don't because both returning a new id token on refresh and having a userinfo
// endpoint are optional.
if len(mergedClaims) == 0 {
return nil
}
newSub, hasSub := getString(mergedClaims, oidcapi.IDTokenClaimSubject)
if !hasSub {
return resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh not found")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
if s.OIDC.UpstreamSubject != newSub {
return resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("subject in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
newIssuer, hasIssuer := getString(mergedClaims, oidcapi.IDTokenClaimIssuer)
// It's possible that an issuer wasn't returned by the upstream provider during refresh,
// but if it is, verify that it hasn't changed.
if hasIssuer && s.OIDC.UpstreamIssuer != newIssuer {
return resolvedprovider.ErrUpstreamRefreshError().WithHintf(
"Upstream refresh failed.").WithTrace(errors.New("issuer in upstream refresh does not match previous value")).
WithDebugf("provider name: %q, provider type: %q", s.ProviderName, s.ProviderType)
}
return nil
}
func getString(m map[string]interface{}, key string) (string, bool) {
val, ok := m[key].(string)
return val, ok
}
func makeDownstreamOIDCCustomSessionData(
oidcUpstream upstreamprovider.UpstreamOIDCIdentityProviderI,
token *oidctypes.Token,
@@ -291,7 +456,7 @@ func getDownstreamIdentityFromUpstreamIDToken(
return "", "", nil, err
}
groups, err := GetGroupsFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
groups, err := getGroupsFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
if err != nil {
return "", "", nil, err
}
@@ -413,10 +578,10 @@ func downstreamUsernameFromUpstreamOIDCSubject(upstreamIssuerAsString string, up
)
}
// GetGroupsFromUpstreamIDToken returns mapped group names coerced into a slice of strings.
// getGroupsFromUpstreamIDToken returns mapped group names coerced into a slice of strings.
// It returns nil when there is no configured groups claim name, or then when the configured claim name is not found
// in the provided map of claims. It returns an error when the claim exists but its value cannot be parsed.
func GetGroupsFromUpstreamIDToken(
func getGroupsFromUpstreamIDToken(
upstreamIDPConfig upstreamprovider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) ([]string, error) {