ContainerImage.Pinniped/internal/oidc/downstreamsession/downstream_session.go
Joshua Casey bfe8dc11ce wip
2023-01-11 17:45:16 -06:00

407 lines
15 KiB
Go

// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
// Package downstreamsession provides some shared helpers for creating downstream OIDC sessions.
package downstreamsession
import (
"errors"
"fmt"
"net/url"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/strings/slices"
oidcapi "go.pinniped.dev/generated/latest/apis/supervisor/oidc"
"go.pinniped.dev/internal/authenticators"
"go.pinniped.dev/internal/constable"
"go.pinniped.dev/internal/oidc"
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
"go.pinniped.dev/internal/psession"
"go.pinniped.dev/pkg/oidcclient/oidctypes"
)
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = oidcapi.ScopeEmail
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
requiredClaimMissingErr = constable.Error("required claim in upstream ID token missing")
requiredClaimInvalidFormatErr = constable.Error("required claim in upstream ID token has invalid format")
requiredClaimEmptyErr = constable.Error("required claim in upstream ID token is empty")
emailVerifiedClaimInvalidFormatErr = constable.Error("email_verified claim in upstream ID token has invalid format")
emailVerifiedClaimFalseErr = constable.Error("email_verified claim in upstream ID token has false value")
)
// MakeDownstreamSession creates a downstream OIDC session.
func MakeDownstreamSession(
subject string,
username string,
groups []string,
grantedScopes []string,
clientID string,
custom *psession.CustomSessionData,
additionalClaims map[string]interface{},
) *psession.PinnipedSession {
now := time.Now().UTC()
openIDSession := &psession.PinnipedSession{
Fosite: &openid.DefaultSession{
Claims: &jwt.IDTokenClaims{
Subject: subject,
RequestedAt: now,
AuthTime: now,
},
},
Custom: custom,
}
if groups == nil {
groups = []string{}
}
extras := map[string]interface{}{}
extras[oidcapi.IDTokenClaimAuthorizedParty] = clientID
if slices.Contains(grantedScopes, oidcapi.ScopeUsername) {
extras[oidcapi.IDTokenClaimUsername] = username
}
if slices.Contains(grantedScopes, oidcapi.ScopeGroups) {
extras[oidcapi.IDTokenClaimGroups] = groups
}
if len(additionalClaims) > 0 {
// TODO: make "additionalClaims" a string constant, possibly in oidcapi?
extras["additionalClaims"] = additionalClaims
}
openIDSession.IDTokenClaims().Extra = extras
return openIDSession
}
func MakeDownstreamLDAPOrADCustomSessionData(
ldapUpstream provider.UpstreamLDAPIdentityProviderI,
idpType psession.ProviderType,
authenticateResponse *authenticators.Response,
username string,
) *psession.CustomSessionData {
customSessionData := &psession.CustomSessionData{
Username: username,
ProviderUID: ldapUpstream.GetResourceUID(),
ProviderName: ldapUpstream.GetName(),
ProviderType: idpType,
}
if idpType == psession.ProviderTypeLDAP {
customSessionData.LDAP = &psession.LDAPSessionData{
UserDN: authenticateResponse.DN,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
}
}
if idpType == psession.ProviderTypeActiveDirectory {
customSessionData.ActiveDirectory = &psession.ActiveDirectorySessionData{
UserDN: authenticateResponse.DN,
ExtraRefreshAttributes: authenticateResponse.ExtraRefreshAttributes,
}
}
return customSessionData
}
func MakeDownstreamOIDCCustomSessionData(
oidcUpstream provider.UpstreamOIDCIdentityProviderI,
token *oidctypes.Token,
username string,
) (*psession.CustomSessionData, error) {
upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, oidcUpstream.GetName(), token.IDToken.Claims)
if err != nil {
return nil, err
}
customSessionData := &psession.CustomSessionData{
Username: username,
ProviderUID: oidcUpstream.GetResourceUID(),
ProviderName: oidcUpstream.GetName(),
ProviderType: psession.ProviderTypeOIDC,
OIDC: &psession.OIDCSessionData{
UpstreamIssuer: upstreamIssuer,
UpstreamSubject: upstreamSubject,
},
}
const pleaseCheck = "please check configuration of OIDCIdentityProvider and the client in the " +
"upstream provider's API/UI and try to get a refresh token if possible"
logKV := []interface{}{
"upstreamName", oidcUpstream.GetName(),
"scopes", oidcUpstream.GetScopes(),
"additionalParams", oidcUpstream.GetAdditionalAuthcodeParams(),
}
hasRefreshToken := token.RefreshToken != nil && token.RefreshToken.Token != ""
hasAccessToken := token.AccessToken != nil && token.AccessToken.Token != ""
switch {
case hasRefreshToken: // we prefer refresh tokens, so check for this first
customSessionData.OIDC.UpstreamRefreshToken = token.RefreshToken.Token
case hasAccessToken: // as a fallback, we can use the access token as long as there is a userinfo endpoint
if !oidcUpstream.HasUserInfoURL() {
plog.Warning("access token was returned by upstream provider during login without a refresh token "+
"and there was no userinfo endpoint available on the provider. "+pleaseCheck, logKV...)
return nil, errors.New("access token was returned by upstream provider but there was no userinfo endpoint")
}
plog.Info("refresh token not returned by upstream provider during login, using access token instead. "+pleaseCheck, logKV...)
customSessionData.OIDC.UpstreamAccessToken = token.AccessToken.Token
// When we are in a flow where we will be performing access token based refresh, issue a warning to the client if the access
// token lifetime is very short, since that would mean that the user's session is very short.
// The warnings are stored here and will be processed by the token handler.
threeHoursFromNow := metav1.NewTime(time.Now().Add(3 * time.Hour))
if !token.AccessToken.Expiry.IsZero() && token.AccessToken.Expiry.Before(&threeHoursFromNow) {
customSessionData.Warnings = append(customSessionData.Warnings, "Access token from identity provider has lifetime of less than 3 hours. Expect frequent prompts to log in.")
}
default:
plog.Warning("refresh token and access token not returned by upstream provider during login. "+pleaseCheck, logKV...)
return nil, errors.New("neither access token nor refresh token returned by upstream provider")
}
return customSessionData, nil
}
// AutoApproveScopes auto-grants the scopes which we support and for which we do not require end-user approval,
// if they were requested. This should only be called after it has been validated that the client is allowed to request
// the scopes that it requested (which is a check performed by fosite).
func AutoApproveScopes(authorizeRequester fosite.AuthorizeRequester) {
for _, scope := range []string{
oidcapi.ScopeOpenID,
oidcapi.ScopeOfflineAccess,
oidcapi.ScopeRequestAudience,
oidcapi.ScopeUsername,
oidcapi.ScopeGroups,
} {
oidc.GrantScopeIfRequested(authorizeRequester, scope)
}
// For backwards-compatibility with old pinniped CLI binaries which never request the username and groups scopes
// (because those scopes did not exist yet when those CLIs were released), grant/approve the username and groups
// scopes even if the CLI did not request them. Basically, pretend that the CLI requested them and auto-approve
// them. Newer versions of the CLI binaries will request these scopes, so after enough time has passed that
// we can assume the old versions of the CLI are no longer in use in the wild, then we can remove this code and
// just let the above logic handle all clients.
if authorizeRequester.GetClient().GetID() == oidcapi.ClientIDPinnipedCLI {
authorizeRequester.GrantScope(oidcapi.ScopeUsername)
authorizeRequester.GrantScope(oidcapi.ScopeGroups)
}
}
// GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order.
func GetDownstreamIdentityFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) (string, string, []string, error) {
subject, username, err := getSubjectAndUsernameFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
if err != nil {
return "", "", nil, err
}
groups, err := GetGroupsFromUpstreamIDToken(upstreamIDPConfig, idTokenClaims)
if err != nil {
return "", "", nil, err
}
return subject, username, groups, err
}
// MapAdditionalClaimsFromUpstreamIDToken returns the additionalClaims mapped from the upstream token, if any.
func MapAdditionalClaimsFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) map[string]interface{} {
mapped := make(map[string]interface{}, len(upstreamIDPConfig.GetAdditionalClaimMappings()))
for downstreamClaimName, upstreamClaimName := range upstreamIDPConfig.GetAdditionalClaimMappings() {
upstreamClaimValue, ok := idTokenClaims[upstreamClaimName]
if !ok {
plog.Warning(
"additionalClaims mapping claim in upstream ID token missing",
"upstreamName", upstreamIDPConfig.GetName(),
"claimName", upstreamClaimName,
)
} else {
mapped[downstreamClaimName] = upstreamClaimValue
}
}
return mapped
}
func getSubjectAndUsernameFromUpstreamIDToken(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) (string, string, error) {
// The spec says the "sub" claim is only unique per issuer,
// so we will prepend the issuer string to make it globally unique.
upstreamIssuer, err := ExtractStringClaimValue(oidcapi.IDTokenClaimIssuer, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
return "", "", err
}
upstreamSubject, err := ExtractStringClaimValue(oidcapi.IDTokenClaimSubject, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
return "", "", err
}
subject := downstreamSubjectFromUpstreamOIDC(upstreamIssuer, upstreamSubject)
usernameClaimName := upstreamIDPConfig.GetUsernameClaim()
if usernameClaimName == "" {
return subject, subject, nil
}
// If the upstream username claim is configured to be the special "email" claim and the upstream "email_verified"
// claim is present, then validate that the "email_verified" claim is true.
emailVerifiedAsInterface, ok := idTokenClaims[emailVerifiedClaimName]
if usernameClaimName == emailClaimName && ok {
emailVerified, ok := emailVerifiedAsInterface.(bool)
if !ok {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim is not a boolean",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
"emailVerifiedClaim", emailVerifiedAsInterface,
)
return "", "", emailVerifiedClaimInvalidFormatErr
}
if !emailVerified {
plog.Warning(
"username claim configured as \"email\" and upstream email_verified claim has false value",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredUsernameClaim", usernameClaimName,
)
return "", "", emailVerifiedClaimFalseErr
}
}
username, err := ExtractStringClaimValue(usernameClaimName, upstreamIDPConfig.GetName(), idTokenClaims)
if err != nil {
return "", "", err
}
return subject, username, nil
}
func ExtractStringClaimValue(claimName string, upstreamIDPName string, idTokenClaims map[string]interface{}) (string, error) {
value, ok := idTokenClaims[claimName]
if !ok {
plog.Warning(
"required claim in upstream ID token missing",
"upstreamName", upstreamIDPName,
"claimName", claimName,
)
return "", requiredClaimMissingErr
}
valueAsString, ok := value.(string)
if !ok {
plog.Warning(
"required claim in upstream ID token is not a string value",
"upstreamName", upstreamIDPName,
"claimName", claimName,
)
return "", requiredClaimInvalidFormatErr
}
if valueAsString == "" {
plog.Warning(
"required claim in upstream ID token has an empty string value",
"upstreamName", upstreamIDPName,
"claimName", claimName,
)
return "", requiredClaimEmptyErr
}
return valueAsString, nil
}
func DownstreamSubjectFromUpstreamLDAP(ldapUpstream provider.UpstreamLDAPIdentityProviderI, authenticateResponse *authenticators.Response) string {
ldapURL := *ldapUpstream.GetURL()
return DownstreamLDAPSubject(authenticateResponse.User.GetUID(), ldapURL)
}
func DownstreamLDAPSubject(uid string, ldapURL url.URL) string {
q := ldapURL.Query()
q.Set(oidcapi.IDTokenClaimSubject, uid)
ldapURL.RawQuery = q.Encode()
return ldapURL.String()
}
func downstreamSubjectFromUpstreamOIDC(upstreamIssuerAsString string, upstreamSubject string) string {
return fmt.Sprintf("%s?%s=%s", upstreamIssuerAsString, oidcapi.IDTokenClaimSubject, url.QueryEscape(upstreamSubject))
}
// 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(
upstreamIDPConfig provider.UpstreamOIDCIdentityProviderI,
idTokenClaims map[string]interface{},
) ([]string, error) {
groupsClaimName := upstreamIDPConfig.GetGroupsClaim()
if groupsClaimName == "" {
return nil, nil
}
groupsAsInterface, ok := idTokenClaims[groupsClaimName]
if !ok {
plog.Warning(
"no groups claim in upstream ID token",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, nil // the upstream IDP may have omitted the claim if the user has no groups
}
groupsAsArray, okAsArray := extractGroups(groupsAsInterface)
if !okAsArray {
plog.Warning(
"groups claim in upstream ID token has invalid format",
"upstreamName", upstreamIDPConfig.GetName(),
"configuredGroupsClaim", groupsClaimName,
)
return nil, requiredClaimInvalidFormatErr
}
return groupsAsArray, nil
}
func extractGroups(groupsAsInterface interface{}) ([]string, bool) {
groupsAsString, okAsString := groupsAsInterface.(string)
if okAsString {
return []string{groupsAsString}, true
}
groupsAsStringArray, okAsStringArray := groupsAsInterface.([]string)
if okAsStringArray {
return groupsAsStringArray, true
}
groupsAsInterfaceArray, okAsArray := groupsAsInterface.([]interface{})
if !okAsArray {
return nil, false
}
var groupsAsStrings []string
for _, groupAsInterface := range groupsAsInterfaceArray {
groupAsString, okAsString := groupAsInterface.(string)
if !okAsString {
return nil, false
}
if groupAsString != "" {
groupsAsStrings = append(groupsAsStrings, groupAsString)
}
}
return groupsAsStrings, true
}