diff --git a/modules/auth/config.ts b/modules/auth/config.ts deleted file mode 100644 index 74110e30..00000000 --- a/modules/auth/config.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface Config { - email?: EmailConfig; -} - -export interface EmailConfig { - fromEmail: string; - fromName?: string; -} diff --git a/modules/auth/db/migrations/20240310214734_init/migration.sql b/modules/auth/db/migrations/20240310214734_init/migration.sql deleted file mode 100644 index 083e45d2..00000000 --- a/modules/auth/db/migrations/20240310214734_init/migration.sql +++ /dev/null @@ -1,48 +0,0 @@ --- CreateTable -CREATE TABLE "Identity" ( - "userId" UUID NOT NULL, - "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deletedAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "Identity_pkey" PRIMARY KEY ("userId") -); - --- CreateTable -CREATE TABLE "EmailPasswordless" ( - "id" UUID NOT NULL, - "identityId" UUID NOT NULL, - "email" TEXT NOT NULL, - "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - - CONSTRAINT "EmailPasswordless_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "EmailPasswordlessVerification" ( - "id" UUID NOT NULL, - "identityId" UUID, - "email" TEXT NOT NULL, - "code" TEXT NOT NULL, - "attemptCount" INTEGER NOT NULL DEFAULT 0, - "maxAttemptCount" INTEGER NOT NULL, - "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - "expireAt" TIMESTAMP NOT NULL, - "completedAt" TIMESTAMP, - - CONSTRAINT "EmailPasswordlessVerification_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE UNIQUE INDEX "EmailPasswordless_email_key" ON "EmailPasswordless"("email"); - --- CreateIndex -CREATE UNIQUE INDEX "EmailPasswordlessVerification_code_key" ON "EmailPasswordlessVerification"("code"); - --- AddForeignKey -ALTER TABLE "EmailPasswordless" ADD CONSTRAINT "EmailPasswordless_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_email_fkey" FOREIGN KEY ("email") REFERENCES "EmailPasswordless"("email") ON DELETE RESTRICT ON UPDATE CASCADE; - --- AddForeignKey -ALTER TABLE "EmailPasswordlessVerification" ADD CONSTRAINT "EmailPasswordlessVerification_identityId_fkey" FOREIGN KEY ("identityId") REFERENCES "Identity"("userId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/modules/auth/db/migrations/20240312024843_init/migration.sql b/modules/auth/db/migrations/20240312024843_init/migration.sql deleted file mode 100644 index c268e246..00000000 --- a/modules/auth/db/migrations/20240312024843_init/migration.sql +++ /dev/null @@ -1,12 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `identityId` on the `EmailPasswordlessVerification` table. All the data in the column will be lost. - -*/ --- DropForeignKey -ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_identityId_fkey"; - --- AlterTable -ALTER TABLE "EmailPasswordlessVerification" DROP COLUMN "identityId", -ADD COLUMN "userId" UUID; diff --git a/modules/auth/db/migrations/20240312033322_/migration.sql b/modules/auth/db/migrations/20240312033322_/migration.sql deleted file mode 100644 index ba914d3a..00000000 --- a/modules/auth/db/migrations/20240312033322_/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- DropForeignKey -ALTER TABLE "EmailPasswordlessVerification" DROP CONSTRAINT "EmailPasswordlessVerification_email_fkey"; diff --git a/modules/auth/db/migrations/20240312035811_/migration.sql b/modules/auth/db/migrations/20240312035811_/migration.sql deleted file mode 100644 index 32460756..00000000 --- a/modules/auth/db/migrations/20240312035811_/migration.sql +++ /dev/null @@ -1,21 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `identityId` on the `EmailPasswordless` table. All the data in the column will be lost. - - You are about to drop the `Identity` table. If the table is not empty, all the data it contains will be lost. - - A unique constraint covering the columns `[userId]` on the table `EmailPasswordless` will be added. If there are existing duplicate values, this will fail. - - Added the required column `userId` to the `EmailPasswordless` table without a default value. This is not possible if the table is not empty. - -*/ --- DropForeignKey -ALTER TABLE "EmailPasswordless" DROP CONSTRAINT "EmailPasswordless_identityId_fkey"; - --- AlterTable -ALTER TABLE "EmailPasswordless" DROP COLUMN "identityId", -ADD COLUMN "userId" UUID NOT NULL; - --- DropTable -DROP TABLE "Identity"; - --- CreateIndex -CREATE UNIQUE INDEX "EmailPasswordless_userId_key" ON "EmailPasswordless"("userId"); diff --git a/modules/auth/module.json b/modules/auth/module.json deleted file mode 100644 index 0b7d940f..00000000 --- a/modules/auth/module.json +++ /dev/null @@ -1,52 +0,0 @@ -{ - "name": "Authentication", - "description": "Authenticate users with multiple authentication methods.", - "icon": "key", - "tags": [ - "core", - "auth", - "user" - ], - "authors": [ - "rivet-gg", - "NathanFlurry" - ], - "status": "stable", - "dependencies": { - "email": {}, - "users": {}, - "rate_limit": {} - }, - "scripts": { - "send_email_verification": { - "name": "Send Email Verification", - "description": "Send a one-time verification code to a user's email address to authenticate them.", - "public": true - }, - "complete_email_verification": { - "name": "Complete Email Verification", - "description": "Verify a user's email address with a one-time verification code.", - "public": true - } - }, - "errors": { - "provider_disabled": { - "name": "Provider Disabled" - }, - "verification_code_invalid": { - "name": "Verification Code Invalid" - }, - "verification_code_attempt_limit": { - "name": "Verification Code Attempt Limit" - }, - "verification_code_expired": { - "name": "Verification Code Expired" - }, - "verification_code_already_used": { - "name": "Verification Code Already Used" - }, - "email_already_used": { - "name": "Email Already Used" - } - } -} diff --git a/modules/auth/scripts/complete_email_verification.ts b/modules/auth/scripts/complete_email_verification.ts deleted file mode 100644 index 684914b7..00000000 --- a/modules/auth/scripts/complete_email_verification.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { assertExists } from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { - RuntimeError, - ScriptContext, -} from "../module.gen.ts"; -import { TokenWithSecret } from "../../tokens/utils/types.ts"; - -export interface Request { - verificationId: string; - code: string; -} - -export interface Response { - token: TokenWithSecret; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - const code = req.code.toUpperCase(); - - // Validate & mark as used - let userId: string | undefined; - await ctx.db.$transaction(async (tx) => { - const verification = await tx.emailPasswordlessVerification.update({ - where: { - id: req.verificationId, - }, - data: { - attemptCount: { - increment: 1, - }, - }, - select: { - email: true, - userId: true, - code: true, - expireAt: true, - completedAt: true, - attemptCount: true, - maxAttemptCount: true, - }, - }); - if (!verification) { - throw new RuntimeError("verification_code_invalid"); - } - if (verification.attemptCount >= verification.maxAttemptCount) { - throw new RuntimeError("verification_code_attempt_limit"); - } - if (verification.completedAt !== null) { - throw new RuntimeError("verification_code_already_used"); - } - if (verification.code !== code) { - // Same error as above to prevent exploitation - throw new RuntimeError("verification_code_invalid"); - } - if (verification.expireAt < new Date()) { - throw new RuntimeError("verification_code_expired"); - } - - // Mark as used - const verificationConfirmation = await tx.emailPasswordlessVerification - .update({ - where: { - id: req.verificationId, - completedAt: null, - }, - data: { - completedAt: new Date(), - }, - }); - if (verificationConfirmation === null) { - throw new RuntimeError("verification_code_already_used"); - } - - // Get or create user - if (verification.userId) { - userId = verification.userId; - } else { - const { user } = await ctx.modules.users.create({}); - userId = user.id; - } - - // Create identity - await tx.emailPasswordless.upsert({ - where: { - email: verification.email, - userId, - }, - create: { - email: verification.email, - userId, - }, - update: {}, - }); - }); - assertExists(userId); - - // Create token - const { token } = await ctx.modules.users.createToken({ userId }); - - return { token }; -} diff --git a/modules/auth/scripts/send_email_verification.ts b/modules/auth/scripts/send_email_verification.ts deleted file mode 100644 index 9cdb527b..00000000 --- a/modules/auth/scripts/send_email_verification.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { RuntimeError } from "../module.gen.ts"; -import { ScriptContext } from "../module.gen.ts"; -import { Verification } from "../utils/types.ts"; - -export interface Request { - email: string; - userToken?: string; -} - -export interface Response { - verification: Verification; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - if (!ctx.config.email) throw new RuntimeError("provider_disabled"); - - // Check if the email is already associated with an identity - const existingIdentity = await ctx.db.emailPasswordless.findFirst({ - where: { email: req.email }, - }); - - // Fetch existing user if session token is provided - let userId: string | undefined = existingIdentity?.userId; - - if (req.userToken) { - const authRes = await ctx.modules.users.authenticateToken({ - userToken: req.userToken, - }); - - if (existingIdentity && existingIdentity.userId !== authRes.userId) { - throw new RuntimeError("email_already_used"); - } - - userId = authRes.userId; - } - - // Create verification - const code = generateCode(); - const maxAttemptCount = 3; - const expiration = 60 * 60 * 1000; - const verification = await ctx.db.emailPasswordlessVerification.create({ - data: { - userId, - email: req.email, - code, - maxAttemptCount, - expireAt: new Date(Date.now() + expiration), - }, - select: { id: true }, - }); - - // Send email - await ctx.modules.email.sendEmail({ - from: { - email: ctx.config.email.fromEmail ?? "hello@test.com", - name: ctx.config.email.fromName ?? "Authentication Code", - }, - to: [{ email: req.email }], - subject: "Your verification code", - text: `Your verification code is: ${code}`, - html: `Your verification code is: ${code}`, - }); - - return { verification }; -} - -function generateCode(): string { - const length = 8; - const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - let result = ""; - for (let i = 0; i < length; i++) { - result += characters.charAt(Math.floor(Math.random() * characters.length)); - } - return result; -} diff --git a/modules/auth/tests/e2e.ts b/modules/auth/tests/e2e.ts deleted file mode 100644 index 7242c6b7..00000000 --- a/modules/auth/tests/e2e.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { test, TestContext } from "../module.gen.ts"; -import { assertEquals } from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; - -test("e2e", async (ctx: TestContext) => { - // First we create a new user, and "register" into the auth - // using an sendEmailVerification({ email, userToken }) - // call - const { user } = await ctx.modules.users.create({}); - - const { token: session } = await ctx.modules.users.createToken({ - userId: user.id - }); - - const fakeEmail = faker.internet.email(); - - // Now we test that post-signin, we get the same user - { - const authRes = await ctx.modules.auth.sendEmailVerification({ - email: fakeEmail, - userToken: session.token - }); - - // Look up correct code - const { code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({ - where: { - id: authRes.verification.id, - }, - }); - - // Now by verifying the email, we register, and can also use - // this to verify the token - const verifyRes = await ctx.modules.auth.completeEmailVerification({ - verificationId: authRes.verification.id, - code: code, - }); - - assertEquals(verifyRes.token.type, "user"); - - - // Make sure we end up with the same user we started with - const verifyRes2 = await ctx.modules.users.authenticateToken({ - userToken: verifyRes.token.token - }); - - assertEquals(verifyRes2.userId, user.id); - } - - // Now we try logging back in with the same email, - // but without a token, expecting the same user - { - const authRes = await ctx.modules.auth.sendEmailVerification({ - email: fakeEmail - }); - - // Look up correct code - const { code: code } = await ctx.db.emailPasswordlessVerification.findFirstOrThrow({ - where: { - id: authRes.verification.id, - }, - }); - - const verifyRes = await ctx.modules.auth.completeEmailVerification({ - verificationId: authRes.verification.id, - code: code, - }); - - const verifyRes2 = await ctx.modules.users.authenticateToken({ - userToken: verifyRes.token.token - }); - - assertEquals(verifyRes2.userId, user.id); - } -}); - diff --git a/modules/auth_email/config.ts b/modules/auth_email/config.ts new file mode 100644 index 00000000..f7f0737f --- /dev/null +++ b/modules/auth_email/config.ts @@ -0,0 +1,9 @@ +export interface Config { + enable: { + passwordless?: boolean; + withPassword?: boolean; + linking?: boolean; + }; + fromEmail?: string; + fromName?: string; +} diff --git a/modules/auth_email/db/migrations/20240701232228_init/migration.sql b/modules/auth_email/db/migrations/20240701232228_init/migration.sql new file mode 100644 index 00000000..7f0024d1 --- /dev/null +++ b/modules/auth_email/db/migrations/20240701232228_init/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "Verifications" ( + "id" UUID NOT NULL, + "email" TEXT NOT NULL, + "code" TEXT NOT NULL, + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "maxAttemptCount" INTEGER NOT NULL, + "createdAt" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expireAt" TIMESTAMP NOT NULL, + "completedAt" TIMESTAMP, + + CONSTRAINT "Verifications_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Verifications_code_key" ON "Verifications"("code"); diff --git a/modules/auth/db/migrations/migration_lock.toml b/modules/auth_email/db/migrations/migration_lock.toml similarity index 100% rename from modules/auth/db/migrations/migration_lock.toml rename to modules/auth_email/db/migrations/migration_lock.toml diff --git a/modules/auth/db/schema.prisma b/modules/auth_email/db/schema.prisma similarity index 54% rename from modules/auth/db/schema.prisma rename to modules/auth_email/db/schema.prisma index 6b8d0de2..08e9a1d8 100644 --- a/modules/auth/db/schema.prisma +++ b/modules/auth_email/db/schema.prisma @@ -3,19 +3,9 @@ datasource db { url = env("DATABASE_URL") } -model EmailPasswordless { - id String @id @default(uuid()) @db.Uuid - userId String @db.Uuid @unique - email String @unique - createdAt DateTime @default(now()) @db.Timestamp -} - -model EmailPasswordlessVerification { +model Verifications { id String @id @default(uuid()) @db.Uuid - // If exists, link to existing identity. If null, create new identity. - userId String? @db.Uuid - email String // Code the user has to input to verify the email diff --git a/modules/auth_email/module.json b/modules/auth_email/module.json new file mode 100644 index 00000000..dc7d92f4 --- /dev/null +++ b/modules/auth_email/module.json @@ -0,0 +1,81 @@ +{ + "name": "Authentication", + "description": "Authenticate users with multiple authentication methods.", + "icon": "key", + "tags": [ + "core", + "auth", + "user" + ], + "authors": [ + "rivet-gg", + "NathanFlurry" + ], + "status": "stable", + "dependencies": { + "email": {}, + "identities": {}, + "users": {}, + "tokens": {}, + "user_passwords": {}, + "rate_limit": {} + }, + "scripts": { + "send_verification": { + "name": "Send Email Verification (No Password)", + "description": "Send a one-time verification code to an email address to verify ownership. Does not require a password.", + "public": true + }, + "verify_add_no_pass": { + "name": "Verify and Add Email to Existing User (No Password)", + "description": "Verify a user's email address and register it with an existing account. Does not require a password.", + "public": true + }, + "verify_login_or_create_no_pass": { + "name": "Verify and Login as (or Create) User (No Password)", + "description": "Verify the email address code and return a userToken to AN account (creates a new account if one doesn't exist). Does not require a password.", + "public": true + }, + "verify_link_email": { + "name": "Verify and Link Email Address to User", + "description": "Verify a user's email address and link it to an existing account without allowing login passwordless.", + "public": true + }, + + "sign_up_email_pass": { + "name": "Verify and Sign Up with Email and Password", + "description": "Sign up a new user with an email and password.", + "public": true + }, + "sign_in_email_pass": { + "name": "Sign In with Email and Password", + "description": "Sign in a user with an email and password.", + "public": true + }, + "verify_add_email_pass": { + "name": "Verify and Add Email and Password to existing user", + "description": "Verify a user's email address and register it with an existing account. Requires a password.", + "public": true + } + }, + "errors": { + "provider_disabled": { + "name": "Provider Disabled" + }, + "verification_code_invalid": { + "name": "Verification Code Invalid" + }, + "verification_code_attempt_limit": { + "name": "Verification Code Attempt Limit" + }, + "verification_code_expired": { + "name": "Verification Code Expired" + }, + "verification_code_already_used": { + "name": "Verification Code Already Used" + }, + "email_already_used": { + "name": "Email Already Used" + } + } +} diff --git a/modules/auth_email/scripts/send_verification.ts b/modules/auth_email/scripts/send_verification.ts new file mode 100644 index 00000000..931a194b --- /dev/null +++ b/modules/auth_email/scripts/send_verification.ts @@ -0,0 +1,39 @@ +import { ScriptContext } from "../module.gen.ts"; +import { createVerification } from "../utils/code_management.ts"; +import { Verification } from "../utils/types.ts"; + +export interface Request { + email: string; + userToken?: string; +} + +export interface Response { + verification: Verification; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + const { code, verification } = await createVerification( + ctx, + req.email, + ); + + console.log(ctx.config); + // Send email + await ctx.modules.email.sendEmail({ + from: { + email: ctx.config.fromEmail ?? "hello@test.com", + name: ctx.config.fromName ?? "Authentication Code", + }, + to: [{ email: req.email }], + subject: "Your verification code", + text: `Your verification code is: ${code}`, + html: `Your verification code is: ${code}`, + }); + + return { verification }; +} diff --git a/modules/auth_email/scripts/sign_in_email_pass.ts b/modules/auth_email/scripts/sign_in_email_pass.ts new file mode 100644 index 00000000..b7382181 --- /dev/null +++ b/modules/auth_email/scripts/sign_in_email_pass.ts @@ -0,0 +1,40 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; + +export interface Request { + email: string; + password: string; +} + +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.withPassword) { + throw new RuntimeError("provider_disabled"); + } + + // Try signing in with the email + const { userToken } = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: req.email, + }, + }); + + // Look up the user ID + const { userId } = await ctx.modules.users.authenticateToken({ userToken }); + + // Verify the password + await ctx.modules.userPasswords.verify({ + userId, + password: req.password, + }); + + return { userToken }; +} diff --git a/modules/auth_email/scripts/sign_up_email_pass.ts b/modules/auth_email/scripts/sign_up_email_pass.ts new file mode 100644 index 00000000..d13167e6 --- /dev/null +++ b/modules/auth_email/scripts/sign_up_email_pass.ts @@ -0,0 +1,61 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; + +export interface Request { + email: string; + password: string; + + verificationId: string; + code: string; +} + +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.withPassword) { + throw new RuntimeError("provider_disabled"); + } + + // Check the verification code. If it is valid, but for the wrong email, say + // the verification failed. + const { email } = await verifyCode(ctx, req.verificationId, req.code); + if (!compareConstantTime(req.email, email)) { + throw new RuntimeError("verification_failed"); + } + + // Ensure that the email is not associated with ANY accounts in ANY way. + await ensureNotAssociatedAll(ctx, email, new Set()); + + // Sign up the user with the passwordless email identity + const { userToken } = await ctx.modules.identities.signUp({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + const { userId } = await ctx.modules.users.authenticateToken({ userToken }); + + await ctx.modules.userPasswords.add({ userId, password: req.password }); + + return { userToken }; +} + +function compareConstantTime(aConstant: string, b: string) { + let isEq = 1; + for (let i = 0; i < aConstant.length; i++) { + isEq &= Number(aConstant[i] === b[i]); + } + isEq &= Number(aConstant.length === b.length); + + return Boolean(isEq); +} diff --git a/modules/auth_email/scripts/verify_add_email_pass.ts b/modules/auth_email/scripts/verify_add_email_pass.ts new file mode 100644 index 00000000..f454ede7 --- /dev/null +++ b/modules/auth_email/scripts/verify_add_email_pass.ts @@ -0,0 +1,80 @@ +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; + +export interface Request { + userToken: string; + + email: string; + password: string; + oldPassword: string | null; + + verificationId: string; + code: string; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.withPassword) { + throw new RuntimeError("provider_disabled"); + } + + // Check the verification code. If it is valid, but for the wrong email, say + // the verification failed. + const { email } = await verifyCode(ctx, req.verificationId, req.code); + if (!compareConstantTime(req.email, email)) { + throw new RuntimeError("verification_failed"); + } + + // Ensure that the email is not associated with ANY accounts in ANY way. + const providedUser = await ctx.modules.users.authenticateToken({ + userToken: req.userToken, + }); + await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId])); + + // If an old password was provided, ensure it was correct and update it. + // If one was not, register the user with the `userPasswords` module. + if (req.oldPassword) { + await ctx.modules.userPasswords.verify({ + userId: providedUser.userId, + password: req.oldPassword, + }); + await ctx.modules.userPasswords.update({ + userId: providedUser.userId, + newPassword: req.password, + }); + } else { + await ctx.modules.userPasswords.add({ + userId: providedUser.userId, + password: req.password, + }); + } + + // Sign up the user with the passwordless email identity + await ctx.modules.identities.link({ + userToken: req.userToken, + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return {}; +} + +function compareConstantTime(aConstant: string, b: string) { + let isEq = 1; + for (let i = 0; i < aConstant.length; i++) { + isEq &= Number(aConstant[i] === b[i]); + } + isEq &= Number(aConstant.length === b.length); + + return Boolean(isEq); +} diff --git a/modules/auth_email/scripts/verify_add_no_pass.ts b/modules/auth_email/scripts/verify_add_no_pass.ts new file mode 100644 index 00000000..10b70531 --- /dev/null +++ b/modules/auth_email/scripts/verify_add_no_pass.ts @@ -0,0 +1,43 @@ +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; + +export interface Request { + verificationId: string; + code: string; + userToken: string; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.passwordless) { + throw new RuntimeError("provider_disabled"); + } + + // Verify that the code is correct and valid + const { email } = await verifyCode(ctx, req.verificationId, req.code); + + // Ensure that the email is not already associated with another account + const providedUser = await ctx.modules.users.authenticateToken({ + userToken: req.userToken, + }); + await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId])); + + // Add email passwordless sign in to the user's account + await ctx.modules.identities.link({ + userToken: req.userToken, + info: IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return {}; +} diff --git a/modules/auth_email/scripts/verify_link_email.ts b/modules/auth_email/scripts/verify_link_email.ts new file mode 100644 index 00000000..48d966d6 --- /dev/null +++ b/modules/auth_email/scripts/verify_link_email.ts @@ -0,0 +1,41 @@ +import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_LINK } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; + +export interface Request { + verificationId: string; + code: string; + userToken: string; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.linking) throw new RuntimeError("provider_disabled"); + + // Verify that the code is correct and valid + const { email } = await verifyCode(ctx, req.verificationId, req.code); + + // Ensure that the email is not already associated with another account + const providedUser = await ctx.modules.users.authenticateToken({ + userToken: req.userToken, + }); + await ensureNotAssociatedAll(ctx, email, new Set([providedUser.userId])); + + // Link the email to the user's account + await ctx.modules.identities.link({ + userToken: req.userToken, + info: IDENTITY_INFO_LINK, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return {}; +} diff --git a/modules/auth_email/scripts/verify_login_or_create_no_pass.ts b/modules/auth_email/scripts/verify_login_or_create_no_pass.ts new file mode 100644 index 00000000..315230d3 --- /dev/null +++ b/modules/auth_email/scripts/verify_login_or_create_no_pass.ts @@ -0,0 +1,53 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; + +export interface Request { + verificationId: string; + code: string; +} + +export interface Response { + userToken: string; +} + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + if (!ctx.config.enable.passwordless) { + throw new RuntimeError("provider_disabled"); + } + + const { email } = await verifyCode(ctx, req.verificationId, req.code); + + // Try signing in with the email, and return the user token if successful. + try { + const signInOrUpResponse = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: email, + }, + }); + + return { userToken: signInOrUpResponse.userToken }; + } catch { + // Email is not associated with an account, we can proceed with signing up. + } + + // Ensure email is not associated to ANY account + await ensureNotAssociatedAll(ctx, email, new Set()); + + // Sign up the user with the passwordless email identity + const signUpResponse = await ctx.modules.identities.signUp({ + info: IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return { userToken: signUpResponse.userToken }; +} diff --git a/modules/auth_email/tests/already_used.ts b/modules/auth_email/tests/already_used.ts new file mode 100644 index 00000000..c991819e --- /dev/null +++ b/modules/auth_email/tests/already_used.ts @@ -0,0 +1,238 @@ +import { RuntimeError, test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { getVerification } from "../utils/tests.ts"; +import { + assertEquals, + assertRejects, +} from "https://deno.land/std@0.208.0/assert/mod.ts"; +import { checkLogin } from "../utils/tests.ts"; + +async function signUpEmailPass( + ctx: TestContext, + email: string, + password: string, +) { + const { verificationId, code } = await getVerification(ctx, email); + return await ctx.modules.authEmail.signUpEmailPass({ + verificationId, + code, + + email, + password, + }); +} +async function signUpEmailNoPass(ctx: TestContext, email: string) { + const { verificationId, code } = await getVerification(ctx, email); + return await ctx.modules.authEmail.verifyLoginOrCreateNoPass({ + verificationId, + code, + }); +} +async function signUpEmailLink(ctx: TestContext, email: string) { + const { user } = await ctx.modules.users.create({}); + const { token: { token } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + + const { verificationId, code } = await getVerification(ctx, email); + await ctx.modules.authEmail.verifyLinkEmail({ + userToken: token, + verificationId, + code, + }); + + return { userToken: token }; +} + +// MARK: SU Pass then SU No Pass +test("sign_up_with_email_pass_then_no_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + await signUpEmailPass(ctx, email, password); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.verifyLoginOrCreateNoPass({ + verificationId, + code, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); + +// MARK: Link then SU No Pass +test("email_link_then_sign_up_no_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + + await signUpEmailLink(ctx, email); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.verifyLoginOrCreateNoPass({ + verificationId, + code, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); + +// MARK: SU No Pass then SU Pass +test("sign_up_with_email_no_pass_then_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + await signUpEmailNoPass(ctx, email); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.signUpEmailPass({ + verificationId, + code, + email, + password, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); + +// MARK: Link then SU Pass +test("email_link_then_sign_up_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + await signUpEmailLink(ctx, email); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.signUpEmailPass({ + verificationId, + code, + email, + password, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); + +// MARK: Link then Add Pass +test("email_link_then_add_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + const { userToken } = await signUpEmailLink(ctx, email); + const { user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + { + const { verificationId, code } = await getVerification(ctx, email); + await ctx.modules.authEmail.verifyAddEmailPass({ + userToken, + verificationId, + code, + email, + password, + oldPassword: null, + }); + } + + { + const { userToken: newUserToken } = await ctx.modules.authEmail + .signInEmailPass({ + email, + password, + }); + + await checkLogin(ctx, user!, newUserToken); + } +}); + +// MARK: Link then Add No Pass +test("email_link_then_add_no_pass", async (ctx: TestContext) => { + const email = faker.internet.email(); + + const { userToken } = await signUpEmailLink(ctx, email); + const { user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + { + const { verificationId, code } = await getVerification(ctx, email); + await ctx.modules.authEmail.verifyAddNoPass({ + userToken, + verificationId, + code, + }); + } + + { + const { verificationId, code } = await getVerification(ctx, email); + const { userToken: newUserToken } = await ctx.modules.authEmail + .verifyLoginOrCreateNoPass({ + verificationId, + code, + }); + await checkLogin(ctx, user!, newUserToken); + } +}); + +// MARK: Link then Pass 2 Users +test("email_link_then_add_pass_on_different_user", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + await signUpEmailLink(ctx, email); + + const { user } = await ctx.modules.users.create({}); + const { token: { token: userToken } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.verifyAddEmailPass({ + userToken, + verificationId, + code, + email, + password, + oldPassword: null, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); + +// MARK: Link then Link 2 Users +test("email_link_then_add_pass_on_different_user", async (ctx: TestContext) => { + const email = faker.internet.email(); + + await signUpEmailLink(ctx, email); + + const { user } = await ctx.modules.users.create({}); + const { token: { token: userToken } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + { + const { verificationId, code } = await getVerification(ctx, email); + const error = await assertRejects(() => { + return ctx.modules.authEmail.verifyLinkEmail({ + userToken, + verificationId, + code, + }); + }, RuntimeError); + + assertEquals(error.code, "email_in_use"); + } +}); diff --git a/modules/auth_email/tests/connect.ts b/modules/auth_email/tests/connect.ts new file mode 100644 index 00000000..6682e592 --- /dev/null +++ b/modules/auth_email/tests/connect.ts @@ -0,0 +1,108 @@ +import { test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { + IDENTITY_INFO_LINK, + IDENTITY_INFO_PASSWORD, + IDENTITY_INFO_PASSWORDLESS, +} from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "../utils/tests.ts"; + +// MARK: Test Email/No Pass +test("connect_email_and_login_passwordless", async (ctx: TestContext) => { + const email = faker.internet.email(); + + const { user } = await ctx.modules.users.create({}); + const { token: { token: userToken } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + + // MARK: Connect + { + const { verificationId, code } = await getVerification(ctx, email); + await ctx.modules.authEmail.verifyAddNoPass({ + userToken, + verificationId, + code, + }); + } + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORDLESS); + + // MARK: Log in + { + const { verificationId, code } = await getVerification(ctx, email); + + const { userToken } = await ctx.modules.authEmail.verifyLoginOrCreateNoPass( + { + verificationId, + code, + }, + ); + + await checkLogin(ctx, user, userToken); + } +}); + +// MARK: Test Email/Pass +test("connect_email_and_login_password", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + const { user } = await ctx.modules.users.create({}); + const { token: { token: userToken } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + + // MARK: Connect + { + const { verificationId, code } = await getVerification(ctx, email); + + // Now by verifying the email, we register, and can also use + // this to verify the token + await ctx.modules.authEmail.verifyAddEmailPass({ + userToken, + verificationId, + code, + + email, + password, + oldPassword: null, + }); + } + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORD); + + // MARK: Log in + { + const { userToken } = await ctx.modules.authEmail.signInEmailPass({ + email, + password, + }); + + await checkLogin(ctx, user, userToken); + } +}); + +// MARK: Test Link Email +test("connect_email_link", async (ctx: TestContext) => { + const email = faker.internet.email(); + + const { user } = await ctx.modules.users.create({}); + const { token: { token: userToken } } = await ctx.modules.users.createToken({ + userId: user.id, + }); + + // MARK: Connect + { + const { verificationId, code } = await getVerification(ctx, email); + + // Link the email to the user as a non-sign-in method + await ctx.modules.authEmail.verifyLinkEmail({ + userToken, + verificationId, + code, + }); + } + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_LINK); +}); diff --git a/modules/auth_email/tests/create.ts b/modules/auth_email/tests/create.ts new file mode 100644 index 00000000..eed303e2 --- /dev/null +++ b/modules/auth_email/tests/create.ts @@ -0,0 +1,84 @@ +import { test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { + IDENTITY_INFO_PASSWORD, + IDENTITY_INFO_PASSWORDLESS, +} from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "../utils/tests.ts"; + +// MARK: Test Email/No Pass +test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { + const email = faker.internet.email(); + + let userToken: string; + + // MARK: Sign Up + { + const { verificationId, code } = await getVerification(ctx, email); + const signUpRes = await ctx.modules.authEmail.verifyLoginOrCreateNoPass({ + verificationId, + code, + }); + userToken = signUpRes.userToken; + } + + const { user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORDLESS); + + // MARK: Log in + { + const { verificationId, code } = await getVerification(ctx, email); + + const { userToken } = await ctx.modules.authEmail.verifyLoginOrCreateNoPass( + { + verificationId, + code, + }, + ); + + await checkLogin(ctx, user!, userToken); + } +}); + +// MARK: Test Email/Pass +test("create_with_email_and_login_password", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + let userToken: string; + + // MARK: Sign Up + { + const { verificationId, code } = await getVerification(ctx, email); + const signUpRes = await ctx.modules.authEmail.signUpEmailPass({ + verificationId, + code, + + email, + password, + }); + + userToken = signUpRes.userToken; + } + + const { user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORD); + + // MARK: Log in + { + const { userToken } = await ctx.modules.authEmail.signInEmailPass({ + email, + password, + }); + + await checkLogin(ctx, user!, userToken); + } +}); diff --git a/modules/auth_email/utils/code_management.ts b/modules/auth_email/utils/code_management.ts new file mode 100644 index 00000000..2e67534a --- /dev/null +++ b/modules/auth_email/utils/code_management.ts @@ -0,0 +1,99 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; + +function generateCode(): string { + const length = 8; + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + let result = ""; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return result; +} + +const MAX_ATTEMPT_COUNT = 3; +const EXPIRATION_TIME = 60 * 60 * 1000; + +export async function createVerification(ctx: ScriptContext, email: string) { + // Create verification + const code = generateCode(); + const verification = await ctx.db.verifications.create({ + data: { + email, + code, + maxAttemptCount: MAX_ATTEMPT_COUNT, + expireAt: new Date(Date.now() + EXPIRATION_TIME), + }, + select: { id: true }, + }); + + return { verification, code }; +} + +export async function verifyCode( + ctx: ScriptContext, + verificationId: string, + codeInput: string, +) { + await ctx.modules.rateLimit.throttlePublic({}); + + const code = codeInput.toUpperCase(); + + return await ctx.db.$transaction(async (tx) => { + const verification = await tx.verifications.update({ + where: { + id: verificationId, + }, + data: { + attemptCount: { + increment: 1, + }, + }, + select: { + email: true, + code: true, + expireAt: true, + completedAt: true, + attemptCount: true, + maxAttemptCount: true, + }, + }); + if (!verification) { + throw new RuntimeError("verification_code_invalid"); + } + if (verification.attemptCount >= verification.maxAttemptCount) { + throw new RuntimeError("verification_code_attempt_limit"); + } + if (verification.completedAt !== null) { + throw new RuntimeError("verification_code_already_used"); + } + if (verification.code !== code) { + // Same error as above to prevent exploitation + throw new RuntimeError("verification_code_invalid"); + } + if (verification.expireAt < new Date()) { + throw new RuntimeError("verification_code_expired"); + } + + const completedAt = new Date(); + + // Mark as used + const verificationConfirmation = await tx.verifications + .update({ + where: { + id: verificationId, + completedAt: null, + }, + data: { + completedAt, + }, + }); + if (verificationConfirmation === null) { + throw new RuntimeError("verification_code_already_used"); + } + + return { + email: verificationConfirmation.email, + completedAt, + }; + }); +} diff --git a/modules/auth_email/utils/link_assertions.ts b/modules/auth_email/utils/link_assertions.ts new file mode 100644 index 00000000..386f1b0f --- /dev/null +++ b/modules/auth_email/utils/link_assertions.ts @@ -0,0 +1,101 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { + IDENTITY_INFO_LINK, + IDENTITY_INFO_PASSWORD, + IDENTITY_INFO_PASSWORDLESS, +} from "./provider.ts"; + +export async function ensureNotAssociated( + ctx: ScriptContext, + providerInfo: + | typeof IDENTITY_INFO_LINK + | typeof IDENTITY_INFO_PASSWORDLESS + | typeof IDENTITY_INFO_PASSWORD, + email: string, + shouldRejectOnExistence: ( + linked: { userId: string; userToken: string }, + ) => boolean | Promise, +) { + // Ensure that the email is not already associated with another account + let existingIdentity: { userToken: string }; + try { + existingIdentity = await ctx.modules.identities.signIn({ + info: providerInfo, + uniqueData: { + identifier: email, + }, + }); + } catch { + // If the email is not associated in this way, the "sign in" will error and put us here. + ctx.log.info( + "Email is confirmed to not be associated with another account thru this provider", + ["email", email], + ["provider", JSON.stringify(providerInfo)], + ); + return; + } + // Email matches an existing identity using this provider + const existingUser = await ctx.modules.users.authenticateToken( + existingIdentity, + ); + + if ( + await shouldRejectOnExistence({ + userId: existingUser.userId, + userToken: existingIdentity.userToken, + }) + ) { + ctx.log.error( + "Email is already associated with another account", + ["email", email], + ["existingUser", existingUser.userId], + ["existingUserToken", existingIdentity.userToken], + ["provider", JSON.stringify(providerInfo)], + ); + + // Revoke the user token just in case + const { tokens } = await ctx.modules.tokens.fetchByToken({ + tokens: [existingIdentity.userToken], + }); + await ctx.modules.tokens.revoke({ + tokenIds: tokens.map((token) => token.id), + }); + + // Reject the request because the email is already associated with some + // account in some incompatible way + throw new RuntimeError("email_in_use"); + } else { + ctx.log.info( + "Email is already associated with an account, but that is okay in this case", + ["email", email], + ["existingUser", existingUser.userId], + ["existingUserToken", existingIdentity.userToken], + ["provider", JSON.stringify(providerInfo)], + ); + + // Revoke the user token just in case + const { tokens } = await ctx.modules.tokens.fetchByToken({ + tokens: [existingIdentity.userToken], + }); + await ctx.modules.tokens.revoke({ + tokenIds: tokens.map((token) => token.id), + }); + } +} + +export async function ensureNotAssociatedAll( + ctx: ScriptContext, + email: string, + allowedUserIds: Set, +) { + const idDoesntMatch = (linked: { userId: string }) => + !allowedUserIds.has(linked.userId); + await ensureNotAssociated( + ctx, + IDENTITY_INFO_PASSWORDLESS, + email, + idDoesntMatch, + ); + await ensureNotAssociated(ctx, IDENTITY_INFO_PASSWORD, email, idDoesntMatch); + await ensureNotAssociated(ctx, IDENTITY_INFO_LINK, email, idDoesntMatch); +} diff --git a/modules/auth_email/utils/provider.ts b/modules/auth_email/utils/provider.ts new file mode 100644 index 00000000..d46bb5cd --- /dev/null +++ b/modules/auth_email/utils/provider.ts @@ -0,0 +1,14 @@ +export const IDENTITY_INFO_PASSWORDLESS = { + identityType: "email", + identityId: "passwordless", +}; + +export const IDENTITY_INFO_PASSWORD = { + identityType: "email", + identityId: "with_password", +}; + +export const IDENTITY_INFO_LINK = { + identityType: "email", + identityId: "link_only", +}; diff --git a/modules/auth_email/utils/tests.ts b/modules/auth_email/utils/tests.ts new file mode 100644 index 00000000..d7cfb4f5 --- /dev/null +++ b/modules/auth_email/utils/tests.ts @@ -0,0 +1,56 @@ +import { TestContext } from "../module.gen.ts"; +import { + assertEquals, + assertExists, +} from "https://deno.land/std@0.208.0/assert/mod.ts"; + +export async function getVerification(ctx: TestContext, email: string) { + // Get a valid verification + const { verification: { id: verificationId } } = await ctx.modules.authEmail + .sendVerification({ email }); + const { code } = await ctx.db.verifications + .findFirstOrThrow({ + where: { + id: verificationId, + }, + }); + + return { verificationId, code }; +} + +export async function verifyProvider( + ctx: TestContext, + userToken: string, + email: string, + provider: unknown, +) { + // Get the providers associated with the user + const { identityProviders: [emailProvider] } = await ctx.modules.identities + .list({ userToken }); + assertEquals(emailProvider, provider); + + // Verify that the provider data is correct + const { data } = await ctx.modules.identities.get({ + userToken, + info: emailProvider, + }); + assertExists(data); + + const { uniqueData, additionalData } = data; + assertEquals(uniqueData, { identifier: email }); + assertEquals(additionalData, {}); +} + +export async function checkLogin( + ctx: TestContext, + origUser: { username: string; id: string }, + newToken: string, +) { + const { userId: signedInUserId, user: signedInUser } = await ctx.modules.users + .authenticateToken({ + userToken: newToken, + fetchUser: true, + }); + assertEquals(signedInUserId, origUser.id); + assertEquals(signedInUser?.username, origUser.username); +} diff --git a/modules/auth/utils/types.ts b/modules/auth_email/utils/types.ts similarity index 100% rename from modules/auth/utils/types.ts rename to modules/auth_email/utils/types.ts diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 87db3449..552c7e31 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -30,11 +30,15 @@ "maxMultipartUploadSize": "10gib" } }, - "auth": { + "auth_email": { "registry": "local", "config": { - "email": { - "fromEmail": "hello@rivet.gg" + "fromEmail": "hello@rivet.gg", + "fromName": "Rivet Sign-In", + "enable": { + "withPassword": true, + "passwordless": true, + "linking": true } } },