feat: add optional OIDC authentication

This commit is contained in:
2026-06-05 17:34:03 +08:00
parent 09f752c8cf
commit 20654d9756
8 changed files with 382 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

88
lib/auth-config.test.ts Normal file
View File

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

101
lib/auth-config.ts Normal file
View File

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

View File

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

81
proxy.ts Normal file
View File

@@ -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 `<!doctype html>
<html lang="zh">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Authentication Configuration Error</title>
<style>
body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: #0f172a; color: #e2e8f0; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
main { max-width: 640px; padding: 32px; }
h1 { margin: 0 0 12px; font-size: 22px; }
p { color: #94a3b8; line-height: 1.6; }
code { color: #f8fafc; }
</style>
</head>
<body>
<main>
<h1>OIDC 配置不完整</h1>
<p><code>${escapeHtml(error)}</code></p>
<p>请补全 OIDC 配置,或移除全部 OIDC 相关变量以保持开放访问。</p>
</main>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}