diff --git a/packages/core/src/@types/session.ts b/packages/core/src/@types/session.ts index 794fff73..90a9b6dd 100644 --- a/packages/core/src/@types/session.ts +++ b/packages/core/src/@types/session.ts @@ -277,6 +277,11 @@ export interface SessionStrategy { headers: Headers }> + /** + * Sign up a new user with the given payload and request. Returns the session token on success. + * @unstable This API is experimental and may change in future releases. + */ + signUp(payload: Record, request: Request): Promise signIn( oauth: string, request: Request, diff --git a/packages/core/src/api/signUp.ts b/packages/core/src/api/signUp.ts index 03c67280..d3def1d6 100644 --- a/packages/core/src/api/signUp.ts +++ b/packages/core/src/api/signUp.ts @@ -1,7 +1,8 @@ -import { createCSRF } from "@/shared/crypto.ts" import { HeadersBuilder } from "@aura-stack/router" -import { secureApiHeaders } from "@/shared/headers.ts" +import { createCSRF } from "@/shared/crypto.ts" +import { getErrorName } from "@/shared/utils.ts" import { AuraAuthError } from "@/shared/errors.ts" +import { secureApiHeaders } from "@/shared/headers.ts" import { createValidation, handleApiError, resolveApiRedirect } from "@/shared/utils/api.ts" import type { FunctionAPIContext, SignUpAPIOptions, SignUpAPIReturn } from "@/@types/api.ts" @@ -33,7 +34,8 @@ export const signUp = async = Record = Record( isProviderConnected: isProviderConnected(ctx), signIn: signIn(ctx), oauthCallback: oauthCallback(ctx), + signUp: signUp(ctx), } } diff --git a/packages/core/src/session/stateful/signUp.ts b/packages/core/src/session/stateful/signUp.ts new file mode 100644 index 00000000..997f504c --- /dev/null +++ b/packages/core/src/session/stateful/signUp.ts @@ -0,0 +1,144 @@ +import { AuraAuthError } from "@/shared/errors.ts" +import { createHash, createSecretValue, hashPassword } from "@/shared/crypto.ts" +import { createDevice as __createDevice } from "@/shared/utils/session-strategy.ts" +import type { InternalStatefulContext } from "@/@types/config.ts" + +/** + * @todo Add transaction support for the signUp process to ensure atomicity and rollback in case of errors. + */ +export const signUp = ({ ctx, cookies, cookieManager }: InternalStatefulContext) => { + const { logger, sessionConfig } = ctx + const createDevice = __createDevice({ ctx, cookies, cookieManager }) + + return async (payload: Record, request: Request): Promise => { + logger?.log("STATEFUL_CREATE_SESSION_START", { + structuredData: { + strategy: "stateful", + operation: "signUp", + }, + }) + + if (ctx.identity.skipValidation) { + logger?.log("IDENTITY_VALIDATION_DISABLED", { + structuredData: { + identity_validation_disabled: true, + }, + }) + } + + /** + * @todo fix wrong logic from identity.schema (User schema) and signUp.schema (SignUpPayload schema) + */ + const { password } = payload + const validatedPayload = ctx.identity.skipValidation ? payload : await ctx.identity.schemaRegistry.parse(payload) + logger?.log("STATEFUL_PAYLOAD_VALIDATION", { + structuredData: { + validation_skipped: ctx.identity.skipValidation || false, + has_email: Boolean(validatedPayload.email) || false, + }, + }) + + const { sub: _sub, email: rawEmail, name, image, ...attributes } = validatedPayload + const email = typeof rawEmail === "string" ? rawEmail.trim().toLowerCase() : rawEmail + + if (email) { + const getEmail = await sessionConfig.adapter.getUserByEmail(email) + if (getEmail) { + throw new AuraAuthError({ code: "EMAIL_ALREADY_REGISTERED" }) + } + } + + const userId = createSecretValue(32) + const user = await sessionConfig.adapter.createUser({ + id: userId, + name, + email, + image, + attributes, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + logger?.log("STATEFUL_USER_CREATED", { + structuredData: { + user_id: user.id, + has_email: Boolean(user.email), + }, + }) + + const account = await sessionConfig.adapter.createAccount({ + id: createSecretValue(32), + userId: user.id, + provider: "credentials", + providerUserId: user.id, + type: "credentials", + status: "active", + }) + + if (password !== undefined && password !== null) { + if (typeof password !== "string" || password.length === 0) { + throw new AuraAuthError({ code: "AUTH_CREDENTIALS_INVALID" }) + } + const passwordHash = await hashPassword(password) + await sessionConfig.adapter.createCredentialAccount({ + accountId: account.id, + passwordHash, + }) + } + + const device = await createDevice(user.id, request) + const secretValue = createSecretValue(64) + logger?.log("STATEFUL_TOKEN_GENERATED", { + structuredData: { + token_length: secretValue.length, + }, + }) + + const tokenHash = await createHash(secretValue) + logger?.log("STATEFUL_TOKEN_HASHED", { + structuredData: { + hash_length: tokenHash.length, + }, + }) + + const expiresAt = new Date(Date.now() + 60 * 60 * 24 * 15 * 1000) + logger?.log("STATEFUL_SESSION_EXPIRATION_SET", { + structuredData: { + expires_at: expiresAt?.toISOString(), + max_age_days: 15, + }, + }) + + const dbSession = await sessionConfig.adapter.createSession({ + id: createSecretValue(32), + userId: user.id, + deviceId: device.id, + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash, + expiresAt, + metadata: null, + }) + + logger?.log("STATEFUL_SESSION_CREATED", { + structuredData: { + session_id: dbSession.id, + user_id: dbSession.userId, + status: dbSession.status, + expires_at: dbSession?.expiresAt?.toISOString(), + }, + }) + + logger?.log("STATEFUL_CREATE_SESSION_SUCCESS", { + structuredData: { + session_id: dbSession.id, + user_id: dbSession.userId, + token_returned: true, + }, + }) + + return secretValue + } +} diff --git a/packages/core/src/session/stateless/index.ts b/packages/core/src/session/stateless/index.ts index 996ed46d..f680701b 100644 --- a/packages/core/src/session/stateless/index.ts +++ b/packages/core/src/session/stateless/index.ts @@ -1,4 +1,5 @@ import { signIn } from "@/session/stateless/signIn.ts" +import { signUp } from "@/session/stateless/signUp.ts" import { getSession } from "@/session/stateless/getSession.ts" import { revokeToken } from "@/session/stateless/revokeToken.ts" import { oauthCallback } from "@/session/stateless/oauthCallback.ts" @@ -31,5 +32,6 @@ export const createStatelessStrategy = ( destroySession: destroySession(ctx), signIn: signIn(ctx), oauthCallback: oauthCallback(ctx), + signUp: signUp(ctx), } } diff --git a/packages/core/src/session/stateless/signUp.ts b/packages/core/src/session/stateless/signUp.ts new file mode 100644 index 00000000..5f04b8b6 --- /dev/null +++ b/packages/core/src/session/stateless/signUp.ts @@ -0,0 +1,10 @@ +import { createSession as __createSession } from "@/session/stateless/createSession.ts" +import type { InternalStatelessContext, TypedJWTPayload, User } from "@/@types/index.ts" + +export const signUp = (ctx: InternalStatelessContext) => { + const createSession = __createSession(ctx) + + return async (payload: Record, _request: Request): Promise => { + return await createSession(payload as TypedJWTPayload) + } +} diff --git a/packages/core/src/session/strategy.ts b/packages/core/src/session/strategy.ts index 6f6ac7a6..28701ea1 100644 --- a/packages/core/src/session/strategy.ts +++ b/packages/core/src/session/strategy.ts @@ -20,7 +20,6 @@ export const createSessionStrategy = ( const cookieManager = createCookieManager(config.cookies) const ctx = { ...config, cookieManager } - console.log("isStateles: ", isStatelessStrategy(config?.ctx?.sessionConfig)) if (!isStatelessStrategy(config?.ctx?.sessionConfig) && !config?.ctx?.sessionConfig?.adapter) { throw new AuraAuthError({ code: "MISSING_ADAPTER_IN_STATEFUL_STRATEGY" }) } diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index 38cfe38f..3082c62d 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -132,6 +132,7 @@ export const AuraErrorCode = { OAUTH_UNLINKED_ACCOUNT_ERROR: "OAUTH_UNLINKED_ACCOUNT_ERROR", OAUTH_ACCOUNT_USER_MISMATCH: "OAUTH_ACCOUNT_USER_MISMATCH", MISSING_ADAPTER_IN_STATEFUL_STRATEGY: "MISSING_ADAPTER_IN_STATEFUL_STRATEGY", + EMAIL_ALREADY_REGISTERED: "EMAIL_ALREADY_REGISTERED", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -927,6 +928,14 @@ export const ERROR_CATALOG: Record = { userMessage: "Internal library configuration error. Database session strategy requires an adapter instance to be configured.", }, + EMAIL_ALREADY_REGISTERED: { + type: "AUTH_FLOW", + statusCode: 409, + name: "AuthError", + message: + "The registration request was rejected because the provided email address is already associated with an existing user record in the system.", + userMessage: "This email address is already registered. Please sign in or use a different email.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index da367444..73b9aaad 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -831,6 +831,12 @@ export const logMessages = { msgId: "STATELESS_REVOKE_SESSION_NOOP", message: "Stateless session revocation is a no-op (no server-side state to revoke)", }, + SIGN_UP_ERROR: { + facility: 4, + severity: "error", + msgId: "SIGN_UP_ERROR", + message: "Error occurred during user sign-up process", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/test/actions/signUp/stateful.test.ts b/packages/core/test/actions/signUp/stateful.test.ts index 26581a76..dc6502db 100644 --- a/packages/core/test/actions/signUp/stateful.test.ts +++ b/packages/core/test/actions/signUp/stateful.test.ts @@ -2,7 +2,15 @@ import { describe, test, expect, vi } from "vitest" import { z } from "zod/v4" import { createCSRF } from "@/shared/crypto.ts" import { identitySchema } from "@/identity/zod.ts" -import { authInstance, deviceEntity, jose, sessionEntityWithUser, sessionPayload, userEntity } from "@test/presets.ts" +import { + accountEntity, + authInstance, + deviceEntity, + jose, + sessionEntityWithUser, + sessionPayload, + userEntity, +} from "@test/presets.ts" import { createSchemaRegistry } from "@/validator/registry.ts" describe("signUp API", async () => { @@ -21,19 +29,22 @@ describe("signUp API", async () => { const spyParse = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ - createSession: createSessionMock, + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, + createAccount: createAccountMock, + createSession: createSessionMock, createDevice: createDeviceMock, - getUserById: getUserByIdMock, getDeviceByFingerprint: getDeviceByFingerprintMock, }) @@ -56,14 +67,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -84,19 +107,22 @@ describe("signUp API", async () => { const spyParse = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) const createUserMock = vi.fn() - const updateUserMock = vi.fn().mockReturnValue(userEntity) - const getUserByIdMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ - createSession: createSessionMock, + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, + createAccount: createAccountMock, + createSession: createSessionMock, createDevice: createDeviceMock, - getUserById: getUserByIdMock, getDeviceByFingerprint: getDeviceByFingerprintMock, }) @@ -108,9 +134,9 @@ describe("signUp API", async () => { }) ) - expect(response.status).toBe(200) + expect(response.status).toBe(409) expect(await response.json()).toEqual({ - success: true, + success: false, redirect: false, redirectURL: null, }) @@ -119,24 +145,11 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).not.toHaveBeenCalled() - expect(updateUserMock).toHaveBeenCalledWith("user-123", { - email: "john@example.com", - name: "John Doe", - image: "https://example.com/image.jpg", - attributes: {}, - }) - expect(createSessionMock).toHaveBeenCalledWith({ - id: expect.any(String), - userId: "user-123", - deviceId: "device-123", - authenticatedWith: "credentials", - status: "active", - mfaState: "none", - tokenHash: expect.any(String), - expiresAt: expect.any(Date), - metadata: null, - }) + expect(createAccountMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + expect(createDeviceMock).not.toHaveBeenCalled() }) test("invalid signUp.onCreateUser return", async () => { @@ -248,18 +261,21 @@ describe("signUp API", async () => { }) test("valid signUp.onCreateUser return with custom schema", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance( { + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -301,18 +317,29 @@ describe("signUp API", async () => { redirectURL: null, }) - expect(getUserByIdMock).toHaveBeenCalledWith("1234567890") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "1234567890", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), - userId: "1234567890", + userId: "user-123", deviceId: "device-123", authenticatedWith: "credentials", status: "active", @@ -324,18 +351,21 @@ describe("signUp API", async () => { }) test("valid signUp.onCreateUser return with custom schema and identity.schema", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance( { + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -383,19 +413,31 @@ describe("signUp API", async () => { redirect: false, redirectURL: null, }) - expect(getUserByIdMock).toHaveBeenCalledWith("1234567890") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "1234567890", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: { role: "user", }, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), - userId: "1234567890", + userId: "user-123", deviceId: "device-123", authenticatedWith: "credentials", status: "active", @@ -407,17 +449,20 @@ describe("signUp API", async () => { }) test("signUp with redirect: true and redirectTo", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -438,15 +483,26 @@ describe("signUp API", async () => { redirectURL: null, }) - expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -461,17 +517,20 @@ describe("signUp API", async () => { }) test("signUp with redirect: false", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -492,15 +551,26 @@ describe("signUp API", async () => { redirectURL: null, }) - expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -515,17 +585,20 @@ describe("signUp API", async () => { }) test("signUp with redirect: false and redirectTo", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createSession: createSessionMock, createDevice: createDeviceMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -546,15 +619,26 @@ describe("signUp API", async () => { redirectURL: "/dashboard", }) - expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -569,17 +653,20 @@ describe("signUp API", async () => { }) test("signUp with redirect: true and invalid redirectTo", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { handlers } = authInstance({ + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -600,15 +687,26 @@ describe("signUp API", async () => { redirectURL: null, }) - expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -623,8 +721,11 @@ describe("signUp API", async () => { }) test("signUp with redirect: false and invalid redirectTo", async () => { - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) @@ -632,8 +733,8 @@ describe("signUp API", async () => { const { handlers } = authInstance({ createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + getUserByEmail: getUserByEmailMock, + createAccount: createAccountMock, createSession: createSessionMock, createDevice: createDeviceMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -654,15 +755,26 @@ describe("signUp API", async () => { redirectURL: "/", }) - expect(getUserByIdMock).toHaveBeenCalledWith("user-123") + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", + id: expect.any(String), name: "John Doe", email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", diff --git a/packages/core/test/api/stateful/signUp.test.ts b/packages/core/test/api/stateful/signUp.test.ts index 61b0ffdc..9afe15f4 100644 --- a/packages/core/test/api/stateful/signUp.test.ts +++ b/packages/core/test/api/stateful/signUp.test.ts @@ -1,7 +1,15 @@ import { describe, test, expect, vi } from "vitest" import { createCSRF } from "@/shared/crypto.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { authInstance, deviceEntity, jose, sessionEntityWithUser, sessionPayload, userEntity } from "@test/presets.ts" +import { + accountEntity, + authInstance, + deviceEntity, + jose, + sessionEntityWithUser, + sessionPayload, + userEntity, +} from "@test/presets.ts" import type { User } from "@/index.ts" describe("signUp API", async () => { @@ -20,17 +28,20 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) - const getUserByIdMock = vi.fn().mockReturnValue(null) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createUserMock = vi.fn().mockReturnValue(userEntity) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { api } = authInstance({ + getUserByEmail: getUserByEmailMock, + createAccount: createAccountMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -52,14 +63,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -213,6 +236,151 @@ describe("signUp API", async () => { expect(updateUserMock).not.toHaveBeenCalled() }) + test("signUp with existing email", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getUserByEmailMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn() + const createUserMock = vi.fn() + const createDeviceMock = vi.fn() + const createSessionMock = vi.fn() + const getDeviceByFingerprintMock = vi.fn() + + const { api } = authInstance({ + getUserByEmail: getUserByEmailMock, + createUser: createUserMock, + createAccount: createAccountMock, + createDevice: createDeviceMock, + createSession: createSessionMock, + getDeviceByFingerprint: getDeviceByFingerprintMock, + }) + + const output = await api.signUp({ + headers, + payload: sessionPayload, + }) + expect(output).toEqual({ + success: false, + redirect: false, + redirectURL: null, + error: { + code: "EMAIL_ALREADY_REGISTERED", + message: "This email address is already registered. Please sign in or use a different email.", + }, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(spy).toHaveBeenCalledWith({ + ...sessionPayload, + sub: "user-123", + }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createUserMock).not.toHaveBeenCalled() + expect(createAccountMock).not.toHaveBeenCalled() + expect(createDeviceMock).not.toHaveBeenCalled() + expect(createSessionMock).not.toHaveBeenCalled() + }) + + /** + * @todo fix wrong logic from identity.schema (User schema) and signUp.schema (SignUpPayload schema) + */ + test("signUp including password", async () => { + vi.stubEnv("BASE_URL", "https://example.com") + + const registry = createSchemaRegistry({}) + const module = await import("@/validator/registry.ts") + + const spy = vi.spyOn(registry, "parse") + vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) + + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createUserMock = vi.fn().mockReturnValue(userEntity) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) + const createCredentialsAccountMock = vi.fn() + const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) + const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) + const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) + + const { api } = authInstance( + { + getUserByEmail: getUserByEmailMock, + createUser: createUserMock, + createAccount: createAccountMock, + createCredentialAccount: createCredentialsAccountMock, + createDevice: createDeviceMock, + createSession: createSessionMock, + getDeviceByFingerprint: getDeviceByFingerprintMock, + }, + { signUp: { onCreateUser: ({ payload }) => payload } } + ) + + const output = await api.signUp({ + headers, + payload: { + ...sessionPayload, + password: "secure-password-123", + }, + }) + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + + expect(spy).toHaveBeenCalledWith({ + ...sessionPayload, + sub: "1234567890", + password: "secure-password-123", + }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") + expect(createUserMock).toHaveBeenCalledWith({ + id: expect.any(String), + name: "John Doe", + email: "john@example.com", + image: "https://example.com/image.jpg", + attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", + }) + expect(createCredentialsAccountMock).toHaveBeenCalledWith({ + accountId: "account-123", + passwordHash: expect.stringMatching(/^pbkdf2-sha256:\d+:[\w-]+:[\w-]+$/), + }) + expect(createSessionMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: "user-123", + deviceId: "device-123", + authenticatedWith: "credentials", + status: "active", + mfaState: "none", + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + metadata: null, + }) + }) + test("signUp with redirect: true and redirectTo", async () => { vi.stubEnv("BASE_URL", "https://example.com") @@ -222,17 +390,20 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { api } = authInstance({ + getUserByEmail: getUserByEmailMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -257,14 +428,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -287,17 +470,20 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { api } = authInstance({ + getUserByEmail: getUserByEmailMock, + createAccount: createAccountMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -322,14 +508,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -352,8 +550,11 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) @@ -361,8 +562,8 @@ describe("signUp API", async () => { const { api } = authInstance({ createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, + getUserByEmail: getUserByEmailMock, + createAccount: createAccountMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -387,14 +588,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -417,17 +630,20 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() - const getUserByIdMock = vi.fn().mockReturnValue(null) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createUserMock = vi.fn().mockReturnValue(userEntity) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { api } = authInstance({ + getUserByEmail: getUserByEmailMock, + createAccount: createAccountMock, createUser: createUserMock, - updateUser: updateUserMock, - getUserById: getUserByIdMock, createDevice: createDeviceMock, createSession: createSessionMock, getDeviceByFingerprint: getDeviceByFingerprintMock, @@ -452,14 +668,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123", @@ -482,19 +710,24 @@ describe("signUp API", async () => { const spy = vi.spyOn(registry, "parse") vi.spyOn(module, "createSchemaRegistry").mockReturnValue(registry) - const updateUserMock = vi.fn() const getUserByIdMock = vi.fn().mockReturnValue(null) const createUserMock = vi.fn().mockReturnValue(userEntity) + const getUserByEmailMock = vi.fn().mockReturnValue(null) + const createAccountMock = vi.fn().mockReturnValue({ + ...accountEntity, + provider: "credentials", + }) const createDeviceMock = vi.fn().mockResolvedValue(deviceEntity) const createSessionMock = vi.fn().mockReturnValue(sessionEntityWithUser) const getDeviceByFingerprintMock = vi.fn().mockReturnValue(null) const { api } = authInstance({ createUser: createUserMock, - updateUser: updateUserMock, getUserById: getUserByIdMock, createDevice: createDeviceMock, + createAccount: createAccountMock, createSession: createSessionMock, + getUserByEmail: getUserByEmailMock, getDeviceByFingerprint: getDeviceByFingerprintMock, }) @@ -517,14 +750,26 @@ describe("signUp API", async () => { ...sessionPayload, sub: "user-123", }) + expect(getUserByEmailMock).toHaveBeenCalledWith("john@example.com") expect(createUserMock).toHaveBeenCalledWith({ - id: "user-123", - email: "john@example.com", + id: expect.any(String), name: "John Doe", + email: "john@example.com", image: "https://example.com/image.jpg", attributes: {}, + status: "active", + mfaEnabled: false, + mfaPreferredMethod: null, + emailVerifiedAt: null, + }) + expect(createAccountMock).toHaveBeenCalledWith({ + id: expect.any(String), + userId: expect.any(String), + provider: "credentials", + providerUserId: expect.any(String), + type: "credentials", + status: "active", }) - expect(updateUserMock).not.toHaveBeenCalled() expect(createSessionMock).toHaveBeenCalledWith({ id: expect.any(String), userId: "user-123",