feat: add Cidaas provider (#2273)

* Add sensible logging flag to default setup for logger

* Fix default value flag for sensitive logging

* Remove sensitive logging changes

* Add Cidaas provider

* Update CHANGELOG.md

* Add required groups scope to defaults

* Fix tests

* Remove if block with protected resource

* Fix linting

* Adjust provider sorting, fixes

* Directly handle error return

Co-authored-by: Jan Larwig <jan@larwig.com>

* Use less deep nesting

Co-authored-by: Jan Larwig <jan@larwig.com>

* Directly handle returned error

Co-authored-by: Jan Larwig <jan@larwig.com>

* Pass provider options to Cidaas provider

Co-authored-by: Jan Larwig <jan@larwig.com>

* Add import for provider options

* Fix tests

* Fix linting

* Add Cidaas doc page

* Add Cidaas provider doc page to overview

* Fix link in docs

* Fix link in docs

* Add link to Cidaas

* fix provider order in docs and changelog position

Signed-off-by: Jan Larwig <jan@larwig.com>

---------

Signed-off-by: Jan Larwig <jan@larwig.com>
Co-authored-by: Teko012 <112829523+Teko012@users.noreply.github.com>
Co-authored-by: Jan Larwig <jan@larwig.com>
Co-authored-by: Kevin Kreitner <kevinkreitner@gmail.com>
This commit is contained in:
Kevin Kreitner 2025-08-12 17:41:45 +02:00 committed by GitHub
parent 9667bce094
commit 4c86a4d574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 684 additions and 1 deletions

View File

@ -8,6 +8,8 @@
## Changes since v7.11.0 ## Changes since v7.11.0
- [#2273](https://github.com/oauth2-proxy/oauth2-proxy/pull/2273) feat: add Cidaas provider (@Bibob7, @Teko012)
# V7.11.0 # V7.11.0
## Release Highlights ## Release Highlights

View File

@ -0,0 +1,37 @@
---
id: cidaas
title: Cidaas
---
[Cidaas](https://www.cidaas.com/) is an Identity as a Service (IDaaS) solution that provides authentication and authorization services.
It supports various protocols including OpenID Connect, OAuth 2.0, and SAML.
However, Cidaas provides groups and their roles as hierarchical claims, which are not supported by oauth2-proxy yet.
The Cidaas provider transforms the hierarchical claims into a flat list of groups, which can be used by oauth2-proxy.
Example of groups and roles in Cidaas:
```json
{
"groups": [
{
"groupId": "group1",
"roles": ["role1", "role2"]
},
{
"groupId": "group2",
"roles": ["role3"]
}
]
}
```
This will be transformed into a flat list of groups:
```json
{
"groups": ["group1:role1", "group2:role2", "group2:role3"]
}
```
Apart from that the Cidaas provider inherits all the features of the [OpenID Connect provider](openid_connect.md).

View File

@ -10,6 +10,7 @@ Valid providers are :
- [ADFS](adfs.md) - [ADFS](adfs.md)
- [Bitbucket](bitbucket.md) - [Bitbucket](bitbucket.md)
- [Cidaas](cidaas.md)
- [DigitalOcean](digitalocean.md) - [DigitalOcean](digitalocean.md)
- [Facebook](facebook.md) - [Facebook](facebook.md)
- [Gitea](gitea.md) - [Gitea](gitea.md)

View File

@ -115,6 +115,9 @@ const (
// BitbucketProvider is the provider type for Bitbucket // BitbucketProvider is the provider type for Bitbucket
BitbucketProvider ProviderType = "bitbucket" BitbucketProvider ProviderType = "bitbucket"
// CidaasProvider is the provider type for Cidaas IDP
CidaasProvider ProviderType = "cidaas"
// DigitalOceanProvider is the provider type for DigitalOcean // DigitalOceanProvider is the provider type for DigitalOcean
DigitalOceanProvider ProviderType = "digitalocean" DigitalOceanProvider ProviderType = "digitalocean"

144
providers/cidaas.go Normal file
View File

@ -0,0 +1,144 @@
package providers
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/bitly/go-simplejson"
"github.com/oauth2-proxy/oauth2-proxy/v7/pkg/apis/options"
"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 groups"
)
// NewCIDAASProvider initiates a new CIDAASProvider
func NewCIDAASProvider(p *ProviderData, opts options.Provider) *CIDAASProvider {
p.setProviderDefaults(providerDefaults{
name: CidaasProviderName,
scope: CidaasDefaultScope,
})
return &CIDAASProvider{
OIDCProvider: NewOIDCProvider(p, opts.OIDCConfig),
}
}
// 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
}
if err := p.redeemRefreshToken(ctx, s); err != nil {
return false, fmt.Errorf("unable to redeem refresh token: %w", err)
}
if err := p.EnrichSession(ctx, s); err != nil {
return false, fmt.Errorf("unable to enrich session data after refresh: %w %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() == "" && s.Email == "" {
return errors.New("id_token did not contain an email and profileURL is not defined")
} else if p.ProfileURL.String() == "" {
return nil
}
// Try to get missing emails or groups from a profileURL
if err := p.enrichFromUserinfoEndpoint(ctx, s); err != nil {
logger.Errorf("Warning: Profile URL request failed: %s", 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
}
groups, err := p.extractGroups(respJSON)
if err != nil {
return fmt.Errorf("extracting groups failed: %w", err)
}
s.Groups = groups
return nil
}
func (p *CIDAASProvider) extractGroups(respJSON *simplejson.Json) ([]string, error) {
rawGroupsClaim, err := respJSON.Get(p.GroupsClaim).MarshalJSON()
if err != nil {
return nil, err
}
var groupsClaimList GroupsClaimList
err = json.Unmarshal(rawGroupsClaim, &groupsClaimList)
if err != nil {
return nil, 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 nil, fmt.Errorf("unmarshal roles failed: %w", err)
}
for _, role := range cidaasRoles {
groups = append(groups, fmt.Sprintf("%s:%s", CidaasGroupName, role))
}
}
return groups, nil
}

493
providers/cidaas_test.go Normal file
View File

@ -0,0 +1,493 @@
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 roles groups",
EmailClaim: "email",
GroupsClaim: "groups",
Verifier: oidc.NewVerifier(
oidcIssuer,
mockJWKS{},
&oidc.Config{ClientID: oidcClientID},
),
}
cfg := options.Provider{
Type: options.CidaasProvider,
}
p := NewCIDAASProvider(providerData, cfg)
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
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": "aa5181ea-0841-4ea7-b67f-81882f153d40",
"groupId": "CIDAAS_ADMINS",
"path": "/CIDAAS_ADMINS/",
"roles": []string{"ADMIN"},
},
{
"sub": "aa5181ea-0841-4ea7-b67f-81882f153d39",
"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,
},
},
"Just format Groups": {
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": "aa5181ea-0841-4ea7-b67f-81882f153d39",
"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", "CIDAAS_USERS:USER", "cidaas:USER"},
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": "aa5181ea-0841-4ea7-b67f-81882f153d40",
"groupId": "CIDAAS_ADMINS",
"path": "/CIDAAS_ADMINS/",
"roles": []string{"ADMIN"},
},
{
"sub": "aa5181ea-0841-4ea7-b67f-81882f153d39",
"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
defer server.Close()
err = provider.EnrichSession(context.Background(), tc.ExistingSession)
if tc.ExpectedError != nil {
assert.EqualError(t, err, tc.ExpectedError.Error())
} else {
assert.NoError(t, 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)
if tc.ExpectedError != nil {
assert.EqualError(t, err, tc.ExpectedError.Error())
} else {
assert.NoError(t, err)
}
assert.Equal(t, tc.ExpectedRefreshed, refreshed)
assert.Equal(t, tc.ExpectedEmail, tc.ExistingSession.Email)
assert.Equal(t, tc.ExpectedUser, tc.ExistingSession.User)
})
}
}

