diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json index b3c4037a..ed7d6bc5 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/Orchestrator_Parameters.json @@ -218,6 +218,13 @@ "type": "String", "value": "changeme-oauth" }, + "scimCredentialLocationInRequest": { + "metadata": { + "description": "Required OAuth2 client credential location: Header or Body (used with OAuth2ClientCredentialsGrant)" + }, + "type": "String", + "value": "Header" + }, "scimContentType": { "metadata": { "description": "Content-Type header for SCIM API calls" @@ -231,7 +238,7 @@ }, "scimOAuthScope": { "metadata": { - "description": "OAuth2 scope for token acquisition (optional)" + "description": "Optional space-delimited OAuth2 scopes for token acquisition" }, "type": "String", "value": "" diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md index 9d72bd45..3a05925a 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIM-Validation-Test-Overview.md @@ -67,7 +67,7 @@ Tests that don't apply are reported as **SKIPPED** (not failures). However, grou | 18 | **SCIM_User_Update_Test** | mandatory | Direct `PATCH /Users/{id}` with auto-generated update values for eligible attributes (single-valued, string/integer/datetime/boolean, excluding groups/roles/id/schemas/meta/active). | PATCH returns 200/204; subsequent GET returns 200/204; all PATCHed attribute values match in the response; zero mismatches. Passes with `(no updatable attributes)` if no eligible attributes exist. | | 19 | **SCIM_Group_Create_Test** | mandatory | Direct `POST /Groups` with SCIM group body. **FAILED** if groups not supported. | HTTP 200 or 201; response contains non-empty `id`; returned `displayName` matches sent `displayName`. | | 20 | **SCIM_Group_Update_Test** | mandatory | Direct `PATCH /Groups/{id}` with attribute updates (single-valued, non-reference, excluding id/members/schemas/meta). | PATCH returns 200/204; GET returns 200/204; all PATCHed attributes match; zero mismatches. | -| 21 | **Validate_Credentials_Test** | mandatory | OAuth 2.0 Client Credentials flow — acquires token from ISV's `scimTokenEndpoint`, then validates SCIM access with the token. **Skipped** when `scimTokenEndpoint` is empty (static bearer token). | Token endpoint returns 2xx with `access_token`; token validity between 60–360 minutes; SCIM endpoint returns 2xx when called with the token. | +| 21 | **Validate_Credentials_Test** | mandatory | OAuth 2.0 Client Credentials flow — acquires a token from the ISV's `scimTokenEndpoint`, sends credentials in the required `Header` or `Body` location, includes the optional space-delimited scope string, then validates SCIM access with the token. **Skipped** when `scimTokenEndpoint` is empty (static bearer token). | Token endpoint returns 2xx with `access_token`; token validity between 60–360 minutes; SCIM endpoint returns 2xx when called with the token. | | 22 | **Federated_Identity_Test** | mandatory | Workload identity federation — acquires Entra ID token, then exchanges it for ISV token via one of three flows: Google Service Account, Google STS, or generic federated endpoint. **Skipped** when federated parameters are empty. | Entra token acquisition returns 2xx with `access_token`; federated token exchange returns 2xx with valid token. Supports Google SA flow (STS exchange → SA impersonation), Google STS flow (direct exchange), and generic flow (client assertion). | | 23 | **SCIM_User_Pagination_Test** | mandatory | Ensures ≥11 users exist (creates throwaway users if needed), then paginates `GET /Users?startIndex=N&count=5` across pages. Cleans up created users afterward. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected value (`page * 5 + 1`); zero failed page verifications. | | 24 | **SCIM_Group_Pagination_Test** | optional | Same as user pagination but for `/Groups`. Failure = WARNING. **Skipped** if groups not supported. | At least 2 pages traversed; all pages return HTTP 200; response `startIndex` matches expected; zero failed verifications. | diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json index be7993bd..690ec0a4 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SCIMTests_Workflow.json @@ -3462,10 +3462,8 @@ "inputs": { "uri": "@{parameters('scimTokenEndpoint')}", "method": "POST", - "headers": { - "Content-Type": "application/x-www-form-urlencoded" - }, - "body": "@{concat('grant_type=client_credentials&client_id=', encodeUriComponent(parameters('scimClientId')), '&client_secret=', encodeUriComponent(parameters('scimClientSecret')), if(empty(coalesce(parameters('scimOAuthScope'), '')), '', concat('&scope=', encodeUriComponent(parameters('scimOAuthScope')))))}" + "headers": "@if(equals(toLower(coalesce(parameters('scimCredentialLocationInRequest'), 'Header')), 'header'), json(concat('{\"Content-Type\":\"application/x-www-form-urlencoded\",\"Authorization\":\"Basic ', base64(concat(encodeUriComponent(parameters('scimClientId')), ':', encodeUriComponent(parameters('scimClientSecret')))), '\"}')), json('{\"Content-Type\":\"application/x-www-form-urlencoded\"}'))", + "body": "@{concat('grant_type=client_credentials', if(equals(toLower(coalesce(parameters('scimCredentialLocationInRequest'), 'Header')), 'body'), concat('&client_id=', encodeUriComponent(parameters('scimClientId')), '&client_secret=', encodeUriComponent(parameters('scimClientSecret'))), ''), if(empty(coalesce(parameters('scimOAuthScope'), '')), '', concat('&scope=', encodeUriComponent(parameters('scimOAuthScope')))))}" } }, "Check_Token_Response": { diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md index 7270737a..44b27ece 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/SetupLogicApp-Standard-Agent.md @@ -408,12 +408,15 @@ hours. will note that `Validate_Credentials_Test` will be skipped (this is expected). - **OAuth client credentials** — If you choose this, the agent will ask - 4 follow-up questions: + 5 follow-up questions: - **Client ID** — Your OAuth application’s client ID - **Client Secret** — Your OAuth application’s client secret - **Token Endpoint URL** — e.g., `https://auth.myapp.com/oauth/token` - - **OAuth Scope** — The scope required for SCIM access (leave empty if - not applicable) + - **How credentials are sent** — Required; select **Header** (the + default, using HTTP Basic authentication) or **Body** (`client_id` + and `client_secret` form fields) + - **OAuth Scope** — Optional; enter multiple scopes as one string + separated by spaces, or enter `none` ### Question 4: Azure Subscription Selection @@ -1003,6 +1006,10 @@ style="width:4.43812in;height:2.54202in" /> +Update `scimCredentialLocationInRequest` with `Header` or `Body`. +For OAuth endpoints that require scopes, update `scimOAuthScope` with +the space-delimited scope string. + ## Run the Logic App 34. You’re now ready to run the Logic app! Navigate to **WorkFlows\>** diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION index d7213f31..f0750610 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/VERSION @@ -1 +1 @@ -12.0 +13.0 diff --git a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md index afc1475d..68db78b5 100644 --- a/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md +++ b/Microsoft.SCIM.LogicAppValidationTemplate/StandardLogicApp/scim-onboarding.agent.md @@ -54,19 +54,22 @@ Collect the ISV's SCIM endpoint and bearer token, validate their Azure environme b. **Bearer token** (must be long-lived — warn if JWT expires within 2 hours) c. **Authentication method** — ask: "Does your SCIM endpoint use OAuth client credentials or a static bearer token?" - - If **OAuth**: ask for all 4 parameters, one at a time: + - If **OAuth**: ask for all 5 parameters, one at a time: - Client ID - Client Secret - Token endpoint URL (e.g., `https://auth.example.com/oauth/token`) + - How credentials are sent — **MANDATORY:** use `ask_user` with exactly two options: `Header` and `Body`. Set `Header` as the suggested default, but require the customer to confirm their selection. + - `Header` sends the client ID and secret using HTTP Basic authentication and omits them from the form body. + - `Body` sends `client_id` and `client_secret` in the form body. - OAuth scope — **MANDATORY: use this EXACT prompt text in `ask_user`, do NOT paraphrase, do NOT use the words "leave blank", "leave empty", "optional", or "if not required":** - > **OAuth scope** (e.g., `https://graph.microsoft.com/.default`). + > **OAuth scope** (e.g., `https://graph.microsoft.com/.default`). Enter multiple scopes as one string separated by spaces. > > ⚠️ If your token endpoint does NOT require a scope, type the word `none` (without quotes) and press Enter. > > Do NOT submit an empty box — empty submissions are treated as cancellation and the agent will stop. The agent treats `none` (case-insensitive) as an empty scope when writing `scimOAuthScope` to `parameters.json`. - - If **static bearer token**: record `authMethod = bearer`. The 4 OAuth fields (`scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimOAuthScope`) will be written as empty strings in Phase 4. (Logic App test behavior — including `Validate_Credentials_Test` — is out of scope for Phase 1; see Phase 4 for parameter handling and expected test behavior.) + - If **static bearer token**: record `authMethod = bearer`. The 5 OAuth fields (`scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, `scimOAuthScope`) will be written as empty strings in Phase 4. (Logic App test behavior — including `Validate_Credentials_Test` — is out of scope for Phase 1; see Phase 4 for parameter handling and expected test behavior.) d. **Federated identity test inputs (for `Federated_Identity_Test`)**: - Ask: "Do you want to run the federated identity validation test now?" @@ -216,6 +219,8 @@ Extract from response: | Bearer only | A | Bearer | `authenticationType` + `baseAddress` + `secretToken` | | Bearer + OAuth | B | OAuth | `authenticationType` + `baseAddress` + `oauth2ClientId` + `oauth2ClientSecret` + `oauth2TokenExchangeUri` + `credentialLocationInRequest` | +For Branch B, also include the optional lower-case `scope` connectivity parameter when the ISV provided scopes. Omit `scope` when the ISV answered `none`. + --- **Sub-step 2b-1: validateCredentials (bearer — ALWAYS run this)** @@ -275,7 +280,8 @@ az rest --method PATCH \ # {"key":"oauth2ClientId","value":""}, # {"key":"oauth2ClientSecret","value":""}, # {"key":"oauth2TokenExchangeUri","value":""}, -# {"key":"credentialLocationInRequest","value":"Header"} +# {"key":"credentialLocationInRequest","value":""}, +# {"key":"scope","value":""} # ]} az rest --method PATCH \ --url "https://graph.microsoft.com/beta/servicePrincipals//synchronization/connectivityParameters" \ @@ -285,6 +291,8 @@ az rest --method PATCH \ > Note: `authenticationType=OAuth2ClientCredentialsGrant` is required for OAuth — without it, Graph won't attempt OAuth token acquisition from the stored creds. > +> `credentialLocationInRequest` is required for OAuth and must be exactly `Header` or `Body`, matching the ISV's Phase 1 answer. Add the `scope` entry only when the ISV provided one or more scopes; omit it when the ISV answered `none`. Preserve multiple scopes as one space-delimited string. +> > Inline `validateCredentials` with OAuth keys (i.e. `useSavedCredentials: false` + credentials array including OAuth keys) **works**, but do **not** include `CredentialLocationInRequest` in that inline payload — Graph returns `InternalError: Requested value 'CredentialLocationInRequest' was not found.` Keep that key only in the beta `connectivityParameters` PATCH payload. Then validate saved credentials work (both branches) — **this is the Test Connection**: @@ -303,7 +311,7 @@ az rest --method POST \ |---|---|---| | HTTP 200/204 (empty body) | Test Connection succeeded — Entra can reach the ISV's SCIM endpoint using the saved credentials | Proceed to 2b-3 | | HTTP 400 `CredentialValidationUnavailable` | **Branch A:** bearer token rejected by the ISV's SCIM server (401/403/5xx). **Branch B:** OAuth token exchange failed — wrong client ID, wrong client secret, bad token endpoint URL, scope issue, or the ISV's token endpoint issued a token that their SCIM server rejected. | Surface the inner error verbatim to the ISV. **ABORT.** Do NOT create the sync job — it will immediately quarantine. Wait for the ISV to provide corrected credentials, then re-PATCH `connectivityParameters` and re-validate. | -| HTTP 500 `InternalError` — `"Requested value 'X' was not found"` | Inline validation payload included an unsupported key (for example `CredentialLocationInRequest`) or the connectivity parameters used the wrong key casing | Re-run inline validation with only the supported OAuth keys, then re-PATCH `connectivityParameters` using the lower-case portal keys (`authenticationType`, `baseAddress`, `oauth2ClientId`, `oauth2ClientSecret`, `oauth2TokenExchangeUri`, `credentialLocationInRequest`). Then re-validate. | +| HTTP 500 `InternalError` — `"Requested value 'X' was not found"` | Inline validation payload included an unsupported key (for example `CredentialLocationInRequest`) or the connectivity parameters used the wrong key casing | Re-run inline validation with only the supported OAuth keys, then re-PATCH `connectivityParameters` using the lower-case portal keys (`authenticationType`, `baseAddress`, `oauth2ClientId`, `oauth2ClientSecret`, `oauth2TokenExchangeUri`, `credentialLocationInRequest`, plus `scope` only when provided). Then re-validate. | | Any other 4xx/5xx | Unexpected | Surface verbatim and **ABORT**. | Before aborting or retrying, capture the exact inner error and match it against **Step 6d: Known Issues**. If the response mentions `SystemForCrossDomainIdentityManagementServiceIncompatible`, Group connectivity, or an inner Group `404`, evaluate **Pattern #15**. Do not classify a generic `401`, `403`, rejected bearer token, or OAuth token-exchange failure as Pattern #15. @@ -356,7 +364,8 @@ else: | `scimClientId` | `""` | `` | | `scimClientSecret` | `""` | `` | | `scimTokenEndpoint` | `""` | `` | -| `scimOAuthScope` | `""` | `` | +| `scimCredentialLocationInRequest` | `""` | `` | +| `scimOAuthScope` | `""` | `` | For federated identity testing, also pass these Phase 4 parameters: @@ -822,7 +831,7 @@ Pass criteria (ALL must hold): - `schedule.state` is `Active` If `status.code == Quarantine` with `lastExecError == SystemForCrossDomainIdentityManagementInvalidCredentials` and `lastExecMsg` mentions `BaseAddress`/`SecretToken`/credential, the Step 2b connectivity parameters were rejected or incomplete. Recovery: -1. Re-PATCH `https://graph.microsoft.com/beta/servicePrincipals//synchronization/connectivityParameters` with ONLY the supported keys for the auth mode (bearer: `authenticationType` + `baseAddress` + `secretToken`; OAuth: `authenticationType` + `baseAddress` + `oauth2ClientId` + `oauth2ClientSecret` + `oauth2TokenExchangeUri` + `credentialLocationInRequest`). +1. Re-PATCH `https://graph.microsoft.com/beta/servicePrincipals//synchronization/connectivityParameters` with ONLY the supported keys for the auth mode (bearer: `authenticationType` + `baseAddress` + `secretToken`; OAuth: `authenticationType` + `baseAddress` + `oauth2ClientId` + `oauth2ClientSecret` + `oauth2TokenExchangeUri` + `credentialLocationInRequest`, plus `scope` only when provided). 2. `POST /servicePrincipals//synchronization/jobs//restart` with body `{"criteria":{"resetScope":"Full"}}`. (Note: `credentials`/`watermark`/`escrows`/`quarantineState` are NOT valid restart criteria properties — schema only allows `resetScope`.) 3. `POST /jobs//start` again. 4. Re-run this Step 3h check. If still quarantined, abort and report the exact error. @@ -907,7 +916,8 @@ Update these parameters in the JSON: | `scimClientId` | `` | From Phase 1 — set if the ISV provided OAuth credentials. Used by the LA's `Validate_Credentials_Test` to exercise the OAuth flow independently. Empty string if not provided. **Note: Entra sync always uses bearer token (Step 2b), NOT these OAuth values.** | | `scimClientSecret` | `` | From Phase 1 — same. Empty string if not provided. | | `scimTokenEndpoint` | `` | From Phase 1 — same. Empty string if not provided. | -| `scimOAuthScope` | `` | From Phase 1 — optional, set if provided (empty string if not). | +| `scimCredentialLocationInRequest` | `` | From Phase 1 — mandatory when OAuth client credentials are provided. Empty string for static bearer authentication. | +| `scimOAuthScope` | `` | From Phase 1 — optional; preserve multiple scopes as one space-delimited string. Empty string if the ISV answered `none`. | | `federatedEntraTenantId` | `` | From Phase 1 federated inputs — empty string if not provided. | | `federatedApplicationId` | `` | From Phase 1 federated inputs — empty string if not provided. | | `federatedApplicationClientSecret` | `` | From Phase 1 federated inputs — empty string if not provided. | @@ -924,7 +934,8 @@ After patching the JSON in memory, assert every key below exists at the top leve servicePrincipalId, scimEndpoint, scimBearerToken, scimContentType, testUserDomain, EnabledTests, IsSoftDeleted, defaultUserProperties, defaultGroupProperties, scimTargetUserValues, -scimClientId, scimClientSecret, scimTokenEndpoint, scimOAuthScope, +scimClientId, scimClientSecret, scimTokenEndpoint, +scimCredentialLocationInRequest, scimOAuthScope, federatedEntraTenantId, federatedApplicationId, federatedApplicationClientSecret, federatedTokenEndpoint, federatedClientId, federatedBaseAddress, federatedAudience ``` @@ -935,7 +946,7 @@ If any key is missing, abort Phase 4 and tell the ISV exactly which key is missi After the PUT completes, re-GET `parameters.json` and for each key in the patch table above, assert the returned value matches what was sent. If `servicePrincipalId` was supposed to be `aaa-bbb-ccc` but the read-back shows something else (or the key is missing), abort Phase 4 with the specific mismatch. Do NOT proceed to Phase 5. -If the ISV did not provide OAuth credentials, leave `scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, and `scimOAuthScope` as empty strings. The `Validate_Credentials_Test` will be SKIPPED — note this as expected in the final report. This is unrelated to the Entra sync engine, which always uses the bearer token (`SecretToken`) configured in Step 2b. +If the ISV did not provide OAuth credentials, leave `scimClientId`, `scimClientSecret`, `scimTokenEndpoint`, `scimCredentialLocationInRequest`, and `scimOAuthScope` as empty strings. The `Validate_Credentials_Test` will be SKIPPED — note this as expected in the final report. This is unrelated to the Entra sync engine, which always uses the bearer token (`SecretToken`) configured in Step 2b. If the ISV did not provide federated identity inputs, leave all federated parameters as empty strings (`federatedEntraTenantId`, `federatedApplicationId`, `federatedApplicationClientSecret`, `federatedTokenEndpoint`, `federatedClientId`, `federatedBaseAddress`, `federatedAudience`).