diff --git a/.env.example b/.env.example index bf7c9dc..0ded640 100644 --- a/.env.example +++ b/.env.example @@ -2,14 +2,16 @@ DATABASE_URL="postgresql://postgres:postgres@localhost:5432/hp_prod_tracker?schema=public" DB_PASSWORD=postgres # Change in production -# ─── Auth (Microsoft Entra ID SSO) ────────────────────── +# ─── Auth (Microsoft Entra ID SSO — SPA registration) ─── AUTH_SECRET="" # Generate with: openssl rand -base64 32 -AUTH_URL="" # e.g. https://your-domain.com/api/auth -AUTH_MICROSOFT_ENTRA_ID_ID="" # Azure AD Application (Client) ID -AUTH_MICROSOFT_ENTRA_ID_TENANT_ID="" # Azure AD Directory (Tenant) ID -# AUTH_MICROSOFT_ENTRA_ID_SECRET — not needed for SPA registrations (PKCE only) -AZURE_REDIRECT_URI="" # URI registered in Azure portal (SPA platform) - # e.g. https://your-domain.com/your-app/login +# Azure AD Application (Client) ID +AZURE_CLIENT_ID="" +# Azure AD Directory (Tenant) ID +AZURE_TENANT_ID="" +# Redirect URI registered in Azure portal (SPA platform) — must be the login page URL +# e.g. https://your-domain.com/your-app/login +AZURE_REDIRECT_URI="" +# No client secret — SPA registrations use PKCE in the browser (no AUTH_URL needed) # ─── Dev Auth Bypass (local development only) ─────────── # Set to "true" to skip SSO and auto-login as dev admin user. diff --git a/apache/hp-prod-tracker.conf b/apache/hp-prod-tracker.conf index 86013f2..57de048 100644 --- a/apache/hp-prod-tracker.conf +++ b/apache/hp-prod-tracker.conf @@ -12,11 +12,6 @@ RewriteCond %{HTTP:Upgrade} websocket [NC] RewriteCond %{HTTP:Connection} upgrade [NC] RewriteRule ^/hp-prod-tracker/(.*) ws://127.0.0.1:3001/hp-prod-tracker/$1 [P,L] -# OAuth callback — must be defined BEFORE the global /api/ → OliVAS rule. -# Auth.js uses /api/auth/* without the Next.js basePath in redirect_uri. -ProxyPass /api/auth http://127.0.0.1:3001/api/auth -ProxyPassReverse /api/auth http://127.0.0.1:3001/api/auth - # Chat + AI endpoints: long timeout for streaming responses ProxyPass /hp-prod-tracker/api/chat http://127.0.0.1:3001/hp-prod-tracker/api/chat timeout=300 ProxyPassReverse /hp-prod-tracker/api/chat http://127.0.0.1:3001/hp-prod-tracker/api/chat diff --git a/deploy.sh b/deploy.sh index 23ddf56..d854118 100644 --- a/deploy.sh +++ b/deploy.sh @@ -112,8 +112,7 @@ if [[ ! -f .env ]]; then fi # Warn on unset critical vars -for var in AUTH_SECRET AUTH_MICROSOFT_ENTRA_ID_ID \ - AUTH_MICROSOFT_ENTRA_ID_TENANT_ID AZURE_REDIRECT_URI AUTH_URL DB_PASSWORD; do +for var in AUTH_SECRET AZURE_CLIENT_ID AZURE_TENANT_ID AZURE_REDIRECT_URI DB_PASSWORD; do val=$(grep -E "^${var}=" .env | cut -d= -f2- | tr -d '"' || true) [[ -z "$val" ]] && warn " WARNING: $var is not set in .env" done diff --git a/docker-compose.yml b/docker-compose.yml index 244d3e3..9cc33d9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,11 +35,10 @@ services: OLLAMA_EMBED_MODEL: ${OLLAMA_EMBED_MODEL:-nomic-embed-text} NODE_ENV: production AUTH_SECRET: ${AUTH_SECRET} - AUTH_URL: ${AUTH_URL:-} AUTH_TRUST_HOST: "true" - AUTH_MICROSOFT_ENTRA_ID_ID: ${AUTH_MICROSOFT_ENTRA_ID_ID} - AUTH_MICROSOFT_ENTRA_ID_TENANT_ID: ${AUTH_MICROSOFT_ENTRA_ID_TENANT_ID} - # SPA registration — no client secret; PKCE used instead + # Azure SPA registration — PKCE in browser, no client secret + AZURE_CLIENT_ID: ${AZURE_CLIENT_ID} + AZURE_TENANT_ID: ${AZURE_TENANT_ID} AZURE_REDIRECT_URI: ${AZURE_REDIRECT_URI:-} CRON_SECRET: ${CRON_SECRET:-change-me} API_KEY: ${API_KEY:-} diff --git a/package-lock.json b/package-lock.json index 180e5d0..4af6de5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@azure/msal-browser": "^5.6.3", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.4.2", @@ -27,6 +28,7 @@ "dotenv": "^17.3.1", "exceljs": "^4.4.0", "hls.js": "^1.6.15", + "jose": "^6.2.2", "lucide-react": "^0.575.0", "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", @@ -116,6 +118,27 @@ "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, + "node_modules/@azure/msal-browser": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/@azure/msal-browser/-/msal-browser-5.6.3.tgz", + "integrity": "sha512-sTjMtUm+bJpENU/1WlRzHEsgEHppZDZ1EtNyaOODg/sQBtMxxJzGB+MOCM+T2Q5Qe1fKBrdxUmjyRxm0r7Ez9w==", + "license": "MIT", + "dependencies": { + "@azure/msal-common": "16.4.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/@azure/msal-common": { + "version": "16.4.1", + "resolved": "https://registry.npmjs.org/@azure/msal-common/-/msal-common-16.4.1.tgz", + "integrity": "sha512-Bl8f+w37xkXsYh7QRkAKCFGYtWMYuOVO7Lv+BxILrvGz3HbIEF22Pt0ugyj0QPOl6NLrHcnNUQ9yeew98P/5iw==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -1906,17 +1929,6 @@ "node": ">=12.4.0" } }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/@panva/hkdf": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", @@ -6489,23 +6501,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/better-sqlite3": { - "version": "12.6.2", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.6.2.tgz", - "integrity": "sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "bindings": "^1.5.0", - "prebuild-install": "^7.1.1" - }, - "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" - } - }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -6537,18 +6532,6 @@ "node": "*" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -7474,36 +7457,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/deep-extend": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", - "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -8512,18 +8465,6 @@ "node": ">=8.3.0" } }, - "node_modules/expand-template": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", - "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", - "dev": true, - "license": "(MIT OR WTFPL)", - "optional": true, - "peer": true, - "engines": { - "node": ">=6" - } - }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -8663,15 +8604,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -9000,15 +8932,6 @@ "giget": "dist/cli.mjs" } }, - "node_modules/github-from-package": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", - "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -9418,15 +9341,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, - "node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -9998,9 +9912,9 @@ } }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz", + "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/panva" @@ -11581,21 +11495,6 @@ "node": ">=8.6" } }, - "node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -11629,15 +11528,6 @@ "mkdirp": "bin/cmd.js" } }, - "node_modules/mkdirp-classic": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", - "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -11696,15 +11586,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/napi-build-utils": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", - "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/napi-postinstall": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", @@ -11875,36 +11756,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/node-abi": { - "version": "3.88.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.88.0.tgz", - "integrity": "sha512-At6b4UqIEVudaqPsXjmUO1r/N5BUr4yhDGs5PkBE8/oG5+TfLPhFechiskFsnT6Ql0VfUXbalUUCbfXxtj7K+w==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-abi/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -12591,36 +12442,6 @@ "preact": ">=10" } }, - "node_modules/prebuild-install": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", - "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", - "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "detect-libc": "^2.0.0", - "expand-template": "^2.0.3", - "github-from-package": "0.0.0", - "minimist": "^1.2.3", - "mkdirp-classic": "^0.5.3", - "napi-build-utils": "^2.0.0", - "node-abi": "^3.3.0", - "pump": "^3.0.0", - "rc": "^1.2.7", - "simple-get": "^4.0.0", - "tar-fs": "^2.0.0", - "tunnel-agent": "^0.6.0" - }, - "bin": { - "prebuild-install": "bin.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -12812,19 +12633,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -12988,36 +12796,6 @@ "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", "license": "MIT" }, - "node_modules/rc": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", - "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", - "dev": true, - "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", - "optional": true, - "peer": true, - "dependencies": { - "deep-extend": "^0.6.0", - "ini": "~1.3.0", - "minimist": "^1.2.0", - "strip-json-comments": "~2.0.1" - }, - "bin": { - "rc": "cli.js" - } - }, - "node_modules/rc/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rc9": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz", @@ -13874,57 +13652,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/simple-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", - "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/simple-get": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", - "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "decompress-response": "^6.0.0", - "once": "^1.3.1", - "simple-concat": "^1.0.0" - } - }, "node_modules/simple-swizzle": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz", @@ -14283,30 +14010,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/tar-fs": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", - "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "chownr": "^1.1.1", - "mkdirp-classic": "^0.5.2", - "pump": "^3.0.0", - "tar-stream": "^2.1.4" - } - }, - "node_modules/tar-fs/node_modules/chownr": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", - "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", - "dev": true, - "license": "ISC", - "optional": true, - "peer": true - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -14509,21 +14212,6 @@ "fsevents": "~2.3.3" } }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 905c0f3..88e5fe5 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@auth/prisma-adapter": "^2.11.1", + "@azure/msal-browser": "^5.6.3", "@hello-pangea/dnd": "^18.0.1", "@hookform/resolvers": "^5.2.2", "@prisma/adapter-pg": "^7.4.2", @@ -39,6 +40,7 @@ "dotenv": "^17.3.1", "exceljs": "^4.4.0", "hls.js": "^1.6.15", + "jose": "^6.2.2", "lucide-react": "^0.575.0", "next": "^16.1.6", "next-auth": "^5.0.0-beta.30", diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 2ac256d..8230c3b 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -10,7 +10,7 @@ import { prisma } from "@/lib/prisma"; export default async function AppLayout({ children }: { children: React.ReactNode }) { // Skip org check in dev bypass mode (only when Entra ID is not configured) - if (!(process.env.DEV_BYPASS_AUTH === "true" && !process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET)) { + if (!(process.env.DEV_BYPASS_AUTH === "true" && process.env.NODE_ENV !== "production")) { const session = await auth(); if (session?.user?.id) { const user = await prisma.user.findUnique({ diff --git a/src/app/(auth)/login/MsalLogin.tsx b/src/app/(auth)/login/MsalLogin.tsx new file mode 100644 index 0000000..8e47939 --- /dev/null +++ b/src/app/(auth)/login/MsalLogin.tsx @@ -0,0 +1,162 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { + Configuration, + PublicClientApplication, + AuthenticationResult, +} from "@azure/msal-browser"; +import { Button } from "@/components/ui/button"; +import type { MsalLoginConfig } from "@/lib/msal-config"; + +type State = "idle" | "processing" | "error"; + +export function MsalLogin({ config }: { config: MsalLoginConfig }) { + const [state, setState] = useState(() => { + // Show "Signing in…" immediately if Azure just redirected back with a code + if (typeof window !== "undefined" && new URLSearchParams(window.location.search).has("code")) { + return "processing"; + } + return "idle"; + }); + const [errorMsg, setErrorMsg] = useState(null); + const msalRef = useRef(null); + const handledRef = useRef(false); + + useEffect(() => { + if (handledRef.current) return; + handledRef.current = true; + + let mounted = true; + + (async () => { + // Lazy-import to keep @azure/msal-browser out of the server bundle + const { PublicClientApplication } = await import("@azure/msal-browser"); + + const msalConfig: Configuration = { + auth: { + clientId: config.clientId, + authority: `https://login.microsoftonline.com/${config.tenantId}`, + redirectUri: config.redirectUri, + }, + cache: { cacheLocation: "sessionStorage" }, + }; + + const instance = new PublicClientApplication(msalConfig); + await instance.initialize(); + msalRef.current = instance; + + let result: AuthenticationResult | null = null; + try { + result = await instance.handleRedirectPromise(); + } catch (err: unknown) { + if (!mounted) return; + const msg = err instanceof Error ? err.message : String(err); + setErrorMsg(msg); + setState("error"); + return; + } + + if (!mounted) return; + + if (!result) { + // No redirect in progress — show the sign-in button + setState("idle"); + return; + } + + // MSAL exchanged the code for tokens in the browser — create a server session + setState("processing"); + try { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH ?? ""; + const res = await fetch(`${basePath}/api/auth/sso`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ idToken: result.idToken }), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error((body as { error?: string }).error ?? `HTTP ${res.status}`); + } + + // Session cookie has been set — navigate to the app + window.location.href = `${basePath}/dashboard`; + } catch (err: unknown) { + if (!mounted) return; + const msg = err instanceof Error ? err.message : String(err); + setErrorMsg(msg); + setState("error"); + } + })(); + + return () => { + mounted = false; + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const handleLogin = async () => { + const instance = msalRef.current; + if (!instance) return; + setState("processing"); + try { + await instance.loginRedirect({ scopes: ["openid", "profile", "email"] }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + setErrorMsg(msg); + setState("error"); + } + }; + + if (state === "processing") { + return ( + + ); + } + + if (state === "error") { + return ( +
+