View File

@ -45,6 +45,8 @@ func NewProvider(providerConfig options.Provider) (Provider, error) {
return NewMicrosoftEntraIDProvider(providerData, providerConfig), nil return NewMicrosoftEntraIDProvider(providerData, providerConfig), nil
case options.BitbucketProvider: case options.BitbucketProvider:
return NewBitbucketProvider(providerData, providerConfig.BitbucketConfig), nil return NewBitbucketProvider(providerData, providerConfig.BitbucketConfig), nil
case options.CidaasProvider:
return NewCIDAASProvider(providerData, providerConfig), nil
case options.DigitalOceanProvider: case options.DigitalOceanProvider:
return NewDigitalOceanProvider(providerData), nil return NewDigitalOceanProvider(providerData), nil
case options.FacebookProvider: case options.FacebookProvider:
@ -188,7 +190,8 @@ func providerRequiresOIDCProviderVerifier(providerType options.ProviderType) (bo
options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider, options.GoogleProvider, options.KeycloakProvider, options.LinkedInProvider, options.LoginGovProvider,
options.NextCloudProvider, options.SourceHutProvider: options.NextCloudProvider, options.SourceHutProvider:
return false, nil return false, nil
case options.ADFSProvider, options.AzureProvider, options.GitLabProvider, options.KeycloakOIDCProvider, options.OIDCProvider, options.MicrosoftEntraIDProvider: case options.OIDCProvider, options.ADFSProvider, options.AzureProvider, options.CidaasProvider,
options.GitLabProvider, options.KeycloakOIDCProvider, options.MicrosoftEntraIDProvider:
return true, nil return true, nil
default: default:
return false, fmt.Errorf("unknown provider type: %s", providerType) return false, fmt.Errorf("unknown provider type: %s", providerType)