From 41ab2d9362f3c3878890e351ec2747dc22eee7b5 Mon Sep 17 00:00:00 2001 From: Skyler Calaman <54462713+Blckbrry-Pi@users.noreply.github.com> Date: Mon, 9 Sep 2024 18:19:33 -0400 Subject: [PATCH] refactor: Update `auth_email_passwordless` and `auth_email_password` to use the new flows proposed --- modules/auth_email_password/module.json | 30 ++-- .../scripts/send_verification.ts | 40 ----- .../auth_email_password/scripts/sign_in.ts | 77 ++++++++++ .../scripts/sign_in_email_pass.ts | 35 ----- .../scripts/verify_add_email_pass.ts | 85 ----------- .../scripts/verify_code.ts | 137 ++++++++++++++++++ .../scripts/verify_sign_up_email_pass.ts | 64 -------- modules/auth_email_password/tests/common.ts | 50 +++++++ modules/auth_email_password/tests/connect.ts | 54 +++++++ modules/auth_email_password/tests/create.ts | 53 +++++++ modules/auth_email_passwordless/module.json | 123 ++++++++-------- .../scripts/send_verification.ts | 41 ------ .../scripts/sign_in.ts | 68 +++++++++ .../scripts/verify_add_no_pass.ts | 47 ------ .../scripts/verify_code.ts | 75 ++++++++++ .../scripts/verify_login_or_create_no_pass.ts | 63 -------- .../auth_email_passwordless/tests/common.ts | 6 +- .../auth_email_passwordless/tests/connect.ts | 28 ++-- .../auth_email_passwordless/tests/create.ts | 34 +++-- .../auth_email_passwordless/utils/types.ts | 8 - .../migrations/1725928547_sturdy_psynapse.sql | 2 + .../migrations/meta/1725928547_snapshot.json | 60 ++++++++ .../db/migrations/meta/_journal.json | 7 + modules/user_passwords/db/schema.ts | 4 +- modules/user_passwords/module.json | 8 + modules/user_passwords/public.ts | 7 + modules/user_passwords/scripts/meta.ts | 29 ++++ .../user_passwords/scripts/set_raw_hash.ts | 33 +++++ modules/user_passwords/scripts/update.ts | 1 + 29 files changed, 773 insertions(+), 496 deletions(-) delete mode 100644 modules/auth_email_password/scripts/send_verification.ts create mode 100644 modules/auth_email_password/scripts/sign_in.ts delete mode 100644 modules/auth_email_password/scripts/sign_in_email_pass.ts delete mode 100644 modules/auth_email_password/scripts/verify_add_email_pass.ts create mode 100644 modules/auth_email_password/scripts/verify_code.ts delete mode 100644 modules/auth_email_password/scripts/verify_sign_up_email_pass.ts create mode 100644 modules/auth_email_password/tests/common.ts create mode 100644 modules/auth_email_password/tests/connect.ts create mode 100644 modules/auth_email_password/tests/create.ts delete mode 100644 modules/auth_email_passwordless/scripts/send_verification.ts create mode 100644 modules/auth_email_passwordless/scripts/sign_in.ts delete mode 100644 modules/auth_email_passwordless/scripts/verify_add_no_pass.ts create mode 100644 modules/auth_email_passwordless/scripts/verify_code.ts delete mode 100644 modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts delete mode 100644 modules/auth_email_passwordless/utils/types.ts create mode 100644 modules/user_passwords/db/migrations/1725928547_sturdy_psynapse.sql create mode 100644 modules/user_passwords/db/migrations/meta/1725928547_snapshot.json create mode 100644 modules/user_passwords/public.ts create mode 100644 modules/user_passwords/scripts/meta.ts create mode 100644 modules/user_passwords/scripts/set_raw_hash.ts diff --git a/modules/auth_email_password/module.json b/modules/auth_email_password/module.json index 69e2bb08..cfc900bd 100644 --- a/modules/auth_email_password/module.json +++ b/modules/auth_email_password/module.json @@ -26,26 +26,16 @@ "fromName": "Authentication Code" }, "scripts": { - "send_verification": { - "name": "Send Email Verification", - "description": "Send a one-time verification code to an email address to verify ownership.", - "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. Requires a password.", - "public": true - } + "sign_in": { + "name": "Sign In", + "description": "Initiates a verification flow with an intended action attached. Returns a token to reference that verification/action.", + "public": true + }, + "verify_code": { + "name": "Verify Code", + "description": "Verifies ownership of an email and executes the intended action. May error if action is not able to be taken.", + "public": true + } }, "errors": { "verification_code_invalid": { diff --git a/modules/auth_email_password/scripts/send_verification.ts b/modules/auth_email_password/scripts/send_verification.ts deleted file mode 100644 index 9da8048b..00000000 --- a/modules/auth_email_password/scripts/send_verification.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { ScriptContext } from "../module.gen.ts"; - -export interface Request { - email: string; - userToken?: string; -} - -export interface Response { - token: string; -} - -const HOUR_MS = 60 * 60 * 1000 * 1000; -const ATTEMPTS = 3; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - const { code, token } = await ctx.modules.verifications.create({ - data: { email: req.email }, - expireAt: new Date(Date.now() + HOUR_MS).toISOString(), - maxAttempts: ATTEMPTS, - }); - - // 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 { token }; -} diff --git a/modules/auth_email_password/scripts/sign_in.ts b/modules/auth_email_password/scripts/sign_in.ts new file mode 100644 index 00000000..066d29a9 --- /dev/null +++ b/modules/auth_email_password/scripts/sign_in.ts @@ -0,0 +1,77 @@ +import { Empty, Module, RuntimeError, ScriptContext, UnreachableError } from "../module.gen.ts"; +import { ensureNotAssociated, ensureNotAssociatedAll } from "../utils/link_assertions.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; + +interface ConnectRequest { + connectEmail: { + userToken: string; + }; +} +interface SignInRequest { + signIn: { + createUser: boolean; + }; +} +interface SignUpRequest { + signUp: Empty; +} +export type Request = { email: string, password: string } & (ConnectRequest | SignInRequest | SignUpRequest); + +export interface Response { + token: string; +} + +const HOUR_MS = 60 * 60 * 1000; +const ATTEMPTS = 3; + +export async function run(ctx: ScriptContext, req: Request): Promise { + let verificationData: unknown; + if ("connectEmail" in req) { + const { userId } = await ctx.modules.users.authenticateToken({ + userToken: req.connectEmail.userToken, + }); + + if (await ctx.modules.userPasswords.meta({ userId })) { + await ctx.modules.userPasswords.verify({ userId, password: req.password }); + await ensureNotAssociatedAll(ctx, req.email, new Set()); + verificationData = { email: req.email, connect: userId, createdAt: new Date().toISOString() }; + } else { + const newHash = Module.userPasswords.prehash(req.password); + verificationData = { email: req.email, newHash, connect: userId }; + } + } else if ("signIn" in req) { + try { + const { userId } = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { identifier: req.email }, + }); + + await ctx.modules.userPasswords.verify({ userId, password: req.password }); + + verificationData = { email: req.email, signIn: userId, createdAt: new Date().toISOString() }; + } catch (e) { + if (!(e instanceof RuntimeError) || e.code !== "identity_provider_not_found") { + throw e; + } + if (!req.signIn.createUser) throw new RuntimeError("email_unregistered"); + + await ensureNotAssociatedAll(ctx, req.email, new Set()); + const newHash = Module.userPasswords.prehash(req.password); + verificationData = { email: req.email, signUp: true, newHash }; + } + } else if ("signUp" in req) { + await ensureNotAssociatedAll(ctx, req.email, new Set()); + const newHash = Module.userPasswords.prehash(req.password); + verificationData = { email: req.email, signUp: true, newHash }; + } else { + throw new UnreachableError(req); + } + + const { token } = await ctx.modules.verifications.create({ + data: verificationData, + expireAt: new Date(Date.now() + HOUR_MS).toISOString(), + maxAttempts: ATTEMPTS, + }); + + return { token }; +} diff --git a/modules/auth_email_password/scripts/sign_in_email_pass.ts b/modules/auth_email_password/scripts/sign_in_email_pass.ts deleted file mode 100644 index 22e1130e..00000000 --- a/modules/auth_email_password/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_password/scripts/verify_add_email_pass.ts b/modules/auth_email_password/scripts/verify_add_email_pass.ts deleted file mode 100644 index 943d1ee4..00000000 --- a/modules/auth_email_password/scripts/verify_add_email_pass.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { Empty, RuntimeError, ScriptContext } from "../module.gen.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; - - token: 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 { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); - if (!succeeded) throw new RuntimeError("invalid_code"); - - if ( - typeof data !== "object" || - data === null || - !("email" in data) || - typeof data.email !== "string" - ) throw new RuntimeError("unknown_err"); - - if (!compareConstantTime(req.email, data.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, data.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: data.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_code.ts b/modules/auth_email_password/scripts/verify_code.ts new file mode 100644 index 00000000..4e3b92e8 --- /dev/null +++ b/modules/auth_email_password/scripts/verify_code.ts @@ -0,0 +1,137 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; +import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; + +export interface Request { + token: string; + code: string; +} + +export interface Response { + userToken?: string; +} + +async function trySignIn(ctx: ScriptContext, email: string): Promise { + // Try signing in with the email, and return the user token if successful. + try { + const signInResponse = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: email, + }, + }); + + return signInResponse.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. + return null; + } else { + throw e; + } + } +} + +async function trySignUp(ctx: ScriptContext, email: string): Promise { + // 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_PASSWORD, + uniqueData: { + identifier: email, + }, + additionalData: {}, + }); + + return signUpResponse.userToken ; +} + + +async function doPasswordAction(ctx: ScriptContext, userId: string, data: object) { + const { meta: hashMeta } = await ctx.modules.userPasswords.meta({ userId }); + if ("newHash" in data && typeof data.newHash === "string") { + if (hashMeta) throw new RuntimeError("verification_invalidated"); + await ctx.modules.userPasswords.setRawHash({ userId, newHash: data.newHash }); + } else if ("createdAt" in data && typeof data.createdAt === "string") { + if (!hashMeta) throw new RuntimeError("unknown_err"); + + if (new Date(hashMeta.updatedAt).getTime() >= new Date(data.createdAt).getTime() - 1000) { + throw new RuntimeError("verification_invalidated"); + } + } +} + +export async function run(ctx: ScriptContext, req: Request): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + // Verify that the code is correct and valid + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); + + // if ("newHash" in data && typeof data.newHash === "string") { + // await ctx.modules.userPasswords.setRawHash({ userId: }) + // } + + if ("connect" in data && typeof data.connect === "string") { + // Ensure that the email is not already associated with another account + await ensureNotAssociatedAll(ctx, data.email, new Set([data.connect])); + + // Either recheck or reset the password + await doPasswordAction(ctx, data.connect, data); + + const { token: { token: userToken } } = await ctx.modules.users.createToken({ userId: data.connect }); + + // Add email/password sign in to the user's account + await ctx.modules.identities.link({ + userToken, + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: data.email, + }, + additionalData: {}, + }); + + return {}; + } else if ("signIn" in data && typeof data.signIn === "string") { + // Check the password hasn't changed + await doPasswordAction(ctx, data.signIn, data); + + const signInResponse = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: data.email, + }, + }); + + return { userToken: signInResponse.userToken }; + } else if ("signUp" in data && data.signUp === true) { + // Ensure email is not associated to ANY account + await ensureNotAssociatedAll(ctx, data.email, new Set()); + + + // Sign up the user with the passwordless email identity + const signUpResponse = await ctx.modules.identities.signUp({ + info: IDENTITY_INFO_PASSWORD, + uniqueData: { + identifier: data.email, + }, + additionalData: {}, + }); + + await doPasswordAction(ctx, signUpResponse.userId, data); + + return { userToken: signUpResponse.userToken }; + } else { + ctx.log.warn("Unknown type", ["verification_data", JSON.stringify(data)]); + throw new RuntimeError("unknown_err"); + } +} 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 deleted file mode 100644 index 5a7d60fa..00000000 --- a/modules/auth_email_password/scripts/verify_sign_up_email_pass.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { IDENTITY_INFO_PASSWORD } from "../utils/provider.ts"; -import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; - -export interface Request { - email: string; - password: string; - - token: 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 { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); - if (!succeeded) throw new RuntimeError("invalid_code"); - - if ( - typeof data !== "object" || - data === null || - !("email" in data) || - typeof data.email !== "string" - ) throw new RuntimeError("unknown_err"); - - if (!compareConstantTime(req.email, data.email)) { - throw new RuntimeError("verification_failed"); - } - - // Ensure that the email is not associated with ANY accounts in ANY way. - await ensureNotAssociatedAll(ctx, data.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: data.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_password/tests/common.ts b/modules/auth_email_password/tests/common.ts new file mode 100644 index 00000000..714b0ba0 --- /dev/null +++ b/modules/auth_email_password/tests/common.ts @@ -0,0 +1,50 @@ +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, token: string) { + const { verification } = await ctx.modules.verifications.get({ token }); + assertExists(verification); + + return verification; +} + +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); + assertExists(emailProvider); + + // Verify that the provider data is correct + const { data } = await ctx.modules.identities.fetch({ + 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_email_password/tests/connect.ts b/modules/auth_email_password/tests/connect.ts new file mode 100644 index 00000000..c62af70a --- /dev/null +++ b/modules/auth_email_password/tests/connect.ts @@ -0,0 +1,54 @@ +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 } from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "./common.ts"; +import { assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; + +// MARK: Test Email/No Pass +test("connect_email_and_login_with_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, + }); + + await ctx.modules.userPasswords.add({ + userId: user.id, + password, + }); + + // MARK: Connect + { + const { token } = await ctx.modules.authEmailPassword.signIn({ + email, + password, + connectEmail: { userToken }, + }); + const { code } = await getVerification(ctx, token); + await ctx.modules.authEmailPassword.verifyCode({ + token, + code, + }); + } + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORD); + + // MARK: Log in + { + const { token } = await ctx.modules.authEmailPassword.signIn({ + email, + password, + signIn: { createUser: false }, + }); + const { code } = await getVerification(ctx, token); + const { userToken } = await ctx.modules.authEmailPassword.verifyCode({ + token, + code, + }); + assertExists(userToken); + + await checkLogin(ctx, user, userToken); + } +}); diff --git a/modules/auth_email_password/tests/create.ts b/modules/auth_email_password/tests/create.ts new file mode 100644 index 00000000..408f6fbb --- /dev/null +++ b/modules/auth_email_password/tests/create.ts @@ -0,0 +1,53 @@ +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 } from "../utils/provider.ts"; +import { checkLogin, getVerification, verifyProvider } from "./common.ts"; +import { assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; + +// MARK: Test Email/No Pass +test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { + const email = faker.internet.email(); + const password = faker.internet.password(); + + let userToken: string; + + // MARK: Sign Up + { + + const { token } = await ctx.modules.authEmailPassword.signIn({ + email, + password, + signIn: { createUser: true }, + }); + const { code } = await getVerification(ctx, token); + userToken = (await ctx.modules.authEmailPassword.verifyCode({ + token, + code, + })).userToken!; + } + + const { user } = await ctx.modules.users.authenticateToken({ + userToken, + fetchUser: true, + }); + assertExists(user); + + await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORD); + + // MARK: Log in + { + const { token } = await ctx.modules.authEmailPassword.signIn({ + email, + password, + signIn: { createUser: false }, + }); + const { code } = await getVerification(ctx, token); + const { userToken } = await ctx.modules.authEmailPassword.verifyCode({ + token, + code, + }); + assertExists(userToken); + + await checkLogin(ctx, user, userToken); + } +}); diff --git a/modules/auth_email_passwordless/module.json b/modules/auth_email_passwordless/module.json index 3d2a5274..cd297680 100644 --- a/modules/auth_email_passwordless/module.json +++ b/modules/auth_email_passwordless/module.json @@ -1,68 +1,63 @@ { - "name": "Auth Email Passwordless", - "description": "Authenticate users with only an email.", - "icon": "key", - "tags": [ - "core", - "auth", - "user" - ], - "authors": [ - "rivet-gg", - "Blckbrry-Pi" - ], - "status": "stable", - "dependencies": { + "status": "stable", + "name": "Auth Email Passwordless", + "description": "Authenticate users with only an email.", + "icon": "key", + "tags": [ + "core", + "auth", + "user" + ], + "authors": [ + "rivet-gg", + "Blckbrry-Pi" + ], + "scripts": { + "sign_in": { + "name": "Sign In", + "description": "Initiates a verification flow with an intended action attached. Returns a token to reference that verification/action.", + "public": true + }, + "verify_code": { + "name": "Verify Code", + "description": "Verifies ownership of an email and executes the intended action. May error if action is not able to be taken.", + "public": true + } + }, + "errors": { + "invalid_code": { + "name": "Verification Code Invalid", + "internal": false + }, + "email_in_usee": { + "name": "Email Is Already In Use", + "internal": false + }, + "email_unregistered": { + "name": "Cannot Sign In To Unregistered Email", + "internal": false + }, + "not_enabled": { + "name": "Functionality Not Enabled", + "internal": false + }, + "unknown_err": { + "name": "Error of Unknown Type", + "description": "An internal error of an unknown type occurred. No guarantees can be made about the validity of the verification.", + "internal": true + } + }, + "dependencies": { "email": {}, - "identities": {}, - "users": {}, + "identities": {}, + "users": {}, "tokens": {}, "rate_limit": {}, - "verifications": {} - }, - "defaultConfig": { - "fromEmail": "hello@test.com", - "fromName": "Authentication Code", - "mode": "login" - }, - "scripts": { - "send_verification": { - "name": "Send Email Verification", - "description": "Send a one-time verification code to an email address to verify ownership.", - "public": true - }, - "verify_add_no_pass": { - "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/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": { - "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 - } -} + "verifications": {} + }, + "defaultConfig": { + "fromEmail": "hello@test.com", + "fromName": "Authentication Code", + "mode": "login" + } +} \ No newline at end of file diff --git a/modules/auth_email_passwordless/scripts/send_verification.ts b/modules/auth_email_passwordless/scripts/send_verification.ts deleted file mode 100644 index 0bf389a3..00000000 --- a/modules/auth_email_passwordless/scripts/send_verification.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { ScriptContext } from "../module.gen.ts"; -import { Verification } from "../utils/types.ts"; - -export interface Request { - email: string; - userToken?: string; -} - -export interface Response { - token: string; -} - -const HOUR_MS = 60 * 60 * 1000 * 1000; -const ATTEMPTS = 3; - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - await ctx.modules.rateLimit.throttlePublic({}); - - const { code, token } = await ctx.modules.verifications.create({ - data: { email: req.email }, - expireAt: new Date(Date.now() + HOUR_MS).toISOString(), - maxAttempts: ATTEMPTS, - }); - - // 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 { token }; -} diff --git a/modules/auth_email_passwordless/scripts/sign_in.ts b/modules/auth_email_passwordless/scripts/sign_in.ts new file mode 100644 index 00000000..40e835a1 --- /dev/null +++ b/modules/auth_email_passwordless/scripts/sign_in.ts @@ -0,0 +1,68 @@ +import { Empty, RuntimeError, ScriptContext, UnreachableError } from "../module.gen.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; +import { IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; + +interface ConnectRequest { + connectEmail: { + userToken: string; + }; +} +interface SignInRequest { + signIn: { + createUser: boolean; + }; +} +interface SignUpRequest { + signUp: Empty; +} +export type Request = { email: string } & (ConnectRequest | SignInRequest | SignUpRequest); + +export interface Response { + token: string; +} + +const HOUR_MS = 60 * 60 * 1000; +const ATTEMPTS = 3; + +export async function run(ctx: ScriptContext, req: Request): Promise { + let verificationData: unknown; + if ("connectEmail" in req) { + const { userId } = await ctx.modules.users.authenticateToken({ + userToken: req.connectEmail.userToken, + }); + await ensureNotAssociatedAll(ctx, req.email, new Set()); + verificationData = { email: req.email, connect: userId }; + } else if ("signIn" in req) { + if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); + try { + const { userId } = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORDLESS, + uniqueData: { identifier: req.email }, + }); + + verificationData = { email: req.email, signIn: userId }; + } catch (e) { + if (!(e instanceof RuntimeError) || e.code !== "identity_provider_not_found") { + throw e; + } + if (!req.signIn.createUser) throw new RuntimeError("email_unregistered"); + + await ensureNotAssociatedAll(ctx, req.email, new Set()); + verificationData = { email: req.email, signUp: true }; + } + } else if ("signUp" in req) { + if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); + await ensureNotAssociatedAll(ctx, req.email, new Set()); + verificationData = { email: req.email, signUp: true }; + } else { + throw new UnreachableError(req); + } + + const { token } = await ctx.modules.verifications.create({ + data: verificationData, + expireAt: new Date(Date.now() + HOUR_MS).toISOString(), + maxAttempts: ATTEMPTS, + }); + + return { token }; +} diff --git a/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts b/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts deleted file mode 100644 index bec23f20..00000000 --- a/modules/auth_email_passwordless/scripts/verify_add_no_pass.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { Empty, RuntimeError, ScriptContext } from "../module.gen.ts"; -import { IDENTITY_INFO_PASSWORDLESS, IDENTITY_INFO_LINK } from "../utils/provider.ts"; -import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; - -export interface Request { - token: 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 { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); - if (!succeeded) throw new RuntimeError("invalid_code"); - - if ( - typeof data !== "object" || - data === null || - !("email" in data) || - typeof data.email !== "string" - ) throw new RuntimeError("unknown_err"); - - // Ensure that the email is not already associated with another account - const providedUser = await ctx.modules.users.authenticateToken({ - userToken: req.userToken, - }); - await ensureNotAssociatedAll(ctx, data.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: data.email, - }, - additionalData: {}, - }); - - return {}; -} diff --git a/modules/auth_email_passwordless/scripts/verify_code.ts b/modules/auth_email_passwordless/scripts/verify_code.ts new file mode 100644 index 00000000..d2b58ee0 --- /dev/null +++ b/modules/auth_email_passwordless/scripts/verify_code.ts @@ -0,0 +1,75 @@ +import { RuntimeError, ScriptContext } from "../module.gen.ts"; +import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; +import { IDENTITY_INFO_LINK, IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; + +export interface Request { + token: string; + code: string; +} + +export interface Response { + userToken?: string; +} + +export async function run(ctx: ScriptContext, req: Request): Promise { + await ctx.modules.rateLimit.throttlePublic({}); + + // Verify that the code is correct and valid + const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); + if (!succeeded) throw new RuntimeError("invalid_code"); + + if ( + typeof data !== "object" || + data === null || + !("email" in data) || + typeof data.email !== "string" + ) throw new RuntimeError("unknown_err"); + + if ("connect" in data && typeof data.connect === "string") { + // Ensure that the email is not already associated with another account + await ensureNotAssociatedAll(ctx, data.email, new Set([data.connect])); + + const { token: { token: userToken } } = await ctx.modules.users.createToken({ userId: data.connect }); + + // Add email passwordless sign in to the user's account + await ctx.modules.identities.link({ + userToken, + info: ctx.config.mode === "link" ? IDENTITY_INFO_LINK : IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: data.email, + }, + additionalData: {}, + }); + + return {}; + } else if ("signIn" in data && typeof data.signIn === "string") { + if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); + + const signInResponse = await ctx.modules.identities.signIn({ + info: IDENTITY_INFO_PASSWORDLESS, + uniqueData: { + identifier: data.email, + }, + }); + + return { userToken: signInResponse.userToken }; + } else if ("signUp" in data && data.signUp === true) { + if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); + + // Ensure email is not associated to ANY account + await ensureNotAssociatedAll(ctx, data.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: data.email, + }, + additionalData: {}, + }); + + return { userToken: signUpResponse.userToken }; + } else { + throw new RuntimeError("unknown_err"); + } +} 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 deleted file mode 100644 index 8981dd5b..00000000 --- a/modules/auth_email_passwordless/scripts/verify_login_or_create_no_pass.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { RuntimeError, ScriptContext } from "../module.gen.ts"; -import { IDENTITY_INFO_PASSWORDLESS } from "../utils/provider.ts"; -import { ensureNotAssociatedAll } from "../utils/link_assertions.ts"; - -export interface Request { - token: string; - code: string; -} - -export interface Response { - userToken: string; -} - -export async function run( - ctx: ScriptContext, - req: Request, -): Promise { - if (ctx.config.mode !== "login") throw new RuntimeError("not_enabled"); - await ctx.modules.rateLimit.throttlePublic({}); - - // Verify that the code is correct and valid - const { data, succeeded } = await ctx.modules.verifications.attempt({ token: req.token, code: req.code }); - if (!succeeded) throw new RuntimeError("invalid_code"); - - if ( - typeof data !== "object" || - data === null || - !("email" in data) || - typeof data.email !== "string" - ) throw new RuntimeError("unknown_err"); - - // 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: data.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, data.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: data.email, - }, - additionalData: {}, - }); - - return { userToken: signUpResponse.userToken }; -} diff --git a/modules/auth_email_passwordless/tests/common.ts b/modules/auth_email_passwordless/tests/common.ts index 31f372a1..714b0ba0 100644 --- a/modules/auth_email_passwordless/tests/common.ts +++ b/modules/auth_email_passwordless/tests/common.ts @@ -4,10 +4,8 @@ import { assertExists, } from "https://deno.land/std@0.208.0/assert/mod.ts"; -export async function getVerification(ctx: TestContext, data: { email: string }) { - const { id } = await ctx.modules.verifications.create({ data }); - - const { verification } = await ctx.modules.verifications.get({ id }); +export async function getVerification(ctx: TestContext, token: string) { + const { verification } = await ctx.modules.verifications.get({ token }); assertExists(verification); return verification; diff --git a/modules/auth_email_passwordless/tests/connect.ts b/modules/auth_email_passwordless/tests/connect.ts index be0bb9e7..7c142eac 100644 --- a/modules/auth_email_passwordless/tests/connect.ts +++ b/modules/auth_email_passwordless/tests/connect.ts @@ -4,6 +4,7 @@ import { IDENTITY_INFO_PASSWORDLESS, } from "../utils/provider.ts"; import { checkLogin, getVerification, verifyProvider } from "./common.ts"; +import { assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; // MARK: Test Email/No Pass test("connect_email_and_login_passwordless", async (ctx: TestContext) => { @@ -16,9 +17,12 @@ test("connect_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Connect { - const { token, code } = await getVerification(ctx, { email }); - await ctx.modules.authEmailPasswordless.verifyAddNoPass({ - userToken, + const { token } = await ctx.modules.authEmailPasswordless.signIn({ + email, + connectEmail: { userToken }, + }); + const { code } = await getVerification(ctx, token); + await ctx.modules.authEmailPasswordless.verifyCode({ token, code, }); @@ -28,14 +32,16 @@ test("connect_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Log in { - const { token, code } = await getVerification(ctx, { email }); - - const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( - { - token, - code, - }, - ); + const { token } = await ctx.modules.authEmailPasswordless.signIn({ + email, + signIn: { createUser: false }, + }); + const { code } = await getVerification(ctx, token); + const { userToken } = await ctx.modules.authEmailPasswordless.verifyCode({ + token, + code, + }); + assertExists(userToken); await checkLogin(ctx, user, userToken); } diff --git a/modules/auth_email_passwordless/tests/create.ts b/modules/auth_email_passwordless/tests/create.ts index 740eb6c2..451ddd0b 100644 --- a/modules/auth_email_passwordless/tests/create.ts +++ b/modules/auth_email_passwordless/tests/create.ts @@ -4,6 +4,7 @@ import { IDENTITY_INFO_PASSWORDLESS, } from "../utils/provider.ts"; import { checkLogin, getVerification, verifyProvider } from "./common.ts"; +import { assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; // MARK: Test Email/No Pass test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { @@ -13,32 +14,39 @@ test("create_with_email_and_login_passwordless", async (ctx: TestContext) => { // MARK: Sign Up { - const { token, code } = await getVerification(ctx, { email }); - const signUpRes = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass({ + + const { token } = await ctx.modules.authEmailPasswordless.signIn({ + email, + signIn: { createUser: true }, + }); + const { code } = await getVerification(ctx, token); + userToken = (await ctx.modules.authEmailPasswordless.verifyCode({ token, code, - }); - userToken = signUpRes.userToken; + })).userToken!; } const { user } = await ctx.modules.users.authenticateToken({ userToken, fetchUser: true, }); + assertExists(user); await verifyProvider(ctx, userToken, email, IDENTITY_INFO_PASSWORDLESS); // MARK: Log in { - const { token, code } = await getVerification(ctx, { email }); - - const { userToken } = await ctx.modules.authEmailPasswordless.verifyLoginOrCreateNoPass( - { - token, - code, - }, - ); + const { token } = await ctx.modules.authEmailPasswordless.signIn({ + email, + signIn: { createUser: false }, + }); + const { code } = await getVerification(ctx, token); + const { userToken } = await ctx.modules.authEmailPasswordless.verifyCode({ + token, + code, + }); + assertExists(userToken); - await checkLogin(ctx, user!, userToken); + await checkLogin(ctx, user, userToken); } }); diff --git a/modules/auth_email_passwordless/utils/types.ts b/modules/auth_email_passwordless/utils/types.ts deleted file mode 100644 index b291c838..00000000 --- a/modules/auth_email_passwordless/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/user_passwords/db/migrations/1725928547_sturdy_psynapse.sql b/modules/user_passwords/db/migrations/1725928547_sturdy_psynapse.sql new file mode 100644 index 00000000..b17a87f0 --- /dev/null +++ b/modules/user_passwords/db/migrations/1725928547_sturdy_psynapse.sql @@ -0,0 +1,2 @@ +ALTER TABLE "module_user_passwords"."passwords" ADD COLUMN "created_at" date DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "module_user_passwords"."passwords" ADD COLUMN "updated_at" date DEFAULT now() NOT NULL; \ No newline at end of file diff --git a/modules/user_passwords/db/migrations/meta/1725928547_snapshot.json b/modules/user_passwords/db/migrations/meta/1725928547_snapshot.json new file mode 100644 index 00000000..82b9522c --- /dev/null +++ b/modules/user_passwords/db/migrations/meta/1725928547_snapshot.json @@ -0,0 +1,60 @@ +{ + "id": "68e81cca-2836-4588-b54a-67c22369f5e8", + "prevId": "4910d9e1-e6d4-4ada-a953-337c261260e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "module_user_passwords.passwords": { + "name": "passwords", + "schema": "module_user_passwords", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "algo": { + "name": "algo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "date", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "schemas": { + "module_user_passwords": "module_user_passwords" + }, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/modules/user_passwords/db/migrations/meta/_journal.json b/modules/user_passwords/db/migrations/meta/_journal.json index 32ac7384..08180102 100644 --- a/modules/user_passwords/db/migrations/meta/_journal.json +++ b/modules/user_passwords/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1724526798153, "tag": "1724526798_modern_puff_adder", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1725928547504, + "tag": "1725928547_sturdy_psynapse", + "breakpoints": true } ] } \ No newline at end of file diff --git a/modules/user_passwords/db/schema.ts b/modules/user_passwords/db/schema.ts index e12b1cdc..18d72509 100644 --- a/modules/user_passwords/db/schema.ts +++ b/modules/user_passwords/db/schema.ts @@ -3,5 +3,7 @@ import { schema, Query } from "./schema.gen.ts"; export const passwords = schema.table("passwords", { userId: Query.uuid("user_id").primaryKey(), passwordHash: Query.text("password_hash").notNull(), - algo: Query.text("algo").notNull() + algo: Query.text("algo").notNull(), + createdAt: Query.date("created_at").notNull().defaultNow(), + updatedAt: Query.date("updated_at").notNull().defaultNow(), }); diff --git a/modules/user_passwords/module.json b/modules/user_passwords/module.json index 4d1fbf1e..ad5da787 100644 --- a/modules/user_passwords/module.json +++ b/modules/user_passwords/module.json @@ -29,6 +29,14 @@ "update": { "name": "Update Password for User", "description": "Update a userID/password combination. Errors if user does not have a password." + }, + "meta": { + "name": "Check the Password Metadata for a User", + "description": "Get the password metadata for users (e.g. whether the user has a password, when the password was last updated, etc)." + }, + "set_raw_hash": { + "name": "Set Raw Hash for User", + "description": "Register OR update a userID/password HASH combination. Not recommended to be used." } }, "errors": { diff --git a/modules/user_passwords/public.ts b/modules/user_passwords/public.ts new file mode 100644 index 00000000..4f27a54a --- /dev/null +++ b/modules/user_passwords/public.ts @@ -0,0 +1,7 @@ +import { hash } from "./utils/common.ts"; +import { ALGORITHM_DEFAULT } from "./utils/common.ts"; + +export function prehash(password: string): string { + const algo = ALGORITHM_DEFAULT; + return hash(password, algo); +} \ No newline at end of file diff --git a/modules/user_passwords/scripts/meta.ts b/modules/user_passwords/scripts/meta.ts new file mode 100644 index 00000000..21d9f9ef --- /dev/null +++ b/modules/user_passwords/scripts/meta.ts @@ -0,0 +1,29 @@ +import { ScriptContext, Database, Query } from "../module.gen.ts"; + +export interface Request { + userId: string; +} + +export interface Response { + meta?: { + initializedAt: string; + updatedAt: string; + }; +}; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Check if the user exists before hashing the password to save compute + // resources + const [user] = await ctx.db.select().from(Database.passwords).where(Query.eq(Database.passwords.userId, req.userId)); + + if (!user) return {}; + return { + meta: { + initializedAt: new Date(user.createdAt).toISOString(), + updatedAt: new Date(user.updatedAt).toISOString(), + }, + } +} diff --git a/modules/user_passwords/scripts/set_raw_hash.ts b/modules/user_passwords/scripts/set_raw_hash.ts new file mode 100644 index 00000000..ff328aa2 --- /dev/null +++ b/modules/user_passwords/scripts/set_raw_hash.ts @@ -0,0 +1,33 @@ +import { Empty, RuntimeError, ScriptContext, Query, Database } from "../module.gen.ts"; +import { ALGORITHM_DEFAULT, Algorithm, hash } from "../utils/common.ts"; + +export interface Request { + userId: string; + newHash: string; +} + +export type Response = Empty; + +export async function run( + ctx: ScriptContext, + req: Request, +): Promise { + // Update/insert the entry for the user's password + await ctx.db.insert(Database.passwords) + .values({ + userId: req.userId, + passwordHash: req.newHash, + algo: ALGORITHM_DEFAULT, + updatedAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: Database.passwords.userId, + set: { + passwordHash: req.newHash, + algo: ALGORITHM_DEFAULT, + updatedAt: new Date().toISOString(), + } + }); + + return {}; +} diff --git a/modules/user_passwords/scripts/update.ts b/modules/user_passwords/scripts/update.ts index 5bf5acfa..a4178daf 100644 --- a/modules/user_passwords/scripts/update.ts +++ b/modules/user_passwords/scripts/update.ts @@ -31,6 +31,7 @@ export async function run( .set({ passwordHash, algo, + updatedAt: new Date().toISOString(), }) .where(Query.eq(Database.passwords.userId, req.userId));