From 20654d9756c7597ebe0aad9ff96a2b4897559924 Mon Sep 17 00:00:00 2001 From: shangzy Date: Fri, 5 Jun 2026 17:34:03 +0800 Subject: [PATCH] feat: add optional OIDC authentication --- .env.production | 5 ++ README.md | 26 +++++++ app/api/auth/[...nextauth]/route.ts | 47 +++++++++++++ bun.lock | 35 +++++++++- lib/auth-config.test.ts | 88 ++++++++++++++++++++++++ lib/auth-config.ts | 101 ++++++++++++++++++++++++++++ package.json | 1 + proxy.ts | 81 ++++++++++++++++++++++ 8 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 app/api/auth/[...nextauth]/route.ts create mode 100644 lib/auth-config.test.ts create mode 100644 lib/auth-config.ts create mode 100644 proxy.ts diff --git a/.env.production b/.env.production index 5ad3659..6c14059 100644 --- a/.env.production +++ b/.env.production @@ -1 +1,6 @@ PG_CONNECTION_STRING=postgres://postgres:XigzIbHE0khmlGgnHSlz7Lsd7OkPTigKcfwM8z0iIcjMggCrcFaUwgiNtloklj8z@114.116.243.78:5432/new-api +OIDC_ISSUER=https://door.copilot.shenyang-bridge.com +OIDC_CLIENT_ID=da95b2f28cf2fccb5c75 +OIDC_CLIENT_SECRET=f2bac46946033c842cf25362f233743e6adc66b5 +AUTH_SECRET=lnT5Ewn25S2Rps0t5aw4VbiPoHFAt72FpCea4KsLUy0= +OIDC_PROVIDER_NAME=Sinodoor diff --git a/README.md b/README.md index ffeab2f..ff3908a 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,32 @@ PG_CONNECTION_STRING=postgres://user:password@host:5432/database The app uses this variable in `lib/db.ts` to create a `pg` connection pool. +OIDC authentication is optional. If no OIDC variables are set, the dashboard and API remain open. +To require login through a standard OIDC provider such as Sinodoor, add: + +```bash +OIDC_ISSUER=https://casdoor.example.com +OIDC_CLIENT_ID=analytics +OIDC_CLIENT_SECRET=replace-me +AUTH_SECRET=replace-with-random-secret +# Optional login button label: +OIDC_PROVIDER_NAME=Sinodoor +``` + +Generate `AUTH_SECRET` with a stable random value, for example: + +```bash +openssl rand -base64 32 +``` + +When OIDC is enabled, configure the provider redirect URI as: + +```text +https://your-analytics-domain/api/auth/callback/oidc +``` + +Partial OIDC configuration is treated as an error instead of falling back to open access. + ## Deployment The included Dockerfile builds a standalone Next.js output and starts `server.js` on port `8019`. diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..f01e676 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,47 @@ +import NextAuth, { type NextAuthOptions } from "next-auth"; +import type { OAuthConfig } from "next-auth/providers/oauth"; +import { + getAuthMode, + getOidcProviderName, + getRequiredAuthSecret, + mapOidcProfile, + type OidcProfile, +} from "@/lib/auth-config"; + +const authMode = getAuthMode(); + +function oidcProvider(): OAuthConfig { + return { + id: "oidc", + name: getOidcProviderName(), + type: "oauth", + wellKnown: `${process.env.OIDC_ISSUER}/.well-known/openid-configuration`, + authorization: { params: { scope: "openid profile email" } }, + checks: ["pkce", "state"], + clientId: process.env.OIDC_CLIENT_ID, + clientSecret: process.env.OIDC_CLIENT_SECRET, + idToken: true, + profile(profile) { + return mapOidcProfile(profile); + }, + }; +} + +const authOptions: NextAuthOptions = { + providers: authMode.enabled ? [oidcProvider()] : [], + secret: authMode.enabled ? getRequiredAuthSecret() : "auth-disabled", + session: { + strategy: "jwt", + }, +}; + +const handler = authMode.enabled + ? NextAuth(authOptions) + : function authDisabled() { + return Response.json( + { error: authMode.error ?? "OIDC authentication is not configured." }, + { status: authMode.error ? 500 : 404 } + ); + }; + +export { handler as GET, handler as POST }; diff --git a/bun.lock b/bun.lock index 0759adf..9cc2122 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "lucide-react": "^1.7.0", "motion": "^12.38.0", "next": "16.2.2", + "next-auth": "4.24.14", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", @@ -62,6 +63,8 @@ "@babel/parser": ["@babel/parser@7.29.2", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], @@ -190,6 +193,8 @@ "@nolyfill/is-core-module": ["@nolyfill/is-core-module@1.0.39", "", {}, "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA=="], + "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], "@rtsao/scc": ["@rtsao/scc@1.1.0", "", {}, "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g=="], @@ -396,6 +401,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], @@ -654,6 +661,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], @@ -708,7 +717,7 @@ "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], @@ -740,12 +749,18 @@ "next": ["next@16.2.2", "", { "dependencies": { "@next/env": "16.2.2", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.2.2", "@next/swc-darwin-x64": "16.2.2", "@next/swc-linux-arm64-gnu": "16.2.2", "@next/swc-linux-arm64-musl": "16.2.2", "@next/swc-linux-x64-gnu": "16.2.2", "@next/swc-linux-x64-musl": "16.2.2", "@next/swc-win32-arm64-msvc": "16.2.2", "@next/swc-win32-x64-msvc": "16.2.2", "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A=="], + "next-auth": ["next-auth@4.24.14", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.3", "next": "^12.2.5 || ^13 || ^14 || ^15 || ^16", "nodemailer": "^7.0.7", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-YRz6xFDXKUwiXSMMChbrBEWyFktZ1qZXEgeSHQQ3nsy08B4c/xLk6REeutRsIFwkjY/1+ShHnu07DN3JeJguig=="], + "node-exports-info": ["node-exports-info@1.6.0", "", { "dependencies": { "array.prototype.flatmap": "^1.3.3", "es-errors": "^1.3.0", "object.entries": "^1.1.9", "semver": "^6.3.1" } }, "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw=="], "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + "oauth": ["oauth@0.9.15", "", {}, "sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], @@ -760,6 +775,10 @@ "object.values": ["object.values@1.2.1", "", { "dependencies": { "call-bind": "^1.0.8", "call-bound": "^1.0.3", "define-properties": "^1.2.1", "es-object-atoms": "^1.0.0" } }, "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA=="], + "oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="], + + "openid-client": ["openid-client@5.7.1", "", { "dependencies": { "jose": "^4.15.9", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -808,8 +827,14 @@ "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], + + "preact-render-to-string": ["preact-render-to-string@5.2.6", "", { "dependencies": { "pretty-format": "^3.8.0" }, "peerDependencies": { "preact": ">=10" } }, "sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "pretty-format": ["pretty-format@3.8.0", "", {}, "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -950,6 +975,8 @@ "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + "uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -966,7 +993,7 @@ "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], @@ -976,6 +1003,8 @@ "@babel/core/json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], @@ -1018,6 +1047,8 @@ "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="], "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], diff --git a/lib/auth-config.test.ts b/lib/auth-config.test.ts new file mode 100644 index 0000000..f9ea7e7 --- /dev/null +++ b/lib/auth-config.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { + getAuthMode, + mapOidcProfile, + isAuthRoute, + isProtectedPath, + type AuthEnv, +} from "./auth-config"; + +describe("optional OIDC auth config", () => { + test("disables auth when no OIDC settings are present", () => { + const env: AuthEnv = {}; + + expect(getAuthMode(env)).toEqual({ enabled: false, error: null }); + }); + + test("does not enable auth when only non-OIDC auth settings are present", () => { + const env: AuthEnv = { + AUTH_SECRET: "session-secret", + OIDC_PROVIDER_NAME: "Casdoor", + }; + + expect(getAuthMode(env)).toEqual({ enabled: false, error: null }); + }); + + test("enables auth when all required OIDC settings are present", () => { + const env: AuthEnv = { + OIDC_ISSUER: "https://door.example.com", + OIDC_CLIENT_ID: "analytics", + OIDC_CLIENT_SECRET: "secret", + AUTH_SECRET: "session-secret", + }; + + expect(getAuthMode(env)).toEqual({ enabled: true, error: null }); + }); + + test("reports missing settings when OIDC config is partial", () => { + const env: AuthEnv = { + OIDC_ISSUER: "https://door.example.com", + OIDC_CLIENT_ID: "analytics", + }; + + expect(getAuthMode(env)).toEqual({ + enabled: false, + error: "Missing required auth environment variables: OIDC_CLIENT_SECRET, AUTH_SECRET", + }); + }); +}); + +describe("auth route matching", () => { + test("protects analytics pages and API data routes", () => { + expect(isProtectedPath("/")).toBe(true); + expect(isProtectedPath("/logs")).toBe(true); + expect(isProtectedPath("/detail/user/alice")).toBe(true); + expect(isProtectedPath("/api/overview")).toBe(true); + expect(isProtectedPath("/api/detail/user/alice")).toBe(true); + }); + + test("does not protect auth or static asset routes", () => { + expect(isProtectedPath("/api/auth/signin")).toBe(false); + expect(isProtectedPath("/_next/static/chunk.js")).toBe(false); + expect(isProtectedPath("/favicon.ico")).toBe(false); + expect(isProtectedPath("/icon.svg")).toBe(false); + }); + + test("detects auth routes", () => { + expect(isAuthRoute("/api/auth/signin")).toBe(true); + expect(isAuthRoute("/api/overview")).toBe(false); + }); +}); + +describe("OIDC profile mapping", () => { + test("uses standard OIDC profile claims for the NextAuth user", () => { + expect( + mapOidcProfile({ + sub: "user-123", + preferred_username: "alice", + email: "alice@example.com", + picture: "https://example.com/alice.png", + }) + ).toEqual({ + id: "user-123", + name: "alice", + email: "alice@example.com", + image: "https://example.com/alice.png", + }); + }); +}); diff --git a/lib/auth-config.ts b/lib/auth-config.ts new file mode 100644 index 0000000..6079068 --- /dev/null +++ b/lib/auth-config.ts @@ -0,0 +1,101 @@ +export interface AuthEnv { + OIDC_ISSUER?: string; + OIDC_CLIENT_ID?: string; + OIDC_CLIENT_SECRET?: string; + OIDC_PROVIDER_NAME?: string; + AUTH_SECRET?: string; +} + +export interface AuthMode { + enabled: boolean; + error: string | null; +} + +export interface OidcProfile { + sub: unknown; + name?: unknown; + preferred_username?: unknown; + email?: unknown; + picture?: unknown; +} + +const REQUIRED_AUTH_KEYS = [ + "OIDC_ISSUER", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", + "AUTH_SECRET", +] as const; + +const OIDC_KEYS = [ + "OIDC_ISSUER", + "OIDC_CLIENT_ID", + "OIDC_CLIENT_SECRET", +] as const; + +const STATIC_FILE_PATTERN = /\.(?:ico|svg|png|jpg|jpeg|gif|webp|css|js|map|txt|xml|json)$/i; + +export function getAuthMode(env: AuthEnv = process.env): AuthMode { + const hasAnyOidcConfig = OIDC_KEYS.some((key) => Boolean(trimEnv(env[key]))); + if (!hasAnyOidcConfig) return { enabled: false, error: null }; + + const missing = REQUIRED_AUTH_KEYS.filter((key) => !trimEnv(env[key])); + if (missing.length > 0) { + return { + enabled: false, + error: `Missing required auth environment variables: ${missing.join(", ")}`, + }; + } + + return { enabled: true, error: null }; +} + +export function isAuthEnabled(env: AuthEnv = process.env): boolean { + return getAuthMode(env).enabled; +} + +export function isAuthRoute(pathname: string): boolean { + return pathname === "/api/auth" || pathname.startsWith("/api/auth/"); +} + +export function isProtectedPath(pathname: string): boolean { + if (isAuthRoute(pathname)) return false; + if (pathname.startsWith("/_next/")) return false; + if (pathname === "/favicon.ico" || pathname === "/robots.txt" || pathname === "/sitemap.xml") return false; + if (STATIC_FILE_PATTERN.test(pathname)) return false; + + return pathname === "/" || pathname.startsWith("/api/") || pathname.startsWith("/"); +} + +export function getOidcProviderName(env: AuthEnv = process.env): string { + return trimEnv(env.OIDC_PROVIDER_NAME) || "OIDC"; +} + +export function getRequiredAuthSecret(env: AuthEnv = process.env): string { + const secret = trimEnv(env.AUTH_SECRET); + if (!secret) throw new Error("Missing required auth environment variable: AUTH_SECRET"); + return secret; +} + +export function mapOidcProfile(profile: OidcProfile) { + const subject = String(profile.sub); + const name = firstString(profile.name, profile.preferred_username, profile.email, subject); + + return { + id: subject, + name, + email: typeof profile.email === "string" ? profile.email : null, + image: typeof profile.picture === "string" ? profile.picture : null, + }; +} + +function trimEnv(value: string | undefined): string { + return value?.trim() ?? ""; +} + +function firstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === "string" && value.trim()) return value; + } + + return ""; +} diff --git a/package.json b/package.json index 2b690fa..0df9a85 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "lucide-react": "^1.7.0", "motion": "^12.38.0", "next": "16.2.2", + "next-auth": "4.24.14", "pg": "^8.20.0", "react": "19.2.4", "react-dom": "19.2.4", diff --git a/proxy.ts b/proxy.ts new file mode 100644 index 0000000..b35395d --- /dev/null +++ b/proxy.ts @@ -0,0 +1,81 @@ +import { getToken } from "next-auth/jwt"; +import { NextResponse, type NextRequest } from "next/server"; +import { getAuthMode, getRequiredAuthSecret, isAuthRoute, isProtectedPath } from "@/lib/auth-config"; + +export async function proxy(request: NextRequest) { + const { pathname } = request.nextUrl; + if (!isProtectedPath(pathname)) return NextResponse.next(); + + const authMode = getAuthMode(); + if (!authMode.enabled && !authMode.error) return NextResponse.next(); + + if (authMode.error) { + return authConfigErrorResponse(request, authMode.error); + } + + const token = await getToken({ + req: request, + secret: getRequiredAuthSecret(), + }); + + if (token) return NextResponse.next(); + + if (pathname.startsWith("/api/")) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const signInUrl = new URL("/api/auth/signin/oidc", request.url); + signInUrl.searchParams.set("callbackUrl", request.nextUrl.href); + return NextResponse.redirect(signInUrl); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg).*)"], +}; + +function authConfigErrorResponse(request: NextRequest, error: string) { + if (request.nextUrl.pathname.startsWith("/api/") && !isAuthRoute(request.nextUrl.pathname)) { + return NextResponse.json({ error }, { status: 500 }); + } + + return new NextResponse(authErrorHtml(error), { + status: 500, + headers: { + "content-type": "text/html; charset=utf-8", + }, + }); +} + +function authErrorHtml(error: string): string { + return ` + + + + + Authentication Configuration Error + + + +
+

OIDC 配置不完整

+

${escapeHtml(error)}

+

请补全 OIDC 配置,或移除全部 OIDC 相关变量以保持开放访问。

+
+ +`; +} + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +}