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:
parent
6701946092
commit
250796dd0c
15 changed files with 329 additions and 549 deletions
16
.env.example
16
.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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
364
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
162
src/app/(auth)/login/MsalLogin.tsx
Normal file
162
src/app/(auth)/login/MsalLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
99
src/app/api/auth/sso/route.ts
Normal file
99
src/app/api/auth/sso/route.ts
Normal 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 });
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
113
src/lib/auth.ts
113
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. <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
7
src/lib/msal-config.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue