{
  "name": "CC-09 Login SSO (Client Care)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "cc/login-sso",
        "responseMode": "responseNode",
        "options": {
          "allowedOrigins": "*"
        }
      },
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        220,
        300
      ],
      "id": "cc09-webhook",
      "name": "Webhook"
    },
    {
      "parameters": {
        "jsCode": "// CC-09: Extract JWT Parts from Microsoft id_token\n// Input: POST body { id_token: '...' }\n// Output: JWT header info for JWKS lookup + signature verification\n\nconst body = $input.first().json.body || {};\nconst idToken = body.id_token || '';\n\nif (!idToken) {\n  return [{ json: { valid: false, error: 'Missing id_token in request body' } }];\n}\n\nconst parts = idToken.split('.');\nif (parts.length !== 3) {\n  return [{ json: { valid: false, error: 'Invalid JWT format: expected 3 parts' } }];\n}\n\ntry {\n  const headerB64 = parts[0].replace(/-/g, '+').replace(/_/g, '/');\n  const headerJson = Buffer.from(headerB64, 'base64').toString('utf8');\n  const header = JSON.parse(headerJson);\n\n  if (!header.kid) {\n    return [{ json: { valid: false, error: 'JWT header missing kid claim' } }];\n  }\n  if (header.alg !== 'RS256') {\n    return [{ json: { valid: false, error: `Unsupported JWT algorithm: ${header.alg}` } }];\n  }\n\n  return [{ json: {\n    valid: true,\n    idToken,\n    kid: header.kid,\n    alg: header.alg,\n    signedContent: parts[0] + '.' + parts[1],\n    signatureB64url: parts[2],\n    payloadB64: parts[1]\n  } }];\n} catch (e) {\n  return [{ json: { valid: false, error: 'Failed to decode JWT header: ' + e.message } }];\n}"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ],
      "id": "cc09-extract-jwt",
      "name": "Extract JWT Parts"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "cc09-if-valid",
              "leftValue": "={{ $json.valid }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        660,
        300
      ],
      "id": "cc09-if-valid",
      "name": "IF Valid JWT"
    },
    {
      "parameters": {
        "url": "https://login.microsoftonline.com/8d1a9049-44e6-4a26-b9e5-d0c405e82e30/discovery/v2.0/keys",
        "options": {}
      },
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        880,
        200
      ],
      "id": "cc09-fetch-jwks",
      "name": "Fetch Microsoft JWKS"
    },
    {
      "parameters": {
        "jsCode": "// CC-09: Verify JWT Signature + Validate Claims\n// Same RSA-SHA256 verification as WF20\n\nconst crypto = require('crypto');\n\nconst jwtData = $('Extract JWT Parts').first().json;\nconst kid = jwtData.kid;\nconst signedContent = jwtData.signedContent;\nconst signatureB64url = jwtData.signatureB64url;\nconst payloadB64 = jwtData.payloadB64;\n\nconst jwksResponse = $input.first().json;\nconst keys = jwksResponse.keys || [];\n\nconst matchingKey = keys.find(k => k.kid === kid);\nif (!matchingKey) {\n  return [{ json: { verified: false, error: `No matching key found for kid: ${kid}` } }];\n}\n\n// Build RSA public key from JWK\nconst publicKey = crypto.createPublicKey({\n  key: { kty: 'RSA', n: matchingKey.n, e: matchingKey.e },\n  format: 'jwk'\n});\n\n// Convert base64url signature to buffer\nconst sigB64 = signatureB64url.replace(/-/g, '+').replace(/_/g, '/');\nconst sigPadded = sigB64 + '='.repeat((4 - sigB64.length % 4) % 4);\nconst signatureBuffer = Buffer.from(sigPadded, 'base64');\n\n// Verify RSA-SHA256 signature\nconst verifier = crypto.createVerify('RSA-SHA256');\nverifier.update(signedContent);\nconst isValid = verifier.verify(publicKey, signatureBuffer);\n\nif (!isValid) {\n  return [{ json: { verified: false, error: 'JWT signature verification failed' } }];\n}\n\n// Decode payload\nconst payloadPadded = payloadB64.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - payloadB64.length % 4) % 4);\nconst payloadJson = Buffer.from(payloadPadded, 'base64').toString('utf8');\nconst payload = JSON.parse(payloadJson);\n\n// Validate issuer\nconst expectedIssuer = 'https://login.microsoftonline.com/8d1a9049-44e6-4a26-b9e5-d0c405e82e30/v2.0';\nif (payload.iss !== expectedIssuer) {\n  return [{ json: { verified: false, error: `Invalid issuer: ${payload.iss}` } }];\n}\n\n// Validate audience (same SPA app as booking dashboard)\nconst expectedAudience = '4df869dd-ca95-49dd-8939-aa796e515df5';\nif (payload.aud !== expectedAudience) {\n  return [{ json: { verified: false, error: `Invalid audience: ${payload.aud}` } }];\n}\n\n// Validate expiration\nconst now = Math.floor(Date.now() / 1000);\nif (payload.exp && payload.exp < now) {\n  return [{ json: { verified: false, error: 'Token has expired' } }];\n}\nif (payload.nbf && payload.nbf > now + 300) {\n  return [{ json: { verified: false, error: 'Token not yet valid' } }];\n}\n\n// Extract email\nconst email = (payload.email || payload.preferred_username || payload.upn || '').toLowerCase().trim();\nif (!email) {\n  return [{ json: { verified: false, error: 'No email found in token claims' } }];\n}\n\nreturn [{ json: {\n  verified: true,\n  email,\n  name: payload.name || '',\n  oid: payload.oid || '',\n  tid: payload.tid || ''\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ],
      "id": "cc09-verify-jwt",
      "name": "Verify JWT Signature"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "cc09-if-verified",
              "leftValue": "={{ $json.verified }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        1320,
        200
      ],
      "id": "cc09-if-verified",
      "name": "IF Verified"
    },
    {
      "parameters": {
        "operation": "search",
        "base": {
          "__rl": true,
          "value": "appPccm6NkaJdvqwy",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "tbl2dmVrEV9sKu5o9",
          "mode": "id"
        },
        "filterByFormula": "=AND(LOWER({Email}) = '{{ $json.email }}', {Is_Active} = TRUE())",
        "options": {
          "alwaysOutputData": true
        }
      },
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        1540,
        100
      ],
      "id": "cc09-find-user",
      "name": "Find CC_User by Email"
    },
    {
      "parameters": {
        "jsCode": "// CC-09: Build CRM Login Response\n// Includes user profile with CRM role, team, permissions\n// Also includes staff info for booking dashboard access + token sync flag\n\nconst ccUserItems = $('Find CC_User by Email').all();\n\nif (!ccUserItems || ccUserItems.length === 0 || !ccUserItems[0].json.id) {\n  return [{ json: {\n    found: false,\n    error: 'No active CRM account found for this email. Contact your administrator.'\n  } }];\n}\n\nconst user = ccUserItems[0].json;\nconst jwtClaims = $('Verify JWT Signature').all()[0].json;\n\n// Build user profile for frontend\nconst userProfile = {\n  id: user.id,\n  name: user.Name || jwtClaims.name || '',\n  email: user.Email || jwtClaims.email || '',\n  role: user.Role || 'READ_ONLY',\n  is_admin: (user.Role === 'ADMIN'),\n  entra_oid: user.Entra_Object_ID || jwtClaims.oid || '',\n  is_active: user.Is_Active || false\n};\n\n// Get team info if linked\nif (user.Team && user.Team.length > 0) {\n  userProfile.team_id = user.Team[0];\n}\n\n// Get manager info if linked\nif (user.Manager && user.Manager.length > 0) {\n  userProfile.manager_id = user.Manager[0];\n}\n\n// Dashboard token (pre-generated in Airtable)\nconst token = user.Dashboard_Token || '';\n\nif (!token) {\n  return [{ json: {\n    found: false,\n    error: 'CRM account has no dashboard token. Contact your administrator.'\n  } }];\n}\n\n// Check Staff table for booking dashboard access\nlet staffRecordId = '';\nlet syncToken = false;\ntry {\n  const staffItems = $('Find Staff by Email').all();\n  if (staffItems && staffItems.length > 0 && staffItems[0].json.id) {\n    const staff = staffItems[0].json;\n    userProfile.staff_id = staff.id;\n    userProfile.staff_slug = staff.Slug || '';\n    staffRecordId = staff.id;\n    // If Staff token differs from CC_Users token, flag for sync\n    if (staff.Dashboard_Token !== token) {\n      syncToken = true;\n    }\n  }\n} catch (e) {\n  // Staff lookup may not return results - that is OK\n}\n\nreturn [{ json: {\n  found: true,\n  token,\n  user: userProfile,\n  record_id: user.id,\n  staff_record_id: staffRecordId,\n  sync_token: syncToken\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1980,
        100
      ],
      "id": "cc09-build-response",
      "name": "Build CRM Response"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "cc09-if-found",
              "leftValue": "={{ $json.found }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2420,
        100
      ],
      "id": "cc09-if-found",
      "name": "IF User Found"
    },
    {
      "parameters": {
        "jsCode": "// CC-09: Prepare Login Response for Respond to Webhook node\n// Uses $input (data passed through IF User Found from Build CRM Response)\n// Avoids $() cross-node refs which break after IF splits\n\nconst resp = $input.all()[0].json;\nreturn [{ json: {\n  success: true,\n  token: resp.token || '',\n  user: resp.user || {}\n} }];"
      },
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3080,
        0
      ],
      "id": "cc09-prepare-response",
      "name": "Prepare Login Response"
    },
    {
      "parameters": {
        "respondWith": "firstIncomingItem",
        "options": {
          "responseCode": 200,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        3300,
        0
      ],
      "id": "cc09-respond-200",
      "name": "Respond 200 OK"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: false, error: $json.error || 'Missing or invalid id_token' }) }}",
        "options": {
          "responseCode": 400,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        880,
        420
      ],
      "id": "cc09-respond-400",
      "name": "Respond 400 Bad Request"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: false, error: $json.error || 'Token verification failed' }) }}",
        "options": {
          "responseCode": 401,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1540,
        320
      ],
      "id": "cc09-respond-401",
      "name": "Respond 401 Unauthorized"
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ JSON.stringify({ success: false, error: $json.error || 'No active CRM account found' }) }}",
        "options": {
          "responseCode": 403,
          "responseHeaders": {
            "entries": [
              {
                "name": "Content-Type",
                "value": "application/json"
              }
            ]
          }
        }
      },
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        2640,
        220
      ],
      "id": "cc09-respond-403",
      "name": "Respond 403 Forbidden"
    },
    {
      "parameters": {
        "operation": "search",
        "base": {
          "__rl": true,
          "value": "appPccm6NkaJdvqwy",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "tblABg23aP4YQ36aN",
          "mode": "id"
        },
        "filterByFormula": "=AND(LOWER({Email}) = '{{ $('Verify JWT Signature').first().json.email }}', {Active} = TRUE())",
        "options": {
          "alwaysOutputData": true
        }
      },
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        1760,
        100
      ],
      "id": "cc09-find-staff",
      "name": "Find Staff by Email"
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "id": "cc09-if-sync",
              "leftValue": "={{ $json.sync_token }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true"
              }
            }
          ],
          "combinator": "and"
        }
      },
      "type": "n8n-nodes-base.if",
      "typeVersion": 2,
      "position": [
        2640,
        0
      ],
      "id": "cc09-if-sync",
      "name": "IF Needs Staff Sync"
    },
    {
      "parameters": {
        "operation": "update",
        "base": {
          "__rl": true,
          "value": "appPccm6NkaJdvqwy",
          "mode": "id"
        },
        "table": {
          "__rl": true,
          "value": "tblABg23aP4YQ36aN",
          "mode": "id"
        },
        "id": "={{ $json.staff_record_id }}",
        "fields": {
          "values": [
            {
              "fieldId": "Dashboard_Token",
              "fieldValue": "={{ $json.token }}"
            }
          ]
        },
        "options": {
          "alwaysOutputData": true
        }
      },
      "type": "n8n-nodes-base.airtable",
      "typeVersion": 2.1,
      "position": [
        2860,
        -100
      ],
      "id": "cc09-sync-token",
      "name": "Sync Staff Token"
    }
  ],
  "connections": {
    "Webhook": {
      "main": [
        [
          {
            "node": "Extract JWT Parts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract JWT Parts": {
      "main": [
        [
          {
            "node": "IF Valid JWT",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Valid JWT": {
      "main": [
        [
          {
            "node": "Fetch Microsoft JWKS",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 400 Bad Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Microsoft JWKS": {
      "main": [
        [
          {
            "node": "Verify JWT Signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify JWT Signature": {
      "main": [
        [
          {
            "node": "IF Verified",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Verified": {
      "main": [
        [
          {
            "node": "Find CC_User by Email",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 401 Unauthorized",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find CC_User by Email": {
      "main": [
        [
          {
            "node": "Find Staff by Email",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Build CRM Response": {
      "main": [
        [
          {
            "node": "IF User Found",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF User Found": {
      "main": [
        [
          {
            "node": "IF Needs Staff Sync",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond 403 Forbidden",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Prepare Login Response": {
      "main": [
        [
          {
            "node": "Respond 200 OK",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Find Staff by Email": {
      "main": [
        [
          {
            "node": "Build CRM Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Needs Staff Sync": {
      "main": [
        [
          {
            "node": "Sync Staff Token",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Prepare Login Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Sync Staff Token": {
      "main": [
        [
          {
            "node": "Prepare Login Response",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "tags": [
    {
      "name": "Client Care"
    }
  ]
}