From 43f0ee6791f7bc1de5fcb0ada2dc718b1d7a70ec Mon Sep 17 00:00:00 2001 From: Kevin Kreitner Date: Wed, 18 Oct 2023 10:07:30 +0200 Subject: [PATCH] Add Cidaas provider --- pkg/apis/options/providers.go | 3 + providers/cidaas.go | 150 +++++++++++ providers/cidaas_test.go | 491 ++++++++++++++++++++++++++++++++++ providers/providers.go | 5 +- 4 files changed, 648 insertions(+), 1 deletion(-) create mode 100644 providers/cidaas.go create mode 100644 providers/cidaas_test.go diff --git a/pkg/apis/options/providers.go b/pkg/apis/options/providers.go index 280b1ce0..7ecf63ae 100644 --- a/pkg/apis/options/providers.go +++ b/pkg/apis/options/providers.go @@ -147,6 +147,9 @@ const ( // OIDCProvider is the provider type for OIDC OIDCProvider ProviderType = "oidc" + + // CidaasProvider is the provider type for Cidaas IDP + CidaasProvider ProviderType = "cidaas" ) type KeycloakOptions struct { diff --git a/providers/cidaas.go b/providers/cidaas.go new file mode 100644 index 00000000..ebac5b35 --- /dev/null +++ b/providers/cidaas.go @@ -0,0 +1,150 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/logger" + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/requests" +) + +type GroupsClaimList []GroupClaimEntry + +type GroupClaimEntry struct { + GroupID string `json:"groupId"` + Roles []string `json:"roles"` +} + +// CIDAASProvider represents an CIDAAS based Identity Provider +type CIDAASProvider struct { + *OIDCProvider +} + +var _ Provider = (*CIDAASProvider)(nil) + +const ( + CidaasProviderName = "CIDAAS" + CidaasGroupName = "cidaas" + CidaasDefaultScope = "openid email profile roles" +) + +// NewCIDAASProvider initiates a new CIDAASProvider +func NewCIDAASProvider(p *ProviderData) *CIDAASProvider { + p.setProviderDefaults(providerDefaults{ + name: CidaasProviderName, + scope: CidaasDefaultScope, + }) + + if p.ProtectedResource != nil && p.ProtectedResource.String() != "" { + resource := p.ProtectedResource.String() + if !strings.HasSuffix(resource, "/") { + resource += "/" + } + + if p.Scope != "" && !strings.HasPrefix(p.Scope, resource) { + p.Scope = resource + p.Scope + } + } + + return &CIDAASProvider{ + OIDCProvider: &OIDCProvider{ + ProviderData: p, + SkipNonce: true, + }, + } +} + +// RefreshSession uses the RefreshToken to fetch new Access and ID Tokens +func (p *CIDAASProvider) RefreshSession(ctx context.Context, s *sessions.SessionState) (bool, error) { + if s == nil || s.RefreshToken == "" { + return false, nil + } + + err := p.redeemRefreshToken(ctx, s) + if err != nil { + return false, fmt.Errorf("unable to redeem refresh token: %v", err) + } + err = p.EnrichSession(ctx, s) + if err != nil { + return false, fmt.Errorf("unable to enrich session data after refresh: %v %v", err, s) + } + + return true, nil +} + +// EnrichSession data to add email an groups +func (p *CIDAASProvider) EnrichSession(ctx context.Context, s *sessions.SessionState) error { + if p.ProfileURL.String() == "" { + if s.Email == "" { + return errors.New("id_token did not contain an email and profileURL is not defined") + } + return nil + } + + // Try to get missing emails or groups from a profileURL + err := p.enrichFromUserinfoEndpoint(ctx, s) + if err != nil { + logger.Errorf("Warning: Profile URL request failed: %v", err) + } + + // If a mandatory email wasn't set, error at this point. + if s.Email == "" { + return errors.New("neither the id_token nor the profileURL set an email") + } + return nil +} + +// enrichFromUserinfoEndpoint enriches a session's Email & Groups via the JSON response of +// an OIDC profile URL +func (p *CIDAASProvider) enrichFromUserinfoEndpoint(ctx context.Context, s *sessions.SessionState) error { + // profile url is userinfo url in case of Cidaas + respJSON, err := requests.New(p.ProfileURL.String()). + WithContext(ctx). + WithHeaders(makeOIDCHeader(s.AccessToken)). + Do(). + UnmarshalSimpleJSON() + if err != nil { + return err + } + + email, err := respJSON.Get(p.EmailClaim).String() + if err == nil && s.Email == "" { + s.Email = email + } + + rawGroupClaim, err := respJSON.Get(p.GroupsClaim).MarshalJSON() + if err != nil { + return err + } + var groupsClaimList GroupsClaimList + err = json.Unmarshal(rawGroupClaim, &groupsClaimList) + if err != nil { + return err + } + + var groups []string + for _, group := range groupsClaimList { + for _, role := range group.Roles { + groups = append(groups, fmt.Sprintf("%s:%s", group.GroupID, role)) + } + } + + // Cidaas specific roles + if rolesVal, rolesClaimExists := respJSON.CheckGet("roles"); rolesClaimExists { + cidaasRoles, err := rolesVal.StringArray() + if err != nil { + return fmt.Errorf("unmarshal roles failed: %w", err) + } + + for _, role := range cidaasRoles { + groups = append(groups, fmt.Sprintf("%s:%s", CidaasGroupName, role)) + } + } + + s.Groups = groups + return nil +} diff --git a/providers/cidaas_test.go b/providers/cidaas_test.go new file mode 100644 index 00000000..8bcdab90 --- /dev/null +++ b/providers/cidaas_test.go @@ -0,0 +1,491 @@ +package providers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/stretchr/testify/assert" + + "github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/sessions" +) + +func newCidaasProvider(serverURL *url.URL) *CIDAASProvider { + providerData := &ProviderData{ + ProviderName: "cidaas", + ClientID: oidcClientID, + ClientSecret: oidcSecret, + LoginURL: &url.URL{ + Scheme: serverURL.Scheme, + Host: serverURL.Host, + Path: "/login/oauth/authorize"}, + RedeemURL: &url.URL{ + Scheme: serverURL.Scheme, + Host: serverURL.Host, + Path: "/login/oauth/access_token"}, + ProfileURL: &url.URL{ + Scheme: serverURL.Scheme, + Host: serverURL.Host, + Path: "/profile"}, + ValidateURL: &url.URL{ + Scheme: serverURL.Scheme, + Host: serverURL.Host, + Path: "/api"}, + Scope: "openid profile offline_access", + EmailClaim: "email", + GroupsClaim: "groups", + Verifier: oidc.NewVerifier( + oidcIssuer, + mockJWKS{}, + &oidc.Config{ClientID: oidcClientID}, + ), + } + + p := NewCIDAASProvider(providerData, options.CidaasOptions{}) + + return p +} + +func newCidaasServer(pathBodyMap map[string][]byte) (*url.URL, *httptest.Server) { + s := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + body, ok := pathBodyMap[r.URL.Path] + if !ok { + rw.WriteHeader(404) + return + } + rw.Header().Add("content-type", "application/json") + _, _ = rw.Write(body) + })) + u, _ := url.Parse(s.URL) + return u, s +} + +func newTestCidaasSetup(pathToBodyMap map[string][]byte) (*httptest.Server, *CIDAASProvider) { + redeemURL, server := newCidaasServer(pathToBodyMap) + provider := newCidaasProvider(redeemURL) + return server, provider +} + +func TestCidaasProvider_EnrichSession(t *testing.T) { + testCases := map[string]struct { + ExistingSession *sessions.SessionState + EmailClaim string + GroupsClaim string + FilterGroups FilterGroups + ProfileJSON map[string]interface{} + ExpectedError error + ExpectedSession *sessions.SessionState + }{ + "Missing Email Only in Profile URL": { + ExistingSession: &sessions.SessionState{ + User: "missing.email", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "email": "found@email.com", + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "missing.email", + Email: "found@email.com", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Email with Custom Claim": { + ExistingSession: &sessions.SessionState{ + User: "missing.email", + Groups: []string{"already", "populated"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "weird", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "weird": "weird@claim.com", + "groups": []map[string]interface{}{ + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "missing.email", + Email: "weird@claim.com", + Groups: []string{"CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Email not in Profile URL": { + ExistingSession: &sessions.SessionState{ + User: "missing.email", + Groups: []string{"already", "populated"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "groups": []map[string]interface{}{ + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: errors.New("neither the id_token nor the profileURL set an email"), + ExpectedSession: &sessions.SessionState{ + User: "missing.email", + Groups: []string{"CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Groups": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: nil, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "email": "new@thing.com", + "groups": []map[string]interface{}{ + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{"CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Empty Groups Claims": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "email": "new@thing.com", + "groups": []map[string]interface{}{ + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{"CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Groups with Custom Claim": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: nil, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups2", + ProfileJSON: map[string]interface{}{ + "email": "already@populated.com", + "groups2": []map[string]interface{}{ + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "CIDAAS_ADMINS", + "path": "/CIDAAS_ADMINS/", + "roles": []string{"ADMIN"}, + }, + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "customers", + "groupType": "Customers", + "path": "/customers/", + "roles": []string{ + "CUSTOMER_ACCOUNT_LOGIN", + "GROUP_ADMIN", + }, + }, + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{"CIDAAS_ADMINS:ADMIN", "customers:CUSTOMER_ACCOUNT_LOGIN", "customers:GROUP_ADMIN", "CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Filter Groups": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: nil, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups2", + FilterGroups: []string{"customers"}, + ProfileJSON: map[string]interface{}{ + "email": "already@populated.com", + "groups2": []map[string]interface{}{ + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "CIDAAS_ADMINS", + "path": "/CIDAAS_ADMINS/", + "roles": []string{"ADMIN"}, + }, + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "customers", + "groupType": "Customers", + "path": "/customers/", + "roles": []string{ + "CUSTOMER_ACCOUNT_LOGIN", + "GROUP_ADMIN", + }, + }, + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{"customers:CUSTOMER_ACCOUNT_LOGIN", "customers:GROUP_ADMIN"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Groups String Profile URL Response": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: nil, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "groups": []map[string]interface{}{ + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "CIDAAS_ADMINS", + "path": "/CIDAAS_ADMINS/", + "roles": []string{"ADMIN"}, + }, + { + "sub": "aa4980ee-0939-4ea7-b67f-81883f143d39", + "groupId": "customers", + "groupType": "Customers", + "path": "/customers/", + "roles": []string{ + "CUSTOMER_ACCOUNT_LOGIN", + "GROUP_ADMIN", + }, + }, + { + "groupId": "CIDAAS_USERS", + "roles": []string{"USER"}, + }, + }, + "roles": []string{"USER"}, + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + Groups: []string{"CIDAAS_ADMINS:ADMIN", "customers:CUSTOMER_ACCOUNT_LOGIN", "customers:GROUP_ADMIN", "CIDAAS_USERS:USER", "cidaas:USER"}, + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + "Missing Groups in both Claims and Profile URL": { + ExistingSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + EmailClaim: "email", + GroupsClaim: "groups", + ProfileJSON: map[string]interface{}{ + "email": "new@thing.com", + }, + ExpectedError: nil, + ExpectedSession: &sessions.SessionState{ + User: "already", + Email: "already@populated.com", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + }, + } + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + path := "/userinfo/" + jsonResp, err := json.Marshal(tc.ProfileJSON) + assert.NoError(t, err) + + server, provider := newTestCidaasSetup(map[string][]byte{path: jsonResp}) + provider.ProfileURL, err = url.Parse(fmt.Sprintf("%s%s", server.URL, path)) + assert.NoError(t, err) + + provider.EmailClaim = tc.EmailClaim + provider.GroupsClaim = tc.GroupsClaim + provider.FilterGroups = tc.FilterGroups + defer server.Close() + + err = provider.EnrichSession(context.Background(), tc.ExistingSession) + assert.Equal(t, tc.ExpectedError, err) + assert.Equal(t, *tc.ExpectedSession, *tc.ExistingSession) + }) + } +} + +func TestCidaasProvider_RefreshSession(t *testing.T) { + testCases := map[string]struct { + ExistingSession *sessions.SessionState + EmailClaim string + GroupsClaim string + ProfileJSON map[string]interface{} + RedeemJSON redeemTokenResponse + ExpectedRefreshed bool + ExpectedError error + ExpectedEmail string + ExpectedUser string + }{ + "Refresh session successfully": { + ExistingSession: &sessions.SessionState{ + User: "session.is.not.locked", + Email: "found@email.com", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + RedeemJSON: redeemTokenResponse{ + AccessToken: accessToken, + ExpiresIn: 10, + TokenType: "Bearer", + RefreshToken: refreshToken, + }, + ExpectedRefreshed: true, + ExpectedError: nil, + ExpectedEmail: defaultIDToken.Email, + ExpectedUser: defaultIDToken.Subject, + }, + "Unable to refresh session": { + ExistingSession: &sessions.SessionState{ + User: "session.is.unable.to.refresh", + Email: "found@email.com", + IDToken: idToken, + AccessToken: accessToken, + RefreshToken: refreshToken, + }, + ExpectedRefreshed: false, + ExpectedError: fmt.Errorf("unable to redeem refresh token: failed to get token: oauth2: server response missing access_token"), + ExpectedUser: "session.is.unable.to.refresh", + ExpectedEmail: "found@email.com", + }, + } + for testName, tc := range testCases { + t.Run(testName, func(t *testing.T) { + idToken, _ := newSignedTestIDToken(defaultIDToken) + tc.RedeemJSON.IDToken = idToken + redeemPath := "/token/" + redeemJSONResp, err := json.Marshal(tc.RedeemJSON) + assert.NoError(t, err) + + serverURL, server := newCidaasServer( + map[string][]byte{ + redeemPath: redeemJSONResp, + }) + provider := newCidaasProvider(serverURL) + + // Disable session enrichment, because we want to focus on refreshing logic + provider.ProfileURL, err = url.Parse("") + assert.NoError(t, err) + provider.RedeemURL, err = url.Parse(fmt.Sprintf("%s%s", server.URL, redeemPath)) + assert.NoError(t, err) + + provider.GroupsClaim = tc.GroupsClaim + defer server.Close() + + var refreshed bool + refreshed, err = provider.RefreshSession(context.Background(), tc.ExistingSession) + + assert.Equal(t, tc.ExpectedError, err) + assert.Equal(t, tc.ExpectedRefreshed, refreshed) + assert.Equal(t, tc.ExpectedEmail, tc.ExistingSession.Email) + assert.Equal(t, tc.ExpectedUser, tc.ExistingSession.User) + }) + } +} diff --git a/providers/providers.go b/providers/providers.go index 3a125a24..f54e976b 100644 --- a/providers/providers.go +++ b/providers/providers.go @@ -67,6 +67,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) { return NewNextcloudProvider(providerData), nil case options.OIDCProvider: return NewOIDCProvider(providerData, providerConfig.OIDCConfig), nil + case options.CidaasProvider: + return NewCIDAASProvider(providerData, providerConfig.CidaasConfig), nil default: return nil, fmt.Errorf("unknown provider type %q", providerConfig.Type) } @@ -185,7 +187,8 @@ func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bo case options.BitbucketProvider, options.DigitalOceanProvider, options.FacebookProvider, options.GitHubProvider, options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, options.NextCloudProvider: return false, nil - case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider, options.MicrosoftEntraIDProvider: + case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.GitLabProvider, + options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider, options.CidaasProvider: return true, nil default: return false, fmt.Errorf("unknown provider type: %s", providerType)