diff --git a/.env.sample b/.env.sample index 25113b4d..4998fcef 100644 --- a/.env.sample +++ b/.env.sample @@ -19,13 +19,11 @@ DATABASE_URL=postgresql://${DATABASE_USER}:${DATABASE_PASSWORD}@${DATABASE_HOST} GOOGLE_CLIENT_ID=xxxx GOOGLE_CLIENT_SECRET=xxxx -# NextAuth.js -NEXTAUTH_URL=${NEXT_PUBLIC_SITE_URL} -# https://next-auth.js.org/configuration/options#secret +# Better Auth +BETTER_AUTH_URL=${NEXT_PUBLIC_SITE_URL} # you must generate a new secret -# error: "ikm" must be at least one byte in length' # $ openssl rand -base64 32 -NEXTAUTH_SECRET=TKDdLVjf7cTyTs5oWVpv04senu6fia4RGQbYHRQIR5Q= +BETTER_AUTH_SECRET=TKDdLVjf7cTyTs5oWVpv04senu6fia4RGQbYHRQIR5Q= # start: otel # # OpenTelemetry diff --git a/.env.test b/.env.test index bf83991d..1567c444 100644 --- a/.env.test +++ b/.env.test @@ -2,8 +2,9 @@ GOOGLE_CLIENT_ID=dummy GOOGLE_CLIENT_SECRET=dummy -# NextAuth.js -NEXTAUTH_TEST_MODE=true +# Better Auth +BETTER_AUTH_URL=http://localhost:3000 +BETTER_AUTH_SECRET=test_secret_key_for_testing_only # start: stripe # # Stripe diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2dc46c25..c90a5d8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,7 +70,8 @@ jobs: --build-arg GOOGLE_CLIENT_ID=${{env.GOOGLE_CLIENT_ID}} \ --build-arg GOOGLE_CLIENT_SECRET=${{env.GOOGLE_CLIENT_SECRET}} \ --build-arg NEXT_PUBLIC_SITE_URL=${{env.NEXT_PUBLIC_SITE_URL}} \ - --build-arg NEXTAUTH_SECRET=${{env.NEXTAUTH_SECRET}} \ + --build-arg BETTER_AUTH_SECRET=${{env.BETTER_AUTH_SECRET}} \ + --build-arg BETTER_AUTH_URL=${{env.BETTER_AUTH_URL}} \ --build-arg TRACE_EXPORTER_URL=${{env.TRACE_EXPORTER_URL}} \ --build-arg STRIPE_PRICE_ID=${{env.STRIPE_PRICE_ID}} \ --build-arg STRIPE_SECRET_KEY=${{env.STRIPE_SECRET_KEY}} \ @@ -84,7 +85,8 @@ jobs: DATABASE_HOST: host.docker.internal DATABASE_PORT: 5432 DATABASE_SCHEMA: public - NEXTAUTH_SECRET: TKDdLVjf7cTyTs5oWVpv04senu6fia4RGQbYHRQIR5Q= + BETTER_AUTH_SECRET: TKDdLVjf7cTyTs5oWVpv04senu6fia4RGQbYHRQIR5Q= + BETTER_AUTH_URL: http://localhost:3000 GOOGLE_CLIENT_ID: dummy GOOGLE_CLIENT_SECRET: dummy # start: otel # diff --git a/Dockerfile b/Dockerfile index 61959920..f087f609 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,8 @@ ARG DATABASE_SCHEMA='' ARG GOOGLE_CLIENT_ID='' ARG GOOGLE_CLIENT_SECRET='' ARG NEXT_PUBLIC_SITE_URL='' -ARG NEXTAUTH_SECRET='' +ARG BETTER_AUTH_SECRET='' +ARG BETTER_AUTH_URL='' # start: otel # ARG TRACE_EXPORTER_URL='' # end: otel # @@ -30,8 +31,8 @@ ENV DATABASE_SCHEMA=$DATABASE_SCHEMA ENV GOOGLE_CLIENT_ID=$GOOGLE_CLIENT_ID ENV GOOGLE_CLIENT_SECRET=$GOOGLE_CLIENT_SECRET ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL -ENV NEXTAUTH_SECRET=$NEXTAUTH_SECRET -ENV NEXTAUTH_URL=$NEXT_PUBLIC_SITE_URL +ENV BETTER_AUTH_SECRET=$BETTER_AUTH_SECRET +ENV BETTER_AUTH_URL=$BETTER_AUTH_URL # start: otel # ENV TRACE_EXPORTER_URL=$TRACE_EXPORTER_URL # end: otel # diff --git a/README.md b/README.md index 816307b4..dfcb4034 100644 --- a/README.md +++ b/README.md @@ -12,18 +12,18 @@ Installing this template automatically sets up the following libraries/tools. By saving you significant effort, it allows you to focus entirely on writing your product code.🤗 -| | | | | | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| **App** |
nextjs
Next.js
|
tailwind
Tailwind CSS
|
next-auth
NextAuth.js
|
react-hook-form
React Hook Form
| -| |
zod
Zod
|
otel
OpenTelemetry
|
prisma
Prisma
|
postgresql
PostgreSQL
| -| |
stripe
Stripe
| | | -| | | | | -| **Tools** |
typescirpt
TypeScript
|
pnpm
pnpm
|
biome
Biome
|
prettier
Prettier
| -| |
knip
Knip
|
editorconfig
EditorConfig
|
lefthook
lefthook
|
docker
Docker
| -| | | | | -| **Testing** |
vitest
Vitest
|
testing-library
Testing Library
|
playwright
Playwright
|
testcontainers
Testcontainers
| +| | | | | | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| **App** |
nextjs
Next.js
|
tailwind
Tailwind CSS
|
better-auth
Better Auth
|
react-hook-form
React Hook Form
| +| |
zod
Zod
|
otel
OpenTelemetry
|
prisma
Prisma
|
postgresql
PostgreSQL
| +| |
stripe
Stripe
| | | +| | | | | +| **Tools** |
typescirpt
TypeScript
|
pnpm
pnpm
|
biome
Biome
|
prettier
Prettier
| +| |
knip
Knip
|
editorconfig
EditorConfig
|
lefthook
lefthook
|
docker
Docker
| +| | | | | +| **Testing** |
vitest
Vitest
|
testing-library
Testing Library
|
playwright
Playwright
|
testcontainers
Testcontainers
| | | | | -| **Others** |
actions
GitHub Actions
|
renovate
Renovate
|
vscode
VSCode
| +| **Others** |
actions
GitHub Actions
|
renovate
Renovate
|
vscode
VSCode
| Please read the features provided by this template first! 👉 [Challenges Solved](https://hiroppy.github.io/web-app-template/introduction/challenges-solved.html) diff --git a/e2e/dummyUsers.ts b/e2e/dummyUsers.ts index 5d69e7ab..f077d665 100644 --- a/e2e/dummyUsers.ts +++ b/e2e/dummyUsers.ts @@ -1,10 +1,8 @@ -import type { User } from "next-auth"; +import type { User } from "../src/app/_clients/betterAuth"; -type RemoveNullish = { - [K in keyof T]-?: NonNullable; -}; - -type NonNullableUser = RemoveNullish; +type NonNullableUser = Required< + Pick +>; export const user1: NonNullableUser = { id: "id1", diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts index ea210774..9d2511f6 100644 --- a/e2e/fixtures.ts +++ b/e2e/fixtures.ts @@ -1,6 +1,6 @@ import AxeBuilder from "@axe-core/playwright"; import { test as base } from "@playwright/test"; -import type { User } from "next-auth"; +import type { User } from "../src/app/_clients/betterAuth"; import { setupDB } from "../tests/db.setup"; import { setupApp } from "./helpers/app"; import { registerUserToDB } from "./helpers/users"; @@ -21,7 +21,9 @@ export type TestFixtures = { signInPage: SignInPage; notFoundPage: NotFoundPage; storageState: string; - registerToDB: (user: User) => Promise; + registerToDB: ( + user: Pick, + ) => Promise; reset: () => Promise; a11y: () => AxeBuilder; }; @@ -82,9 +84,11 @@ export const test = base.extend({ }, ], registerToDB: async ({ reset, setup }, use) => { - await use(async (user: User) => { - await registerUserToDB(user, setup.dbURL); - }); + await use( + async (user: Pick) => { + await registerUserToDB(user, setup.dbURL); + }, + ); await reset(); }, reset: ({ context, setup }, use) => { diff --git a/e2e/helpers/app.ts b/e2e/helpers/app.ts index 15dbce00..6c5ea828 100644 --- a/e2e/helpers/app.ts +++ b/e2e/helpers/app.ts @@ -6,7 +6,7 @@ export async function setupApp(dbPort: number) { const appPort = await getRandomPort(); const baseURL = `http://localhost:${appPort}`; const cp = exec( - `NEXTAUTH_URL=${baseURL} DATABASE_PORT=${dbPort} pnpm start --port ${appPort}`, + `BETTER_AUTH_URL=${baseURL} DATABASE_PORT=${dbPort} pnpm start --port ${appPort}`, ); await waitForHealth(baseURL); diff --git a/e2e/helpers/users.ts b/e2e/helpers/users.ts index e2ad6a12..c9a61e98 100644 --- a/e2e/helpers/users.ts +++ b/e2e/helpers/users.ts @@ -1,22 +1,28 @@ +import crypto from "node:crypto"; import type { BrowserContext, TestType } from "@playwright/test"; -import type { User } from "next-auth"; -import type { JWT } from "next-auth/jwt"; +import type { User } from "../../src/app/_clients/betterAuth"; import type { TestFixtures, WorkerFixtures } from "../fixtures"; import { generatePrismaClient } from "./prisma"; -export async function registerUserToDB(user: User, dbUrl: string) { +export async function registerUserToDB( + user: Pick, + dbUrl: string, +) { await using db = await generatePrismaClient(dbUrl); await db.prisma.user.create({ data: { - ...user, + id: user.id, + name: user.name, + email: user.email, + emailVerified: false, + image: user.image ?? null, + role: user.role as "USER" | "ADMIN", accounts: { create: { - type: "oauth", - provider: "google", - providerAccountId: `${Math.random()}`, - id_token: "id_token", - access_token: "access_token", - token_type: "Bearer", + accountId: `${Math.random()}`, + providerId: "google", + accessToken: "access_token", + idToken: "id_token", scope: "scope", }, }, @@ -24,24 +30,95 @@ export async function registerUserToDB(user: User, dbUrl: string) { }); } -export async function createUserAuthState(context: BrowserContext, jwt: JWT) { +export async function createUserAuthState( + context: BrowserContext, + jwt: { user: Pick }, + dbUrl: string, +) { + // Create session directly in database + await using db = await generatePrismaClient(dbUrl); + + const sessionToken = crypto.randomUUID(); + const expiresAt = new Date(Date.now() + 60 * 60 * 24 * 1000 * 7); // 7 days + + const session = await db.prisma.session.create({ + data: { + token: sessionToken, + userId: jwt.user.id, + expiresAt, + ipAddress: "127.0.0.1", + userAgent: "test", + }, + }); + + // Create session data with signature (required by better-auth) + // Note: The key order matters for signature verification + // better-auth uses {session: {...}, user: {...}} order in setCookieCache + const sessionData = { + session: { + userId: jwt.user.id, + expiresAt: session.expiresAt.toISOString(), + }, + user: { + id: jwt.user.id, + name: jwt.user.name, + email: jwt.user.email, + image: jwt.user.image, + role: jwt.user.role, + }, + }; + + const expiresAtTimestamp = Date.now() + 60 * 60 * 1000; // 1 hour + + // Create HMAC signature using the same method as better-auth + const secret = process.env.BETTER_AUTH_SECRET; + if (!secret) { + throw new Error("BETTER_AUTH_SECRET is not set"); + } + + // The signature is verified against sessionData spread with expiresAt + // See better-auth getCookieCache: JSON.stringify({...sessionDataPayload.session, expiresAt: sessionDataPayload.expiresAt}) + const dataToSign = JSON.stringify({ + session: sessionData.session, + user: sessionData.user, + expiresAt: expiresAtTimestamp, + }); + + const signature = crypto + .createHmac("sha256", secret) + .update(dataToSign) + .digest("base64url"); + + const cookieValue = Buffer.from( + JSON.stringify({ + session: sessionData, + expiresAt: expiresAtTimestamp, + signature, + }), + ).toString("base64url"); + + // Set cookies with the session token and session data await context.addCookies([ { - name: "authjs.session-token", - value: btoa( - JSON.stringify({ - ...jwt, - // google provider attaches `sub` to the token - sub: jwt.user.id, - }), - ), + name: "better-auth.session_token", + value: sessionToken, + domain: "localhost", + path: "/", + httpOnly: true, + sameSite: "Lax", + expires: Math.round(expiresAt.getTime() / 1000), + }, + { + name: "better-auth.session_data", + value: cookieValue, domain: "localhost", path: "/", httpOnly: true, sameSite: "Lax", - expires: Math.round((Date.now() + 60 * 60 * 24 * 1000 * 7) / 1000), + expires: Math.round(expiresAtTimestamp / 1000), }, ]); + await context.storageState({ path: getStorageStatePath(jwt.user.id ?? ""), }); @@ -49,7 +126,7 @@ export async function createUserAuthState(context: BrowserContext, jwt: JWT) { export async function useUser>( test: T, - user: User, + user: Pick, ) { test.use({ storageState: getStorageStatePath(user.id) }); test.beforeEach(async ({ registerToDB }) => { diff --git a/e2e/models/Base.ts b/e2e/models/Base.ts index f0d1a605..7c9222e3 100644 --- a/e2e/models/Base.ts +++ b/e2e/models/Base.ts @@ -1,5 +1,5 @@ import { expect, type Locator, type Page } from "@playwright/test"; -import type { User } from "next-auth"; +import type { User } from "../../src/app/_clients/betterAuth"; export class Base { page: Page; diff --git a/e2e/models/TopPage.ts b/e2e/models/TopPage.ts index 980669cb..685afaf1 100644 --- a/e2e/models/TopPage.ts +++ b/e2e/models/TopPage.ts @@ -1,5 +1,5 @@ import { expect, type Locator, type Page } from "@playwright/test"; -import type { User } from "next-auth"; +import type { User } from "../../src/app/_clients/betterAuth"; import { Base } from "./Base"; export class TopPage extends Base { diff --git a/e2e/setup/auth.ts b/e2e/setup/auth.ts index e467a9f2..634d4cc8 100644 --- a/e2e/setup/auth.ts +++ b/e2e/setup/auth.ts @@ -1,15 +1,25 @@ -import { test as setup } from "@playwright/test"; import { admin1, user1 } from "../dummyUsers"; -import { createUserAuthState } from "../helpers/users"; +import { test as setup } from "../fixtures"; +import { createUserAuthState, registerUserToDB } from "../helpers/users"; -setup("Create user1 auth", async ({ context }) => { - await createUserAuthState(context, { - user: user1, - }); +setup("Create user1 auth", async ({ context, setup: setupFixture }) => { + await registerUserToDB(user1, setupFixture.dbURL); + await createUserAuthState( + context, + { + user: user1, + }, + setupFixture.dbURL, + ); }); -setup("Create admin1 auth", async ({ context }) => { - await createUserAuthState(context, { - user: admin1, - }); +setup("Create admin1 auth", async ({ context, setup: setupFixture }) => { + await registerUserToDB(admin1, setupFixture.dbURL); + await createUserAuthState( + context, + { + user: admin1, + }, + setupFixture.dbURL, + ); }); diff --git a/env.ts b/env.ts index e6ed235c..9d25741f 100644 --- a/env.ts +++ b/env.ts @@ -24,8 +24,8 @@ const staticEnv = z.object({ GOOGLE_CLIENT_ID: z.string().min(1), GOOGLE_CLIENT_SECRET: z.string().min(1), - NEXTAUTH_URL: z.string().min(1), - NEXTAUTH_SECRET: z.string().min(1), + BETTER_AUTH_SECRET: z.string().min(1), + BETTER_AUTH_URL: z.string().min(1), /* start: otel */ TRACE_EXPORTER_URL: z.url().optional().or(z.literal("")), diff --git a/package.json b/package.json index cba1f763..22ed7e01 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,6 @@ "fmt": "prettier -w './**/*.{md,yml}' && biome format --write . && prisma format" }, "dependencies": { - "@auth/prisma-adapter": "2.10.0", "@hookform/resolvers": "5.2.2", "@next/env": "15.5.4", "@opentelemetry/exporter-metrics-otlp-grpc": "0.205.0", @@ -36,9 +35,9 @@ "@opentelemetry/semantic-conventions": "1.37.0", "@prisma/client": "6.16.2", "@prisma/instrumentation": "6.16.2", + "better-auth": "1.3.27", "clsx": "2.1.1", "next": "15.5.4", - "next-auth": "5.0.0-beta.29", "prisma": "6.16.2", "react": "19.1.1", "react-dom": "19.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1149e7e1..0440ac3d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - '@auth/prisma-adapter': - specifier: 2.10.0 - version: 2.10.0(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.3))(typescript@5.9.3)) '@hookform/resolvers': specifier: 5.2.2 version: 5.2.2(react-hook-form@7.63.0(react@19.1.1)) @@ -47,15 +44,15 @@ importers: '@prisma/instrumentation': specifier: 6.16.2 version: 6.16.2(@opentelemetry/api@1.9.0) + better-auth: + specifier: 1.3.27 + version: 1.3.27(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1) clsx: specifier: 2.1.1 version: 2.1.1 next: specifier: 15.5.4 version: 15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - next-auth: - specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1) prisma: specifier: 6.16.2 version: 6.16.2(typescript@5.9.3) @@ -142,25 +139,6 @@ packages: '@asamuzakjp/nwsapi@2.3.9': resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} - '@auth/core@0.40.0': - resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - - '@auth/prisma-adapter@2.10.0': - resolution: {integrity: sha512-EliOQoTjGK87jWWqnJvlQjbR4PjQZQqtwRwPAe108WwT9ubuuJJIrL68aNnQr4hFESz6P7SEX2bZy+y2yL37Gw==} - peerDependencies: - '@prisma/client': '>=2.26.0 || >=3 || >=4 || >=5 || >=6' - '@axe-core/playwright@4.10.2': resolution: {integrity: sha512-6/b5BJjG6hDaRNtgzLIfKr5DfwyiLHO4+ByTLB0cJgWSM8Ll7KqtdblIS6bEkwSF642/Ex91vNqIl3GLXGlceg==} peerDependencies: @@ -256,6 +234,15 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@better-auth/core@1.3.27': + resolution: {integrity: sha512-3Sfdax6MQyronY+znx7bOsfQHI6m1SThvJWb0RDscFEAhfqLy95k1sl+/PgGyg0cwc2cUXoEiAOSqYdFYrg3vA==} + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.18': + resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@biomejs/biome@2.2.4': resolution: {integrity: sha512-TBHU5bUy/Ok6m8c0y3pZiuO/BZoY/OcGxoLlrfQof5s8ISVwbVBdFINPQZyFfKwil8XibYWb7JMwnT8wT4WVPg==} engines: {node: '>=14.21.3'} @@ -517,6 +504,9 @@ packages: engines: {node: '>=6'} hasBin: true + '@hexagon/base64@1.1.28': + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -671,6 +661,9 @@ packages: '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@levischuck/tiny-cbor@0.2.11': + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} + '@napi-rs/wasm-runtime@1.0.5': resolution: {integrity: sha512-TBr9Cf9onSAS2LQ2+QHx6XcC6h9+RIzJgbqG3++9TUZSH204AwEy5jg3BTQ0VATsyoGj4ee49tN/y6rvaOOtcg==} @@ -725,6 +718,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.0.1': + resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1010,8 +1011,41 @@ packages: cpu: [x64] os: [win32] - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + '@peculiar/asn1-android@2.5.0': + resolution: {integrity: sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A==} + + '@peculiar/asn1-cms@2.5.0': + resolution: {integrity: sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A==} + + '@peculiar/asn1-csr@2.5.0': + resolution: {integrity: sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ==} + + '@peculiar/asn1-ecc@2.5.0': + resolution: {integrity: sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg==} + + '@peculiar/asn1-pfx@2.5.0': + resolution: {integrity: sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug==} + + '@peculiar/asn1-pkcs8@2.5.0': + resolution: {integrity: sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw==} + + '@peculiar/asn1-pkcs9@2.5.0': + resolution: {integrity: sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A==} + + '@peculiar/asn1-rsa@2.5.0': + resolution: {integrity: sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q==} + + '@peculiar/asn1-schema@2.5.0': + resolution: {integrity: sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ==} + + '@peculiar/asn1-x509-attr@2.5.0': + resolution: {integrity: sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A==} + + '@peculiar/asn1-x509@2.5.0': + resolution: {integrity: sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ==} + + '@peculiar/x509@1.14.0': + resolution: {integrity: sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg==} '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} @@ -1195,6 +1229,13 @@ packages: cpu: [x64] os: [win32] + '@simplewebauthn/browser@13.2.2': + resolution: {integrity: sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==} + + '@simplewebauthn/server@13.2.2': + resolution: {integrity: sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==} + engines: {node: '>=20.0.0'} + '@standard-schema/spec@1.0.0': resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} @@ -1460,6 +1501,10 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.6: + resolution: {integrity: sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA==} + engines: {node: '>=12.0.0'} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1516,6 +1561,38 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-auth@1.3.27: + resolution: {integrity: sha512-SwiGAJ7yU6dBhNg0NdV1h5M8T5sa7/AszZVc4vBfMDrLLmvUfbt9JoJ0uRUJUEdKRAAxTyl9yA+F3+GhtAD80w==} + peerDependencies: + '@lynx-js/react': '*' + '@sveltejs/kit': '*' + next: '*' + react: '*' + react-dom: '*' + solid-js: '*' + svelte: '*' + vue: '*' + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + + better-call@1.0.19: + resolution: {integrity: sha512-sI3GcA1SCVa3H+CDHl8W8qzhlrckwXOTKhqq3OOPXjgn5aTOMIqGY34zLY/pHA6tRRMjTUC3lz5Mi7EbDA24Kw==} + bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} @@ -2033,6 +2110,10 @@ packages: '@types/node': '>=18' typescript: '>=5.0.4 <7' + kysely@0.28.8: + resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} + engines: {node: '>=20.0.0'} + lazystream@1.0.1: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} @@ -2243,27 +2324,15 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.0.1: + resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.3.3: resolution: {integrity: sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} hasBin: true - next-auth@5.0.0-beta.29: - resolution: {integrity: sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - next: ^14.0.0-0 || ^15.0.0-0 - nodemailer: ^6.6.5 - react: ^18.2.0 || ^19.0.0-0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - next@15.5.4: resolution: {integrity: sha512-xH4Yjhb82sFYQfY3vbkJfgSDgXvBB6a8xPs9i35k6oZJRoQRihZH+4s9Yo2qsWpzBmZ3lPXaJ2KPXLfkvW4LnA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -2300,9 +2369,6 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - oauth4webapi@3.8.1: - resolution: {integrity: sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -2375,14 +2441,6 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} - preact-render-to-string@6.5.11: - resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} - peerDependencies: - preact: '>=10' - - preact@10.24.3: - resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} - prettier@3.6.2: resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} engines: {node: '>=14'} @@ -2430,6 +2488,13 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.3: + resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==} + engines: {node: '>=6.0.0'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -2480,6 +2545,9 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2510,6 +2578,9 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rou3@0.5.1: + resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -2541,6 +2612,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.1: + resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} + sharp@0.34.3: resolution: {integrity: sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -2752,9 +2826,16 @@ packages: resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} engines: {node: '>=18'} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + tweetnacl@0.14.5: resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} @@ -2763,6 +2844,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -2975,23 +3059,6 @@ snapshots: '@asamuzakjp/nwsapi@2.3.9': {} - '@auth/core@0.40.0': - dependencies: - '@panva/hkdf': 1.2.1 - jose: 6.1.0 - oauth4webapi: 3.8.1 - preact: 10.24.3 - preact-render-to-string: 6.5.11(preact@10.24.3) - - '@auth/prisma-adapter@2.10.0(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.3))(typescript@5.9.3))': - dependencies: - '@auth/core': 0.40.0 - '@prisma/client': 6.16.2(prisma@6.16.2(typescript@5.9.3))(typescript@5.9.3) - transitivePeerDependencies: - - '@simplewebauthn/browser' - - '@simplewebauthn/server' - - nodemailer - '@axe-core/playwright@4.10.2(playwright-core@1.55.1)': dependencies: axe-core: 4.10.3 @@ -3113,6 +3180,15 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@better-auth/core@1.3.27': + dependencies: + better-call: 1.0.19 + zod: 4.1.12 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.18': {} + '@biomejs/biome@2.2.4': optionalDependencies: '@biomejs/cli-darwin-arm64': 2.2.4 @@ -3278,6 +3354,8 @@ snapshots: protobufjs: 7.5.4 yargs: 17.7.2 + '@hexagon/base64@1.1.28': {} + '@hookform/resolvers@5.2.2(react-hook-form@7.63.0(react@19.1.1))': dependencies: '@standard-schema/utils': 0.3.0 @@ -3403,6 +3481,8 @@ snapshots: '@js-sdsl/ordered-map@4.4.2': {} + '@levischuck/tiny-cbor@0.2.11': {} + '@napi-rs/wasm-runtime@1.0.5': dependencies: '@emnapi/core': 1.5.0 @@ -3436,6 +3516,10 @@ snapshots: '@next/swc-win32-x64-msvc@15.5.4': optional: true + '@noble/ciphers@2.0.1': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -3760,7 +3844,101 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.8.3': optional: true - '@panva/hkdf@1.2.1': {} + '@peculiar/asn1-android@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-cms@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + '@peculiar/asn1-x509-attr': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-csr@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-ecc@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-pfx@2.5.0': + dependencies: + '@peculiar/asn1-cms': 2.5.0 + '@peculiar/asn1-pkcs8': 2.5.0 + '@peculiar/asn1-rsa': 2.5.0 + '@peculiar/asn1-schema': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs8@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-pkcs9@2.5.0': + dependencies: + '@peculiar/asn1-cms': 2.5.0 + '@peculiar/asn1-pfx': 2.5.0 + '@peculiar/asn1-pkcs8': 2.5.0 + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + '@peculiar/asn1-x509-attr': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-rsa@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-schema@2.5.0': + dependencies: + asn1js: 3.0.6 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + asn1js: 3.0.6 + tslib: 2.8.1 + + '@peculiar/asn1-x509@2.5.0': + dependencies: + '@peculiar/asn1-schema': 2.5.0 + asn1js: 3.0.6 + pvtsutils: 1.3.6 + tslib: 2.8.1 + + '@peculiar/x509@1.14.0': + dependencies: + '@peculiar/asn1-cms': 2.5.0 + '@peculiar/asn1-csr': 2.5.0 + '@peculiar/asn1-ecc': 2.5.0 + '@peculiar/asn1-pkcs9': 2.5.0 + '@peculiar/asn1-rsa': 2.5.0 + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 '@pkgjs/parseargs@0.11.0': optional: true @@ -3899,6 +4077,19 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.50.0': optional: true + '@simplewebauthn/browser@13.2.2': {} + + '@simplewebauthn/server@13.2.2': + dependencies: + '@hexagon/base64': 1.1.28 + '@levischuck/tiny-cbor': 0.2.11 + '@peculiar/asn1-android': 2.5.0 + '@peculiar/asn1-ecc': 2.5.0 + '@peculiar/asn1-rsa': 2.5.0 + '@peculiar/asn1-schema': 2.5.0 + '@peculiar/asn1-x509': 2.5.0 + '@peculiar/x509': 1.14.0 + '@standard-schema/spec@1.0.0': {} '@standard-schema/utils@0.3.0': {} @@ -4186,6 +4377,12 @@ snapshots: dependencies: safer-buffer: 2.1.2 + asn1js@3.0.6: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.3 + tslib: 2.8.1 + assertion-error@2.0.1: {} async-lock@1.4.1: {} @@ -4229,6 +4426,34 @@ snapshots: dependencies: tweetnacl: 0.14.5 + better-auth@1.3.27(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + '@better-auth/core': 1.3.27 + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 2.0.1 + '@noble/hashes': 2.0.1 + '@simplewebauthn/browser': 13.2.2 + '@simplewebauthn/server': 13.2.2 + better-call: 1.0.19 + defu: 6.1.4 + jose: 6.1.0 + kysely: 0.28.8 + nanostores: 1.0.1 + zod: 4.1.12 + optionalDependencies: + next: 15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + + better-call@1.0.19: + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + rou3: 0.5.1 + set-cookie-parser: 2.7.1 + uncrypto: 0.1.3 + bidi-js@1.0.3: dependencies: require-from-string: 2.0.2 @@ -4782,6 +5007,8 @@ snapshots: typescript: 5.9.3 zod: 4.1.12 + kysely@0.28.8: {} + lazystream@1.0.1: dependencies: readable-stream: 2.3.8 @@ -4940,13 +5167,9 @@ snapshots: nanoid@3.3.11: {} - napi-postinstall@0.3.3: {} + nanostores@1.0.1: {} - next-auth@5.0.0-beta.29(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1): - dependencies: - '@auth/core': 0.40.0 - next: 15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) - react: 19.1.1 + napi-postinstall@0.3.3: {} next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.55.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1): dependencies: @@ -4987,8 +5210,6 @@ snapshots: pkg-types: 2.3.0 tinyexec: 1.0.1 - oauth4webapi@3.8.1: {} - object-inspect@1.13.4: {} ohash@2.0.11: {} @@ -5074,12 +5295,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - preact-render-to-string@6.5.11(preact@10.24.3): - dependencies: - preact: 10.24.3 - - preact@10.24.3: {} - prettier@3.6.2: {} pretty-format@27.5.1: @@ -5135,6 +5350,12 @@ snapshots: pure-rand@6.1.0: {} + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + + pvutils@1.1.3: {} + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -5191,6 +5412,8 @@ snapshots: readdirp@4.1.2: {} + reflect-metadata@0.2.2: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -5240,6 +5463,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.50.0 fsevents: 2.3.3 + rou3@0.5.1: {} + rrweb-cssom@0.8.0: {} run-parallel@1.2.0: @@ -5262,6 +5487,8 @@ snapshots: semver@7.7.2: {} + set-cookie-parser@2.7.1: {} + sharp@0.34.3: dependencies: color: 4.2.3 @@ -5529,12 +5756,20 @@ snapshots: dependencies: punycode: 2.3.1 + tslib@1.14.1: {} + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + tweetnacl@0.14.5: {} typescript@5.9.3: {} + uncrypto@0.1.3: {} + undici-types@5.26.5: {} undici-types@6.21.0: {} diff --git a/prisma/schema/user.prisma b/prisma/schema/user.prisma index 9457af38..f88cb722 100644 --- a/prisma/schema/user.prisma +++ b/prisma/schema/user.prisma @@ -3,47 +3,69 @@ enum Role { ADMIN } -// https://authjs.dev/getting-started/adapters/prisma#schema -model Account { - id String @id @default(cuid()) - userId String @map("user_id") - type String - provider String - providerAccountId String @map("provider_account_id") - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - @@unique([provider, providerAccountId]) - @@map("accounts") -} - +// https://www.better-auth.com/docs/concepts/database#core-schema model User { id String @id @default(cuid()) - name String? - email String? @unique - emailVerified DateTime? @map("email_verified") + name String + email String @unique + emailVerified Boolean @map("email_verified") image String? - accounts Account[] createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - // https://authjs.dev/guides/basics/role-based-access-control + accounts Account[] + sessions Session[] role Role @default(USER) - // start: sample items Item[] - // end: sample stripeId String? @unique @map("stripe_id") // cus_XXXX subscriptions Subscription[] @@map("users") } +model Session { + id String @id @default(cuid()) + expiresAt DateTime @map("expires_at") + token String @unique + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + userId String @map("user_id") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("sessions") +} + +model Account { + id String @id @default(cuid()) + accountId String @map("account_id") + providerId String @map("provider_id") + userId String @map("user_id") + accessToken String? @map("access_token") + refreshToken String? @map("refresh_token") + idToken String? @map("id_token") + accessTokenExpiresAt DateTime? @map("access_token_expires_at") + refreshTokenExpiresAt DateTime? @map("refresh_token_expires_at") + scope String? + password String? + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("accounts") +} + +model Verification { + id String @id @default(cuid()) + identifier String + value String + expiresAt DateTime @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@map("verifications") +} + model Subscription { id String @id @default(cuid()) subscriptionId String @unique @map("subscription_id") // sub_XXXX diff --git a/src/app/(private)/me/page.tsx b/src/app/(private)/me/page.tsx index 214af57e..6f6d233d 100644 --- a/src/app/(private)/me/page.tsx +++ b/src/app/(private)/me/page.tsx @@ -9,7 +9,5 @@ export default async function Page() { notFound(); } - const { user } = session.data; - - return ; + return ; } diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 6f520c6a..044ed8ff 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -25,11 +25,11 @@ async function Status() { return (

- {session?.data?.user - ? `you are signed in as ${session.data.user.name} 😄` + {session.success + ? `you are signed in as ${session.data.name} 😄` : "you are not signed in 🥲"}

- {session?.data?.user && } + {session.success && }
); } diff --git a/src/app/(public)/signin/page.tsx b/src/app/(public)/signin/page.tsx index 728348c7..34258802 100644 --- a/src/app/(public)/signin/page.tsx +++ b/src/app/(public)/signin/page.tsx @@ -1,12 +1,21 @@ "use client"; -import { signIn } from "next-auth/react"; +import { signIn } from "../../_clients/betterAuthClient"; import { Button } from "../../_components/Button"; export default function SignIn() { return (
- +
); } diff --git a/src/app/_actions/items.test.ts b/src/app/_actions/items.test.ts index 479c3d4f..dac7af04 100644 --- a/src/app/_actions/items.test.ts +++ b/src/app/_actions/items.test.ts @@ -36,7 +36,7 @@ describe("actions/items", () => { }); test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); expect(await create({ content: "hello" })).toMatchInlineSnapshot(` { @@ -92,7 +92,7 @@ describe("actions/items", () => { }); test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); await expect(deleteAll()).rejects.toThrow("no session token"); }); diff --git a/src/app/_actions/items.ts b/src/app/_actions/items.ts index a76a0d2f..ad939891 100644 --- a/src/app/_actions/items.ts +++ b/src/app/_actions/items.ts @@ -19,7 +19,6 @@ export async function create(input: ItemSchema): Promise { return session; } - const { user } = session.data; const validatedFields = itemSchema.safeParse(input); if (!validatedFields.success) { @@ -35,7 +34,7 @@ export async function create(input: ItemSchema): Promise { content: validatedFields.data.content, user: { connect: { - id: user.id, + id: session.data.id, }, }, }, @@ -61,12 +60,10 @@ export async function deleteAll(): Promise { throw new Error("no session token"); } - const { user } = session.data; - await prisma.$transaction(async (prisma) => { await prisma.item.deleteMany({ where: { - userId: user.id, + userId: session.data.id, }, }); }); diff --git a/src/app/_actions/payment.test.ts b/src/app/_actions/payment.test.ts index a43c3e42..a3fdf11b 100644 --- a/src/app/_actions/payment.test.ts +++ b/src/app/_actions/payment.test.ts @@ -51,7 +51,7 @@ describe("actions/payment", () => { describe("checkout", () => { test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); expect(await checkout()).toMatchInlineSnapshot(` { @@ -97,7 +97,6 @@ describe("actions/payment", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "hello@a.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "name", @@ -153,7 +152,6 @@ describe("actions/payment", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "hello@a.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "name", @@ -180,7 +178,6 @@ describe("actions/payment", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "hello@a.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "name", @@ -228,7 +225,6 @@ describe("actions/payment", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "hello@a.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "name", @@ -250,7 +246,6 @@ describe("actions/payment", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "hello@a.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "name", @@ -274,7 +269,7 @@ describe("actions/payment", () => { }); test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); expect(await update(true)).toMatchInlineSnapshot(` { @@ -375,7 +370,7 @@ describe("actions/payment", () => { describe("redirectToBillingPortal", () => { test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); await expect(redirectToBillingPortal()).rejects.toThrow( "no session token", diff --git a/src/app/_actions/payment.ts b/src/app/_actions/payment.ts index c0b9a6c6..b8aa4398 100644 --- a/src/app/_actions/payment.ts +++ b/src/app/_actions/payment.ts @@ -19,7 +19,7 @@ export async function checkout(): Promise { const me = await prisma.user.findUnique({ where: { - id: session.data.user.id, + id: session.data.id, }, }); @@ -153,7 +153,7 @@ export async function redirectToBillingPortal(): Promise { const me = await prisma.user.findUnique({ where: { - id: session.data.user.id, + id: session.data.id, }, }); diff --git a/src/app/_actions/users.test.ts b/src/app/_actions/users.test.ts index 023f2a3a..ebe941db 100644 --- a/src/app/_actions/users.test.ts +++ b/src/app/_actions/users.test.ts @@ -39,7 +39,6 @@ describe("actions/users", () => { expect(await getUser()).toMatchInlineSnapshot(` { "email": "b@c.com", - "emailVerified": null, "id": "id", "image": "https://a.com", "name": "foo", @@ -75,7 +74,7 @@ describe("actions/users", () => { }); test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); const formData = new FormData(); diff --git a/src/app/_actions/users.ts b/src/app/_actions/users.ts index 96a5c091..4083bf87 100644 --- a/src/app/_actions/users.ts +++ b/src/app/_actions/users.ts @@ -23,12 +23,10 @@ export async function updateMe( return session; } - const { user } = session.data; - const input: PartialWithNullable = { - name: user.name, - email: user.email, - image: user.image, + name: session.data.name, + email: session.data.email, + image: session.data.image, ...Object.fromEntries(formData.entries()), }; @@ -45,7 +43,7 @@ export async function updateMe( await prisma.user.update({ where: { - id: user.id, + id: session.data.id, }, data: validatedFields.data, }); diff --git a/src/app/_clients/betterAuth.ts b/src/app/_clients/betterAuth.ts new file mode 100644 index 00000000..b43fe820 --- /dev/null +++ b/src/app/_clients/betterAuth.ts @@ -0,0 +1,46 @@ +import { betterAuth } from "better-auth"; +import { prismaAdapter } from "better-auth/adapters/prisma"; +import { prisma } from "./prisma"; + +export const auth = betterAuth({ + database: prismaAdapter(prisma, { + provider: "postgresql", + }), + emailAndPassword: { + enabled: false, + }, + socialProviders: { + google: { + clientId: process.env.GOOGLE_CLIENT_ID as string, + clientSecret: process.env.GOOGLE_CLIENT_SECRET as string, + }, + }, + session: { + // JWT session strategy for Edge Runtime compatibility + expiresIn: 60 * 60, // Session expires after 1 hour + updateAge: 60 * 15, // Session auto-refreshes every 15 minutes when used + cookieCache: { + enabled: true, + maxAge: 60 * 60, // Cache expires after 1 hour + }, + }, + user: { + additionalFields: { + role: { + type: "string", + required: true, + defaultValue: "USER", + }, + }, + }, + advanced: { + database: { + generateId: () => { + return crypto.randomUUID(); + }, + }, + }, + trustedOrigins: [process.env.NEXT_PUBLIC_SITE_URL as string], +}); + +export type User = typeof auth.$Infer.Session.user; diff --git a/src/app/_clients/betterAuthClient.ts b/src/app/_clients/betterAuthClient.ts new file mode 100644 index 00000000..bf87fa1f --- /dev/null +++ b/src/app/_clients/betterAuthClient.ts @@ -0,0 +1,8 @@ +import { createAuthClient } from "better-auth/react"; + +const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_SITE_URL, +}); + +// e.g. useSession +export const { signIn, signOut } = authClient; diff --git a/src/app/_clients/nextAuth.ts b/src/app/_clients/nextAuth.ts deleted file mode 100644 index e9e8b12c..00000000 --- a/src/app/_clients/nextAuth.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { PrismaAdapter } from "@auth/prisma-adapter"; -import NextAuth from "next-auth"; -import type { Adapter } from "next-auth/adapters"; -import type { JWT } from "next-auth/jwt"; -import { config, configForTest } from "./nextAuthConfig"; -import { prisma } from "./prisma"; - -export const { auth, handlers } = NextAuth({ - // https://authjs.dev/getting-started/migrating-to-v5#edge-compatibility - ...config, - adapter: PrismaAdapter(prisma) as Adapter, - session: { - strategy: "jwt", - }, - callbacks: { - ...config.callbacks, - jwt: async ({ - token, - /* user exists when only signing in */ user, - trigger, - }): Promise => { - const userId = token.sub ?? user?.id; - - if (!userId) { - throw new Error("Token is invalid"); - } - - // support for multiple devices - const me = await prisma.user.findUnique({ - where: { - id: userId, - }, - }); - - // to avoid accessing devices having invalid JWT if user is not found - if (trigger !== "signUp" && !user && !me) { - throw new Error("User not found"); - } - - // in favor of the user's latest data and update the token - token.user = { - id: userId, - name: me?.name ?? token.name ?? "", - email: me?.email ?? token.email ?? "", - role: me?.role ?? user.role, - image: me?.image ?? token.picture ?? "", - }; - - return token; - }, - }, - ...(process.env.NEXTAUTH_TEST_MODE === "true" ? configForTest : {}), -}); diff --git a/src/app/_clients/nextAuthConfig.ts b/src/app/_clients/nextAuthConfig.ts deleted file mode 100644 index b7f015f2..00000000 --- a/src/app/_clients/nextAuthConfig.ts +++ /dev/null @@ -1,62 +0,0 @@ -// don't import prisma or @auth/prisma-adapter here to avoid importing it at Edge -// [auth][error] JWTSessionError: Read more at https://errors.authjs.dev#jwtsessionerror -// [auth][cause]: Error: PrismaClient is not configured to run in Edge Runtime (Vercel Edge Functions, Vercel Edge Middleware, Next.js (Pages Router) Edge API Routes, Next.js (App Router) Edge Route Handlers or Next.js Middleware). In order to run Prisma Client on edge runtime, either: - -import type { NextAuthConfig } from "next-auth"; -import GoogleProvider from "next-auth/providers/google"; - -export const configForTest = { - jwt: { - encode: async ({ token }) => { - return btoa(JSON.stringify(token)); - }, - decode: async ({ token }) => { - if (!token) { - return {}; - } - - return JSON.parse(atob(token)); - }, - }, -} satisfies Omit; - -export const config = { - pages: { - signIn: "/signin", - }, - providers: [ - GoogleProvider({ - clientId: process.env.GOOGLE_CLIENT_ID, - clientSecret: process.env.GOOGLE_CLIENT_SECRET, - profile(profile) { - return { - id: profile.sub, - name: profile.name, - email: profile.email, - image: profile.picture, - // https://authjs.dev/guides/role-based-access-control#persisting-the-role - role: profile.role ?? "USER", - }; - }, - }), - ], - callbacks: { - redirect: ({ baseUrl }) => { - return baseUrl; - }, - session: ({ session, token }) => { - if (session?.user && token.user.id) { - session.user.id = token.user.id; - session.user.name = token.user.name; - session.user.email = token.user.email ?? ""; - session.user.image = token.user.image; - session.user.role = token.user.role; - } - - return session; - }, - }, - // https://authjs.dev/getting-started/deployment#docker - trustHost: true, - ...(process.env.NEXTAUTH_TEST_MODE === "true" ? configForTest : {}), -} satisfies NextAuthConfig; diff --git a/src/app/_components/AuthButtons.tsx b/src/app/_components/AuthButtons.tsx index c0597480..87af1bcc 100644 --- a/src/app/_components/AuthButtons.tsx +++ b/src/app/_components/AuthButtons.tsx @@ -1,12 +1,32 @@ "use client"; -import { signIn, signOut } from "next-auth/react"; +import { signIn, signOut } from "../_clients/betterAuthClient"; import { Button } from "./Button"; export function SignInButton() { - return ; + return ( + + ); } export function SignOutButton() { - return ; + return ( + + ); } diff --git a/src/app/_components/Header.tsx b/src/app/_components/Header.tsx index eb228ab0..833fcc07 100644 --- a/src/app/_components/Header.tsx +++ b/src/app/_components/Header.tsx @@ -10,10 +10,10 @@ export async function Header() { return (
- {session?.data?.user?.image ? ( + {session.success && session.data.image ? ( { expect(await getSessionOrReject()).toMatchInlineSnapshot(` { "data": { - "user": { - "email": "hello@a.com", - "id": "id", - "image": "https://a.com", - "name": "name", - "role": "USER", - }, + "email": "hello@a.com", + "id": "id", + "image": "https://a.com", + "name": "name", + "role": "USER", }, "success": true, } diff --git a/src/app/_utils/auth.ts b/src/app/_utils/auth.ts index 1d8b2587..09f4d8b9 100644 --- a/src/app/_utils/auth.ts +++ b/src/app/_utils/auth.ts @@ -1,10 +1,12 @@ -import type { Session } from "next-auth"; -import { auth } from "../_clients/nextAuth"; +import { headers } from "next/headers"; +import { auth, type User } from "../_clients/betterAuth"; import type { Result } from "../_types/result"; -export async function getSessionOrReject(): Promise> { +export async function getSessionOrReject(): Promise> { try { - const session = await auth(); + const session = await auth.api.getSession({ + headers: await headers(), + }); if (!session?.user?.id) { return { @@ -15,7 +17,7 @@ export async function getSessionOrReject(): Promise> { return { success: true, - data: session, + data: session.user, }; } catch { return { diff --git a/src/app/_utils/payment.test.ts b/src/app/_utils/payment.test.ts index 18eebc8e..36990704 100644 --- a/src/app/_utils/payment.test.ts +++ b/src/app/_utils/payment.test.ts @@ -161,7 +161,7 @@ describe("utils/payment", () => { describe("status", () => { test("should throw an error if there is no session token", async () => { - mock.auth.mockReturnValueOnce(null); + mock.getSession.mockResolvedValueOnce(null); expect(await status()).toMatchInlineSnapshot(` { diff --git a/src/app/_utils/payment.ts b/src/app/_utils/payment.ts index 4ae34815..0ec5fcf8 100644 --- a/src/app/_utils/payment.ts +++ b/src/app/_utils/payment.ts @@ -17,10 +17,9 @@ export async function status(): Promise { return session; } - const { user } = session.data; const subscription = await prisma.subscription.findFirst({ where: { - userId: user.id, + userId: session.data.id, status: { in: ["active", "complete"], }, diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts new file mode 100644 index 00000000..d13d9786 --- /dev/null +++ b/src/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from "../../../_clients/betterAuth"; + +export const GET = auth.handler; +export const POST = auth.handler; diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 04b7d342..00000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from "../../../_clients/nextAuth"; - -export const { GET, POST } = handlers; diff --git a/src/app/globals.d.ts b/src/app/globals.d.ts index 50e1819f..9db6b202 100644 --- a/src/app/globals.d.ts +++ b/src/app/globals.d.ts @@ -1,5 +1,4 @@ import type { Schema } from "../../env"; -import type { Role } from "./__generated__/prisma"; declare global { namespace NodeJS { @@ -10,23 +9,3 @@ declare global { [P in keyof T]?: T[P] | null; }; } - -declare module "next-auth" { - interface User { - id: string; - name: string; - email: string; - image: string; - role: Role; - } - - interface Session { - user: User; - } -} - -declare module "next-auth/jwt" { - interface JWT { - user: import("next-auth").Session["user"]; - } -} diff --git a/src/middleware.test.ts b/src/middleware.test.ts index 8c705b98..7e195ada 100644 --- a/src/middleware.test.ts +++ b/src/middleware.test.ts @@ -1,30 +1,17 @@ -import { beforeEach } from "node:test"; -import type { AppRouteHandlerFn } from "next/dist/server/route-modules/app-route/module"; import { getRewrittenUrl, isRewrite, unstable_doesMiddlewareMatch, } from "next/experimental/testing/server.js"; import { NextRequest, type NextResponse } from "next/server"; -import type { NextAuthResult } from "next-auth"; -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import nextConfig from "../next.config"; import middleware, { config } from "./middleware"; -type NextAuthRequest = Parameters[0]>[0]; - describe("middleware", () => { beforeEach(() => { - vi.mock("next-auth", async (actual) => ({ - ...(await actual()), - default: () => ({ - auth: ( - fn: ( - req: NextAuthRequest, - ctx: AppRouteHandlerFn, - ) => Promise, - ) => fn, - }), + vi.mock("better-auth/cookies", async () => ({ + getCookieCache: vi.fn(), })); }); @@ -46,28 +33,42 @@ describe("middleware", () => { }); test("should route /signin to when fallback", async () => { - const req = new NextRequest("http://localhost:3000"); - const res = (await middleware(req, { - params: Promise.resolve({}), - })) as NextResponse; + const { getCookieCache } = await import("better-auth/cookies"); + vi.mocked(getCookieCache).mockResolvedValue(null); + + const req = new NextRequest("http://localhost:3000/me"); + const res = (await middleware(req)) as NextResponse; - expect(isRewrite(res)).toEqual(true); - expect(getRewrittenUrl(res)).toEqual("http://localhost:3000/signin"); + expect(isRewrite(res)).toEqual(false); + expect(getRewrittenUrl(res)).toEqual(null); + expect(res.status).toEqual(307); + expect(res.headers.get("location")).toEqual("http://localhost:3000/signin"); }); test("should accept only users having role of user", async () => { - const req = { - auth: { - user: { - role: "USER", - }, - expires: "expires", + const { getCookieCache } = await import("better-auth/cookies"); + vi.mocked(getCookieCache).mockResolvedValue({ + user: { + id: "id", + name: "name", + email: "email@example.com", + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + role: "USER", + }, + session: { + id: "session-id", + userId: "id", + expiresAt: new Date(), + token: "token", + createdAt: new Date(), + updatedAt: new Date(), }, - } as NextAuthRequest; + }); - const res = (await middleware(req, { - params: Promise.resolve({}), - })) as NextResponse; + const req = new NextRequest("http://localhost:3000/me"); + const res = (await middleware(req)) as NextResponse; expect(isRewrite(res)).toEqual(false); expect(getRewrittenUrl(res)).toEqual(null); diff --git a/src/middleware.ts b/src/middleware.ts index 7af18483..8b68b854 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,18 +1,17 @@ +import { getCookieCache } from "better-auth/cookies"; import type { MiddlewareSourceConfig } from "next/experimental/testing/server"; -import { NextResponse } from "next/server"; -import NextAuth from "next-auth"; -import { config as authConfig } from "./app/_clients/nextAuthConfig"; - -const { auth } = NextAuth(authConfig); +import { type NextRequest, NextResponse } from "next/server"; export const config: MiddlewareSourceConfig = { matcher: ["/me(.*)"], }; -export default auth(async function middleware(req) { - if (req.auth?.user.role === "USER") { +export default async function middleware(request: NextRequest) { + const session = await getCookieCache(request); + + if (session?.user && (session.user as { role?: string }).role === "USER") { return NextResponse.next(); } - return NextResponse.rewrite(new URL("/signin", req.url)); -}); + return NextResponse.redirect(new URL("/signin", request.url)); +} diff --git a/tests/vitest.helper.ts b/tests/vitest.helper.ts index 5282a8bd..9abfb9a3 100644 --- a/tests/vitest.helper.ts +++ b/tests/vitest.helper.ts @@ -1,5 +1,5 @@ -import type { User } from "next-auth"; import { afterAll, afterEach, expect, vi } from "vitest"; +import type { User } from "../src/app/_clients/betterAuth"; export async function setup() { const { container, prisma, truncate, down } = await vi.hoisted(async () => { @@ -9,7 +9,7 @@ export async function setup() { }); const mock = vi.hoisted(() => ({ - auth: vi.fn(), + getSession: vi.fn(), revalidatePath: vi.fn(), revalidateTag: vi.fn(), redirect: vi.fn(), @@ -20,11 +20,13 @@ export async function setup() { prisma, })); - vi.mock("next-auth", async (actual) => ({ - ...(await actual()), - default: () => ({ - auth: mock.auth, - }), + vi.mock("../src/app/_clients/betterAuth", async (actual) => ({ + ...(await actual()), + auth: { + api: { + getSession: mock.getSession, + }, + }, })); vi.mock("next/cache", async (actual) => ({ @@ -38,6 +40,10 @@ export async function setup() { redirect: mock.redirect, })); + vi.mock("next/headers", async () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), + })); + afterAll(async () => { await down(); }); @@ -47,7 +53,7 @@ export async function setup() { }); async function createUser() { - const user: User = { + const user: Pick = { id: "id", name: "name", email: "hello@a.com", @@ -56,12 +62,26 @@ export async function setup() { }; await prisma.user.create({ - data: user, + data: { + id: user.id, + name: user.name, + email: user.email, + emailVerified: false, + image: user.image, + role: user.role as "USER" | "ADMIN", + }, }); - mock.auth.mockReturnValue({ + const sessionMock = { + session: { + id: "session-id", + userId: user.id, + expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7), + }, user, - }); + }; + + mock.getSession.mockResolvedValue(sessionMock); expect(await prisma.user.count()).toBe(1); @@ -75,7 +95,7 @@ export async function setup() { throw new Error("User not found"); } - const { createdAt: _, updatedAt: __, ...rest } = me; + const { createdAt: _, updatedAt: __, emailVerified: ___, ...rest } = me; return rest; } diff --git a/vitest.config.ts b/vitest.config.ts index bd4a307a..3dab85f6 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,12 +15,6 @@ export default defineConfig(async () => { include: ["./src/**/*.test.{ts,tsx}"], globalSetup: "./tests/vitest.setup.ts", environment: "jsdom", - // https://github.com/nextauthjs/next-auth/discussions/9385 - server: { - deps: { - inline: ["next"], - }, - }, }, }; });