diff --git a/.env.docker.example b/.env.docker.example index 59b8901..9b9c44f 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -1,7 +1,11 @@ PREMIUM_EMBEDDING_TOKEN="" METABASE_JWT_SHARED_SECRET="ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" METABASE_STATIC_EMBEDDING_SECRET="eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" -METABASE_ADMIN_API_KEY="mb_NgLv2g7CFm1VDIhfensgdMhLPYvvKIz3cNxrW99/Cro=" +METABASE_ADMIN_EMAIL="rene@example.com" +METABASE_ADMIN_FIRST_NAME="Rene" +METABASE_ADMIN_LAST_NAME="Descartes" +METABASE_ADMIN_PASSWORD="foobarbaz" +METABASE_DASHBOARD_ID_TO_EMBED=1 MB_PORT=4300 SERVER_PORT=4400 diff --git a/.env.example b/.env.example index d1ce550..5e62b14 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ PORT=3100 METABASE_INSTANCE_URL="http://localhost:3000" METABASE_JWT_SHARED_SECRET="" METABASE_STATIC_EMBEDDING_SECRET="" +METABASE_ADMIN_EMAIL="rene@example.com" +METABASE_ADMIN_FIRST_NAME="Rene" +METABASE_ADMIN_LAST_NAME="Descartes" +METABASE_ADMIN_PASSWORD="foobarbaz" +METABASE_DASHBOARD_ID_TO_EMBED=1 diff --git a/Dockerfile b/Dockerfile index 8e79fea..5708218 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,4 +7,4 @@ COPY server.js ./ RUN npm ci -CMD ["npm", "run", "start"] +CMD ["node", "server.js"] diff --git a/README.md b/README.md index b485879..dab4ea2 100644 --- a/README.md +++ b/README.md @@ -36,18 +36,13 @@ npm run docker:up ### Running locally with an existing Metabase instance -Before running, ensure your Metabase instance is configured: - -- Guest and modular embedding must be enabled in Admin > Embedding settings -- Static embedding secret key must be set -- JWT authentication must be enabled with a shared secret -- A dashboard must be published via the Embed JS editor +Before running, ensure your Metabase instance is configured following the [guest embedding setup guide](https://www.metabase.com/docs/latest/embedding/guest-embedding) and [SSO authentication guide](https://www.metabase.com/docs/latest/embedding/authentication). 1. Copy the environment file: ```bash cp .env.example .env -# Edit .env with your Metabase instance URL and JWT secrets +# Edit .env with your Metabase instance URL, JWT secrets, and dashboard ID ``` 2. Install dependencies and start the server: @@ -56,9 +51,8 @@ cp .env.example .env npm install npm start ``` -3. On your instance open any dashboard and publish it by going through EmbedJS Wizard => Guest embed setup -4. Open http://localhost:3100 in your browser +3. Open http://localhost:3100 in your browser ## Project Structure @@ -76,7 +70,7 @@ npm start ### Guest Embed (Signed JWT) -Guest embeds use signed JWT tokens to provide anonymous access to specific dashboards or questions. The server signs a token containing the resource ID and parameters, which is then passed to the `` component. +Guest embeds use signed JWT tokens to provide anonymous access to specific dashboards or questions. The server signs a token containing the resource ID and parameters, which is then passed to the `` component. See the [guest embedding docs](https://www.metabase.com/docs/latest/embedding/guest-embedding) for more details. ```javascript // Server generates a signed token @@ -102,7 +96,7 @@ const token = jwt.sign(payload, METABASE_STATIC_EMBEDDING_SECRET); ### SSO Embed (JWT Authentication) -SSO embeds authenticate users via JWT, creating a full user session in Metabase. The server returns a JWT containing user information when the embed.js client requests authentication. +SSO embeds authenticate users via JWT, creating a full user session in Metabase. The server returns a JWT containing user information when the embed.js client requests authentication. See the [authentication docs](https://www.metabase.com/docs/latest/embedding/authentication) for more details. ```javascript // Server returns user JWT at /auth/sso endpoint @@ -140,6 +134,7 @@ res.json({ jwt: ssoToken }); | `METABASE_INSTANCE_URL` | Metabase URL | `http://localhost:3000` | | `METABASE_JWT_SHARED_SECRET` | JWT signing secret for SSO authentication | - | | `METABASE_STATIC_EMBEDDING_SECRET` | JWT signing secret for guest embeds | - | +| `METABASE_DASHBOARD_ID_TO_EMBED` | ID of the dashboard to embed | `1` | | `PREMIUM_EMBEDDING_TOKEN` | Metabase Enterprise license | - | ## Running E2E Tests diff --git a/docker-compose.yml b/docker-compose.yml index cac4b51..a13a79d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,10 @@ services: MB_RUN_MODE: "${MB_RUN_MODE}" METASTORE_DEV_SERVER_URL: "${METASTORE_DEV_SERVER_URL}" MB_JWT_IDENTITY_PROVIDER_URI: "http://localhost:${SERVER_PORT}/auth/sso" + METABASE_ADMIN_EMAIL: "${METABASE_ADMIN_EMAIL}" + METABASE_ADMIN_FIRST_NAME: "${METABASE_ADMIN_FIRST_NAME}" + METABASE_ADMIN_LAST_NAME: "${METABASE_ADMIN_LAST_NAME}" + METABASE_ADMIN_PASSWORD: "${METABASE_ADMIN_PASSWORD}" healthcheck: test: curl --fail -X GET -I "http://localhost:${MB_PORT}/api/health" || exit 1 interval: 15s @@ -30,7 +34,9 @@ services: condition: service_healthy environment: METABASE_INSTANCE_URL: "http://metabase:${MB_PORT}" - METABASE_ADMIN_API_KEY: "${METABASE_ADMIN_API_KEY}" + METABASE_ADMIN_EMAIL: "${METABASE_ADMIN_EMAIL}" + METABASE_ADMIN_PASSWORD: "${METABASE_ADMIN_PASSWORD}" + METABASE_DASHBOARD_ID_TO_EMBED: "${METABASE_DASHBOARD_ID_TO_EMBED}" volumes: - ./metabase/setup.sh:/setup.sh:ro entrypoint: ["/bin/sh", "/setup.sh"] @@ -49,6 +55,10 @@ services: METABASE_INSTANCE_URL: "http://localhost:${MB_PORT}" METABASE_JWT_SHARED_SECRET: "${METABASE_JWT_SHARED_SECRET}" METABASE_STATIC_EMBEDDING_SECRET: "${METABASE_STATIC_EMBEDDING_SECRET}" + METABASE_DASHBOARD_ID_TO_EMBED: "${METABASE_DASHBOARD_ID_TO_EMBED}" + METABASE_ADMIN_EMAIL: "${METABASE_ADMIN_EMAIL}" + METABASE_ADMIN_FIRST_NAME: "${METABASE_ADMIN_FIRST_NAME}" + METABASE_ADMIN_LAST_NAME: "${METABASE_ADMIN_LAST_NAME}" healthcheck: test: curl --fail -X GET -I "http://localhost:${SERVER_PORT}/" || exit 1 interval: 2s diff --git a/e2e/package.json b/e2e/package.json index 21cac4a..e66e138 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "devDependencies": { "@testing-library/cypress": "^10.0.3", - "cypress": "^14.0.3" + "cypress": "^14.0.3", + "env-cmd": "^10.1.0" }, "scripts": { "cypress:open": "env-cmd -f ../.env.docker cypress open --config-file ./support/cypress.config.js --e2e", diff --git a/metabase/config.yml b/metabase/config.yml index ffdde3f..8118ce1 100644 --- a/metabase/config.yml +++ b/metabase/config.yml @@ -1,15 +1,10 @@ version: 1 config: users: - - first_name: Rene - last_name: Descartes - password: foobarbaz - email: rene@example.com - api-keys: - - name: main - key: "mb_NgLv2g7CFm1VDIhfensgdMhLPYvvKIz3cNxrW99/Cro=" - group: admin - creator: rene@example.com + - first_name: "{{ env METABASE_ADMIN_FIRST_NAME }}" + last_name: "{{ env METABASE_ADMIN_LAST_NAME }}" + password: "{{ env METABASE_ADMIN_PASSWORD }}" + email: "{{ env METABASE_ADMIN_EMAIL }}" settings: enable-embedding-simple: true enable-embedding-static: true diff --git a/metabase/setup.sh b/metabase/setup.sh index c243055..e559ec5 100755 --- a/metabase/setup.sh +++ b/metabase/setup.sh @@ -1,19 +1,34 @@ #!/bin/bash set -e -METABASE_INSTANCE_URL="${METABASE_INSTANCE_URL}" -METABASE_ADMIN_API_KEY="${METABASE_ADMIN_API_KEY}" - echo "Waiting for Metabase to be ready..." until curl -sf "${METABASE_INSTANCE_URL}/api/health" > /dev/null 2>&1; do sleep 2 done echo "Metabase is ready!" -echo "Enabling static embedding for dashboard 1..." -curl -sf -X PUT "${METABASE_INSTANCE_URL}/api/dashboard/1" \ +echo "Getting session token..." +SESSION_RESPONSE=$(curl -s -X POST \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"${METABASE_ADMIN_EMAIL}\", \"password\": \"${METABASE_ADMIN_PASSWORD}\"}" \ + "${METABASE_INSTANCE_URL}/api/session") + +SESSION_ID=$(echo "$SESSION_RESPONSE" | sed -n 's/.*"id":"\([^"]*\)".*/\1/p') + +if [ -z "$SESSION_ID" ]; then + echo "Failed to get session token. Response: $SESSION_RESPONSE" + exit 1 +fi + +echo "Enabling static embedding for dashboard ${METABASE_DASHBOARD_ID_TO_EMBED}..." +RESPONSE=$(curl -s -X PUT "${METABASE_INSTANCE_URL}/api/dashboard/${METABASE_DASHBOARD_ID_TO_EMBED}" \ -H "Content-Type: application/json" \ - -H "X-Api-Key: ${METABASE_ADMIN_API_KEY}" \ - -d '{"enable_embedding": true}' + -H "X-Metabase-Session: ${SESSION_ID}" \ + -d '{"enable_embedding": true}') + +if echo "$RESPONSE" | grep -q '"error"'; then + echo "Failed to enable embedding. Response: $RESPONSE" + exit 1 +fi -echo "Static embedding enabled for dashboard 1!" +echo "Static embedding enabled for dashboard ${METABASE_DASHBOARD_ID_TO_EMBED}!" diff --git a/package-lock.json b/package-lock.json index a818d85..9742f9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.21.0", "jsonwebtoken": "^9.0.2" @@ -124,6 +125,23 @@ "version": "1.0.7", "license": "MIT" }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -565,6 +583,15 @@ "node": ">= 0.6" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "license": "MIT", diff --git a/package.json b/package.json index 6a4fd00..6851607 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "docker:rm": "yarn docker:down --rmi all --volumes" }, "dependencies": { + "cors": "^2.8.5", "env-cmd": "^10.1.0", "express": "^4.21.0", "jsonwebtoken": "^9.0.2" diff --git a/server.js b/server.js index 9f4ed31..aaaebc8 100644 --- a/server.js +++ b/server.js @@ -1,15 +1,24 @@ import express from "express"; +import cors from "cors"; import jwt from "jsonwebtoken"; const app = express(); const port = process.env.PORT; +app.use(cors({ credentials: true, origin: true })); + const METABASE_JWT_SHARED_SECRET = process.env.METABASE_JWT_SHARED_SECRET; const METABASE_STATIC_EMBEDDING_SECRET = process.env.METABASE_STATIC_EMBEDDING_SECRET; -const METABASE_SITE_URL = - process.env.METABASE_INSTANCE_URL; +const METABASE_SITE_URL = process.env.METABASE_INSTANCE_URL; +const METABASE_DASHBOARD_ID_TO_EMBED = process.env.METABASE_DASHBOARD_ID_TO_EMBED + ? parseInt(process.env.METABASE_DASHBOARD_ID_TO_EMBED) + : 1; + +const METABASE_ADMIN_EMAIL = process.env.METABASE_ADMIN_EMAIL || "rene@example.com"; +const METABASE_ADMIN_FIRST_NAME = process.env.METABASE_ADMIN_FIRST_NAME || "Rene"; +const METABASE_ADMIN_LAST_NAME = process.env.METABASE_ADMIN_LAST_NAME || "Descartes"; const JWT_PROVIDER_URI = `http://localhost:${port}/auth/sso`; @@ -59,12 +68,19 @@ app.get("/auth/sso", (req, res) => { // Example: // const { user } = req.session; const user = { - email: "rene@example.com", - firstName: "Rene", - lastName: "Descartes", - group: "Customer", + email: METABASE_ADMIN_EMAIL, + firstName: METABASE_ADMIN_FIRST_NAME, + lastName: METABASE_ADMIN_LAST_NAME, + group: 'Customer', }; + if (!user) { + return res.status(401).json({ + status: "error", + message: "Not authenticated", + }); + } + const ssoPayload = { email: user.email, first_name: user.firstName, @@ -74,12 +90,6 @@ app.get("/auth/sso", (req, res) => { }; const ssoToken = jwt.sign(ssoPayload, METABASE_JWT_SHARED_SECRET); - const origin = req.headers.origin || "*"; - - res.set({ - "Access-Control-Allow-Origin": origin, - "Access-Control-Allow-Credentials": "true", - }); res.json({ jwt: ssoToken }); }); @@ -87,7 +97,7 @@ app.get("/auth/sso", (req, res) => { // Guest Embed - uses signed JWT tokens for anonymous access app.get(["/", "/guest-embed"], (req, res) => { const payload = { - resource: { dashboard: 1 }, + resource: { dashboard: METABASE_DASHBOARD_ID_TO_EMBED }, params: {}, exp: Math.round(Date.now() / 1000) + 10 * 60, // 10 minutes }; @@ -111,7 +121,7 @@ app.get("/sso-embed", (req, res) => { jwtProviderUri: JWT_PROVIDER_URI, enableInternalNavigation: true, }, - ``, + ``, "/sso-embed", ), );