2022-01-07 23:04:58 +00:00
// Copyright 2021-2022 the Pinniped contributors. All Rights Reserved.
2021-06-30 22:02:14 +00:00
// SPDX-License-Identifier: Apache-2.0
// Package downstreamsession provides some shared helpers for creating downstream OIDC sessions.
package downstreamsession
import (
2022-01-11 19:00:54 +00:00
"errors"
2021-08-12 17:00:18 +00:00
"fmt"
"net/url"
2021-06-30 22:02:14 +00:00
"time"
2021-10-20 11:59:24 +00:00
coreosoidc "github.com/coreos/go-oidc/v3/oidc"
2021-06-30 22:02:14 +00:00
"github.com/ory/fosite"
"github.com/ory/fosite/handler/openid"
"github.com/ory/fosite/token/jwt"
2022-01-18 23:34:19 +00:00
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2021-06-30 22:02:14 +00:00
2021-08-18 19:06:46 +00:00
"go.pinniped.dev/internal/constable"
2021-06-30 22:02:14 +00:00
"go.pinniped.dev/internal/oidc"
2021-08-12 17:00:18 +00:00
"go.pinniped.dev/internal/oidc/provider"
"go.pinniped.dev/internal/plog"
2021-10-06 22:28:13 +00:00
"go.pinniped.dev/internal/psession"
2022-01-11 19:00:54 +00:00
"go.pinniped.dev/pkg/oidcclient/oidctypes"
2021-08-12 17:00:18 +00:00
)
const (
// The name of the email claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailClaimName = "email"
// The name of the email_verified claim from https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
emailVerifiedClaimName = "email_verified"
2021-08-18 19:06:46 +00:00
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" )
2021-06-30 22:02:14 +00:00
)
// MakeDownstreamSession creates a downstream OIDC session.
2021-10-08 22:48:21 +00:00
func MakeDownstreamSession ( subject string , username string , groups [ ] string , custom * psession . CustomSessionData ) * psession . PinnipedSession {
2021-06-30 22:02:14 +00:00
now := time . Now ( ) . UTC ( )
2021-10-06 22:28:13 +00:00
openIDSession := & psession . PinnipedSession {
Fosite : & openid . DefaultSession {
Claims : & jwt . IDTokenClaims {
Subject : subject ,
RequestedAt : now ,
AuthTime : now ,
} ,
2021-06-30 22:02:14 +00:00
} ,
2021-10-08 22:48:21 +00:00
Custom : custom ,
2021-06-30 22:02:14 +00:00
}
if groups == nil {
groups = [ ] string { }
}
2021-10-06 22:28:13 +00:00
openIDSession . IDTokenClaims ( ) . Extra = map [ string ] interface { } {
2021-06-30 22:02:14 +00:00
oidc . DownstreamUsernameClaim : username ,
oidc . DownstreamGroupsClaim : groups ,
}
return openIDSession
}
2022-01-11 19:00:54 +00:00
func MakeDownstreamOIDCCustomSessionData ( oidcUpstream provider . UpstreamOIDCIdentityProviderI , token * oidctypes . Token ) ( * psession . CustomSessionData , error ) {
upstreamSubject , err := ExtractStringClaimValue ( oidc . IDTokenSubjectClaim , oidcUpstream . GetName ( ) , token . IDToken . Claims )
if err != nil {
return nil , err
}
upstreamIssuer , err := ExtractStringClaimValue ( oidc . IDTokenIssuerClaim , oidcUpstream . GetName ( ) , token . IDToken . Claims )
if err != nil {
return nil , err
}
customSessionData := & psession . CustomSessionData {
ProviderUID : oidcUpstream . GetResourceUID ( ) ,
ProviderName : oidcUpstream . GetName ( ) ,
ProviderType : psession . ProviderTypeOIDC ,
OIDC : & psession . OIDCSessionData {
UpstreamIssuer : upstreamIssuer ,
UpstreamSubject : upstreamSubject ,
} ,
}
2022-01-11 23:40:38 +00:00
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 ( ) ,
}
2022-01-11 19:00:54 +00:00
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
2022-01-11 23:40:38 +00:00
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 ... )
2022-01-11 19:00:54 +00:00
customSessionData . OIDC . UpstreamAccessToken = token . AccessToken . Token
2022-01-18 23:34:19 +00:00
// 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." )
}
2022-01-11 19:00:54 +00:00
default :
2022-01-11 23:40:38 +00:00
plog . Warning ( "refresh token and access token not returned by upstream provider during login. " + pleaseCheck , logKV ... )
2022-01-11 19:00:54 +00:00
return nil , errors . New ( "neither access token nor refresh token returned by upstream provider" )
}
2022-01-11 23:40:38 +00:00
2022-01-11 19:00:54 +00:00
return customSessionData , nil
}
2021-06-30 22:02:14 +00:00
// GrantScopesIfRequested auto-grants the scopes for which we do not require end-user approval, if they were requested.
func GrantScopesIfRequested ( authorizeRequester fosite . AuthorizeRequester ) {
2021-10-20 11:59:24 +00:00
oidc . GrantScopeIfRequested ( authorizeRequester , coreosoidc . ScopeOpenID )
oidc . GrantScopeIfRequested ( authorizeRequester , coreosoidc . ScopeOfflineAccess )
2021-06-30 22:02:14 +00:00
oidc . GrantScopeIfRequested ( authorizeRequester , "pinniped:request-audience" )
}
2021-08-12 17:00:18 +00:00
2021-08-17 20:14:09 +00:00
// GetDownstreamIdentityFromUpstreamIDToken returns the mapped subject, username, and group names, in that order.
func GetDownstreamIdentityFromUpstreamIDToken (
2021-08-12 17:00:18 +00:00
upstreamIDPConfig provider . UpstreamOIDCIdentityProviderI ,
idTokenClaims map [ string ] interface { } ,
2021-08-17 20:14:09 +00:00
) ( string , string , [ ] string , error ) {
subject , username , err := getSubjectAndUsernameFromUpstreamIDToken ( upstreamIDPConfig , idTokenClaims )
if err != nil {
return "" , "" , nil , err
2021-08-12 17:00:18 +00:00
}
2022-01-15 00:38:21 +00:00
groups , err := GetGroupsFromUpstreamIDToken ( upstreamIDPConfig , idTokenClaims )
2021-08-17 20:14:09 +00:00
if err != nil {
return "" , "" , nil , err
2021-08-12 17:00:18 +00:00
}
2021-08-17 20:14:09 +00:00
return subject , username , groups , err
}
2021-08-12 17:00:18 +00:00
2021-08-17 20:14:09 +00:00
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.
2022-01-07 23:04:58 +00:00
upstreamIssuer , err := ExtractStringClaimValue ( oidc . IDTokenIssuerClaim , upstreamIDPConfig . GetName ( ) , idTokenClaims )
2021-08-17 20:14:09 +00:00
if err != nil {
return "" , "" , err
}
2022-01-07 23:04:58 +00:00
upstreamSubject , err := ExtractStringClaimValue ( oidc . IDTokenSubjectClaim , upstreamIDPConfig . GetName ( ) , idTokenClaims )
2021-08-17 20:14:09 +00:00
if err != nil {
return "" , "" , err
}
2021-12-16 20:53:49 +00:00
subject := downstreamSubjectFromUpstreamOIDC ( upstreamIssuer , upstreamSubject )
2021-08-12 17:00:18 +00:00
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 ,
)
2021-08-18 19:06:46 +00:00
return "" , "" , emailVerifiedClaimInvalidFormatErr
2021-08-12 17:00:18 +00:00
}
if ! emailVerified {
plog . Warning (
"username claim configured as \"email\" and upstream email_verified claim has false value" ,
"upstreamName" , upstreamIDPConfig . GetName ( ) ,
"configuredUsernameClaim" , usernameClaimName ,
)
2021-08-18 19:06:46 +00:00
return "" , "" , emailVerifiedClaimFalseErr
2021-08-12 17:00:18 +00:00
}
}
2022-01-07 23:04:58 +00:00
username , err := ExtractStringClaimValue ( usernameClaimName , upstreamIDPConfig . GetName ( ) , idTokenClaims )
2021-08-17 20:14:09 +00:00
if err != nil {
return "" , "" , err
}
return subject , username , nil
}
2022-01-07 23:04:58 +00:00
func ExtractStringClaimValue ( claimName string , upstreamIDPName string , idTokenClaims map [ string ] interface { } ) ( string , error ) {
2021-08-17 20:14:09 +00:00
value , ok := idTokenClaims [ claimName ]
2021-08-12 17:00:18 +00:00
if ! ok {
plog . Warning (
2021-08-17 20:14:09 +00:00
"required claim in upstream ID token missing" ,
"upstreamName" , upstreamIDPName ,
"claimName" , claimName ,
2021-08-12 17:00:18 +00:00
)
2021-08-18 19:06:46 +00:00
return "" , requiredClaimMissingErr
2021-08-12 17:00:18 +00:00
}
2021-08-17 20:14:09 +00:00
valueAsString , ok := value . ( string )
2021-08-12 17:00:18 +00:00
if ! ok {
plog . Warning (
2021-08-17 20:14:09 +00:00
"required claim in upstream ID token is not a string value" ,
"upstreamName" , upstreamIDPName ,
"claimName" , claimName ,
2021-08-12 17:00:18 +00:00
)
2021-08-18 19:06:46 +00:00
return "" , requiredClaimInvalidFormatErr
2021-08-12 17:00:18 +00:00
}
2021-08-17 20:14:09 +00:00
if valueAsString == "" {
plog . Warning (
"required claim in upstream ID token has an empty string value" ,
"upstreamName" , upstreamIDPName ,
"claimName" , claimName ,
)
2021-08-18 19:06:46 +00:00
return "" , requiredClaimEmptyErr
2021-08-17 20:14:09 +00:00
}
return valueAsString , nil
2021-08-12 17:00:18 +00:00
}
2021-10-25 21:25:43 +00:00
func DownstreamLDAPSubject ( uid string , ldapURL url . URL ) string {
q := ldapURL . Query ( )
q . Set ( oidc . IDTokenSubjectClaim , uid )
ldapURL . RawQuery = q . Encode ( )
return ldapURL . String ( )
}
2021-12-16 20:53:49 +00:00
func downstreamSubjectFromUpstreamOIDC ( upstreamIssuerAsString string , upstreamSubject string ) string {
2021-08-12 17:00:18 +00:00
return fmt . Sprintf ( "%s?%s=%s" , upstreamIssuerAsString , oidc . IDTokenSubjectClaim , url . QueryEscape ( upstreamSubject ) )
}
2022-01-15 00:38:21 +00:00
// 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 (
2021-08-12 17:00:18 +00:00
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 ,
)
2021-08-18 19:06:46 +00:00
return nil , requiredClaimInvalidFormatErr
2021-08-12 17:00:18 +00:00
}
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
}