diff --git a/app/handlers/common.go b/app/handlers/common.go index 4affddb..ac14d9b 100644 --- a/app/handlers/common.go +++ b/app/handlers/common.go @@ -1,22 +1,26 @@ package handlers import ( + "bytes" "context" "encoding/base64" + "encoding/json" "net/http" "net/url" "github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jwt" "github.com/lestrrat-go/jwx/jwt/openid" + log "github.com/sirupsen/logrus" "git.cacert.org/oidc_login/app/services" + "git.cacert.org/oidc_login/common/models" commonServices "git.cacert.org/oidc_login/common/services" ) const sessionName = "resource_session" -func Authenticate(ctx context.Context, clientId string) func(http.Handler) http.Handler { +func Authenticate(ctx context.Context, logger *log.Logger, clientId string) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { session, err := services.GetSessionStore().Get(r, sessionName) @@ -43,6 +47,7 @@ func Authenticate(ctx context.Context, clientId string) func(http.Handler) http. queryValues.Set("response_type", "code") queryValues.Set("scope", "openid offline_access profile email") queryValues.Set("state", base64.URLEncoding.EncodeToString(commonServices.GenerateKey(8))) + queryValues.Set("claims", getRequestedClaims(logger)) authUrl.RawQuery = queryValues.Encode() w.Header().Set("Location", authUrl.String()) @@ -51,6 +56,22 @@ func Authenticate(ctx context.Context, clientId string) func(http.Handler) http. } } +func getRequestedClaims(logger *log.Logger) string { + claims := make(models.OIDCClaimsRequest) + claims["userinfo"] = make(models.ClaimElement) + essentialItem := make(models.IndividualClaimRequest) + essentialItem["essential"] = true + claims["userinfo"]["https://cacert.localhost/groups"] = &essentialItem + + target := make([]byte, 0) + buf := bytes.NewBuffer(target) + enc := json.NewEncoder(buf) + if err := enc.Encode(claims); err != nil { + logger.Warnf("could not encode claims request parameter: %v", err) + } + return buf.String() +} + func ParseIdToken(token string, keySet *jwk.Set) (openid.Token, error) { if parsedIdToken, err := jwt.ParseString(token, jwt.WithKeySet(keySet), jwt.WithOpenIDClaims()); err != nil { return nil, err diff --git a/cmd/app/main.go b/cmd/app/main.go index 03d7f7b..db2b514 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -87,7 +87,7 @@ func main() { services.InitSessionStore(logger, sessionPath, sessionAuthKey, sessionEncKey) - authMiddleware := handlers.Authenticate(ctx, oidcClientId) + authMiddleware := handlers.Authenticate(ctx, logger, oidcClientId) serverAddr := fmt.Sprintf("%s:%d", config.String("server.name"), config.Int("server.port")) diff --git a/common/models/oidc.go b/common/models/oidc.go new file mode 100644 index 0000000..9d40e76 --- /dev/null +++ b/common/models/oidc.go @@ -0,0 +1,158 @@ +/* +This package contains data models. +*/ +package models + +// An individual claim request. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests +type IndividualClaimRequest map[string]interface{} + +// ClaimElement represents a claim element +type ClaimElement map[string]*IndividualClaimRequest + +// OIDCClaimsRequest the claims request parameter sent with the authorization request. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +type OIDCClaimsRequest map[string]ClaimElement + +// GetUserInfo extracts the userinfo claim element from the request. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +// +// Requests that the listed individual Claims be returned from the UserInfo +// Endpoint. If present, the listed Claims are being requested to be added to +// any Claims that are being requested using scope values. If not present, the +// Claims being requested from the UserInfo Endpoint are only those requested +// using scope values. +// +// When the userinfo member is used, the request MUST also use a response_type +// value that results in an Access Token being issued to the Client for use at +// the UserInfo Endpoint. +func (r OIDCClaimsRequest) GetUserInfo() *ClaimElement { + if userInfo, ok := r["userinfo"]; ok { + return &userInfo + } + return nil +} + +// GetIDToken extracts the id_token claim element from the request. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +// +// Requests that the listed individual Claims be returned in the ID Token. If +// present, the listed Claims are being requested to be added to the default +// Claims in the ID Token. If not present, the default ID Token Claims are +// requested, as per the ID Token definition in Section 2 and per the +// additional per-flow ID Token requirements in Sections 3.1.3.6, 3.2.2.10, +// 3.3.2.11, and 3.3.3.6. +func (r OIDCClaimsRequest) GetIDToken() *ClaimElement { + if idToken, ok := r["id_token"]; ok { + return &idToken + } + return nil +} + +// Checks whether the individual claim is an essential claim. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests +// +// Indicates whether the Claim being requested is an Essential Claim. If the +// value is true, this indicates that the Claim is an Essential Claim. For +// instance, the Claim request: +// +// "auth_time": {"essential": true} +// +// can be used to specify that it is Essential to return an auth_time Claim +// Value. If the value is false, it indicates that it is a Voluntary Claim. +// The default is false. +// +// By requesting Claims as Essential Claims, the RP indicates to the End-User +// that releasing these Claims will ensure a smooth authorization for the +// specific task requested by the End-User. +// +// Note that even if the Claims are not available because the End-User did not +// authorize their release or they are not present, the Authorization Server +// MUST NOT generate an error when Claims are not returned, whether they are +// Essential or Voluntary, unless otherwise specified in the description of +// the specific claim. +func (i IndividualClaimRequest) IsEssential() bool { + if essential, ok := i["essential"]; ok { + return essential.(bool) + } + return false +} + +// Returns the wanted value for an individual claim request. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests +// +// Requests that the Claim be returned with a particular value. For instance +// the Claim request: +// +// "sub": {"value": "248289761001"} +// +// can be used to specify that the request apply to the End-User with Subject +// Identifier 248289761001. The value of the value member MUST be a valid +// value for the Claim being requested. Definitions of individual Claims can +// include requirements on how and whether the value qualifier is to be used +// when requesting that Claim. +func (i IndividualClaimRequest) WantedValue() *string { + if value, ok := i["value"]; ok { + valueString := value.(string) + return &valueString + } + return nil +} + +// Get the allowed values for an individual claim request that specifies +// a values field. +// +// Specification +// +// https://openid.net/specs/openid-connect-core-1_0.html#IndividualClaimsRequests +// +// Requests that the Claim be returned with one of a set of values, with the +// values appearing in order of preference. For instance the Claim request: +// +// "acr": {"essential": true, +// "values": ["urn:mace:incommon:iap:silver", +// "urn:mace:incommon:iap:bronze"]} +// +// specifies that it is Essential that the acr Claim be returned with either +// the value urn:mace:incommon:iap:silver or urn:mace:incommon:iap:bronze. +// The values in the values member array MUST be valid values for the Claim +// being requested. Definitions of individual Claims can include requirements +// on how and whether the values qualifier is to be used when requesting that +// Claim. +func (i IndividualClaimRequest) AllowedValues() []string { + if values, ok := i["values"]; ok { + return values.([]string) + } + return nil +} + +// OpenIDConfiguration contains the parts of the OpenID discovery information +// that are relevant for us. +// +// Specification +// +// See https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata +type OpenIDConfiguration struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JwksUri string `json:"jwks_uri"` + EndSessionEndpoint string `json:"end_session_endpoint"` +} diff --git a/common/services/godoc.go b/common/services/godoc.go new file mode 100644 index 0000000..a8441f0 --- /dev/null +++ b/common/services/godoc.go @@ -0,0 +1,4 @@ +/* +The package services provides services shared by the idp and the app. +*/ +package services diff --git a/common/services/oidc.go b/common/services/oidc.go index 31d1d9d..36565cf 100644 --- a/common/services/oidc.go +++ b/common/services/oidc.go @@ -10,23 +10,20 @@ import ( "github.com/lestrrat-go/jwx/jwk" log "github.com/sirupsen/logrus" "golang.org/x/oauth2" + + "git.cacert.org/oidc_login/common/models" ) type oidcContextKey int +// context keys const ( ctxOidcConfig oidcContextKey = iota ctxOAuth2Config ctxOidcJwks ) -type OpenIDConfiguration struct { - AuthorizationEndpoint string `json:"authorization_endpoint"` - TokenEndpoint string `json:"token_endpoint"` - JwksUri string `json:"jwks_uri"` - EndSessionEndpoint string `json:"end_session_endpoint"` -} - +// Parameters for DiscoverOIDC type OidcParams struct { OidcServer string OidcClientId string @@ -34,6 +31,16 @@ type OidcParams struct { APIClient *http.Client } +// Discover OpenID Connect parameters from the discovery endpoint and the +// JSON Web Key Set from the discovered jwksUri. +// +// The subset of values specified by models.OpenIDConfiguration is stored in +// the given context and can be retrieved from the context by GetOidcConfig. +// +// OAuth2 specific values are stored in another context object and can be +// retrieved by GetOAuth2Config. +// +// The JSON Web Key Set can be retrieved by GetJwkSet. func DiscoverOIDC(ctx context.Context, logger *log.Logger, params *OidcParams) (context.Context, error) { var discoveryUrl *url.URL @@ -60,7 +67,7 @@ func DiscoverOIDC(ctx context.Context, logger *log.Logger, params *OidcParams) ( } dec := json.NewDecoder(resp.Body) - discoveryResponse := &OpenIDConfiguration{} + discoveryResponse := &models.OpenIDConfiguration{} err = dec.Decode(discoveryResponse) if err != nil { return nil, err @@ -87,14 +94,23 @@ func DiscoverOIDC(ctx context.Context, logger *log.Logger, params *OidcParams) ( return ctx, nil } -func GetOidcConfig(ctx context.Context) *OpenIDConfiguration { - return ctx.Value(ctxOidcConfig).(*OpenIDConfiguration) +// Get the OpenID configuration from the context. +// +// DiscoverOIDC needs to be called before this is available. +func GetOidcConfig(ctx context.Context) *models.OpenIDConfiguration { + return ctx.Value(ctxOidcConfig).(*models.OpenIDConfiguration) } +// Get the OAuth 2 configuration configuration from the context. +// +// DiscoverOIDC needs to be called before this is available. func GetOAuth2Config(ctx context.Context) *oauth2.Config { return ctx.Value(ctxOAuth2Config).(*oauth2.Config) } +// Get the JSON Web Key set from the context. +// +// DiscoverOIDC needs to be called before this is available. func GetJwkSet(ctx context.Context) *jwk.Set { return ctx.Value(ctxOidcJwks).(*jwk.Set) } diff --git a/idp/handlers/consent.go b/idp/handlers/consent.go index 518dd4a..dc68e56 100644 --- a/idp/handlers/consent.go +++ b/idp/handlers/consent.go @@ -3,9 +3,11 @@ package handlers import ( "context" "database/sql" + "encoding/json" "fmt" "html/template" "net/http" + "net/url" "strconv" "strings" "time" @@ -20,6 +22,7 @@ import ( log "github.com/sirupsen/logrus" "git.cacert.org/oidc_login/common/handlers" + models2 "git.cacert.org/oidc_login/common/models" commonServices "git.cacert.org/oidc_login/common/services" "git.cacert.org/oidc_login/idp/services" ) @@ -147,10 +150,16 @@ WHERE uniqueid = ? for _, scope := range consentData.GetPayload().RequestedScope { switch scope { case "email": + // email + // OPTIONAL. This scope value requests access to the email and email_verified Claims. idTokenData[openid.EmailKey] = userInfo.Email idTokenData[openid.EmailVerifiedKey] = userInfo.EmailVerified break case "profile": + // profile + // OPTIONAL. This scope value requests access to the End-User's default profile Claims, which + // are: name, family_name, given_name, middle_name, nickname, preferred_username, profile, + // picture, website, gender, birthdate, zoneinfo, locale, and updated_at. idTokenData[openid.GivenNameKey] = userInfo.GivenName idTokenData[openid.FamilyNameKey] = userInfo.FamilyName idTokenData[openid.MiddleNameKey] = userInfo.MiddleName @@ -164,6 +173,14 @@ WHERE uniqueid = ? idTokenData[openid.UpdatedAtKey] = userInfo.Modified.Time.Unix() } break + case "address": + // address + // OPTIONAL. This scope value requests access to the address Claim. + break + case "phone": + // phone + // OPTIONAL. This scope value requests access to the phone_number and phone_number_verified Claims. + break } } sessionData := &models.ConsentRequestSession{ @@ -218,6 +235,21 @@ func (h *consentHandler) renderConsentForm( return h.messageCatalog.LookupMessage(id, nil, localizer) } + var requestedClaims models2.OIDCClaimsRequest + requestUrl, err := url.Parse(consentData.Payload.RequestURL) + if err != nil { + h.logger.Warnf("could not parse original request URL %s: %v", consentData.Payload.RequestURL, err) + } else { + claimsParameter := requestUrl.Query().Get("claims") + if claimsParameter != "" { + decoder := json.NewDecoder(strings.NewReader(claimsParameter)) + err := decoder.Decode(&requestedClaims) + if err != nil { + h.logger.Warnf("ignoring claims request parameter %s that could not be decoded: %v", claimsParameter, err) + } + } + } + // render consent form client := consentData.GetPayload().Client err = h.consentTemplate.Lookup("base").Execute(w, map[string]interface{}{