+ Sign-in failed: {errorMsg ?? "Unknown error"} +

+ +
+ ); + } + + return ( + + ); +} + +function MicrosoftIcon() { + return ( + + + + + + + ); +} diff --git a/src/app/(auth)/login/OAuthRelay.tsx b/src/app/(auth)/login/OAuthRelay.tsx deleted file mode 100644 index ec9013e..0000000 --- a/src/app/(auth)/login/OAuthRelay.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useSearchParams } from "next/navigation"; - -/** - * Handles the Azure SPA OAuth redirect. - * - * Azure is configured with the login PAGE as redirect_uri (SPA registration). - * When Azure redirects here with ?code=…, this component forwards ALL query - * params to the Auth.js callback route via a full-page navigation so that - * Auth.js can complete the PKCE token exchange using the code_verifier cookie. - * - * Note: Auth.js sends only PKCE (no `state`) in the authorization URL, so the - * callback has `code` + `session_state` (Azure) but no OAuth `state`. - */ -export function OAuthRelay() { - const params = useSearchParams(); - - useEffect(() => { - const code = params.get("code"); - if (!code) return; - - // Forward all Azure callback params to the Auth.js handler - const callbackUrl = new URL( - "/api/auth/callback/microsoft-entra-id", - window.location.origin - ); - params.forEach((value, key) => { - callbackUrl.searchParams.set(key, value); - }); - - // Full-page navigation required — Auth.js callback is a server route handler - window.location.replace(callbackUrl.toString()); - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - return null; -} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index a186202..f841a83 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,8 +1,8 @@ -import { auth, signIn } from "@/lib/auth"; +import { auth } from "@/lib/auth"; import { redirect } from "next/navigation"; import { Suspense } from "react"; -import { Button } from "@/components/ui/button"; -import { OAuthRelay } from "./OAuthRelay"; +import { MsalLogin } from "./MsalLogin"; +import type { MsalLoginConfig } from "@/lib/msal-config"; export default async function LoginPage() { const session = await auth(); @@ -11,12 +11,14 @@ export default async function LoginPage() { redirect("/dashboard"); } + const msalConfig: MsalLoginConfig = { + clientId: process.env.AZURE_CLIENT_ID!, + tenantId: process.env.AZURE_TENANT_ID!, + redirectUri: process.env.AZURE_REDIRECT_URI!, + }; + return (
- {/* Relay Azure SPA OAuth code → Auth.js callback */} - - - {/* Left panel — green brand block */}
@@ -62,21 +64,9 @@ export default async function LoginPage() {
-
{ - "use server"; - await signIn("microsoft-entra-id", { redirectTo: "/dashboard" }); - }} - > - -
+ + +
@@ -89,25 +79,3 @@ export default async function LoginPage() {
); } - -function GoogleIcon() { - return ( - - - - - - - ); -} - -function MicrosoftIcon() { - return ( - - - - - - - ); -} diff --git a/src/app/api/auth/sso/route.ts b/src/app/api/auth/sso/route.ts new file mode 100644 index 0000000..341408e --- /dev/null +++ b/src/app/api/auth/sso/route.ts @@ -0,0 +1,99 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { randomUUID } from "crypto"; +import { cookies } from "next/headers"; +import { prisma } from "@/lib/prisma"; + +export async function POST(request: NextRequest) { + const body = await request.json().catch(() => null); + const idToken: unknown = body?.idToken; + if (typeof idToken !== "string" || !idToken) { + return NextResponse.json({ error: "Missing idToken" }, { status: 400 }); + } + + const tenantId = process.env.AZURE_TENANT_ID; + const clientId = process.env.AZURE_CLIENT_ID; + if (!tenantId || !clientId) { + return NextResponse.json({ error: "Server not configured for SSO" }, { status: 500 }); + } + + // Validate the id_token using Azure's public JWKS + const JWKS = createRemoteJWKSet( + new URL(`https://login.microsoftonline.com/${tenantId}/discovery/v2.0/keys`) + ); + + let payload: Record; + try { + const result = await jwtVerify(idToken, JWKS, { + issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`, + audience: clientId, + }); + payload = result.payload as Record; + } catch { + return NextResponse.json({ error: "Invalid or expired token" }, { status: 401 }); + } + + const email = + (payload.preferred_username as string | undefined) ?? + (payload.email as string | undefined); + const name = (payload.name as string | undefined) ?? null; + const oid = + (payload.oid as string | undefined) ?? (payload.sub as string | undefined); + + if (!email || !oid) { + return NextResponse.json({ error: "Token missing required claims" }, { status: 400 }); + } + + // Find or create user by email + let user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + user = await prisma.user.create({ + data: { email, name, emailVerified: new Date() }, + }); + } + + // Link Azure account (upsert to handle re-logins and existing users) + await prisma.account.upsert({ + where: { provider_providerAccountId: { provider: "microsoft-entra-id", providerAccountId: oid } }, + update: { id_token: idToken }, + create: { + userId: user.id, + type: "oidc", + provider: "microsoft-entra-id", + providerAccountId: oid, + id_token: idToken, + }, + }); + + // Auto-assign organization by email domain (mirrors the old Auth.js signIn event) + if (!user.organizationId) { + const domain = email.split("@")[1]; + if (domain) { + const org = await prisma.organization.findFirst({ where: { domain } }); + if (org) { + await prisma.user.update({ where: { id: user.id }, data: { organizationId: org.id } }); + } + } + } + + // Create a database session (matches PrismaAdapter.createSession format) + const sessionToken = randomUUID(); + const expires = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days + await prisma.session.create({ data: { sessionToken, userId: user.id, expires } }); + + // Set the session cookie in the Auth.js-compatible format so auth() can read it + const forwarded = request.headers.get("x-forwarded-proto"); + const isSecure = forwarded === "https" || request.url.startsWith("https://"); + const cookieName = isSecure ? "__Secure-authjs.session-token" : "authjs.session-token"; + + const cookieStore = await cookies(); + cookieStore.set(cookieName, sessionToken, { + httpOnly: true, + secure: isSecure, + sameSite: "lax", + path: "/", + expires, + }); + + return NextResponse.json({ success: true }); +} diff --git a/src/lib/api-utils.ts b/src/lib/api-utils.ts index 4bb2649..7f6bd0e 100644 --- a/src/lib/api-utils.ts +++ b/src/lib/api-utils.ts @@ -14,7 +14,7 @@ export async function getAuthSession() { // Safety: only honoured when Entra ID credentials are NOT configured. if ( process.env.DEV_BYPASS_AUTH === "true" && - !process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET + process.env.NODE_ENV !== "production" ) { const devUserId = process.env.DEV_USER_ID ?? "dev-user-001"; return { diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 1e3cc58..fe2b7f6 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,121 +1,18 @@ import NextAuth from "next-auth"; -import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from "@/lib/prisma"; -// ── Azure SPA registration — no client_secret ──────────────────────────────── -// -// The Azure app is registered as "Single-page application", which means: -// • No client_secret is issued — PKCE is the only allowed code exchange method -// • The redirect_uri registered in Azure is the login PAGE (not the Auth.js -// callback route): https://optical-dev.oliver.solutions/hp-prod-tracker/login -// -// Flow: -// 1. signIn() → Auth.js generates PKCE code_verifier (stored in cookie) and -// redirects browser to Azure with redirect_uri = AZURE_REDIRECT_URI -// 2. Azure authenticates → redirects to /hp-prod-tracker/login?code=…&state=… -// 3. client component detects the query params and does: -// window.location.replace("/api/auth/callback/microsoft-entra-id?code=…&state=…") -// 4. Auth.js callback route handles the request, calls token.request below -// 5. token.request exchanges code + code_verifier for tokens (no secret sent) -// -// Apache already proxies /api/auth → container:3001/api/auth (no basePath). +// Auth.js is used only for session management (reading sessions + sign-out). +// OAuth / token exchange is handled by MSAL.js in the browser. +// Sessions are created by /api/auth/sso after MSAL completes token exchange. -const tenantId = process.env.AUTH_MICROSOFT_ENTRA_ID_TENANT_ID!; -const clientId = process.env.AUTH_MICROSOFT_ENTRA_ID_ID!; - -// redirect_uri sent to Azure — must match the SPA registration exactly -const azureRedirectUri = process.env.AZURE_REDIRECT_URI!; - -const msTokenUrl = `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`; - -export const { handlers, auth, signIn, signOut } = NextAuth({ +export const { handlers, auth, signOut } = NextAuth({ basePath: "/api/auth", adapter: PrismaAdapter(prisma), - providers: [ - { - // Spread the standard provider for OIDC discovery / profile parsing, - // then override authorization + token for SPA PKCE without client_secret. - ...MicrosoftEntraID({ - clientId, - clientSecret: "SPA_NO_SECRET", // placeholder — never sent to Azure - issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`, - allowDangerousEmailAccountLinking: true, - }), - - // PKCE-only — Auth.js does not add `state` when PKCE is the sole check - checks: ["pkce"], - - // Override redirect_uri → must match Azure SPA registration - authorization: { - url: `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/authorize`, - params: { - scope: "openid profile email", - response_type: "code", - redirect_uri: azureRedirectUri, - }, - }, - - // Override token exchange: public client PKCE — no client_secret - // eslint-disable-next-line @typescript-eslint/no-explicit-any - token: { - url: msTokenUrl, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async request(context: any) { - const body = new URLSearchParams({ - grant_type: "authorization_code", - client_id: clientId, - code: String(context.params.code), - redirect_uri: azureRedirectUri, - code_verifier: String(context.checks?.code_verifier ?? ""), - }); - - const res = await fetch(msTokenUrl, { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: body.toString(), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`Azure token exchange failed (${res.status}): ${text}`); - } - - return { tokens: await res.json() }; - }, - }, - }, - ], + providers: [], session: { strategy: "database", }, - events: { - async signIn({ user }) { - if (!user.id || !user.email) return; - - // Auto-assign organization by email domain match - const dbUser = await prisma.user.findUnique({ - where: { id: user.id }, - select: { organizationId: true, email: true }, - }); - - if (dbUser?.organizationId || !dbUser?.email) return; - - const domain = dbUser.email.split("@")[1]; - if (!domain) return; - - const org = await prisma.organization.findFirst({ - where: { domain }, - }); - - if (org) { - await prisma.user.update({ - where: { id: user.id }, - data: { organizationId: org.id }, - }); - } - }, - }, callbacks: { async session({ session, user }) { const dbUser = await prisma.user.findUnique({ diff --git a/src/lib/msal-config.ts b/src/lib/msal-config.ts new file mode 100644 index 0000000..fb3a014 --- /dev/null +++ b/src/lib/msal-config.ts @@ -0,0 +1,7 @@ +// Config props passed from the login server component to the MsalLogin client component. +// Using plain props (not NEXT_PUBLIC_ env vars) so Docker runtime env vars work correctly. +export interface MsalLoginConfig { + clientId: string; + tenantId: string; + redirectUri: string; +} diff --git a/src/middleware.ts b/src/middleware.ts index f1be42e..04ccdd0 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -18,7 +18,7 @@ export function middleware(request: NextRequest) { // preventing accidental bypass in production. if ( process.env.DEV_BYPASS_AUTH === "true" && - !process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET + process.env.NODE_ENV !== "production" ) { return NextResponse.next(); }