mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-08-15 00:29:41 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 699a5771f5 | |||
| e862714d6a | |||
| 716981aa04 | |||
| 66e3284898 | |||
| 695f0ff005 | |||
| 53f2cf36fc | |||
| 8ffcf3bdc6 | |||
| a07af96f84 |
+4
-1
@@ -21,6 +21,9 @@ DATABASE_URL=sqlite://data/gitea-mirror.db
|
||||
BETTER_AUTH_SECRET=change-this-to-a-secure-random-string-in-production
|
||||
BETTER_AUTH_URL=http://localhost:4321
|
||||
# ENCRYPTION_SECRET=optional-encryption-key-for-token-encryption # Generate with: openssl rand -base64 48
|
||||
# Better Auth log verbosity: debug | info | warn | error (default: warn).
|
||||
# Set to "debug" to trace SSO/OIDC sign-in and callback issues.
|
||||
# BETTER_AUTH_LOG_LEVEL=debug
|
||||
|
||||
# ===========================================
|
||||
# REVERSE PROXY CONFIGURATION
|
||||
@@ -60,7 +63,7 @@ PUBLIC_BETTER_AUTH_URL=http://localhost:4321
|
||||
|
||||
# Docker Registry Configuration
|
||||
DOCKER_REGISTRY=ghcr.io
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror:
|
||||
DOCKER_IMAGE=raylabshq/gitea-mirror
|
||||
DOCKER_TAG=latest
|
||||
|
||||
# ===========================================
|
||||
|
||||
@@ -88,6 +88,24 @@ jobs:
|
||||
|
||||
- name: Start Gitea and git-server containers
|
||||
run: |
|
||||
# Retry pulls before `up` — Docker Hub occasionally returns
|
||||
# "context deadline exceeded" on the first attempt. Pulling
|
||||
# separately also keeps the failure mode obvious if it persists.
|
||||
echo "Pulling images..."
|
||||
for attempt in 1 2 3; do
|
||||
if docker compose -f tests/e2e/docker-compose.e2e.yml pull --quiet; then
|
||||
echo "✓ Images pulled (attempt $attempt)"
|
||||
break
|
||||
fi
|
||||
if [ "$attempt" -eq 3 ]; then
|
||||
echo "ERROR: docker compose pull failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
sleep_s=$((attempt * 10))
|
||||
echo "Pull attempt $attempt failed, retrying in ${sleep_s}s..."
|
||||
sleep "$sleep_s"
|
||||
done
|
||||
|
||||
echo "Starting containers via docker compose..."
|
||||
docker compose -f tests/e2e/docker-compose.e2e.yml up -d
|
||||
|
||||
|
||||
@@ -52,3 +52,4 @@ tests/e2e/git-repos/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.auth/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -38,7 +39,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -165,23 +166,25 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@better-auth/core": ["@better-auth/core@1.5.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-1oR/2jAp821Dcf67kQYHUoyNcdc1TcShfw4QMK0YTVntuRES5mUOyvEJql5T6eIuLfaqaN4LOF78l0FtF66HXA=="],
|
||||
"@better-auth/core": ["@better-auth/core@1.6.11", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-LrwidLCV8azdMGjvtwp30nj9tIv1BwI3VhtC0UaGSjQkAVWw4bN42I8qwbxRziPeSQoj+zUVkOpxZzAWBDARtQ=="],
|
||||
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "drizzle-orm": ">=0.41.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-HAi9xAP40oDt48QZeYBFTcmg3vt1Jik90GwoRIfangd7VGbxesIIDBJSnvwMbZ52GBIc6+V4FRw9lasNiNrPfw=="],
|
||||
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-4jpkETIGZOHCf7BK4jnu22fdN6jjomH0/HhEzkaWy3+Eppi5PYlHTF/460jrTmA3Xc+Vqwp9t282ymHiEPypGw=="],
|
||||
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "kysely": "^0.27.0 || ^0.28.0" } }, "sha512-LmHffIVnqbfsxcxckMOoE8MwibWrbVFch+kwPKJ5OFDFv6lin75ufN7ZZ7twH0IMPLT/FcgzaRjP8jRrXRef9g=="],
|
||||
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "kysely": "^0.28.17" }, "optionalPeers": ["kysely"] }, "sha512-/g8M9RfIjdcZDnbstSUvQiINkvdNlCeZr248zwqx2/PVksQI1MhQofbzUn3RnQnbPKp0EPwpX/dR3oudRFenUg=="],
|
||||
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0" } }, "sha512-4X0j1/2L+nsgmObjmy9xEGUFWUv38Qjthp558fwS3DAp6ueWWyCaxaD6VJZ7m5qPNMrsBStO5WGP8CmJTEWm7g=="],
|
||||
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0" } }, "sha512-hpdfw0BBf8MuzLkIdmbcUZICbY9r/bhLO2RxSnkzT5+/O+0I0u2I8+m0YUP7vNllP/ZCKASHOYgXPLO75Z0f9Q=="],
|
||||
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "mongodb": "^6.0.0 || ^7.0.0" } }, "sha512-P1J9ljL5X5k740I8Rx1esPWNgWYPdJR5hf2CY7BwDSrQFPUHuzeCg0YhtEEP55niNateTXhBqGAcy0fVOeamZg=="],
|
||||
"@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-3Tor8rSv8vSEIMEaV2PFpPEuVhqc1gNoZ6eGvoh3LwExXXuj8madew6ob+H1pH7Aphn3Ar5PQ08AguT8TbwFAA=="],
|
||||
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.5.5", "", { "peerDependencies": { "@better-auth/core": "1.5.5", "@better-auth/utils": "^0.3.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-CliDd78CXHzzwQIXhCdwGr5Ml53i6JdCHWV7PYwTIJz9EAm6qb2RVBdpP3nqEfNjINGM22A6gfleCgCdZkTIZg=="],
|
||||
"@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.6.11", "", { "dependencies": { "jose": "^6.1.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-iMywpOEAiAUdtvpaRS8yKye+wO3AieOB3Sfv8czkmPduzFuKBICCWuOEAElQEk5tQz3vzWx64zNlLBkgEAOhuw=="],
|
||||
|
||||
"@better-auth/sso": ["@better-auth/sso@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "fast-xml-parser": "^5.4.1", "jose": "^6.1.3", "samlify": "^2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "1.5.5", "better-auth": "1.5.5", "better-call": "1.3.2" } }, "sha512-G3tvv5oKtEfpmBrt7Db/hSl5A3xttUkB4EhEjb202UhHz/XBiT0Orv5CkRa0kmjRyyAwOzn/lKZzYsd3VrjViA=="],
|
||||
"@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-Pw+7q7zTp+VSci1V+CYMvuxIbAeVMZLe4lRo46LJoAKMHfjFl5T/ycsyFvWs/DkWC7n9gZZzRDEbHp0I5FiKKw=="],
|
||||
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.5.5", "", { "dependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.5.5" } }, "sha512-1+lklxArn4IMHuU503RcPdXrSG2tlXt4jnGG3omolmspQ7tktg/Y9XO/yAkYDurtvMn1xJ8X1Ov01Ji/r5s9BQ=="],
|
||||
"@better-auth/sso": ["@better-auth/sso@1.6.11", "", { "dependencies": { "fast-xml-parser": "^5.5.7", "jose": "^6.1.3", "samlify": "~2.10.2", "tldts": "^6.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.11", "better-call": "1.3.5" } }, "sha512-lJHmoCayp9Woh/MPKTHDfGq7k1oQbU2yz5tIOZXl/pzrgLxV7fMGo9aJCyabHkw3GHMjBes4byC6aakHYzpZIg=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="],
|
||||
"@better-auth/telemetry": ["@better-auth/telemetry@1.6.11", "", { "peerDependencies": { "@better-auth/core": "^1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-hsjDHc8MZbm6/AHeNdtywrWedXevnBjmdvnHTcZub+rTVjOv+Td0roI8USKuC6uUibmrl//2rJfVCsGbopihNA=="],
|
||||
|
||||
"@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="],
|
||||
|
||||
"@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="],
|
||||
|
||||
@@ -353,14 +356,14 @@
|
||||
|
||||
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
|
||||
|
||||
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.4.6", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
|
||||
|
||||
"@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
|
||||
|
||||
"@octokit/core": ["@octokit/core@7.0.6", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
|
||||
@@ -387,6 +390,8 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
|
||||
|
||||
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
|
||||
@@ -683,10 +688,6 @@
|
||||
|
||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||
|
||||
"@types/webidl-conversions": ["@types/webidl-conversions@7.0.3", "", {}, "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="],
|
||||
|
||||
"@types/whatwg-url": ["@types/whatwg-url@13.0.0", "", { "dependencies": { "@types/webidl-conversions": "*" } }, "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q=="],
|
||||
|
||||
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
@@ -769,9 +770,9 @@
|
||||
|
||||
"before-after-hook": ["before-after-hook@4.0.0", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
|
||||
|
||||
"better-auth": ["better-auth@1.5.5", "", { "dependencies": { "@better-auth/core": "1.5.5", "@better-auth/drizzle-adapter": "1.5.5", "@better-auth/kysely-adapter": "1.5.5", "@better-auth/memory-adapter": "1.5.5", "@better-auth/mongo-adapter": "1.5.5", "@better-auth/prisma-adapter": "1.5.5", "@better-auth/telemetry": "1.5.5", "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.2", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.11", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-GpVPaV1eqr3mOovKfghJXXk6QvlcVeFbS3z+n+FPDid5rK/2PchnDtiaVCzWyXA9jH2KkirOfl+JhAUvnja0Eg=="],
|
||||
"better-auth": ["better-auth@1.6.11", "", { "dependencies": { "@better-auth/core": "1.6.11", "@better-auth/drizzle-adapter": "1.6.11", "@better-auth/kysely-adapter": "1.6.11", "@better-auth/memory-adapter": "1.6.11", "@better-auth/mongo-adapter": "1.6.11", "@better-auth/prisma-adapter": "1.6.11", "@better-auth/telemetry": "1.6.11", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-Wwt6+q07dwIhsp6XiM7L1qSXVUWBEtNl+eZvwM778CguFqDZFBN9Pt6LtFaHl55t8Z+Zc//5kxcbgDY8/79vFQ=="],
|
||||
|
||||
"better-call": ["better-call@1.3.2", "", { "dependencies": { "@better-auth/utils": "^0.3.1", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-4cZIfrerDsNTn3cm+MhLbUePN0gdwkhSXEuG7r/zuQ8c/H7iU0/jSK5TD3FW7U0MgKHce/8jGpPYNO4Ve+4NBw=="],
|
||||
"better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="],
|
||||
|
||||
"bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="],
|
||||
|
||||
@@ -781,15 +782,13 @@
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bson": ["bson@7.2.0", "", {}, "sha512-YCEo7KjMlbNlyHhz7zAZNDpIpQbd+wOEHJYezv0nMYTn4x31eIUM2yomNNubclAt63dObUzKHWsBLJ9QcZNSnQ=="],
|
||||
|
||||
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"camelcase": ["camelcase@9.0.0", "", {}, "sha512-TO9xmyXTZ9HUHI8M1OnvExxYB0eYVS/1e5s7IDMTAoIcwUd+aNcFODs6Xk83mobk0velyHFQgA1yIrvYc6wclw=="],
|
||||
"camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001779", "", {}, "sha512-U5og2PN7V4DMgF50YPNtnZJGWVLFjjsN3zb6uMT5VGYIewieDj1upwfuVNXf4Kor+89c3iCRJnSzMD5LmTvsfA=="],
|
||||
|
||||
@@ -961,7 +960,7 @@
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.2.0", "", { "dependencies": { "path-expression-matcher": "^1.5.0", "xml-naming": "^0.1.0" } }, "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.6", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw=="],
|
||||
"fast-xml-parser": ["fast-xml-parser@5.8.0", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.2.0", "path-expression-matcher": "^1.5.0", "strnum": "^2.3.0", "xml-naming": "^0.1.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-6bIM7fsJxeo3uXv7OncQYsBAMPJ7V16Slahl/6M98C/i2q+vB1+4a0MtrvYwDFEUrwDSbAmeLDRXsOBwrL7yAg=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -1181,8 +1180,6 @@
|
||||
|
||||
"mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="],
|
||||
|
||||
"memory-pager": ["memory-pager@1.5.0", "", {}, "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="],
|
||||
|
||||
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
|
||||
|
||||
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
|
||||
@@ -1259,10 +1256,6 @@
|
||||
|
||||
"min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="],
|
||||
|
||||
"mongodb": ["mongodb@7.1.0", "", { "dependencies": { "@mongodb-js/saslprep": "^1.3.0", "bson": "^7.1.1", "mongodb-connection-string-url": "^7.0.0" }, "peerDependencies": { "@aws-sdk/credential-providers": "^3.806.0", "@mongodb-js/zstd": "^7.0.0", "gcp-metadata": "^7.0.1", "kerberos": "^7.0.0", "mongodb-client-encryption": ">=7.0.0 <7.1.0", "snappy": "^7.3.2", "socks": "^2.8.6" }, "optionalPeers": ["@aws-sdk/credential-providers", "@mongodb-js/zstd", "gcp-metadata", "kerberos", "mongodb-client-encryption", "snappy", "socks"] }, "sha512-kMfnKunbolQYwCIyrkxNJFB4Ypy91pYqua5NargS/f8ODNSJxT03ZU3n1JqL4mCzbSih8tvmMEMLpKTT7x5gCg=="],
|
||||
|
||||
"mongodb-connection-string-url": ["mongodb-connection-string-url@7.0.1", "", { "dependencies": { "@types/whatwg-url": "^13.0.0", "whatwg-url": "^14.1.0" } }, "sha512-h0AZ9A7IDVwwHyMxmdMXKy+9oNlF0zFoahHiX3vQ8e3KFcSP3VmsmfvtRSuLPxmyv2vjIDxqty8smTgie/SNRQ=="],
|
||||
|
||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -1281,6 +1274,8 @@
|
||||
|
||||
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
|
||||
|
||||
"node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="],
|
||||
|
||||
"node-mock-http": ["node-mock-http@1.0.4", "", {}, "sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
|
||||
@@ -1311,6 +1306,8 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
|
||||
"parse-latin": ["parse-latin@7.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "@types/unist": "^3.0.0", "nlcst-to-string": "^4.0.0", "unist-util-modify-children": "^4.0.0", "unist-util-visit-children": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ=="],
|
||||
@@ -1319,7 +1316,7 @@
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-expression-matcher": ["path-expression-matcher@1.1.3", "", {}, "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ=="],
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
@@ -1431,7 +1428,7 @@
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"samlify": ["samlify@2.11.0", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.11", "camelcase": "^9.0.0", "node-rsa": "^1.1.1", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.34" } }, "sha512-1C9ukjlf0rRsuyqdzztqikdItqa33j9NCCDZgeBiWk0etU6vxNB+SWJKW4Flk07ZlhXeev/twALEKrPhIAyfDg=="],
|
||||
"samlify": ["samlify@2.10.2", "", { "dependencies": { "@authenio/xml-encryption": "^2.0.2", "@xmldom/xmldom": "^0.8.6", "camelcase": "^6.2.0", "node-forge": "^1.3.0", "node-rsa": "^1.1.1", "pako": "^1.0.10", "uuid": "^8.3.2", "xml": "^1.0.1", "xml-crypto": "^6.1.2", "xml-escape": "^1.1.0", "xpath": "^0.0.32" } }, "sha512-y5s1cHwclqwP8h7K2Wj9SfP1q+1S9+jrs5OAegYTLAiuFi7nDvuKqbiXLmUTvYPMpzHcX94wTY2+D604jgTKvA=="],
|
||||
|
||||
"sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="],
|
||||
|
||||
@@ -1467,8 +1464,6 @@
|
||||
|
||||
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
|
||||
|
||||
"sparse-bitfield": ["sparse-bitfield@3.0.3", "", { "dependencies": { "memory-pager": "^1.0.2" } }, "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ=="],
|
||||
|
||||
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
@@ -1483,7 +1478,7 @@
|
||||
|
||||
"strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="],
|
||||
|
||||
"strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="],
|
||||
"strnum": ["strnum@2.3.0", "", {}, "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q=="],
|
||||
|
||||
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
|
||||
|
||||
@@ -1663,7 +1658,7 @@
|
||||
|
||||
"xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="],
|
||||
|
||||
"xpath": ["xpath@0.0.34", "", {}, "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA=="],
|
||||
"xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
|
||||
|
||||
@@ -1691,8 +1686,6 @@
|
||||
|
||||
"@astrojs/react/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
|
||||
|
||||
"@authenio/xml-encryption/xpath": ["xpath@0.0.32", "", {}, "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
@@ -1759,14 +1752,10 @@
|
||||
|
||||
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
|
||||
|
||||
"fast-xml-builder/path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"hast-util-from-html/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"hast-util-raw/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
@@ -1775,6 +1764,8 @@
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.9", "", {}, "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw=="],
|
||||
|
||||
"samlify/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="],
|
||||
|
||||
"tough-cookie/tldts": ["tldts@7.0.25", "", { "dependencies": { "tldts-core": "^7.0.25" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="],
|
||||
@@ -1927,10 +1918,6 @@
|
||||
|
||||
"csso/css-tree/mdn-data": ["mdn-data@2.0.28", "", {}, "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/tr46": ["tr46@5.1.1", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw=="],
|
||||
|
||||
"mongodb-connection-string-url/whatwg-url/webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
|
||||
|
||||
"tough-cookie/tldts/tldts-core": ["tldts-core@7.0.25", "", {}, "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="],
|
||||
|
||||
@@ -40,6 +40,7 @@ Essential application settings required for running Gitea Mirror.
|
||||
| `BETTER_AUTH_URL` | Authentication origin (scheme + host only, e.g. `https://git.example.com`). Do **not** include a path — any path is automatically stripped, and `BASE_URL` is applied separately. | `http://localhost:4321` | No |
|
||||
| `PUBLIC_BETTER_AUTH_URL` | Client-side auth origin for multi-origin access (same rule: origin only, no path). Set this to your primary domain when you need to access the app from different origins (e.g., both IP and domain). The client will use this URL for all auth requests instead of the current browser origin. | - | No |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Trusted origins for authentication requests. Comma-separated list of URLs. Use this to specify additional access URLs (e.g., local IP + domain: `http://10.10.20.45:4321,https://gitea-mirror.mydomain.tld`), SSO providers, reverse proxies, etc. | - | No |
|
||||
| `BETTER_AUTH_LOG_LEVEL` | Better Auth logger verbosity. Set to `debug` to surface the full SSO/OIDC sign-in and callback trace when troubleshooting authentication. Accepted values: `debug`, `info`, `warn`, `error`. (Better Auth does **not** use the `DEBUG` env var.) | `warn` | No |
|
||||
| `ENCRYPTION_SECRET` | Optional encryption key for tokens (generate with: `openssl rand -base64 48`) | - | No |
|
||||
|
||||
## HTTPS / TLS
|
||||
|
||||
+20
-3
@@ -28,7 +28,7 @@ SSO allows your users to sign in using external identity providers like Google,
|
||||
#### Required Fields
|
||||
|
||||
- **Issuer URL**: The OIDC issuer URL (e.g., `https://accounts.google.com`)
|
||||
- **Domain**: The email domain for this provider (e.g., `example.com`)
|
||||
- **Domain**: The email domain this provider serves (e.g., `example.com`). **This is load-bearing for account-linking trust — see [Account Linking](#account-linking) below.** Multi-domain IdPs can use a comma-separated list (`example.com,subsidiary.com`).
|
||||
- **Provider ID**: A unique identifier for this provider (e.g., `google-sso`)
|
||||
- **Client ID**: The OAuth client ID from your provider
|
||||
- **Client Secret**: The OAuth client secret from your provider
|
||||
@@ -57,6 +57,22 @@ https://your-domain.com/api/auth/sso/callback/{provider-id}
|
||||
|
||||
Replace `{provider-id}` with your chosen Provider ID.
|
||||
|
||||
### Account Linking
|
||||
|
||||
When a user signs in via SSO whose email matches an **existing** email/password account, Gitea Mirror will try to *link* the SSO identity to that account so the user lands on the same dashboard — instead of being bounced to the login page or creating a duplicate account.
|
||||
|
||||
The trust model is **domain-scoped**:
|
||||
|
||||
- Every SSO provider you register is automatically marked `domainVerified: true` for the **Domain** field you set on registration.
|
||||
- An SSO sign-in is auto-linked to an existing local account **only when** the user's email address actually belongs to that registered domain. Cross-domain emails are rejected even if the provider is registered.
|
||||
- Local email/password accounts in this app are never verified (no email-verification flow exists), so we deliberately turn off `requireLocalEmailVerified` on the linker — otherwise no one could ever link.
|
||||
|
||||
**What this means for you:**
|
||||
|
||||
- Set the **Domain** field to the email domain(s) your IdP actually issues identities for. Don't set it to `example.com` if your IdP issues `@elsewhere.com` emails — auto-linking will silently refuse them.
|
||||
- If your IdP allows users to self-register or claim arbitrary emails *within the registered domain*, an attacker on that IdP can absorb a local account by claiming the same email. This is a trust decision: by registering an IdP, you're vouching for its identity model for that domain.
|
||||
- Multi-domain IdPs (one Authentik serving `acme.com,acquired.io`) work fine — list every domain comma-separated in the **Domain** field.
|
||||
|
||||
### Example: Google SSO Setup
|
||||
|
||||
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
|
||||
@@ -99,7 +115,7 @@ Working Authentik deployments (see [#134](https://github.com/RayLabsHQ/gitea-mir
|
||||
|
||||
Notes:
|
||||
- Make sure `BETTER_AUTH_URL` and (if you serve the UI from multiple origins) `BETTER_AUTH_TRUSTED_ORIGINS` point at the public URL users reach. A mismatch can surface as 500 errors after redirect.
|
||||
- Authentik must report the user’s email as verified (default behavior) so Gitea Mirror can auto-link accounts.
|
||||
- Set the **Domain** field to the email domain your Authentik users actually have. Auto-linking to an existing local admin only happens for emails in that domain — see [Account Linking](#account-linking) for the trust model. (Authentik's default email scope mapping returns `email_verified: False` for OIDC clients, which is why account linking is gated on the domain match here rather than the IdP's verified-email claim.)
|
||||
- If you created an Authentik provider before v3.8.10 you should delete it and re-add it after upgrading; older versions saved incomplete endpoint data which leads to the `url.startsWith` error explained in the Troubleshooting section.
|
||||
|
||||
## Setting up Header / Forward Authentication
|
||||
@@ -257,7 +273,8 @@ When an application requests authentication:
|
||||
1. **"Invalid origin" error**: Check that your Gitea Mirror URL matches the configured redirect URI
|
||||
2. **"Provider not found" error**: Ensure the provider is properly configured and enabled
|
||||
3. **Redirect loop**: Verify the redirect URI in both Gitea Mirror and the SSO provider match exactly
|
||||
4. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
4. **`?error=UNKNOWN` on the homepage after a successful upstream login** (or `?error=account%20not%20linked` in development): the SSO callback succeeded but Better Auth refused to link the SSO identity to an existing local account. The most common cause is the SSO provider's registered **Domain** not matching the user's actual email domain — see [Account Linking](#account-linking). Set `BETTER_AUTH_LOG_LEVEL=debug` to see Better Auth's full callback trace (look for "User already exist but account isn't linked to ...") and confirm the diagnosis. The same symptom in production gets sanitized to `UNKNOWN` by Better Auth's error page before the redirect, which is why the visible error is opaque.
|
||||
5. **`TypeError: undefined is not an object (evaluating 'url.startsWith')`**: This indicates the stored provider configuration is missing OIDC endpoints. Delete the provider from Gitea Mirror and re-register it using the **Discover** button so authorization/token URLs are saved (see [#73](https://github.com/RayLabsHQ/gitea-mirror/issues/73) and [#122](https://github.com/RayLabsHQ/gitea-mirror/issues/122) for examples).
|
||||
|
||||
### OIDC Provider Issues
|
||||
|
||||
|
||||
+50
-2
@@ -125,11 +125,59 @@ npm start
|
||||
|
||||
### Debug Mode:
|
||||
|
||||
Enable debug logging by setting environment variable:
|
||||
> **Note:** Better Auth uses its own logger and does **not** read the `DEBUG`
|
||||
> environment variable (it is not based on the `debug` npm package). An older
|
||||
> version of this guide suggested `DEBUG=better-auth:*` — that has no effect.
|
||||
|
||||
Better Auth's logger defaults to the `warn` level, so SSO/OIDC sign-in and
|
||||
callback details are hidden. Set the log level to `debug` to surface the full
|
||||
trace:
|
||||
|
||||
```bash
|
||||
DEBUG=better-auth:* bun run dev
|
||||
# Local dev
|
||||
BETTER_AUTH_LOG_LEVEL=debug bun run dev
|
||||
```
|
||||
|
||||
```yaml
|
||||
# Docker Compose
|
||||
services:
|
||||
gitea-mirror:
|
||||
environment:
|
||||
- BETTER_AUTH_LOG_LEVEL=debug
|
||||
```
|
||||
|
||||
Then watch the server logs (e.g. `docker compose logs -f gitea-mirror`) while you
|
||||
attempt an SSO login. Lines are prefixed with `[Better Auth]:`. Accepted values
|
||||
are `debug`, `info`, `warn`, and `error`.
|
||||
|
||||
#### Debugging a login that bounces back to `/login`
|
||||
|
||||
If clicking the SSO button sends you to the provider and then straight back to
|
||||
the login screen, the OAuth flow itself usually succeeded but **no session
|
||||
cookie was persisted**. Work through these checks:
|
||||
|
||||
1. **Enable `BETTER_AUTH_LOG_LEVEL=debug`** (above) and look for errors during
|
||||
the `/api/auth/sso/callback/<provider-id>` request.
|
||||
2. **Check for `?error=UNKNOWN` on the landing URL** (or `?error=account%20not%20linked` in dev).
|
||||
That's Better Auth's account-linking step refusing to attach the SSO identity
|
||||
to an existing email/password account. The debug log line to look for is
|
||||
`User already exist but account isn't linked to <providerId>`. The fix is
|
||||
almost always to set the SSO provider's **Domain** field to the email domain
|
||||
your users actually have — auto-linking is gated on that domain match.
|
||||
See [docs/SSO-OIDC-SETUP.md#account-linking](./SSO-OIDC-SETUP.md#account-linking).
|
||||
3. **Check the redirect URI** registered in your IdP exactly matches
|
||||
`https://<your-domain>/api/auth/sso/callback/<provider-id>` (scheme, host,
|
||||
and provider ID — no trailing slash).
|
||||
4. **Confirm the session cookie is set.** In the browser DevTools → Network,
|
||||
inspect the callback response for a `Set-Cookie: better-auth-session=…`
|
||||
header, and DevTools → Application → Cookies for the stored cookie. Behind a
|
||||
reverse proxy, ensure `BETTER_AUTH_URL` is your **external HTTPS** URL so the
|
||||
cookie is issued with the correct domain and `Secure` flag, and that the
|
||||
proxy forwards `X-Forwarded-Proto: https` and `X-Forwarded-Host`.
|
||||
5. **A `401` on `/api/sso/applications` is unrelated** to client login — that
|
||||
endpoint backs the OAuth *provider* (consent) management UI and requires an
|
||||
existing session. It is not part of the Authentik/OIDC sign-in flow.
|
||||
|
||||
## Testing Different Scenarios
|
||||
|
||||
### 1. New User Registration
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
-- Migrate the OAuth/OIDC *provider* feature from the deprecated
|
||||
-- `oidc-provider` plugin to `@better-auth/oauth-provider`.
|
||||
--
|
||||
-- Tables: oauth_applications -> oauth_clients, oauth_access_tokens reshaped,
|
||||
-- new oauth_refresh_tokens, oauth_consent -> oauth_consents, plus a `jwks`
|
||||
-- table for the `jwt` plugin (id_token signing keys).
|
||||
--
|
||||
-- Data preservation:
|
||||
-- * Registered clients are copied from oauth_applications into oauth_clients,
|
||||
-- converting the legacy comma-separated `redirect_urls` into the JSON
|
||||
-- string[] (`redirect_uris`) the new adapter expects.
|
||||
-- * Access tokens and consent records are NOT migrated: access tokens are
|
||||
-- short-lived (and the column shape changed entirely), and consents are
|
||||
-- cheaply re-granted on next authorization. Both old tables are dropped.
|
||||
--
|
||||
-- NOTE: legacy client secrets were stored in plaintext, whereas the new
|
||||
-- provider stores them hashed. Migrated client secrets will therefore not
|
||||
-- validate as-is — affected applications must rotate their secret after
|
||||
-- upgrade.
|
||||
|
||||
CREATE TABLE `oauth_clients` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`client_secret` text,
|
||||
`name` text,
|
||||
`disabled` integer DEFAULT false,
|
||||
`skip_consent` integer,
|
||||
`enable_end_session` integer,
|
||||
`subject_type` text,
|
||||
`scopes` text,
|
||||
`user_id` text,
|
||||
`uri` text,
|
||||
`icon` text,
|
||||
`contacts` text,
|
||||
`tos` text,
|
||||
`policy` text,
|
||||
`software_id` text,
|
||||
`software_version` text,
|
||||
`software_statement` text,
|
||||
`redirect_uris` text NOT NULL,
|
||||
`post_logout_redirect_uris` text,
|
||||
`token_endpoint_auth_method` text,
|
||||
`grant_types` text,
|
||||
`response_types` text,
|
||||
`public` integer,
|
||||
`type` text,
|
||||
`require_pkce` integer,
|
||||
`reference_id` text,
|
||||
`metadata` text,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_clients_client_id_unique` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_client_id` ON `oauth_clients` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_clients_user_id` ON `oauth_clients` (`user_id`);--> statement-breakpoint
|
||||
INSERT INTO `oauth_clients` (
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
`redirect_uris`, `type`, `metadata`, `created_at`, `updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`, `client_id`, `client_secret`, `name`, `disabled`, `user_id`,
|
||||
'["' || replace(`redirect_urls`, ',', '","') || '"]',
|
||||
`type`, `metadata`, `created_at`, `updated_at`
|
||||
FROM `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_applications`;--> statement-breakpoint
|
||||
DROP TABLE `oauth_access_tokens`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_access_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`refresh_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_access_tokens_token_unique` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_token` ON `oauth_access_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_client_id` ON `oauth_access_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_access_tokens_user_id` ON `oauth_access_tokens` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `oauth_refresh_tokens` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`token` text NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`session_id` text,
|
||||
`user_id` text NOT NULL,
|
||||
`reference_id` text,
|
||||
`expires_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`revoked` integer,
|
||||
`auth_time` integer,
|
||||
`scopes` text NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `oauth_refresh_tokens_token_unique` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_token` ON `oauth_refresh_tokens` (`token`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_client_id` ON `oauth_refresh_tokens` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_refresh_tokens_user_id` ON `oauth_refresh_tokens` (`user_id`);--> statement-breakpoint
|
||||
DROP TABLE `oauth_consent`;--> statement-breakpoint
|
||||
CREATE TABLE `oauth_consents` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`client_id` text NOT NULL,
|
||||
`user_id` text,
|
||||
`reference_id` text,
|
||||
`scopes` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()),
|
||||
`updated_at` integer DEFAULT (unixepoch()),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_client_id` ON `oauth_consents` (`client_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_oauth_consents_user_id` ON `oauth_consents` (`user_id`);--> statement-breakpoint
|
||||
CREATE TABLE `jwks` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`public_key` text NOT NULL,
|
||||
`private_key` text NOT NULL,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`expires_at` integer
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
CREATE TABLE `__new_organizations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`config_id` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`normalized_name` text NOT NULL,
|
||||
`avatar_url` text NOT NULL,
|
||||
`membership_role` text DEFAULT 'member' NOT NULL,
|
||||
`is_included` integer DEFAULT true NOT NULL,
|
||||
`destination_org` text,
|
||||
`status` text DEFAULT 'imported' NOT NULL,
|
||||
`last_mirrored` integer,
|
||||
`error_message` text,
|
||||
`repository_count` integer DEFAULT 0 NOT NULL,
|
||||
`public_repository_count` integer,
|
||||
`private_repository_count` integer,
|
||||
`fork_repository_count` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action,
|
||||
FOREIGN KEY (`config_id`) REFERENCES `configs`(`id`) ON UPDATE no action ON DELETE no action
|
||||
);
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `__new_organizations`("id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at") SELECT "id", "user_id", "config_id", "name", "normalized_name", "avatar_url", "membership_role", "is_included", "destination_org", "status", "last_mirrored", "error_message", "repository_count", "public_repository_count", "private_repository_count", "fork_repository_count", "created_at", "updated_at" FROM `organizations`;--> statement-breakpoint
|
||||
DROP TABLE `organizations`;--> statement-breakpoint
|
||||
ALTER TABLE `__new_organizations` RENAME TO `organizations`;--> statement-breakpoint
|
||||
PRAGMA foreign_keys=ON;--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_user_id` ON `organizations` (`user_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_config_id` ON `organizations` (`config_id`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_status` ON `organizations` (`status`);--> statement-breakpoint
|
||||
CREATE INDEX `idx_organizations_is_included` ON `organizations` (`is_included`);--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `uniq_organizations_user_normalized_name` ON `organizations` (`user_id`,`normalized_name`);--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `saml_config` text;--> statement-breakpoint
|
||||
ALTER TABLE `sso_providers` ADD `domain_verified` integer DEFAULT true NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,20 @@
|
||||
"when": 1774058400000,
|
||||
"tag": "0011_notification_config",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "6",
|
||||
"when": 1774062000000,
|
||||
"tag": "0012_oauth_provider_migration",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1780377747526,
|
||||
"tag": "0013_slim_galactus",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "gitea-mirror",
|
||||
"type": "module",
|
||||
"version": "3.16.2",
|
||||
"version": "3.17.1",
|
||||
"engines": {
|
||||
"bun": ">=1.2.9"
|
||||
},
|
||||
@@ -58,7 +58,8 @@
|
||||
"@astrojs/mdx": "5.0.0",
|
||||
"@astrojs/node": "10.0.1",
|
||||
"@astrojs/react": "^5.0.0",
|
||||
"@better-auth/sso": "1.5.5",
|
||||
"@better-auth/oauth-provider": "1.6.11",
|
||||
"@better-auth/sso": "1.6.11",
|
||||
"@octokit/plugin-throttling": "^11.0.3",
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
@@ -87,7 +88,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"astro": "^6.0.4",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.5.5",
|
||||
"better-auth": "1.6.11",
|
||||
"buffer": "^6.0.3",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { readFileSync } from "fs";
|
||||
import path from "path";
|
||||
import {
|
||||
repairDuplicateSsoColumns,
|
||||
restoreSsoDataAfter0013,
|
||||
} from "../src/lib/db/migration-repairs";
|
||||
|
||||
type JournalEntry = {
|
||||
idx: number;
|
||||
@@ -188,6 +192,202 @@ function verify0011Migration(db: any) {
|
||||
assert(parsed.provider === "ntfy", "Expected default notification_config.provider to be 'ntfy'");
|
||||
}
|
||||
|
||||
function seedPre0012Database(db: any) {
|
||||
// The harness has already run migrations 0000-0011, so the legacy
|
||||
// oidc-provider tables exist. Seed a registered client (with the legacy
|
||||
// comma-separated redirect_urls format) plus the related token/consent rows
|
||||
// to exercise the create/transform/drop paths in 0012.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'user1', 'User One')");
|
||||
db.run("INSERT INTO oauth_applications (id, client_id, client_secret, name, redirect_urls, type, disabled, user_id) VALUES ('app1', 'client-1', 'secret-1', 'Example App', 'https://example.com/callback,https://example.com/cb2', 'web', false, 'u1')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, access_token, refresh_token, access_token_expires_at, refresh_token_expires_at, client_id, user_id, scopes) VALUES ('oat1', 'tok', 'rtok', 7000, 8000, 'client-1', 'u1', '[\"repo\"]')");
|
||||
db.run("INSERT INTO oauth_consent (id, user_id, client_id, scopes, consent_given) VALUES ('cons1', 'u1', 'client-1', '[\"repo\"]', true)");
|
||||
}
|
||||
|
||||
function verify0012Migration(db: any) {
|
||||
// Old provider tables are dropped.
|
||||
for (const table of ["oauth_applications", "oauth_consent"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(!row, `Expected ${table} table to be dropped after migration`);
|
||||
}
|
||||
|
||||
// New provider tables exist.
|
||||
for (const table of ["oauth_clients", "oauth_access_tokens", "oauth_refresh_tokens", "oauth_consents", "jwks"]) {
|
||||
const row = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
|
||||
.get(table) as { name: string } | null;
|
||||
assert(row, `Expected ${table} table to exist after migration`);
|
||||
}
|
||||
|
||||
// The registered client is preserved and its redirect URIs converted from
|
||||
// the legacy comma-separated string into a JSON string[].
|
||||
const client = db
|
||||
.query("SELECT client_id, client_secret, name, redirect_uris, type, user_id FROM oauth_clients WHERE id = 'app1'")
|
||||
.get() as { client_id: string; client_secret: string; name: string; redirect_uris: string; type: string; user_id: string } | null;
|
||||
assert(client, "Expected migrated oauth_clients row for app1");
|
||||
assert(client.client_id === "client-1", "Expected client_id to be preserved");
|
||||
assert(client.name === "Example App", "Expected client name to be preserved");
|
||||
assert(client.user_id === "u1", "Expected owner user_id to be preserved");
|
||||
|
||||
const uris = JSON.parse(client.redirect_uris);
|
||||
assert(
|
||||
Array.isArray(uris) && uris.length === 2 && uris[0] === "https://example.com/callback" && uris[1] === "https://example.com/cb2",
|
||||
`Expected redirect_uris to be a JSON array of the two callbacks, got ${client.redirect_uris}`,
|
||||
);
|
||||
|
||||
// The reshaped tables accept the new column layout.
|
||||
db.run("INSERT INTO oauth_clients (id, client_id, redirect_uris) VALUES ('app2', 'client-2', '[\"https://example.com/cb\"]')");
|
||||
db.run("INSERT INTO oauth_refresh_tokens (id, token, client_id, user_id, scopes) VALUES ('rt1', 'refresh-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_access_tokens (id, token, client_id, user_id, scopes) VALUES ('at1', 'access-1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO oauth_consents (id, client_id, user_id, scopes) VALUES ('co1', 'client-2', 'u1', '[\"openid\"]')");
|
||||
db.run("INSERT INTO jwks (id, public_key, private_key) VALUES ('jwk1', 'public', 'private')");
|
||||
}
|
||||
|
||||
function seedPre0013Database(db: any) {
|
||||
// Migrations 0000-0012 have run, so sso_providers lacks samlConfig /
|
||||
// domainVerified and the organizations table still carries the inherited
|
||||
// DEFAULT '' on normalized_name from 0007. Seed both so the table-rebuild
|
||||
// and the column-adds can be verified end-to-end.
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u-sso', 'sso@example.com', 'sso', 'SSO User')");
|
||||
db.run("INSERT INTO configs (id, user_id, name, is_active, github_config, gitea_config, schedule_config, cleanup_config) VALUES ('cfg-pre13', 'u-sso', 'Default', 1, '{}', '{}', '{}', '{}')");
|
||||
db.run("INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id) VALUES ('sso-pre13', 'https://idp.example.com', 'example.com', '{\"clientId\":\"x\"}', 'u-sso', 'idp-pre13')");
|
||||
db.run("INSERT INTO organizations (id, user_id, config_id, name, avatar_url, normalized_name) VALUES ('org-pre13', 'u-sso', 'cfg-pre13', 'Example', 'https://example.com/a.png', 'example')");
|
||||
}
|
||||
|
||||
function verify0013Migration(db: any) {
|
||||
// New columns on sso_providers.
|
||||
const ssoCols = db
|
||||
.query("PRAGMA table_info(sso_providers)")
|
||||
.all() as Array<{ name: string; notnull: number; dflt_value: string | null }>;
|
||||
const saml = ssoCols.find((c) => c.name === "saml_config");
|
||||
const domainVerified = ssoCols.find((c) => c.name === "domain_verified");
|
||||
assert(saml, "Expected sso_providers.saml_config column to exist");
|
||||
assert(saml.notnull === 0, "Expected saml_config to be nullable");
|
||||
assert(domainVerified, "Expected sso_providers.domain_verified column to exist");
|
||||
assert(domainVerified.notnull === 1, "Expected domain_verified to be NOT NULL");
|
||||
assert(
|
||||
domainVerified.dflt_value === "true",
|
||||
`Expected domain_verified DEFAULT true, got ${domainVerified.dflt_value}`,
|
||||
);
|
||||
|
||||
// Pre-existing SSO row picked up the default (1 = true) on domain_verified.
|
||||
const ssoRow = db
|
||||
.query("SELECT provider_id, saml_config, domain_verified FROM sso_providers WHERE id = 'sso-pre13'")
|
||||
.get() as { provider_id: string; saml_config: string | null; domain_verified: number } | null;
|
||||
assert(ssoRow, "Expected pre-existing OIDC provider row to survive migration");
|
||||
assert(ssoRow.saml_config === null, `Expected saml_config NULL, got ${ssoRow.saml_config}`);
|
||||
assert(ssoRow.domain_verified === 1, `Expected domain_verified=1, got ${ssoRow.domain_verified}`);
|
||||
|
||||
// Organizations rebuild preserved the seeded row and dropped the inherited
|
||||
// DEFAULT '' on normalized_name (drizzle reconciles to schema.ts).
|
||||
const orgRow = db
|
||||
.query("SELECT id, normalized_name FROM organizations WHERE id = 'org-pre13'")
|
||||
.get() as { id: string; normalized_name: string } | null;
|
||||
assert(orgRow, "Expected pre-existing organization row to survive table rebuild");
|
||||
assert(orgRow.normalized_name === "example", `Expected organization normalized_name preserved, got ${orgRow.normalized_name}`);
|
||||
const orgCols = db
|
||||
.query("PRAGMA table_info(organizations)")
|
||||
.all() as Array<{ name: string; dflt_value: string | null }>;
|
||||
const normName = orgCols.find((c) => c.name === "normalized_name");
|
||||
assert(normName, "Expected organizations.normalized_name column to exist");
|
||||
assert(normName.dflt_value === null, `Expected normalized_name to have no default, got ${normName.dflt_value}`);
|
||||
}
|
||||
|
||||
const MIGRATION_0012_TIMESTAMP = 1774062000000;
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
/**
|
||||
* Reproduce the issue #312 crash state — sso_providers already carries
|
||||
* saml_config / domain_verified before migration 0013 runs (stranded on an
|
||||
* intermediate build), with __drizzle_migrations recorded only through 0012 —
|
||||
* and verify repairDuplicateSsoColumns()/restoreSsoDataAfter0013() let the
|
||||
* canonical 0013 run while preserving real SAML provider data.
|
||||
*/
|
||||
function validateBroken0013Repair() {
|
||||
const migration0013 = migrations.find((m) => m.entry.tag === "0013_slim_galactus");
|
||||
if (!migration0013) return; // 0013 not present (shouldn't happen) — nothing to test.
|
||||
|
||||
const db = new Database(":memory:");
|
||||
try {
|
||||
runMigrations(db, migrations.slice(0, 13)); // 0000-0012
|
||||
|
||||
// A real upgraded instance has a __drizzle_migrations table recorded
|
||||
// through 0012 but not 0013.
|
||||
db.run(
|
||||
"CREATE TABLE IF NOT EXISTS `__drizzle_migrations` (id INTEGER PRIMARY KEY AUTOINCREMENT, hash text NOT NULL, created_at numeric)",
|
||||
);
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0012', ?)", [
|
||||
MIGRATION_0012_TIMESTAMP,
|
||||
]);
|
||||
|
||||
// Stranded columns from the intermediate build.
|
||||
db.run("ALTER TABLE sso_providers ADD saml_config text");
|
||||
db.run("ALTER TABLE sso_providers ADD domain_verified integer DEFAULT true NOT NULL");
|
||||
|
||||
db.run("INSERT INTO users (id, email, username, name) VALUES ('u1', 'u1@example.com', 'u1', 'User One')");
|
||||
const samlJson = '{"entryPoint":"https://idp.example.com/sso","cert":"ABC123"}';
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('oidc1', 'https://idp', 'a.com', '{}', 'u1', 'p-oidc', NULL, 1)",
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('saml1', 'https://idp', 'b.com', '{}', 'u1', 'p-saml', ?, 1)",
|
||||
[samlJson],
|
||||
);
|
||||
db.run(
|
||||
"INSERT INTO sso_providers (id, issuer, domain, oidc_config, user_id, provider_id, saml_config, domain_verified) VALUES ('unv1', 'https://idp', 'c.com', '{}', 'u1', 'p-unv', NULL, 0)",
|
||||
);
|
||||
|
||||
const preserved = repairDuplicateSsoColumns(db);
|
||||
|
||||
const colsAfterRepair = (db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]).map(
|
||||
(c) => c.name,
|
||||
);
|
||||
assert(!colsAfterRepair.includes("saml_config"), "Expected repair to drop stranded saml_config column");
|
||||
assert(
|
||||
!colsAfterRepair.includes("domain_verified"),
|
||||
"Expected repair to drop stranded domain_verified column",
|
||||
);
|
||||
const preservedIds = preserved.map((r) => r.id).sort();
|
||||
assert(
|
||||
preservedIds.length === 2 && preservedIds[0] === "saml1" && preservedIds[1] === "unv1",
|
||||
`Expected SAML + unverified rows to be preserved, got ${JSON.stringify(preservedIds)}`,
|
||||
);
|
||||
|
||||
// The canonical 0013 must now run without a duplicate-column error.
|
||||
runMigration(db, migration0013);
|
||||
restoreSsoDataAfter0013(db, preserved);
|
||||
|
||||
const rows = db
|
||||
.query("SELECT id, saml_config, domain_verified FROM sso_providers ORDER BY id")
|
||||
.all() as Array<{ id: string; saml_config: string | null; domain_verified: number }>;
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
|
||||
assert(byId.oidc1.saml_config === null, "Expected OIDC provider saml_config to remain NULL");
|
||||
assert(byId.oidc1.domain_verified === 1, "Expected OIDC provider domain_verified default 1");
|
||||
assert(byId.saml1.saml_config === samlJson, "Expected SAML provider config to be preserved");
|
||||
assert(byId.saml1.domain_verified === 1, "Expected SAML provider domain_verified preserved as 1");
|
||||
assert(byId.unv1.saml_config === null, "Expected unverified provider saml_config NULL");
|
||||
assert(byId.unv1.domain_verified === 0, "Expected explicit domain_verified=0 to be preserved");
|
||||
|
||||
// Idempotency: 0013 is now applied, so a re-run of the repair is a no-op.
|
||||
db.run("INSERT INTO `__drizzle_migrations` (hash, created_at) VALUES ('through-0013', ?)", [
|
||||
MIGRATION_0013_TIMESTAMP,
|
||||
]);
|
||||
const secondPass = repairDuplicateSsoColumns(db);
|
||||
assert(secondPass.length === 0, "Expected repair to no-op once migration 0013 is recorded");
|
||||
const colsAfterSecondPass = (
|
||||
db.query("PRAGMA table_info(sso_providers)").all() as TableInfoRow[]
|
||||
).map((c) => c.name);
|
||||
assert(
|
||||
colsAfterSecondPass.includes("saml_config") && colsAfterSecondPass.includes("domain_verified"),
|
||||
"Expected columns to remain intact on the no-op second pass",
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
"0009_nervous_tyger_tiger": {
|
||||
seed: seedPre0009Database,
|
||||
@@ -201,6 +401,14 @@ const latestUpgradeFixtures: Record<string, UpgradeFixture> = {
|
||||
seed: seedPre0011Database,
|
||||
verify: verify0011Migration,
|
||||
},
|
||||
"0012_oauth_provider_migration": {
|
||||
seed: seedPre0012Database,
|
||||
verify: verify0012Migration,
|
||||
},
|
||||
"0013_slim_galactus": {
|
||||
seed: seedPre0013Database,
|
||||
verify: verify0013Migration,
|
||||
},
|
||||
};
|
||||
|
||||
function lintMigrations(selectedMigrations: Migration[]) {
|
||||
@@ -251,8 +459,11 @@ function validateMigrations() {
|
||||
upgradeDb.close();
|
||||
}
|
||||
|
||||
// Exercise the runtime repair for the issue #312 duplicate-column crash.
|
||||
validateBroken0013Repair();
|
||||
|
||||
console.log(
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}.`,
|
||||
`Validated ${migrations.length} migrations from scratch and upgrade path for ${latestMigration.entry.tag}, plus the #312 SSO-column repair.`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,41 +90,29 @@ export default function ConsentPage() {
|
||||
const handleConsent = async (accept: boolean) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// The OAuth provider redirected here with the authorization request in
|
||||
// the query string (client_id, scope, code). Hand that back via
|
||||
// `oauth_query` so the provider can resume the flow. It validates the
|
||||
// redirect URI server-side and returns the URL to navigate to — either
|
||||
// with an authorization code (accept) or an error (deny).
|
||||
const oauthQuery = window.location.search.replace(/^\?/, '');
|
||||
const result = await authClient.oauth2.consent({
|
||||
accept,
|
||||
oauth_query: oauthQuery,
|
||||
scope: Array.from(selectedScopes).join(' '),
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(result.error.message || 'Consent failed');
|
||||
}
|
||||
|
||||
// The consent method should handle the redirect
|
||||
if (!accept) {
|
||||
// If denied, redirect back to the application with error
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const redirectUri = params.get('redirect_uri');
|
||||
|
||||
if (redirectUri && application) {
|
||||
// Validate redirect URI against authorized URIs
|
||||
const authorizedUris = parseRedirectUris(application.redirectURLs);
|
||||
|
||||
if (isValidRedirectUri(redirectUri, authorizedUris)) {
|
||||
try {
|
||||
// Parse and reconstruct the URL to ensure it's safe
|
||||
const url = new URL(redirectUri);
|
||||
url.searchParams.set('error', 'access_denied');
|
||||
|
||||
// Safe to redirect - URI has been validated and sanitized
|
||||
window.location.href = url.toString();
|
||||
} catch (e) {
|
||||
console.error('Failed to parse redirect URI:', e);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
} else {
|
||||
console.error('Unauthorized redirect URI:', redirectUri);
|
||||
setError('Invalid redirect URI');
|
||||
}
|
||||
}
|
||||
const redirectUri = (result.data as { redirect_uri?: string } | undefined)?.redirect_uri;
|
||||
if (redirectUri) {
|
||||
// The redirect URI is produced and validated server-side.
|
||||
window.location.href = redirectUri;
|
||||
} else if (!accept) {
|
||||
// No redirect target returned on denial — return to the app.
|
||||
window.location.href = '/';
|
||||
}
|
||||
} catch (error) {
|
||||
showErrorToast(error, toast);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "@/lib/polyfills/buffer";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import { oidcClient } from "better-auth/client/plugins";
|
||||
import { oauthProviderClient } from "@better-auth/oauth-provider/client";
|
||||
import { ssoClient } from "@better-auth/sso/client";
|
||||
import type { Session as BetterAuthSession, User as BetterAuthUser } from "better-auth";
|
||||
import { withBase } from "@/lib/base-path";
|
||||
@@ -41,7 +41,7 @@ export const authClient = createAuthClient({
|
||||
})(),
|
||||
basePath: withBase('/api/auth'), // Explicitly set the base path
|
||||
plugins: [
|
||||
oidcClient(),
|
||||
oauthProviderClient(),
|
||||
ssoClient(),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, mock, test, beforeEach } from "bun:test";
|
||||
|
||||
// Stub `./auth` so we can drive the response shape `mintSessionFromHeaders`
|
||||
// sees from the plugin endpoint without standing up the full Better Auth
|
||||
// stack + DB.
|
||||
const signInWithHeaderMock = mock<(args: unknown) => Promise<Response>>(
|
||||
async () => new Response(null, { status: 500 }),
|
||||
);
|
||||
|
||||
mock.module("@/lib/auth", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
signInWithHeader: signInWithHeaderMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { mintSessionFromHeaders } from "./auth-header-bridge";
|
||||
|
||||
function makeRequest(headers: Record<string, string> = {}): Request {
|
||||
return new Request("http://localhost/test", { headers });
|
||||
}
|
||||
|
||||
describe("mintSessionFromHeaders", () => {
|
||||
beforeEach(() => {
|
||||
signInWithHeaderMock.mockReset();
|
||||
});
|
||||
|
||||
test("returns user, session, and Set-Cookie values on a 200 response", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
const response = new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1", email: "u@example.com" },
|
||||
session: { id: "sess-1", userId: "user-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
// Modern runtimes coalesce multiple Set-Cookie via append.
|
||||
response.headers.append("set-cookie", "better-auth-session=abc; Path=/");
|
||||
response.headers.append("set-cookie", "better-auth-remember=1; Path=/");
|
||||
return response;
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.user.id).toBe("user-1");
|
||||
expect(result?.session.id).toBe("sess-1");
|
||||
expect(result?.setCookies).toEqual([
|
||||
"better-auth-session=abc; Path=/",
|
||||
"better-auth-remember=1; Path=/",
|
||||
]);
|
||||
});
|
||||
|
||||
test("returns null when the endpoint responds non-2xx (header auth disabled / unauthorized)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () => new Response("Unauthorized", { status: 401 }),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the endpoint throws (transient failure)", async () => {
|
||||
signInWithHeaderMock.mockImplementation(async () => {
|
||||
throw new Error("upstream unreachable");
|
||||
});
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the response body is missing user or session", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ token: "abc" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the body is malformed JSON", async () => {
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response("not json at all", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns an empty setCookies array when no Set-Cookie headers were attached", async () => {
|
||||
// Defensive — should never happen in practice because the plugin
|
||||
// always calls setSessionCookie. If it does happen, we still want
|
||||
// the user/session to come through so SSR works on this request.
|
||||
signInWithHeaderMock.mockImplementation(
|
||||
async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
user: { id: "user-1" },
|
||||
session: { id: "sess-1" },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const result = await mintSessionFromHeaders(makeRequest());
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.setCookies).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { auth } from "./auth";
|
||||
|
||||
export interface BridgeResult {
|
||||
user: any;
|
||||
session: any;
|
||||
setCookies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the `header-auth` plugin endpoint to mint a real Better Auth
|
||||
* session from trusted upstream headers (Authentik / Authelia /
|
||||
* oauth2-proxy / Caddy), and returns the user, session, and the
|
||||
* `Set-Cookie` headers for the middleware to forward onto the
|
||||
* outbound response.
|
||||
*
|
||||
* Fail-open: returns null on any failure (endpoint disabled, headers
|
||||
* missing, DB blip, malformed response). The middleware then sets
|
||||
* locals to null and the request proceeds as anonymous — broken
|
||||
* header auth must never lock everyone out of the cookie-auth path.
|
||||
*
|
||||
* Cookie extraction prefers `Response.headers.getSetCookie()` (Node 18+
|
||||
* fetch, undici). Older runtimes that only expose `get('set-cookie')`
|
||||
* fall through to the single-header form; that path coalesces all
|
||||
* Set-Cookie values into one comma-separated string, which is wrong
|
||||
* for cookies whose attributes contain commas (`Expires` does). We
|
||||
* accept that risk because: (a) Bun and the supported Node versions
|
||||
* for this project both implement `getSetCookie`, and (b) the
|
||||
* fallback only fires on truly ancient runtimes that aren't in our
|
||||
* support matrix.
|
||||
*/
|
||||
export async function mintSessionFromHeaders(
|
||||
request: Request,
|
||||
): Promise<BridgeResult | null> {
|
||||
try {
|
||||
const response = await auth.api.signInWithHeader({
|
||||
headers: request.headers,
|
||||
asResponse: true,
|
||||
});
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!data?.user || !data?.session) return null;
|
||||
|
||||
const setCookies =
|
||||
typeof response.headers.getSetCookie === "function"
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get("set-cookie")
|
||||
? [response.headers.get("set-cookie") as string]
|
||||
: [];
|
||||
|
||||
return { user: data.user, session: data.session, setCookies };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
describe("headerAuthPlugin", () => {
|
||||
test("registers the `header-auth` plugin id", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.id).toBe("header-auth");
|
||||
});
|
||||
|
||||
test("exposes a `signInWithHeader` endpoint", () => {
|
||||
const plugin = headerAuthPlugin();
|
||||
expect(plugin.endpoints?.signInWithHeader).toBeDefined();
|
||||
});
|
||||
|
||||
test("mounts the endpoint at POST /sign-in/header", () => {
|
||||
// The Astro API route is /api/auth/<plugin-path>, so the resolved
|
||||
// URL the middleware bridge talks to is /api/auth/sign-in/header.
|
||||
// Locking that path down in a test prevents an accidental rename
|
||||
// from silently breaking the React SPA's auth flow.
|
||||
const plugin = headerAuthPlugin();
|
||||
const endpoint = plugin.endpoints?.signInWithHeader as unknown as {
|
||||
path: string;
|
||||
options: { method: string };
|
||||
};
|
||||
|
||||
expect(endpoint.path).toBe("/sign-in/header");
|
||||
expect(endpoint.options.method).toBe("POST");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BetterAuthPlugin } from "better-auth";
|
||||
import { APIError, createAuthEndpoint } from "better-auth/api";
|
||||
import { setSessionCookie } from "better-auth/cookies";
|
||||
import { authenticateWithHeaders, isHeaderAuthEnabled } from "./auth-header";
|
||||
|
||||
/**
|
||||
* Better Auth plugin that bridges header / forward authentication into a
|
||||
* real Better Auth session.
|
||||
*
|
||||
* Why this exists: the Astro middleware historically populated
|
||||
* `context.locals.user` from trusted upstream headers (Authentik /
|
||||
* Authelia / oauth2-proxy / Caddy), but never minted a Better Auth
|
||||
* session. Server-rendered pages saw the user, but the React SPA's
|
||||
* `/api/auth/get-session` call hit Better Auth's handler — which only
|
||||
* reads its session cookie — and got `null`. The auth guard then
|
||||
* bounced to `/login`, so header auth was end-to-end broken.
|
||||
*
|
||||
* This plugin exposes `POST /sign-in/header`, which the middleware
|
||||
* calls once per cold request (no cookie yet) when header auth is
|
||||
* enabled. It verifies the trusted headers, creates a real session
|
||||
* row via `internalAdapter.createSession`, and attaches the
|
||||
* `Set-Cookie` to the response. The middleware then forwards that
|
||||
* cookie to the outbound Astro response, so the SPA's next call to
|
||||
* `get-session` carries the cookie and works.
|
||||
*
|
||||
* The endpoint trusts whatever upstream sets the configured headers —
|
||||
* the security model here is "the operator controls the reverse
|
||||
* proxy." Make sure the proxy strips inbound copies of these headers
|
||||
* before forwarding (documented in docs/SSO-OIDC-SETUP.md).
|
||||
*/
|
||||
export const headerAuthPlugin = () =>
|
||||
({
|
||||
id: "header-auth",
|
||||
endpoints: {
|
||||
signInWithHeader: createAuthEndpoint(
|
||||
"/sign-in/header",
|
||||
{ method: "POST" },
|
||||
async (ctx) => {
|
||||
if (!isHeaderAuthEnabled()) {
|
||||
throw new APIError("NOT_FOUND", {
|
||||
message: "Header authentication is not enabled",
|
||||
});
|
||||
}
|
||||
|
||||
const headers = ctx.request?.headers ?? ctx.headers;
|
||||
if (!headers) {
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
message: "Request headers unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await authenticateWithHeaders(headers);
|
||||
if (!user) {
|
||||
throw new APIError("UNAUTHORIZED", {
|
||||
message: "Header authentication failed",
|
||||
});
|
||||
}
|
||||
|
||||
const session = await ctx.context.internalAdapter.createSession(
|
||||
user.id,
|
||||
);
|
||||
if (!session) {
|
||||
throw new APIError("INTERNAL_SERVER_ERROR", {
|
||||
message: "Failed to create session",
|
||||
});
|
||||
}
|
||||
|
||||
await setSessionCookie(ctx, { session, user });
|
||||
|
||||
return ctx.json({
|
||||
token: session.token,
|
||||
user,
|
||||
session,
|
||||
});
|
||||
},
|
||||
),
|
||||
},
|
||||
}) satisfies BetterAuthPlugin;
|
||||
@@ -0,0 +1,158 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test";
|
||||
import {
|
||||
extractUserFromHeaders,
|
||||
isHeaderAuthEnabled,
|
||||
getHeaderAuthConfig,
|
||||
} from "./auth-header";
|
||||
|
||||
// `auth-header` reads config from `process.env` at call time. We snapshot
|
||||
// the relevant keys and restore them after each test so cases don't bleed.
|
||||
const HEADER_ENV_KEYS = [
|
||||
"HEADER_AUTH_ENABLED",
|
||||
"HEADER_AUTH_AUTO_PROVISION",
|
||||
"HEADER_AUTH_USER_HEADER",
|
||||
"HEADER_AUTH_EMAIL_HEADER",
|
||||
"HEADER_AUTH_NAME_HEADER",
|
||||
"HEADER_AUTH_ALLOWED_DOMAINS",
|
||||
] as const;
|
||||
|
||||
let savedEnv: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string | undefined>> = {};
|
||||
|
||||
function setEnv(vars: Partial<Record<(typeof HEADER_ENV_KEYS)[number], string>>) {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
if (key in vars) {
|
||||
process.env[key] = vars[key]!;
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = Object.fromEntries(
|
||||
HEADER_ENV_KEYS.map((k) => [k, process.env[k]]),
|
||||
) as typeof savedEnv;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of HEADER_ENV_KEYS) {
|
||||
const v = savedEnv[key];
|
||||
if (v === undefined) delete process.env[key];
|
||||
else process.env[key] = v;
|
||||
}
|
||||
});
|
||||
|
||||
describe("isHeaderAuthEnabled", () => {
|
||||
test("returns false when HEADER_AUTH_ENABLED is unset", () => {
|
||||
setEnv({});
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns false when HEADER_AUTH_ENABLED is anything other than the string 'true'", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "1" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
setEnv({ HEADER_AUTH_ENABLED: "yes" });
|
||||
expect(isHeaderAuthEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true only for HEADER_AUTH_ENABLED='true' exactly", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
expect(isHeaderAuthEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractUserFromHeaders", () => {
|
||||
test("returns null when header auth is disabled", () => {
|
||||
setEnv({});
|
||||
const headers = new Headers({ "X-Authentik-Username": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the configured user header is absent", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({ "X-Some-Other-Header": "u" });
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("returns username, email, and name from default Authentik headers", () => {
|
||||
setEnv({ HEADER_AUTH_ENABLED: "true" });
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@example.com",
|
||||
"X-Authentik-Name": "Alice Q",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@example.com",
|
||||
name: "Alice Q",
|
||||
});
|
||||
});
|
||||
|
||||
test("respects HEADER_AUTH_USER_HEADER override (Caddy / caddy-security style)", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_EMAIL_HEADER: "X-Token-User-Email",
|
||||
HEADER_AUTH_NAME_HEADER: "X-Token-User-Name",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Token-User-Email": "bob@example.com",
|
||||
"X-Token-User-Name": "Bob",
|
||||
});
|
||||
|
||||
// lanrat's reported config: username and email are both pulled from
|
||||
// the same header. Both should resolve to that value.
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "bob@example.com",
|
||||
email: "bob@example.com",
|
||||
name: "Bob",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects when email domain is not on the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "evil",
|
||||
"X-Authentik-Email": "evil@elsewhere.test",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toBeNull();
|
||||
});
|
||||
|
||||
test("accepts when email domain matches the allow list", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_ALLOWED_DOMAINS: "example.com,corp.example",
|
||||
});
|
||||
const headers = new Headers({
|
||||
"X-Authentik-Username": "alice",
|
||||
"X-Authentik-Email": "alice@corp.example",
|
||||
});
|
||||
|
||||
expect(extractUserFromHeaders(headers)).toEqual({
|
||||
username: "alice",
|
||||
email: "alice@corp.example",
|
||||
name: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHeaderAuthConfig", () => {
|
||||
test("merges env overrides over defaults without leaking unset env values", () => {
|
||||
setEnv({
|
||||
HEADER_AUTH_ENABLED: "true",
|
||||
HEADER_AUTH_USER_HEADER: "X-Forwarded-User",
|
||||
});
|
||||
|
||||
const config = getHeaderAuthConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.userHeader).toBe("X-Forwarded-User");
|
||||
// Unset overrides should fall back to defaults, not become undefined.
|
||||
expect(config.emailHeader).toBe("X-Authentik-Email");
|
||||
expect(config.nameHeader).toBe("X-Authentik-Name");
|
||||
});
|
||||
});
|
||||
+106
-11
@@ -1,11 +1,13 @@
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { oidcProvider } from "better-auth/plugins";
|
||||
import { jwt } from "better-auth/plugins";
|
||||
import { oauthProvider } from "@better-auth/oauth-provider";
|
||||
import { sso } from "@better-auth/sso";
|
||||
import { db, users } from "./db";
|
||||
import * as schema from "./db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { withBase } from "./base-path";
|
||||
import { headerAuthPlugin } from "./auth-header-plugin";
|
||||
|
||||
/**
|
||||
* Resolves the list of trusted origins for Better Auth CSRF validation.
|
||||
@@ -73,6 +75,23 @@ export async function resolveTrustedOrigins(request?: Request): Promise<string[]
|
||||
return uniqueOrigins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the Better Auth logger level from BETTER_AUTH_LOG_LEVEL.
|
||||
* Returns undefined for unset/invalid values so Better Auth falls back
|
||||
* to its built-in default ("warn"). "success" is intentionally excluded
|
||||
* — it is an output level, not a valid threshold.
|
||||
*/
|
||||
function resolveAuthLogLevel(): "debug" | "info" | "warn" | "error" | undefined {
|
||||
const raw = process.env.BETTER_AUTH_LOG_LEVEL?.trim().toLowerCase();
|
||||
if (raw === "debug" || raw === "info" || raw === "warn" || raw === "error") {
|
||||
return raw;
|
||||
}
|
||||
if (raw) {
|
||||
console.warn(`Invalid BETTER_AUTH_LOG_LEVEL: "${raw}", using default ("warn")`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
// Database configuration
|
||||
database: drizzleAdapter(db, {
|
||||
@@ -84,6 +103,17 @@ export const auth = betterAuth({
|
||||
// Secret for signing tokens
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
|
||||
// Logger configuration.
|
||||
//
|
||||
// Better Auth ships its own logger (it does NOT read the `DEBUG` env
|
||||
// var / the `debug` npm package — `DEBUG=better-auth:*` is a no-op).
|
||||
// The default level is "warn", so SSO/OIDC debug and info messages
|
||||
// are hidden out of the box. Set BETTER_AUTH_LOG_LEVEL=debug to surface
|
||||
// the full sign-in / callback trace when troubleshooting SSO.
|
||||
logger: {
|
||||
level: resolveAuthLogLevel(),
|
||||
},
|
||||
|
||||
// Base URL configuration - use the primary URL (Better Auth only supports single baseURL)
|
||||
baseURL: (() => {
|
||||
const url = process.env.BETTER_AUTH_URL;
|
||||
@@ -153,26 +183,71 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
// Account linking configuration.
|
||||
//
|
||||
// Lets a user who first registered with email/password sign in through SSO
|
||||
// and land on the *same* account, instead of being bounced back to /login.
|
||||
// Better Auth's auto-link path (link-account.mjs) refuses unless BOTH sides
|
||||
// pass:
|
||||
// - upstream: provider is "trusted" (either listed in `trustedProviders`
|
||||
// or the SSO plugin marks it trusted via domainVerified +
|
||||
// domain match) OR userInfo.emailVerified === true
|
||||
// - local: existing user is verified (or requireLocalEmailVerified=false)
|
||||
//
|
||||
// We don't wire an email-verification flow, so the local admin always has
|
||||
// emailVerified=false — `requireLocalEmailVerified: false` is required.
|
||||
//
|
||||
// We deliberately do NOT use the catch-all `trustedProviders` list (which
|
||||
// would blanket-trust every registered IdP). Instead the SSO provider's
|
||||
// own `domainVerified` flag — set to true at registration time, scoped to
|
||||
// the operator-supplied `domain` — gates linking. The SSO plugin enforces
|
||||
// `validateEmailDomain(userInfo.email, provider.domain)` on top of it, so
|
||||
// a sign-in is only auto-linked when (a) the operator vouched for the IdP
|
||||
// by registering it, and (b) the user's email actually belongs to the
|
||||
// domain that was vouched for. Cross-domain claims from a compromised or
|
||||
// permissive IdP do not silently absorb local accounts. (Same-domain
|
||||
// claims still require the operator to trust their IdP's identity model.)
|
||||
account: {
|
||||
accountLinking: {
|
||||
enabled: true,
|
||||
requireLocalEmailVerified: false,
|
||||
// Keep the default (false): never link accounts whose emails differ.
|
||||
allowDifferentEmails: false,
|
||||
},
|
||||
},
|
||||
|
||||
// Plugins configuration
|
||||
plugins: [
|
||||
// OIDC Provider plugin - allows this app to act as an OIDC provider
|
||||
oidcProvider({
|
||||
// JWT plugin — provides the JWKS keypair that the OAuth provider uses to
|
||||
// sign OIDC id_tokens with an asymmetric key (what most relying parties
|
||||
// expect). Required by @better-auth/oauth-provider unless disableJwtPlugin
|
||||
// is set. Keys are persisted in the `jwks` table.
|
||||
jwt(),
|
||||
|
||||
// OAuth 2.1 / OIDC Provider — allows this app to act as an identity
|
||||
// provider for other applications. Replaces the deprecated `oidcProvider`
|
||||
// plugin (which Better Auth will remove in a future release). Client and
|
||||
// consent records now live in the oauth_clients / oauth_consents tables.
|
||||
oauthProvider({
|
||||
loginPage: withBase("/login"),
|
||||
consentPage: withBase("/oauth/consent"),
|
||||
// Allow dynamic client registration for flexibility
|
||||
allowDynamicClientRegistration: true,
|
||||
// Note: trustedClients would be configured here if Better Auth supports it
|
||||
// For now, we'll use dynamic registration
|
||||
// Customize user info claims based on scopes
|
||||
getAdditionalUserInfoClaim: (user, scopes) => {
|
||||
// Mirror the old getAdditionalUserInfoClaim: expose our extra `username`
|
||||
// field on both the userinfo endpoint and the id_token when the
|
||||
// "profile" scope is granted.
|
||||
customUserInfoClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) {
|
||||
claims.username = user.username;
|
||||
}
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
customIdTokenClaims: ({ user, scopes }) => {
|
||||
const claims: Record<string, any> = {};
|
||||
if (scopes.includes("profile")) claims.username = (user as any).username;
|
||||
return claims;
|
||||
},
|
||||
}),
|
||||
|
||||
|
||||
// SSO plugin - allows users to authenticate with external OIDC providers
|
||||
sso({
|
||||
// Provision new users when they sign in with SSO
|
||||
@@ -204,7 +279,27 @@ export const auth = betterAuth({
|
||||
disableImplicitSignUp: false,
|
||||
// Trust email_verified claims from the upstream provider so we can link by matching email
|
||||
trustEmailVerified: true,
|
||||
// Surface `domainVerified` to the model so account-linking can read it.
|
||||
//
|
||||
// Better Auth's adapter transformOutput (factory.mjs) only copies fields
|
||||
// that are declared in the plugin's model schema; the SSO plugin only
|
||||
// declares `domainVerified` when this option is enabled. Without it the
|
||||
// column is in the DB but stripped from the returned provider object, so
|
||||
// the trust check `"domainVerified" in provider` is silently false and
|
||||
// sign-ins land on /?error=UNKNOWN. We don't use the DNS-based
|
||||
// verify-domain flow this option also exposes — we set
|
||||
// `domainVerified: true` directly in src/pages/api/auth/sso/register.ts
|
||||
// after the plugin's create, scoped by the operator-supplied `domain`.
|
||||
domainVerification: { enabled: true },
|
||||
}),
|
||||
|
||||
// Header / forward authentication bridge. Exposes
|
||||
// POST /api/auth/sign-in/header so the middleware can mint a real
|
||||
// Better Auth session from trusted upstream headers (Authentik /
|
||||
// Authelia / oauth2-proxy / Caddy). Without this the SPA's
|
||||
// /api/auth/get-session call returns null on header-auth-only
|
||||
// requests and bounces the user to /login. See auth-header-plugin.ts.
|
||||
headerAuthPlugin(),
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
+13
-2
@@ -3,6 +3,7 @@ import { drizzle } from "drizzle-orm/bun-sqlite";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
|
||||
import { repairDuplicateSsoColumns, restoreSsoDataAfter0013 } from "./migration-repairs";
|
||||
|
||||
// Skip database initialization in test environment
|
||||
let db: ReturnType<typeof drizzle>;
|
||||
@@ -95,9 +96,17 @@ if (process.env.NODE_ENV !== "test") {
|
||||
// Fix any migrations that were recorded but actually failed (e.g. v3.13.0 bug)
|
||||
repairFailedMigrations();
|
||||
|
||||
// Fix the v3.17.0 duplicate-column crash: reconcile stranded sso_providers
|
||||
// columns so migration 0013 can run (see #312). Returns data to re-apply
|
||||
// once 0013 has re-added the columns.
|
||||
const preservedSsoData = repairDuplicateSsoColumns(sqlite);
|
||||
|
||||
// Run migrations using Drizzle migrate function
|
||||
migrate(db, { migrationsFolder: "./drizzle" });
|
||||
|
||||
// Re-apply any SSO provider data preserved by the repair above.
|
||||
restoreSsoDataAfter0013(sqlite, preservedSsoData);
|
||||
|
||||
console.log("✅ Database migrations completed successfully");
|
||||
} catch (error) {
|
||||
console.error("❌ Error running migrations:", error);
|
||||
@@ -123,9 +132,11 @@ export {
|
||||
accounts,
|
||||
verificationTokens,
|
||||
verifications,
|
||||
oauthApplications,
|
||||
oauthClients,
|
||||
oauthAccessTokens,
|
||||
oauthConsent,
|
||||
oauthRefreshTokens,
|
||||
oauthConsents,
|
||||
jwkss,
|
||||
ssoProviders,
|
||||
rateLimits
|
||||
} from "./schema";
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Database } from "bun:sqlite";
|
||||
|
||||
/**
|
||||
* Pre-migration repairs that reconcile a database into the exact shape Drizzle's
|
||||
* migrator expects, so a previously-failed migration can complete on the next
|
||||
* boot. These run BEFORE `migrate()` and are deliberately defensive: any failure
|
||||
* is logged and swallowed so they never make a recoverable database worse.
|
||||
*/
|
||||
|
||||
/** Migration 0013 journal timestamp (from drizzle/meta/_journal.json, idx 13). */
|
||||
const MIGRATION_0013_TIMESTAMP = 1780377747526;
|
||||
|
||||
export type PreservedSsoRow = {
|
||||
id: string;
|
||||
saml_config?: string | null;
|
||||
domain_verified?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repair the v3.17.0 (PR #307) "duplicate column name: saml_config" crash loop
|
||||
* reported in issue #312.
|
||||
*
|
||||
* Some instances ended up with `sso_providers.saml_config` / `domain_verified`
|
||||
* already present BEFORE migration 0013 ran — the columns were declared in
|
||||
* schema.ts and entered the DB via `db:push` or an SSO-register round-trip on an
|
||||
* intermediate build, while `__drizzle_migrations` never recorded a 0013 row.
|
||||
*
|
||||
* Migration 0013 runs as a single transaction (organizations rebuild + the two
|
||||
* `ALTER TABLE sso_providers ADD ...`). The ADD hits the pre-existing column,
|
||||
* throws "duplicate column", and rolls back the ENTIRE transaction — so 0013 is
|
||||
* never recorded and is retried, failing identically, on every boot.
|
||||
*
|
||||
* This is the mirror image of the 0009 repair in index.ts (record present,
|
||||
* column missing): here the column is present but the record is missing. We
|
||||
* reconcile `sso_providers` back to its true pre-0013 shape so the canonical
|
||||
* 0013 can run in full (the organizations rebuild MUST NOT be skipped),
|
||||
* preserving any real SAML provider config across the drop/re-add.
|
||||
*
|
||||
* Returns the rows whose values must be re-applied by {@link restoreSsoDataAfter0013}
|
||||
* once 0013 has re-added the columns. Returns an empty array when there is
|
||||
* nothing to do (fresh install, clean upgrade, or genuine pre-0013 shape).
|
||||
*/
|
||||
export function repairDuplicateSsoColumns(sqlite: Database): PreservedSsoRow[] {
|
||||
try {
|
||||
const migrationsTableExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='__drizzle_migrations'")
|
||||
.get();
|
||||
|
||||
// Fresh install — no migrations recorded yet, vanilla migrate() handles it.
|
||||
if (!migrationsTableExists) return [];
|
||||
|
||||
// 0013 already recorded (clean upgrade / already healed) — nothing to do.
|
||||
const alreadyApplied = sqlite
|
||||
.query("SELECT 1 FROM __drizzle_migrations WHERE created_at >= ? LIMIT 1")
|
||||
.get(MIGRATION_0013_TIMESTAMP);
|
||||
if (alreadyApplied) return [];
|
||||
|
||||
const ssoExists = sqlite
|
||||
.query("SELECT name FROM sqlite_master WHERE type='table' AND name='sso_providers'")
|
||||
.get();
|
||||
if (!ssoExists) return [];
|
||||
|
||||
const cols = sqlite.query("PRAGMA table_info(sso_providers)").all() as { name: string }[];
|
||||
const hasSaml = cols.some((c) => c.name === "saml_config");
|
||||
const hasDomainVerified = cols.some((c) => c.name === "domain_verified");
|
||||
|
||||
// Genuine pre-0013 shape — let migration 0013 add the columns as-is.
|
||||
if (!hasSaml && !hasDomainVerified) return [];
|
||||
|
||||
console.log(
|
||||
"🔧 Detected stranded SSO columns (migration 0013 not recorded). Reconciling sso_providers so 0013 can run...",
|
||||
);
|
||||
|
||||
// Preserve any real data before dropping. SAML providers store JSON config
|
||||
// in saml_config; domain_verified may have been explicitly set to false.
|
||||
const selectCols = ["id"];
|
||||
if (hasSaml) selectCols.push("saml_config");
|
||||
if (hasDomainVerified) selectCols.push("domain_verified");
|
||||
const preserved = sqlite
|
||||
.query(`SELECT ${selectCols.join(", ")} FROM sso_providers`)
|
||||
.all() as PreservedSsoRow[];
|
||||
|
||||
// SQLite >= 3.35 (bun:sqlite ships much newer) supports DROP COLUMN.
|
||||
if (hasSaml) sqlite.run("ALTER TABLE sso_providers DROP COLUMN saml_config");
|
||||
if (hasDomainVerified) sqlite.run("ALTER TABLE sso_providers DROP COLUMN domain_verified");
|
||||
|
||||
// Only rows whose values differ from the 0013 defaults (saml_config NULL,
|
||||
// domain_verified true/1) need restoring after the columns are re-added.
|
||||
return preserved.filter(
|
||||
(r) => (hasSaml && r.saml_config != null) || (hasDomainVerified && r.domain_verified === 0),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ SSO column repair check failed (non-fatal):", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-apply the SSO provider values preserved by {@link repairDuplicateSsoColumns}
|
||||
* once migration 0013 has re-added saml_config / domain_verified with their
|
||||
* defaults (saml_config NULL, domain_verified = 1). No-op when nothing was
|
||||
* preserved (the common OIDC-only case).
|
||||
*/
|
||||
export function restoreSsoDataAfter0013(sqlite: Database, preserved: PreservedSsoRow[]): void {
|
||||
if (preserved.length === 0) return;
|
||||
try {
|
||||
const stmt = sqlite.prepare(
|
||||
"UPDATE sso_providers SET saml_config = ?, domain_verified = ? WHERE id = ?",
|
||||
);
|
||||
for (const r of preserved) {
|
||||
stmt.run(r.saml_config ?? null, r.domain_verified ?? 1, r.id);
|
||||
}
|
||||
console.log(`✅ Restored ${preserved.length} preserved SSO provider value(s) after migration 0013.`);
|
||||
} catch (error) {
|
||||
console.warn("⚠️ Failed to restore preserved SSO data (non-fatal):", error);
|
||||
}
|
||||
}
|
||||
+107
-46
@@ -618,69 +618,121 @@ export const verifications = sqliteTable("verifications", {
|
||||
|
||||
// ===== OIDC Provider Tables =====
|
||||
|
||||
// OAuth Applications table
|
||||
export const oauthApplications = sqliteTable("oauth_applications", {
|
||||
// ===== OAuth 2.1 / OIDC Provider tables (@better-auth/oauth-provider) =====
|
||||
//
|
||||
// These back the OAuth/OIDC *provider* feature (gitea-mirror acting as an
|
||||
// identity provider for other apps). They are managed entirely by Better
|
||||
// Auth's drizzle adapter, so:
|
||||
// - the exported binding name must equal the plugin model name pluralized
|
||||
// under `usePlural: true` (oauthClient -> oauthClients, jwks -> jwkss);
|
||||
// - the object property names must match the plugin field names (camelCase),
|
||||
// while the SQL column names may be snake_case;
|
||||
// - `string[]` and `json` fields are serialized to JSON text by the adapter,
|
||||
// so they are plain `text` columns here.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin (tables
|
||||
// oauth_applications / oauth_access_tokens / oauth_consent). See the
|
||||
// accompanying Drizzle migration for the data-preserving upgrade path.
|
||||
|
||||
// OAuth clients (replaces the old `oauth_applications` table)
|
||||
export const oauthClients = sqliteTable("oauth_clients", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull().unique(),
|
||||
clientSecret: text("client_secret").notNull(),
|
||||
name: text("name").notNull(),
|
||||
redirectURLs: text("redirect_urls").notNull(), // Comma-separated list
|
||||
metadata: text("metadata"), // JSON string
|
||||
type: text("type").notNull(), // web, mobile, etc
|
||||
disabled: integer("disabled", { mode: "boolean" }).notNull().default(false),
|
||||
userId: text("user_id"), // Optional - owner of the application
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
clientSecret: text("client_secret"),
|
||||
name: text("name"),
|
||||
disabled: integer("disabled", { mode: "boolean" }).default(false),
|
||||
skipConsent: integer("skip_consent", { mode: "boolean" }),
|
||||
enableEndSession: integer("enable_end_session", { mode: "boolean" }),
|
||||
subjectType: text("subject_type"),
|
||||
scopes: text("scopes"), // JSON string[]
|
||||
userId: text("user_id").references(() => users.id),
|
||||
uri: text("uri"),
|
||||
icon: text("icon"),
|
||||
contacts: text("contacts"), // JSON string[]
|
||||
tos: text("tos"),
|
||||
policy: text("policy"),
|
||||
softwareId: text("software_id"),
|
||||
softwareVersion: text("software_version"),
|
||||
softwareStatement: text("software_statement"),
|
||||
redirectUris: text("redirect_uris").notNull(), // JSON string[]
|
||||
postLogoutRedirectUris: text("post_logout_redirect_uris"), // JSON string[]
|
||||
tokenEndpointAuthMethod: text("token_endpoint_auth_method"),
|
||||
grantTypes: text("grant_types"), // JSON string[]
|
||||
responseTypes: text("response_types"), // JSON string[]
|
||||
public: integer("public", { mode: "boolean" }),
|
||||
type: text("type"),
|
||||
requirePKCE: integer("require_pkce", { mode: "boolean" }),
|
||||
referenceId: text("reference_id"),
|
||||
metadata: text("metadata"), // JSON
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_applications_client_id").on(table.clientId),
|
||||
index("idx_oauth_applications_user_id").on(table.userId),
|
||||
index("idx_oauth_clients_client_id").on(table.clientId),
|
||||
index("idx_oauth_clients_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Access Tokens table
|
||||
// OAuth access tokens
|
||||
export const oauthAccessTokens = sqliteTable("oauth_access_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
accessToken: text("access_token").notNull(),
|
||||
refreshToken: text("refresh_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at", { mode: "timestamp" }).notNull(),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at", { mode: "timestamp" }),
|
||||
token: text("token").unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
refreshId: text("refresh_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_access_tokens_access_token").on(table.accessToken),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
index("idx_oauth_access_tokens_token").on(table.token),
|
||||
index("idx_oauth_access_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_access_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth Consent table
|
||||
export const oauthConsent = sqliteTable("oauth_consent", {
|
||||
// OAuth refresh tokens (new in the OAuth 2.1 provider)
|
||||
export const oauthRefreshTokens = sqliteTable("oauth_refresh_tokens", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
token: text("token").notNull().unique(),
|
||||
clientId: text("client_id").notNull(),
|
||||
scopes: text("scopes").notNull(), // Comma-separated list
|
||||
consentGiven: integer("consent_given", { mode: "boolean" }).notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" })
|
||||
.notNull()
|
||||
.default(sql`(unixepoch())`),
|
||||
sessionId: text("session_id"),
|
||||
userId: text("user_id").notNull().references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
revoked: integer("revoked", { mode: "timestamp" }),
|
||||
authTime: integer("auth_time", { mode: "timestamp" }),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
}, (table) => [
|
||||
index("idx_oauth_consent_user_id").on(table.userId),
|
||||
index("idx_oauth_consent_client_id").on(table.clientId),
|
||||
index("idx_oauth_consent_user_client").on(table.userId, table.clientId),
|
||||
index("idx_oauth_refresh_tokens_token").on(table.token),
|
||||
index("idx_oauth_refresh_tokens_client_id").on(table.clientId),
|
||||
index("idx_oauth_refresh_tokens_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// OAuth consent records
|
||||
export const oauthConsents = sqliteTable("oauth_consents", {
|
||||
id: text("id").primaryKey(),
|
||||
clientId: text("client_id").notNull(),
|
||||
userId: text("user_id").references(() => users.id),
|
||||
referenceId: text("reference_id"),
|
||||
scopes: text("scopes").notNull(), // JSON string[]
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
updatedAt: integer("updated_at", { mode: "timestamp" }).default(sql`(unixepoch())`),
|
||||
}, (table) => [
|
||||
index("idx_oauth_consents_client_id").on(table.clientId),
|
||||
index("idx_oauth_consents_user_id").on(table.userId),
|
||||
]);
|
||||
|
||||
// JWKS keypairs for signing OIDC id_tokens (better-auth `jwt` plugin).
|
||||
// Model name "jwks" pluralizes to the binding name "jwkss" under usePlural,
|
||||
// while the physical table stays "jwks".
|
||||
export const jwkss = sqliteTable("jwks", {
|
||||
id: text("id").primaryKey(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
privateKey: text("private_key").notNull(),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }),
|
||||
});
|
||||
|
||||
// ===== SSO Provider Tables =====
|
||||
|
||||
// SSO Providers table
|
||||
@@ -689,6 +741,15 @@ export const ssoProviders = sqliteTable("sso_providers", {
|
||||
issuer: text("issuer").notNull(),
|
||||
domain: text("domain").notNull(),
|
||||
oidcConfig: text("oidc_config").notNull(), // JSON string with OIDC configuration
|
||||
// The upgraded @better-auth/sso plugin writes this on every insert (null for OIDC providers).
|
||||
// Drizzle's adapter rejects unknown fields, so the column must exist.
|
||||
samlConfig: text("saml_config"),
|
||||
// Used by the SSO plugin's account-linking trust check: a sign-in is treated
|
||||
// as trusted when this is true AND the user's email domain matches `domain`
|
||||
// above. We set this to true on register (see /api/auth/sso/register.ts) so
|
||||
// domain-scoped auto-linking works out of the box; the column default keeps
|
||||
// existing rows trusted after upgrade.
|
||||
domainVerified: integer("domain_verified", { mode: "boolean" }).notNull().default(true),
|
||||
userId: text("user_id").notNull(), // Admin who created this provider
|
||||
providerId: text("provider_id").notNull().unique(), // Unique identifier for the provider
|
||||
organizationId: text("organization_id"), // Optional - if provider is linked to an organization
|
||||
|
||||
+36
-27
@@ -6,7 +6,8 @@ import { startRepositoryCleanupService, stopRepositoryCleanupService } from './l
|
||||
import { initializeShutdownManager, registerShutdownCallback } from './lib/shutdown-manager';
|
||||
import { setupSignalHandlers } from './lib/signal-handlers';
|
||||
import { auth } from './lib/auth';
|
||||
import { isHeaderAuthEnabled, authenticateWithHeaders } from './lib/auth-header';
|
||||
import { isHeaderAuthEnabled } from './lib/auth-header';
|
||||
import { mintSessionFromHeaders } from './lib/auth-header-bridge';
|
||||
import { initializeConfigFromEnv } from './lib/env-config-loader';
|
||||
import { db, users } from './lib/db';
|
||||
import { getBasePath } from './lib/base-path';
|
||||
@@ -37,6 +38,13 @@ let envConfigCheckCount = 0; // Track attempts to avoid excessive checking
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const basePath = getBasePath();
|
||||
|
||||
// Set-Cookie headers we mint during the header-auth bridge below.
|
||||
// Forwarded onto the outbound response after `next()` so the browser
|
||||
// persists the Better Auth session cookie. Until that happens the
|
||||
// SPA's /api/auth/get-session call returns null and bounces to
|
||||
// /login — see the bridge block for the full rationale.
|
||||
let pendingSetCookies: string[] = [];
|
||||
|
||||
// First, try Better Auth session (cookie-based)
|
||||
try {
|
||||
const session = await auth.api.getSession({
|
||||
@@ -46,36 +54,26 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
if (session) {
|
||||
context.locals.user = session.user;
|
||||
context.locals.session = session.session;
|
||||
} else {
|
||||
// No cookie session, check for header authentication
|
||||
if (isHeaderAuthEnabled()) {
|
||||
const headerUser = await authenticateWithHeaders(context.request.headers);
|
||||
if (headerUser) {
|
||||
// Create a session-like object for header auth
|
||||
context.locals.user = {
|
||||
id: headerUser.id,
|
||||
email: headerUser.email,
|
||||
emailVerified: headerUser.emailVerified,
|
||||
name: headerUser.name || headerUser.username,
|
||||
username: headerUser.username,
|
||||
createdAt: headerUser.createdAt,
|
||||
updatedAt: headerUser.updatedAt,
|
||||
};
|
||||
context.locals.session = {
|
||||
id: `header-${headerUser.id}`,
|
||||
userId: headerUser.id,
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 1 day
|
||||
ipAddress: context.request.headers.get('x-forwarded-for') || context.clientAddress,
|
||||
userAgent: context.request.headers.get('user-agent'),
|
||||
};
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} else if (isHeaderAuthEnabled()) {
|
||||
// No cookie session, but header auth is on. Call the
|
||||
// header-auth plugin endpoint to mint a real Better Auth
|
||||
// session from the trusted upstream headers, then forward the
|
||||
// Set-Cookie onto the outbound response so the SPA's next
|
||||
// /api/auth/get-session call carries the cookie. Without this
|
||||
// bridge the React app sees null on mount and redirects to
|
||||
// /login even though server-rendered code paths know the user.
|
||||
const bridge = await mintSessionFromHeaders(context.request);
|
||||
if (bridge) {
|
||||
context.locals.user = bridge.user;
|
||||
context.locals.session = bridge.session;
|
||||
pendingSetCookies = bridge.setCookies;
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} else {
|
||||
context.locals.user = null;
|
||||
context.locals.session = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// If there's an error getting the session, set to null
|
||||
@@ -252,6 +250,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
||||
// Continue with the request
|
||||
const response = await next();
|
||||
|
||||
// Forward any Set-Cookie headers minted by the header-auth bridge
|
||||
// onto the outbound response. Done before the early returns below so
|
||||
// every return path (basePath rewrite, non-HTML responses, etc.)
|
||||
// carries the cookie. The body-rewrite branch further down clones
|
||||
// `response.headers`, so anything appended here survives the clone.
|
||||
if (pendingSetCookies.length > 0) {
|
||||
for (const cookie of pendingSetCookies) {
|
||||
response.headers.append("set-cookie", cookie);
|
||||
}
|
||||
}
|
||||
|
||||
if (basePath === "/") {
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,11 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Use Better Auth server API to register OAuth2 application
|
||||
const response = await auth.api.registerOAuthApplication({
|
||||
// Use Better Auth server API to register OAuth2 client (RFC 7591
|
||||
// dynamic client registration via @better-auth/oauth-provider).
|
||||
// Note: jwks / jwks_uri / metadata are not part of the new
|
||||
// registration body and are intentionally omitted.
|
||||
const response = await auth.api.registerOAuthClient({
|
||||
body: {
|
||||
client_name,
|
||||
redirect_uris,
|
||||
@@ -61,9 +64,6 @@ export async function POST(context: APIContext) {
|
||||
contacts,
|
||||
tos_uri,
|
||||
policy_uri,
|
||||
jwks_uri,
|
||||
jwks,
|
||||
metadata,
|
||||
software_id,
|
||||
software_version,
|
||||
software_statement,
|
||||
|
||||
@@ -152,25 +152,14 @@ export async function POST(context: APIContext) {
|
||||
headers.set("cookie", cookieHeader);
|
||||
}
|
||||
|
||||
// Register the SSO provider using Better Auth's API
|
||||
const response = await auth.api.registerSSOProvider({
|
||||
// Register the SSO provider using Better Auth's API.
|
||||
// auth.api.* returns the parsed result directly (not a fetch Response); it
|
||||
// throws APIError on failure, which createSecureErrorResponse handles below.
|
||||
const result = await auth.api.registerSSOProvider({
|
||||
body: registrationBody,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Failed to register SSO provider: ${error}` }),
|
||||
{
|
||||
status: response.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Mirror provider entry into local SSO table for UI listing
|
||||
try {
|
||||
const existing = await db
|
||||
@@ -183,6 +172,13 @@ export async function POST(context: APIContext) {
|
||||
issuer: registrationBody.issuer,
|
||||
domain: registrationBody.domain,
|
||||
organizationId: registrationBody.organizationId,
|
||||
// Mark this provider as trusted for the `domain` it was registered
|
||||
// under. Better Auth's SSO plugin gates account auto-linking on this
|
||||
// flag together with an email-domain match (validateEmailDomain), so
|
||||
// sign-ins from users outside the registered domain are NOT
|
||||
// auto-linked even though the provider is trusted. The plugin's own
|
||||
// create call hardcodes this to false, so we set it here.
|
||||
domainVerified: true,
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
@@ -210,6 +206,7 @@ export async function POST(context: APIContext) {
|
||||
userId: user.id,
|
||||
providerId: registrationBody.providerId,
|
||||
organizationId: registrationBody.organizationId,
|
||||
domainVerified: true,
|
||||
});
|
||||
}
|
||||
} catch (mirroringError) {
|
||||
|
||||
@@ -1,26 +1,61 @@
|
||||
import type { APIContext } from "astro";
|
||||
import { createSecureErrorResponse } from "@/lib/utils";
|
||||
import { requireAuth } from "@/lib/utils/auth-helpers";
|
||||
import { db, oauthApplications } from "@/lib/db";
|
||||
import { nanoid } from "nanoid";
|
||||
import { auth } from "@/lib/auth";
|
||||
import { db, oauthClients } from "@/lib/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { generateRandomString } from "@/lib/utils";
|
||||
|
||||
// GET /api/sso/applications - List all OAuth applications
|
||||
// Backward-compatible OAuth application management API.
|
||||
//
|
||||
// Migrated from the deprecated `oidc-provider` plugin to
|
||||
// `@better-auth/oauth-provider`. Clients now live in the `oauth_clients`
|
||||
// table and are managed by the plugin (which generates and hashes the
|
||||
// client secret), so mutations delegate to `auth.api.*OAuthClient`. Reads
|
||||
// are served straight from the table and mapped back to the legacy response
|
||||
// shape (`redirectURLs` as a comma-separated string) so existing consumers
|
||||
// — notably the consent page — keep working.
|
||||
|
||||
// `redirectUris` is stored by the adapter as a JSON-encoded string[].
|
||||
function parseRedirectUris(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value as string[];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
} catch {
|
||||
// Legacy comma-separated fallback
|
||||
return value.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// Map a new oauth_clients row onto the legacy application response shape.
|
||||
function toLegacyApplication(row: typeof oauthClients.$inferSelect) {
|
||||
return {
|
||||
id: row.id,
|
||||
clientId: row.clientId,
|
||||
name: row.name ?? "",
|
||||
redirectURLs: parseRedirectUris(row.redirectUris).join(","),
|
||||
type: row.type ?? "web",
|
||||
disabled: row.disabled ?? false,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
// Never expose the (hashed) client secret in list responses
|
||||
clientSecret: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/sso/applications - List all OAuth clients
|
||||
export async function GET(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const applications = await db.select().from(oauthApplications);
|
||||
const rows = await db.select().from(oauthClients);
|
||||
const sanitized = rows.map(toLegacyApplication);
|
||||
|
||||
// Don't send client secrets in list response
|
||||
const sanitizedApps = applications.map(app => ({
|
||||
...app,
|
||||
clientSecret: undefined,
|
||||
}));
|
||||
|
||||
return new Response(JSON.stringify(sanitizedApps), {
|
||||
return new Response(JSON.stringify(sanitized), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -29,10 +64,10 @@ export async function GET(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/sso/applications - Create a new OAuth application
|
||||
// POST /api/sso/applications - Register a new OAuth client
|
||||
export async function POST(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const body = await context.request.json();
|
||||
@@ -49,27 +84,27 @@ export async function POST(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Generate client credentials
|
||||
const clientId = `client_${generateRandomString(32)}`;
|
||||
const clientSecret = `secret_${generateRandomString(48)}`;
|
||||
const redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Insert new application
|
||||
const [newApp] = await db
|
||||
.insert(oauthApplications)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
clientId,
|
||||
clientSecret,
|
||||
name,
|
||||
redirectURLs: Array.isArray(redirectURLs) ? redirectURLs.join(",") : redirectURLs,
|
||||
// Delegate to the OAuth provider plugin so the client_id / client_secret
|
||||
// are generated and the secret is stored using the plugin's hashing.
|
||||
const created = await auth.api.createOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: {
|
||||
client_name: name,
|
||||
redirect_uris,
|
||||
type,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
userId: user.id,
|
||||
disabled: false,
|
||||
})
|
||||
.returning();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(newApp), {
|
||||
// The plugin returns RFC-style snake_case fields. Surface them plus the
|
||||
// one-time client_secret (only returned here, never again).
|
||||
return new Response(JSON.stringify(created), {
|
||||
status: 201,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -78,18 +113,19 @@ export async function POST(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/sso/applications/:id - Update an OAuth application
|
||||
// PUT /api/sso/applications?id=<clientId> - Update an OAuth client
|
||||
export async function PUT(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.pathname.split("/").pop();
|
||||
// Accept the OAuth client_id either from the query string or the path.
|
||||
const clientId = url.searchParams.get("id") || url.pathname.split("/").pop();
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -98,35 +134,26 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
|
||||
const body = await context.request.json();
|
||||
const { name, redirectURLs, disabled, metadata } = body;
|
||||
const { name, redirectURLs, disabled } = body;
|
||||
|
||||
const updateData: any = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
const updateBody: Record<string, unknown> = { client_id: clientId };
|
||||
if (name !== undefined) updateBody.client_name = name;
|
||||
if (disabled !== undefined) updateBody.disabled = disabled;
|
||||
if (redirectURLs !== undefined) {
|
||||
updateData.redirectURLs = Array.isArray(redirectURLs)
|
||||
? redirectURLs.join(",")
|
||||
: redirectURLs;
|
||||
}
|
||||
if (disabled !== undefined) updateData.disabled = disabled;
|
||||
if (metadata !== undefined) updateData.metadata = JSON.stringify(metadata);
|
||||
|
||||
const [updated] = await db
|
||||
.update(oauthApplications)
|
||||
.set({
|
||||
...updateData,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
updateBody.redirect_uris = Array.isArray(redirectURLs)
|
||||
? redirectURLs
|
||||
: String(redirectURLs)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ ...updated, clientSecret: undefined }), {
|
||||
const updated = await auth.api.updateOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: updateBody as any,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(updated), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
@@ -135,18 +162,18 @@ export async function PUT(context: APIContext) {
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/sso/applications/:id - Delete an OAuth application
|
||||
// DELETE /api/sso/applications?id=<clientId> - Delete an OAuth client
|
||||
export async function DELETE(context: APIContext) {
|
||||
try {
|
||||
const { user, response } = await requireAuth(context);
|
||||
const { response } = await requireAuth(context);
|
||||
if (response) return response;
|
||||
|
||||
const url = new URL(context.request.url);
|
||||
const appId = url.searchParams.get("id");
|
||||
const clientId = url.searchParams.get("id");
|
||||
|
||||
if (!appId) {
|
||||
if (!clientId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Application ID is required" }),
|
||||
JSON.stringify({ error: "Client ID is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -154,18 +181,26 @@ export async function DELETE(context: APIContext) {
|
||||
);
|
||||
}
|
||||
|
||||
const deleted = await db
|
||||
.delete(oauthApplications)
|
||||
.where(eq(oauthApplications.id, appId))
|
||||
.returning();
|
||||
// Ensure the client exists so we can return a 404 (the plugin endpoint
|
||||
// may otherwise succeed silently).
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(oauthClients)
|
||||
.where(eq(oauthClients.clientId, clientId))
|
||||
.limit(1);
|
||||
|
||||
if (deleted.length === 0) {
|
||||
if (existing.length === 0) {
|
||||
return new Response(JSON.stringify({ error: "Application not found" }), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
await auth.api.deleteOAuthClient({
|
||||
headers: context.request.headers,
|
||||
body: { client_id: clientId },
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -173,4 +208,4 @@ export async function DELETE(context: APIContext) {
|
||||
} catch (error) {
|
||||
return createSecureErrorResponse(error, "SSO applications API");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user