Repository: cloudflare/cloudflare-go
Pull request #3708 (2024-12-06 19:27:34)

AUTH-6145 support multi-valued + service token auth for scim provisioning
## Description

Support Access Service Tokens as an authentication method for SCIM provisioning to Access Applications. Additionally, support configuring multiple authentication methods for SCIM provisioning to Access applications. This is useful when you have an application behind Access, but the SCIM provider also has its own authentication method. 

## Has your change been tested?

Unit tests

## Screenshots (if appropriate):

## Types of changes

What sort of change does your code introduce/modify?

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to change)

## Checklist:

- [x] My code follows the code style of this project.
- [ ] My change requires a change to the documentation.
- [ ] I have updated the documentation accordingly.
- [x] I have added tests to cover my changes.
- [x] All new and existing tests passed.
- [ ] This change is using publicly documented in [cloudflare/api-schemas](https://github.com/cloudflare/api-schemas) 
      and relies on stable APIs.

[1]: https://help.github.com/articles/closing-issues-using-keywords/


github-actions[bot]: Oops! It looks like no changelog entry is attached to this PR. Please include a release note as described in https://github.com/cloudflare/cloudflare-go/blob/master/docs/changelog-process.md.

Example: 

~~~
```release-note:TYPE
Release note
```
~~~

If you do not require a release note to be included, please add the `workflow/skip-changelog-entry` label.

Patch:
diff --git a/.changelog/3708.txt b/.changelog/3708.txt
new file mode 100644
index 000000000..17cbcffdb
--- /dev/null
+++ b/.changelog/3708.txt
@@ -0,0 +1,3 @@
+```release-note:enhancement
+access_application: support Access service token + multi-valued authentication for SCIM provisioning
+```
diff --git a/access_application.go b/access_application.go
index eef107a4c..8a66327f8 100644
--- a/access_application.go
+++ b/access_application.go
@@ -110,13 +110,42 @@ const (
 	AccessApplicationScimAuthenticationSchemeHttpBasic        AccessApplicationScimAuthenticationScheme = "httpbasic"
 	AccessApplicationScimAuthenticationSchemeOauthBearerToken AccessApplicationScimAuthenticationScheme = "oauthbearertoken"
 	AccessApplicationScimAuthenticationSchemeOauth2           AccessApplicationScimAuthenticationScheme = "oauth2"
+	AccessApplicationScimAuthenticationAccessServiceToken     AccessApplicationScimAuthenticationScheme = "access_service_token"
 )
 
+type ScimAuthentication interface {
+	isScimAuthentication()
+	isMultipleScimAuthentication() bool
+}
+
+type SingleScimAuthentication interface {
+	isSingleScimAuthentication()
+	isScimAuthentication()
+	isMultipleScimAuthentication() bool
+}
+
+type AccessApplicationMultipleScimAuthentication []*AccessApplicationScimAuthenticationSingleJSON
+
+func (m *AccessApplicationMultipleScimAuthentication) isScimAuthentication() {}
+
+func (m *AccessApplicationMultipleScimAuthentication) isMultipleScimAuthentication() bool {
+	return true
+}
+
+type AccessApplicationScimAuthenticationSingleJSON struct {
+	Value SingleScimAuthentication
+}
+
 type AccessApplicationScimAuthenticationJson struct {
 	Value AccessApplicationScimAuthentication
 }
 
 func (a *AccessApplicationScimAuthenticationJson) UnmarshalJSON(buf []byte) error {
+	if len(buf) > 0 && rune(buf[0]) == '[' {
+		a.Value = new(AccessApplicationMultipleScimAuthentication)
+		return json.Unmarshal(buf, a.Value)
+	}
+
 	var scheme baseScimAuthentication
 	if err := json.Unmarshal(buf, &scheme); err != nil {
 		return err
@@ -129,6 +158,8 @@ func (a *AccessApplicationScimAuthenticationJson) UnmarshalJSON(buf []byte) erro
 		a.Value = new(AccessApplicationScimAuthenticationOauthBearerToken)
 	case AccessApplicationScimAuthenticationSchemeOauth2:
 		a.Value = new(AccessApplicationScimAuthenticationOauth2)
+	case AccessApplicationScimAuthenticationAccessServiceToken:
+		a.Value = new(AccessApplicationScimAuthenticationServiceToken)
 	default:
 		return errors.New("invalid authentication scheme")
 	}
@@ -140,15 +171,43 @@ func (a *AccessApplicationScimAuthenticationJson) MarshalJSON() ([]byte, error)
 	return json.Marshal(a.Value)
 }
 
+func (a *AccessApplicationScimAuthenticationSingleJSON) UnmarshalJSON(buf []byte) error {
+	var scheme baseScimAuthentication
+	if err := json.Unmarshal(buf, &scheme); err != nil {
+		return err
+	}
+	switch scheme.Scheme {
+	case AccessApplicationScimAuthenticationSchemeHttpBasic:
+		a.Value = new(AccessApplicationScimAuthenticationHttpBasic)
+	case AccessApplicationScimAuthenticationSchemeOauthBearerToken:
+		a.Value = new(AccessApplicationScimAuthenticationOauthBearerToken)
+	case AccessApplicationScimAuthenticationSchemeOauth2:
+		a.Value = new(AccessApplicationScimAuthenticationOauth2)
+	case AccessApplicationScimAuthenticationAccessServiceToken:
+		a.Value = new(AccessApplicationScimAuthenticationServiceToken)
+	default:
+		return errors.New("invalid authentication scheme")
+	}
+
+	return json.Unmarshal(buf, a.Value)
+}
+
+func (a AccessApplicationScimAuthenticationSingleJSON) MarshalJSON() ([]byte, error) {
+	return json.Marshal(a.Value)
+}
+
 type AccessApplicationScimAuthentication interface {
 	isScimAuthentication()
+	isMultipleScimAuthentication() bool
 }
 
 type baseScimAuthentication struct {
 	Scheme AccessApplicationScimAuthenticationScheme `json:"scheme"`
 }
 
-func (baseScimAuthentication) isScimAuthentication() {}
+func (baseScimAuthentication) isScimAuthentication()              {}
+func (baseScimAuthentication) isSingleScimAuthentication()        {}
+func (baseScimAuthentication) isMultipleScimAuthentication() bool { return false }
 
 type AccessApplicationScimAuthenticationHttpBasic struct {
 	baseScimAuthentication
@@ -170,6 +229,12 @@ type AccessApplicationScimAuthenticationOauth2 struct {
 	Scopes           []string `json:"scopes,omitempty"`
 }
 
+type AccessApplicationScimAuthenticationServiceToken struct {
+	baseScimAuthentication
+	ClientID     string `json:"client_id,omitempty" validate:"required_with=ClientSecret,excluded_with=ServiceTokenID"`
+	ClientSecret string `json:"client_secret,omitempty" validate:"required_with=ClientID,excluded_with=ServiceTokenID"`
+}
+
 type AccessApplicationScimMapping struct {
 	Schema           string                                  `json:"schema"`
 	Enabled          *bool                                   `json:"enabled,omitempty"`


Test patch:
diff --git a/access_application_test.go b/access_application_test.go
index 753d1eb42..bd7a3ee72 100644
--- a/access_application_test.go
+++ b/access_application_test.go
@@ -1604,3 +1604,224 @@ func TestCreateAccessApplicationWithSCIMProvisioning(t *testing.T) {
 		assert.Equal(t, fullAccessApplication, actual)
 	}
 }
+
+func TestCreateAccessApplicationWithSCIMProvisioningMultiAuthentication(t *testing.T) {
+	setup()
+	defer teardown()
+
+	handler := func(w http.ResponseWriter, r *http.Request) {
+		assert.Equal(t, http.MethodPost, r.Method, "Expected method 'POST', got %s", r.Method)
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"success": true,
+			"errors": [],
+			"messages": [],
+			"result": {
+				"id": "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+				"created_at": "2014-01-01T05:20:00.12345Z",
+				"updated_at": "2014-01-01T05:20:00.12345Z",
+				"aud": "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+				"name": "Admin SCIM App",
+				"domain": "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+				"type": "saas",
+				"session_duration": "24h",
+				"allowed_idps": [],
+				"auto_redirect_to_identity": false,
+				"enable_binding_cookie": false,
+				"custom_deny_url": "https://www.example.com",
+				"custom_deny_message": "denied!",
+				"logo_url": "https://www.example.com/example.png",
+				"skip_interstitial": true,
+				"app_launcher_visible": true,
+				"service_auth_401_redirect": true,
+				"custom_non_identity_deny_url": "https://blocked.com",
+				"tags": ["engineers"],
+				"scim_config": {
+					"enabled": true,
+					"remote_uri": "https://scim.com",
+					"authentication": [{
+						 "scheme": "oauthbearertoken",
+						 "token": "1234567890"
+					}, {
+						"scheme": "access_service_token",
+						"client_id": "1234",
+						"client_secret": "5678"
+					}],
+					"idp_uid": "1234567",
+					"deactivate_on_delete": true,
+					"mappings": [
+						{
+							"schema": "urn:ietf:params:scim:schemas:core:2.0:User",
+							"enabled": true,
+							"filter": "title pr or userType eq \"Intern\"",
+							"transform_jsonata": "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])",
+							"operations": {
+								"create": true,
+								"update": true,
+								"delete": true
+							},
+							"strictness": "passthrough"
+						}
+					]
+				}
+			}
+		}
+		`)
+	}
+
+	createdAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z")
+	updatedAt, _ := time.Parse(time.RFC3339, "2014-01-01T05:20:00.12345Z")
+
+	fullAccessApplication := AccessApplication{
+		ID:                       "480f4f69-1a28-4fdd-9240-1ed29f0ac1db",
+		Name:                     "Admin SCIM App",
+		Domain:                   "example.cloudflareaccess.com/cdn-cgi/access/sso/oidc/737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+		Type:                     "saas",
+		SessionDuration:          "24h",
+		AUD:                      "737646a56ab1df6ec9bddc7e5ca84eaf3b0768850f3ffb5d74f1534911fe3893",
+		AllowedIdps:              []string{},
+		AutoRedirectToIdentity:   BoolPtr(false),
+		EnableBindingCookie:      BoolPtr(false),
+		AppLauncherVisible:       BoolPtr(true),
+		ServiceAuth401Redirect:   BoolPtr(true),
+		CustomDenyMessage:        "denied!",
+		CustomDenyURL:            "https://www.example.com",
+		LogoURL:                  "https://www.example.com/example.png",
+		SkipInterstitial:         BoolPtr(true),
+		CreatedAt:                &createdAt,
+		UpdatedAt:                &updatedAt,
+		CustomNonIdentityDenyURL: "https://blocked.com",
+		Tags:                     []string{"engineers"},
+		SCIMConfig: &AccessApplicationSCIMConfig{
+			Enabled:   BoolPtr(true),
+			RemoteURI: "https://scim.com",
+			Authentication: &AccessApplicationScimAuthenticationJson{
+				Value: &AccessApplicationMultipleScimAuthentication{
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationOauthBearerToken{
+							Token:                  "1234567890",
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken},
+						},
+					},
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationServiceToken{
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationAccessServiceToken},
+							ClientID:               "1234",
+							ClientSecret:           "5678",
+						},
+					},
+				},
+			},
+			IdPUID:             "1234567",
+			DeactivateOnDelete: BoolPtr(true),
+			Mappings: []*AccessApplicationScimMapping{
+				{
+					Schema:           "urn:ietf:params:scim:schemas:core:2.0:User",
+					Enabled:          BoolPtr(true),
+					Filter:           "title pr or userType eq \"Intern\"",
+					TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])",
+					Operations: &AccessApplicationScimMappingOperations{
+						Create: BoolPtr(true),
+						Update: BoolPtr(true),
+						Delete: BoolPtr(true),
+					},
+					Strictness: "passthrough",
+				},
+			},
+		},
+	}
+
+	mux.HandleFunc("/accounts/"+testAccountID+"/access/apps", handler)
+
+	actual, err := client.CreateAccessApplication(context.Background(), AccountIdentifier(testAccountID), CreateAccessApplicationParams{
+		Name: "Admin Saas Site",
+		SCIMConfig: &AccessApplicationSCIMConfig{
+			Enabled:   BoolPtr(true),
+			RemoteURI: "https://scim.com",
+			Authentication: &AccessApplicationScimAuthenticationJson{
+				Value: &AccessApplicationMultipleScimAuthentication{
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationOauthBearerToken{
+							Token:                  "1234567890",
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken},
+						},
+					},
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationServiceToken{
+							ClientID:               "1234",
+							ClientSecret:           "5678",
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationAccessServiceToken},
+						},
+					},
+				},
+			},
+			IdPUID:             "1234567",
+			DeactivateOnDelete: BoolPtr(true),
+			Mappings: []*AccessApplicationScimMapping{
+				{
+					Schema:           "urn:ietf:params:scim:schemas:core:2.0:User",
+					Enabled:          BoolPtr(true),
+					Filter:           "title pr or userType eq \"Intern\"",
+					TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])",
+					Operations: &AccessApplicationScimMappingOperations{
+						Create: BoolPtr(true),
+						Update: BoolPtr(true),
+						Delete: BoolPtr(true),
+					},
+					Strictness: "strict",
+				},
+			},
+		},
+	})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, fullAccessApplication, actual)
+	}
+
+	mux.HandleFunc("/zones/"+testZoneID+"/access/apps", handler)
+
+	actual, err = client.CreateAccessApplication(context.Background(), ZoneIdentifier(testZoneID), CreateAccessApplicationParams{
+		Name: "Admin SCIM Site",
+		SCIMConfig: &AccessApplicationSCIMConfig{
+			Enabled:   BoolPtr(true),
+			RemoteURI: "https://scim.com",
+			Authentication: &AccessApplicationScimAuthenticationJson{
+				Value: &AccessApplicationMultipleScimAuthentication{
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationOauthBearerToken{
+							Token:                  "1234567890",
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationSchemeOauthBearerToken},
+						},
+					},
+					&AccessApplicationScimAuthenticationSingleJSON{
+						Value: &AccessApplicationScimAuthenticationServiceToken{
+							ClientID:               "1234",
+							ClientSecret:           "5678",
+							baseScimAuthentication: baseScimAuthentication{Scheme: AccessApplicationScimAuthenticationAccessServiceToken},
+						},
+					},
+				},
+			},
+			IdPUID:             "1234567",
+			DeactivateOnDelete: BoolPtr(true),
+			Mappings: []*AccessApplicationScimMapping{
+				{
+					Schema:           "urn:ietf:params:scim:schemas:core:2.0:User",
+					Enabled:          BoolPtr(true),
+					Filter:           "title pr or userType eq \"Intern\"",
+					TransformJsonata: "$merge([$, {'userName': $substringBefore($.userName, '@') & '+test@' & $substringAfter($.userName, '@')}])",
+					Operations: &AccessApplicationScimMappingOperations{
+						Create: BoolPtr(true),
+						Update: BoolPtr(true),
+						Delete: BoolPtr(true),
+					},
+					Strictness: "strict",
+				},
+			},
+		},
+	})
+
+	if assert.NoError(t, err) {
+		assert.Equal(t, fullAccessApplication, actual)
+	}
+}