diff --git a/.changeset/secure-cookie-defaults.md b/.changeset/secure-cookie-defaults.md new file mode 100644 index 0000000..907e6b3 --- /dev/null +++ b/.changeset/secure-cookie-defaults.md @@ -0,0 +1,15 @@ +--- +"@codelitdev/oauth-server-kit": minor +--- + +Add hardened Better Auth defaults. `@codelitdev/oauth-server-kit/better-auth` exports `secureCookieOptions` (`__Host-` session cookies over https, `__Secure-` with cross-subdomain cookies, plain over http, optional client-IP header), `sessionCookieName`, `secureSessionOptions` (no session cookie cache), `secureEmailAndPasswordOptions` (password sign-up off; opting in also requires a verified email before sign-in) and `isRecentlyAuthenticated`. Session identities now carry `authenticatedAt`. `@codelitdev/oauth-server-kit/express` exports `parseTrustProxy`, `DEFAULT_TRUST_PROXY` and `createClientIpMiddleware`. + +Requires Better Auth 1.7.16 or later (`better-auth` and `@better-auth/oauth-provider` peer floor `^1.7.16`; tested on 1.7.22), which carries fixes for GHSA-fvxx-76fc-893x, GHSA-2f9c-7xj7-gh55 and GHSA-9hm7-xhfp-cx58. A product's resource client must be bound to its real Better Auth instance (`options` and a `$context` resolving `baseURL`), as newer `@better-auth/oauth-provider` requires. + +**Product changes for this release** + +- Upgrade `better-auth` and every `@better-auth/*` package together (1.7.22 needs `@better-auth/utils` 0.5.1, `better-call` 1.4.5 and `@better-fetch/fetch` 1.3.3; mismatched copies break plugin types). If your database ran Better Auth 1.7.0–1.7.2, make `account.issuer` nullable and drop `account_issuer_accountId_uidx` (see Better Auth's 1.7 upgrade guide). + +- Spread `secureCookieOptions({ baseURL, cookiePrefix, clientIpHeader })` into `advanced`, `secureSessionOptions()` into `session`, and set `emailAndPassword: secureEmailAndPasswordOptions()`. Switching to `__Host-` renames the session cookie, so users sign in once more; select credentials with `sessionCookieName(advanced)`. +- Set `app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY))` and mount `createClientIpMiddleware` before Better Auth's handler. +- Under `__Host-` the OAuth provider's RP-initiated logout confirmation cookie is rejected by browsers; use cross-subdomain mode if you need that step. diff --git a/AGENTS.md b/AGENTS.md index bd3f0ef..bf07d86 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ ## General Tips - We are building a "good enough" framework, not a "perfect" one. Focus on building in a way where we are able to ship faster without compromising reliability, mantainability and security but know when we are going overboard and being pedantic. - None of our platform has get hit thousands of users (let alone millions), so don't waste time trying to pre-optimize the stack for such cases. -- Every product must set a product-specific `advanced.cookiePrefix` on its Better Auth instance (for example, `courselit` or `sendlit`) so products on the same browser hostname do not overwrite each other's session cookies. Keep any cookie-name parsing or legacy-cookie cleanup aligned with that prefix, and preserve other `advanced` settings such as `crossSubDomainCookies`. This belongs in the product's Better Auth configuration, not `@codelitdev/oauth-server-kit`, which does not create the auth instance. +- Every product must set a product-specific `advanced.cookiePrefix` on its Better Auth instance (for example, `courselit` or `sendlit`) so products on the same browser hostname do not overwrite each other's session cookies. Keep any cookie-name parsing or legacy-cookie cleanup aligned with that prefix, and preserve other `advanced` settings such as `crossSubDomainCookies`. Products keep their own prefix but pass it through `secureCookieOptions` (and `secureSessionOptions` for `session`) from `@codelitdev/oauth-server-kit/better-auth`, which adds the `__Host-` prefix over https and the trusted client-IP header. The kit still does not create the auth instance; the product spreads the helpers into its own Better Auth config. - Make sure to record critical decisions as ADR in `docs/decisions` diff --git a/bun.lock b/bun.lock index 240555e..363ed43 100644 --- a/bun.lock +++ b/bun.lock @@ -16,10 +16,10 @@ "name": "@reference-product/api", "version": "0.0.0", "dependencies": { - "@better-auth/core": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", + "@better-auth/core": "1.7.22", + "@better-auth/oauth-provider": "1.7.22", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", "@codelitdev/billing": "workspace:*", "@codelitdev/mcp-server-kit": "workspace:*", "@codelitdev/oauth-server-kit": "workspace:*", @@ -30,8 +30,8 @@ "@ts-rest/express": "3.52.1", "@ts-rest/open-api": "^3.52.1", "@types/pg": "^8.23.1", - "better-auth": "1.7.2", - "better-call": "1.4.0", + "better-auth": "1.7.22", + "better-call": "1.4.5", "drizzle-orm": "^0.45.2", "express": "^4.21.2", "pg": "^8.23.0", @@ -80,7 +80,7 @@ }, "packages/billing": { "name": "@codelitdev/billing", - "version": "0.8.1", + "version": "0.9.0", "bin": { "codelit-billing": "./bin/codelit-billing.js", }, @@ -106,7 +106,7 @@ }, "packages/design-system": { "name": "@codelitdev/design-system", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@types/react": "^19.2.17", "@types/react-dom": "^19.2.5", @@ -124,7 +124,7 @@ }, "packages/mcp-server-kit": { "name": "@codelitdev/mcp-server-kit", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "@modelcontextprotocol/server": "^2.4.0", "zod": "^3.25.76", @@ -138,36 +138,36 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/platform": ">=0.4.2", + "@codelitdev/platform": ">=0.9.0", }, }, "packages/oauth-server-kit": { "name": "@codelitdev/oauth-server-kit", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { - "@better-auth/core": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", + "@better-auth/core": "1.7.22", + "@better-auth/oauth-provider": "1.7.22", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", "@types/bun": "^1.4.1", "@types/express": "^4.17.20", "@types/node": "^22.14.1", - "better-auth": "1.7.2", - "better-call": "1.4.0", + "better-auth": "1.7.22", + "better-call": "1.4.5", "express": "^4.21.2", "rimraf": "^5.0.0", "tsup": "^8.3.0", "typescript": "^5.9.3", }, "peerDependencies": { - "@better-auth/oauth-provider": "^1.7.2", - "better-auth": "^1.7.2", + "@better-auth/oauth-provider": "^1.7.16", + "better-auth": "^1.7.16", "express": "^4.21.2", }, }, "packages/observability": { "name": "@codelitdev/observability", - "version": "0.8.1", + "version": "0.9.0", "dependencies": { "pino": "^9.6.0", }, @@ -192,7 +192,7 @@ }, "packages/platform": { "name": "@codelitdev/platform", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@codelitdev/billing": "workspace:*", "@electric-sql/pglite": "^0.2.17", @@ -202,7 +202,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/billing": ">=0.4.2", + "@codelitdev/billing": ">=0.9.0", "drizzle-orm": "^0.45.2", }, "optionalPeers": [ @@ -212,7 +212,7 @@ }, "packages/platform-cli": { "name": "@codelitdev/platform-cli", - "version": "0.8.1", + "version": "0.9.0", "bin": { "codelit-platform": "./dist/bin.js", }, @@ -224,7 +224,7 @@ }, "packages/platform-conformance": { "name": "@codelitdev/platform-conformance", - "version": "0.8.1", + "version": "0.9.0", "devDependencies": { "@codelitdev/platform": "workspace:*", "@types/bun": "^1.4.1", @@ -232,7 +232,7 @@ "typescript": "^5.9.3", }, "peerDependencies": { - "@codelitdev/platform": ">=0.1.0-alpha.0", + "@codelitdev/platform": ">=0.9.0", }, }, }, @@ -307,25 +307,25 @@ "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], - "@better-auth/core": ["@better-auth/core@1.7.2", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.41.1", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.0", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-j0nM4ygsWbF/fcYRoKtDn8gn8uLXkmC+075HqSqsJEAV828cJR9bvYBCUQ1zmxNyRBk6Iz/qXsA0Zm2oksiOTg=="], + "@better-auth/core": ["@better-auth/core@1.7.22", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "@opentelemetry/api": "^1.9.0", "better-call": "1.4.5", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-SnXqxtrf9Ns8GSGzDzqKuf80Ve9m2UTPdBPpPqCvp0XgzFmHQ8aa5dXoeEoyXPc/GXpuxUu9S1Ug97tYnMITkQ=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-A5wE10PIv3aS5LGePecEHntQylKy6OOF17B4dqlE0DwJeqU/IOBSd7/LZhMop9cNJ3WFjKMpazVSf91yYM/NFg=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.0" }, "optionalPeers": ["drizzle-orm"] }, "sha512-L1STjx5/scvG920w7aTap0floq55Rljt9s63EzSZXeHSmhQgNSinJX63NP+ko3Ad8pq/E8QDVzGVjZk/KOOgcQ=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-LYdSRLOvZiF+6S0UThu+wE/Qxsq9P2jQs7ZKkY6BIBJqUjYyxVDmi8HFcantBvWWW1/BeQCSsD7YVDG4gICMIQ=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-0z0B6jNAty9M9vE9GFPGT3JLQ+TNhn1WxVFEYD9EETZru9nT4wKwotcTCexfgsJt+T6TsmksbDCziCtw3feC1w=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2" } }, "sha512-0q1SXMzm5esH9L0xVuM6IxCk59E4G+3HySX4My9gvEwqtmUobykn+iuc/si3Y4xwUO7JODqQ5o+/pPcLDDMIrA=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0" } }, "sha512-8fnbduapnOG7SANS/UOpDd1bkoEw1PeN6HhFPug0sy9rrUDFzp6+4Ym9fQOIIMh8dsiuMTj2dg69ip4qNwLI+Q=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-4879SmUWHUs0OYlvHoCFbycZ7i1bqytkcgAUdt9RLQMvZ5H3LRMTgax2YVlGZEXgwNjY/X7xAoXOecWLhlQWeA=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-h0SpvQcOeJyGswgYsjCMCZzlY2505ayAYtVy/JtXalGlfdLB7JXIVuNgAM1ZcnRn7twN3gtNLpfX0yQTkXQiwA=="], - "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.2", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "better-auth": "^1.7.2", "better-call": "1.4.0" } }, "sha512-td7FnUz3lLKXFXN+0RbZe3ygaHqxpRqDG+gxbfSZbztbVfG7vuZtR4ba3uccsodAw7anchhIg2xwVP1/zlcxcw=="], + "@better-auth/oauth-provider": ["@better-auth/oauth-provider@1.7.22", "", { "dependencies": { "jose": "^6.2.3", "zod": "^4.5.4" }, "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3", "better-auth": "^1.7.22", "better-call": "1.4.5" } }, "sha512-LmzWmMo2GCTxekOeHJ0LGoDP+4Rhrm3bTqloS/6dQtqgwC+Uyp09plwXPr+5/n3+y71mznG5qRwlhAcOHXxnJg=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@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-mXTr/83WrNWLrvzIjtgDgdu9iXhOcSG1+qBQOAKlbGSFiOB+z4IMRneQ2wmMOiB8mKY9qGkClVUjKRFXqtHnFQ=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.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-Eht1/hSH373kcxePH52ppD3btDM7yJBcDAKzi0zM7UND6EQzjAILF2qfzbqyy+27qQdihOBHJpWKUM+cEES7kA=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.7.2", "", { "peerDependencies": { "@better-auth/core": "^1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-LcWu+O0zrxYDQj8E36vfkJwGPW4k9ZDA/rCo0zST6ihzL+juR7pBowoZIM9E6tK0Vit52mf6412bGT4XM4eTjQ=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.7.22", "", { "peerDependencies": { "@better-auth/core": "^1.7.22", "@better-auth/utils": "^0.4.2 || ^0.5.0", "@better-fetch/fetch": "1.3.3" } }, "sha512-Ih3bPGDrPBfMU7PNeImsaJOYy8kcfwbGbMRo0t4VTws4OERd1Kdk06DB2nXOKoLSM/b1X1Y2dhDOx/wPeeaQcw=="], - "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], + "@better-auth/utils": ["@better-auth/utils@0.5.1", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-WETqw5vQ52uQ/xwWR4E8rTgT6fDEr/wnS+otDNkBCa/69281FCF32OKDdQUcahMCnjsvqXBNiEvAQHFB0EVRRg=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.3", "", {}, "sha512-U6SlDt/GUWtK2JAxS/NhdoYTEim+EL1G5byz2TUj83KjLZBUpYzb5SmX0aXaCnbGkAjFKnV/o/0+cyXMbEcqqg=="], "@biomejs/biome": ["@biomejs/biome@2.5.12", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.12", "@biomejs/cli-darwin-x64": "2.5.12", "@biomejs/cli-linux-arm64": "2.5.12", "@biomejs/cli-linux-arm64-musl": "2.5.12", "@biomejs/cli-linux-x64": "2.5.12", "@biomejs/cli-linux-x64-musl": "2.5.12", "@biomejs/cli-win32-arm64": "2.5.12", "@biomejs/cli-win32-x64": "2.5.12" }, "bin": { "biome": "bin/biome" } }, "sha512-Lw4VHZRebrReBBnlHa12JQjnIBm3JJAA55PDB9LbBVBF0q4RYphm6KfmIjqtPhf61MxZ5Q9KoK8R8x+7per5Aw=="], @@ -573,8 +573,6 @@ "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], - "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], - "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -731,9 +729,9 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="], - "better-auth": ["better-auth@1.7.2", "", { "dependencies": { "@better-auth/core": "1.7.2", "@better-auth/drizzle-adapter": "1.7.2", "@better-auth/kysely-adapter": "1.7.2", "@better-auth/memory-adapter": "1.7.2", "@better-auth/mongo-adapter": "1.7.2", "@better-auth/prisma-adapter": "1.7.2", "@better-auth/telemetry": "1.7.2", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.0", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "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 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.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-gKapKBEvYIGcMxi74RjQ7EbFLiqyQt58vdoJmL1qAlWSkY1Bc2Vqshl524/3u1NxauiOU03M/Ebh762Brmac9A=="], + "better-auth": ["better-auth@1.7.22", "", { "dependencies": { "@better-auth/core": "1.7.22", "@better-auth/drizzle-adapter": "1.7.22", "@better-auth/kysely-adapter": "1.7.22", "@better-auth/memory-adapter": "1.7.22", "@better-auth/mongo-adapter": "1.7.22", "@better-auth/prisma-adapter": "1.7.22", "@better-auth/telemetry": "1.7.22", "@better-auth/utils": "0.5.1", "@better-fetch/fetch": "1.3.3", "@noble/ciphers": "^2.2.0", "@noble/hashes": "^2.2.0", "better-call": "1.4.5", "defu": "^6.1.4", "jose": "^6.2.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.3.0", "zod": "^4.5.4" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0 || ^3.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "drizzle-kit": ">=0.31.4 || >=1.0.0-beta.1", "drizzle-orm": "^0.45.2 || >=1.0.0-rc.1 <2.0.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 || ^5.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-vVUQaDiYjJyocBHY1eSRIQuG9JEC5kNg281xfqrZiLP6SLoplJyaXJO4zgv1ZlK8EFn7syOuXR7wjYq9TeQFYA=="], - "better-call": ["better-call@1.4.0", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA=="], + "better-call": ["better-call@1.4.5", "", { "dependencies": { "@better-auth/utils": "^0.5.0", "@better-fetch/fetch": "^1.3.1", "rou3": "^0.9.1", "set-cookie-parser": "^3.1.2" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-GVOWfn3pjEfUXn6GrfGf2h7tPAqj1oMLFBWBEswJrSkDKcLk6TIcjV0zexibcZZ0c5N1mkLMsPFopXWzWj/gDQ=="], "body-parser": ["body-parser@1.20.6", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.15.1", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g=="], @@ -1499,8 +1497,6 @@ "better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], - "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], - "better-call/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "body-parser/raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], diff --git a/docs/decisions/0018-secure-session-cookies-and-client-ip.md b/docs/decisions/0018-secure-session-cookies-and-client-ip.md new file mode 100644 index 0000000..a1de9f1 --- /dev/null +++ b/docs/decisions/0018-secure-session-cookies-and-client-ip.md @@ -0,0 +1,56 @@ +# ADR 0018: Secure session cookies, no cookie cache, trusted client IP + +Status: accepted +Date: 2026-11-13 + +## Context + +CourseLit hardened its Better Auth setup locally. SendLit and MediaLit have the +same exposure, and each would otherwise re-derive it. The kit does not create +the Better Auth instance, so these must be helpers products spread into their +own config. + +## Decision + +- Better Auth 1.7.16 or later is required (peer floor): it fixes revoked + sessions outliving the cookie cache on sensitive routes, OTP attempt-limit + races, and unverified OAuth emails counting as verified (GHSA-fvxx-76fc-893x, + GHSA-2f9c-7xj7-gh55, GHSA-9hm7-xhfp-cx58). The kit is tested on 1.7.22. + +- Session cookies default to `__Host-.session_token` over https + (Secure, `Path=/`, no `Domain`) so a sibling subdomain cannot plant or + shadow them. Better Auth only knows `__Secure-`, so `__Host-` is folded into + `cookiePrefix` with `useSecureCookies: false` and Secure set through + `defaultCookieAttributes`. Products that need cross-subdomain cookies keep + `__Secure-` (`__Host-` forbids `Domain`). Plain names are used over http. + Exposed as `secureCookieOptions` and `sessionCookieName`. +- Password sign-up is off by default (`secureEmailAndPasswordOptions`); a + product that opts in must also require a verified email before sign-in, + so nobody can register an address they don't own and sit on it. +- The session cookie cache is off (`secureSessionOptions`): every request + reads the session row, so a revoked session stops working immediately. +- The client address comes from a trusted proxy chain: the app sets Express + `trust proxy` (`parseTrustProxy`), `createClientIpMiddleware` writes `req.ip` + (which walks `X-Forwarded-For` from the right) into an internal header, + overwriting client input, and Better Auth is told to read only that header. + The default trusted proxies are private networks; products whose clients + can reach them from a private network set `TRUST_PROXY` to the real proxy + subnet. The same internal header also feeds product rate limiting, so one + resolution serves Better Auth and the product (rather than Better Auth's + own `ipAddress.trustedProxies`, which only Better Auth would use). +- Session identities expose `authenticatedAt` (the session's `createdAt`) and + `isRecentlyAuthenticated` supports step-up checks. Products decide which + actions need it; OAuth identities never count. + +## Consequences + +- `@better-auth/oauth-provider` sets its logout-confirmation cookie + (`.oauth_logout_confirmation`) on + `/api/auth/oauth2/end-session/confirm`; browsers reject a `__Host-` cookie + with that path. Neither plugin options nor `advanced.cookies` can change its + name or path, so under `__Host-` the confirmation step of RP-initiated + logout does not complete. Accepted; products needing it use cross-subdomain + (`__Secure-`) mode. +- Disabling the cookie cache costs one session lookup per request, acceptable + at our scale. +- Changing the cookie name logs existing users out once. diff --git a/examples/reference-product/apps/api/package.json b/examples/reference-product/apps/api/package.json index 511515c..2497118 100644 --- a/examples/reference-product/apps/api/package.json +++ b/examples/reference-product/apps/api/package.json @@ -21,10 +21,10 @@ "pretest": "bun run check:auth-schema" }, "dependencies": { - "@better-auth/core": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", + "@better-auth/core": "1.7.22", + "@better-auth/oauth-provider": "1.7.22", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", "@codelitdev/billing": "workspace:*", "@codelitdev/mcp-server-kit": "workspace:*", "@codelitdev/oauth-server-kit": "workspace:*", @@ -35,8 +35,8 @@ "@ts-rest/express": "3.52.1", "@ts-rest/open-api": "^3.52.1", "@types/pg": "^8.23.1", - "better-call": "1.4.0", - "better-auth": "1.7.2", + "better-call": "1.4.5", + "better-auth": "1.7.22", "drizzle-orm": "^0.45.2", "express": "^4.21.2", "pg": "^8.23.0", diff --git a/examples/reference-product/apps/api/src/auth/authenticate.ts b/examples/reference-product/apps/api/src/auth/authenticate.ts index 92227e4..82329d0 100644 --- a/examples/reference-product/apps/api/src/auth/authenticate.ts +++ b/examples/reference-product/apps/api/src/auth/authenticate.ts @@ -33,7 +33,9 @@ export async function authenticateHttpRequest( headers: HeaderMap, deps: AuthRuntime, ): Promise> { - const selected = selectHttpCredential(headers); + const selected = selectHttpCredential(headers, { + sessionCookieName: deps.auth.sessionCookieName, + }); if (selected.kind === "absent") { return mapTransportAuthentication({ kind: "absent" }, { transport: "http" }); } diff --git a/examples/reference-product/apps/api/src/auth/better-auth.ts b/examples/reference-product/apps/api/src/auth/better-auth.ts index bafae05..ef237d4 100644 --- a/examples/reference-product/apps/api/src/auth/better-auth.ts +++ b/examples/reference-product/apps/api/src/auth/better-auth.ts @@ -3,7 +3,12 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import * as authSchema from "../db/schema/auth.generated.js"; import type { AppDb } from "../types.js"; -import { AUTH_BASE_PATH, authUrls, referenceAuthOptions } from "./options.js"; +import { + AUTH_BASE_PATH, + authCookieOptions, + authUrls, + referenceAuthOptions, +} from "./options.js"; export function createReferenceAuth(input: { db: AppDb; @@ -32,6 +37,7 @@ export function createReferenceAuth(input: { oauthResourceClient, ...urls, authBasePath: AUTH_BASE_PATH, + sessionCookieName: authCookieOptions(input.publicApiUrl).sessionCookieName, }; } diff --git a/examples/reference-product/apps/api/src/auth/options.ts b/examples/reference-product/apps/api/src/auth/options.ts index 627a2e7..ee4c4d6 100644 --- a/examples/reference-product/apps/api/src/auth/options.ts +++ b/examples/reference-product/apps/api/src/auth/options.ts @@ -1,9 +1,17 @@ import { oauthProvider } from "@better-auth/oauth-provider"; -import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth"; +import { + createOAuthProviderOptions, + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, + sessionCookieName, +} from "@codelitdev/oauth-server-kit/better-auth"; +import type { BetterAuthOptions } from "better-auth"; import { emailOTP } from "better-auth/plugins/email-otp"; import { jwt } from "better-auth/plugins/jwt"; export const AUTH_BASE_PATH = "/api/auth"; +export const AUTH_COOKIE_PREFIX = "reference-product"; export const AUTH_SECRET_MIN_LENGTH = 32; export const DATA_READ_SCOPE = "data:read"; @@ -16,6 +24,15 @@ export const MCP_SCOPES_SUPPORTED = [ "offline_access", ] as const; +/** Better Auth's `advanced` cookie options, plus the session cookie name they produce. */ +export function authCookieOptions(publicApiUrl: string) { + const advanced = secureCookieOptions({ + baseURL: publicApiUrl, + cookiePrefix: AUTH_COOKIE_PREFIX, + }); + return { advanced, sessionCookieName: sessionCookieName(advanced) }; +} + export type ReferenceAuthUrls = { publicApiUrl: string; webOrigin: string; @@ -68,7 +85,7 @@ export function referenceAuthOptions(input: { publicApiUrl: string; secret: string; webOrigin?: string; - database?: unknown; + database?: BetterAuthOptions["database"]; }) { const urls = authUrls(input.publicApiUrl, input.webOrigin); return { @@ -76,8 +93,12 @@ export function referenceAuthOptions(input: { baseURL: urls.publicApiUrl, basePath: AUTH_BASE_PATH, secret: input.secret, + // `__Host-` session cookie over https; each product keeps its own prefix. + advanced: { ...authCookieOptions(urls.publicApiUrl).advanced }, + // No cookie cache: a revoked session stops working immediately. + session: secureSessionOptions(), trustedOrigins: [urls.webOrigin, urls.publicApiUrl], - emailAndPassword: { enabled: false }, + emailAndPassword: secureEmailAndPasswordOptions(), ...(input.database ? { database: input.database } : {}), plugins: [ jwt(), diff --git a/examples/reference-product/apps/api/src/db/migrations/0000_auth.sql b/examples/reference-product/apps/api/src/db/migrations/0000_auth.sql index d1ef5f7..1da530f 100644 --- a/examples/reference-product/apps/api/src/db/migrations/0000_auth.sql +++ b/examples/reference-product/apps/api/src/db/migrations/0000_auth.sql @@ -22,7 +22,6 @@ CREATE TABLE IF NOT EXISTS "session" ( --> statement-breakpoint CREATE TABLE IF NOT EXISTS "account" ( id text PRIMARY KEY, - "issuer" text NOT NULL, "account_id" text NOT NULL, "provider_id" text NOT NULL, "user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE, diff --git a/examples/reference-product/apps/api/src/db/schema/auth.generated.ts b/examples/reference-product/apps/api/src/db/schema/auth.generated.ts index c29d012..d37015c 100644 --- a/examples/reference-product/apps/api/src/db/schema/auth.generated.ts +++ b/examples/reference-product/apps/api/src/db/schema/auth.generated.ts @@ -29,7 +29,6 @@ export const session = pgTable("session", { export const account = pgTable("account", { id: text("id").primaryKey(), - issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }), diff --git a/packages/oauth-server-kit/README.md b/packages/oauth-server-kit/README.md index a532d80..9b2bf1b 100644 --- a/packages/oauth-server-kit/README.md +++ b/packages/oauth-server-kit/README.md @@ -31,12 +31,19 @@ import { import { createOAuthProviderOptions, + isRecentlyAuthenticated, resolveBetterAuthSession, + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, + sessionCookieName, } from "@codelitdev/oauth-server-kit/better-auth"; import { + createClientIpMiddleware, createOAuthBearerMiddleware, createOAuthPagesRouter, + parseTrustProxy, } from "@codelitdev/oauth-server-kit/express"; import { createMcpOAuthDiscoveryRoutes } from "@codelitdev/oauth-server-kit/mcp"; @@ -57,9 +64,33 @@ application. - Ordinary-login redirects are restricted to configured origins. - Hosted pages reject framing and clear configured stale host-only cookies at both `/login` and `/oauth/login`. +- Session cookies are `__Host-.session_token` (Secure, `Path=/`, no + `Domain`) over https through `secureCookieOptions`; with cross-subdomain + cookies they stay `__Secure-`, and over http (local dev) they are plain. + Products keep their own `cookiePrefix` and pass it to the helper. +- `secureSessionOptions()` turns the session cookie cache off, so a revoked + session stops working on the next request. +- `secureEmailAndPasswordOptions()` keeps password sign-up off; a product + that opts in with `{ enabled: true }` also requires a verified email before + sign-in, so nobody can claim an address they don't own. +- `createClientIpMiddleware` resolves the client address from a trusted proxy + chain (`req.ip`, which ignores the attacker-controlled left of + `X-Forwarded-For`) into an internal header, and `secureCookieOptions`' + `clientIpHeader` makes Better Auth read only that header. +- Session identities carry `authenticatedAt`; `isRecentlyAuthenticated` + supports step-up checks. OAuth identities never count as recent. - Product claims such as `team_id`, roles, or permissions are not returned as trusted package identity fields. +Known limitation: `@better-auth/oauth-provider`'s RP-initiated logout +confirmation cookie (`.oauth_logout_confirmation`) is set on +`/api/auth/oauth2/end-session/confirm`. Browsers reject a `__Host-` cookie with a +path other than `/`, so under `__Host-` that confirmation step (shown when an +OAuth client calls end-session without a valid `id_token_hint`) cannot complete. +Neither the plugin options nor `advanced.cookies` can change that cookie's name +or path. Use cross-subdomain mode (`__Secure-`) if a product needs the flow. +See [ADR 0018](../../docs/decisions/0018-secure-session-cookies-and-client-ip.md). + See [docs/integration.md](docs/integration.md) for full web, mobile, REST, and MCP setup, and [docs/architecture.md](docs/architecture.md) for the normative product requirements and public contract. diff --git a/packages/oauth-server-kit/docs/integration.md b/packages/oauth-server-kit/docs/integration.md index cbc1fcc..0e708bb 100644 --- a/packages/oauth-server-kit/docs/integration.md +++ b/packages/oauth-server-kit/docs/integration.md @@ -18,7 +18,12 @@ import { cimd } from "@better-auth/cimd"; import { fetchClientMetadataResource } from "@better-auth/cimd/node"; import { oauthProvider } from "@better-auth/oauth-provider"; import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; -import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth"; +import { + createOAuthProviderOptions, + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, +} from "@codelitdev/oauth-server-kit/better-auth"; const publicApiUrl = "https://api.example.com"; const authBasePath = "/api/auth"; @@ -31,6 +36,19 @@ export const auth = betterAuth({ basePath: authBasePath, secret: process.env.BETTER_AUTH_SECRET, database: productBetterAuthAdapter, + // Keep your own prefix; the helper makes it `__Host-` over https. + advanced: { + ...secureCookieOptions({ + baseURL: publicApiUrl, + cookiePrefix: "myproduct", + // crossSubDomainCookies: { enabled: true, domain: "example.com" }, + clientIpHeader: "x-client-ip", // see "Client IP" below + }), + }, + // No session cookie cache: revoked sessions stop working immediately. + session: secureSessionOptions(), + // Password sign-up off; `{ enabled: true }` also requires a verified email. + emailAndPassword: secureEmailAndPasswordOptions(), trustedOrigins: ["https://app.example.com", publicApiUrl], plugins: [ emailOTP({ sendVerificationOTP: productSendOtp }), @@ -70,6 +88,48 @@ export const auth = betterAuth({ export const oauthResourceClient = oauthProviderResourceClient(auth); ``` +### Cookies, client IP and step-up checks + +`secureCookieOptions` returns Better Auth `advanced` settings: a +`__Host-` session cookie over https (or `__Secure-` when +`crossSubDomainCookies` is enabled, since `__Host-` forbids `Domain`), plain +names over http. Products that pick credentials by cookie name can call +`sessionCookieName(advanced)` for the exact name. + +Client IP. Set Express's trust proxy, then mount the middleware before Better +Auth's handler so Better Auth never reads `X-Forwarded-For`: + +```ts +import { + createClientIpMiddleware, + parseTrustProxy, +} from "@codelitdev/oauth-server-kit/express"; + +app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY)); +app.use(createClientIpMiddleware({ header: "x-client-ip" })); +app.all("/api/auth/*", toNodeHandler(auth)); +``` + +`TRUST_PROXY` defaults to private networks (`loopback, linklocal, +uniquelocal`). Set it to your proxies' actual addresses or subnet when clients +can also reach the app from a private network. This is the approach Better +Auth recommends for `ipAddressHeaders` (list only a header your server sets, +replacing any client value); its own `ipAddress.trustedProxies` is not needed +alongside it. + +Step-up checks. Session identities from `resolveBetterAuthSession` carry +`authenticatedAt`; the product decides which actions need a recent sign-in: + +```ts +if (!isRecentlyAuthenticated(identity, { maxAgeMs: 10 * 60 * 1000 })) { + return response.status(403).json({ error: "recent_sign_in_required" }); +} +``` + +Limitation: under `__Host-` the OAuth provider's logout-confirmation cookie +(path `/api/auth/oauth2/end-session/confirm`) is rejected by browsers; see the +README. + ### MCP client registration with CIMD Install a compatible `@better-auth/cimd` version in the product API. For Node diff --git a/packages/oauth-server-kit/package.json b/packages/oauth-server-kit/package.json index 6213486..3978744 100644 --- a/packages/oauth-server-kit/package.json +++ b/packages/oauth-server-kit/package.json @@ -79,20 +79,20 @@ "tag": "latest" }, "peerDependencies": { - "@better-auth/oauth-provider": "^1.7.2", - "better-auth": "^1.7.2", + "@better-auth/oauth-provider": "^1.7.16", + "better-auth": "^1.7.16", "express": "^4.21.2" }, "devDependencies": { - "@better-auth/core": "1.7.2", - "@better-auth/oauth-provider": "1.7.2", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.1", + "@better-auth/core": "1.7.22", + "@better-auth/oauth-provider": "1.7.22", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", "@types/express": "^4.17.20", "@types/node": "^22.14.1", "@types/bun": "^1.4.1", - "better-auth": "1.7.2", - "better-call": "1.4.0", + "better-auth": "1.7.22", + "better-call": "1.4.5", "express": "^4.21.2", "rimraf": "^5.0.0", "tsup": "^8.3.0", diff --git a/packages/oauth-server-kit/src/better-auth-entry.ts b/packages/oauth-server-kit/src/better-auth-entry.ts index 44af587..0007e5d 100644 --- a/packages/oauth-server-kit/src/better-auth-entry.ts +++ b/packages/oauth-server-kit/src/better-auth-entry.ts @@ -6,3 +6,16 @@ export { type ResolveBetterAuthSessionOptions, resolveBetterAuthSession, } from "./better-auth"; +export { + type IsRecentlyAuthenticatedOptions, + isRecentlyAuthenticated, +} from "./recent-authentication"; +export { + type SecureCookieAdvancedOptions, + type SecureCookieOptionsInput, + type SecureEmailAndPasswordInput, + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, + sessionCookieName, +} from "./secure-cookies"; diff --git a/packages/oauth-server-kit/src/better-auth.test.ts b/packages/oauth-server-kit/src/better-auth.test.ts index 0da45e9..b7d36e5 100644 --- a/packages/oauth-server-kit/src/better-auth.test.ts +++ b/packages/oauth-server-kit/src/better-auth.test.ts @@ -22,6 +22,7 @@ describe("resolveBetterAuthSession", () => { session: { id: "session_1", expiresAt: new Date(Date.now() + 1000), + createdAt: new Date("2026-03-01T12:00:00.000Z"), }, }); const headers = new Headers({ cookie: "session=value" }); @@ -36,6 +37,7 @@ describe("resolveBetterAuthSession", () => { email: "a@example.com", name: "A", scopes: [], + authenticatedAt: new Date("2026-03-01T12:00:00.000Z"), }, }); expect(auth.api.getSession).toHaveBeenCalledWith({ headers }); diff --git a/packages/oauth-server-kit/src/better-auth.ts b/packages/oauth-server-kit/src/better-auth.ts index c9a5f02..72ce14f 100644 --- a/packages/oauth-server-kit/src/better-auth.ts +++ b/packages/oauth-server-kit/src/better-auth.ts @@ -10,6 +10,8 @@ export type BetterAuthSession = { session: { id: string; expiresAt: Date; + /** When the session was created, i.e. when the user signed in. */ + createdAt: Date | string; }; }; @@ -59,6 +61,7 @@ export async function resolveBetterAuthSession( email: session.user.email, name: session.user.name, scopes: [], + authenticatedAt: new Date(session.session.createdAt), }, }; } catch (cause) { diff --git a/packages/oauth-server-kit/src/client-ip.test.ts b/packages/oauth-server-kit/src/client-ip.test.ts new file mode 100644 index 0000000..8ad40e3 --- /dev/null +++ b/packages/oauth-server-kit/src/client-ip.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "bun:test"; +import express from "express"; +import { withHttpServer } from "../test/http"; +import { + createClientIpMiddleware, + DEFAULT_TRUST_PROXY, + parseTrustProxy, +} from "./client-ip"; + +describe("parseTrustProxy", () => { + it("defaults to private networks", () => { + expect(parseTrustProxy()).toBe(DEFAULT_TRUST_PROXY); + expect(parseTrustProxy(" ")).toBe("loopback, linklocal, uniquelocal"); + }); + it("parses booleans, hop counts and address lists", () => { + expect(parseTrustProxy("true")).toBe(true); + expect(parseTrustProxy("false")).toBe(false); + expect(parseTrustProxy("2")).toBe(2); + expect(parseTrustProxy("10.0.0.0/8, 192.168.1.1")).toBe("10.0.0.0/8, 192.168.1.1"); + }); +}); + +async function resolved(trust: string | undefined, headers: Record) { + const app = express(); + app.set("trust proxy", parseTrustProxy(trust)); + app.use(createClientIpMiddleware({ header: "X-Client-IP" })); + app.get("/", (req, res) => { + res.json({ header: req.headers["x-client-ip"] }); + }); + return withHttpServer(app, async (baseUrl) => { + const res = await fetch(baseUrl, { headers }); + return (await res.json()) as { header: string }; + }); +} + +describe("createClientIpMiddleware", () => { + it("rejects an empty header name", () => { + expect(() => createClientIpMiddleware({ header: " " })).toThrow("header"); + }); + it("ignores a spoofed leftmost X-Forwarded-For entry", async () => { + const honest = await resolved(undefined, { "x-forwarded-for": "203.0.113.9" }); + const spoofed = await resolved(undefined, { + "x-forwarded-for": "6.6.6.6, 203.0.113.9", + }); + expect(honest.header).toBe("203.0.113.9"); + expect(spoofed.header).toBe("203.0.113.9"); + }); + it("skips trusted private hops from the right", async () => { + const r = await resolved(undefined, { + "x-forwarded-for": "6.6.6.6, 203.0.113.9, 10.1.2.3", + }); + expect(r.header).toBe("203.0.113.9"); + }); + it("overwrites a client-sent header", async () => { + const r = await resolved(undefined, { + "x-client-ip": "6.6.6.6", + "x-forwarded-for": "203.0.113.9", + }); + expect(r.header).toBe("203.0.113.9"); + }); + it("uses the peer address when proxies are not trusted", async () => { + const r = await resolved("false", { "x-forwarded-for": "203.0.113.9" }); + expect(r.header).toMatch(/127\.0\.0\.1/); + }); +}); diff --git a/packages/oauth-server-kit/src/client-ip.ts b/packages/oauth-server-kit/src/client-ip.ts new file mode 100644 index 0000000..26bd384 --- /dev/null +++ b/packages/oauth-server-kit/src/client-ip.ts @@ -0,0 +1,51 @@ +import type { RequestHandler } from "express"; + +/** + * Private networks: the cluster and local development. Fine when clients come + * from the public internet. If clients can reach the app from a private + * network too, set `TRUST_PROXY` to your proxies' actual addresses or subnet, + * or those clients could forge their address (Better Auth gives the same + * advice for its own `trustedProxies`). + */ +export const DEFAULT_TRUST_PROXY = "loopback, linklocal, uniquelocal"; + +/** + * Parses a `TRUST_PROXY` value into what Express's `trust proxy` setting + * accepts: a hop count (`1`), `true`/`false`, or a comma-separated list of + * addresses, CIDRs and the names `loopback`, `linklocal`, `uniquelocal`. + * Empty means {@link DEFAULT_TRUST_PROXY}. + */ +export function parseTrustProxy(raw?: string): boolean | number | string { + const value = raw?.trim(); + if (!value) return DEFAULT_TRUST_PROXY; + if (value === "true") return true; + if (value === "false") return false; + if (/^\d+$/.test(value)) return Number.parseInt(value, 10); + return value; +} + +export interface CreateClientIpMiddlewareOptions { + /** Internal header to carry the resolved client address. */ + header: string; +} + +/** + * Sets `req.headers[header]` to the client address, overwriting anything the + * client sent. Pair it with `secureCookieOptions({ clientIpHeader })` so + * Better Auth's rate limiting and session IP never read `X-Forwarded-For`. + * + * The app must call `app.set("trust proxy", parseTrustProxy(process.env.TRUST_PROXY))` + * first and mount this before Better Auth's handler. `req.ip` then walks + * `X-Forwarded-For` from the right, skipping trusted proxy hops, so the + * attacker-controlled left side never counts. + */ +export function createClientIpMiddleware( + options: CreateClientIpMiddlewareOptions, +): RequestHandler { + const header = options.header.trim().toLowerCase(); + if (!header) throw new Error("header must not be empty"); + return (req, _res, next) => { + req.headers[header] = req.ip || req.socket.remoteAddress || "unknown"; + next(); + }; +} diff --git a/packages/oauth-server-kit/src/express-entry.ts b/packages/oauth-server-kit/src/express-entry.ts index 3ce9ef1..f8c9877 100644 --- a/packages/oauth-server-kit/src/express-entry.ts +++ b/packages/oauth-server-kit/src/express-entry.ts @@ -1,3 +1,9 @@ +export { + type CreateClientIpMiddlewareOptions, + createClientIpMiddleware, + DEFAULT_TRUST_PROXY, + parseTrustProxy, +} from "./client-ip"; export { type CreateOAuthBearerMiddlewareOptions, createOAuthBearerMiddleware, diff --git a/packages/oauth-server-kit/src/public-api.test.ts b/packages/oauth-server-kit/src/public-api.test.ts index a2d2c8a..b100a59 100644 --- a/packages/oauth-server-kit/src/public-api.test.ts +++ b/packages/oauth-server-kit/src/public-api.test.ts @@ -12,14 +12,22 @@ describe("public API", () => { it("exports only the documented Better Auth helpers", () => { expect(Object.keys(betterAuthEntry).sort()).toEqual([ "createOAuthProviderOptions", + "isRecentlyAuthenticated", "resolveBetterAuthSession", + "secureCookieOptions", + "secureEmailAndPasswordOptions", + "secureSessionOptions", + "sessionCookieName", ]); }); it("exports only the documented Express helpers", () => { expect(Object.keys(expressEntry).sort()).toEqual([ + "DEFAULT_TRUST_PROXY", + "createClientIpMiddleware", "createOAuthBearerMiddleware", "createOAuthPagesRouter", + "parseTrustProxy", ]); }); diff --git a/packages/oauth-server-kit/src/recent-authentication.test.ts b/packages/oauth-server-kit/src/recent-authentication.test.ts new file mode 100644 index 0000000..9efa69b --- /dev/null +++ b/packages/oauth-server-kit/src/recent-authentication.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "bun:test"; +import { isRecentlyAuthenticated } from "./recent-authentication"; +import type { AuthenticatedIdentity } from "./types"; + +const now = new Date("2026-03-01T12:00:00.000Z"); +const maxAgeMs = 10 * 60 * 1000; + +function session(authenticatedAt: Date): AuthenticatedIdentity { + return { + method: "session", + issuer: "https://auth.example.com", + subject: "u1", + email: "a@example.com", + scopes: [], + authenticatedAt, + }; +} + +describe("isRecentlyAuthenticated", () => { + it("accepts a sign-in inside the window, including the boundary", () => { + expect( + isRecentlyAuthenticated(session(new Date(now.getTime() - 1000)), { + maxAgeMs, + now, + }), + ).toBe(true); + expect( + isRecentlyAuthenticated(session(new Date(now.getTime() - maxAgeMs)), { + maxAgeMs, + now, + }), + ).toBe(true); + }); + + it("refuses an older sign-in", () => { + expect( + isRecentlyAuthenticated(session(new Date(now.getTime() - maxAgeMs - 1)), { + maxAgeMs, + now, + }), + ).toBe(false); + }); + + it("tolerates small clock skew but not a far-future time", () => { + expect( + isRecentlyAuthenticated(session(new Date(now.getTime() + 5_000)), { + maxAgeMs, + now, + }), + ).toBe(true); + expect( + isRecentlyAuthenticated(session(new Date(now.getTime() + 10 * 60_000)), { + maxAgeMs, + now, + }), + ).toBe(false); + }); + + it("refuses an invalid time", () => { + expect(isRecentlyAuthenticated(session(new Date("nope")), { maxAgeMs, now })).toBe( + false, + ); + }); + + it("never counts OAuth identities", () => { + expect( + isRecentlyAuthenticated( + { + method: "oauth", + issuer: "https://auth.example.com", + subject: "u1", + clientId: "c", + scopes: [], + audiences: [], + }, + { maxAgeMs, now }, + ), + ).toBe(false); + }); + + it("defaults now to the current time", () => { + expect(isRecentlyAuthenticated(session(new Date()), { maxAgeMs })).toBe(true); + }); +}); diff --git a/packages/oauth-server-kit/src/recent-authentication.ts b/packages/oauth-server-kit/src/recent-authentication.ts new file mode 100644 index 0000000..411792c --- /dev/null +++ b/packages/oauth-server-kit/src/recent-authentication.ts @@ -0,0 +1,25 @@ +import type { AuthenticatedIdentity } from "./types"; + +/** A sign-in stamped this far ahead of `now` is clock skew, not a forgery. */ +const CLOCK_SKEW_MS = 60_000; + +export interface IsRecentlyAuthenticatedOptions { + maxAgeMs: number; + now?: Date; +} + +/** + * Whether the identity is a browser session that signed in within `maxAgeMs`. + * OAuth identities never count: a bearer token carries no interactive + * sign-in. Products choose which actions require this (step-up checks). + */ +export function isRecentlyAuthenticated( + identity: AuthenticatedIdentity, + options: IsRecentlyAuthenticatedOptions, +): boolean { + if (identity.method !== "session") return false; + const signedInAt = identity.authenticatedAt?.getTime(); + if (signedInAt === undefined || !Number.isFinite(signedInAt)) return false; + const age = (options.now ?? new Date()).getTime() - signedInAt; + return age >= -CLOCK_SKEW_MS && age <= options.maxAgeMs; +} diff --git a/packages/oauth-server-kit/src/secure-cookies.test.ts b/packages/oauth-server-kit/src/secure-cookies.test.ts new file mode 100644 index 0000000..7f2c2cd --- /dev/null +++ b/packages/oauth-server-kit/src/secure-cookies.test.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from "bun:test"; +import { betterAuth } from "better-auth"; +import { + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, + sessionCookieName, +} from "./secure-cookies"; + +describe("secureCookieOptions", () => { + it("uses __Host- with Secure and Path=/ over https", () => { + const advanced = secureCookieOptions({ + baseURL: "https://api.example.com", + cookiePrefix: "acme", + }); + expect(advanced).toEqual({ + cookiePrefix: "__Host-acme", + useSecureCookies: false, + defaultCookieAttributes: { secure: true, path: "/" }, + }); + expect(sessionCookieName(advanced)).toBe("__Host-acme.session_token"); + }); + + it("keeps __Secure- with cross-subdomain cookies over https", () => { + const advanced = secureCookieOptions({ + baseURL: "https://api.example.com", + cookiePrefix: "acme", + crossSubDomainCookies: { enabled: true, domain: "example.com" }, + }); + expect(advanced).toEqual({ + cookiePrefix: "acme", + useSecureCookies: true, + crossSubDomainCookies: { enabled: true, domain: "example.com" }, + }); + expect(sessionCookieName(advanced)).toBe("__Secure-acme.session_token"); + }); + + it("treats disabled cross-subdomain cookies as plain https", () => { + expect( + secureCookieOptions({ + baseURL: "https://api.example.com", + cookiePrefix: "acme", + crossSubDomainCookies: { enabled: false }, + }).cookiePrefix, + ).toBe("__Host-acme"); + }); + + it("only sets the prefix over http", () => { + const advanced = secureCookieOptions({ + baseURL: "http://127.0.0.1:4000", + cookiePrefix: "acme", + }); + expect(advanced).toEqual({ cookiePrefix: "acme" }); + expect(sessionCookieName(advanced)).toBe("acme.session_token"); + }); + + it("adds the client IP header for every mode", () => { + for (const baseURL of ["http://localhost:3000", "https://a.example.com"]) { + expect( + secureCookieOptions({ + baseURL, + cookiePrefix: "acme", + clientIpHeader: "X-Client-IP", + }).ipAddress, + ).toEqual({ ipAddressHeaders: ["x-client-ip"] }); + } + }); + + it("validates its input", () => { + const ok = { baseURL: "https://a.example.com", cookiePrefix: "acme" }; + expect(() => secureCookieOptions({ ...ok, baseURL: "api.example.com" })).toThrow( + "absolute URL", + ); + expect(() => + secureCookieOptions({ ...ok, baseURL: "ftp://a.example.com" }), + ).toThrow("http or https"); + expect(() => secureCookieOptions({ ...ok, cookiePrefix: "" })).toThrow( + "cookiePrefix", + ); + expect(() => secureCookieOptions({ ...ok, cookiePrefix: "a b" })).toThrow( + "cookiePrefix", + ); + expect(() => secureCookieOptions({ ...ok, cookiePrefix: "a;b" })).toThrow( + "cookiePrefix", + ); + expect(() => secureCookieOptions({ ...ok, cookiePrefix: "__Host-acme" })).toThrow( + "__Host-", + ); + expect(() => secureCookieOptions({ ...ok, clientIpHeader: " " })).toThrow( + "clientIpHeader", + ); + }); +}); + +describe("secureSessionOptions", () => { + it("disables the cookie cache", () => { + expect(secureSessionOptions()).toEqual({ cookieCache: { enabled: false } }); + }); +}); + +function liveAuth( + baseURL: string, + advancedInput: Parameters[0], +) { + const advanced = secureCookieOptions(advancedInput); + const auth = betterAuth({ + baseURL, + secret: "test-secret-that-is-at-least-thirty-two-characters-long", + emailAndPassword: { enabled: true }, + advanced, + session: secureSessionOptions(), + }); + return { auth, advanced }; +} + +async function signUp(auth: ReturnType["auth"], email: string) { + return auth.api.signUpEmail({ + body: { name: "Tester", email, password: "correct-horse-battery" }, + asResponse: true, + }); +} + +describe("live Better Auth round trip", () => { + it("issues and reads back a __Host- session cookie over https", async () => { + const baseURL = "https://api.example.com"; + const { auth, advanced } = liveAuth(baseURL, { baseURL, cookiePrefix: "acme" }); + const response = await signUp(auth, "host@example.com"); + expect(response.ok).toBe(true); + const cookies = response.headers.getSetCookie(); + for (const cookie of cookies) { + expect(cookie.startsWith("__Host-acme.")).toBe(true); + expect(cookie).not.toContain("__Secure-"); + expect(cookie).toContain("Secure"); + expect(cookie).toContain("Path=/"); + expect(cookie).not.toMatch(/Domain=/i); + } + const names = cookies.map((c) => c.split("=", 1)[0]); + expect(names).toContain(sessionCookieName(advanced)); + expect(names).not.toContain("__Host-acme.session_data"); + const session = cookies + .map((c) => c.split(";", 1)[0]!) + .find((c) => c.startsWith(`${sessionCookieName(advanced)}=`))!; + const resolved = await auth.api.getSession({ + headers: new Headers({ cookie: session }), + }); + expect(resolved?.user.email).toBe("host@example.com"); + expect(resolved?.session.createdAt).toBeInstanceOf(Date); + // A plain-name cookie planted over https is not a session. + const planted = await auth.api.getSession({ + headers: new Headers({ cookie: session.replace("__Host-acme.", "acme.") }), + }); + expect(planted).toBeNull(); + }); + + it("issues a __Secure- cookie with a Domain in cross-subdomain mode", async () => { + const baseURL = "https://api.example.com"; + const { auth, advanced } = liveAuth(baseURL, { + baseURL, + cookiePrefix: "acme", + crossSubDomainCookies: { enabled: true, domain: "example.com" }, + }); + const response = await signUp(auth, "cross@example.com"); + const session = response.headers + .getSetCookie() + .find((c) => c.startsWith(`${sessionCookieName(advanced)}=`))!; + expect(session).toBeDefined(); + expect(session.startsWith("__Secure-acme.session_token=")).toBe(true); + expect(session).toMatch(/Domain=example\.com/i); + expect(session).toContain("Secure"); + }); + + it("keeps the plain name over http", async () => { + const baseURL = "http://127.0.0.1:4000"; + const { auth, advanced } = liveAuth(baseURL, { baseURL, cookiePrefix: "acme" }); + const response = await signUp(auth, "http@example.com"); + const names = response.headers.getSetCookie().map((c) => c.split("=", 1)[0]); + expect(names).toContain(sessionCookieName(advanced)); + expect(names).toContain("acme.session_token"); + expect(names.some((n) => n!.startsWith("__"))).toBe(false); + }); + + it("rejects a revoked session on the very next request", async () => { + const baseURL = "http://127.0.0.1:4000"; + const { auth, advanced } = liveAuth(baseURL, { baseURL, cookiePrefix: "acme" }); + const response = await signUp(auth, "revoke@example.com"); + const cookie = response.headers + .getSetCookie() + .map((c) => c.split(";", 1)[0]!) + .find((c) => c.startsWith(`${sessionCookieName(advanced)}=`))!; + const headers = () => new Headers({ cookie }); + // Prime any would-be cache with a successful read first. + expect((await auth.api.getSession({ headers: headers() }))?.user.email).toBe( + "revoke@example.com", + ); + await auth.api.signOut({ headers: headers() }); + expect(await auth.api.getSession({ headers: headers() })).toBeNull(); + }); +}); + +describe("secureEmailAndPasswordOptions", () => { + const secret = "test-secret-that-is-at-least-thirty-two-characters-long"; + const body = { + name: "Tester", + email: "pw@example.com", + password: "correct-horse-battery", + }; + + it("keeps password sign-up off by default", async () => { + expect(secureEmailAndPasswordOptions()).toEqual({ enabled: false }); + const auth = betterAuth({ + baseURL: "https://api.example.com", + secret, + emailAndPassword: secureEmailAndPasswordOptions(), + }); + const response = await auth.api.signUpEmail({ body, asResponse: true }); + expect(response.ok).toBe(false); + }); + + it("requires a verified email before sign-in when a product opts in", async () => { + expect(secureEmailAndPasswordOptions({ enabled: true })).toEqual({ + enabled: true, + requireEmailVerification: true, + }); + const auth = betterAuth({ + baseURL: "https://api.example.com", + secret, + emailAndPassword: secureEmailAndPasswordOptions({ enabled: true }), + }); + const signedUp = await auth.api.signUpEmail({ body, asResponse: true }); + // Signing up must not hand out a session for an unverified address. + expect( + signedUp.headers + .getSetCookie() + .some((c) => c.includes("session_token=") && !c.includes("session_token=;")), + ).toBe(false); + const signIn = await auth.api.signInEmail({ + body: { email: body.email, password: body.password }, + asResponse: true, + }); + expect(signIn.ok).toBe(false); + }); +}); diff --git a/packages/oauth-server-kit/src/secure-cookies.ts b/packages/oauth-server-kit/src/secure-cookies.ts new file mode 100644 index 0000000..3a2a3fa --- /dev/null +++ b/packages/oauth-server-kit/src/secure-cookies.ts @@ -0,0 +1,158 @@ +export interface SecureCookieOptionsInput { + /** Public URL Better Auth is served from (its `baseURL`). */ + baseURL: string; + /** Product-specific prefix, e.g. `courselit`. Must not already carry `__Host-`/`__Secure-`. */ + cookiePrefix: string; + /** Pass through only when cookies must be shared across sibling subdomains. */ + crossSubDomainCookies?: { enabled: boolean; domain?: string }; + /** + * Name of an internal header the app's edge sets to the trusted client + * address (see `createClientIpMiddleware`). Better Auth then reads the + * address only from it, never from `X-Forwarded-For`. + */ + clientIpHeader?: string; +} + +/** The subset of Better Auth's `advanced` options these helpers produce. */ +export interface SecureCookieAdvancedOptions { + cookiePrefix: string; + useSecureCookies?: boolean; + defaultCookieAttributes?: { secure: boolean; path: string }; + crossSubDomainCookies?: { enabled: boolean; domain?: string }; + ipAddress?: { ipAddressHeaders: string[] }; +} + +const COOKIE_PREFIX_PATTERN = /^[A-Za-z0-9_-]+$/; +const HOST_PREFIX = "__Host-"; +const SECURE_PREFIX = "__Secure-"; +const SESSION_TOKEN_SUFFIX = ".session_token"; + +function assertBaseUrl(value: string): URL { + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("baseURL must be an absolute URL"); + } + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("baseURL must use http or https"); + } + return url; +} + +function assertCookiePrefix(value: string) { + if (!COOKIE_PREFIX_PATTERN.test(value)) { + throw new Error( + "cookiePrefix must be non-empty and contain only letters, digits, '_' or '-'", + ); + } + if (value.startsWith(HOST_PREFIX) || value.startsWith(SECURE_PREFIX)) { + throw new Error( + "cookiePrefix must not include __Host- or __Secure-; the kit adds it", + ); + } +} + +/** + * Better Auth `advanced` options for hardened session cookies. Spread them + * into your own config: `advanced: { ...secureCookieOptions({...}) }`. + * + * - https, no cross-subdomain cookies: the session cookie is + * `__Host-.session_token` (Secure, `Path=/`, no `Domain`), so a + * sibling subdomain cannot plant or shadow it. Better Auth only knows + * `__Secure-` and would produce `__Secure-__Host-...`, so `__Host-` is + * folded into `cookiePrefix` and Secure is set via `defaultCookieAttributes`. + * - https with cross-subdomain cookies: `__Host-` forbids `Domain`, so the + * cookie keeps Better Auth's `__Secure-` prefix and is shared as configured. + * - http (local development): plain `.session_token`. + * + * Known limitation: `@better-auth/oauth-provider` stores a short-lived + * RP-initiated-logout confirmation cookie named + * `.oauth_logout_confirmation` on the path + * `/api/auth/oauth2/end-session/confirm`. Browsers reject a `__Host-` cookie + * with any path but `/`, so that cookie is dropped under `__Host-` and the + * logout confirmation step (shown when an OAuth client calls end-session + * without a valid `id_token_hint`) cannot complete. Neither the plugin's + * options nor `advanced.cookies` can change its name or path. Products that + * need that flow must use cross-subdomain mode (`__Secure-`) or accept it. + */ +export function secureCookieOptions( + input: SecureCookieOptionsInput, +): SecureCookieAdvancedOptions { + const url = assertBaseUrl(input.baseURL); + assertCookiePrefix(input.cookiePrefix); + const clientIpHeader = input.clientIpHeader?.trim().toLowerCase(); + if (input.clientIpHeader !== undefined && !clientIpHeader) { + throw new Error("clientIpHeader must not be empty"); + } + const ipAddress = clientIpHeader + ? { ipAddress: { ipAddressHeaders: [clientIpHeader] } } + : {}; + const crossSubdomain = input.crossSubDomainCookies?.enabled === true; + + if (url.protocol !== "https:") { + return { + cookiePrefix: input.cookiePrefix, + ...(input.crossSubDomainCookies + ? { crossSubDomainCookies: input.crossSubDomainCookies } + : {}), + ...ipAddress, + }; + } + if (crossSubdomain) { + return { + cookiePrefix: input.cookiePrefix, + useSecureCookies: true, + crossSubDomainCookies: input.crossSubDomainCookies, + ...ipAddress, + }; + } + return { + cookiePrefix: `${HOST_PREFIX}${input.cookiePrefix}`, + useSecureCookies: false, + defaultCookieAttributes: { secure: true, path: "/" }, + ...ipAddress, + }; +} + +/** + * The exact session-token cookie name Better Auth produces for options built + * by `secureCookieOptions`, for products that choose credentials by cookie + * name. Pass the object `secureCookieOptions` returned. + */ +export function sessionCookieName( + advanced: Pick, +): string { + const secure = advanced.useSecureCookies === true ? SECURE_PREFIX : ""; + return `${secure}${advanced.cookiePrefix}${SESSION_TOKEN_SUFFIX}`; +} + +/** + * Better Auth `session` options with the cookie cache off: every request + * checks the session row, so a revoked session stops working immediately + * instead of until the cached cookie expires (5 minutes by default). + */ +export function secureSessionOptions(): { cookieCache: { enabled: false } } { + return { cookieCache: { enabled: false } }; +} + +export interface SecureEmailAndPasswordInput { + /** Turn password sign-in on. Off by default: products sign people in + * with email codes, social providers or OAuth. */ + enabled?: boolean; +} + +/** + * Better Auth `emailAndPassword` settings. Password sign-up is off unless a + * product opts in: with it on and no verification, anyone can register an + * address they don't own and sit on it until the real owner shows up. When + * a product does enable passwords, the address must be verified before the + * account can sign in. + */ +export function secureEmailAndPasswordOptions( + input: SecureEmailAndPasswordInput = {}, +): { enabled: false } | { enabled: true; requireEmailVerification: true } { + return input.enabled === true + ? { enabled: true, requireEmailVerification: true } + : { enabled: false }; +} diff --git a/packages/oauth-server-kit/src/types.ts b/packages/oauth-server-kit/src/types.ts index f39be4e..b9f89c1 100644 --- a/packages/oauth-server-kit/src/types.ts +++ b/packages/oauth-server-kit/src/types.ts @@ -1,4 +1,5 @@ import type { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; +import type { BetterAuthOptions } from "better-auth/types"; export type AuthenticatedIdentity = | { @@ -8,6 +9,8 @@ export type AuthenticatedIdentity = email: string; name?: string | null; scopes: []; + /** When the session was created (the sign-in time), for step-up checks. */ + authenticatedAt: Date; } | { method: "oauth"; @@ -26,9 +29,12 @@ export type AuthenticationResult = | { status: "invalid_token" } | { status: "unavailable"; cause?: unknown }; +// The product's Better Auth instance. Since @better-auth/oauth-provider 1.7.7 +// (checked on 1.7.22) a bound resource client needs the instance's options and a context +// that resolves its baseURL, so it can find the issuer's JWKS itself. type ConfiguredResourceClientAuth = { - options: { baseURL?: string; basePath?: string }; - $context: Promise; + options: BetterAuthOptions; + $context: Promise<{ baseURL: string }>; }; export type OAuthResourceClient = ReturnType< diff --git a/packages/platform-cli/src/preset.json b/packages/platform-cli/src/preset.json index bef0e89..cbb4d96 100644 --- a/packages/platform-cli/src/preset.json +++ b/packages/platform-cli/src/preset.json @@ -54,8 +54,8 @@ "pg": "8.23.0", "@types/pg": "8.23.1", "zod": "3.25.76", - "better-auth": "1.7.7", - "@better-auth/oauth-provider": "1.7.7", + "better-auth": "1.7.22", + "@better-auth/oauth-provider": "1.7.22", "drizzle-orm": "0.45.2", "drizzle-kit": "0.31.10", "pino": "9.6.0", diff --git a/templates/saas-product/apps/api/package.json b/templates/saas-product/apps/api/package.json index 3d020ad..15d5e91 100644 --- a/templates/saas-product/apps/api/package.json +++ b/templates/saas-product/apps/api/package.json @@ -21,22 +21,22 @@ "pretest": "bun run check:auth-schema" }, "dependencies": { - "@better-auth/core": "1.7.7", + "@better-auth/core": "1.7.22", "@codelitdev/billing": "workspace:*", "@codelitdev/oauth-server-kit": "workspace:*", "@codelitdev/mcp-server-kit": "workspace:*", "@codelitdev/platform-conformance": "workspace:*", "@codelitdev/observability": "workspace:*", - "@better-auth/oauth-provider": "1.7.7", - "@better-auth/utils": "0.4.2", - "@better-fetch/fetch": "1.3.2", + "@better-auth/oauth-provider": "1.7.22", + "@better-auth/utils": "0.5.1", + "@better-fetch/fetch": "1.3.3", "@ts-rest/express": "3.52.1", "@ts-rest/open-api": "3.52.1", "@codelitdev/platform": "workspace:*", "@__PRODUCT_SLUG__/api-contract": "workspace:*", "@types/pg": "8.23.1", - "better-call": "1.4.0", - "better-auth": "1.7.7", + "better-call": "1.4.5", + "better-auth": "1.7.22", "drizzle-orm": "0.45.2", "express": "4.21.2", "pg": "8.23.0", diff --git a/templates/saas-product/apps/api/src/auth/authenticate.ts b/templates/saas-product/apps/api/src/auth/authenticate.ts index 315e8ea..891bd66 100644 --- a/templates/saas-product/apps/api/src/auth/authenticate.ts +++ b/templates/saas-product/apps/api/src/auth/authenticate.ts @@ -33,7 +33,9 @@ export async function authenticateHttpRequest( headers: HeaderMap, deps: AuthRuntime, ): Promise> { - const selected = selectHttpCredential(headers); + const selected = selectHttpCredential(headers, { + sessionCookieName: deps.auth.sessionCookieName, + }); if (selected.kind === "absent") { return mapTransportAuthentication({ kind: "absent" }, { transport: "http" }); } diff --git a/templates/saas-product/apps/api/src/auth/better-auth.ts b/templates/saas-product/apps/api/src/auth/better-auth.ts index bafae05..ef237d4 100644 --- a/templates/saas-product/apps/api/src/auth/better-auth.ts +++ b/templates/saas-product/apps/api/src/auth/better-auth.ts @@ -3,7 +3,12 @@ import { betterAuth } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import * as authSchema from "../db/schema/auth.generated.js"; import type { AppDb } from "../types.js"; -import { AUTH_BASE_PATH, authUrls, referenceAuthOptions } from "./options.js"; +import { + AUTH_BASE_PATH, + authCookieOptions, + authUrls, + referenceAuthOptions, +} from "./options.js"; export function createReferenceAuth(input: { db: AppDb; @@ -32,6 +37,7 @@ export function createReferenceAuth(input: { oauthResourceClient, ...urls, authBasePath: AUTH_BASE_PATH, + sessionCookieName: authCookieOptions(input.publicApiUrl).sessionCookieName, }; } diff --git a/templates/saas-product/apps/api/src/auth/options.ts b/templates/saas-product/apps/api/src/auth/options.ts index a48fe50..eb53deb 100644 --- a/templates/saas-product/apps/api/src/auth/options.ts +++ b/templates/saas-product/apps/api/src/auth/options.ts @@ -1,10 +1,17 @@ import { oauthProvider } from "@better-auth/oauth-provider"; -import { createOAuthProviderOptions } from "@codelitdev/oauth-server-kit/better-auth"; +import { + createOAuthProviderOptions, + secureCookieOptions, + secureEmailAndPasswordOptions, + secureSessionOptions, + sessionCookieName, +} from "@codelitdev/oauth-server-kit/better-auth"; import type { BetterAuthOptions } from "better-auth"; import { emailOTP } from "better-auth/plugins/email-otp"; import { jwt } from "better-auth/plugins/jwt"; export const AUTH_BASE_PATH = "/api/auth"; +export const AUTH_COOKIE_PREFIX = "__PRODUCT_SLUG__"; export const AUTH_SECRET_MIN_LENGTH = 32; export const DATA_READ_SCOPE = "data:read"; @@ -17,6 +24,15 @@ export const MCP_SCOPES_SUPPORTED = [ "offline_access", ] as const; +/** Better Auth's `advanced` cookie options, plus the session cookie name they produce. */ +export function authCookieOptions(publicApiUrl: string) { + const advanced = secureCookieOptions({ + baseURL: publicApiUrl, + cookiePrefix: AUTH_COOKIE_PREFIX, + }); + return { advanced, sessionCookieName: sessionCookieName(advanced) }; +} + export type ReferenceAuthUrls = { publicApiUrl: string; webOrigin: string; @@ -77,8 +93,12 @@ export function referenceAuthOptions(input: { baseURL: urls.publicApiUrl, basePath: AUTH_BASE_PATH, secret: input.secret, + // `__Host-` session cookie over https; each product keeps its own prefix. + advanced: { ...authCookieOptions(urls.publicApiUrl).advanced }, + // No cookie cache: a revoked session stops working immediately. + session: secureSessionOptions(), trustedOrigins: [urls.webOrigin, urls.publicApiUrl], - emailAndPassword: { enabled: false }, + emailAndPassword: secureEmailAndPasswordOptions(), ...(input.database ? { database: input.database } : {}), plugins: [ jwt(),