diff --git a/sigma-embed-sharepoint/.gitignore b/sigma-embed-sharepoint/.gitignore new file mode 100644 index 00000000..45518881 --- /dev/null +++ b/sigma-embed-sharepoint/.gitignore @@ -0,0 +1,11 @@ +# Node +node_modules/ +npm-debug.log* +package-lock.json + +# Azure Functions local settings (DO NOT COMMIT SECRETS) +local.settings.json + +# VS Code cruft +.vscode/* +!.vscode/launch.json diff --git a/sigma-embed-sharepoint/.nvmrc b/sigma-embed-sharepoint/.nvmrc new file mode 100644 index 00000000..2edeafb0 --- /dev/null +++ b/sigma-embed-sharepoint/.nvmrc @@ -0,0 +1 @@ +20 \ No newline at end of file diff --git a/sigma-embed-sharepoint/host.json b/sigma-embed-sharepoint/host.json new file mode 100644 index 00000000..5ce59573 --- /dev/null +++ b/sigma-embed-sharepoint/host.json @@ -0,0 +1 @@ +{ "version": "2.0" } diff --git a/sigma-embed-sharepoint/jwt/function.json b/sigma-embed-sharepoint/jwt/function.json new file mode 100644 index 00000000..c3d4c152 --- /dev/null +++ b/sigma-embed-sharepoint/jwt/function.json @@ -0,0 +1,13 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ "get", "post" ], + "route": "jwt" + }, + { "type": "http", "direction": "out", "name": "res" } + ] +} diff --git a/sigma-embed-sharepoint/jwt/index.js b/sigma-embed-sharepoint/jwt/index.js new file mode 100644 index 00000000..8ec7cc5f --- /dev/null +++ b/sigma-embed-sharepoint/jwt/index.js @@ -0,0 +1,111 @@ +// Azure Functions (Node 20+, classic model) +// HS256 JWT signing with built-in crypto (no external deps) +const crypto = require("crypto"); + +/** base64url helper */ +function b64url(input) { + return Buffer.from(input) + .toString("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); +} + +/** create HS256 JWT (adds kid to header) */ +function signHS256(payload, secret, kid) { + const header = { alg: "HS256", typ: "JWT" }; + if (kid) header.kid = kid; + + const encHeader = b64url(JSON.stringify(header)); + const encPayload = b64url(JSON.stringify(payload)); + const data = `${encHeader}.${encPayload}`; + + const sig = crypto + .createHmac("sha256", secret) + .update(data) + .digest("base64") + .replace(/=/g, "") + .replace(/\+/g, "-") + .replace(/\//g, "_"); + + return `${data}.${sig}`; +} + +module.exports = async function (context, req) { + try { + // ---- config from app settings ---- + const { + BASE_URL, + CLIENT_ID, + SECRET, + ACCOUNT_TYPE = "Embed", + TEAM = "", + SESSION_LENGTH = "300", + DEV_EMAIL = "" + } = process.env; + + if (!BASE_URL || !CLIENT_ID || !SECRET) { + context.res = { status: 500, body: { error: "config_missing" } }; + return; + } + + // ---- caller identity (QS-friendly) ---- + // Use ?email=... or body.email, else fall back to DEV_EMAIL + const email = + (req.query && req.query.email) || + (req.body && req.body.email) || + DEV_EMAIL; + + if (!email) { + context.res = { status: 401, body: { error: "email_required" } }; + return; + } + + // ---- JWT claims (Sigma-compatible) ---- + const now = Math.floor(Date.now() / 1000); + const maxSession = Math.min(parseInt(SESSION_LENGTH, 10) || 300, 60 * 60 * 24 * 30); // <= 30 days + const jti = + (crypto.randomUUID && crypto.randomUUID()) || + [...crypto.randomBytes(16)] + .map(b => b.toString(16).padStart(2, "0")) + .join(""); + + const teams = TEAM ? [TEAM] : []; + + const payload = { + sub: email, // subject (user) + iss: CLIENT_ID, // your embed client id + iat: now, // issued at + exp: now + maxSession, // expiry + jti, // unique token id + account_type: ACCOUNT_TYPE, + teams + // Add other optional claims if needed: + // aud: "sigma", + // email: email + }; + + const token = signHS256(payload, SECRET, CLIENT_ID); + + // Build Sigma embed URL (workbook URL with :jwt & :embed=true) + const join = BASE_URL.includes("?") ? "&" : "?"; + const embedUrl = `${BASE_URL}${join}:jwt=${encodeURIComponent(token)}&:embed=true`; + + // Optional CORS reflect (QS-friendly) + const origin = req.headers?.origin; + const headers = { + "content-type": "application/json", + "cache-control": "no-store" + }; + if (origin) headers["access-control-allow-origin"] = origin; + + context.res = { + status: 200, + headers, + body: JSON.stringify({ embedUrl, expires_in: maxSession }) + }; + } catch (err) { + context.log.error(err); + context.res = { status: 500, body: { error: "jwt_mint_failed" } }; + } +}; diff --git a/sigma-embed-sharepoint/local.settings.json b/sigma-embed-sharepoint/local.settings.json new file mode 100644 index 00000000..50b74c1c --- /dev/null +++ b/sigma-embed-sharepoint/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": true, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "CfDJ8C04gdgkYIZIk6tYemE73sc9GtnC+zhi+s9bYBxVbBraCRyCZI3HG1dKhwDxtcZTBryD0MKP+6UaEOV+2RsdShQEJpahK4Himy07VDrK3AkbBUwW3KunDHN4u52um/85DQ==" + }, + "ConnectionStrings": {} +} \ No newline at end of file diff --git a/sigma-embed-sharepoint/local.settings.template.json b/sigma-embed-sharepoint/local.settings.template.json new file mode 100644 index 00000000..7eccc9cb --- /dev/null +++ b/sigma-embed-sharepoint/local.settings.template.json @@ -0,0 +1,14 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "node", + "BASE_URL": "https://app.sigmacomputing.com//workbook/-", + "CLIENT_ID": "", + "SECRET": "", + "ACCOUNT_TYPE": "Embed", + "TEAM": "Embed_Users", + "SESSION_LENGTH": "300", + "DEV_EMAIL": "sharepoint@test.com" + } +} \ No newline at end of file diff --git a/sigma-embed-sharepoint/package.json b/sigma-embed-sharepoint/package.json new file mode 100644 index 00000000..2fc613e8 --- /dev/null +++ b/sigma-embed-sharepoint/package.json @@ -0,0 +1,14 @@ +{ + "name": "sigma-embed-sharepoint", + "private": true, + "engines": { "node": ">=20 <21" }, + "scripts": { + "start": "npx func start --verbose", + "start:v20": "nvm exec 20 npx func start --verbose", + "deploy": "func azure functionapp publish $APP" + }, + "devDependencies": { + "azure-functions-core-tools": "4", + "azurite": "^3" + } +}