{
  "name": "AAD 人登入(auth-code)→ JWKS 驗簽 → level 授權路由 → Dify RAG (sample)",
  "nodes": [
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "login",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "10000000-0000-0000-0000-000000000001",
      "name": "① Webhook /login(員工進入口)",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        180
      ],
      "webhookId": "login"
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "",
        "options": {
          "responseCode": 302,
          "responseHeaders": {
            "entries": [
              {
                "name": "Location",
                "value": "={{ $env.OIDC_BASE || 'https://login.microsoftonline.com' }}/{{ $env.TENANT }}/oauth2/v2.0/authorize?client_id={{ $env.CLIENT_ID }}&response_type=code&redirect_uri={{ encodeURIComponent($env.REDIRECT_URI) }}&response_mode=query&scope={{ encodeURIComponent($env.SCOPE) }}&state=demo"
              }
            ]
          }
        }
      },
      "id": "10000000-0000-0000-0000-000000000002",
      "name": "② 302 導去 Entra 登入",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        430,
        180
      ]
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "callback",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "20000000-0000-0000-0000-000000000001",
      "name": "③ Webhook /callback(= redirect URI)",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        200,
        420
      ],
      "webhookId": "callback"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $env.OIDC_BASE || 'https://login.microsoftonline.com' }}/{{ $env.TENANT }}/oauth2/v2.0/token",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Content-Type",
              "value": "application/x-www-form-urlencoded"
            }
          ]
        },
        "sendBody": true,
        "contentType": "form-urlencoded",
        "bodyParameters": {
          "parameters": [
            {
              "name": "grant_type",
              "value": "authorization_code"
            },
            {
              "name": "code",
              "value": "={{ $json.query.code }}"
            },
            {
              "name": "client_id",
              "value": "={{ $env.CLIENT_ID }}"
            },
            {
              "name": "client_secret",
              "value": "={{ $env.CLIENT_SECRET }}"
            },
            {
              "name": "redirect_uri",
              "value": "={{ $env.REDIRECT_URI }}"
            },
            {
              "name": "scope",
              "value": "={{ $env.SCOPE }}"
            }
          ]
        },
        "options": {}
      },
      "id": "20000000-0000-0000-0000-000000000002",
      "name": "④ 用 code 換『員工』token",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        430,
        420
      ]
    },
    {
      "parameters": {
        "method": "GET",
        "url": "={{ $env.OIDC_BASE || 'https://login.microsoftonline.com' }}/{{ $env.TENANT }}/discovery/v2.0/keys",
        "options": {}
      },
      "id": "20000000-0000-0000-0000-00000000000b",
      "name": "④b 取 Entra JWKS(公鑰)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        545,
        600
      ]
    },
    {
      "parameters": {
        "mode": "runOnceForAllItems",
        "language": "javaScript",
        "jsCode": "// 驗 Entra 簽發的 JWT:JWKS 對 kid 驗簽 → 檢查 iss/aud/exp/nbf → 讀 roles → level → 挑 Dify key(PEP)。\n// ⚠️ 需在 n8n 設 env:NODE_FUNCTION_ALLOW_BUILTIN=crypto\n// ⚠️ Azure 注意:只有『發給你自己 API(aud=你的 app)的 access token』或『id_token』能被第三方驗簽;\n//    給 MS Graph 的 access token 帶 nonce、無法自行驗。路由請用 id_token 或你自訂 API scope 的 token。\nconst crypto = require('crypto');\nconst tokResp = $('④ 用 code 換『員工』token').first().json;\nconst tok = tokResp.id_token || tokResp.access_token;\nif (!tok) { throw new Error('沒換到 token'); }\n\nconst [h, p, s] = tok.split('.');\nconst header  = JSON.parse(Buffer.from(h, 'base64url').toString());\nconst payload = JSON.parse(Buffer.from(p, 'base64url').toString());\n\n// 1) 簽章:用 Entra JWKS 裡 kid 對應公鑰驗(RS256)—— 這一步擋掉自己捏造的 token\nconst jwks = ($('④b 取 Entra JWKS(公鑰)').first().json.keys) || [];\nconst jwk = jwks.find(k => k.kid === header.kid);\nif (!jwk) { throw new Error('找不到對應公鑰(kid),token 可疑'); }\nconst pub = crypto.createPublicKey({ key: jwk, format: 'jwk' });\nconst ok = crypto.verify('RSA-SHA256', Buffer.from(h + '.' + p), pub, Buffer.from(s, 'base64url'));\nif (!ok) { throw new Error('JWT 簽章無效 —— 擋掉偽造 token'); }\n\n// 2) claim 檢查:exp / nbf / iss / aud(依你的 tenant / app 調整)\nconst now = Math.floor(Date.now() / 1000);\nif ((payload.exp || 0) < now)               { throw new Error('token 已過期(exp)'); }\nif ((payload.nbf || 0) > now + 60)          { throw new Error('token 尚未生效(nbf)'); }\nif ($env.EXPECTED_ISS && payload.iss !== $env.EXPECTED_ISS) { throw new Error('iss 不符,不是你的 tenant 發的'); }\nif ($env.EXPECTED_AUD && payload.aud !== $env.EXPECTED_AUD) { throw new Error('aud 不符,不是給這個 app 的 token'); }\n\n// 3) roles → level → 挑對應 Dify key\nconst roles = payload.roles || [];\nlet level = 'general', keyEnv = 'DIFY_KEY_GENERAL';\nif (roles.includes('RAG.Sensitive'))    { level = 'sensitive'; keyEnv = 'DIFY_KEY_SENSITIVE'; }\nelse if (roles.includes('RAG.Manager')) { level = 'manager';   keyEnv = 'DIFY_KEY_MANAGER'; }\n\n// 登入後第一個問題:demo 用 callback 的 ?q= 帶入;正式應建 session,後續每次問帶 session。\nconst q = $('③ Webhook /callback(= redirect URI)').first().json.query?.q || '你好,請自我介紹';\nreturn [{ json: {\n  level,\n  user: payload.preferred_username || payload.oid || 'user',\n  query: q,\n  difyKey: $env[keyEnv],\n  difyUrl: ($env.DIFY_BASE || 'https://dify.internal') + '/v1/chat-messages'\n} }];"
      },
      "id": "20000000-0000-0000-0000-000000000003",
      "name": "⑤ 讀 roles → level → 挑 key(PEP)",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        760,
        420
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $json.difyUrl }}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "=Bearer {{ $json.difyKey }}"
            },
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={\n  \"inputs\": {},\n  \"query\": {{ JSON.stringify($json.query) }},\n  \"response_mode\": \"blocking\",\n  \"user\": {{ JSON.stringify($json.user) }}\n}",
        "options": {}
      },
      "id": "20000000-0000-0000-0000-000000000004",
      "name": "⑥ 打對應的 Dify app(REST,內部)",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        980,
        420
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { level: $('⑤ 讀 roles → level → 挑 key(PEP)').first().json.level, user: $('⑤ 讀 roles → level → 挑 key(PEP)').first().json.user, answer: $json.answer } }}"
      },
      "id": "20000000-0000-0000-0000-000000000005",
      "name": "⑦ 回傳答案",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1,
      "position": [
        1200,
        420
      ]
    }
  ],
  "connections": {
    "① Webhook /login(員工進入口)": {
      "main": [
        [
          {
            "node": "② 302 導去 Entra 登入",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "③ Webhook /callback(= redirect URI)": {
      "main": [
        [
          {
            "node": "④ 用 code 換『員工』token",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "④ 用 code 換『員工』token": {
      "main": [
        [
          {
            "node": "④b 取 Entra JWKS(公鑰)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "④b 取 Entra JWKS(公鑰)": {
      "main": [
        [
          {
            "node": "⑤ 讀 roles → level → 挑 key(PEP)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "⑤ 讀 roles → level → 挑 key(PEP)": {
      "main": [
        [
          {
            "node": "⑥ 打對應的 Dify app(REST,內部)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "⑥ 打對應的 Dify app(REST,內部)": {
      "main": [
        [
          {
            "node": "⑦ 回傳答案",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {},
  "meta": {
    "_note": "人登入(auth-code)+ 真 JWKS 驗簽版:員工開 /login → 導去 Entra 登入 → Entra callback 回 /callback(= redirect URI)→ ④ 用 code 換『員工』token → ④b 抓 Entra JWKS 公鑰 → ⑤ 驗簽(kid 對公鑰 + crypto.verify RSA-SHA256)+ 檢查 exp/nbf/iss/aud → 讀 roles 分流 → 打對應 Dify app。這一版會擋掉自己捏造的 token(沒有 Entra 私鑰就簽不出有效簽章)。n8n env 需要:NODE_FUNCTION_ALLOW_BUILTIN=crypto、TENANT、CLIENT_ID、CLIENT_SECRET、REDIRECT_URI(你的 /callback 完整網址,要在 Entra App 註冊為 redirect URI)、SCOPE(如 openid profile <你的 API scope> 或 api://<app-id>/.default)、EXPECTED_ISS(如 https://login.microsoftonline.com/<tenant>/v2.0)、EXPECTED_AUD(你的 app/client id 或 API app id URI)、DIFY_BASE、DIFY_KEY_GENERAL/MANAGER/SENSITIVE。Azure 注意:只有『發給你自己 API(aud=你的 app)的 access token』或『id_token』能被第三方自行驗簽;給 MS Graph 的 access token 帶 nonce、無法自驗,路由請用 id_token 或你自訂 API scope 的 token。正式:登入後應發 session(cookie/JWT)給前端,後續每次問帶 session,不要每次重登;JWKS 建議快取(依 Cache-Control),驗簽失敗要寫稽核 log。"
  }
}
