diff --git a/examples/hackathon-submissions/migrations/20231214123430_new_auth/migration.sql b/examples/hackathon-submissions/migrations/20231214123430_new_auth/migration.sql new file mode 100644 index 0000000000..bfefb1ac48 --- /dev/null +++ b/examples/hackathon-submissions/migrations/20231214123430_new_auth/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Submission" ( + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "github" TEXT NOT NULL, + "description" TEXT NOT NULL, + "twitter" TEXT, + "country" TEXT, + "website" TEXT, + "image" TEXT, + "approved" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Submission_pkey" PRIMARY KEY ("name") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Submission_name_key" ON "Submission"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "Submission_email_key" ON "Submission"("email"); diff --git a/examples/hackathon-submissions/migrations/migration_lock.toml b/examples/hackathon-submissions/migrations/migration_lock.toml new file mode 100644 index 0000000000..fbffa92c2b --- /dev/null +++ b/examples/hackathon-submissions/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/examples/thoughts/main.wasp b/examples/thoughts/main.wasp index 8f76d8e1e6..b419160a8b 100644 --- a/examples/thoughts/main.wasp +++ b/examples/thoughts/main.wasp @@ -80,8 +80,6 @@ psl=} entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String thoughts Thought[] tags Tag[] diff --git a/examples/thoughts/migrations/20210513205603_/migration.sql b/examples/thoughts/migrations/20210513205603_/migration.sql deleted file mode 100644 index e8895ac357..0000000000 --- a/examples/thoughts/migrations/20210513205603_/migration.sql +++ /dev/null @@ -1,51 +0,0 @@ --- CreateTable -CREATE TABLE "Thought" ( - "id" SERIAL NOT NULL, - "textMarkdown" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" TIMESTAMP(3) NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Tag" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "_TagToThought" ( - "A" INTEGER NOT NULL, - "B" INTEGER NOT NULL -); - --- CreateIndex -CREATE UNIQUE INDEX "Tag.name_unique" ON "Tag"("name"); - --- CreateIndex -CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "_TagToThought_AB_unique" ON "_TagToThought"("A", "B"); - --- CreateIndex -CREATE INDEX "_TagToThought_B_index" ON "_TagToThought"("B"); - --- AddForeignKey -ALTER TABLE "_TagToThought" ADD FOREIGN KEY ("A") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "_TagToThought" ADD FOREIGN KEY ("B") REFERENCES "Thought"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/thoughts/migrations/20210713180928_attached_entities_to_user/migration.sql b/examples/thoughts/migrations/20210713180928_attached_entities_to_user/migration.sql deleted file mode 100644 index f31546132c..0000000000 --- a/examples/thoughts/migrations/20210713180928_attached_entities_to_user/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ -/* - Warnings: - - - Added the required column `userId` to the `Tag` table without a default value. This is not possible if the table is not empty. - - Added the required column `userId` to the `Thought` table without a default value. This is not possible if the table is not empty. - -*/ --- AlterTable -ALTER TABLE "Tag" ADD COLUMN "userId" INTEGER NOT NULL; - --- AlterTable -ALTER TABLE "Thought" ADD COLUMN "userId" INTEGER NOT NULL; - --- AddForeignKey -ALTER TABLE "Tag" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Thought" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/thoughts/migrations/20210713182512_tags_unique/migration.sql b/examples/thoughts/migrations/20210713182512_tags_unique/migration.sql deleted file mode 100644 index af1ec85284..0000000000 --- a/examples/thoughts/migrations/20210713182512_tags_unique/migration.sql +++ /dev/null @@ -1,11 +0,0 @@ -/* - Warnings: - - - A unique constraint covering the columns `[name,userId]` on the table `Tag` will be added. If there are existing duplicate values, this will fail. - -*/ --- DropIndex -DROP INDEX "Tag.name_unique"; - --- CreateIndex -CREATE UNIQUE INDEX "Tag.name_userId_unique" ON "Tag"("name", "userId"); diff --git a/examples/thoughts/migrations/20220818151104_rename_email_to_username/migration.sql b/examples/thoughts/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index a3815987a5..0000000000 --- a/examples/thoughts/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User.email_unique"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User.username_unique" ON "User"("username"); diff --git a/examples/thoughts/migrations/20220818165835_getting_up_to_date/migration.sql b/examples/thoughts/migrations/20220818165835_getting_up_to_date/migration.sql deleted file mode 100644 index 8456b0ed90..0000000000 --- a/examples/thoughts/migrations/20220818165835_getting_up_to_date/migration.sql +++ /dev/null @@ -1,17 +0,0 @@ --- DropForeignKey -ALTER TABLE "Tag" DROP CONSTRAINT "Tag_userId_fkey"; - --- DropForeignKey -ALTER TABLE "Thought" DROP CONSTRAINT "Thought_userId_fkey"; - --- AddForeignKey -ALTER TABLE "Thought" ADD CONSTRAINT "Thought_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- RenameIndex -ALTER INDEX "Tag.name_userId_unique" RENAME TO "Tag_name_userId_key"; - --- RenameIndex -ALTER INDEX "User.username_unique" RENAME TO "User_username_key"; diff --git a/examples/thoughts/migrations/20231214130619_new_auth/migration.sql b/examples/thoughts/migrations/20231214130619_new_auth/migration.sql new file mode 100644 index 0000000000..9b2973bbde --- /dev/null +++ b/examples/thoughts/migrations/20231214130619_new_auth/migration.sql @@ -0,0 +1,81 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Thought" ( + "id" SERIAL NOT NULL, + "textMarkdown" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Thought_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Tag" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" INTEGER NOT NULL, + + CONSTRAINT "Tag_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL, + "userId" INTEGER, + + CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") +); + +-- CreateTable +CREATE TABLE "_TagToThought" ( + "A" INTEGER NOT NULL, + "B" INTEGER NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Tag_name_userId_key" ON "Tag"("name", "userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_TagToThought_AB_unique" ON "_TagToThought"("A", "B"); + +-- CreateIndex +CREATE INDEX "_TagToThought_B_index" ON "_TagToThought"("B"); + +-- AddForeignKey +ALTER TABLE "Thought" ADD CONSTRAINT "Thought_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Tag" ADD CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TagToThought" ADD CONSTRAINT "_TagToThought_A_fkey" FOREIGN KEY ("A") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_TagToThought" ADD CONSTRAINT "_TagToThought_B_fkey" FOREIGN KEY ("B") REFERENCES "Thought"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/thoughts/src/client/TopNavbar.jsx b/examples/thoughts/src/client/TopNavbar.jsx index 3e988c4855..5e032a47a8 100644 --- a/examples/thoughts/src/client/TopNavbar.jsx +++ b/examples/thoughts/src/client/TopNavbar.jsx @@ -1,20 +1,24 @@ -import React from 'react' +import React from "react"; -import logout from '@wasp/auth/logout' +import logout from "@wasp/auth/logout"; -import './TopNavbar.css' +import "./TopNavbar.css"; +import { getUsername } from "@wasp/auth/user"; -const TopNavbar = (props) => { - const user = props.user +const TopNavbar = ({ user }) => { + const username = getUsername(user); return (
- { user.username } + {username}  |  - +
- ) -} + ); +}; -export default TopNavbar +export default TopNavbar; diff --git a/examples/todo-typescript/main.wasp b/examples/todo-typescript/main.wasp index bfb394c34e..cd84cad132 100644 --- a/examples/todo-typescript/main.wasp +++ b/examples/todo-typescript/main.wasp @@ -21,8 +21,6 @@ app TodoTypescript { // Then run `wasp db studio` to open Prisma Studio and view your db models entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/todo-typescript/migrations/20231012121747_initial/migration.sql b/examples/todo-typescript/migrations/20231012121747_initial/migration.sql deleted file mode 100644 index a94cea93d2..0000000000 --- a/examples/todo-typescript/migrations/20231012121747_initial/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER NOT NULL, - CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql b/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql new file mode 100644 index 0000000000..0ea8e16da6 --- /dev/null +++ b/examples/todo-typescript/migrations/20231214130914_new_auth/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER NOT NULL, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/todo-typescript/src/client/MainPage.tsx b/examples/todo-typescript/src/client/MainPage.tsx index b16d4242cf..44dd312539 100644 --- a/examples/todo-typescript/src/client/MainPage.tsx +++ b/examples/todo-typescript/src/client/MainPage.tsx @@ -9,7 +9,9 @@ import getTasks from "@wasp/queries/getTasks"; import createTask from "@wasp/actions/createTask"; import updateTask from "@wasp/actions/updateTask"; import deleteTasks from "@wasp/actions/deleteTasks"; -import type { Task, User } from "@wasp/entities"; +import type { Task } from "@wasp/entities"; +import type { User } from "@wasp/auth/types"; +import { getUsername } from "@wasp/auth/user"; export const MainPage = ({ user }: { user: User }) => { const { data: tasks, isLoading, error } = useQuery(getTasks); @@ -24,7 +26,7 @@ export const MainPage = ({ user }: { user: User }) => { wasp logo {user && (

- {user.username} + {getUsername(user)} {`'s tasks :)`}

)} diff --git a/examples/tutorials/TodoApp/main.wasp b/examples/tutorials/TodoApp/main.wasp index 821016f186..7389a43656 100644 --- a/examples/tutorials/TodoApp/main.wasp +++ b/examples/tutorials/TodoApp/main.wasp @@ -36,8 +36,6 @@ page LoginPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql b/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql deleted file mode 100644 index 7a12b36244..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220113204751_init/migration.sql +++ /dev/null @@ -1,18 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER, - FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); - --- CreateIndex -CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); diff --git a/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql b/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index a3815987a5..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User.email_unique"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User.username_unique" ON "User"("username"); diff --git a/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql b/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql deleted file mode 100644 index 215d194c3a..0000000000 --- a/examples/tutorials/TodoApp/migrations/20220818170255_getting_up_to_date/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- RedefineIndex -DROP INDEX "User.username_unique"; -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql b/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql new file mode 100644 index 0000000000..533c854ea7 --- /dev/null +++ b/examples/tutorials/TodoApp/migrations/20231214131517_new_auth/migration.sql @@ -0,0 +1,39 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql b/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql new file mode 100644 index 0000000000..969799968d --- /dev/null +++ b/examples/tutorials/TodoApp/migrations/20231214131607_remove_extra_auth_fields/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); +INSERT INTO "new_User" ("id") SELECT "id" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/examples/tutorials/TodoAppTs/main.wasp b/examples/tutorials/TodoAppTs/main.wasp index ffe78756cb..a6c25b7e62 100644 --- a/examples/tutorials/TodoAppTs/main.wasp +++ b/examples/tutorials/TodoAppTs/main.wasp @@ -36,8 +36,6 @@ page LoginPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String tasks Task[] psl=} diff --git a/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql deleted file mode 100644 index 32fc9d064f..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013154636_initial/migration.sql +++ /dev/null @@ -1,6 +0,0 @@ --- CreateTable -CREATE TABLE "Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false -); diff --git a/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql deleted file mode 100644 index bcc4dd7d09..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013155651_user/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "username" TEXT NOT NULL, - "password" TEXT NOT NULL -); - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql deleted file mode 100644 index c800c980bd..0000000000 --- a/examples/tutorials/TodoAppTs/migrations/20231013160107_users_tasks/migration.sql +++ /dev/null @@ -1,14 +0,0 @@ --- RedefineTables -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_Task" ( - "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, - "description" TEXT NOT NULL, - "isDone" BOOLEAN NOT NULL DEFAULT false, - "userId" INTEGER, - CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); -INSERT INTO "new_Task" ("description", "id", "isDone") SELECT "description", "id", "isDone" FROM "Task"; -DROP TABLE "Task"; -ALTER TABLE "new_Task" RENAME TO "Task"; -PRAGMA foreign_key_check; -PRAGMA foreign_keys=ON; diff --git a/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql b/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql new file mode 100644 index 0000000000..9e6e2345a3 --- /dev/null +++ b/examples/tutorials/TodoAppTs/migrations/20231214131753_new_auth/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); + +-- CreateTable +CREATE TABLE "Task" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "description" TEXT NOT NULL, + "isDone" BOOLEAN NOT NULL DEFAULT false, + "userId" INTEGER, + CONSTRAINT "Task_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/waspello/main.wasp b/examples/waspello/main.wasp index 1bafe9b287..321d149480 100644 --- a/examples/waspello/main.wasp +++ b/examples/waspello/main.wasp @@ -11,7 +11,6 @@ app waspello { auth: { userEntity: User, - externalAuthEntity: SocialLogin, methods: { usernameAndPassword: {}, google: {} @@ -47,21 +46,9 @@ page Login { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String + lists List[] cards Card[] - externalAuthAssociations SocialLogin[] -psl=} - -entity SocialLogin {=psl - id Int @id @default(autoincrement()) - provider String - providerId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - userId Int - createdAt DateTime @default(now()) - @@unique([provider, providerId, userId]) psl=} entity List {=psl diff --git a/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql b/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql deleted file mode 100644 index 203a88dfce..0000000000 --- a/examples/waspello/migrations/20211002184633_added_waspello_entities/migration.sql +++ /dev/null @@ -1,41 +0,0 @@ --- CreateTable -CREATE TABLE "User" ( - "id" SERIAL NOT NULL, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "List" ( - "id" SERIAL NOT NULL, - "name" TEXT NOT NULL, - "pos" DOUBLE PRECISION NOT NULL, - "userId" INTEGER NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "Card" ( - "id" SERIAL NOT NULL, - "title" TEXT NOT NULL, - "pos" DOUBLE PRECISION NOT NULL, - "listId" INTEGER NOT NULL, - "authorId" INTEGER NOT NULL, - - PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "User.email_unique" ON "User"("email"); - --- AddForeignKey -ALTER TABLE "List" ADD FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE CASCADE ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql b/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql deleted file mode 100644 index 893fbabf5a..0000000000 --- a/examples/waspello/migrations/20220211211659_upgrade_prisma_to_3_9_1/migration.sql +++ /dev/null @@ -1,20 +0,0 @@ --- DropForeignKey -ALTER TABLE "Card" DROP CONSTRAINT "Card_authorId_fkey"; - --- DropForeignKey -ALTER TABLE "Card" DROP CONSTRAINT "Card_listId_fkey"; - --- DropForeignKey -ALTER TABLE "List" DROP CONSTRAINT "List_userId_fkey"; - --- AddForeignKey -ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD CONSTRAINT "Card_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "Card" ADD CONSTRAINT "Card_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE RESTRICT ON UPDATE CASCADE; - --- RenameIndex -ALTER INDEX "User.email_unique" RENAME TO "User_email_key"; diff --git a/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql b/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql deleted file mode 100644 index 10182cf688..0000000000 --- a/examples/waspello/migrations/20220818151104_rename_email_to_username/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- DropIndex -DROP INDEX "User_email_key"; - --- AlterTable -ALTER TABLE "User" -RENAME COLUMN "email" TO "username"; - --- CreateIndex -CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); diff --git a/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql b/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql deleted file mode 100644 index 0ae1754d7d..0000000000 --- a/examples/waspello/migrations/20221122161320_added_social_login_with_google/migration.sql +++ /dev/null @@ -1,16 +0,0 @@ --- CreateTable -CREATE TABLE "SocialLogin" ( - "id" SERIAL NOT NULL, - "provider" TEXT NOT NULL, - "providerId" TEXT NOT NULL, - "userId" INTEGER NOT NULL, - "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "SocialLogin_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "SocialLogin_provider_providerId_userId_key" ON "SocialLogin"("provider", "providerId", "userId"); - --- AddForeignKey -ALTER TABLE "SocialLogin" ADD CONSTRAINT "SocialLogin_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/waspello/migrations/20231214132020_new_auth/migration.sql b/examples/waspello/migrations/20231214132020_new_auth/migration.sql new file mode 100644 index 0000000000..1649460c27 --- /dev/null +++ b/examples/waspello/migrations/20231214132020_new_auth/migration.sql @@ -0,0 +1,63 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" SERIAL NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "List" ( + "id" SERIAL NOT NULL, + "name" TEXT NOT NULL, + "pos" DOUBLE PRECISION NOT NULL, + "userId" INTEGER NOT NULL, + + CONSTRAINT "List_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Card" ( + "id" SERIAL NOT NULL, + "title" TEXT NOT NULL, + "pos" DOUBLE PRECISION NOT NULL, + "listId" INTEGER NOT NULL, + "authorId" INTEGER NOT NULL, + + CONSTRAINT "Card_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL, + "userId" INTEGER, + + CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- AddForeignKey +ALTER TABLE "List" ADD CONSTRAINT "List_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Card" ADD CONSTRAINT "Card_listId_fkey" FOREIGN KEY ("listId") REFERENCES "List"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Card" ADD CONSTRAINT "Card_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/examples/waspello/src/client/Navbar.jsx b/examples/waspello/src/client/Navbar.jsx index 8f109cf7d9..ca0a7de9bf 100644 --- a/examples/waspello/src/client/Navbar.jsx +++ b/examples/waspello/src/client/Navbar.jsx @@ -1,29 +1,34 @@ -import React from 'react' +import React from "react"; -import logout from '@wasp/auth/logout' +import logout from "@wasp/auth/logout"; -import logo from './waspello-logo-navbar.svg' -import './Navbar.css' +import logo from "./waspello-logo-navbar.svg"; +import "./Navbar.css"; +import { getName } from "./user"; const Navbar = ({ user }) => { + const name = getName(user); return (
-
+
Home
Waspello -
+
- { user.username } + {name}  |  - +
- ) -} + ); +}; -export default Navbar +export default Navbar; diff --git a/examples/waspello/src/client/user.ts b/examples/waspello/src/client/user.ts new file mode 100644 index 0000000000..e0dcfa300d --- /dev/null +++ b/examples/waspello/src/client/user.ts @@ -0,0 +1,19 @@ +import { User } from "@wasp/auth/types"; +import { findUserIdentity, getUsername } from "@wasp/auth/user"; + +export function getName(user: User): string { + // We have two ways of authenticating users, so + // we have to check which one is used. + const googleIdentity = findUserIdentity(user, "google"); + const usernameIdentity = findUserIdentity(user, "username"); + + if (usernameIdentity) { + return getUsername(user); + } + + if (googleIdentity) { + return `Google user ${googleIdentity.providerUserId}`; + } + + return "Unknown user"; +} diff --git a/examples/websockets-realtime-voting/main.wasp b/examples/websockets-realtime-voting/main.wasp index d0bad794f5..4ce3bf7ebc 100644 --- a/examples/websockets-realtime-voting/main.wasp +++ b/examples/websockets-realtime-voting/main.wasp @@ -24,8 +24,6 @@ app whereDoWeEat { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String psl=} route RootRoute { path: "/", to: MainPage } diff --git a/examples/websockets-realtime-voting/migrations/20231214111940_new_auth/migration.sql b/examples/websockets-realtime-voting/migrations/20231214111940_new_auth/migration.sql new file mode 100644 index 0000000000..fd930305e2 --- /dev/null +++ b/examples/websockets-realtime-voting/migrations/20231214111940_new_auth/migration.sql @@ -0,0 +1,30 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + "username" TEXT NOT NULL, + "password" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" INTEGER, + CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + PRIMARY KEY ("providerName", "providerUserId"), + CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_username_key" ON "User"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); diff --git a/examples/websockets-realtime-voting/migrations/20231214112306_remove_extra_user_fields/migration.sql b/examples/websockets-realtime-voting/migrations/20231214112306_remove_extra_user_fields/migration.sql new file mode 100644 index 0000000000..969799968d --- /dev/null +++ b/examples/websockets-realtime-voting/migrations/20231214112306_remove_extra_user_fields/migration.sql @@ -0,0 +1,17 @@ +/* + Warnings: + + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. + +*/ +-- RedefineTables +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT +); +INSERT INTO "new_User" ("id") SELECT "id" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +PRAGMA foreign_key_check; +PRAGMA foreign_keys=ON; diff --git a/examples/websockets-realtime-voting/migrations/migration_lock.toml b/examples/websockets-realtime-voting/migrations/migration_lock.toml new file mode 100644 index 0000000000..e5e5c4705a --- /dev/null +++ b/examples/websockets-realtime-voting/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "sqlite" \ No newline at end of file diff --git a/examples/websockets-realtime-voting/src/client/Layout.jsx b/examples/websockets-realtime-voting/src/client/Layout.jsx index dc3de690a6..ac42770a0d 100644 --- a/examples/websockets-realtime-voting/src/client/Layout.jsx +++ b/examples/websockets-realtime-voting/src/client/Layout.jsx @@ -4,6 +4,7 @@ import "./Main.css"; import { Flowbite, Dropdown, Navbar, Avatar } from "flowbite-react"; import Logo from "./logo.png"; import useAuth from "@wasp/auth/useAuth"; +import { getUsername } from "@wasp/auth/user"; import logout from "@wasp/auth/logout"; const customTheme = { @@ -11,21 +12,18 @@ const customTheme = { color: { primary: "bg-red-500 hover:bg-red-600", }, - } + }, }; export const Layout = ({ children }) => { const { data: user } = useAuth(); + return (
- Fox Logo + Fox Logo Undecisive Fox App @@ -37,13 +35,15 @@ export const Layout = ({ children }) => { label={ } > - {user.username} + {getUsername(user)} Dashboard Settings diff --git a/examples/websockets-realtime-voting/src/server/ws-server.ts b/examples/websockets-realtime-voting/src/server/ws-server.ts index 07e2b1b60a..8a911f4006 100644 --- a/examples/websockets-realtime-voting/src/server/ws-server.ts +++ b/examples/websockets-realtime-voting/src/server/ws-server.ts @@ -1,33 +1,29 @@ -import { WebSocketDefinition } from "@wasp/webSocket" -import { User } from "@wasp/entities" +import { WebSocketDefinition } from "@wasp/webSocket"; +import { getUsername } from "@wasp/auth/user.js"; type PollState = { - question: string + question: string; options: { - id: number - text: string - description: string - votes: string[] - }[] -} + id: number; + text: string; + description: string; + votes: string[]; + }[]; +}; interface ServerToClientEvents { - updateState: (state: PollState) => void + updateState: (state: PollState) => void; } interface ClientToServerEvents { vote: (optionId: number) => void; askForStateUpdate: () => void; } interface InterServerEvents {} -interface SocketData { - user: User; -} export const webSocketFn: WebSocketDefinition< -ClientToServerEvents, -ServerToClientEvents, -InterServerEvents, -SocketData + ClientToServerEvents, + ServerToClientEvents, + InterServerEvents > = (io, context) => { const poll: PollState = { question: "What are eating for lunch ✨ Let's order", @@ -52,33 +48,40 @@ SocketData }, ], }; - io.on('connection', (socket) => { + io.on("connection", (socket) => { if (!socket.data.user) { - console.log('Socket connected without user'); + console.log("Socket connected without user"); return; } - console.log('Socket connected: ', socket.data.user?.username); + const connectionUsername = getUsername(socket.data.user); + + console.log("Socket connected: ", connectionUsername); socket.on("askForStateUpdate", () => { - socket.emit('updateState', poll); + socket.emit("updateState", poll); }); socket.on("vote", (optionId) => { + if (!connectionUsername) { + return; + } // If user has already voted, remove their vote. poll.options.forEach((option) => { - option.votes = option.votes.filter((username) => username !== socket.data.user.username); + option.votes = option.votes.filter( + (username) => username !== connectionUsername + ); }); // And then add their vote to the new option. const option = poll.options.find((o) => o.id === optionId); if (!option) { return; } - option.votes.push(socket.data.user.username); - io.emit('updateState', poll); + option.votes.push(connectionUsername); + io.emit("updateState", poll); }); - socket.on('disconnect', () => { - console.log('Socket disconnected: ', socket.data.user?.username); + socket.on("disconnect", () => { + console.log("Socket disconnected: ", connectionUsername ?? "unknown"); }); }); -} \ No newline at end of file +}; diff --git a/waspc/data/Generator/templates/react-app/src/auth/types.ts b/waspc/data/Generator/templates/react-app/src/auth/types.ts index b9614390c4..4405410cc7 100644 --- a/waspc/data/Generator/templates/react-app/src/auth/types.ts +++ b/waspc/data/Generator/templates/react-app/src/auth/types.ts @@ -1,2 +1,2 @@ // todo(filip): turn into a proper import/path -export type { SanitizedUser as User } from '../../../server/src/_types/' +export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/' diff --git a/waspc/data/Generator/templates/react-app/src/auth/user.ts b/waspc/data/Generator/templates/react-app/src/auth/user.ts new file mode 100644 index 0000000000..5799c71ea7 --- /dev/null +++ b/waspc/data/Generator/templates/react-app/src/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { User, ProviderName, DeserializedAuthEntity } from './types' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/data/Generator/templates/react-app/src/entities/index.ts b/waspc/data/Generator/templates/react-app/src/entities/index.ts index a6b0269ac0..27d4ebc25e 100644 --- a/waspc/data/Generator/templates/react-app/src/entities/index.ts +++ b/waspc/data/Generator/templates/react-app/src/entities/index.ts @@ -9,6 +9,10 @@ export type { {=# entities =} {= name =}, {=/ entities =} + {=# isAuthEnabled =} + {= authEntityName =}, + {= authIdentityEntityName =}, + {=/ isAuthEnabled =} } from '@prisma/client' export type Entity = diff --git a/waspc/data/Generator/templates/server/src/_types/index.ts b/waspc/data/Generator/templates/server/src/_types/index.ts index 1c5562f5ab..2b3c6bf5ae 100644 --- a/waspc/data/Generator/templates/server/src/_types/index.ts +++ b/waspc/data/Generator/templates/server/src/_types/index.ts @@ -4,7 +4,16 @@ import { type Request, type Response } from 'express' import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core' import prisma from "../dbClient.js" {=# isAuthEnabled =} -import { type {= userEntityName =} } from "../entities" +import { + type {= userEntityName =}, + type {= authEntityName =}, + type {= authIdentityEntityName =}, +} from "../entities" +import { + type EmailProviderData, + type UsernameProviderData, + type OAuthProviderData, +} from '../auth/utils.js' {=/ isAuthEnabled =} import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; @@ -77,11 +86,22 @@ type Context = Expand<{ }> {=# isAuthEnabled =} -type ContextWithUser = Expand & { user?: SanitizedUser}> +type ContextWithUser = Expand & { user?: SanitizedUser }> // TODO: This type must match the logic in core/auth.js (if we remove the // password field from the object there, we must do the same here). Ideally, // these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 -export type SanitizedUser = Omit<{= userEntityName =}, 'password'> + +export type DeserializedAuthEntity = Expand & { + providerData: Omit | Omit | OAuthProviderData +}> + +export type SanitizedUser = {= userEntityName =} & { + {= authFieldOnUserEntityName =}: {= authEntityName =} & { + {= identitiesFieldOnAuthEntityName =}: DeserializedAuthEntity[] + } | null +} + +export type { ProviderName } from '../auth/utils.js' {=/ isAuthEnabled =} diff --git a/waspc/data/Generator/templates/server/src/auth/index.ts b/waspc/data/Generator/templates/server/src/auth/index.ts index 374824b697..cf74b0773e 100644 --- a/waspc/data/Generator/templates/server/src/auth/index.ts +++ b/waspc/data/Generator/templates/server/src/auth/index.ts @@ -1,7 +1 @@ -{{={= =}=}} -{=# isEmailAuthEnabled =} -export { defineAdditionalSignupFields } from './providers/email/types.js'; -{=/ isEmailAuthEnabled =} -{=# isLocalAuthEnabled =} -export { defineAdditionalSignupFields } from './providers/local/types.js'; -{=/ isLocalAuthEnabled =} \ No newline at end of file +export { defineAdditionalSignupFields } from './providers/types.js'; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts index 5d87d81976..a397bfa4be 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/_oauth.ts @@ -11,7 +11,7 @@ import type { OAuthConfig } from "../oauth/types.js"; const _waspGetUserFieldsFn = {= userFieldsFn.importIdentifier =} {=/ userFieldsFn.isDefined =} {=^ userFieldsFn.isDefined =} -import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js' +const _waspGetUserFieldsFn = undefined {=/ userFieldsFn.isDefined =} {=# configFn.isDefined =} {=& configFn.importStatement =} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts index 220857004c..ef327d20b5 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/email.ts @@ -62,6 +62,7 @@ const config: ProviderConfig = { fromField, clientRoute: '{= emailVerificationClientRoute =}', getVerificationEmailContent: _waspGetVerificationEmailContent, + allowUnverifiedLogin: {=# allowUnverifiedLogin =}true{=/ allowUnverifiedLogin =}{=^ allowUnverifiedLogin =}false{=/ allowUnverifiedLogin =}, })); router.post('/signup', signupRoute); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/config/local.ts b/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts similarity index 81% rename from waspc/data/Generator/templates/server/src/auth/providers/config/local.ts rename to waspc/data/Generator/templates/server/src/auth/providers/config/username.ts index 92496bfb18..f7b78ed83b 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/config/local.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/config/username.ts @@ -2,8 +2,8 @@ import { Router } from "express"; -import login from "../local/login.js"; -import signup from "../local/signup.js"; +import login from "../username/login.js"; +import signup from "../username/signup.js"; import { ProviderConfig } from "../types.js"; const config: ProviderConfig = { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts index fa53896468..b50422e3a6 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/login.ts @@ -1,6 +1,12 @@ import { Request, Response } from 'express'; import { verifyPassword, throwInvalidCredentialsError } from "../../../core/auth.js"; -import { findUserBy, createAuthToken } from "../../utils.js"; +import { + createProviderId, + findAuthIdentity, + findAuthWithUserBy, + createAuthToken, + deserializeAndSanitizeProviderData, +} from "../../utils.js"; import { ensureValidEmail, ensurePasswordIsPresent } from "../../validation.js"; export function getLoginRoute({ @@ -12,25 +18,27 @@ export function getLoginRoute({ req: Request<{ email: string; password: string; }>, res: Response, ): Promise> { - const userFields = req.body || {} - ensureValidArgs(userFields) + const fields = req.body ?? {} + ensureValidArgs(fields) - userFields.email = userFields.email.toLowerCase() - - const user = await findUserBy({ email: userFields.email }) - if (!user) { + const authIdentity = await findAuthIdentity( + createProviderId("email", fields.email) + ) + if (!authIdentity) { throwInvalidCredentialsError() } - if (!user.isEmailVerified && !allowUnverifiedLogin) { + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData) + if (!providerData.isEmailVerified && !allowUnverifiedLogin) { throwInvalidCredentialsError() } try { - await verifyPassword(user.password, userFields.password); + await verifyPassword(providerData.hashedPassword, fields.password); } catch(e) { throwInvalidCredentialsError() } - const token = await createAuthToken(user) + const auth = await findAuthWithUserBy({ id: authIdentity.authId }) + const token = await createAuthToken(auth.userId) return res.json({ token }) }; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts index e665240dd2..c0936bac59 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/requestPasswordReset.ts @@ -1,7 +1,9 @@ import { Request, Response } from 'express'; import { - findUserBy, + createProviderId, + findAuthIdentity, doFakeWork, + deserializeAndSanitizeProviderData, } from "../../utils.js"; import { createPasswordResetLink, @@ -11,6 +13,7 @@ import { import { ensureValidEmail } from "../../validation.js"; import type { EmailFromField } from '../../../email/core/types.js'; import { GetPasswordResetEmailContentFn } from './types.js'; +import HttpError from '../../../core/HttpError.js'; export function getRequestPasswordResetRoute({ fromField, @@ -24,39 +27,47 @@ export function getRequestPasswordResetRoute({ return async function requestPasswordReset( req: Request<{ email: string; }>, res: Response, - ): Promise> { - const args = req.body || {}; + ): Promise> { + const args = req.body ?? {}; ensureValidEmail(args); - args.email = args.email.toLowerCase(); + const authIdentity = await findAuthIdentity( + createProviderId("email", args.email), + ); - const user = await findUserBy({ email: args.email }); - - // User not found or not verified - don't leak information - if (!user || !user.isEmailVerified) { + /** + * By doing fake work, we make it harder to enumerate users by measuring + * the time it takes to respond. If we would respond immediately, an attacker + * could measure the time it takes to respond and figure out if the user exists. + */ + + if (!authIdentity) { await doFakeWork(); return res.json({ success: true }); } - if (!isEmailResendAllowed(user, 'passwordResetSentAt')) { - return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + const { isResendAllowed, timeLeft } = isEmailResendAllowed(providerData, 'passwordResetSentAt'); + if (!isResendAllowed) { + throw new HttpError(400, `Please wait ${timeLeft} secs before trying again.`); } - - const passwordResetLink = await createPasswordResetLink(user, clientRoute); + + const passwordResetLink = await createPasswordResetLink(args.email, clientRoute); try { + const email = authIdentity.providerUserId await sendPasswordResetEmail( - user.email, + email, { from: fromField, - to: user.email, + to: email, ...getPasswordResetEmailContent({ passwordResetLink }), - } + }, ); } catch (e: any) { console.error("Failed to send password reset email:", e); - return res.status(500).json({ success: false, message: "Failed to send password reset email." }); + throw new HttpError(500, "Failed to send password reset email."); } - res.json({ success: true }); + return res.json({ success: true }); }; } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts index d7dce9f0e9..3f01d47c32 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/resetPassword.ts @@ -1,31 +1,48 @@ import { Request, Response } from 'express'; -import { findUserBy, verifyToken } from "../../utils.js"; -import { updateUserPassword } from "./utils.js"; +import { + createProviderId, + findAuthIdentity, + updateAuthIdentityProviderData, + verifyToken, + deserializeAndSanitizeProviderData, +} from "../../utils.js"; import { ensureTokenIsPresent, ensurePasswordIsPresent, ensureValidPassword } from "../../validation.js"; import { tokenVerificationErrors } from "./types.js"; +import HttpError from '../../../core/HttpError.js'; export async function resetPassword( req: Request<{ token: string; password: string; }>, res: Response, -): Promise> { - const args = req.body || {}; +): Promise> { + const args = req.body ?? {}; ensureValidArgs(args); const { token, password } = args; try { - const { id: userId } = await verifyToken(token); - const user = await findUserBy({ id: userId }); - if (!user) { - return res.status(400).json({ success: false, message: 'Invalid token' }); + const { email } = await verifyToken<{ email: string }>(token); + + const providerId = createProviderId('email', email); + const authIdentity = await findAuthIdentity(providerId); + if (!authIdentity) { + throw new HttpError(400, "Password reset failed, invalid token"); } - await updateUserPassword(userId, password); + + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + + await updateAuthIdentityProviderData(providerId, providerData, { + // The act of resetting the password verifies the email + isEmailVerified: true, + // The password will be hashed when saving the providerData + // in the DB + hashedPassword: password, + }); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' : 'invalid'; - return res.status(400).json({ success: false, message: `Password reset failed, ${reason} token`}); + throw new HttpError(400, `Password reset failed, ${reason} token`); } - res.json({ success: true }); + return res.json({ success: true }); }; function ensureValidArgs(args: unknown): void { diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts index 3ad9d795a2..e6755e2b47 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/signup.ts @@ -2,9 +2,13 @@ import { Request, Response } from 'express'; import { EmailFromField } from "../../../email/core/types.js"; import { createUser, - findUserBy, - deleteUser, + createProviderId, + findAuthIdentity, + deleteUserByAuthId, doFakeWork, + deserializeAndSanitizeProviderData, + sanitizeAndSerializeProviderData, + rethrowPossibleAuthError, } from "../../utils.js"; import { createEmailVerificationLink, @@ -14,58 +18,123 @@ import { import { ensureValidEmail, ensureValidPassword, ensurePasswordIsPresent } from "../../validation.js"; import { GetVerificationEmailContentFn } from './types.js'; import { validateAndGetAdditionalFields } from '../../utils.js' +import HttpError from '../../../core/HttpError.js'; export function getSignupRoute({ fromField, clientRoute, getVerificationEmailContent, + allowUnverifiedLogin, }: { fromField: EmailFromField; clientRoute: string; getVerificationEmailContent: GetVerificationEmailContentFn; + allowUnverifiedLogin: boolean; }) { return async function signup( req: Request<{ email: string; password: string; }>, res: Response, - ): Promise> { - const userFields = req.body; - ensureValidArgs(userFields); + ): Promise> { + const fields = req.body; + ensureValidArgs(fields); - userFields.email = userFields.email.toLowerCase(); + const providerId = createProviderId("email", fields.email); + const existingAuthIdentity = await findAuthIdentity(providerId); - const existingUser = await findUserBy({ email: userFields.email }); - // User already exists and is verified - don't leak information - if (existingUser && existingUser.isEmailVerified) { - await doFakeWork(); - return res.json({ success: true }); - } else if (existingUser && !existingUser.isEmailVerified) { - if (!isEmailResendAllowed(existingUser, 'emailVerificationSentAt')) { - return res.status(400).json({ success: false, message: "Please wait a minute before trying again." }); + /** + * + * There are two cases to consider in the case of an existing user: + * - if we allow unverified login + * - if the user is already verified + * + * Let's see what happens when we **don't** allow unverified login: + * + * We are handling the case of an existing auth identity in two ways: + * + * 1. If the user already exists and is verified, we don't want + * to leak that piece of info and instead we pretend that the user + * was created successfully. + * - This prevents the attacker from learning which emails already have + * an account created. + * + * 2. If the user is not verified: + * - We check when we last sent a verification email and if it was less than X seconds ago, + * we don't send another one. + * - If it was more than X seconds ago, we delete the user and create a new one. + * - This prevents the attacker from creating an account with somebody + * else's email address and therefore permanently making that email + * address unavailable for later account creation (by real owner). + */ + if (existingAuthIdentity) { + if (allowUnverifiedLogin) { + /** + * This is the case where we allow unverified login. + * + * If we pretended that the user was created successfully that would bring + * us little value: the attacker would not be able to login and figure out + * if the user exists or not, anyway. + * + * So, we throw an error that says that the user already exists. + */ + throw new HttpError(422, "User with that email already exists.") + } + + const providerData = deserializeAndSanitizeProviderData<'email'>(existingAuthIdentity.providerData); + + // TOOD: faking work makes sense if the time spent on faking the work matches the time + // it would take to send the email. Atm, the fake work takes obviously longer than sending + // the email! + if (providerData.isEmailVerified) { + await doFakeWork(); + return res.json({ success: true }); + } + + // TODO: we are still leaking information here since when we are faking work + // we are not checking if the email was sent or not! + const { isResendAllowed, timeLeft } = isEmailResendAllowed(providerData, 'passwordResetSentAt'); + if (!isResendAllowed) { + throw new HttpError(400, `Please wait ${timeLeft} secs before trying again.`); + } + + try { + await deleteUserByAuthId(existingAuthIdentity.authId); + } catch (e: unknown) { + rethrowPossibleAuthError(e); } - await deleteUser(existingUser); } - const additionalFields = await validateAndGetAdditionalFields(userFields); - - const user = await createUser({ - ...additionalFields, - email: userFields.email, - password: userFields.password, + const userFields = await validateAndGetAdditionalFields(fields); + + const newUserProviderData = await sanitizeAndSerializeProviderData<'email'>({ + hashedPassword: fields.password, + isEmailVerified: false, + emailVerificationSentAt: null, + passwordResetSentAt: null, }); - const verificationLink = await createEmailVerificationLink(user, clientRoute); + try { + await createUser( + providerId, + newUserProviderData, + userFields, + ); + } catch (e: unknown) { + rethrowPossibleAuthError(e); + } + + const verificationLink = await createEmailVerificationLink(fields.email, clientRoute); try { await sendEmailVerificationEmail( - userFields.email, + fields.email, { from: fromField, - to: userFields.email, + to: fields.email, ...getVerificationEmailContent({ verificationLink }), } ); - } catch (e: any) { + } catch (e: unknown) { console.error("Failed to send email verification email:", e); - return res.status(500).json({ success: false, message: "Failed to send email verification email." }); + throw new HttpError(500, "Failed to send email verification email."); } return res.json({ success: true }); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts index d1a7bc4a13..f213c80e93 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/types.ts @@ -1,5 +1,3 @@ -import { createDefineAdditionalSignupFieldsFn } from '../types.js' - export type GetVerificationEmailContentFn = (params: { verificationLink: string }) => EmailContent; export type GetPasswordResetEmailContentFn = (params: { passwordResetLink: string }) => EmailContent; @@ -13,5 +11,3 @@ type EmailContent = { export const tokenVerificationErrors = { TokenExpiredError: 'TokenExpiredError', }; - -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"email" | "password">() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts index 814325e181..a158b91482 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/utils.ts @@ -1,99 +1,94 @@ {{={= =}=}} -import { sign } from '../../../core/auth.js' +import { signData } from '../../../core/auth.js' import { emailSender } from '../../../email/index.js'; import { Email } from '../../../email/core/types.js'; -import { rethrowPossiblePrismaError } from '../../utils.js' -import prisma from '../../../dbClient.js' +import { + createProviderId, + updateAuthIdentityProviderData, + findAuthIdentity, + deserializeAndSanitizeProviderData, + type EmailProviderData, +} from '../../utils.js'; import waspServerConfig from '../../../config.js'; -import { type {= userEntityUpper =} } from '../../../entities/index.js' +import { type {= userEntityUpper =}, type {= authEntityUpper =} } from '../../../entities/index.js' -type {= userEntityUpper =}Id = {= userEntityUpper =}['id'] - -export async function updateUserEmailVerification(userId: {= userEntityUpper =}Id): Promise { - try { - await prisma.{= userEntityLower =}.update({ - where: { id: userId }, - data: { isEmailVerified: true }, - }) - } catch (e) { - rethrowPossiblePrismaError(e); - } -} - -export async function updateUserPassword(userId: {= userEntityUpper =}Id, password: string): Promise { - try { - await prisma.{= userEntityLower =}.update({ - where: { id: userId }, - data: { password }, - }) - } catch (e) { - rethrowPossiblePrismaError(e); - } -} - -export async function createEmailVerificationLink(user: {= userEntityUpper =}, clientRoute: string): Promise { - const token = await createEmailVerificationToken(user); - return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; -} - -export async function createPasswordResetLink(user: {= userEntityUpper =}, clientRoute: string): Promise { - const token = await createPasswordResetToken(user); - return `${waspServerConfig.frontendUrl}${clientRoute}?token=${token}`; +export async function createEmailVerificationLink( + email: string, + clientRoute: string, +): Promise { + const { jwtToken } = await createEmailJwtToken(email); + return `${waspServerConfig.frontendUrl}${clientRoute}?token=${jwtToken}`; } -async function createEmailVerificationToken(user: {= userEntityUpper =}): Promise { - return sign(user.id, { expiresIn: '30m' }); +export async function createPasswordResetLink( + email: string, + clientRoute: string, +): Promise { + const { jwtToken } = await createEmailJwtToken(email); + return `${waspServerConfig.frontendUrl}${clientRoute}?token=${jwtToken}`; } -async function createPasswordResetToken(user: {= userEntityUpper =}): Promise { - return sign(user.id, { expiresIn: '30m' }); +async function createEmailJwtToken(email: string): Promise<{ jwtToken: string; }> { + const jwtToken = await signData({ email }, { expiresIn: '30m' }); + return { jwtToken }; } export async function sendPasswordResetEmail( email: string, content: Email, ): Promise { - return sendEmailAndLogTimestamp(email, content, 'passwordResetSentAt'); + return sendEmailAndSaveMetadata(email, content, { + passwordResetSentAt: (new Date()).toISOString(), + }); } export async function sendEmailVerificationEmail( email: string, content: Email, ): Promise { - return sendEmailAndLogTimestamp(email, content, 'emailVerificationSentAt'); + return sendEmailAndSaveMetadata(email, content, { + emailVerificationSentAt: (new Date()).toISOString(), + }); } -async function sendEmailAndLogTimestamp( +async function sendEmailAndSaveMetadata( email: string, content: Email, - field: 'emailVerificationSentAt' | 'passwordResetSentAt', + metadata: Partial, ): Promise { - // Set the timestamp first, and then send the email - // so the user can't send multiple requests while - // the email is being sent. - try { - await prisma.{= userEntityLower =}.update({ - where: { email }, - data: { [field]: new Date() }, - }) - } catch (e) { - rethrowPossiblePrismaError(e); - } + // Save the metadata (e.g. timestamp) first, and then send the email + // so the user can't send multiple requests while the email is being sent. + const providerId = createProviderId("email", email); + const authIdentity = await findAuthIdentity(providerId); + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + await updateAuthIdentityProviderData<'email'>(providerId, providerData, metadata); + emailSender.send(content).catch((e) => { - console.error(`Failed to send email for ${field}`, e); + console.error('Failed to send email', e); }); } -export function isEmailResendAllowed( - user: {= userEntityUpper =}, - field: 'emailVerificationSentAt' | 'passwordResetSentAt', +export function isEmailResendAllowed( + fields: { + [field in Field]: string | null + }, + field: Field, resendInterval: number = 1000 * 60, -): boolean { - const sentAt = user[field]; +): { + isResendAllowed: boolean; + timeLeft: number; +} { + const sentAt = fields[field]; if (!sentAt) { - return true; + return { + isResendAllowed: true, + timeLeft: 0, + }; } const now = new Date(); - const diff = now.getTime() - sentAt.getTime(); - return diff > resendInterval; + const diff = now.getTime() - new Date(sentAt).getTime(); + const isResendAllowed = diff > resendInterval; + // Time left in seconds + const timeLeft = isResendAllowed ? 0 : Math.round((resendInterval - diff) / 1000); + return { isResendAllowed, timeLeft }; } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts index e152591e41..7dc52d2576 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/email/verifyEmail.ts @@ -1,21 +1,35 @@ import { Request, Response } from 'express'; -import { updateUserEmailVerification } from './utils.js'; -import { verifyToken } from '../../utils.js'; +import { + verifyToken, + createProviderId, + findAuthIdentity, + updateAuthIdentityProviderData, + deserializeAndSanitizeProviderData, +} from '../../utils.js'; import { tokenVerificationErrors } from './types.js'; +import HttpError from '../../../core/HttpError.js'; + export async function verifyEmail( req: Request<{ token: string }>, res: Response, -): Promise> { +): Promise> { try { const { token } = req.body; - const { id: userId } = await verifyToken(token); - await updateUserEmailVerification(userId); + const { email } = await verifyToken<{ email: string }>(token); + + const providerId = createProviderId('email', email); + const authIdentity = await findAuthIdentity(providerId); + const providerData = deserializeAndSanitizeProviderData<'email'>(authIdentity.providerData); + + await updateAuthIdentityProviderData(providerId, providerData, { + isEmailVerified: true, + }); } catch (e) { const reason = e.name === tokenVerificationErrors.TokenExpiredError ? 'expired' : 'invalid'; - return res.status(400).json({ success: false, message: `Token is ${reason}` }); + throw new HttpError(400, `Token is ${reason}`); } return res.json({ success: true }); diff --git a/waspc/data/Generator/templates/server/src/auth/providers/index.ts b/waspc/data/Generator/templates/server/src/auth/providers/index.ts index 952abdb621..f3e3326fbe 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/index.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/index.ts @@ -1,17 +1,18 @@ {{={= =}=}} +import { join } from 'path' import { Router } from "express"; -import { getDirFromFileUrl, importJsFilesFromDir } from "../../utils.js"; +import { getDirPathFromFileUrl, importJsFilesFromDir } from "../../utils.js"; import { ProviderConfig } from "./types"; -const allowedConfigs = [ +const whitelistedProviderConfigFileNames = [ {=# enabledProviderIds =} "{= . =}.js", {=/ enabledProviderIds =} ]; -const providers = await importProviders(allowedConfigs); +const providers = await importProviders(whitelistedProviderConfigFileNames); const router = Router(); @@ -27,8 +28,9 @@ for (const provider of providers) { export default router; -async function importProviders(providerConfigs: string[]): Promise { - const currentExecutionDir = getDirFromFileUrl(import.meta.url); - const providers = await importJsFilesFromDir(currentExecutionDir, "./config", providerConfigs); +async function importProviders(whitelistedProviderConfigFileNames: string[]): Promise { + const currentExecutionDir = getDirPathFromFileUrl(import.meta.url); + const pathToDirWithConfigs = join(currentExecutionDir, "./config"); + const providers = await importJsFilesFromDir(pathToDirWithConfigs, whitelistedProviderConfigFileNames); return providers.map((provider) => provider.default); } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts deleted file mode 100644 index af566844eb..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/login.ts +++ /dev/null @@ -1,37 +0,0 @@ -{{={= =}=}} -import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js' -import { handleRejection } from '../../../utils.js' - -import { findUserBy, createAuthToken } from '../../utils.js' -import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' - -export default handleRejection(async (req, res) => { - const userFields = req.body || {} - ensureValidArgs(userFields) - - const user = await findUserBy({ username: userFields.username }) - if (!user) { - throwInvalidCredentialsError() - } - - try { - await verifyPassword(user.password, userFields.password) - } catch(e) { - throwInvalidCredentialsError() - } - - // Username & password valid - generate token. - const token = await createAuthToken(user) - - // NOTE(matija): Possible option - instead of explicitly returning token here, - // we could add to response header 'Set-Cookie {token}' directive which would then make - // browser automatically save cookie with token. - // NOTE(shayne): Cross-domain cookies have serious limitations, which we recently explored. - - return res.json({ token }) -}) - -function ensureValidArgs(args: unknown): void { - ensureValidUsername(args); - ensurePasswordIsPresent(args); -} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts deleted file mode 100644 index 8705e411d1..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/signup.ts +++ /dev/null @@ -1,26 +0,0 @@ -{{={= =}=}} -import { handleRejection } from '../../../utils.js' -import { createUser } from '../../utils.js' -import { ensureValidUsername, ensurePasswordIsPresent, ensureValidPassword } from '../../validation.js' -import { validateAndGetAdditionalFields } from '../../utils.js' - -export default handleRejection(async (req, res) => { - const userFields = req.body || {} - ensureValidArgs(userFields) - - const additionalFields = await validateAndGetAdditionalFields(userFields) - - await createUser({ - ...additionalFields, - username: userFields.username, - password: userFields.password, - }) - - return res.json({ success: true }) -}) - -function ensureValidArgs(args: unknown): void { - ensureValidUsername(args); - ensurePasswordIsPresent(args); - ensureValidPassword(args); -} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts deleted file mode 100644 index 5d72e7d7ab..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/local/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { createDefineAdditionalSignupFieldsFn } from '../types.js' - -export const defineAdditionalSignupFields = createDefineAdditionalSignupFieldsFn<"username" | "password">() diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts index aafb489ac5..d8a4fe0b62 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/createRouter.ts @@ -2,21 +2,29 @@ import { Router } from "express" import passport from "passport" -import { v4 as uuidv4 } from 'uuid' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' -import { sign } from '../../../core/auth.js' -import { authConfig, contextWithUserEntity, createUser } from "../../utils.js" - -import type { {= userEntityUpper =} } from '../../../entities'; +import { + type ProviderName, + type ProviderId, + createProviderId, + authConfig, + contextWithUserEntity, + createUser, + findAuthWithUserBy, + createAuthToken, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, +} from "../../utils.js" +import { type {= userEntityUpper =} } from "../../../entities/index.js" import type { ProviderConfig, RequestWithWasp } from "../types.js" import type { GetUserFieldsFn } from "./types.js" import { handleRejection } from "../../../utils.js" // For oauth providers, we have an endpoint /login to get the auth URL, // and the /callback endpoint which is used to get the actual access_token and the user info. -export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn: GetUserFieldsFn }) { +export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { const { passportStrategyName, getUserFieldsFn } = initData; const router = Router(); @@ -42,48 +50,57 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`); } - // Wrap call to getUserFieldsFn so we can invoke only if needed. - const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }); - // TODO: In the future we could make this configurable, possibly associating an external account - // with the currently logged in account, or by some DB lookup. - const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields); + const providerId = createProviderId(provider.id, providerProfile.id); - const token = await sign(user.id); - res.json({ token }); + try { + const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn) + const token = await createAuthToken(userId) + res.json({ token }) + } catch (e) { + rethrowPossibleAuthError(e) + } }) ) return router; } -async function findOrCreateUserByExternalAuthAssociation( - provider: string, - providerId: string, - getUserFields: () => ReturnType, -): Promise<{= userEntityUpper =}> { - // Attempt to find a User by an external auth association. - const externalAuthAssociation = await prisma.{= externalAuthEntityLower =}.findFirst({ - where: { provider, providerId }, - include: { user: true } +// We need a user id to create the auth token, so we either find an existing user +// or create a new one if none exists for this provider. +async function getUserIdFromProviderDetails( + providerId: ProviderId, + providerProfile: any, + getUserFieldsFn?: GetUserFieldsFn, +): Promise<{= userEntityUpper =}['id']> { + const existingAuthIdentity = await prisma.{= authIdentityEntityLower =}.findUnique({ + where: { + providerName_providerUserId: providerId, + }, + include: { + {= authFieldOnAuthIdentityEntityName =}: { + include: { + {= userFieldOnAuthEntityName =}: true + } + } + } }) - if (externalAuthAssociation) { - return externalAuthAssociation.user - } + if (existingAuthIdentity) { + return existingAuthIdentity.{= authFieldOnAuthIdentityEntityName =}.{= userFieldOnAuthEntityName =}.id + } else { + const userFields = getUserFieldsFn + ? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) + : {}; - // No external auth association linkage found. Create a new User using details from - // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. - const userFields = await getUserFields() - const userAndExternalAuthAssociation = { - ...userFields, - {=# isPasswordOnUserEntity =} - // TODO: Decouple social from usernameAndPassword auth. - password: uuidv4(), - {=/ isPasswordOnUserEntity =} - externalAuthAssociations: { - create: [{ provider, providerId }] - } - } + // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. + const providerData = await sanitizeAndSerializeProviderData({}) + + const user = await createUser( + providerId, + providerData, + userFields, + ) - return createUser(userAndExternalAuthAssociation) + return user.id + } } diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts deleted file mode 100644 index a6dede224d..0000000000 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/defaults.ts +++ /dev/null @@ -1,12 +0,0 @@ -{{={= =}=}} -import { generateAvailableDictionaryUsername } from '../../../core/auth.js' - -export async function getUserFieldsFn(_context, _args) { - {=# isUsernameOnUserEntity =} - const username = await generateAvailableDictionaryUsername() - return { username } - {=/ isUsernameOnUserEntity =} - {=^ isUsernameOnUserEntity =} - return {} - {=/ isUsernameOnUserEntity =} -} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts index 130836ebb8..ac5a56dafe 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/init.ts @@ -71,6 +71,6 @@ function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; - getUserFieldsFn: GetUserFieldsFn; oAuthConfig: OAuthConfig; + getUserFieldsFn?: GetUserFieldsFn; }; diff --git a/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts index 266c289247..390cd45923 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/oauth/types.ts @@ -9,11 +9,11 @@ export type OAuthConfig = { scope?: string[]; } -export type CreateOAuthUser = Omit +export type UserFieldsFromOAuthSignup = Prisma.{= userEntityName =}CreateInput export type UserDefinedConfigFn = () => { [key: string]: any } export type GetUserFieldsFn = ( context: typeof contextWithUserEntity, args: { profile: { [key: string]: any } }, -) => Promise +) => Promise diff --git a/waspc/data/Generator/templates/server/src/auth/providers/types.ts b/waspc/data/Generator/templates/server/src/auth/providers/types.ts index 0889298c0a..e2ff6e09a7 100644 --- a/waspc/data/Generator/templates/server/src/auth/providers/types.ts +++ b/waspc/data/Generator/templates/server/src/auth/providers/types.ts @@ -1,10 +1,14 @@ +{{={= =}=}} import type { Router, Request } from 'express' -import type { User } from '../../entities' +import type { Prisma } from '@prisma/client' import type { Expand } from '../../universal/types' +import type { ProviderName } from '../utils' + +type UserEntityCreateInput = Prisma.{= userEntityUpper =}CreateInput export type ProviderConfig = { // Unique provider identifier, used as part of URL paths - id: string; + id: ProviderName; displayName: string; // Each provider config can have an init method which is ran on setup time // e.g. for oAuth providers this is the time when the Passport strategy is registered. @@ -20,20 +24,14 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export function createDefineAdditionalSignupFieldsFn< - // Wasp already includes these fields in the signup process - ExistingFields extends keyof User, - PossibleAdditionalFields = Expand< - Partial> +export type PossibleAdditionalSignupFields = Expand> + +export function defineAdditionalSignupFields(config: { + [key in keyof PossibleAdditionalSignupFields]: FieldGetter< + PossibleAdditionalSignupFields[key] > ->() { - return function defineFields(config: { - [key in keyof PossibleAdditionalFields]: FieldGetter< - PossibleAdditionalFields[key] - > - }) { - return config - } +}) { + return config } type FieldGetter = ( diff --git a/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts b/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts new file mode 100644 index 0000000000..9bb5841bf5 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/providers/username/login.ts @@ -0,0 +1,43 @@ +{{={= =}=}} +import { verifyPassword, throwInvalidCredentialsError } from '../../../core/auth.js' +import { handleRejection } from '../../../utils.js' + +import { + createProviderId, + findAuthIdentity, + findAuthWithUserBy, + createAuthToken, + deserializeAndSanitizeProviderData, +} from '../../utils.js' +import { ensureValidUsername, ensurePasswordIsPresent } from '../../validation.js' + +export default handleRejection(async (req, res) => { + const fields = req.body ?? {} + ensureValidArgs(fields) + + const providerId = createProviderId('username', fields.username) + const authIdentity = await findAuthIdentity(providerId) + if (!authIdentity) { + throwInvalidCredentialsError() + } + + try { + const providerData = deserializeAndSanitizeProviderData<'username'>(authIdentity.providerData) + + await verifyPassword(providerData.hashedPassword, fields.password) + } catch(e) { + throwInvalidCredentialsError() + } + + const auth = await findAuthWithUserBy({ + id: authIdentity.authId + }) + const token = await createAuthToken(auth.userId) + + return res.json({ token }) +}) + +function ensureValidArgs(args: unknown): void { + ensureValidUsername(args); + ensurePasswordIsPresent(args); +} diff --git a/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts new file mode 100644 index 0000000000..478749b9b2 --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/providers/username/signup.ts @@ -0,0 +1,46 @@ +{{={= =}=}} +import { handleRejection } from '../../../utils.js' +import { + createProviderId, + createUser, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, +} from '../../utils.js' +import { + ensureValidUsername, + ensurePasswordIsPresent, + ensureValidPassword, +} from '../../validation.js' +import { validateAndGetAdditionalFields } from '../../utils.js' + +export default handleRejection(async (req, res) => { + const fields = req.body ?? {} + ensureValidArgs(fields) + + const userFields = await validateAndGetAdditionalFields(fields) + + const providerId = createProviderId('username', fields.username) + const providerData = await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: fields.password, + }) + + try { + await createUser( + providerId, + providerData, + // Using any here because we want to avoid TypeScript errors and + // rely on Prisma to validate the data. + userFields as any + ) + } catch (e: unknown) { + rethrowPossibleAuthError(e) + } + + return res.json({ success: true }) +}) + +function ensureValidArgs(args: unknown): void { + ensureValidUsername(args) + ensurePasswordIsPresent(args) + ensureValidPassword(args) +} diff --git a/waspc/data/Generator/templates/server/src/auth/user.ts b/waspc/data/Generator/templates/server/src/auth/user.ts new file mode 100644 index 0000000000..a5d987fc4e --- /dev/null +++ b/waspc/data/Generator/templates/server/src/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/data/Generator/templates/server/src/auth/utils.ts b/waspc/data/Generator/templates/server/src/auth/utils.ts index 5495937b56..0ff4c7d29e 100644 --- a/waspc/data/Generator/templates/server/src/auth/utils.ts +++ b/waspc/data/Generator/templates/server/src/auth/utils.ts @@ -1,11 +1,15 @@ {{={= =}=}} -import { sign, verify } from '../core/auth.js' +import { hashPassword, sign, verify } from '../core/auth.js' import AuthError from '../core/AuthError.js' import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' -import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' -import { type {= userEntityUpper =} } from '../entities/index.js' -import { type Prisma } from '@prisma/client'; +import { sleep } from '../utils.js' +import { + type {= userEntityUpper =}, + type {= authEntityUpper =}, + type {= authIdentityEntityUpper =}, +} from '../entities/index.js' +import { Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' @@ -13,16 +17,43 @@ import { throwValidationError } from './validation.js' {=& additionalSignupFields.importStatement =} {=/ additionalSignupFields.isDefined =} +import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' {=# additionalSignupFields.isDefined =} const _waspAdditionalSignupFieldsConfig = {= additionalSignupFields.importIdentifier =} {=/ additionalSignupFields.isDefined =} {=^ additionalSignupFields.isDefined =} -import { createDefineAdditionalSignupFieldsFn } from './providers/types.js' -const _waspAdditionalSignupFieldsConfig = {} as ReturnType< - ReturnType> -> +const _waspAdditionalSignupFieldsConfig = {} as ReturnType {=/ additionalSignupFields.isDefined =} +export type EmailProviderData = { + hashedPassword: string; + isEmailVerified: boolean; + emailVerificationSentAt: string | null; + passwordResetSentAt: string | null; +} + +export type UsernameProviderData = { + hashedPassword: string; +} + +export type OAuthProviderData = {} + +/** + * This type is used for type-level programming e.g. to enumerate + * all possible provider data types. + * + * The keys of this type are the names of the providers and the values + * are the types of the provider data. + */ +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +export type ProviderName = keyof PossibleProviderData + export const contextWithUserEntity = { entities: { {= userEntityUpper =}: prisma.{= userEntityLower =} @@ -34,31 +65,112 @@ export const authConfig = { successRedirectPath: "{= successRedirectPath =}", } -export async function findUserBy(where: Prisma.{= userEntityUpper =}WhereUniqueInput): Promise<{= userEntityUpper =}> { - return prisma.{= userEntityLower =}.findUnique({ where }); +/** + * ProviderId uniquely identifies an auth identity e.g. + * "email" provider with user id "test@test.com" or + * "google" provider with user id "1234567890". + * + * We use this type to avoid passing the providerName and providerUserId + * separately. Also, we can normalize the providerUserId to make sure it's + * consistent across different DB operations. + */ +export type ProviderId = { + providerName: ProviderName; + providerUserId: string; } -export async function createUser(data: Prisma.{= userEntityUpper =}CreateInput): Promise<{= userEntityUpper =}> { - try { - return await prisma.{= userEntityLower =}.create({ data }) - } catch (e) { - rethrowPossiblePrismaError(e); +export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId { + return { + providerName, + providerUserId: providerUserId.toLowerCase(), } } -export async function deleteUser(user: {= userEntityUpper =}): Promise<{= userEntityUpper =}> { - try { - return await prisma.{= userEntityLower =}.delete({ where: { id: user.id } }) - } catch (e) { - rethrowPossiblePrismaError(e); +export async function findAuthIdentity(providerId: ProviderId): Promise<{= authIdentityEntityUpper =} | null> { + return prisma.{= authIdentityEntityLower =}.findUnique({ + where: { + providerName_providerUserId: providerId, + } + }); +} + +/** + * Updates the provider data for the given auth identity. + * + * This function performs data sanitization and serialization. + * Sanitization is done by hashing the password, so this function + * expects the password received in the `providerDataUpdates` + * **not to be hashed**. + */ +export async function updateAuthIdentityProviderData( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise<{= authIdentityEntityUpper =}> { + // We are doing the sanitization here only on updates to avoid + // hashing the password multiple times. + const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); + const newProviderData = { + ...existingProviderData, + ...sanitizedProviderDataUpdates, } + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.{= authIdentityEntityLower =}.update({ + where: { + providerName_providerUserId: providerId, + }, + data: { providerData: serializedProviderData }, + }); } -export async function createAuthToken(user: {= userEntityUpper =}): Promise { - return sign(user.id); +type FindAuthWithUserResult = {= authEntityUpper =} & { + {= userFieldOnAuthEntityName =}: {= userEntityUpper =} } -export async function verifyToken(token: string): Promise<{ id: any }> { +export async function findAuthWithUserBy( + where: Prisma.{= authEntityUpper =}WhereInput +): Promise { + return prisma.{= authEntityLower =}.findFirst({ where, include: { {= userFieldOnAuthEntityName =}: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleAdditionalSignupFields, +): Promise<{= userEntityUpper =}> { + return prisma.{= userEntityLower =}.create({ + data: { + // Using any here to prevent type errors when userFields are not + // defined. We want Prisma to throw an error in that case. + ...(userFields ?? {} as any), + {= authFieldOnUserEntityName =}: { + create: { + {= identitiesFieldOnAuthEntityName =}: { + create: { + providerName: providerId.providerName, + providerUserId: providerId.providerUserId, + providerData: serializedProviderData, + }, + }, + } + }, + } + }) +} + +export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { + return prisma.{= userEntityLower =}.deleteMany({ where: { auth: { + id: authId, + } } }) +} + +export async function createAuthToken( + userId: {= userEntityUpper =}['id'] +): Promise { + return sign(userId); +} + +export async function verifyToken(token: string): Promise { return verify(token); } @@ -69,24 +181,61 @@ export async function verifyToken(token: string): Promise<{ id: any }> { // NOTE: Attacker measuring time to response can still determine // if a user exists or not. We'll be able to avoid it when // we implement e-mail sending via jobs. -export async function doFakeWork() { +export async function doFakeWork(): Promise { const timeToWork = Math.floor(Math.random() * 1000) + 1000; return sleep(timeToWork); } -export function rethrowPossiblePrismaError(e: unknown): void { +export function rethrowPossibleAuthError(e: unknown): void { if (e instanceof AuthError) { throwValidationError(e.message); - } else if (isPrismaError(e)) { - throw prismaErrorToHttpError(e) - } else { - throw new HttpError(500) } + + // Prisma code P2002 is for unique constraint violations. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpError(422, 'Save failed', { + message: `user with the same identity already exists`, + }) + } + + if (e instanceof Prisma.PrismaClientValidationError) { + // NOTE: Logging the error since this usually means that there are + // required fields missing in the request, we want the developer + // to know about it. + console.error(e) + throw new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + + // Prisma code P2021 is for missing table errors. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') { + // NOTE: Logging the error since this usually means that the database + // migrations weren't run, we want the developer to know about it. + console.error(e) + console.info('🐝 This error can happen if you did\'t run the database migrations.') + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + // Prisma code P2003 is for foreign key constraint failure + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { + console.error(e) + console.info(`🐝 This error can happen if you have some relation on your {= userEntityUpper =} entity + but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull". + Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`) + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + throw e } export async function validateAndGetAdditionalFields(data: { [key: string]: unknown -}) { +}): Promise> { const { password: _password, ...sanitizedData @@ -102,3 +251,49 @@ export async function validateAndGetAdditionalFields(data: { } return result; } + +export function deserializeAndSanitizeProviderData( + providerData: string, + { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, +): PossibleProviderData[PN] { + // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. + let data = JSON.parse(providerData) as PossibleProviderData[PN]; + + if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { + delete data.hashedPassword; + } + + return data; +} + +export async function sanitizeAndSerializeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + const data = { + ...providerData, + }; + if (providerDataHasPasswordField(data)) { + data.hashedPassword = await hashPassword(data.hashedPassword); + } + + return data; +} + + +function providerDataHasPasswordField( + providerData: PossibleProviderData[keyof PossibleProviderData], +): providerData is { hashedPassword: string } { + return 'hashedPassword' in providerData; +} diff --git a/waspc/data/Generator/templates/server/src/core/auth.js b/waspc/data/Generator/templates/server/src/core/auth.js index 06f9b20dc8..33105b1cbb 100644 --- a/waspc/data/Generator/templates/server/src/core/auth.js +++ b/waspc/data/Generator/templates/server/src/core/auth.js @@ -8,13 +8,15 @@ import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' import HttpError from '../core/HttpError.js' import config from '../config.js' +import { deserializeAndSanitizeProviderData } from '../auth/utils.js' const jwtSign = util.promisify(jwt.sign) const jwtVerify = util.promisify(jwt.verify) const JWT_SECRET = config.auth.jwtSecret -export const sign = (id, options) => jwtSign({ id }, JWT_SECRET, options) +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const sign = (id, options) => signData({ id }, options) export const verify = (token) => jwtVerify(token, JWT_SECRET) const auth = handleRejection(async (req, res, next) => { @@ -48,7 +50,17 @@ export async function getUserFromToken(token) { } } - const user = await prisma.{= userEntityLower =}.findUnique({ where: { id: userIdFromToken } }) + const user = await prisma.{= userEntityLower =} + .findUnique({ + where: { id: userIdFromToken }, + include: { + {= authFieldOnUserEntityName =}: { + include: { + {= identitiesFieldOnAuthEntityName =}: true + } + } + } + }) if (!user) { throwInvalidCredentialsError() } @@ -57,9 +69,12 @@ export async function getUserFromToken(token) { // password field from the object here, we must to do the same there). // Ideally, these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 - const { password, ...userView } = user - - return userView + let sanitizedUser = { ...user } + sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =} = sanitizedUser.{= authFieldOnUserEntityName =}.{= identitiesFieldOnAuthEntityName =}.map(identity => { + identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) + return identity + }); + return sanitizedUser } const SP = new SecurePassword() diff --git a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js b/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js deleted file mode 100644 index 6670f2363c..0000000000 --- a/waspc/data/Generator/templates/server/src/core/auth/prismaMiddleware.js +++ /dev/null @@ -1,31 +0,0 @@ -{{={= =}=}} -import { hashPassword } from '../auth.js' -import { PASSWORD_FIELD } from '../../auth/validation.js' - -// Make sure password is always hashed before storing to the database. -const registerPasswordHashing = (prismaClient) => { - prismaClient.$use(async (params, next) => { - if (params.model === '{= userEntityUpper =}') { - if (['create', 'update', 'updateMany'].includes(params.action)) { - if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { - params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) - } - } else if (params.action === 'upsert') { - if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { - params.args.create[PASSWORD_FIELD] = - await hashPassword(params.args.create[PASSWORD_FIELD]) - } - if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { - params.args.update[PASSWORD_FIELD] = - await hashPassword(params.args.update[PASSWORD_FIELD]) - } - } - } - - return next(params) - }) -} - -export const registerAuthMiddleware = (prismaClient) => { - registerPasswordHashing(prismaClient) -} diff --git a/waspc/data/Generator/templates/server/src/crud/_operations.ts b/waspc/data/Generator/templates/server/src/crud/_operations.ts index e69d53521b..7ffb5a1fd2 100644 --- a/waspc/data/Generator/templates/server/src/crud/_operations.ts +++ b/waspc/data/Generator/templates/server/src/crud/_operations.ts @@ -12,9 +12,7 @@ import type { {=/ isAuthEnabled =} _{= crud.entityUpper =}, } from "../_types"; -import type { - Prisma, -} from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { Payload } from "../_types/serialization.js"; import type { {= crud.entityUpper =}, diff --git a/waspc/data/Generator/templates/server/src/dbClient.ts b/waspc/data/Generator/templates/server/src/dbClient.ts index fead2ab166..e6319ff696 100644 --- a/waspc/data/Generator/templates/server/src/dbClient.ts +++ b/waspc/data/Generator/templates/server/src/dbClient.ts @@ -1,17 +1,10 @@ {{={= =}=}} import Prisma from '@prisma/client' -{=# isAuthEnabled =} -import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js' -{=/ isAuthEnabled =} const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - {=# isAuthEnabled =} - registerAuthMiddleware(prismaClient) - {=/ isAuthEnabled =} - return prismaClient } diff --git a/waspc/data/Generator/templates/server/src/entities/index.ts b/waspc/data/Generator/templates/server/src/entities/index.ts index 576f05f289..591309c09d 100644 --- a/waspc/data/Generator/templates/server/src/entities/index.ts +++ b/waspc/data/Generator/templates/server/src/entities/index.ts @@ -9,6 +9,10 @@ export { {=# entities =} type {= name =}, {=/ entities =} + {=# isAuthEnabled =} + type {= authEntityName =}, + type {= authIdentityEntityName =}, + {=/ isAuthEnabled =} } from "@prisma/client" export type Entity = diff --git a/waspc/data/Generator/templates/server/src/utils.js b/waspc/data/Generator/templates/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/data/Generator/templates/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/data/Generator/templates/server/src/utils.ts b/waspc/data/Generator/templates/server/src/utils.ts new file mode 100644 index 0000000000..3a38f87ede --- /dev/null +++ b/waspc/data/Generator/templates/server/src/utils.ts @@ -0,0 +1,71 @@ +{{={= =}=}} +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + +{=# isAuthEnabled =} +import { type SanitizedUser } from './_types/index.js' +{=/ isAuthEnabled =} + +type RequestWithExtraFields = Request & { + {=# isAuthEnabled =} + user?: SanitizedUser + {=/ isAuthEnabled =} +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/data/Generator/templates/server/src/webSocket/index.ts b/waspc/data/Generator/templates/server/src/webSocket/index.ts index 1ccff7ffba..3393b500bc 100644 --- a/waspc/data/Generator/templates/server/src/webSocket/index.ts +++ b/waspc/data/Generator/templates/server/src/webSocket/index.ts @@ -4,6 +4,9 @@ import { Server } from 'socket.io' import { EventsMap, DefaultEventsMap } from '@socket.io/component-emitter' import prisma from '../dbClient.js' +{=# isAuthEnabled =} +import { type SanitizedUser } from '../_types/index.js' +{=/ isAuthEnabled =} {=& userWebSocketFn.importStatement =} @@ -29,7 +32,9 @@ export type WebSocketDefinition< ) => Promise | void export interface WaspSocketData { - user?: any + {=# isAuthEnabled =} + user?: SanitizedUser + {=/ isAuthEnabled =} } export type ServerType = Parameters[0] diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest index 96fee87b1c..412fac0d7d 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/files.manifest @@ -30,7 +30,7 @@ waspBuild/.wasp/build/server/src/server.ts waspBuild/.wasp/build/server/src/types/index.ts waspBuild/.wasp/build/server/src/universal/types.ts waspBuild/.wasp/build/server/src/universal/url.ts -waspBuild/.wasp/build/server/src/utils.js +waspBuild/.wasp/build/server/src/utils.ts waspBuild/.wasp/build/server/tsconfig.json waspBuild/.wasp/build/web-app/.npmrc waspBuild/.wasp/build/web-app/README.md diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums index 83674f3df6..8ac76226b6 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/.waspchecksums @@ -130,7 +130,7 @@ "file", "server/src/dbClient.ts" ], - "20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43" + "f26bfa4a60022d45195f3179548c4b09305f6a9cfe301b5edd1615677a59ae3f" ], [ [ @@ -219,9 +219,9 @@ [ [ "file", - "server/src/utils.js" + "server/src/utils.ts" ], - "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" + "6a664e44a89efe8ed8cb7559a84bd2871422c38287b276fee2a7aed4680e1d1e" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts index 11b87caa50..66e7801be3 100644 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/dbClient.ts @@ -4,7 +4,6 @@ import Prisma from '@prisma/client' const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts new file mode 100644 index 0000000000..99492c5474 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspBuild-golden/waspBuild/.wasp/build/server/src/utils.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + + +type RequestWithExtraFields = Request & { +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest index d84378957c..0658a077b2 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/files.manifest @@ -31,7 +31,7 @@ waspCompile/.wasp/out/server/src/server.ts waspCompile/.wasp/out/server/src/types/index.ts waspCompile/.wasp/out/server/src/universal/types.ts waspCompile/.wasp/out/server/src/universal/url.ts -waspCompile/.wasp/out/server/src/utils.js +waspCompile/.wasp/out/server/src/utils.ts waspCompile/.wasp/out/server/tsconfig.json waspCompile/.wasp/out/web-app/.env waspCompile/.wasp/out/web-app/.npmrc diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums index bc63689d32..bb318f5532 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/.waspchecksums @@ -137,7 +137,7 @@ "file", "server/src/dbClient.ts" ], - "20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43" + "f26bfa4a60022d45195f3179548c4b09305f6a9cfe301b5edd1615677a59ae3f" ], [ [ @@ -226,9 +226,9 @@ [ [ "file", - "server/src/utils.js" + "server/src/utils.ts" ], - "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" + "6a664e44a89efe8ed8cb7559a84bd2871422c38287b276fee2a7aed4680e1d1e" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts index 11b87caa50..66e7801be3 100644 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/dbClient.ts @@ -4,7 +4,6 @@ import Prisma from '@prisma/client' const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts new file mode 100644 index 0000000000..99492c5474 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspCompile-golden/waspCompile/.wasp/out/server/src/utils.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + + +type RequestWithExtraFields = Request & { +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest index a0b6cd1ea8..af31ebb1a2 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/files.manifest @@ -24,17 +24,16 @@ waspComplexTest/.wasp/out/server/src/app.js waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts waspComplexTest/.wasp/out/server/src/auth/providers/index.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts -waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts waspComplexTest/.wasp/out/server/src/auth/providers/types.ts +waspComplexTest/.wasp/out/server/src/auth/user.ts waspComplexTest/.wasp/out/server/src/auth/utils.ts waspComplexTest/.wasp/out/server/src/auth/validation.ts waspComplexTest/.wasp/out/server/src/config.js waspComplexTest/.wasp/out/server/src/core/AuthError.js waspComplexTest/.wasp/out/server/src/core/HttpError.js waspComplexTest/.wasp/out/server/src/core/auth.js -waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js waspComplexTest/.wasp/out/server/src/crud/tasks.ts waspComplexTest/.wasp/out/server/src/dbClient.ts waspComplexTest/.wasp/out/server/src/dbSeed/types.ts @@ -76,7 +75,7 @@ waspComplexTest/.wasp/out/server/src/server.ts waspComplexTest/.wasp/out/server/src/types/index.ts waspComplexTest/.wasp/out/server/src/universal/types.ts waspComplexTest/.wasp/out/server/src/universal/url.ts -waspComplexTest/.wasp/out/server/src/utils.js +waspComplexTest/.wasp/out/server/src/utils.ts waspComplexTest/.wasp/out/server/tsconfig.json waspComplexTest/.wasp/out/web-app/.env waspComplexTest/.wasp/out/web-app/.npmrc @@ -110,6 +109,7 @@ waspComplexTest/.wasp/out/web-app/src/auth/pages/OAuthCodeExchange.jsx waspComplexTest/.wasp/out/web-app/src/auth/pages/createAuthRequiredPage.jsx waspComplexTest/.wasp/out/web-app/src/auth/types.ts waspComplexTest/.wasp/out/web-app/src/auth/useAuth.ts +waspComplexTest/.wasp/out/web-app/src/auth/user.ts waspComplexTest/.wasp/out/web-app/src/config.js waspComplexTest/.wasp/out/web-app/src/crud/tasks.ts waspComplexTest/.wasp/out/web-app/src/entities/index.ts diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums index f9c0e57cc9..2c7fdeee1b 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/.waspchecksums @@ -18,7 +18,7 @@ "file", "db/schema.prisma" ], - "cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1" + "3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393" ], [ [ @@ -88,7 +88,7 @@ "file", "server/src/_types/index.ts" ], - "aa5f2c417b5732f732241362fb456b8d068a270d245d692e6530defbb035e778" + "92027caebe484c7d412f97be9bc3c39257d10f9f2bc2e447e52c6855d5d30ffc" ], [ [ @@ -137,56 +137,56 @@ "file", "server/src/auth/providers/config/google.ts" ], - "ff060f3cb2437755867b9548dce153bd38b985dc3fc102073bd698d3d7fa23a7" + "62e519ae90c87e1032e53d089a8d6106331a278ecd1293767c8e8e9cb4848f6a" ], [ [ "file", "server/src/auth/providers/index.ts" ], - "c56c888520fb6148883d86ede9e2226f3548d2ca929fb6b89145d69b490c775e" + "e1c33c91fd91a2de63619320c462817234c097e18d3b04a11c63d4216bcaca85" ], [ [ "file", "server/src/auth/providers/oauth/createRouter.ts" ], - "63dbe409a2de70c55e3f4c01b5faa1da6d09ac0947ff90133c6c616c57ad75c7" + "da122a8a244ddbd9b84bba72a7da2f32ffa41c6093614c6c0d59e113244d2bbc" ], [ [ "file", - "server/src/auth/providers/oauth/defaults.ts" + "server/src/auth/providers/oauth/init.ts" ], - "2bff7ab070b402aac4ea69a950c0ef6ba7e58c27d4727bdf6046961cb7c0dd48" + "cef00c764f6c6923c0138f114eaf0484ad30c4e9dde7f6b44a143061909a8ba1" ], [ [ "file", - "server/src/auth/providers/oauth/init.ts" + "server/src/auth/providers/oauth/types.ts" ], - "ff022c6f9132db9c3833afd12018fecfcdd3243cbf9e5d1b69367864301fe085" + "00c951bd5dae77b7aedca90c0847f6e861e7f151e89b1906e794469981191b47" ], [ [ "file", - "server/src/auth/providers/oauth/types.ts" + "server/src/auth/providers/types.ts" ], - "81c3ff4d945025fc2530b57db7ceecb5c14857e84d98e8c11f045282c25efe37" + "b647575a04eeb7824d95082a461d59763d034dc7d03a8fbcdd25143b6f8431b6" ], [ [ "file", - "server/src/auth/providers/types.ts" + "server/src/auth/user.ts" ], - "323555d76755fe32b21084f063caf931faabcb5937c279cc706bbecad3361d43" + "5787f3cdab4739781090f2950ba432dca812483ec23c6319ac3f876118324d15" ], [ [ "file", "server/src/auth/utils.ts" ], - "ab1f6a90dca62ba0b3b9e1931209c4db1ea4fd0c757fadd27e90b527bcd869e0" + "ba76300456ffdbd647923b27ff163df5e3efa016d7cd2a01af0b6a86dcd780a9" ], [ [ @@ -221,28 +221,21 @@ "file", "server/src/core/auth.js" ], - "cb1941ba655c0300bbabda6b51096acdfcd28665ba2f07de676bacb26bd8cddc" - ], - [ - [ - "file", - "server/src/core/auth/prismaMiddleware.js" - ], - "a9ccf84f089cf98a022fa800e5b3cc06d3a8b69f5785718dc068286836fd77fa" + "d708303af170e8159b93f0dda521b6f622c0f3add2d4f4f8f2fd88c0a4f7b79e" ], [ [ "file", "server/src/crud/tasks.ts" ], - "5c7e55d9eecf8e54822b90662727fcab47c6171c0c49043b25f4e7073ecbe085" + "2c4e1f94939adf825df14624940019889394a0e56cdea2855686d67e0c08458a" ], [ [ "file", "server/src/dbClient.ts" ], - "a749686af4e331a0b982e8c2acbfe00aff043340f34d54e80a34d417c6901e56" + "f26bfa4a60022d45195f3179548c4b09305f6a9cfe301b5edd1615677a59ae3f" ], [ [ @@ -298,7 +291,7 @@ "file", "server/src/entities/index.ts" ], - "783fbc250d0628073328625ff299f120ac8bef45232c10b7d7b5897d42b788c1" + "cdd9cdbeebfdad8c54ddbf4978a3c4974faa66df05b87b42a5030fcc214394f8" ], [ [ @@ -520,9 +513,9 @@ [ [ "file", - "server/src/utils.js" + "server/src/utils.ts" ], - "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" + "f8834df362946064f32ef6a145769f83d10da712ad3daa226243fc590f89618f" ], [ [ @@ -753,7 +746,7 @@ "file", "web-app/src/auth/types.ts" ], - "0d37136807f6d196015d07b65ab56280ae5f56cac9be84992318886550ce4ad3" + "5ce8d0493c362093b0b2fc7b9df78a86688d3f40264ea8f29530f1d8fa67c4c6" ], [ [ @@ -762,6 +755,13 @@ ], "f730cb58a5ebd12285b7568bec34f4e5615261580cff1727e64b5c871d784d62" ], + [ + [ + "file", + "web-app/src/auth/user.ts" + ], + "7113c286081f5597b822f5e576735d321cce38fcbd1a25db0d90e1163570068f" + ], [ [ "file", @@ -781,7 +781,7 @@ "file", "web-app/src/entities/index.ts" ], - "cfb9f8237f80aea777840621702d03803172b005b3aaee2fc0be6c9f1b1c8414" + "f3daa2f99b1ead27d95ef05fbd493c26b697a4c6d413432a1959d41e4cb205a2" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma index 8b5c55f36b..69c01a4535 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma @@ -14,6 +14,7 @@ model User { username String @unique password String externalAuthAssociations SocialLogin[] + auth Auth? } model SocialLogin { @@ -32,3 +33,19 @@ model Task { isDone Boolean @default(false) } +model Auth { + id String @id @default(uuid()) + userId Int? @unique + user User? @relation(fields: [userId], references: [id], onDelete: Cascade) + identities AuthIdentity[] + +} +model AuthIdentity { + providerName String + providerUserId String + providerData String @default("{}") + authId String + auth Auth @relation(fields: [authId], references: [id], onDelete: Cascade) + @@id([providerName, providerUserId]) + +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum index 70f188e833..b340294f3e 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/db/schema.prisma.wasp-generate-checksum @@ -1 +1 @@ -cbea4d60a2c1bef984008597eefc64540e95048d7a43cb718303f5628d386ae1 \ No newline at end of file +3e6bfc3dadfc9399a3fae3ea77cadb1aa3259efa0b8362ed77d6d5ee5013d393 \ No newline at end of file diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts index 333f64f26b..23017df130 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/_types/index.ts @@ -2,7 +2,16 @@ import { type Expand } from "../universal/types.js"; import { type Request, type Response } from 'express' import { type ParamsDictionary as ExpressParams, type Query as ExpressQuery } from 'express-serve-static-core' import prisma from "../dbClient.js" -import { type User } from "../entities" +import { + type User, + type Auth, + type AuthIdentity, +} from "../entities" +import { + type EmailProviderData, + type UsernameProviderData, + type OAuthProviderData, +} from '../auth/utils.js' import { type _Entity } from "./taggedEntities" import { type Payload } from "./serialization"; @@ -71,10 +80,21 @@ type Context = Expand<{ entities: Expand> }> -type ContextWithUser = Expand & { user?: SanitizedUser}> +type ContextWithUser = Expand & { user?: SanitizedUser }> // TODO: This type must match the logic in core/auth.js (if we remove the // password field from the object there, we must do the same here). Ideally, // these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 -export type SanitizedUser = Omit + +export type DeserializedAuthEntity = Expand & { + providerData: Omit | Omit | OAuthProviderData +}> + +export type SanitizedUser = User & { + auth: Auth & { + identities: DeserializedAuthEntity[] + } | null +} + +export type { ProviderName } from '../auth/utils.js' diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts index a2e0363bd3..0525771050 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/config/google.ts @@ -5,7 +5,7 @@ import { makeOAuthInit } from "../oauth/init.js"; import type { ProviderConfig } from "../types.js"; import type { OAuthConfig } from "../oauth/types.js"; -import { getUserFieldsFn as _waspGetUserFieldsFn } from '../oauth/defaults.js' +const _waspGetUserFieldsFn = undefined const _waspUserDefinedConfigFn = undefined const _waspOAuthConfig: OAuthConfig = { diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts index 877ddc8343..c96736abe3 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/index.ts @@ -1,14 +1,15 @@ +import { join } from 'path' import { Router } from "express"; -import { getDirFromFileUrl, importJsFilesFromDir } from "../../utils.js"; +import { getDirPathFromFileUrl, importJsFilesFromDir } from "../../utils.js"; import { ProviderConfig } from "./types"; -const allowedConfigs = [ +const whitelistedProviderConfigFileNames = [ "google.js", ]; -const providers = await importProviders(allowedConfigs); +const providers = await importProviders(whitelistedProviderConfigFileNames); const router = Router(); @@ -24,8 +25,9 @@ for (const provider of providers) { export default router; -async function importProviders(providerConfigs: string[]): Promise { - const currentExecutionDir = getDirFromFileUrl(import.meta.url); - const providers = await importJsFilesFromDir(currentExecutionDir, "./config", providerConfigs); +async function importProviders(whitelistedProviderConfigFileNames: string[]): Promise { + const currentExecutionDir = getDirPathFromFileUrl(import.meta.url); + const pathToDirWithConfigs = join(currentExecutionDir, "./config"); + const providers = await importJsFilesFromDir(pathToDirWithConfigs, whitelistedProviderConfigFileNames); return providers.map((provider) => provider.default); } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts index 85a04a93c5..a64162b8dd 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/createRouter.ts @@ -1,21 +1,29 @@ import { Router } from "express" import passport from "passport" -import { v4 as uuidv4 } from 'uuid' import prisma from '../../../dbClient.js' import waspServerConfig from '../../../config.js' -import { sign } from '../../../core/auth.js' -import { authConfig, contextWithUserEntity, createUser } from "../../utils.js" - -import type { User } from '../../../entities'; +import { + type ProviderName, + type ProviderId, + createProviderId, + authConfig, + contextWithUserEntity, + createUser, + findAuthWithUserBy, + createAuthToken, + rethrowPossibleAuthError, + sanitizeAndSerializeProviderData, +} from "../../utils.js" +import { type User } from "../../../entities/index.js" import type { ProviderConfig, RequestWithWasp } from "../types.js" import type { GetUserFieldsFn } from "./types.js" import { handleRejection } from "../../../utils.js" // For oauth providers, we have an endpoint /login to get the auth URL, // and the /callback endpoint which is used to get the actual access_token and the user info. -export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn: GetUserFieldsFn }) { +export function createRouter(provider: ProviderConfig, initData: { passportStrategyName: string, getUserFieldsFn?: GetUserFieldsFn }) { const { passportStrategyName, getUserFieldsFn } = initData; const router = Router(); @@ -41,46 +49,57 @@ export function createRouter(provider: ProviderConfig, initData: { passportStrat throw new Error(`${provider.displayName} provider profile was missing required id property. This should not happen! Please contact Wasp.`); } - // Wrap call to getUserFieldsFn so we can invoke only if needed. - const getUserFields = () => getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }); - // TODO: In the future we could make this configurable, possibly associating an external account - // with the currently logged in account, or by some DB lookup. - const user = await findOrCreateUserByExternalAuthAssociation(provider.id, providerProfile.id, getUserFields); + const providerId = createProviderId(provider.id, providerProfile.id); - const token = await sign(user.id); - res.json({ token }); + try { + const userId = await getUserIdFromProviderDetails(providerId, providerProfile, getUserFieldsFn) + const token = await createAuthToken(userId) + res.json({ token }) + } catch (e) { + rethrowPossibleAuthError(e) + } }) ) return router; } -async function findOrCreateUserByExternalAuthAssociation( - provider: string, - providerId: string, - getUserFields: () => ReturnType, -): Promise { - // Attempt to find a User by an external auth association. - const externalAuthAssociation = await prisma.socialLogin.findFirst({ - where: { provider, providerId }, - include: { user: true } +// We need a user id to create the auth token, so we either find an existing user +// or create a new one if none exists for this provider. +async function getUserIdFromProviderDetails( + providerId: ProviderId, + providerProfile: any, + getUserFieldsFn?: GetUserFieldsFn, +): Promise { + const existingAuthIdentity = await prisma.authIdentity.findUnique({ + where: { + providerName_providerUserId: providerId, + }, + include: { + auth: { + include: { + user: true + } + } + } }) - if (externalAuthAssociation) { - return externalAuthAssociation.user - } + if (existingAuthIdentity) { + return existingAuthIdentity.auth.user.id + } else { + const userFields = getUserFieldsFn + ? await getUserFieldsFn(contextWithUserEntity, { profile: providerProfile }) + : {}; - // No external auth association linkage found. Create a new User using details from - // `getUserFields()`. Additionally, associate the externalAuthAssociations with the new User. - const userFields = await getUserFields() - const userAndExternalAuthAssociation = { - ...userFields, - // TODO: Decouple social from usernameAndPassword auth. - password: uuidv4(), - externalAuthAssociations: { - create: [{ provider, providerId }] - } - } + // For now, we don't have any extra data for the oauth providers, so we just pass an empty object. + const providerData = await sanitizeAndSerializeProviderData({}) + + const user = await createUser( + providerId, + providerData, + userFields, + ) - return createUser(userAndExternalAuthAssociation) + return user.id + } } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts deleted file mode 100644 index ad3df3f415..0000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/defaults.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { generateAvailableDictionaryUsername } from '../../../core/auth.js' - -export async function getUserFieldsFn(_context, _args) { - const username = await generateAvailableDictionaryUsername() - return { username } -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts index 130836ebb8..ac5a56dafe 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/init.ts @@ -71,6 +71,6 @@ function ensureValidConfig(provider: ProviderConfig, config: OAuthConfig): void export type OAuthImports = { npmPackage: string; userDefinedConfigFn?: UserDefinedConfigFn; - getUserFieldsFn: GetUserFieldsFn; oAuthConfig: OAuthConfig; + getUserFieldsFn?: GetUserFieldsFn; }; diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts index 1589e88876..ca1e7a3f50 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/oauth/types.ts @@ -8,11 +8,11 @@ export type OAuthConfig = { scope?: string[]; } -export type CreateOAuthUser = Omit +export type UserFieldsFromOAuthSignup = Prisma.UserCreateInput export type UserDefinedConfigFn = () => { [key: string]: any } export type GetUserFieldsFn = ( context: typeof contextWithUserEntity, args: { profile: { [key: string]: any } }, -) => Promise +) => Promise diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts index 0889298c0a..9defb94486 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/providers/types.ts @@ -1,10 +1,13 @@ import type { Router, Request } from 'express' -import type { User } from '../../entities' +import type { Prisma } from '@prisma/client' import type { Expand } from '../../universal/types' +import type { ProviderName } from '../utils' + +type UserEntityCreateInput = Prisma.UserCreateInput export type ProviderConfig = { // Unique provider identifier, used as part of URL paths - id: string; + id: ProviderName; displayName: string; // Each provider config can have an init method which is ran on setup time // e.g. for oAuth providers this is the time when the Passport strategy is registered. @@ -20,20 +23,14 @@ export type InitData = { export type RequestWithWasp = Request & { wasp?: { [key: string]: any } } -export function createDefineAdditionalSignupFieldsFn< - // Wasp already includes these fields in the signup process - ExistingFields extends keyof User, - PossibleAdditionalFields = Expand< - Partial> +export type PossibleAdditionalSignupFields = Expand> + +export function defineAdditionalSignupFields(config: { + [key in keyof PossibleAdditionalSignupFields]: FieldGetter< + PossibleAdditionalSignupFields[key] > ->() { - return function defineFields(config: { - [key in keyof PossibleAdditionalFields]: FieldGetter< - PossibleAdditionalFields[key] - > - }) { - return config - } +}) { + return config } type FieldGetter = ( diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts new file mode 100644 index 0000000000..a5d987fc4e --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/react-app/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../_types/index' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts index b6d503bec0..ba1ad7074c 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/auth/utils.ts @@ -1,18 +1,49 @@ -import { sign, verify } from '../core/auth.js' +import { hashPassword, sign, verify } from '../core/auth.js' import AuthError from '../core/AuthError.js' import HttpError from '../core/HttpError.js' import prisma from '../dbClient.js' -import { isPrismaError, prismaErrorToHttpError, sleep } from '../utils.js' -import { type User } from '../entities/index.js' -import { type Prisma } from '@prisma/client'; +import { sleep } from '../utils.js' +import { + type User, + type Auth, + type AuthIdentity, +} from '../entities/index.js' +import { Prisma } from '@prisma/client'; import { throwValidationError } from './validation.js' -import { createDefineAdditionalSignupFieldsFn } from './providers/types.js' -const _waspAdditionalSignupFieldsConfig = {} as ReturnType< - ReturnType> -> +import { defineAdditionalSignupFields, type PossibleAdditionalSignupFields } from './providers/types.js' +const _waspAdditionalSignupFieldsConfig = {} as ReturnType + +export type EmailProviderData = { + hashedPassword: string; + isEmailVerified: boolean; + emailVerificationSentAt: string | null; + passwordResetSentAt: string | null; +} + +export type UsernameProviderData = { + hashedPassword: string; +} + +export type OAuthProviderData = {} + +/** + * This type is used for type-level programming e.g. to enumerate + * all possible provider data types. + * + * The keys of this type are the names of the providers and the values + * are the types of the provider data. + */ +export type PossibleProviderData = { + email: EmailProviderData; + username: UsernameProviderData; + google: OAuthProviderData; + github: OAuthProviderData; +} + +export type ProviderName = keyof PossibleProviderData export const contextWithUserEntity = { entities: { @@ -25,31 +56,112 @@ export const authConfig = { successRedirectPath: "/", } -export async function findUserBy(where: Prisma.UserWhereUniqueInput): Promise { - return prisma.user.findUnique({ where }); +/** + * ProviderId uniquely identifies an auth identity e.g. + * "email" provider with user id "test@test.com" or + * "google" provider with user id "1234567890". + * + * We use this type to avoid passing the providerName and providerUserId + * separately. Also, we can normalize the providerUserId to make sure it's + * consistent across different DB operations. + */ +export type ProviderId = { + providerName: ProviderName; + providerUserId: string; } -export async function createUser(data: Prisma.UserCreateInput): Promise { - try { - return await prisma.user.create({ data }) - } catch (e) { - rethrowPossiblePrismaError(e); +export function createProviderId(providerName: ProviderName, providerUserId: string): ProviderId { + return { + providerName, + providerUserId: providerUserId.toLowerCase(), } } -export async function deleteUser(user: User): Promise { - try { - return await prisma.user.delete({ where: { id: user.id } }) - } catch (e) { - rethrowPossiblePrismaError(e); +export async function findAuthIdentity(providerId: ProviderId): Promise { + return prisma.authIdentity.findUnique({ + where: { + providerName_providerUserId: providerId, + } + }); +} + +/** + * Updates the provider data for the given auth identity. + * + * This function performs data sanitization and serialization. + * Sanitization is done by hashing the password, so this function + * expects the password received in the `providerDataUpdates` + * **not to be hashed**. + */ +export async function updateAuthIdentityProviderData( + providerId: ProviderId, + existingProviderData: PossibleProviderData[PN], + providerDataUpdates: Partial, +): Promise { + // We are doing the sanitization here only on updates to avoid + // hashing the password multiple times. + const sanitizedProviderDataUpdates = await sanitizeProviderData(providerDataUpdates); + const newProviderData = { + ...existingProviderData, + ...sanitizedProviderDataUpdates, } + const serializedProviderData = await serializeProviderData(newProviderData); + return prisma.authIdentity.update({ + where: { + providerName_providerUserId: providerId, + }, + data: { providerData: serializedProviderData }, + }); } -export async function createAuthToken(user: User): Promise { - return sign(user.id); +type FindAuthWithUserResult = Auth & { + user: User } -export async function verifyToken(token: string): Promise<{ id: any }> { +export async function findAuthWithUserBy( + where: Prisma.AuthWhereInput +): Promise { + return prisma.auth.findFirst({ where, include: { user: true }}); +} + +export async function createUser( + providerId: ProviderId, + serializedProviderData?: string, + userFields?: PossibleAdditionalSignupFields, +): Promise { + return prisma.user.create({ + data: { + // Using any here to prevent type errors when userFields are not + // defined. We want Prisma to throw an error in that case. + ...(userFields ?? {} as any), + auth: { + create: { + identities: { + create: { + providerName: providerId.providerName, + providerUserId: providerId.providerUserId, + providerData: serializedProviderData, + }, + }, + } + }, + } + }) +} + +export async function deleteUserByAuthId(authId: string): Promise<{ count: number }> { + return prisma.user.deleteMany({ where: { auth: { + id: authId, + } } }) +} + +export async function createAuthToken( + userId: User['id'] +): Promise { + return sign(userId); +} + +export async function verifyToken(token: string): Promise { return verify(token); } @@ -60,24 +172,61 @@ export async function verifyToken(token: string): Promise<{ id: any }> { // NOTE: Attacker measuring time to response can still determine // if a user exists or not. We'll be able to avoid it when // we implement e-mail sending via jobs. -export async function doFakeWork() { +export async function doFakeWork(): Promise { const timeToWork = Math.floor(Math.random() * 1000) + 1000; return sleep(timeToWork); } -export function rethrowPossiblePrismaError(e: unknown): void { +export function rethrowPossibleAuthError(e: unknown): void { if (e instanceof AuthError) { throwValidationError(e.message); - } else if (isPrismaError(e)) { - throw prismaErrorToHttpError(e) - } else { - throw new HttpError(500) } + + // Prisma code P2002 is for unique constraint violations. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') { + throw new HttpError(422, 'Save failed', { + message: `user with the same identity already exists`, + }) + } + + if (e instanceof Prisma.PrismaClientValidationError) { + // NOTE: Logging the error since this usually means that there are + // required fields missing in the request, we want the developer + // to know about it. + console.error(e) + throw new HttpError(422, 'Save failed', { + message: 'there was a database error' + }) + } + + // Prisma code P2021 is for missing table errors. + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2021') { + // NOTE: Logging the error since this usually means that the database + // migrations weren't run, we want the developer to know about it. + console.error(e) + console.info('🐝 This error can happen if you did\'t run the database migrations.') + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + // Prisma code P2003 is for foreign key constraint failure + if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2003') { + console.error(e) + console.info(`🐝 This error can happen if you have some relation on your User entity + but you didn't specify the "onDelete" behaviour to either "Cascade" or "SetNull". + Read more at: https://www.prisma.io/docs/orm/prisma-schema/data-model/relations/referential-actions`) + throw new HttpError(500, 'Save failed', { + message: `there was a database error`, + }) + } + + throw e } export async function validateAndGetAdditionalFields(data: { [key: string]: unknown -}) { +}): Promise> { const { password: _password, ...sanitizedData @@ -93,3 +242,49 @@ export async function validateAndGetAdditionalFields(data: { } return result; } + +export function deserializeAndSanitizeProviderData( + providerData: string, + { shouldRemovePasswordField = false }: { shouldRemovePasswordField?: boolean } = {}, +): PossibleProviderData[PN] { + // NOTE: We are letting JSON.parse throw an error if the providerData is not valid JSON. + let data = JSON.parse(providerData) as PossibleProviderData[PN]; + + if (providerDataHasPasswordField(data) && shouldRemovePasswordField) { + delete data.hashedPassword; + } + + return data; +} + +export async function sanitizeAndSerializeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + return serializeProviderData( + await sanitizeProviderData(providerData) + ); +} + +function serializeProviderData(providerData: PossibleProviderData[PN]): string { + return JSON.stringify(providerData); +} + +async function sanitizeProviderData( + providerData: PossibleProviderData[PN], +): Promise { + const data = { + ...providerData, + }; + if (providerDataHasPasswordField(data)) { + data.hashedPassword = await hashPassword(data.hashedPassword); + } + + return data; +} + + +function providerDataHasPasswordField( + providerData: PossibleProviderData[keyof PossibleProviderData], +): providerData is { hashedPassword: string } { + return 'hashedPassword' in providerData; +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js index 11c884307d..9c8c03ce91 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth.js @@ -7,13 +7,15 @@ import prisma from '../dbClient.js' import { handleRejection } from '../utils.js' import HttpError from '../core/HttpError.js' import config from '../config.js' +import { deserializeAndSanitizeProviderData } from '../auth/utils.js' const jwtSign = util.promisify(jwt.sign) const jwtVerify = util.promisify(jwt.verify) const JWT_SECRET = config.auth.jwtSecret -export const sign = (id, options) => jwtSign({ id }, JWT_SECRET, options) +export const signData = (data, options) => jwtSign(data, JWT_SECRET, options) +export const sign = (id, options) => signData({ id }, options) export const verify = (token) => jwtVerify(token, JWT_SECRET) const auth = handleRejection(async (req, res, next) => { @@ -47,7 +49,17 @@ export async function getUserFromToken(token) { } } - const user = await prisma.user.findUnique({ where: { id: userIdFromToken } }) + const user = await prisma.user + .findUnique({ + where: { id: userIdFromToken }, + include: { + auth: { + include: { + identities: true + } + } + } + }) if (!user) { throwInvalidCredentialsError() } @@ -56,9 +68,12 @@ export async function getUserFromToken(token) { // password field from the object here, we must to do the same there). // Ideally, these two things would live in the same place: // https://github.com/wasp-lang/wasp/issues/965 - const { password, ...userView } = user - - return userView + let sanitizedUser = { ...user } + sanitizedUser.auth.identities = sanitizedUser.auth.identities.map(identity => { + identity.providerData = deserializeAndSanitizeProviderData(identity.providerData, { shouldRemovePasswordField: true }) + return identity + }); + return sanitizedUser } const SP = new SecurePassword() diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js deleted file mode 100644 index 44eff27892..0000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/core/auth/prismaMiddleware.js +++ /dev/null @@ -1,30 +0,0 @@ -import { hashPassword } from '../auth.js' -import { PASSWORD_FIELD } from '../../auth/validation.js' - -// Make sure password is always hashed before storing to the database. -const registerPasswordHashing = (prismaClient) => { - prismaClient.$use(async (params, next) => { - if (params.model === 'User') { - if (['create', 'update', 'updateMany'].includes(params.action)) { - if (params.args.data.hasOwnProperty(PASSWORD_FIELD)) { - params.args.data[PASSWORD_FIELD] = await hashPassword(params.args.data[PASSWORD_FIELD]) - } - } else if (params.action === 'upsert') { - if (params.args.create.hasOwnProperty(PASSWORD_FIELD)) { - params.args.create[PASSWORD_FIELD] = - await hashPassword(params.args.create[PASSWORD_FIELD]) - } - if (params.args.update.hasOwnProperty(PASSWORD_FIELD)) { - params.args.update[PASSWORD_FIELD] = - await hashPassword(params.args.update[PASSWORD_FIELD]) - } - } - } - - return next(params) - }) -} - -export const registerAuthMiddleware = (prismaClient) => { - registerPasswordHashing(prismaClient) -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts index 0306683e35..e2de0a4a3a 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/crud/tasks.ts @@ -5,9 +5,7 @@ import type { AuthenticatedQuery, _Task, } from "../_types"; -import type { - Prisma, -} from "@prisma/client"; +import type { Prisma } from "@prisma/client"; import { Payload } from "../_types/serialization.js"; import type { Task, diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts index c0720735ed..66e7801be3 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/dbClient.ts @@ -1,12 +1,9 @@ import Prisma from '@prisma/client' -import { registerAuthMiddleware } from './core/auth/prismaMiddleware.js' const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - registerAuthMiddleware(prismaClient) - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts index c31c1b7724..d5eec2c3a2 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/entities/index.ts @@ -8,6 +8,8 @@ export { type User, type SocialLogin, type Task, + type Auth, + type AuthIdentity, } from "@prisma/client" export type Entity = diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts new file mode 100644 index 0000000000..a930149d08 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/server/src/utils.ts @@ -0,0 +1,66 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + +import { type SanitizedUser } from './_types/index.js' + +type RequestWithExtraFields = Request & { + user?: SanitizedUser +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts index b9614390c4..4405410cc7 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/types.ts @@ -1,2 +1,2 @@ // todo(filip): turn into a proper import/path -export type { SanitizedUser as User } from '../../../server/src/_types/' +export type { SanitizedUser as User, ProviderName, DeserializedAuthEntity } from '../../../server/src/_types/' diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts new file mode 100644 index 0000000000..5799c71ea7 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/auth/user.ts @@ -0,0 +1,27 @@ +// We decided not to deduplicate these helper functions in the server and the client. +// We have them duplicated in this file and in data/Generator/templates/server/src/auth/user.ts +// If you are changing the logic here, make sure to change it there as well. + +import type { User, ProviderName, DeserializedAuthEntity } from './types' + +export function getEmail(user: User): string | null { + return findUserIdentity(user, "email")?.providerUserId ?? null; +} + +export function getUsername(user: User): string | null { + return findUserIdentity(user, "username")?.providerUserId ?? null; +} + +export function getFirstProviderUserId(user?: User): string | null { + if (!user || !user.auth || !user.auth.identities || user.auth.identities.length === 0) { + return null; + } + + return user.auth.identities[0].providerUserId ?? null; +} + +export function findUserIdentity(user: User, providerName: ProviderName): DeserializedAuthEntity | undefined { + return user.auth.identities.find( + (identity) => identity.providerName === providerName + ); +} diff --git a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts index 6435f6bd35..38ae6521e9 100644 --- a/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts +++ b/waspc/e2e-test/test-outputs/waspComplexTest-golden/waspComplexTest/.wasp/out/web-app/src/entities/index.ts @@ -8,6 +8,8 @@ export type { User, SocialLogin, Task, + Auth, + AuthIdentity, } from '@prisma/client' export type Entity = diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest index 1d8986f7d2..356ddfd991 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspJob-golden/files.manifest @@ -38,7 +38,7 @@ waspJob/.wasp/out/server/src/server.ts waspJob/.wasp/out/server/src/types/index.ts waspJob/.wasp/out/server/src/universal/types.ts waspJob/.wasp/out/server/src/universal/url.ts -waspJob/.wasp/out/server/src/utils.js +waspJob/.wasp/out/server/src/utils.ts waspJob/.wasp/out/server/tsconfig.json waspJob/.wasp/out/web-app/.env waspJob/.wasp/out/web-app/.npmrc diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums index 661e200e8a..66b385cb14 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/.waspchecksums @@ -137,7 +137,7 @@ "file", "server/src/dbClient.ts" ], - "20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43" + "f26bfa4a60022d45195f3179548c4b09305f6a9cfe301b5edd1615677a59ae3f" ], [ [ @@ -268,9 +268,9 @@ [ [ "file", - "server/src/utils.js" + "server/src/utils.ts" ], - "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" + "6a664e44a89efe8ed8cb7559a84bd2871422c38287b276fee2a7aed4680e1d1e" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts index 11b87caa50..66e7801be3 100644 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/dbClient.ts @@ -4,7 +4,6 @@ import Prisma from '@prisma/client' const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts new file mode 100644 index 0000000000..99492c5474 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspJob-golden/waspJob/.wasp/out/server/src/utils.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + + +type RequestWithExtraFields = Request & { +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest index f663697234..a8c4f8eeb5 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/files.manifest @@ -36,7 +36,7 @@ waspMigrate/.wasp/out/server/src/server.ts waspMigrate/.wasp/out/server/src/types/index.ts waspMigrate/.wasp/out/server/src/universal/types.ts waspMigrate/.wasp/out/server/src/universal/url.ts -waspMigrate/.wasp/out/server/src/utils.js +waspMigrate/.wasp/out/server/src/utils.ts waspMigrate/.wasp/out/server/tsconfig.json waspMigrate/.wasp/out/web-app/.env waspMigrate/.wasp/out/web-app/.npmrc diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums index bffd68da92..a2bc2feb86 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/.waspchecksums @@ -137,7 +137,7 @@ "file", "server/src/dbClient.ts" ], - "20c67ca197da3de2d37528ceaff2e40af910be8177f346c6d5c2b2f983810c43" + "f26bfa4a60022d45195f3179548c4b09305f6a9cfe301b5edd1615677a59ae3f" ], [ [ @@ -226,9 +226,9 @@ [ [ "file", - "server/src/utils.js" + "server/src/utils.ts" ], - "300e9bb586b163f2608acb27346b5c94ec3e58cdc25dace5381f3d0c6710a7ec" + "6a664e44a89efe8ed8cb7559a84bd2871422c38287b276fee2a7aed4680e1d1e" ], [ [ diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts index 11b87caa50..66e7801be3 100644 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/dbClient.ts @@ -4,7 +4,6 @@ import Prisma from '@prisma/client' const createDbClient = () => { const prismaClient = new Prisma.PrismaClient() - return prismaClient } diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js deleted file mode 100644 index d6c7ed639a..0000000000 --- a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.js +++ /dev/null @@ -1,79 +0,0 @@ -import Prisma from '@prisma/client' -import HttpError from './core/HttpError.js' - -import { readdir } from "fs"; -import { join, dirname } from "path"; -import { fileURLToPath } from 'url'; - -/** - * Decorator for async express middleware that handles promise rejections. - * @param {Func} middleware - Express middleware function. - * @returns {Func} Express middleware that is exactly the same as the given middleware but, - * if given middleware returns promise, reject of that promise will be correctly handled, - * meaning that error will be forwarded to next(). - */ -export const handleRejection = (middleware) => async (req, res, next) => { - try { - await middleware(req, res, next) - } catch (error) { - next(error) - } -} - -export const isPrismaError = (e) => { - return e instanceof Prisma.PrismaClientKnownRequestError || - e instanceof Prisma.PrismaClientUnknownRequestError || - e instanceof Prisma.PrismaClientRustPanicError || - e instanceof Prisma.PrismaClientInitializationError || - e instanceof Prisma.PrismaClientValidationError -} - -export const prismaErrorToHttpError = (e) => { - if (e instanceof Prisma.PrismaClientKnownRequestError) { - if (e.code === 'P2002') { - return new HttpError(422, 'Save failed', { - message: `user with the same ${e.meta.target.join(', ')} already exists`, - target: e.meta.target - }) - } else { - // TODO(shayne): Go through https://www.prisma.io/docs/reference/api-reference/error-reference#error-codes - // and decide which are input errors (422) and which are not (500) - // See: https://github.com/wasp-lang/wasp/issues/384 - return new HttpError(500) - } - } else if (e instanceof Prisma.PrismaClientValidationError) { - return new HttpError(422, 'Save failed') - } else { - return new HttpError(500) - } -} - -export const sleep = ms => new Promise(r => setTimeout(r, ms)) - -export function getDirFromFileUrl(fileUrl) { - return fileURLToPath(dirname(fileUrl)); -} - -export async function importJsFilesFromDir(absoluteDir, relativePath, whitelist = null) { - const pathToDir = join(absoluteDir, relativePath); - - return new Promise((resolve, reject) => { - readdir(pathToDir, async (err, files) => { - if (err) { - return reject(err); - } - const importPromises = files - .filter((file) => file.endsWith(".js") && isWhitelisted(file)) - .map((file) => import(`${pathToDir}/${file}`)); - resolve(Promise.all(importPromises)); - }); - }); - - function isWhitelisted(file) { - // No whitelist means all files are whitelisted - if (!Array.isArray(whitelist)) { - return true; - } - return whitelist.some((whitelistedFile) => file.endsWith(whitelistedFile)); - } -} diff --git a/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts new file mode 100644 index 0000000000..99492c5474 --- /dev/null +++ b/waspc/e2e-test/test-outputs/waspMigrate-golden/waspMigrate/.wasp/out/server/src/utils.ts @@ -0,0 +1,64 @@ +import crypto from 'crypto' +import { Request, Response, NextFunction } from 'express' + +import { readdir } from 'fs' +import { dirname } from 'path' +import { fileURLToPath } from 'url' + + +type RequestWithExtraFields = Request & { +} + +/** + * Decorator for async express middleware that handles promise rejections. + * @param {Func} middleware - Express middleware function. + * @returns Express middleware that is exactly the same as the given middleware but, + * if given middleware returns promise, reject of that promise will be correctly handled, + * meaning that error will be forwarded to next(). + */ +export const handleRejection = ( + middleware: ( + req: RequestWithExtraFields, + res: Response, + next: NextFunction + ) => any +) => +async (req: RequestWithExtraFields, res: Response, next: NextFunction) => { + try { + await middleware(req, res, next) + } catch (error) { + next(error) + } +} + +export const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) + +export function getDirPathFromFileUrl(fileUrl: string): string { + return fileURLToPath(dirname(fileUrl)) +} + +export async function importJsFilesFromDir( + pathToDir: string, + whitelistedFileNames: string[] | null = null +): Promise { + return new Promise((resolve, reject) => { + readdir(pathToDir, async (err, files) => { + if (err) { + return reject(err) + } + const importPromises = files + .filter((file) => file.endsWith('.js') && isWhitelistedFileName(file)) + .map((file) => import(`${pathToDir}/${file}`)) + resolve(Promise.all(importPromises)) + }) + }) + + function isWhitelistedFileName(fileName: string) { + // No whitelist means all files are whitelisted + if (!Array.isArray(whitelistedFileNames)) { + return true + } + + return whitelistedFileNames.some((whitelistedFileName) => fileName === whitelistedFileName) + } +} diff --git a/waspc/examples/crud-testing/main.wasp b/waspc/examples/crud-testing/main.wasp index 06f0573feb..1e1807d030 100644 --- a/waspc/examples/crud-testing/main.wasp +++ b/waspc/examples/crud-testing/main.wasp @@ -1,6 +1,6 @@ app crudTesting { wasp: { - version: "^0.11.0" + version: "^0.12.0" }, head: [ "" @@ -20,7 +20,10 @@ app crudTesting { ("zod", "^3.22.2") ], db: { - system: PostgreSQL + system: PostgreSQL, + seeds: [ + import { migrateAuth } from "@server/seeds/migrateAuth.js" + ] } } @@ -37,7 +40,7 @@ page LoginPage { route SignupRoute { path: "/signup", to: SignupPage } page SignupPage { - component: import { SignupPage } from "@client/CustomSignupPage.tsx", + component: import { SignupPage } from "@client/SignupPage.tsx", } route DetailRoute { path: "/:id/:something?", to: DetailPage } @@ -48,8 +51,6 @@ page DetailPage { entity User {=psl id Int @id @default(autoincrement()) - username String @unique - password String address String? tasks Task[] psl=} @@ -91,5 +92,4 @@ job simplePrintJob { action customSignup { fn: import { signup } from "@server/auth.js", - entities: [User] } \ No newline at end of file diff --git a/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql b/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql new file mode 100644 index 0000000000..280d1f5fc5 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231124155039_add_auth/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL, + "email" TEXT, + "username" TEXT, + "password" TEXT, + "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, + "emailVerificationSentAt" TIMESTAMP(3), + "passwordResetSentAt" TIMESTAMP(3), + "userId" INTEGER, + + CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SocialAuthProvider" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "authId" TEXT NOT NULL, + + CONSTRAINT "SocialAuthProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- AddForeignKey +ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SocialAuthProvider" ADD CONSTRAINT "SocialAuthProvider_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql b/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql new file mode 100644 index 0000000000..dcc286e347 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212120120_inital/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the `SocialAuthProvider` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SocialAuthProvider" DROP CONSTRAINT "SocialAuthProvider_authId_fkey"; + +-- DropTable +DROP TABLE "SocialAuthProvider"; + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") +); + +-- AddForeignKey +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql b/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql new file mode 100644 index 0000000000..90ca6265e3 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212132054_random/migration.sql @@ -0,0 +1,24 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `emailVerificationSentAt` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `isEmailVerified` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `password` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `passwordResetSentAt` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `username` on the `Auth` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Auth_email_key"; + +-- DropIndex +DROP INDEX "Auth_username_key"; + +-- AlterTable +ALTER TABLE "Auth" DROP COLUMN "email", +DROP COLUMN "emailVerificationSentAt", +DROP COLUMN "isEmailVerified", +DROP COLUMN "password", +DROP COLUMN "passwordResetSentAt", +DROP COLUMN "username"; diff --git a/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql b/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql new file mode 100644 index 0000000000..5ad32a2c57 --- /dev/null +++ b/waspc/examples/crud-testing/migrations/20231212132224_thrid/migration.sql @@ -0,0 +1,13 @@ +/* + Warnings: + + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `username` on the `User` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "User_username_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "password", +DROP COLUMN "username"; diff --git a/waspc/examples/crud-testing/src/client/MainPage.tsx b/waspc/examples/crud-testing/src/client/MainPage.tsx index 96d604d314..6f53c22f02 100644 --- a/waspc/examples/crud-testing/src/client/MainPage.tsx +++ b/waspc/examples/crud-testing/src/client/MainPage.tsx @@ -1,64 +1,65 @@ -import "./Main.css"; +import './Main.css' -import React, { useState } from "react"; -import { Link, routes } from "@wasp/router"; -import logout from "@wasp/auth/logout"; +import React, { useState } from 'react' +import { Link, routes } from '@wasp/router' +import logout from '@wasp/auth/logout' +import { getUsername } from '@wasp/auth/user' -import { tasks as tasksCrud } from "@wasp/crud/tasks"; -import { User } from "@wasp/entities"; +import { tasks as tasksCrud } from '@wasp/crud/tasks' +import { User } from '@wasp/entities' -const MainPage = ({ user }: { user: User }) => { - const { data: tasks, isLoading } = tasksCrud.getAll.useQuery(); +const MainPage = () => { + const { data: tasks, isLoading } = tasksCrud.getAll.useQuery() - type Task = NonNullable[number]; + type Task = NonNullable[number] - const createTask = tasksCrud.create.useAction(); - const deleteTask = tasksCrud.delete.useAction(); - const updateTask = tasksCrud.update.useAction(); + const createTask = tasksCrud.create.useAction() + const deleteTask = tasksCrud.delete.useAction() + const updateTask = tasksCrud.update.useAction() - const [newTaskTitle, setNewTaskTitle] = useState(""); - const [editTaskTitle, setEditTaskTitle] = useState(""); - const [error, setError] = useState(""); - const [isEditing, setIsEditing] = useState(null); + const [newTaskTitle, setNewTaskTitle] = useState('') + const [editTaskTitle, setEditTaskTitle] = useState('') + const [error, setError] = useState('') + const [isEditing, setIsEditing] = useState(null) async function handleCreateTask(e: React.FormEvent) { - setError(""); - e.preventDefault(); + setError('') + e.preventDefault() try { await createTask({ title: newTaskTitle, - }); + }) } catch (err: unknown) { - setError(`Error creating task: ${err as any}`); + setError(`Error creating task: ${err as any}`) } - setNewTaskTitle(""); + setNewTaskTitle('') } async function handleUpdateTask(e: React.FormEvent) { - setError(""); - e.preventDefault(); + setError('') + e.preventDefault() try { - await updateTask({ id: isEditing!, title: editTaskTitle }); + await updateTask({ id: isEditing!, title: editTaskTitle }) } catch (err: unknown) { - setError("Error updating task."); + setError('Error updating task.') } - setIsEditing(null); - setEditTaskTitle(""); + setIsEditing(null) + setEditTaskTitle('') } function handleStartEditing(task: { id: number; title: string }) { - setIsEditing(task.id); - setEditTaskTitle(task.title); + setIsEditing(task.id) + setEditTaskTitle(task.title) } async function handleTaskDelete(task: { id: number }) { try { - if (!confirm("Are you sure you want to delete this task?")) { - return; + if (!confirm('Are you sure you want to delete this task?')) { + return } - await deleteTask({ id: task.id }); + await deleteTask({ id: task.id }) } catch (err: unknown) { - setError("Error deleting task."); + setError('Error deleting task.') } } @@ -92,12 +93,13 @@ const MainPage = ({ user }: { user: User }) => {
- Visit {task.title} at{" "} + Visit {task.title} at{' '} {routes.DetailRoute.build({ - params: { id: task.id, something: "else" }, - })} + params: { id: task.id, something: 'else' }, + })}{' '} + by {getUsername(task.user)}
@@ -126,7 +128,7 @@ const MainPage = ({ user }: { user: User }) => {
- ); -}; + ) +} -export default MainPage; +export default MainPage diff --git a/waspc/examples/crud-testing/src/server/auth.ts b/waspc/examples/crud-testing/src/server/auth.ts index 32dcd388e8..c0b111b628 100644 --- a/waspc/examples/crud-testing/src/server/auth.ts +++ b/waspc/examples/crud-testing/src/server/auth.ts @@ -5,6 +5,9 @@ import { ensureValidPassword, ensureValidUsername, } from '@wasp/auth/validation.js' +import prisma from '@wasp/dbClient.js' +import { CustomSignup } from '@wasp/actions/types' +import { sanitizeAndSerializeProviderData } from '@wasp/auth/utils.js' export const fields = defineAdditionalSignupFields({ address: (data) => { @@ -23,8 +26,6 @@ export const fields = defineAdditionalSignupFields({ }, }) -import { CustomSignup } from '@wasp/actions/types' - type CustomSignupInput = { username: string password: string @@ -38,17 +39,28 @@ type CustomSignupOutput = { export const signup: CustomSignup< CustomSignupInput, CustomSignupOutput -> = async (args, { entities: { User } }) => { +> = async (args) => { ensureValidUsername(args) ensurePasswordIsPresent(args) ensureValidPassword(args) try { - await User.create({ + await prisma.auth.create({ data: { - username: args.username, - password: args.password, - address: args.address, + user: { + create: { + address: args.address, + }, + }, + identities: { + create: { + providerName: 'username', + providerUserId: args.username, + providerData: await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: args.password, + }), + }, + }, }, }) } catch (e: any) { diff --git a/waspc/examples/crud-testing/src/server/seeds/migrateAuth.ts b/waspc/examples/crud-testing/src/server/seeds/migrateAuth.ts new file mode 100644 index 0000000000..1739958489 --- /dev/null +++ b/waspc/examples/crud-testing/src/server/seeds/migrateAuth.ts @@ -0,0 +1,45 @@ +import { sanitizeAndSerializeProviderData } from '@wasp/auth/utils' +import prisma from '@wasp/dbClient.js' + +export async function migrateAuth(db: typeof prisma) { + // 0. Update to the latest version of Wasp and run `wasp db migrate-dev` + // 1. Run this migration script + // 2. Then remove the username & password fields from User model + const users = await db.user.findMany() + + for (let user of users) { + const username = (user as any).username + const authIdentity = await db.authIdentity.findUnique({ + where: { + providerName_providerUserId: { + providerName: 'username', + providerUserId: username, + }, + }, + }) + + // If authIdentity already exists, skip this user + if (authIdentity) { + continue + } + + db.auth.create({ + data: { + user: { + connect: { + id: user.id, + }, + }, + identities: { + create: { + providerName: 'username', + providerUserId: username, + providerData: await sanitizeAndSerializeProviderData<'username'>({ + hashedPassword: (user as any).password, + }), + }, + }, + }, + }) + } +} diff --git a/waspc/examples/crud-testing/src/server/tasks.ts b/waspc/examples/crud-testing/src/server/tasks.ts index a79116b848..6c5c820a01 100644 --- a/waspc/examples/crud-testing/src/server/tasks.ts +++ b/waspc/examples/crud-testing/src/server/tasks.ts @@ -1,59 +1,47 @@ -import HttpError from "@wasp/core/HttpError.js"; -import type { GetQuery, GetAllQuery, CreateAction } from "@wasp/crud/tasks"; -import { Task, User } from "@wasp/entities"; -import { simplePrintJob } from "@wasp/jobs/simplePrintJob.js"; +import HttpError from '@wasp/core/HttpError.js' +import type { GetQuery, GetAllQuery, CreateAction } from '@wasp/crud/tasks' +import { Task } from '@wasp/entities' export const getTask = (async (args, context) => { return context.entities.Task.findUnique({ where: { id: args.id }, include: { - user: { select: { username: true } }, + user: { + // include: { + // auth: { + // select: { username: true }, + // }, + // }, + }, }, - }); -}) satisfies GetQuery< - { id: Task["id"] }, - | (Task & { - user: Pick; - }) - | null ->; + }) +}) satisfies GetQuery<{ id: Task['id'] }, {}> export const getAllTasks = (async (args, context) => { - const result = await simplePrintJob.submit({ - name: "moje ime", - }); - - await new Promise((resolve) => setTimeout(resolve, 3000)); - - const details = await result.pgBoss.details(); - - if (details && details.state === "completed") { - console.log("Job started with data:", details.data); - console.log("Job completed with output:", details.output.tasks); - } else if (details) { - console.log("Job state and output", details.state, details.output); - } - return context.entities.Task.findMany({ - orderBy: { id: "desc" }, + orderBy: { id: 'desc' }, select: { id: true, title: true, user: { - select: { - username: true, + include: { + auth: { + include: { + identities: true, + }, + }, }, }, }, - }); -}) satisfies GetAllQuery<{}, {}>; + }) +}) satisfies GetAllQuery<{}, {}> export const createTask = (async (args, context) => { if (!context.user) { - throw new HttpError(401, "You must be logged in to create a task."); + throw new HttpError(401, 'You must be logged in to create a task.') } if (!args.title) { - throw new HttpError(400, "Task title is required."); + throw new HttpError(400, 'Task title is required.') } return context.entities.Task.create({ data: { @@ -64,5 +52,5 @@ export const createTask = (async (args, context) => { }, }, }, - }); -}) satisfies CreateAction<{ title: Task["title"] }, Task>; + }) +}) satisfies CreateAction<{ title: Task['title'] }, Task> diff --git a/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql b/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql new file mode 100644 index 0000000000..280d1f5fc5 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231124161113_initial/migration.sql @@ -0,0 +1,38 @@ +-- CreateTable +CREATE TABLE "Auth" ( + "id" TEXT NOT NULL, + "email" TEXT, + "username" TEXT, + "password" TEXT, + "isEmailVerified" BOOLEAN NOT NULL DEFAULT false, + "emailVerificationSentAt" TIMESTAMP(3), + "passwordResetSentAt" TIMESTAMP(3), + "userId" INTEGER, + + CONSTRAINT "Auth_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SocialAuthProvider" ( + "id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "providerId" TEXT NOT NULL, + "authId" TEXT NOT NULL, + + CONSTRAINT "SocialAuthProvider_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_email_key" ON "Auth"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_username_key" ON "Auth"("username"); + +-- CreateIndex +CREATE UNIQUE INDEX "Auth_userId_key" ON "Auth"("userId"); + +-- AddForeignKey +ALTER TABLE "Auth" ADD CONSTRAINT "Auth_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SocialAuthProvider" ADD CONSTRAINT "SocialAuthProvider_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql b/waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql new file mode 100644 index 0000000000..8c10eaf5f5 --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231124161208_remove_extras/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `emailVerificationSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `isEmailVerified` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + - You are about to drop the column `passwordResetSentAt` on the `User` table. All the data in the column will be lost. + - You are about to drop the `SocialLogin` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SocialLogin" DROP CONSTRAINT "SocialLogin_userId_fkey"; + +-- DropIndex +DROP INDEX "User_email_key"; + +-- AlterTable +ALTER TABLE "User" DROP COLUMN "email", +DROP COLUMN "emailVerificationSentAt", +DROP COLUMN "isEmailVerified", +DROP COLUMN "password", +DROP COLUMN "passwordResetSentAt"; + +-- DropTable +DROP TABLE "SocialLogin"; diff --git a/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql b/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql new file mode 100644 index 0000000000..8d5980ab4a --- /dev/null +++ b/waspc/examples/todoApp/migrations/20231212135316_next/migration.sql @@ -0,0 +1,44 @@ +/* + Warnings: + + - You are about to drop the column `email` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `emailVerificationSentAt` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `isEmailVerified` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `password` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `passwordResetSentAt` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the column `username` on the `Auth` table. All the data in the column will be lost. + - You are about to drop the `SocialAuthProvider` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- DropForeignKey +ALTER TABLE "SocialAuthProvider" DROP CONSTRAINT "SocialAuthProvider_authId_fkey"; + +-- DropIndex +DROP INDEX "Auth_email_key"; + +-- DropIndex +DROP INDEX "Auth_username_key"; + +-- AlterTable +ALTER TABLE "Auth" DROP COLUMN "email", +DROP COLUMN "emailVerificationSentAt", +DROP COLUMN "isEmailVerified", +DROP COLUMN "password", +DROP COLUMN "passwordResetSentAt", +DROP COLUMN "username"; + +-- DropTable +DROP TABLE "SocialAuthProvider"; + +-- CreateTable +CREATE TABLE "AuthIdentity" ( + "providerName" TEXT NOT NULL, + "providerUserId" TEXT NOT NULL, + "providerData" TEXT NOT NULL DEFAULT '{}', + "authId" TEXT NOT NULL, + + CONSTRAINT "AuthIdentity_pkey" PRIMARY KEY ("providerName","providerUserId") +); + +-- AddForeignKey +ALTER TABLE "AuthIdentity" ADD CONSTRAINT "AuthIdentity_authId_fkey" FOREIGN KEY ("authId") REFERENCES "Auth"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/waspc/examples/todoApp/src/client/App.tsx b/waspc/examples/todoApp/src/client/App.tsx index 94c45f9541..acdbe144a7 100644 --- a/waspc/examples/todoApp/src/client/App.tsx +++ b/waspc/examples/todoApp/src/client/App.tsx @@ -7,6 +7,7 @@ import getDate from '@wasp/queries/getDate' import { useSocket } from '@wasp/webSocket' import './Main.css' +import { getName } from './user' export function App({ children }: any) { const { data: user } = useAuth() @@ -27,7 +28,7 @@ export function App({ children }: any) { {user && (
- Hello, {user.email} + Hello, {getName(user)}