{
  "nodes": [
    {
      "id": "node-trigger",
      "name": "When clicking 'Test workflow'",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -384,
        448
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "node-config",
      "name": "Configuration",
      "type": "n8n-nodes-base.set",
      "position": [
        -160,
        448
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "cfg-api-base",
              "name": "SF_API_BASE_URL",
              "type": "string",
              "value": "https://apiXX.successfactors.com/odata/v2"
            },
            {
              "id": "cfg-idp",
              "name": "SF_IDP_URL",
              "type": "string",
              "value": "https://apiXX.successfactors.com/oauth/idp"
            },
            {
              "id": "cfg-token",
              "name": "SF_TOKEN_URL",
              "type": "string",
              "value": "https://apiXX.successfactors.com/oauth/token"
            },
            {
              "id": "cfg-company",
              "name": "company_id",
              "type": "string",
              "value": "<your SF company ID>"
            },
            {
              "id": "cfg-client",
              "name": "client_id",
              "type": "string",
              "value": "<API Key from OAuth2 client registration>"
            },
            {
              "id": "cfg-user",
              "name": "user_id",
              "type": "string",
              "value": "<SF user ID to authenticate as>"
            },
            {
              "id": "cfg-key",
              "name": "private_key",
              "type": "string",
              "value": "<base64 key body \u2014 from PEM file, no BEGIN/END lines, no line breaks>"
            },
            {
              "id": "cfg-top",
              "name": "top",
              "type": "number",
              "value": 20
            },
            {
              "id": "cfg-select",
              "name": "select",
              "type": "string",
              "value": "personIdExternal,perPersonUuid"
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "node-get-saml",
      "name": "Get SAML Assertion",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        64,
        448
      ],
      "parameters": {
        "url": "={{ $('Configuration').first().json.SF_IDP_URL }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "bodyParameters": {
          "parameters": [
            {
              "name": "client_id",
              "value": "={{ $('Configuration').first().json.client_id }}"
            },
            {
              "name": "user_id",
              "value": "={{ $('Configuration').first().json.user_id }}"
            },
            {
              "name": "token_url",
              "value": "={{ $('Configuration').first().json.SF_TOKEN_URL }}"
            },
            {
              "name": "private_key",
              "value": "={{ $('Configuration').first().json.private_key }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "node-get-token",
      "name": "Get Bearer Token",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        288,
        448
      ],
      "parameters": {
        "url": "={{ $('Configuration').first().json.SF_TOKEN_URL }}",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "contentType": "form-urlencoded",
        "bodyParameters": {
          "parameters": [
            {
              "name": "company_id",
              "value": "={{ $('Configuration').first().json.company_id }}"
            },
            {
              "name": "client_id",
              "value": "={{ $('Configuration').first().json.client_id }}"
            },
            {
              "name": "grant_type",
              "value": "urn:ietf:params:oauth:grant-type:saml2-bearer"
            },
            {
              "name": "assertion",
              "value": "={{ $json.data }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "node-fetch",
      "name": "Fetch PerPerson from SF",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        512,
        448
      ],
      "parameters": {
        "url": "={{ $('Configuration').first().json.SF_API_BASE_URL }}/PerPerson",
        "options": {},
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "$format",
              "value": "json"
            },
            {
              "name": "$top",
              "value": "={{ $('Configuration').first().json.top }}"
            },
            {
              "name": "$select",
              "value": "={{ $('Configuration').first().json.select }}"
            },
            {
              "name": "$expand",
              "value": "employmentNav"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.access_token }}"
            }
          ]
        }
      },
      "typeVersion": 4.3
    },
    {
      "id": "node-flatten",
      "name": "Flatten Results",
      "type": "n8n-nodes-base.code",
      "position": [
        736,
        448
      ],
      "parameters": {
        "jsCode": "// Unpack the OData v2 d.results wrapper from the SF API response\nconst persons = $input.first().json?.d?.results ?? [];\nconst output = [];\n\nfor (const person of persons) {\n  const employments = person.employmentNav?.results ?? [];\n\n  if (employments.length === 0) {\n    // Include persons who have no employment record\n    output.push({\n      json: {\n        personIdExternal: person.personIdExternal ?? null,\n        perPersonUuid:    person.perPersonUuid    ?? null,\n        empStartDate:     null,\n        empEndDate:       null,\n        userId:           null,\n      }\n    });\n  } else {\n    for (const emp of employments) {\n      output.push({\n        json: {\n          personIdExternal: person.personIdExternal ?? null,\n          perPersonUuid:    person.perPersonUuid    ?? null,\n          empStartDate:     emp.startDate           ?? null,\n          empEndDate:       emp.endDate             ?? null,\n          userId:           emp.userId              ?? null,\n        }\n      });\n    }\n  }\n}\n\nreturn output;"
      },
      "typeVersion": 2
    },
    {
      "id": "sticky-overview",
      "name": "Overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1168,
        -144
      ],
      "parameters": {
        "color": 3,
        "width": 668,
        "height": 936,
        "content": "## SuccessFactors read Person & Employment \u2014 via SAML 2.0 Bearer Assertion based on OAuth 2 configuration.\n\n**Note:** SuccessFactors uses a proprietary **SAML 2.0 Bearer Assertion** flow \u2014 n8n's built-in OAuth2 credential does not work here. Basic Authentication is not recommended as a secure authentication mechanism.\n\n### Flow\n1. **Configuration** \u2014 URLs, credentials, private key\n2. **Get SAML Assertion** \u2192 POST to `/oauth/idp` \n3. **Get Bearer Token** \u2192 POST to `/oauth/token`\n4. **Fetch PerPerson** \u2192 OData v2 GET with Bearer token\n5. **Flatten Results** \u2192 one item per person-employment record\n\nExample URLs can be found in configuration node.\n\n### Setup\n1. Register OAuth2 client in SF Admin \u2192 `Go to Manage OAuth2 Client` Applications. Generate a new certificate from there.  \n2. `Register new client application` and provide an application name and URL which are easy to understand. Both can be virtual and must not exist.\n3. Optional step: you can limit the client registration to a single user, which permissions will be relevant for the API in SuccessFactors. I recommend keeping this empty, since the SAML Assertion requires to limit on a user too.\n4. Press `Generate X.509 Certificate` and provide at least a virtual Common Name (CN) and limit the validity of the certificate to your needs.\n5. When generated you got the necessary certificate and **API Key** which is our `client_id`.\n6. Export Certificate.pem from the OAuth 2 Client registration in SuccessFactors. Open with a text editor and copy base64 content between `<redacted-credential>\n` and `-----END ENCRYPTED PRIVATE KEY-----`. \n--> That is your `private_key`.\n3. Fill in the Configuration node.\n\n### Extend\n- Add `personalInfoNav`, `jobInfoNav` to ``\n- Loop + `` for full pagination\n- Replace Manual Trigger with Schedule Trigger"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-section-1",
      "name": "Section: Configure",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -432,
        32
      ],
      "parameters": {
        "color": 0,
        "width": 400,
        "height": 374,
        "content": "### 1 \u2014 Configure\n- `SF_API_BASE_URL` \u2014 OData v2 base URL\n- `SF_IDP_URL` \u2014 assertion endpoint\n- `SF_TOKEN_URL` \u2014 token endpoint\n- `company_id` \u2014 SF tenant ID\n- `client_id` \u2014 API Key from OAuth2 registration\n- `user_id` \u2014 **Mandatory.** SF user the token is issued for. Data access is scoped to this user's permissions \u2014 use a service account.\n- `private_key` \u2014 base64 body from the exported `.pem` (no headers, no line breaks)\n- `top` / `select` \u2014 OData query params"
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-section-2",
      "name": "Section: Get Token",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        80,
        32
      ],
      "parameters": {
        "color": 0,
        "width": 400,
        "height": 367,
        "content": "### 2 \u2014 Get Token\n**Get SAML Assertion** \u2014 POSTs `client_id`, `user_id`, `token_url`, `private_key` to `/oauth/idp`. Returns a signed SAML 2.0 XML assertion.\n\n**Get Bearer Token** \u2014 POSTs assertion + `company_id` + `client_id` to `/oauth/token` with `grant_type=urn:ietf:params:oauth:grant-type:saml2-bearer`. Returns `access_token`."
      },
      "typeVersion": 1
    },
    {
      "id": "sticky-section-3",
      "name": "Section: Fetch & Flatten",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        560,
        32
      ],
      "parameters": {
        "color": 0,
        "width": 400,
        "height": 369,
        "content": "### 3 \u2014 Fetch & Flatten\n**Fetch PerPerson** \u2014 GET `/PerPerson?=employmentNav` with `Authorization: Bearer <token>`.\n\n**Flatten Results** \u2014 unpacks `d.results` and `employmentNav.results`. Outputs one item per person-employment combination."
      },
      "typeVersion": 1
    }
  ],
  "connections": {
    "Configuration": {
      "main": [
        [
          {
            "node": "Get SAML Assertion",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get Bearer Token": {
      "main": [
        [
          {
            "node": "Fetch PerPerson from SF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get SAML Assertion": {
      "main": [
        [
          {
            "node": "Get Bearer Token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch PerPerson from SF": {
      "main": [
        [
          {
            "node": "Flatten Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking 'Test workflow'": {
      "main": [
        [
          {
            "node": "Configuration",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}