Replace Auth.js OAuth with MSAL.js SPA browser flow

- Token exchange now happens entirely in the browser via @azure/msal-browser
  (PKCE, no client_secret — correct for Azure SPA registrations)
- Browser stays on /hp-prod-tracker/login throughout; the /api/auth/callback
  URL never appears in the address bar
- New /api/auth/sso route validates the id_token (jose + Azure JWKS),
  creates User/Account/Session in Prisma, and sets the authjs session cookie
- Auth.js retained only for session reading (auth()) and signOut()
- Fix dev bypass safety gate: use NODE_ENV !== production instead of
  absence of AUTH_MICROSOFT_ENTRA_ID_SECRET
- Rename env vars: AUTH_MICROSOFT_ENTRA_ID_ID → AZURE_CLIENT_ID,
  AUTH_MICROSOFT_ENTRA_ID_TENANT_ID → AZURE_TENANT_ID, remove AUTH_URL
- Remove /api/auth Apache proxy rule (no longer needed)
- Delete OAuthRelay.tsx, add MsalLogin.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-04-16 18:49:43 +01:00
parent 6701946092
commit 250796dd0c
15 changed files with 329 additions and 549 deletions

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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:-}

364
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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({

View file

@ -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<State>(() => {
// 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<string | null>(null);
const msalRef = useRef<PublicClientApplication | null>(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 (
<Button
variant="outline"
disabled
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase shadow-[var(--shadow-sm)]"
>
<MicrosoftIcon />
Signing in
</Button>
);
}
if (state === "error") {
return (
<div className="space-y-3">
<p className="text-xs text-destructive">
Sign-in failed: {errorMsg ?? "Unknown error"}
</p>
<Button
variant="outline"
onClick={() => { setErrorMsg(null); setState("idle"); }}
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase shadow-[var(--shadow-sm)]"
>
Try again
</Button>
</div>
);
}
return (
<Button
variant="outline"
onClick={handleLogin}
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]"
>
<MicrosoftIcon />
Continue with Microsoft
</Button>
);
}
function MicrosoftIcon() {
return (
<svg className="mr-2 h-3.5 w-3.5 shrink-0" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H12z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H12z" />
</svg>
);
}

View file

@ -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;
}

View file

@ -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 (
<div className="flex min-h-screen bg-[var(--background)]">
{/* Relay Azure SPA OAuth code → Auth.js callback */}
<Suspense fallback={null}>
<OAuthRelay />
</Suspense>
{/* Left panel — green brand block */}
<div className="hidden w-[40%] flex-col justify-between bg-[var(--primary)] p-10 md:flex">
<div>
@ -62,21 +64,9 @@ export default async function LoginPage() {
</div>
<div className="space-y-2.5">
<form
action={async () => {
"use server";
await signIn("microsoft-entra-id", { redirectTo: "/dashboard" });
}}
>
<Button
type="submit"
variant="outline"
className="w-full h-11 rounded-xl border-[var(--border)] text-[11px] font-semibold tracking-[0.06em] uppercase hover:bg-[var(--foreground)] hover:text-[var(--background)] transition-all shadow-[var(--shadow-sm)] hover:shadow-[var(--shadow-md)]"
>
<MicrosoftIcon />
Continue with Microsoft
</Button>
</form>
<Suspense fallback={null}>
<MsalLogin config={msalConfig} />
</Suspense>
</div>
<div className="mt-10 border-t pt-6">
@ -89,25 +79,3 @@ export default async function LoginPage() {
</div>
);
}
function GoogleIcon() {
return (
<svg className="mr-2 h-3.5 w-3.5 shrink-0" viewBox="0 0 24 24">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z" fill="#4285F4" />
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853" />
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05" />
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
);
}
function MicrosoftIcon() {
return (
<svg className="mr-2 h-3.5 w-3.5 shrink-0" viewBox="0 0 23 23">
<path fill="#f35325" d="M1 1h10v10H1z" />
<path fill="#81bc06" d="M12 1h10v10H12z" />
<path fill="#05a6f0" d="M1 12h10v10H1z" />
<path fill="#ffba08" d="M12 12h10v10H12z" />
</svg>
);
}

View file

@ -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<string, unknown>;
try {
const result = await jwtVerify(idToken, JWKS, {
issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
audience: clientId,
});
payload = result.payload as Record<string, unknown>;
} 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 });
}

View file

@ -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 {

View file

@ -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. <OAuthRelay> 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({

7
src/lib/msal-config.ts Normal file
View file

@ -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;
}

View file

@ -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();
}