Compare commits

...

8 Commits

Author SHA1 Message Date
Arunavo Ray 699a5771f5 chore: bump version to 3.17.1 2026-06-05 18:42:11 +05:30
ARUNAVO RAY e862714d6a fix(db): self-heal sso_providers duplicate-column crash on upgrade (#312) (#313)
Migration 0013 runs as a single transaction that rebuilds `organizations`
and then `ALTER TABLE sso_providers ADD saml_config` / `ADD domain_verified`.
On instances where those columns were already created outside Drizzle (declared
in schema.ts and added via db:push / an SSO-register round-trip on an
intermediate build), the ADD throws "duplicate column name: saml_config". That
rolls back the entire 0013 transaction, so 0013 is never recorded in
`__drizzle_migrations` and is retried — failing identically — on every boot,
crash-looping the server.

Add a pre-migrate repair (mirroring the existing repairFailedMigrations() for
the 0009 case): when 0013 is unrecorded but the columns already exist, preserve
any real SAML provider config, drop the stranded columns so the canonical 0013
runs in full (organizations rebuild included), then restore the preserved
values once the columns are re-added. No-op on fresh installs, clean upgrades,
and already-migrated databases.

This lets affected instances recover automatically on the next boot after
upgrading — no manual SQLite surgery required.

- src/lib/db/migration-repairs.ts: repairDuplicateSsoColumns + restoreSsoDataAfter0013
- src/lib/db/index.ts: wire both around migrate()
- scripts/validate-migrations.ts: cover the broken-upgrade + data-preservation path
2026-06-05 18:41:46 +05:30
Arunavo Ray 716981aa04 chore: bump version to 3.17.0 2026-06-02 11:41:24 +05:30
ARUNAVO RAY 66e3284898 fix(sso): repair SSO login bounce + migrate to @better-auth/oauth-provider (#307)
Resolves #306. SSO sign-in via OIDC (Authentik / Keycloak / etc.) now links the
SSO identity to an existing email/password admin instead of bouncing to /login
with `?error=UNKNOWN`. Account-linking is gated on the operator-supplied
**Domain** field — cross-domain claims from a compromised IdP are refused.

Also bundles the deprecated `oidcProvider` → `@better-auth/oauth-provider`
migration. **Operators using the OAuth-provider feature must rotate registered
client secrets after upgrade** (legacy plaintext → hashed storage; see the
0012 migration notes).

Verified end-to-end on the pr-307 image against a real Authentik instance:
SSO login lands on the dashboard, `accounts` table gets both `credential` and
`authentik` rows for the same user. See PR description for full details.
2026-06-02 11:40:54 +05:30
IYUANWEIZE 695f0ff005 Fix Docker image line in .env.example (#305) 2026-05-28 13:49:32 +05:30
Arunavo Ray 53f2cf36fc chore: bump version to 3.16.3 2026-05-27 14:53:34 +05:30
ARUNAVO RAY 8ffcf3bdc6 fix: bridge header auth into a real Better Auth session (#303)
Header / forward authentication has been end-to-end broken since the
v3 rewrite. The middleware populated `context.locals.user` from
trusted upstream headers (Authentik / Authelia / oauth2-proxy /
Caddy), but never minted a Better Auth session, and never set a
cookie. 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
redirected to `/login`, even though the upstream proxy had already
authenticated the user.

Reported on issue #29 by @lanrat with a clean repro on v3.16.1.

Fix: add a small Better Auth plugin (`header-auth`) that exposes
`POST /api/auth/sign-in/header`. The endpoint validates the trusted
headers via `authenticateWithHeaders`, creates a real session row via
`internalAdapter.createSession`, and attaches the `Set-Cookie` via
`setSessionCookie` — the same pattern the magic-link, anonymous, and
phone-number plugins use after their respective verification steps.

The Astro middleware now calls this endpoint when no cookie session
exists and header auth is enabled, forwards the `Set-Cookie` onto the
outbound response, and populates `context.locals` from the minted
session. After the first request the browser has the cookie; every
subsequent request takes the normal cookie-auth fast path and the
bridge doesn't fire.

Fail-open everywhere: any endpoint failure (header auth disabled,
auth rejected, DB blip, malformed response) returns null from the
bridge and the request proceeds as anonymous. A broken header-auth
configuration must never lock everyone out of the cookie-auth path.

Tests:
- `auth-header.test.ts` — unit tests for `extractUserFromHeaders` and
  `isHeaderAuthEnabled`, including lanrat's reported config shape
  (same header for username and email).
- `auth-header-plugin.test.ts` — locks down plugin id, endpoint key,
  path, and method so an accidental rename can't silently break the
  middleware bridge.
- `auth-header-bridge.test.ts` — covers the cookie-extraction logic
  and the fail-open paths (non-2xx, thrown error, malformed JSON,
  missing fields, no Set-Cookie attached).

Stacks on top of #301 (better-auth 1.6.11 bump).

Refs: #29
2026-05-27 14:53:16 +05:30
ARUNAVO RAY a07af96f84 chore: bump better-auth to 1.6.11 (#301)
Updates `better-auth` and `@better-auth/sso` from 1.5.5 to 1.6.11 to
pick up the patch fixes that have landed since (OAuth state CSRF
verification, scrypt non-blocking password hashing, account cookie
comparison fix, session freshness alignment, etc.). All 270 local
tests pass against the new version.

The only behavioral surface to watch:
  - `freshAge` now aligns with session `createdAt` instead of
    `updatedAt`. Not applicable here — we don't gate anything on
    session freshness.
  - 1.6.2 adds OAuth state-parameter CSRF verification. Applies to
    OIDC/SAML SSO flows; no code changes needed.
  - 1.6 emits a deprecation warning for `oidc-provider` in favor of
    `@better-auth/oauth-provider`. The plugin still works in 1.6.x;
    migrating it is a separate cleanup.

Prep work for an upcoming header-auth fix (issue #29 follow-up).
2026-05-27 14:44:17 +05:30
29 changed files with 6217 additions and 265 deletions
+4 -1
View File
@@ -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
# ===========================================
+18
View File
@@ -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
+1
View File
@@ -52,3 +52,4 @@ tests/e2e/git-repos/
/blob-report/
/playwright/.cache/
/playwright/.auth/
.playwright-mcp/
+32 -45
View File
@@ -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=="],
+1
View File
@@ -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
View File
@@ -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 users 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
View File
@@ -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
+126
View File
@@ -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
);
+35
View File
@@ -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
+15 -1
View File
@@ -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
View File
@@ -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",
+212 -1
View File
@@ -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.`,
);
}
+15 -27
View File
@@ -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);
+2 -2
View File
@@ -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(),
],
});
+123
View File
@@ -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([]);
});
});
+56
View File
@@ -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;
}
}
+29
View File
@@ -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");
});
});
+78
View File
@@ -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;
+158
View File
@@ -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
View File
@@ -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
View File
@@ -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";
+117
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+5 -5
View File
@@ -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,
+12 -15
View File
@@ -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) {
+109 -74
View File
@@ -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");
}
}
}