diff --git a/modules/auth_email/config.ts b/modules/auth_email/config.ts deleted file mode 100644 index adda742a..00000000 --- a/modules/auth_email/config.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface Config { - fromEmail?: string; - fromName?: string; -} diff --git a/modules/auth_email/module.json b/modules/auth_email/module.json deleted file mode 100644 index 5580f0a7..00000000 --- a/modules/auth_email/module.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "name": "Auth Email", - "description": "[INTERNAL-ONLY: use auth_email_password/auth_email_passwordless/auth_email_link.] Authenticating users with email only or an email/password combination.", - "icon": "key", - "tags": [ - "core", - "auth", - "user", - "internal" - ], - "authors": [ - "rivet-gg", - "NathanFlurry", - "Blckbrry-Pi" - ], - "status": "stable", - "dependencies": { - "email": {}, - "identities": {}, - "users": {}, - "tokens": {}, - "user_passwords": {}, - "rate_limit": {} - }, - "defaultConfig": { - "fromEmail": "hello@test.com", - "fromName": "Authentication Code" - }, - "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." - }, - "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." - }, - "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." - }, - "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." - }, - "verify_sign_up_email_pass": { - "name": "Verify and Sign Up with Email and Password", - "description": "Sign up a new user with an email and password." - }, - "sign_in_email_pass": { - "name": "Sign In with Email and Password", - "description": "Sign in a user with an email and password." - }, - "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." - } - }, - "errors": { - "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" - } - }, - "enableConfig": { - "enable": true - } -} diff --git a/modules/auth_email/scripts/send_verification.ts b/modules/auth_email/scripts/send_verification.ts deleted file mode 100644 index 8349234a..00000000 --- a/modules/auth_email/scripts/send_verification.ts +++ /dev/null @@ -1,38 +0,0 @@ -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, - ); - - // 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 deleted file mode 100644 index 22e1130e..00000000 --- a/modules/auth_email/scripts/sign_in_email_pass.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { 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; - userId: string; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - // Try signing in with the email - const { userToken, userId } = await ctx.modules.identities.signIn({ - info: IDENTITY_INFO_PASSWORD, - uniqueData: { - identifier: req.email, - }, - }); - - // Verify the password - await ctx.modules.userPasswords.verify({ - userId, - password: req.password, - }); - - return { userToken, userId }; -} diff --git a/modules/auth_email/scripts/verify_add_email_pass.ts b/modules/auth_email/scripts/verify_add_email_pass.ts deleted file mode 100644 index fae6c48a..00000000 --- a/modules/auth_email/scripts/verify_add_email_pass.ts +++ /dev/null @@ -1,77 +0,0 @@ -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; - - verificationToken: string; - code: string; -} - -export type Response = Empty; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - // Check the verification code. If it is valid, but for the wrong email, say - // the verification failed. - const { email } = await verifyCode(ctx, req.verificationToken, 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 deleted file mode 100644 index 5181c657..00000000 --- a/modules/auth_email/scripts/verify_add_no_pass.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Empty, 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 { - verificationToken: string; - code: string; - userToken: string; -} - -export type Response = Empty; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - // Verify that the code is correct and valid - const { email } = await verifyCode(ctx, req.verificationToken, 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 deleted file mode 100644 index 680a4847..00000000 --- a/modules/auth_email/scripts/verify_link_email.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Empty, 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 { - verificationToken: string; - code: string; - userToken: string; -} - -export type Response = Empty; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - // Verify that the code is correct and valid - const { email } = await verifyCode(ctx, req.verificationToken, 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 deleted file mode 100644 index 4162f0e0..00000000 --- a/modules/auth_email/scripts/verify_login_or_create_no_pass.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 { - verificationToken: string; - code: string; -} - -export interface Response { - userToken: string; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - const { email } = await verifyCode(ctx, req.verificationToken, 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 (e) { - if (e instanceof RuntimeError && e.code === "identity_provider_not_found") { - // Email is not associated with an account, we can proceed with signing up. - } else { - throw e; - } - } - - // 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/scripts/verify_sign_up_email_pass.ts b/modules/auth_email/scripts/verify_sign_up_email_pass.ts deleted file mode 100644 index 1519a4a7..00000000 --- a/modules/auth_email/scripts/verify_sign_up_email_pass.ts +++ /dev/null @@ -1,56 +0,0 @@ -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; - - verificationToken: string; - code: string; -} - -export interface Response { - userToken: string; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - // Check the verification code. If it is valid, but for the wrong email, say - // the verification failed. - const { email } = await verifyCode(ctx, req.verificationToken, 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, userId } = await ctx.modules.identities.signUp({ - info: IDENTITY_INFO_PASSWORD, - uniqueData: { - identifier: email, - }, - additionalData: {}, - }); - - 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/tests/already_used.ts b/modules/auth_email/tests/already_used.ts deleted file mode 100644 index bc79ada5..00000000 --- a/modules/auth_email/tests/already_used.ts +++ /dev/null @@ -1,238 +0,0 @@ -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 "./common.ts"; -import { - assertEquals, - assertRejects, -} from "https://deno.land/std@0.208.0/assert/mod.ts"; -import { checkLogin } from "./common.ts"; - -async function signUpEmailPass( - ctx: TestContext, - email: string, - password: string, -) { - const { verificationToken, code } = await getVerification(ctx, email); - return await ctx.modules.authEmail.verifySignUpEmailPass({ - verificationToken, - code, - - email, - password, - }); -} -async function signUpEmailNoPass(ctx: TestContext, email: string) { - const { verificationToken, code } = await getVerification(ctx, email); - return await ctx.modules.authEmail.verifyLoginOrCreateNoPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - await ctx.modules.authEmail.verifyLinkEmail({ - userToken: token, - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifyLoginOrCreateNoPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifyLoginOrCreateNoPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifySignUpEmailPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifySignUpEmailPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - await ctx.modules.authEmail.verifyAddEmailPass({ - userToken, - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - await ctx.modules.authEmail.verifyAddNoPass({ - userToken, - verificationToken, - code, - }); - } - - { - const { verificationToken, code } = await getVerification(ctx, email); - const { userToken: newUserToken } = await ctx.modules.authEmail - .verifyLoginOrCreateNoPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifyAddEmailPass({ - userToken, - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const error = await assertRejects(() => { - return ctx.modules.authEmail.verifyLinkEmail({ - userToken, - verificationToken, - code, - }); - }, RuntimeError); - - assertEquals(error.code, "email_in_use"); - } -}); diff --git a/modules/auth_email/tests/connect.ts b/modules/auth_email/tests/connect.ts deleted file mode 100644 index 3bca90c5..00000000 --- a/modules/auth_email/tests/connect.ts +++ /dev/null @@ -1,108 +0,0 @@ -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 "./common.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 { verificationToken, code } = await getVerification(ctx, email); - await ctx.modules.authEmail.verifyAddNoPass({ - userToken, - verificationToken, - code, - }); - } - - await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORDLESS); - - // MARK: Log in - { - const { verificationToken, code } = await getVerification(ctx, email); - - const { userToken } = await ctx.modules.authEmail.verifyLoginOrCreateNoPass( - { - verificationToken, - 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 { verificationToken, 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, - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - - // Link the email to the user as a non-sign-in method - await ctx.modules.authEmail.verifyLinkEmail({ - userToken, - verificationToken, - 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 deleted file mode 100644 index b4c9780a..00000000 --- a/modules/auth_email/tests/create.ts +++ /dev/null @@ -1,84 +0,0 @@ -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 "./common.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 { verificationToken, code } = await getVerification(ctx, email); - const signUpRes = await ctx.modules.authEmail.verifyLoginOrCreateNoPass({ - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - - const { userToken } = await ctx.modules.authEmail.verifyLoginOrCreateNoPass( - { - verificationToken, - 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 { verificationToken, code } = await getVerification(ctx, email); - const signUpRes = await ctx.modules.authEmail.verifySignUpEmailPass({ - verificationToken, - 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/types.ts b/modules/auth_email/utils/types.ts deleted file mode 100644 index b291c838..00000000 --- a/modules/auth_email/utils/types.ts +++ /dev/null @@ -1,8 +0,0 @@ -export interface Verification { - token: string; -} - -export interface Session { - token: string; - expireAt: string; -} diff --git a/modules/auth_email_link/module.json b/modules/auth_email_link/module.json deleted file mode 100644 index 881cf28d..00000000 --- a/modules/auth_email_link/module.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "Auth Email Linking", - "description": "Link emails to users.", - "icon": "key", - "tags": [ - "core", - "auth", - "user", - "internal" - ], - "authors": [ - "rivet-gg", - "Blckbrry-Pi" - ], - "status": "stable", - "dependencies": { - "auth_email": {} - }, - "defaultConfig": { - "enable": false - }, - "scripts": { - "send_verification": { - "name": "Send Email Verification", - "description": "Send a one-time verification code to an email address to verify ownership.", - "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 implicitly allowing login." - } - }, - "errors": { - "provider_disabled": { - "name": "Provider Disabled" - } - } -} diff --git a/modules/auth_email_link/scripts/send_verification.ts b/modules/auth_email_link/scripts/send_verification.ts deleted file mode 100644 index 9eecec69..00000000 --- a/modules/auth_email_link/scripts/send_verification.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; - - -export type Request = ReqOf; -export type Response = ResOf; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - return await ctx.modules.authEmail.sendVerification(req); -} diff --git a/modules/auth_email_link/scripts/verify_link_email.ts b/modules/auth_email_link/scripts/verify_link_email.ts deleted file mode 100644 index 0e4e77aa..00000000 --- a/modules/auth_email_link/scripts/verify_link_email.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; - - -export type Request = ReqOf; -export type Response = ResOf; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - return await ctx.modules.authEmail.verifyLinkEmail(req); -} - diff --git a/modules/auth_email_link/utils/types.ts b/modules/auth_email_link/utils/types.ts deleted file mode 100644 index 8de38050..00000000 --- a/modules/auth_email_link/utils/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type ReqOf = T extends (req: infer Req) => Promise ? Req : never; -export type ResOf = T extends (req: infer Req) => Promise ? Res : never; diff --git a/modules/auth_email_password/config.ts b/modules/auth_email_password/config.ts new file mode 100644 index 00000000..261e588e --- /dev/null +++ b/modules/auth_email_password/config.ts @@ -0,0 +1,4 @@ +export interface Config { + fromEmail?: string; + fromName?: string; +} diff --git a/modules/auth_email/db/migrations/1724526776_powerful_maverick.sql b/modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql similarity index 100% rename from modules/auth_email/db/migrations/1724526776_powerful_maverick.sql rename to modules/auth_email_password/db/migrations/1724526776_powerful_maverick.sql diff --git a/modules/auth_email/db/migrations/meta/1724526776_snapshot.json b/modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json similarity index 100% rename from modules/auth_email/db/migrations/meta/1724526776_snapshot.json rename to modules/auth_email_password/db/migrations/meta/1724526776_snapshot.json diff --git a/modules/auth_email/db/migrations/meta/_journal.json b/modules/auth_email_password/db/migrations/meta/_journal.json similarity index 100% rename from modules/auth_email/db/migrations/meta/_journal.json rename to modules/auth_email_password/db/migrations/meta/_journal.json diff --git a/modules/auth_email/db/schema.ts b/modules/auth_email_password/db/schema.ts similarity index 100% rename from modules/auth_email/db/schema.ts rename to modules/auth_email_password/db/schema.ts diff --git a/modules/auth_email_password/module.json b/modules/auth_email_password/module.json index 29b92bdc..73a94e72 100644 --- a/modules/auth_email_password/module.json +++ b/modules/auth_email_password/module.json @@ -1,6 +1,6 @@ { - "name": "Auth Email/Password", - "description": "Authenticate users with an email/password combination.", + "name": "Auth Email & Password", + "description": "Authenticate users with a email/password combination.", "icon": "key", "tags": [ "core", @@ -13,10 +13,16 @@ ], "status": "stable", "dependencies": { - "auth_email": {} + "email": {}, + "identities": {}, + "users": {}, + "tokens": {}, + "user_passwords": {}, + "rate_limit": {} }, "defaultConfig": { - "enable": false + "fromEmail": "hello@test.com", + "fromName": "Authentication Code" }, "scripts": { "send_verification": { @@ -24,25 +30,40 @@ "description": "Send a one-time verification code to an email address to verify ownership.", "public": true }, - "verify_sign_up_email_pass": { - "name": "Verify and Sign Up with Email and Password", - "description": "Verify a user's email address and register 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_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 + }, "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.", + "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" } + }, + "enableConfig": { + "enable": true } } diff --git a/modules/auth_email_password/scripts/send_verification.ts b/modules/auth_email_password/scripts/send_verification.ts index 9eecec69..8349234a 100644 --- a/modules/auth_email_password/scripts/send_verification.ts +++ b/modules/auth_email_password/scripts/send_verification.ts @@ -1,13 +1,38 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; +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 type Request = ReqOf; -export type Response = ResOf; +export interface Response { + verification: Verification; +} export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.sendVerification(req); + await ctx.modules.rateLimit.throttlePublic({}); + + const { code, verification } = await createVerification( + ctx, + req.email, + ); + + // 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_password/scripts/sign_in_email_pass.ts b/modules/auth_email_password/scripts/sign_in_email_pass.ts index d3839341..22e1130e 100644 --- a/modules/auth_email_password/scripts/sign_in_email_pass.ts +++ b/modules/auth_email_password/scripts/sign_in_email_pass.ts @@ -1,14 +1,35 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; +import { ScriptContext } from "../module.gen.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; +export interface Request { + email: string; + password: string; +} -export type Request = ReqOf; -export type Response = ResOf; +export interface Response { + userToken: string; + userId: string; +} export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.signInEmailPass(req); -} + await ctx.modules.rateLimit.throttlePublic({}); + // Try signing in with the email + const { userToken, userId } = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: req.email, + }, + }); + + // Verify the password + await ctx.modules.userPasswords.verify({ + userId, + password: req.password, + }); + + return { userToken, userId }; +} diff --git a/modules/auth_email_password/scripts/verify_add_email_pass.ts b/modules/auth_email_password/scripts/verify_add_email_pass.ts index fe9d8f47..fae6c48a 100644 --- a/modules/auth_email_password/scripts/verify_add_email_pass.ts +++ b/modules/auth_email_password/scripts/verify_add_email_pass.ts @@ -1,13 +1,77 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; +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; -export type Request = ReqOf; -export type Response = ResOf; + email: string; + password: string; + oldPassword: string | null; + + verificationToken: string; + code: string; +} + +export type Response = Empty; export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.verifyAddEmailPass(req); + await ctx.modules.rateLimit.throttlePublic({}); + + // Check the verification code. If it is valid, but for the wrong email, say + // the verification failed. + const { email } = await verifyCode(ctx, req.verificationToken, 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_password/scripts/verify_sign_up_email_pass.ts b/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts index 4d3a78b0..1519a4a7 100644 --- a/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts +++ b/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts @@ -1,13 +1,56 @@ import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.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; -export type Request = ReqOf; -export type Response = ResOf; + verificationToken: string; + code: string; +} + +export interface Response { + userToken: string; +} export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.verifySignUpEmailPass(req); + await ctx.modules.rateLimit.throttlePublic({}); + + // Check the verification code. If it is valid, but for the wrong email, say + // the verification failed. + const { email } = await verifyCode(ctx, req.verificationToken, 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, userId } = await ctx.modules.identities.signUp({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + 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/utils/code_management.ts b/modules/auth_email_password/utils/code_management.ts similarity index 100% rename from modules/auth_email/utils/code_management.ts rename to modules/auth_email_password/utils/code_management.ts diff --git a/modules/auth_email/utils/link_assertions.ts b/modules/auth_email_password/utils/link_assertions.ts similarity index 100% rename from modules/auth_email/utils/link_assertions.ts rename to modules/auth_email_password/utils/link_assertions.ts diff --git a/modules/auth_email/utils/provider.ts b/modules/auth_email_password/utils/provider.ts similarity index 100% rename from modules/auth_email/utils/provider.ts rename to modules/auth_email_password/utils/provider.ts diff --git a/modules/auth_email_password/utils/types.ts b/modules/auth_email_password/utils/types.ts index 8de38050..b291c838 100644 --- a/modules/auth_email_password/utils/types.ts +++ b/modules/auth_email_password/utils/types.ts @@ -1,2 +1,8 @@ -export type ReqOf = T extends (req: infer Req) => Promise ? Req : never; -export type ResOf = T extends (req: infer Req) => Promise ? Res : never; +export interface Verification { + token: string; +} + +export interface Session { + token: string; + expireAt: string; +} diff --git a/modules/auth_email_passwordless/config.ts b/modules/auth_email_passwordless/config.ts new file mode 100644 index 00000000..ae3e3cd1 --- /dev/null +++ b/modules/auth_email_passwordless/config.ts @@ -0,0 +1,5 @@ +export interface Config { + fromEmail?: string; + fromName?: string; + mode: "link" | "login" +} diff --git a/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql b/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql new file mode 100644 index 00000000..ea1087c9 --- /dev/null +++ b/modules/auth_email_passwordless/db/migrations/1724526776_powerful_maverick.sql @@ -0,0 +1,15 @@ +CREATE SCHEMA "module_auth_email"; +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "module_auth_email"."verifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "email" text NOT NULL, + "code" text NOT NULL, + "token" text NOT NULL, + "attempt_count" integer DEFAULT 0 NOT NULL, + "max_attempt_count" integer NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expire_at" timestamp NOT NULL, + "completed_at" timestamp, + CONSTRAINT "verifications_code_unique" UNIQUE("code"), + CONSTRAINT "verifications_token_unique" UNIQUE("token") +); diff --git a/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json b/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json new file mode 100644 index 00000000..7639d487 --- /dev/null +++ b/modules/auth_email_passwordless/db/migrations/meta/1724526776_snapshot.json @@ -0,0 +1,100 @@ +{ + "id": "9c7e6120-edaf-4441-880b-a2a1c21f20e3", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "module_auth_email.verifications": { + "name": "verifications", + "schema": "module_auth_email", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempt_count": { + "name": "max_attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expire_at": { + "name": "expire_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "verifications_code_unique": { + "name": "verifications_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + }, + "verifications_token_unique": { + "name": "verifications_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + } + } + }, + "enums": {}, + "schemas": { + "module_auth_email": "module_auth_email" + }, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/modules/auth_email_passwordless/db/migrations/meta/_journal.json b/modules/auth_email_passwordless/db/migrations/meta/_journal.json new file mode 100644 index 00000000..52f085c9 --- /dev/null +++ b/modules/auth_email_passwordless/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1724526776986, + "tag": "1724526776_powerful_maverick", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/modules/auth_email_passwordless/db/schema.ts b/modules/auth_email_passwordless/db/schema.ts new file mode 100644 index 00000000..0287e367 --- /dev/null +++ b/modules/auth_email_passwordless/db/schema.ts @@ -0,0 +1,18 @@ +import { schema, Query } from "./schema.gen.ts"; + +export const verifications = schema.table('verifications', { + id: Query.uuid("id").primaryKey().defaultRandom(), + + email: Query.text("email").notNull(), + + code: Query.text("code").notNull().unique(), + token: Query.text("token").notNull().unique(), + + attemptCount: Query.integer("attempt_count").notNull().default(0), + maxAttemptCount: Query.integer("max_attempt_count").notNull(), + + createdAt: Query.timestamp("created_at").notNull().defaultNow(), + expireAt: Query.timestamp("expire_at").notNull(), + completedAt: Query.timestamp("completed_at"), +}); + diff --git a/modules/auth_email_passwordless/module.json b/modules/auth_email_passwordless/module.json index 7ecdd900..3271ccf9 100644 --- a/modules/auth_email_passwordless/module.json +++ b/modules/auth_email_passwordless/module.json @@ -13,10 +13,16 @@ ], "status": "stable", "dependencies": { - "auth_email": {} + "email": {}, + "identities": {}, + "users": {}, + "tokens": {}, + "rate_limit": {} }, "defaultConfig": { - "enable": false + "fromEmail": "hello@test.com", + "fromName": "Authentication Code", + "mode": "login" }, "scripts": { "send_verification": { @@ -25,19 +31,37 @@ "public": true }, "verify_add_no_pass": { - "name": "Verify and Add Email to Existing User (No Password)", + "name": "Verify and Add Login Method", "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.", + "name": "Verify and Login/Create User", + "description": "Verify the email address code and return a userToken to its account (creates a new account if one doesn't exist). Does not require 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" + }, + "not_enabled": { + "name": "Functionality Not Enabled" } + }, + "enableConfig": { + "enable": true } } diff --git a/modules/auth_email_passwordless/scripts/send_verification.ts b/modules/auth_email_passwordless/scripts/send_verification.ts index 9eecec69..0fd3cbf6 100644 --- a/modules/auth_email_passwordless/scripts/send_verification.ts +++ b/modules/auth_email_passwordless/scripts/send_verification.ts @@ -1,13 +1,40 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; +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; + fromEmail?: string; + fromName?: string; +} -export type Request = ReqOf; -export type Response = ResOf; +export interface Response { + verification: Verification; +} export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.sendVerification(req); + await ctx.modules.rateLimit.throttlePublic({}); + + const { code, verification } = await createVerification( + ctx, + req.email, + ); + + // 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_passwordless/scripts/verify_add_no_pass.ts b/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts index f6f576c0..6c21da53 100644 --- a/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts +++ b/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts @@ -1,13 +1,40 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.ts"; +import { Empty, ScriptContext } from "../module.gen.ts"; +import { verifyCode } from "../utils/code_management.ts"; +import { IDENTITY_INFO_PASSWORDLESS, IDENTITY_INFO_LINK } from "../utils/provider.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; +export interface Request { + verificationToken: string; + code: string; + userToken: string; +} -export type Request = ReqOf; -export type Response = ResOf; +export type Response = Empty; export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.verifyAddNoPass(req); + await ctx.modules.rateLimit.throttlePublic({}); + + // Verify that the code is correct and valid + const { email } = await verifyCode(ctx, req.verificationToken, 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: ctx.config.mode === "link" ? IDENTITY_INFO_LINK : IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return {}; } diff --git a/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts b/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts index 6bdd3a8c..09f08c48 100644 --- a/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts +++ b/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts @@ -1,14 +1,55 @@ import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { ReqOf, ResOf } from "../utils/types.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 { + verificationToken: string; + code: string; +} -export type Request = ReqOf; -export type Response = ResOf; +export interface Response { + userToken: string; +} export async function run( ctx: ScriptContext, req: Request, ): Promise { - return await ctx.modules.authEmail.verifyLoginOrCreateNoPass(req); -} + if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); + await ctx.modules.rateLimit.throttlePublic({}); + + const { email } = await verifyCode(ctx, req.verificationToken, 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 (e) { + if (e instanceof RuntimeError && e.code === "identity_provider_not_found") { + // Email is not associated with an account, we can proceed with signing up. + } else { + throw e; + } + } + + // 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/common.ts b/modules/auth_email_passwordless/tests/common.ts similarity index 96% rename from modules/auth_email/tests/common.ts rename to modules/auth_email_passwordless/tests/common.ts index 8f0179d4..4a7d1895 100644 --- a/modules/auth_email/tests/common.ts +++ b/modules/auth_email_passwordless/tests/common.ts @@ -6,7 +6,7 @@ import { export async function getVerification(ctx: TestContext, email: string) { // Get a valid verification - const { verification: { token: verificationToken } } = await ctx.modules.authEmail + const { verification: { token: verificationToken } } = await ctx.modules.authEmailPasswordless .sendVerification({ email }); const verification = await ctx.db.query.verifications.findFirst({ where: Query.eq(Database.verifications.token, verificationToken), @@ -26,6 +26,7 @@ export async function verifyProvider( const { identityProviders: [emailProvider] } = await ctx.modules.identities .list({ userToken }); assertEquals(emailProvider, provider); + assertExists(emailProvider); // Verify that the provider data is correct const { data } = await ctx.modules.identities.fetch({ diff --git a/modules/auth_email_passwordless/tests/connect.ts b/modules/auth_email_passwordless/tests/connect.ts new file mode 100644 index 00000000..35539210 --- /dev/null +++ b/modules/auth_email_passwordless/tests/connect.ts @@ -0,0 +1,42 @@ +import { test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { + IDENTITY_INFO_PASSWORDLESS, +} from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "./common.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 { verificationToken, code } = await getVerification(ctx, email); + await ctx.modules.authEmailPasswordless.verifyAddNoPass({ + userToken, + verificationToken, + code, + }); + } + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORDLESS); + + // MARK: Log in + { + const { verificationToken, code } = await getVerification(ctx, email); + + const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( + { + verificationToken, + code, + }, + ); + + await checkLogin(ctx, user, userToken); + } +}); diff --git a/modules/auth_email_passwordless/tests/create.ts b/modules/auth_email_passwordless/tests/create.ts new file mode 100644 index 00000000..fdbb2efe --- /dev/null +++ b/modules/auth_email_passwordless/tests/create.ts @@ -0,0 +1,44 @@ +import { test, TestContext } from "../module.gen.ts"; +import { faker } from "https://deno.land/x/deno_faker@v1.0.3/mod.ts"; +import { + IDENTITY_INFO_PASSWORDLESS, +} from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "./common.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 { verificationToken, code } = await getVerification(ctx, email); + const signUpRes = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass({ + verificationToken, + 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 { verificationToken, code } = await getVerification(ctx, email); + + const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( + { + verificationToken, + code, + }, + ); + + await checkLogin(ctx, user!, userToken); + } +}); diff --git a/modules/auth_email_passwordless/utils/code_management.ts b/modules/auth_email_passwordless/utils/code_management.ts new file mode 100644 index 00000000..66740b90 --- /dev/null +++ b/modules/auth_email_passwordless/utils/code_management.ts @@ -0,0 +1,76 @@ +import { RuntimeError, ScriptContext, Module, Database, Query } from "../module.gen.ts"; + +const MAX_ATTEMPT_COUNT = 3; +const EXPIRATION_TIME = 60 * 60 * 1000; + +export async function createVerification(ctx: ScriptContext, email: string) { + // Create verification + const code = Module.tokens.generateRandomCodeSecure("ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 8); + const verification = await ctx.db.insert(Database.verifications) + .values({ + token: Module.tokens.genSecureId(), + email, + code, + maxAttemptCount: MAX_ATTEMPT_COUNT, + expireAt: new Date(Date.now() + EXPIRATION_TIME), + }) + .returning(); + + return { verification: verification[0]!, code }; +} + +export async function verifyCode( + ctx: ScriptContext, + verificationToken: string, + codeInput: string, +) { + await ctx.modules.rateLimit.throttlePublic({}); + + const code = codeInput.toUpperCase(); + + return await ctx.db.transaction(async (tx) => { + const verification = await tx.update(Database.verifications) + .set({ + attemptCount: Query.sql`${Database.verifications.attemptCount} + 1`, + }) + .where(Query.eq(Database.verifications.token, verificationToken)) + .returning(); + if (!verification[0]) { + throw new RuntimeError("verification_code_invalid"); + } + if (verification[0]!.attemptCount >= verification[0]!.maxAttemptCount) { + throw new RuntimeError("verification_code_attempt_limit"); + } + if (verification[0]!.completedAt !== null) { + throw new RuntimeError("verification_code_already_used"); + } + if (verification[0]!.code !== code) { + // Same error as above to prevent exploitation + throw new RuntimeError("verification_code_invalid"); + } + if (verification[0]!.expireAt < new Date()) { + throw new RuntimeError("verification_code_expired"); + } + + const completedAt = new Date(); + + // Mark as used + const verificationConfirmation = await tx.update(Database.verifications) + .set({ + completedAt, + }) + .where(Query.and( + Query.eq(Database.verifications.token, verificationToken), + Query.isNull(Database.verifications.completedAt) + )) + .returning(); + if (verificationConfirmation.length === 0) { + throw new RuntimeError("verification_code_already_used"); + } + + return { + email: verificationConfirmation[0]!.email, + completedAt, + }; + }); +} \ No newline at end of file diff --git a/modules/auth_email_passwordless/utils/link_assertions.ts b/modules/auth_email_passwordless/utils/link_assertions.ts new file mode 100644 index 00000000..62939197 --- /dev/null +++ b/modules/auth_email_passwordless/utils/link_assertions.ts @@ -0,0 +1,107 @@ +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 (e) { + if (e instanceof RuntimeError && e.code === "identity_provider_not_found") { + // 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; + } else { + // If this is some other error, rethrow it, because it isn't + // NECESSARILY what we want. + throw e; + } + } + // 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_passwordless/utils/provider.ts b/modules/auth_email_passwordless/utils/provider.ts new file mode 100644 index 00000000..d46bb5cd --- /dev/null +++ b/modules/auth_email_passwordless/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_passwordless/utils/types.ts b/modules/auth_email_passwordless/utils/types.ts index 8de38050..b291c838 100644 --- a/modules/auth_email_passwordless/utils/types.ts +++ b/modules/auth_email_passwordless/utils/types.ts @@ -1,2 +1,8 @@ -export type ReqOf = T extends (req: infer Req) => Promise ? Req : never; -export type ResOf = T extends (req: infer Req) => Promise ? Res : never; +export interface Verification { + token: string; +} + +export interface Session { + token: string; + expireAt: string; +} diff --git a/modules/auth_username_password/module.json b/modules/auth_username_password/module.json index 49004843..d49e171c 100644 --- a/modules/auth_username_password/module.json +++ b/modules/auth_username_password/module.json @@ -1,5 +1,5 @@ { - "name": "Auth Username Password", + "name": "Auth Username & Password", "description": "Authenticate users with a username/password combination.", "icon": "key", "tags": [ diff --git a/tests/basic/backend.json b/tests/basic/backend.json index 72bf5577..039ade2e 100644 --- a/tests/basic/backend.json +++ b/tests/basic/backend.json @@ -10,12 +10,6 @@ "analytics": { "registry": "local" }, - "auth_email": { - "registry": "local" - }, - "auth_email_link": { - "registry": "local" - }, "auth_email_password": { "registry": "local" },