diff --git a/packages/user/package.json b/packages/user/package.json index 426528a47..039dc410d 100644 --- a/packages/user/package.json +++ b/packages/user/package.json @@ -19,7 +19,9 @@ "main": "./dist/prefabs-tech-fastify-user.cjs", "module": "./dist/prefabs-tech-fastify-user.js", "types": "./dist/types/index.d.ts", - "files": ["dist"], + "files": [ + "dist" + ], "scripts": { "build": "vite build && tsc --emitDeclarationOnly && mv dist/src dist/types", "lint": "eslint .", @@ -78,5 +80,10 @@ }, "engines": { "node": ">=20" + }, + "peerDependenciesMeta": { + "supertokens-node": { + "optional": true + } } } diff --git a/packages/user/src/auth/__test__/providers.spec.ts b/packages/user/src/auth/__test__/providers.spec.ts new file mode 100644 index 000000000..a59650a50 --- /dev/null +++ b/packages/user/src/auth/__test__/providers.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import type { AuthProvider } from "../adapter"; + +import { + getAuthProvider, + registerAuthProvider, + supertokensProvider, +} from "../providers"; + +describe("auth providers registry", () => { + it("returns the built-in supertokens provider", () => { + expect(getAuthProvider("supertokens")).toBe(supertokensProvider); + }); + + it("throws for unknown provider names", () => { + expect(() => getAuthProvider("unknown-provider")).toThrow( + "Unknown auth provider: unknown-provider", + ); + }); + + it("allows registering a custom provider", () => { + const custom: AuthProvider = { + adapter: supertokensProvider.adapter, + }; + + registerAuthProvider("custom-test", custom); + + expect(getAuthProvider("custom-test")).toBe(custom); + }); +}); diff --git a/packages/user/src/auth/adapter.ts b/packages/user/src/auth/adapter.ts new file mode 100644 index 000000000..b776e9a62 --- /dev/null +++ b/packages/user/src/auth/adapter.ts @@ -0,0 +1,235 @@ +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; + +import type { ClaimValidationError, RefreshableClaim } from "./types"; + +export interface AuthAdapter { + claims?: ClaimsProvider; + createUserContext( + request: FastifyRequest, + existing?: AuthUserContext, + ): AuthUserContext; + emailPassword: EmailPasswordProvider; + emailVerification?: EmailVerificationProvider; + errors: AuthErrorsProvider; + roles: RolesProvider; + session: SessionProvider; +} + +export interface AuthErrorsProvider { + createInvalidClaimsError(errors: ClaimValidationError[]): Error; + + createUnauthorizedError(message?: string): Error; + + isAuthError(error: unknown): boolean; +} + +export interface AuthProvider { + adapter: AuthAdapter; + init?: (fastify: FastifyInstance) => Promise; +} + +export type AuthResult = + | { error: string; success: false } + | { success: true; user: T }; + +export interface AuthSession { + getAccessTokenPayload(userContext?: AuthUserContext): unknown; + getUserId(userContext?: AuthUserContext): string; + revokeSession(userContext?: AuthUserContext): Promise; +} + +export interface AuthUser { + [key: string]: unknown; + email: string; + id: string; + timeJoined?: number; +} + +export interface AuthUserContext { + [key: string]: unknown; +} + +export interface ClaimsProvider { + assertProfileValid( + session: AuthSession, + request: FastifyRequest, + userContext?: AuthUserContext, + ): Promise; + + readonly keys: { + emailVerification: string; + profileValidation: string; + }; + + refreshSessionClaims( + session: AuthSession, + request: FastifyRequest, + claims: RefreshableClaim[], + userContext?: AuthUserContext, + ): Promise; +} + +export interface EmailPasswordProvider { + createResetPasswordToken?(userId: string): Promise; + + emailPasswordSignIn( + email: string, + password: string, + userContext?: AuthUserContext, + ): Promise; + + emailPasswordSignUp( + email: string, + password: string, + userContext?: AuthUserContext, + ): Promise; + + getUserById(userId: string): Promise; + + getUsersByEmail?(email: string): Promise; + + resetPasswordUsingToken?( + token: string, + newPassword: string, + ): Promise; + + updateEmailOrPassword(input: { + email?: string; + password?: string; + userId: string; + }): Promise; +} + +export interface EmailVerificationProvider { + createEmailVerificationToken( + userId: string, + email?: string, + userContext?: AuthUserContext, + ): Promise; + + isEmailVerified(userId: string, email?: string): Promise; + + sendVerificationEmail?(input: { + appOrigin: string; + email: string; + token: string; + userContext?: AuthUserContext; + userId: string; + }): Promise<{ status: string; success: boolean }>; + + unverifyEmail?(userId: string, email?: string): Promise; + + verifyEmailUsingToken( + token: string, + userContext?: AuthUserContext, + ): Promise; +} + +export interface GetSessionOptions { + checkDatabase?: boolean; + sessionRequired?: boolean; + skipClaims?: RefreshableClaim[]; +} + +export type ResetPasswordResult = + | { error: "INVALID_TOKEN" | "TOKEN_EXPIRED" | string; success: false } + | { success: true }; + +export interface RolesProvider { + addRoleToUser?(userId: string, role: string): Promise; + + createNewRoleOrAddPermissions( + role: string, + permissions: string[], + ): Promise; + + deleteRole(role: string): Promise; + + getAllRoles(): Promise; + + getPermissionsForRole(role: string): Promise; + + getRolesForUser(userId: string): Promise; + + getUsersThatHaveRole(role: string): Promise; + + PermissionClaim?: { + key: string; + }; + + removePermissionsFromRole(role: string, permissions: string[]): Promise; + + rolesExist(roles: string[]): Promise; +} + +export interface SessionProvider { + createNewSession( + request: FastifyRequest, + reply: FastifyReply, + userId: string, + accessTokenPayload?: Record, + sessionData?: Record, + userContext?: AuthUserContext, + ): Promise; + + getSession( + request: FastifyRequest, + reply: FastifyReply, + options?: GetSessionOptions, + ): Promise; + + /** + * Returns a Fastify preHandler middleware that validates the session + * and attaches it to the request. Abstracts provider-specific session + * verification (e.g. SuperTokens claims validation). + */ + getVerifySession( + options?: GetSessionOptions, + ): ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; + + revokeAllSessionsForUser( + userId: string, + userContext?: AuthUserContext, + ): Promise; +} + +export interface UpdateEmailOrPasswordResult { + error?: string; + success: boolean; +} + +export type { ClaimValidationError, RefreshableClaim } from "./types"; + +let authInstance: AuthAdapter | undefined; + +export function getAuth(): AuthAdapter { + if (!authInstance) { + throw new Error("Auth adapter not initialized. Call initAuth() first."); + } + + return authInstance; +} + +export async function initAuth( + fastify: FastifyInstance, + provider: AuthProvider, +): Promise { + if (!authInstance) { + authInstance = provider.adapter; + + if (provider.init) { + await provider.init(fastify); + } + } + + return authInstance; +} + +export const auth = new Proxy({} as AuthAdapter, { + get(_target, property) { + return getAuth()[property as keyof AuthAdapter]; + }, +}); diff --git a/packages/user/src/auth/claims/profileValidation.ts b/packages/user/src/auth/claims/profileValidation.ts new file mode 100644 index 000000000..b2bd993e4 --- /dev/null +++ b/packages/user/src/auth/claims/profileValidation.ts @@ -0,0 +1,32 @@ +import type { UserUpdateInput } from "../../types"; + +export interface ProfileValidationConfig { + enabled?: boolean; + fields?: Array; + gracePeriodInDays?: number; +} + +export interface ProfileValidationResult { + gracePeriodEndsAt?: number; + isVerified: boolean; +} + +export function checkProfileValidation( + user: T, + config: ProfileValidationConfig, +): ProfileValidationResult { + const fields = config.fields ?? []; + + const isVerified = !fields.some( + (field) => + (user as Record)[field as string] === null || + (user as Record)[field as string] === undefined, + ); + + const gracePeriodEndsAt = + !isVerified && config.gracePeriodInDays + ? user.signedUpAt + config.gracePeriodInDays * 24 * 60 * 60 * 1000 + : undefined; + + return { gracePeriodEndsAt, isVerified }; +} diff --git a/packages/user/src/auth/index.ts b/packages/user/src/auth/index.ts new file mode 100644 index 000000000..4755bc073 --- /dev/null +++ b/packages/user/src/auth/index.ts @@ -0,0 +1,28 @@ +export { auth, getAuth, initAuth } from "./adapter"; +export type { + AuthAdapter, + AuthErrorsProvider, + AuthProvider, + AuthSession, + AuthUser, + AuthUserContext, + ClaimsProvider, + ClaimValidationError, + EmailPasswordProvider, + EmailVerificationProvider, + GetSessionOptions, + RefreshableClaim, + RolesProvider, + SessionProvider, +} from "./adapter"; +export { checkProfileValidation } from "./claims/profileValidation"; + +export type { + ProfileValidationConfig, + ProfileValidationResult, +} from "./claims/profileValidation"; +export { + getAuthProvider, + registerAuthProvider, + supertokensProvider, +} from "./providers"; diff --git a/packages/user/src/auth/providers/index.ts b/packages/user/src/auth/providers/index.ts new file mode 100644 index 000000000..1e965c12e --- /dev/null +++ b/packages/user/src/auth/providers/index.ts @@ -0,0 +1,27 @@ +import type { AuthProvider } from "../adapter"; + +import { supertokensProvider } from "../supertokens"; + +const providers: Record = { + supertokens: supertokensProvider, +}; + +export function getAuthProvider(name: string): AuthProvider { + const provider = providers[name]; + + if (!provider) { + throw new Error(`Unknown auth provider: ${name}`); + } + + return provider; +} + +/** Register a custom auth provider (e.g. better-auth) before the user plugin loads. */ +export function registerAuthProvider( + name: string, + provider: AuthProvider, +): void { + providers[name] = provider; +} + +export { supertokensProvider } from "../supertokens"; diff --git a/packages/user/src/auth/supertokens.ts b/packages/user/src/auth/supertokens.ts new file mode 100644 index 000000000..78c7aa107 --- /dev/null +++ b/packages/user/src/auth/supertokens.ts @@ -0,0 +1,428 @@ +import type { FastifyInstance, FastifyRequest } from "fastify"; + +import { CustomError } from "@prefabs.tech/fastify-error-handler"; +import { wrapResponse } from "supertokens-node/framework/fastify"; +import EmailVerification, { + EmailVerificationClaim, +} from "supertokens-node/recipe/emailverification"; +import Session, { Error as STError } from "supertokens-node/recipe/session"; +import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; +import UserRoles from "supertokens-node/recipe/userroles"; + +import type { + AuthErrorsProvider, + AuthProvider, + AuthResult, + AuthSession, + AuthUser, + AuthUserContext, + ClaimsProvider, + EmailPasswordProvider, + EmailVerificationProvider, + ResetPasswordResult, + RolesProvider, + SessionProvider, + UpdateEmailOrPasswordResult, +} from "./adapter"; +import type { ClaimValidationError, RefreshableClaim } from "./types"; + +import { ERROR_CODES } from "../constants"; +import supertokensPlugin from "../supertokens"; +import createUserContextImpl from "../supertokens/utils/createUserContext"; +import ProfileValidationClaim from "../supertokens/utils/profileValidationClaim"; + +const claimKeyByType: Record = { + emailVerification: EmailVerificationClaim.key, + profileValidation: ProfileValidationClaim.key, +}; + +function excludeValidatorIds( + validators: T[], + skip: RefreshableClaim[], +): T[] { + const skipKeys = new Set(skip.map((claim) => claimKeyByType[claim])); + return validators.filter((validator) => !skipKeys.has(validator.id)); +} + +const supertokensClaimsAdapter: ClaimsProvider = { + async assertProfileValid(session, request, userContext) { + const profileValidationClaim = new ProfileValidationClaim(); + const context = createUserContextImpl(userContext, request); + + try { + await ( + session as unknown as { + assertClaims?: (...arguments_: unknown[]) => Promise; + } + ).assertClaims?.( + [profileValidationClaim.validators.isVerified()], + context, + ); + + return; + } catch (error) { + if (error instanceof STError && error.type === "INVALID_CLAIMS") { + return (error.payload ?? []) as unknown as ClaimValidationError[]; + } + + throw error; + } + }, + + keys: { + emailVerification: EmailVerificationClaim.key, + profileValidation: ProfileValidationClaim.key, + }, + + async refreshSessionClaims(session, request, claims, userContext) { + const context = createUserContextImpl(userContext, request); + + for (const claim of claims) { + const stSession = session as unknown as { + fetchAndSetClaim?: (...arguments_: unknown[]) => Promise; + }; + + if (claim === "emailVerification") { + await stSession.fetchAndSetClaim?.(EmailVerificationClaim, context); + } else if (claim === "profileValidation") { + await stSession.fetchAndSetClaim?.( + new ProfileValidationClaim(), + context, + ); + } + } + }, +}; + +const supertokensErrorsAdapter: AuthErrorsProvider = { + createInvalidClaimsError(errors) { + return new STError({ + message: "invalid claim", + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: errors as any, + type: "INVALID_CLAIMS", + }); + }, + + createUnauthorizedError(message = "unauthorised") { + return new STError({ + message, + type: "UNAUTHORISED", + }); + }, + + isAuthError(error: unknown) { + return STError.isErrorFromSuperTokens(error); + }, +}; + +const createUserContext = ( + request: FastifyRequest, + existing?: AuthUserContext, +): AuthUserContext => createUserContextImpl(existing, request); + +const supertokensEmailPasswordAdapter: EmailPasswordProvider = { + async createResetPasswordToken(userId: string): Promise { + const response = + await ThirdPartyEmailPassword.createResetPasswordToken(userId); + + if (response.status === "OK") { + return response.token; + } + + throw new CustomError( + `Failed to create reset password token: ${response.status}`, + ERROR_CODES.RESET_PASSWORD_TOKEN_FAILED, + ); + }, + + async emailPasswordSignIn( + email: string, + password: string, + userContext?: AuthUserContext, + ): Promise { + const response = await ThirdPartyEmailPassword.emailPasswordSignIn( + email, + password, + userContext, + ); + + if (response.status === "OK" && response.user) { + return { + success: true, + user: response.user as AuthUser, + }; + } + + return { + error: response.status, + success: false, + }; + }, + + async emailPasswordSignUp( + email: string, + password: string, + userContext?: AuthUserContext, + ): Promise { + const response = await ThirdPartyEmailPassword.emailPasswordSignUp( + email, + password, + userContext, + ); + + if (response.status === "OK" && response.user) { + return { + success: true, + user: response.user as AuthUser, + }; + } + + return { + error: response.status, + success: false, + }; + }, + + async getUserById(userId: string): Promise { + const user = await ThirdPartyEmailPassword.getUserById(userId); + + if (!user) return undefined; + + return user as AuthUser; + }, + + async getUsersByEmail(email: string): Promise { + const users = await ThirdPartyEmailPassword.getUsersByEmail(email); + + return users.map((user) => user as AuthUser); + }, + + async resetPasswordUsingToken( + token: string, + newPassword: string, + ): Promise { + const response = await ThirdPartyEmailPassword.resetPasswordUsingToken( + token, + newPassword, + ); + + if (response.status === "OK") { + return { success: true }; + } + + return { error: response.status, success: false }; + }, + + async updateEmailOrPassword(input: { + email?: string; + password?: string; + userId: string; + }): Promise { + const response = await ThirdPartyEmailPassword.updateEmailOrPassword(input); + + if (response.status === "OK") { + return { success: true }; + } + + return { error: response.status, success: false }; + }, +}; + +const supertokensEmailVerificationAdapter: EmailVerificationProvider = { + async createEmailVerificationToken( + userId: string, + email?: string, + userContext?: AuthUserContext, + ): Promise { + const response = await EmailVerification.createEmailVerificationToken( + userId, + email, + userContext, + ); + + if (response.status === "OK") { + return response.token; + } + + throw new CustomError( + `Failed to create email verification token: ${response.status}`, + ERROR_CODES.EMAIL_VERIFICATION_TOKEN_FAILED, + ); + }, + + async isEmailVerified(userId: string, email?: string): Promise { + return EmailVerification.isEmailVerified(userId, email); + }, + + async sendVerificationEmail(input) { + await EmailVerification.sendEmail({ + emailVerifyLink: `${input.appOrigin}/auth/verify-email?token=${input.token}&rid=emailverification`, + type: "EMAIL_VERIFICATION", + user: { + email: input.email, + id: input.userId, + }, + userContext: input.userContext, + }); + + return { status: "OK", success: true }; + }, + + async unverifyEmail(userId: string, email?: string): Promise { + await EmailVerification.unverifyEmail(userId, email); + }, + + async verifyEmailUsingToken( + token: string, + userContext?: AuthUserContext, + ): Promise { + const response = await EmailVerification.verifyEmailUsingToken( + token, + userContext, + ); + + return response.status === "OK"; + }, +}; + +const supertokensRolesAdapter: RolesProvider = { + async addRoleToUser(userId: string, role: string): Promise { + const response = await UserRoles.addRoleToUser(userId, role); + + if (response.status !== "OK") { + throw new CustomError( + `Failed to add role to user: ${response.status}`, + ERROR_CODES.ADD_ROLE_FAILED, + ); + } + }, + + async createNewRoleOrAddPermissions( + role: string, + permissions: string[], + ): Promise { + const response = await UserRoles.createNewRoleOrAddPermissions( + role, + permissions, + ); + + return response.createdNewRole; + }, + + async deleteRole(role: string): Promise { + const response = await UserRoles.deleteRole(role); + + return response.didRoleExist; + }, + + async getAllRoles(): Promise { + const response = await UserRoles.getAllRoles(); + + return response.roles; + }, + + async getPermissionsForRole(role: string): Promise { + const response = await UserRoles.getPermissionsForRole(role); + + if (response.status === "OK") { + return response.permissions; + } + + return []; + }, + + async getRolesForUser(userId: string): Promise { + const response = await UserRoles.getRolesForUser(userId); + + return response.roles; + }, + + async getUsersThatHaveRole(role: string): Promise { + const response = await UserRoles.getUsersThatHaveRole(role); + + if (response.status === "OK") { + return response.users; + } + + return []; + }, + + PermissionClaim: UserRoles.PermissionClaim, + + async removePermissionsFromRole( + role: string, + permissions: string[], + ): Promise { + await UserRoles.removePermissionsFromRole(role, permissions); + }, + + async rolesExist(roles: string[]): Promise { + const allRoles = await supertokensRolesAdapter.getAllRoles(); + + return roles.every((role) => allRoles.includes(role)); + }, +}; + +const supertokensSessionAdapter: SessionProvider = { + async createNewSession( + request, + reply, + userId, + accessTokenPayload, + sessionData, + userContext, + ): Promise { + return Session.createNewSession( + request, + reply, + userId, + accessTokenPayload, + sessionData, + userContext, + ) as unknown as AuthSession; + }, + + async getSession(request, reply, options): Promise { + return supertokensSessionAdapter.getVerifySession(options)(request, reply); + }, + + getVerifySession(options) { + const skipClaims = options?.skipClaims; + + return async (request, reply) => { + const session = await Session.getSession(request, wrapResponse(reply), { + checkDatabase: options?.checkDatabase, + overrideGlobalClaimValidators: skipClaims?.length + ? async (globalValidators) => + excludeValidatorIds(globalValidators, skipClaims) + : undefined, + sessionRequired: options?.sessionRequired, + }); + + // Attach the session to the request so handlers can access it + request.session = session; + + return session as unknown as AuthSession | undefined; + }; + }, + + async revokeAllSessionsForUser(userId: string): Promise { + await Session.revokeAllSessionsForUser(userId); + }, +}; + +export const supertokensProvider: AuthProvider = { + adapter: { + claims: supertokensClaimsAdapter, + createUserContext, + emailPassword: supertokensEmailPasswordAdapter, + emailVerification: supertokensEmailVerificationAdapter, + errors: supertokensErrorsAdapter, + roles: supertokensRolesAdapter, + session: supertokensSessionAdapter, + }, + init: async (fastify: FastifyInstance) => { + await fastify.register(supertokensPlugin); + }, +}; diff --git a/packages/user/src/auth/types.ts b/packages/user/src/auth/types.ts new file mode 100644 index 000000000..8a461b35e --- /dev/null +++ b/packages/user/src/auth/types.ts @@ -0,0 +1,7 @@ +export interface ClaimValidationError { + id: string; + reason: unknown; +} + +/** Session claims that can be refreshed or excluded from route verification. */ +export type RefreshableClaim = "emailVerification" | "profileValidation"; diff --git a/packages/user/src/constants.ts b/packages/user/src/constants.ts index 8ad70f6d2..d7af7ba39 100644 --- a/packages/user/src/constants.ts +++ b/packages/user/src/constants.ts @@ -52,13 +52,17 @@ const PERMISSIONS_USERS_READ = "users:read"; const DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB = 5; const ERROR_CODES = { + ADD_ROLE_FAILED: "ADD_ROLE_FAILED_ERROR", + CHANGE_EMAIL: "CHANGE_EMAIL_ERROR", CHANGE_PASSWORD: "CHANGE_PASSWORD_ERROR", + EMAIL_VERIFICATION_TOKEN_FAILED: "EMAIL_VERIFICATION_TOKEN_FAILED_ERROR", INVALID_EMAIL: "INVALID_EMAIL_ERROR", INVALID_PASSWORD: "INVALID_PASSWORD_ERROR", INVITATION_ALREADY_EXISTS: "INVITATION_ALREADY_EXISTS_ERROR", INVITATION_NOT_FOUND: "INVITATION_NOT_FOUND_ERROR", PHOTO_FILE_MISSING: "PHOTO_FILE_MISSING_ERROR", PHOTO_FILE_TOO_LARGE: "PHOTO_FILE_TOO_LARGE_ERROR", + RESET_PASSWORD_TOKEN_FAILED: "RESET_PASSWORD_TOKEN_FAILED_ERROR", ROLE_ALREADY_EXISTS: "ROLE_ALREADY_EXISTS_ERROR", ROLE_IN_USE: "ROLE_IN_USE_ERROR", ROLE_NOT_FOUND: "ROLE_NOT_FOUND_ERROR", diff --git a/packages/user/src/index.ts b/packages/user/src/index.ts index 4b7a96248..ac31b9af0 100644 --- a/packages/user/src/index.ts +++ b/packages/user/src/index.ts @@ -1,3 +1,4 @@ +import type { AuthSession } from "./auth/adapter"; import type { User, UserConfig } from "./types"; import hasPermission from "./middlewares/hasPermission"; @@ -8,6 +9,7 @@ declare module "fastify" { } interface FastifyRequest { + session?: AuthSession; user?: User; } } @@ -25,6 +27,8 @@ declare module "@prefabs.tech/fastify-config" { } } +export * from "./auth"; + export * from "./constants"; export { default as userSchema } from "./graphql/schema"; @@ -56,6 +60,11 @@ export { } from "./model/users/sql"; export { default as UserSqlFactory } from "./model/users/sqlFactory"; export { default } from "./plugin"; +/* + * @deprecated Import supertokens internals directly from "@prefabs.tech/fastify-user/supertokens" + * if you need them. These exports will be removed in a future release. + * The auth adapter is available via `import { auth } from "@prefabs.tech/fastify-user/auth"`. + */ export { errorHandler as supertokensErrorHandler } from "./supertokens/errorHandler"; export { default as areRolesExist } from "./supertokens/utils/areRolesExist"; export { default as createUserContext } from "./supertokens/utils/createUserContext"; diff --git a/packages/user/src/lib/__test__/hasUserPermission.spec.ts b/packages/user/src/lib/__test__/hasUserPermission.spec.ts index 9c78492b4..59b89c5de 100644 --- a/packages/user/src/lib/__test__/hasUserPermission.spec.ts +++ b/packages/user/src/lib/__test__/hasUserPermission.spec.ts @@ -5,19 +5,20 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ROLE_SUPERADMIN } from "../../constants"; import hasUserPermission from "../hasUserPermission"; -// Mock supertokens UserRoles so we can control what roles/permissions come back -// without needing a running SuperTokens server. -vi.mock("supertokens-node/recipe/userroles", () => ({ - default: { - getPermissionsForRole: vi.fn(), - getRolesForUser: vi.fn(), - }, +const { mockGetPermissionsForRole, mockGetRolesForUser } = vi.hoisted(() => ({ + mockGetPermissionsForRole: vi.fn(), + mockGetRolesForUser: vi.fn(), })); -import UserRoles from "supertokens-node/recipe/userroles"; - -const mockGetRolesForUser = vi.mocked(UserRoles.getRolesForUser); -const mockGetPermissionsForRole = vi.mocked(UserRoles.getPermissionsForRole); +// Mock the auth adapter +vi.mock("../../auth/adapter", () => ({ + auth: { + roles: { + getPermissionsForRole: mockGetPermissionsForRole, + getRolesForUser: mockGetRolesForUser, + }, + }, +})); const makeFastify = (permissions?: string[]) => ({ config: { user: { permissions } } }) as unknown as FastifyInstance; @@ -64,10 +65,7 @@ describe("hasUserPermission", () => { describe("SUPERADMIN bypass", () => { it("returns true for a SUPERADMIN regardless of the requested permission", async () => { - mockGetRolesForUser.mockResolvedValue({ - roles: [ROLE_SUPERADMIN], - status: "OK", - }); + mockGetRolesForUser.mockResolvedValue([ROLE_SUPERADMIN]); const result = await hasUserPermission( makeFastify(["billing:manage"]), @@ -82,14 +80,11 @@ describe("hasUserPermission", () => { describe("permission check via role", () => { it("returns true when the user holds a role that grants the required permission", async () => { - mockGetRolesForUser.mockResolvedValue({ - roles: ["EDITOR"], - status: "OK", - }); - mockGetPermissionsForRole.mockResolvedValue({ - permissions: ["content:publish", "billing:manage"], - status: "OK", - }); + mockGetRolesForUser.mockResolvedValue(["EDITOR"]); + mockGetPermissionsForRole.mockResolvedValue([ + "content:publish", + "billing:manage", + ]); const result = await hasUserPermission( makeFastify(["billing:manage"]), @@ -101,14 +96,8 @@ describe("hasUserPermission", () => { }); it("returns false when none of the user's roles grant the required permission", async () => { - mockGetRolesForUser.mockResolvedValue({ - roles: ["VIEWER"], - status: "OK", - }); - mockGetPermissionsForRole.mockResolvedValue({ - permissions: ["content:read"], - status: "OK", - }); + mockGetRolesForUser.mockResolvedValue(["VIEWER"]); + mockGetPermissionsForRole.mockResolvedValue(["content:read"]); const result = await hasUserPermission( makeFastify(["billing:manage"]), @@ -120,15 +109,9 @@ describe("hasUserPermission", () => { }); it("de-duplicates permissions when multiple roles grant the same permission", async () => { - mockGetRolesForUser.mockResolvedValue({ - roles: ["ROLE_A", "ROLE_B"], - status: "OK", - }); + mockGetRolesForUser.mockResolvedValue(["ROLE_A", "ROLE_B"]); // Both roles grant the same permission - mockGetPermissionsForRole.mockResolvedValue({ - permissions: ["billing:manage"], - status: "OK", - }); + mockGetPermissionsForRole.mockResolvedValue(["billing:manage"]); const result = await hasUserPermission( makeFastify(["billing:manage"]), diff --git a/packages/user/src/lib/__test__/seedRoles.spec.ts b/packages/user/src/lib/__test__/seedRoles.spec.ts index 1f33426c9..34afb2f37 100644 --- a/packages/user/src/lib/__test__/seedRoles.spec.ts +++ b/packages/user/src/lib/__test__/seedRoles.spec.ts @@ -3,15 +3,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../../constants"; import seedRoles from "../seedRoles"; -vi.mock("supertokens-node/recipe/userroles", () => ({ - default: { - createNewRoleOrAddPermissions: vi.fn().mockResolvedValue({ status: "OK" }), - }, +const { mockCreateNewRoleOrAddPermissions } = vi.hoisted(() => ({ + mockCreateNewRoleOrAddPermissions: vi.fn().mockResolvedValue(true), })); -import UserRoles from "supertokens-node/recipe/userroles"; +vi.mock("../../auth/adapter", () => ({ + auth: { + roles: { + createNewRoleOrAddPermissions: mockCreateNewRoleOrAddPermissions, + }, + }, +})); -const mockCreate = vi.mocked(UserRoles.createNewRoleOrAddPermissions); +const mockCreate = mockCreateNewRoleOrAddPermissions; describe("seedRoles", () => { beforeEach(() => { diff --git a/packages/user/src/lib/hasUserPermission.ts b/packages/user/src/lib/hasUserPermission.ts index 5c507ae35..96fab8fb4 100644 --- a/packages/user/src/lib/hasUserPermission.ts +++ b/packages/user/src/lib/hasUserPermission.ts @@ -1,18 +1,14 @@ import type { FastifyInstance } from "fastify"; -import UserRoles from "supertokens-node/recipe/userroles"; - +import { auth } from "../auth/adapter"; import { ROLE_SUPERADMIN } from "../constants"; const getPermissions = async (roles: string[]) => { let permissions: string[] = []; for (const role of roles) { - const response = await UserRoles.getPermissionsForRole(role); - - if (response.status === "OK") { - permissions = [...new Set([...permissions, ...response.permissions])]; - } + const rolePermissions = await auth.roles.getPermissionsForRole(role); + permissions = [...new Set([...permissions, ...rolePermissions])]; } return permissions; @@ -30,7 +26,7 @@ const hasUserPermission = async ( return true; } - const { roles } = await UserRoles.getRolesForUser(userId); + const roles = await auth.roles.getRolesForUser(userId); // Allow if user has super admin role if (roles && roles.includes(ROLE_SUPERADMIN)) { diff --git a/packages/user/src/lib/seedRoles.ts b/packages/user/src/lib/seedRoles.ts index ab53de021..c82b93ed1 100644 --- a/packages/user/src/lib/seedRoles.ts +++ b/packages/user/src/lib/seedRoles.ts @@ -1,5 +1,4 @@ -import UserRoles from "supertokens-node/recipe/userroles"; - +import { auth } from "../auth/adapter"; import { ROLE_ADMIN, ROLE_SUPERADMIN, ROLE_USER } from "../constants"; import { UserConfig } from "../types"; @@ -12,7 +11,7 @@ const seedRoles = async (userConfig?: Partial) => { ]; for (const role of roles) { - await UserRoles.createNewRoleOrAddPermissions(role, []); + await auth.roles.createNewRoleOrAddPermissions(role, []); } }; diff --git a/packages/user/src/lib/verifyEmail.ts b/packages/user/src/lib/verifyEmail.ts index 375731556..9578fb60d 100644 --- a/packages/user/src/lib/verifyEmail.ts +++ b/packages/user/src/lib/verifyEmail.ts @@ -1,4 +1,6 @@ -import EmailVerification from "supertokens-node/recipe/emailverification"; +import type { AuthUserContext } from "../auth/adapter"; + +import { auth } from "../auth/adapter"; /** * Auto verify user email. @@ -6,21 +8,21 @@ import EmailVerification from "supertokens-node/recipe/emailverification"; const verifyEmail = async ( userId: string, email?: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - userContext?: any, + userContext?: AuthUserContext, ) => { - const tokenResponse = await EmailVerification.createEmailVerificationToken( + if (!auth.emailVerification) { + throw new Error( + "Email verification is not supported by the current auth provider", + ); + } + + const token = await auth.emailVerification.createEmailVerificationToken( userId, email, userContext, ); - if (tokenResponse.status === "OK") { - await EmailVerification.verifyEmailUsingToken( - tokenResponse.token, - userContext, - ); - } + await auth.emailVerification.verifyEmailUsingToken(token, userContext); }; export default verifyEmail; diff --git a/packages/user/src/mercurius-auth/authPlugin.ts b/packages/user/src/mercurius-auth/authPlugin.ts index 5b91a5662..05f53e010 100644 --- a/packages/user/src/mercurius-auth/authPlugin.ts +++ b/packages/user/src/mercurius-auth/authPlugin.ts @@ -1,13 +1,12 @@ -import type { FastifyInstance } from "fastify"; +import type { FastifyInstance, FastifyRequest } from "fastify"; import FastifyPlugin from "fastify-plugin"; import { mercurius } from "mercurius"; import mercuriusAuth from "mercurius-auth"; -import emailVerificationRecipe from "supertokens-node/recipe/emailverification"; -import { Error } from "supertokens-node/recipe/session"; -import createUserContext from "../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../supertokens/utils/profileValidationClaim"; +import type { AuthSession } from "../auth/adapter"; + +import { auth } from "../auth/adapter"; const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { await fastify.register(mercuriusAuth, { @@ -20,7 +19,10 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { return new mercurius.ErrorWithProps("user is disabled", {}, 401); } - if (fastify.config.user.features?.signUp?.emailVerification) { + if ( + fastify.config.user.features?.signUp?.emailVerification && + auth.emailVerification + ) { const emailVerification = authDirectiveAST.arguments.find( (argument: { name: { value: string } }) => argument?.name?.value === "emailVerification", @@ -28,16 +30,15 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { if ( emailVerification?.value?.value !== false && - !(await emailVerificationRecipe.isEmailVerified(context.user.id)) + !(await auth.emailVerification.isEmailVerified(context.user.id)) ) { - // Added the claim validation errors to match with rest endpoint - // response for email verification return new mercurius.ErrorWithProps( "invalid claim", { claimValidationErrors: [ { - id: "st-ev", + id: + auth.claims?.keys.emailVerification ?? "emailVerification", reason: { actualValue: false, expectedValue: true, @@ -51,7 +52,10 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { } } - if (fastify.config.user.features?.profileValidation?.enabled) { + if ( + fastify.config.user.features?.profileValidation?.enabled && + auth.claims + ) { const profileValidation = authDirectiveAST.arguments.find( (argument: { name: { value: string } }) => argument?.name?.value === "profileValidation", @@ -59,30 +63,41 @@ const plugin = FastifyPlugin(async (fastify: FastifyInstance) => { if (profileValidation?.value?.value != false) { const request = context.reply.request; + const session = (request as FastifyRequest & { session: AuthSession }) + .session; - const profileValidationClaim = new ProfileValidationClaim(); - - const userContext = createUserContext( - undefined, - context.reply.request, - ); + const userContext = auth.createUserContext(request); - await request.session?.fetchAndSetClaim( - profileValidationClaim, + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], userContext, ); try { - await request.session?.assertClaims( - [profileValidationClaim.validators.isVerified()], + const errors = await auth.claims.assertProfileValid( + session, + request, userContext, ); + + if (errors && errors.length > 0) { + return new mercurius.ErrorWithProps( + "invalid claim", + { + claimValidationErrors: errors, + }, + 403, + ); + } } catch (error) { - if (error instanceof Error) { + if (auth.errors.isAuthError(error)) { return new mercurius.ErrorWithProps( "invalid claim", { - claimValidationErrors: error.payload, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + claimValidationErrors: (error as any).payload, }, 403, ); diff --git a/packages/user/src/middlewares/__test__/hasPermission.spec.ts b/packages/user/src/middlewares/__test__/hasPermission.spec.ts index a6494d198..0fbca374f 100644 --- a/packages/user/src/middlewares/__test__/hasPermission.spec.ts +++ b/packages/user/src/middlewares/__test__/hasPermission.spec.ts @@ -1,12 +1,24 @@ -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -import Fastify, { type FastifyInstance } from "fastify"; -import { Error as STError } from "supertokens-node/recipe/session"; +import Fastify, { type FastifyInstance, type FastifyRequest } from "fastify"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import hasPermission from "../hasPermission"; -const { mockHasUserPermission } = vi.hoisted(() => ({ +const { + mockCreateInvalidClaimsError, + mockCreateUnauthorizedError, + mockHasUserPermission, +} = vi.hoisted(() => ({ + mockCreateInvalidClaimsError: vi.fn((errors: unknown[]) => { + const err = new Error("Not have enough permission"); + (err as Record).type = "INVALID_CLAIMS"; + (err as Record).payload = errors; + return err; + }), + mockCreateUnauthorizedError: vi.fn((message?: string) => { + const err = new Error(message); + (err as Record).type = "UNAUTHORISED"; + return err; + }), mockHasUserPermission: vi.fn(), })); @@ -14,14 +26,28 @@ vi.mock("../../lib/hasUserPermission", () => ({ default: mockHasUserPermission, })); +vi.mock("../../auth/adapter", () => ({ + auth: { + errors: { + createInvalidClaimsError: mockCreateInvalidClaimsError, + createUnauthorizedError: mockCreateUnauthorizedError, + }, + roles: { + PermissionClaim: { + key: "st-role.permissions", + }, + }, + }, +})); + const buildRequest = ( fastify: FastifyInstance, user?: { id: string }, -): SessionRequest => { +): FastifyRequest => { return { server: fastify, user, - } as unknown as SessionRequest; + } as FastifyRequest; }; describe("hasPermission middleware", () => { @@ -81,12 +107,15 @@ describe("hasPermission middleware", () => { expect(mockHasUserPermission.mock.calls[0]?.[2]).toBe("users:read"); }); - it("throws SuperTokens errors for unauthorized outcomes", async () => { + it("throws auth errors for unauthorized outcomes", async () => { mockHasUserPermission.mockResolvedValue(false); const preHandler = hasPermission("roles:update"); await expect( preHandler(buildRequest(fastify, { id: "user-7" })), - ).rejects.toBeInstanceOf(STError); + ).rejects.toMatchObject({ + message: "Not have enough permission", + type: "INVALID_CLAIMS", + }); }); }); diff --git a/packages/user/src/middlewares/hasPermission.ts b/packages/user/src/middlewares/hasPermission.ts index d7510b7f2..7be31e823 100644 --- a/packages/user/src/middlewares/hasPermission.ts +++ b/packages/user/src/middlewares/hasPermission.ts @@ -1,37 +1,27 @@ -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -import { Error as STError } from "supertokens-node/recipe/session"; -import UserRoles from "supertokens-node/recipe/userroles"; +import type { FastifyRequest } from "fastify"; +import { auth } from "../auth/adapter"; import hasUserPermission from "../lib/hasUserPermission"; const hasPermission = (permission: string) => - async (request: SessionRequest): Promise => { + async (request: FastifyRequest): Promise => { const user = request.user; if (!user) { - throw new STError({ - message: "unauthorised", - type: "UNAUTHORISED", - }); + throw auth.errors.createUnauthorizedError("unauthorised"); } if (!(await hasUserPermission(request.server, user.id, permission))) { - // this error tells SuperTokens to return a 403 http response. - throw new STError({ - message: "Not have enough permission", - payload: [ - { - id: UserRoles.PermissionClaim.key, - reason: { - expectedToInclude: permission, - message: "Not have enough permission", - }, + throw auth.errors.createInvalidClaimsError([ + { + id: auth.roles.PermissionClaim?.key || "st-role.permissions", + reason: { + expectedToInclude: permission, + message: "Not have enough permission", }, - ], - type: "INVALID_CLAIMS", - }); + }, + ]); } }; diff --git a/packages/user/src/model/invitations/graphql/resolver.ts b/packages/user/src/model/invitations/graphql/resolver.ts index 876bd49ed..5ff279ac8 100644 --- a/packages/user/src/model/invitations/graphql/resolver.ts +++ b/packages/user/src/model/invitations/graphql/resolver.ts @@ -3,8 +3,6 @@ import type { MercuriusContext } from "mercurius"; import { formatDate } from "@prefabs.tech/fastify-slonik"; import { mercurius } from "mercurius"; -import { createNewSession } from "supertokens-node/recipe/session"; -import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { User } from "../../../types"; import type { @@ -12,6 +10,7 @@ import type { InvitationCreateInput, } from "../../../types/invitation"; +import { auth } from "../../../auth/adapter"; import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import sendInvitation from "../../../lib/sendInvitation"; @@ -82,13 +81,17 @@ const Mutation = { } // signup - const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [invitation.role], - }); + const signUpResponse = await auth.emailPassword.emailPasswordSignUp( + email, + password, + { + autoVerifyEmail: true, + roles: [invitation.role], + }, + ); - if (signUpResponse.status !== "OK") { - return signUpResponse; + if (!signUpResponse.success) { + return { status: signUpResponse.error, user: undefined }; } // update invitation's acceptedAt value with current time @@ -108,10 +111,14 @@ const Mutation = { } // create new session so the user be logged in on signup - await createNewSession(reply.request, reply, signUpResponse.user.id); + await auth.session.createNewSession( + reply.request, + reply, + signUpResponse.user.id, + ); return { - ...signUpResponse, + status: "OK", user: { ...signUpResponse.user, roles: [invitation.role], diff --git a/packages/user/src/model/invitations/handlers/acceptInvitation.ts b/packages/user/src/model/invitations/handlers/acceptInvitation.ts index eb0fdfa4a..97962edf5 100644 --- a/packages/user/src/model/invitations/handlers/acceptInvitation.ts +++ b/packages/user/src/model/invitations/handlers/acceptInvitation.ts @@ -1,11 +1,10 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import { formatDate } from "@prefabs.tech/fastify-slonik"; -import { createNewSession } from "supertokens-node/recipe/session"; -import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; import type { User } from "../../../types"; +import { auth } from "../../../auth/adapter"; import getInvitationService from "../../../lib/getInvitationService"; import isInvitationValid from "../../../lib/isInvitationValid"; import validateEmail from "../../../validator/email"; @@ -66,14 +65,18 @@ const acceptInvitation = async ( } // signup - const signUpResponse = await emailPasswordSignUp(email, password, { - autoVerifyEmail: true, - roles: [invitation.role], - }); + const signUpResponse = await auth.emailPassword.emailPasswordSignUp( + email, + password, + { + autoVerifyEmail: true, + roles: [invitation.role], + }, + ); - if (signUpResponse.status !== "OK") { + if (!signUpResponse.success) { throw request.server.httpErrors.unprocessableEntity( - "EMAIL_ALREADY_EXISTS_ERROR", + signUpResponse.error || "EMAIL_ALREADY_EXISTS_ERROR", ); } @@ -94,10 +97,10 @@ const acceptInvitation = async ( } // create new session so the user be logged in on signup - await createNewSession(request, reply, signUpResponse.user.id); + await auth.session.createNewSession(request, reply, signUpResponse.user.id); reply.send({ - ...signUpResponse, + status: "OK", user: { ...signUpResponse.user, roles: [invitation.role], diff --git a/packages/user/src/model/invitations/handlers/createInvitation.ts b/packages/user/src/model/invitations/handlers/createInvitation.ts index 08e31b378..d0f5fa20a 100644 --- a/packages/user/src/model/invitations/handlers/createInvitation.ts +++ b/packages/user/src/model/invitations/handlers/createInvitation.ts @@ -1,5 +1,4 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { Invitation, @@ -10,7 +9,7 @@ import getInvitationService from "../../../lib/getInvitationService"; import sendInvitation from "../../../lib/sendInvitation"; const createInvitation = async ( - request: SessionRequest, + request: FastifyRequest, reply: FastifyReply, ) => { const { diff --git a/packages/user/src/model/invitations/handlers/deleteInvitation.ts b/packages/user/src/model/invitations/handlers/deleteInvitation.ts index c90133c1f..12778a38b 100644 --- a/packages/user/src/model/invitations/handlers/deleteInvitation.ts +++ b/packages/user/src/model/invitations/handlers/deleteInvitation.ts @@ -1,12 +1,11 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { Invitation } from "../../../types/invitation"; import Service from "../service"; const deleteInvitation = async ( - request: SessionRequest, + request: FastifyRequest, reply: FastifyReply, ) => { const { config, dbSchema, params, slonik } = request; diff --git a/packages/user/src/model/invitations/handlers/listInvitation.ts b/packages/user/src/model/invitations/handlers/listInvitation.ts index 45ede5370..8518b4c61 100644 --- a/packages/user/src/model/invitations/handlers/listInvitation.ts +++ b/packages/user/src/model/invitations/handlers/listInvitation.ts @@ -1,12 +1,11 @@ import type { PaginatedList } from "@prefabs.tech/fastify-slonik"; -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { Invitation } from "../../../types/invitation"; import getInvitationService from "../../../lib/getInvitationService"; -const listInvitation = async (request: SessionRequest, reply: FastifyReply) => { +const listInvitation = async (request: FastifyRequest, reply: FastifyReply) => { const { config, dbSchema, query, slonik } = request; const { filters, limit, offset, sort } = query as { diff --git a/packages/user/src/model/invitations/handlers/resendInvitation.ts b/packages/user/src/model/invitations/handlers/resendInvitation.ts index 09c1c1fb7..395b38e4a 100644 --- a/packages/user/src/model/invitations/handlers/resendInvitation.ts +++ b/packages/user/src/model/invitations/handlers/resendInvitation.ts @@ -1,5 +1,4 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { Invitation } from "../../../types/invitation"; @@ -8,7 +7,7 @@ import isInvitationValid from "../../../lib/isInvitationValid"; import sendInvitation from "../../../lib/sendInvitation"; const resendInvitation = async ( - request: SessionRequest, + request: FastifyRequest, reply: FastifyReply, ) => { const { config, dbSchema, headers, hostname, log, params, server, slonik } = diff --git a/packages/user/src/model/invitations/handlers/revokeInvitation.ts b/packages/user/src/model/invitations/handlers/revokeInvitation.ts index b39fccf3f..f28ad52db 100644 --- a/packages/user/src/model/invitations/handlers/revokeInvitation.ts +++ b/packages/user/src/model/invitations/handlers/revokeInvitation.ts @@ -1,5 +1,4 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import { formatDate } from "@prefabs.tech/fastify-slonik"; @@ -8,7 +7,7 @@ import type { Invitation } from "../../../types/invitation"; import getInvitationService from "../../../lib/getInvitationService"; const revokeInvitation = async ( - request: SessionRequest, + request: FastifyRequest, reply: FastifyReply, ) => { const { config, dbSchema, params, server, slonik } = request; diff --git a/packages/user/src/model/invitations/service.ts b/packages/user/src/model/invitations/service.ts index 8f07ee624..d5bb8208f 100644 --- a/packages/user/src/model/invitations/service.ts +++ b/packages/user/src/model/invitations/service.ts @@ -9,10 +9,10 @@ import type { InvitationUpdateInput, } from "../../types"; +import { auth } from "../../auth/adapter"; import { ERROR_CODES } from "../../constants"; import computeInvitationExpiresAt from "../../lib/computeInvitationExpiresAt"; import getUserService from "../../lib/getUserService"; -import areRolesExist from "../../supertokens/utils/areRolesExist"; import validateEmail from "../../validator/email"; import InvitationSqlFactory from "./sqlFactory"; @@ -78,7 +78,7 @@ class InvitationService extends BaseService< ); } - if (!(await areRolesExist([role]))) { + if (!(await auth.roles.rolesExist([role]))) { throw new CustomError( `Role "${role}" does not exist`, ERROR_CODES.ROLE_NOT_FOUND, diff --git a/packages/user/src/model/permissions/handlers/getPermissions.ts b/packages/user/src/model/permissions/handlers/getPermissions.ts index b52388db4..1f127a571 100644 --- a/packages/user/src/model/permissions/handlers/getPermissions.ts +++ b/packages/user/src/model/permissions/handlers/getPermissions.ts @@ -1,7 +1,6 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; -const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { +const getPermissions = async (request: FastifyRequest, reply: FastifyReply) => { const { config } = request; const permissions: string[] = config.user.permissions || []; diff --git a/packages/user/src/model/roles/handlers/createRole.ts b/packages/user/src/model/roles/handlers/createRole.ts index 22cda0fbe..c40b25bf2 100644 --- a/packages/user/src/model/roles/handlers/createRole.ts +++ b/packages/user/src/model/roles/handlers/createRole.ts @@ -1,11 +1,10 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; -const createRole = async (request: SessionRequest, reply: FastifyReply) => { +const createRole = async (request: FastifyRequest, reply: FastifyReply) => { const { body } = request; const { permissions, role } = body as { diff --git a/packages/user/src/model/roles/handlers/deleteRole.ts b/packages/user/src/model/roles/handlers/deleteRole.ts index dddd384e7..b7aa49ae0 100644 --- a/packages/user/src/model/roles/handlers/deleteRole.ts +++ b/packages/user/src/model/roles/handlers/deleteRole.ts @@ -1,12 +1,11 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { ERROR_CODES } from "../../../constants"; import RoleService from "../service"; -const deleteRole = async (request: SessionRequest, reply: FastifyReply) => { +const deleteRole = async (request: FastifyRequest, reply: FastifyReply) => { const { query } = request; try { diff --git a/packages/user/src/model/roles/handlers/getPermissions.ts b/packages/user/src/model/roles/handlers/getPermissions.ts index 2fe21f203..880ca210f 100644 --- a/packages/user/src/model/roles/handlers/getPermissions.ts +++ b/packages/user/src/model/roles/handlers/getPermissions.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import RoleService from "../service"; -const getPermissions = async (request: SessionRequest, reply: FastifyReply) => { +const getPermissions = async (request: FastifyRequest, reply: FastifyReply) => { const { query } = request; let permissions: string[] = []; diff --git a/packages/user/src/model/roles/handlers/getRoles.ts b/packages/user/src/model/roles/handlers/getRoles.ts index 40abe3db3..888f190e0 100644 --- a/packages/user/src/model/roles/handlers/getRoles.ts +++ b/packages/user/src/model/roles/handlers/getRoles.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import RoleService from "../service"; -const getRoles = async (request: SessionRequest, reply: FastifyReply) => { +const getRoles = async (request: FastifyRequest, reply: FastifyReply) => { const service = new RoleService(); const roles = await service.getRoles(); diff --git a/packages/user/src/model/roles/handlers/updatePermissions.ts b/packages/user/src/model/roles/handlers/updatePermissions.ts index 8dc30eae7..cd3a2e78a 100644 --- a/packages/user/src/model/roles/handlers/updatePermissions.ts +++ b/packages/user/src/model/roles/handlers/updatePermissions.ts @@ -1,12 +1,11 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; import RoleService from "../service"; const updatePermissions = async ( - request: SessionRequest, + request: FastifyRequest, reply: FastifyReply, ) => { const { body } = request; diff --git a/packages/user/src/model/roles/service.ts b/packages/user/src/model/roles/service.ts index ae1171ba3..bab19ad5d 100644 --- a/packages/user/src/model/roles/service.ts +++ b/packages/user/src/model/roles/service.ts @@ -1,14 +1,14 @@ import { CustomError } from "@prefabs.tech/fastify-error-handler"; -import UserRoles from "supertokens-node/recipe/userroles"; +import { auth } from "../../auth/adapter"; import { ERROR_CODES } from "../../constants"; class RoleService { async createRole( role: string, permissions?: string[], - ): Promise<{ status: "OK" }> { - const { roles } = await UserRoles.getAllRoles(role); + ): Promise<{ status: string }> { + const roles = await auth.roles.getAllRoles(); if (roles.includes(role)) { throw new CustomError( @@ -17,63 +17,54 @@ class RoleService { ); } - const createRoleResponse = await UserRoles.createNewRoleOrAddPermissions( + const createdNewRole = await auth.roles.createNewRoleOrAddPermissions( role, permissions || [], ); - return { status: createRoleResponse.status }; + return { status: createdNewRole ? "OK" : "ROLE_ALREADY_EXISTS" }; } - async deleteRole(role: string): Promise<{ status: "OK" }> { - const response = await UserRoles.getUsersThatHaveRole(role); + async deleteRole(role: string): Promise<{ status: string }> { + const users = await auth.roles.getUsersThatHaveRole(role); - if (response.status === "UNKNOWN_ROLE_ERROR") { - throw new CustomError("Invalid role", ERROR_CODES.UNKNOWN_ROLE_ERROR); + if (users.length === 0) { + const allRoles = await auth.roles.getAllRoles(); + if (!allRoles.includes(role)) { + throw new CustomError("Invalid role", ERROR_CODES.UNKNOWN_ROLE_ERROR); + } } - if (response.users.length > 0) { + if (users.length > 0) { throw new CustomError( "The role is currently assigned to one or more users and cannot be deleted", ERROR_CODES.ROLE_IN_USE, ); } - const deleteRoleResponse = await UserRoles.deleteRole(role); + const didRoleExist = await auth.roles.deleteRole(role); - return { status: deleteRoleResponse.status }; + return { status: didRoleExist ? "OK" : "UNKNOWN_ROLE_ERROR" }; } async getPermissionsForRole(role: string): Promise { - let permissions: string[] = []; - - const response = await UserRoles.getPermissionsForRole(role); - - if (response.status === "OK") { - permissions = response.permissions; - } - - return permissions; + return auth.roles.getPermissionsForRole(role); } async getRoles(): Promise<{ permissions: string[]; role: string }[]> { - let roles: { permissions: string[]; role: string }[] = []; - - const response = await UserRoles.getAllRoles(); - - if (response.status === "OK") { - // [DU 2024-MAR-20] This is N+1 problem - roles = await Promise.all( - response.roles.map(async (role) => { - const response = await UserRoles.getPermissionsForRole(role); - - return { - permissions: response.status === "OK" ? response.permissions : [], - role, - }; - }), - ); - } + const roleNames = await auth.roles.getAllRoles(); + + // [DU 2024-MAR-20] This is N+1 problem + const roles = await Promise.all( + roleNames.map(async (role: string) => { + const permissions = await auth.roles.getPermissionsForRole(role); + + return { + permissions, + role, + }; + }), + ); return roles; } @@ -82,24 +73,25 @@ class RoleService { role: string, permissions: string[], ): Promise<{ permissions: string[]; status: "OK" }> { - const response = await UserRoles.getPermissionsForRole(role); + const rolePermissions = await auth.roles.getPermissionsForRole(role); - if (response.status === "UNKNOWN_ROLE_ERROR") { - throw new CustomError("Invalid role", ERROR_CODES.UNKNOWN_ROLE_ERROR); + if (rolePermissions.length === 0) { + const allRoles = await auth.roles.getAllRoles(); + if (!allRoles.includes(role)) { + throw new CustomError("Invalid role", ERROR_CODES.UNKNOWN_ROLE_ERROR); + } } - const rolePermissions = response.permissions; - const newPermissions = permissions.filter( - (permission) => !rolePermissions.includes(permission), + (permission: string) => !rolePermissions.includes(permission), ); const removedPermissions = rolePermissions.filter( - (permission) => !permissions.includes(permission), + (permission: string) => !permissions.includes(permission), ); - await UserRoles.removePermissionsFromRole(role, removedPermissions); - await UserRoles.createNewRoleOrAddPermissions(role, newPermissions); + await auth.roles.removePermissionsFromRole(role, removedPermissions); + await auth.roles.createNewRoleOrAddPermissions(role, newPermissions); const permissionsResponse = await this.getPermissionsForRole(role); diff --git a/packages/user/src/model/users/controller.ts b/packages/user/src/model/users/controller.ts index 48f81e186..15efd96f6 100644 --- a/packages/user/src/model/users/controller.ts +++ b/packages/user/src/model/users/controller.ts @@ -1,7 +1,6 @@ import type { FastifyInstance } from "fastify"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; - +import { auth } from "../../auth/adapter"; import { PERMISSIONS_USERS_DISABLE, PERMISSIONS_USERS_ENABLE, @@ -17,7 +16,6 @@ import { ROUTE_USERS_ENABLE, ROUTE_USERS_FIND_BY_ID, } from "../../constants"; -import ProfileValidationClaim from "../../supertokens/utils/profileValidationClaim"; import handlers from "./handlers"; import { adminSignUpSchema, @@ -74,15 +72,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.post( ROUTE_CHANGE_EMAIL, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![ - EmailVerificationClaim.key, - ProfileValidationClaim.key, - ].includes(sessionClaimValidator.id), - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["emailVerification", "profileValidation"], }), schema: changeEmailSchema, }, @@ -92,15 +83,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.get( ROUTE_ME, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![ - EmailVerificationClaim.key, - ProfileValidationClaim.key, - ].includes(sessionClaimValidator.id), - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["emailVerification", "profileValidation"], }), schema: getMeSchema, }, @@ -110,15 +94,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.put( ROUTE_ME, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![ - EmailVerificationClaim.key, - ProfileValidationClaim.key, - ].includes(sessionClaimValidator.id), - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["emailVerification", "profileValidation"], }), schema: updateMeSchema, }, @@ -128,12 +105,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.delete( ROUTE_ME, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - sessionClaimValidator.id !== ProfileValidationClaim.key, - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["profileValidation"], }), schema: deleteMeSchema, }, @@ -143,15 +116,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.put( ROUTE_ME_PHOTO, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![ - EmailVerificationClaim.key, - ProfileValidationClaim.key, - ].includes(sessionClaimValidator.id), - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["emailVerification", "profileValidation"], }), schema: uploadPhotoSchema, }, @@ -161,15 +127,8 @@ const plugin = async (fastify: FastifyInstance) => { fastify.delete( ROUTE_ME_PHOTO, { - preHandler: fastify.verifySession({ - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![ - EmailVerificationClaim.key, - ProfileValidationClaim.key, - ].includes(sessionClaimValidator.id), - ), + preHandler: auth.session.getVerifySession({ + skipClaims: ["emailVerification", "profileValidation"], }), schema: removePhotoSchema, }, diff --git a/packages/user/src/model/users/graphql/resolver.ts b/packages/user/src/model/users/graphql/resolver.ts index 452d19ff8..4ef4af8e1 100644 --- a/packages/user/src/model/users/graphql/resolver.ts +++ b/packages/user/src/model/users/graphql/resolver.ts @@ -1,26 +1,17 @@ import type { FilterInput, SortInput } from "@prefabs.tech/fastify-slonik"; +import type { FastifyRequest } from "fastify"; import type { MercuriusContext } from "mercurius"; import { GraphQLUpload, Multipart } from "@prefabs.tech/fastify-s3"; import { mercurius } from "mercurius"; -import EmailVerification, { - EmailVerificationClaim, - isEmailVerified, -} from "supertokens-node/recipe/emailverification"; -import { createNewSession } from "supertokens-node/recipe/session"; -import { - emailPasswordSignUp, - getUsersByEmail, -} from "supertokens-node/recipe/thirdpartyemailpassword"; -import UserRoles from "supertokens-node/recipe/userroles"; +import type { AuthSession } from "../../../auth/adapter"; import type { UserUpdateInput } from "../../../types"; +import { auth } from "../../../auth/adapter"; import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import CustomApiError from "../../../customApiError"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; import filterUserUpdateInput from "../filterUserUpdateInput"; @@ -42,21 +33,22 @@ const Mutation = { const { email, password } = arguments_.data; // check if already admin user exists - const adminUsers = await UserRoles.getUsersThatHaveRole(ROLE_ADMIN); + const adminUsers = await auth.roles.getUsersThatHaveRole(ROLE_ADMIN); const superAdminUsers = - await UserRoles.getUsersThatHaveRole(ROLE_SUPERADMIN); + await auth.roles.getUsersThatHaveRole(ROLE_SUPERADMIN); let errorMessage: string | undefined; - if ( - adminUsers.status === "UNKNOWN_ROLE_ERROR" && - superAdminUsers.status === "UNKNOWN_ROLE_ERROR" - ) { - errorMessage = adminUsers.status; - } else if ( - (adminUsers.status === "OK" && adminUsers.users.length > 0) || - (superAdminUsers.status === "OK" && superAdminUsers.users.length > 0) - ) { + if (adminUsers.length === 0 && superAdminUsers.length === 0) { + const allRoles = await auth.roles.getAllRoles(); + + if ( + !allRoles.includes(ROLE_ADMIN) && + !allRoles.includes(ROLE_SUPERADMIN) + ) { + errorMessage = "UNKNOWN_ROLE_ERROR"; + } + } else if (adminUsers.length > 0 || superAdminUsers.length > 0) { errorMessage = "First admin user already exists"; } @@ -89,31 +81,34 @@ const Mutation = { } // signup - const signUpResponse = await emailPasswordSignUp(email, password, { - _default: { - request: { - request: reply.request, - }, + const signUpResponse = await auth.emailPassword.emailPasswordSignUp( + email, + password, + { + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.length > 0 ? [ROLE_SUPERADMIN] : []), + ], }, - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], - }); + ); - if (signUpResponse.status !== "OK") { + if (!signUpResponse.success) { const mercuriusError = new mercurius.ErrorWithProps( - signUpResponse.status, + signUpResponse.error || "UNKNOWN_ERROR", ); return mercuriusError; } // create new session so the user be logged in on signup - await createNewSession(reply.request, reply, signUpResponse.user.id); + await auth.session.createNewSession( + reply.request, + reply, + signUpResponse.user.id, + ); - return signUpResponse; + return { status: "OK", user: signUpResponse.user }; } catch (error) { // FIXME [OP 28 SEP 2022] app.log.error(error); @@ -143,18 +138,25 @@ const Mutation = { } const request = reply.request; - - if (config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + const userContext = auth.createUserContext(request); + + if (config.user.features?.profileValidation?.enabled && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if (config.user.features?.signUp?.emailVerification && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } @@ -168,41 +170,42 @@ const Mutation = { return new mercurius.ErrorWithProps("EMAIL_SAME_AS_CURRENT_ERROR"); } - if (config.user.features?.signUp?.emailVerification) { - const isVerified = await isEmailVerified(user.id, arguments_.email); + if ( + config.user.features?.signUp?.emailVerification && + auth.emailVerification + ) { + const isVerified = await auth.emailVerification.isEmailVerified( + user.id, + arguments_.email, + ); if (!isVerified) { - const users = await getUsersByEmail(arguments_.email); + const users = + (await auth.emailPassword.getUsersByEmail?.(arguments_.email)) || + []; const emailPasswordRecipeUsers = users.filter( - (user) => !user.thirdParty, + (user) => !(user as Record).thirdParty, ); if (emailPasswordRecipeUsers.length > 0) { return new mercurius.ErrorWithProps("EMAIL_ALREADY_EXISTS_ERROR"); } - const tokenResponse = - await EmailVerification.createEmailVerificationToken( + const token = + await auth.emailVerification.createEmailVerificationToken( user.id, arguments_.email, + userContext, ); - if (tokenResponse.status === "OK") { - await EmailVerification.sendEmail({ - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, - type: "EMAIL_VERIFICATION", - user: { - email: arguments_.email, - id: user.id, - }, - userContext: { - _default: { - request: { - request: request, - }, - }, - }, + if (token) { + await auth.emailVerification.sendVerificationEmail?.({ + appOrigin: config.appOrigin[0] as string, + email: arguments_.email, + token, + userContext, + userId: user.id, }); return { @@ -211,7 +214,9 @@ const Mutation = { }; } - return new mercurius.ErrorWithProps(tokenResponse.status); + return new mercurius.ErrorWithProps( + "EMAIL_VERIFICATION_TOKEN_FAILED", + ); } } @@ -267,7 +272,7 @@ const Mutation = { ); if (response.status === "OK") { - await createNewSession(reply.request, reply, user.id); + await auth.session.createNewSession(reply.request, reply, user.id); } return response; @@ -405,17 +410,31 @@ const Mutation = { request.user = updatedUser; - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const userContext = auth.createUserContext(request); + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + + if ( + request.config.user.features?.profileValidation?.enabled && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if ( + request.config.user.features?.signUp?.emailVerification && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } @@ -453,13 +472,17 @@ const Mutation = { const updatedUser = await service.update(user.id, data); const request = reply.request; + const session = (request as FastifyRequest & { session: AuthSession }) + .session; request.user = updatedUser; - if (config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + if (config.user.features?.profileValidation?.enabled && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + auth.createUserContext(request), ); } @@ -525,17 +548,31 @@ const Mutation = { request.user = updatedUser; - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const userContext = auth.createUserContext(request); + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + + if ( + request.config.user.features?.profileValidation?.enabled && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if ( + request.config.user.features?.signUp?.emailVerification && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } @@ -571,21 +608,26 @@ const Query = { try { // check if already admin user exists - const adminUsers = await UserRoles.getUsersThatHaveRole(ROLE_ADMIN); + const adminUsers = await auth.roles.getUsersThatHaveRole(ROLE_ADMIN); const superAdminUsers = - await UserRoles.getUsersThatHaveRole(ROLE_SUPERADMIN); + await auth.roles.getUsersThatHaveRole(ROLE_SUPERADMIN); - if ( - adminUsers.status === "UNKNOWN_ROLE_ERROR" && - superAdminUsers.status === "UNKNOWN_ROLE_ERROR" - ) { - const mercuriusError = new mercurius.ErrorWithProps(adminUsers.status); + if (adminUsers.length === 0 && superAdminUsers.length === 0) { + const allRoles = await auth.roles.getAllRoles(); - return mercuriusError; - } else if ( - (adminUsers.status === "OK" && adminUsers.users.length > 0) || - (superAdminUsers.status === "OK" && superAdminUsers.users.length > 0) - ) { + if ( + !allRoles.includes(ROLE_ADMIN) && + !allRoles.includes(ROLE_SUPERADMIN) + ) { + const mercuriusError = new mercurius.ErrorWithProps( + "UNKNOWN_ROLE_ERROR", + ); + + return mercuriusError; + } + } + + if (adminUsers.length > 0 || superAdminUsers.length > 0) { return { signUp: false }; } @@ -636,12 +678,19 @@ const Query = { const user = await service.findById(arguments_.id); - if (context.config.user.features?.profileValidation?.enabled) { + if ( + context.config.user.features?.profileValidation?.enabled && + auth.claims + ) { const request = context.reply.request; - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + auth.createUserContext(request), ); } diff --git a/packages/user/src/model/users/handlers/adminSignUp.ts b/packages/user/src/model/users/handlers/adminSignUp.ts index 93f568507..e74684d44 100644 --- a/packages/user/src/model/users/handlers/adminSignUp.ts +++ b/packages/user/src/model/users/handlers/adminSignUp.ts @@ -1,9 +1,6 @@ import type { FastifyReply, FastifyRequest } from "fastify"; -import { createNewSession } from "supertokens-node/recipe/session"; -import { emailPasswordSignUp } from "supertokens-node/recipe/thirdpartyemailpassword"; -import UserRoles from "supertokens-node/recipe/userroles"; - +import { auth } from "../../../auth/adapter"; import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; import validateEmail from "../../../validator/email"; import validatePassword from "../../../validator/password"; @@ -21,18 +18,16 @@ const adminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { const { email, password } = body; // check if already admin user exists - const adminUsers = await UserRoles.getUsersThatHaveRole(ROLE_ADMIN); - const superAdminUsers = await UserRoles.getUsersThatHaveRole(ROLE_SUPERADMIN); - - if ( - adminUsers.status === "UNKNOWN_ROLE_ERROR" && - superAdminUsers.status === "UNKNOWN_ROLE_ERROR" - ) { - throw server.httpErrors.unprocessableEntity(adminUsers.status); - } else if ( - (adminUsers.status === "OK" && adminUsers.users.length > 0) || - (superAdminUsers.status === "OK" && superAdminUsers.users.length > 0) - ) { + const adminUsers = await auth.roles.getUsersThatHaveRole(ROLE_ADMIN); + const superAdminUsers = + await auth.roles.getUsersThatHaveRole(ROLE_SUPERADMIN); + + if (adminUsers.length === 0 && superAdminUsers.length === 0) { + const allRoles = await auth.roles.getAllRoles(); + if (!allRoles.includes(ROLE_ADMIN) && !allRoles.includes(ROLE_SUPERADMIN)) { + throw server.httpErrors.unprocessableEntity("Required roles not found"); + } + } else if (adminUsers.length > 0 || superAdminUsers.length > 0) { throw server.httpErrors.conflict("First admin user already exists"); } @@ -55,27 +50,31 @@ const adminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { } // signup - const signUpResponse = await emailPasswordSignUp(email, password, { - _default: { - request: { - request, + const signUpResponse = await auth.emailPassword.emailPasswordSignUp( + email, + password, + { + _default: { + request: { + request, + }, }, + autoVerifyEmail: true, + roles: [ + ROLE_ADMIN, + ...(superAdminUsers.length === 0 ? [ROLE_SUPERADMIN] : []), + ], }, - autoVerifyEmail: true, - roles: [ - ROLE_ADMIN, - ...(superAdminUsers.status === "OK" ? [ROLE_SUPERADMIN] : []), - ], - }); + ); - if (signUpResponse.status !== "OK") { - return reply.send(signUpResponse); + if (!signUpResponse.success) { + return reply.send({ status: signUpResponse.error }); } // create new session so the user be logged in on signup - await createNewSession(request, reply, signUpResponse.user.id); + await auth.session.createNewSession(request, reply, signUpResponse.user.id); - reply.send(signUpResponse); + reply.send({ status: "OK", user: signUpResponse.user }); }; export default adminSignUp; diff --git a/packages/user/src/model/users/handlers/canAdminSignUp.ts b/packages/user/src/model/users/handlers/canAdminSignUp.ts index dc9993a16..85e6fe8fa 100644 --- a/packages/user/src/model/users/handlers/canAdminSignUp.ts +++ b/packages/user/src/model/users/handlers/canAdminSignUp.ts @@ -1,25 +1,25 @@ import type { FastifyReply, FastifyRequest } from "fastify"; -import UserRoles from "supertokens-node/recipe/userroles"; - +import { auth } from "../../../auth/adapter"; import { ROLE_ADMIN, ROLE_SUPERADMIN } from "../../../constants"; const canAdminSignUp = async (request: FastifyRequest, reply: FastifyReply) => { const { server } = request; // check if already admin user exists - const adminUsers = await UserRoles.getUsersThatHaveRole(ROLE_ADMIN); - const superAdminUsers = await UserRoles.getUsersThatHaveRole(ROLE_SUPERADMIN); - - if ( - adminUsers.status === "UNKNOWN_ROLE_ERROR" && - superAdminUsers.status === "UNKNOWN_ROLE_ERROR" - ) { - throw server.httpErrors.unprocessableEntity(adminUsers.status); - } else if ( - (adminUsers.status === "OK" && adminUsers.users.length > 0) || - (superAdminUsers.status === "OK" && superAdminUsers.users.length > 0) - ) { + const adminUsers = await auth.roles.getUsersThatHaveRole(ROLE_ADMIN); + const superAdminUsers = + await auth.roles.getUsersThatHaveRole(ROLE_SUPERADMIN); + + if (adminUsers.length === 0 && superAdminUsers.length === 0) { + const allRoles = await auth.roles.getAllRoles(); + + if (!allRoles.includes(ROLE_ADMIN) && !allRoles.includes(ROLE_SUPERADMIN)) { + throw server.httpErrors.unprocessableEntity("UNKNOWN_ROLE_ERROR"); + } + } + + if (adminUsers.length > 0 || superAdminUsers.length > 0) { return reply.send({ signUp: false }); } diff --git a/packages/user/src/model/users/handlers/changeEmail.ts b/packages/user/src/model/users/handlers/changeEmail.ts index d3926fb11..97d72c017 100644 --- a/packages/user/src/model/users/handlers/changeEmail.ts +++ b/packages/user/src/model/users/handlers/changeEmail.ts @@ -1,21 +1,16 @@ -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -import { FastifyReply } from "fastify"; -import EmailVerification, { - EmailVerificationClaim, - isEmailVerified, -} from "supertokens-node/recipe/emailverification"; -import { getUsersByEmail } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { FastifyReply, FastifyRequest } from "fastify"; +import type { AuthSession } from "../../../auth/adapter"; import type { ChangeEmailInput } from "../../../types"; +import { auth } from "../../../auth/adapter"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import validateEmail from "../../../validator/email"; -const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { - const { body, config, server, session, slonik, user } = request; +const changeEmail = async (request: FastifyRequest, reply: FastifyReply) => { + const { body, config, server, slonik, user } = request; + const session = (request as FastifyRequest & { session: AuthSession }) + .session; if (!user) { throw server.httpErrors.unauthorized("Unauthorised"); @@ -28,17 +23,23 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { } try { - if (config.user.features?.profileValidation?.enabled) { - await session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const userContext = auth.createUserContext(request); + + if (config.user.features?.profileValidation?.enabled && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (config.user.features?.signUp?.emailVerification) { - await session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if (config.user.features?.signUp?.emailVerification && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } @@ -59,14 +60,20 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { }); } - if (config.user.features?.signUp?.emailVerification) { - const isVerified = await isEmailVerified(user.id, email); + if ( + config.user.features?.signUp?.emailVerification && + auth.emailVerification + ) { + const isVerified = await auth.emailVerification.isEmailVerified( + user.id, + email, + ); if (!isVerified) { - const users = await getUsersByEmail(email); + const users = (await auth.emailPassword.getUsersByEmail?.(email)) || []; const emailPasswordRecipeUsers = users.filter( - (user) => !user.thirdParty, + (user) => !(user as Record).thirdParty, ); if (emailPasswordRecipeUsers.length > 0) { @@ -75,24 +82,19 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { }); } - const tokenResponse = - await EmailVerification.createEmailVerificationToken(user.id, email); - - if (tokenResponse.status === "OK") { - await EmailVerification.sendEmail({ - emailVerifyLink: `${config.appOrigin[0]}/auth/verify-email?token=${tokenResponse.token}&rid=emailverification`, - type: "EMAIL_VERIFICATION", - user: { - email: email, - id: user.id, - }, - userContext: { - _default: { - request: { - request: request, - }, - }, - }, + const token = await auth.emailVerification.createEmailVerificationToken( + user.id, + email, + userContext, + ); + + if (token) { + await auth.emailVerification.sendVerificationEmail?.({ + appOrigin: config.appOrigin[0] as string, + email, + token, + userContext, + userId: user.id, }); return reply.send({ @@ -101,7 +103,7 @@ const changeEmail = async (request: SessionRequest, reply: FastifyReply) => { }); } - return reply.send(tokenResponse.status); + return reply.send({ status: "EMAIL_VERIFICATION_TOKEN_FAILED" }); } } diff --git a/packages/user/src/model/users/handlers/changePassword.ts b/packages/user/src/model/users/handlers/changePassword.ts index 98bff47b0..b3b398b89 100644 --- a/packages/user/src/model/users/handlers/changePassword.ts +++ b/packages/user/src/model/users/handlers/changePassword.ts @@ -1,14 +1,11 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; - -import { createNewSession } from "supertokens-node/recipe/session"; +import type { FastifyReply, FastifyRequest } from "fastify"; import type { ChangePasswordInput } from "../../../types"; +import { auth } from "../../../auth/adapter"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -const changePassword = async (request: SessionRequest, reply: FastifyReply) => { +const changePassword = async (request: FastifyRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request; if (!user) { @@ -27,13 +24,13 @@ const changePassword = async (request: SessionRequest, reply: FastifyReply) => { ); if (response.status === "OK") { - await createNewSession( + await auth.session.createNewSession( request, reply, user.id, undefined, undefined, - createUserContext(undefined, request), + auth.createUserContext(request), ); } diff --git a/packages/user/src/model/users/handlers/deleteMe.ts b/packages/user/src/model/users/handlers/deleteMe.ts index f6915fdfe..45a080921 100644 --- a/packages/user/src/model/users/handlers/deleteMe.ts +++ b/packages/user/src/model/users/handlers/deleteMe.ts @@ -1,11 +1,10 @@ import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; import getUserService from "../../../lib/getUserService"; -const deleteMe = async (request: SessionRequest, reply: FastifyReply) => { +const deleteMe = async (request: FastifyRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ Body: { diff --git a/packages/user/src/model/users/handlers/disable.ts b/packages/user/src/model/users/handlers/disable.ts index 910780ef7..03ac852df 100644 --- a/packages/user/src/model/users/handlers/disable.ts +++ b/packages/user/src/model/users/handlers/disable.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import getUserService from "../../../lib/getUserService"; -const disable = async (request: SessionRequest, reply: FastifyReply) => { +const disable = async (request: FastifyRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; if (!user) { diff --git a/packages/user/src/model/users/handlers/enable.ts b/packages/user/src/model/users/handlers/enable.ts index e3cbc4587..20bd38491 100644 --- a/packages/user/src/model/users/handlers/enable.ts +++ b/packages/user/src/model/users/handlers/enable.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import getUserService from "../../../lib/getUserService"; -const enable = async (request: SessionRequest, reply: FastifyReply) => { +const enable = async (request: FastifyRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; if (!user) { diff --git a/packages/user/src/model/users/handlers/me.ts b/packages/user/src/model/users/handlers/me.ts index 0c131af9f..8abe46d51 100644 --- a/packages/user/src/model/users/handlers/me.ts +++ b/packages/user/src/model/users/handlers/me.ts @@ -1,38 +1,42 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; -import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { AuthSession } from "../../../auth/adapter"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; +import { auth } from "../../../auth/adapter"; -const me = async (request: SessionRequest, reply: FastifyReply) => { - const { config, server, session, user } = request; +const me = async (request: FastifyRequest, reply: FastifyReply) => { + const { config, server, session, user } = request as FastifyRequest & { + session: AuthSession; + }; if (!user) { throw server.httpErrors.unauthorized("Unauthorised"); } - const authUser = await getUserById(user.id); + const authUser = await auth.emailPassword.getUserById(user.id); + const userContext = auth.createUserContext(request); - if (config.user.features?.profileValidation?.enabled) { - await session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + if (config.user.features?.profileValidation?.enabled && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (config.user.features?.signUp?.emailVerification) { - await session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if (config.user.features?.signUp?.emailVerification && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } const response = { ...user, - thirdParty: authUser?.thirdParty, + thirdParty: (authUser as Record)?.thirdParty, }; reply.send(response); diff --git a/packages/user/src/model/users/handlers/removePhoto.ts b/packages/user/src/model/users/handlers/removePhoto.ts index c00496aba..59a279e27 100644 --- a/packages/user/src/model/users/handlers/removePhoto.ts +++ b/packages/user/src/model/users/handlers/removePhoto.ts @@ -1,14 +1,11 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; -import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { AuthSession } from "../../../auth/adapter"; +import { auth } from "../../../auth/adapter"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -const removePhoto = async (request: SessionRequest, reply: FastifyReply) => { +const removePhoto = async (request: FastifyRequest, reply: FastifyReply) => { const { config, dbSchema, server, slonik, user } = request; if (!user) { @@ -26,25 +23,33 @@ const removePhoto = async (request: SessionRequest, reply: FastifyReply) => { request.user = updatedUser; - const authUser = await getUserById(user.id); + const authUser = await auth.emailPassword.getUserById(user.id); + const userContext = auth.createUserContext(request); - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + + if (request.config.user.features?.profileValidation?.enabled && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if (request.config.user.features?.signUp?.emailVerification && auth.claims) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } const response = { ...updatedUser, - thirdParty: authUser?.thirdParty, + thirdParty: (authUser as Record)?.thirdParty, }; reply.send(response); diff --git a/packages/user/src/model/users/handlers/updateMe.ts b/packages/user/src/model/users/handlers/updateMe.ts index f1fb4f227..d91b741e1 100644 --- a/packages/user/src/model/users/handlers/updateMe.ts +++ b/packages/user/src/model/users/handlers/updateMe.ts @@ -1,20 +1,17 @@ import type { File } from "@prefabs.tech/fastify-s3"; import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; -import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { AuthSession } from "../../../auth/adapter"; import type { UserUpdateInput } from "../../../types"; +import { auth } from "../../../auth/adapter"; import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; import filterUserUpdateInput from "../filterUserUpdateInput"; -const updateMe = async (request: SessionRequest, reply: FastifyReply) => { +const updateMe = async (request: FastifyRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ Body: UserUpdateInput; @@ -49,25 +46,39 @@ const updateMe = async (request: SessionRequest, reply: FastifyReply) => { request.user = updatedUser; - const authUser = await getUserById(user.id); - - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const authUser = await auth.emailPassword.getUserById(user.id); + const userContext = auth.createUserContext(request); + + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + + if ( + request.config.user.features?.profileValidation?.enabled && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if ( + request.config.user.features?.signUp?.emailVerification && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } const response = { ...updatedUser, - thirdParty: authUser?.thirdParty, + thirdParty: (authUser as Record)?.thirdParty, }; reply.send(response); diff --git a/packages/user/src/model/users/handlers/uploadPhoto.ts b/packages/user/src/model/users/handlers/uploadPhoto.ts index fbfd2384e..4ad8bb3f3 100644 --- a/packages/user/src/model/users/handlers/uploadPhoto.ts +++ b/packages/user/src/model/users/handlers/uploadPhoto.ts @@ -1,19 +1,16 @@ import type { Multipart } from "@prefabs.tech/fastify-s3"; import type { FastifyReply, FastifyRequest } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; import { CustomError } from "@prefabs.tech/fastify-error-handler"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; -import { getUserById } from "supertokens-node/recipe/thirdpartyemailpassword"; +import type { AuthSession } from "../../../auth/adapter"; import type { UserUpdateInput } from "../../../types"; +import { auth } from "../../../auth/adapter"; import { ERROR_CODES } from "../../../constants"; import getUserService from "../../../lib/getUserService"; -import createUserContext from "../../../supertokens/utils/createUserContext"; -import ProfileValidationClaim from "../../../supertokens/utils/profileValidationClaim"; -const uploadPhoto = async (request: SessionRequest, reply: FastifyReply) => { +const uploadPhoto = async (request: FastifyRequest, reply: FastifyReply) => { const { body, config, dbSchema, server, slonik, user } = request as FastifyRequest<{ Body: UserUpdateInput; @@ -51,25 +48,39 @@ const uploadPhoto = async (request: SessionRequest, reply: FastifyReply) => { request.user = updatedUser; - const authUser = await getUserById(user.id); - - if (request.config.user.features?.profileValidation?.enabled) { - await request.session?.fetchAndSetClaim( - new ProfileValidationClaim(), - createUserContext(undefined, request), + const authUser = await auth.emailPassword.getUserById(user.id); + const userContext = auth.createUserContext(request); + + const session = (request as FastifyRequest & { session: AuthSession }) + .session; + + if ( + request.config.user.features?.profileValidation?.enabled && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["profileValidation"], + userContext, ); } - if (request.config.user.features?.signUp?.emailVerification) { - await request.session?.fetchAndSetClaim( - EmailVerificationClaim, - createUserContext(undefined, request), + if ( + request.config.user.features?.signUp?.emailVerification && + auth.claims + ) { + await auth.claims.refreshSessionClaims( + session, + request, + ["emailVerification"], + userContext, ); } const response = { ...updatedUser, - thirdParty: authUser?.thirdParty, + thirdParty: (authUser as Record)?.thirdParty, }; reply.send(response); diff --git a/packages/user/src/model/users/handlers/user.ts b/packages/user/src/model/users/handlers/user.ts index 0de2764e4..e6d2faef3 100644 --- a/packages/user/src/model/users/handlers/user.ts +++ b/packages/user/src/model/users/handlers/user.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import getUserService from "../../../lib/getUserService"; -const user = async (request: SessionRequest, reply: FastifyReply) => { +const user = async (request: FastifyRequest, reply: FastifyReply) => { const service = getUserService( request.config, request.slonik, diff --git a/packages/user/src/model/users/handlers/users.ts b/packages/user/src/model/users/handlers/users.ts index 0a28a042b..12a84a0bc 100644 --- a/packages/user/src/model/users/handlers/users.ts +++ b/packages/user/src/model/users/handlers/users.ts @@ -1,9 +1,8 @@ -import type { FastifyReply } from "fastify"; -import type { SessionRequest } from "supertokens-node/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; import getUserService from "../../../lib/getUserService"; -const users = async (request: SessionRequest, reply: FastifyReply) => { +const users = async (request: FastifyRequest, reply: FastifyReply) => { const service = getUserService( request.config, request.slonik, diff --git a/packages/user/src/model/users/service.ts b/packages/user/src/model/users/service.ts index d2ce8765b..9c3bf3aec 100644 --- a/packages/user/src/model/users/service.ts +++ b/packages/user/src/model/users/service.ts @@ -1,11 +1,10 @@ import { CustomError } from "@prefabs.tech/fastify-error-handler"; import { File, FileService, Multipart } from "@prefabs.tech/fastify-s3"; import { BaseService } from "@prefabs.tech/fastify-slonik"; -import Session from "supertokens-node/recipe/session"; -import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpassword"; import type { User, UserCreateInput, UserUpdateInput } from "../../types"; +import { auth } from "../../auth/adapter"; import { DEFAULT_USER_PHOTO_MAX_SIZE_IN_MB, ERROR_CODES, @@ -49,13 +48,16 @@ class UserService extends BaseService { protected photoPath = "photo"; async changeEmail(id: string, email: string) { - const response = await ThirdPartyEmailPassword.updateEmailOrPassword({ + const result = await auth.emailPassword.updateEmailOrPassword({ email: email, userId: id, }); - if (response.status !== "OK") { - throw new CustomError(response.status, response.status); + if (!result.success) { + throw new CustomError( + result.error || ERROR_CODES.CHANGE_EMAIL, + result.error || ERROR_CODES.CHANGE_EMAIL, + ); } const query = this.factory.getUpdateSql(id, { email }); @@ -81,25 +83,24 @@ class UserService extends BaseService { }; } - const userInfo = await ThirdPartyEmailPassword.getUserById(userId); + const userInfo = await auth.emailPassword.getUserById(userId); if (oldPassword && newPassword) { if (userInfo) { - const isPasswordValid = - await ThirdPartyEmailPassword.emailPasswordSignIn( - userInfo.email, - oldPassword, - { dbSchema: this.schema }, - ); - - if (isPasswordValid.status === "OK") { - const result = await ThirdPartyEmailPassword.updateEmailOrPassword({ + const isPasswordValid = await auth.emailPassword.emailPasswordSignIn( + userInfo.email, + oldPassword, + { dbSchema: this.schema }, + ); + + if (isPasswordValid.success) { + const result = await auth.emailPassword.updateEmailOrPassword({ password: newPassword, userId, }); - if (result) { - await Session.revokeAllSessionsForUser(userId); + if (result.success) { + await auth.session.revokeAllSessionsForUser(userId); return { status: "OK", @@ -144,7 +145,7 @@ class UserService extends BaseService { } async deleteMe(userId: string, password: string) { - const user = await ThirdPartyEmailPassword.getUserById(userId); + const user = await auth.emailPassword.getUserById(userId); if (!user) { throw new CustomError("User not found", ERROR_CODES.USER_NOT_FOUND); @@ -154,13 +155,13 @@ class UserService extends BaseService { throw new CustomError("Invalid password", ERROR_CODES.INVALID_PASSWORD); } - const signInResponse = await ThirdPartyEmailPassword.emailPasswordSignIn( + const signInResponse = await auth.emailPassword.emailPasswordSignIn( user.email, password, { dbSchema: this.schema }, ); - if (signInResponse.status === "OK") { + if (signInResponse.success) { return await this.delete(userId); } else { throw new CustomError("Invalid password", ERROR_CODES.INVALID_PASSWORD); @@ -199,7 +200,7 @@ class UserService extends BaseService { } protected async postDelete(result: User): Promise { - await Session.revokeAllSessionsForUser(result.id); + await auth.session.revokeAllSessionsForUser(result.id); return result; } diff --git a/packages/user/src/plugin.ts b/packages/user/src/plugin.ts index b61d94b76..bdb08b55e 100644 --- a/packages/user/src/plugin.ts +++ b/packages/user/src/plugin.ts @@ -3,6 +3,8 @@ import type { FastifyPluginAsync } from "fastify"; import FastifyPlugin from "fastify-plugin"; +import { initAuth } from "./auth/adapter"; +import { getAuthProvider } from "./auth/providers"; import seedRoles from "./lib/seedRoles"; import mercuriusAuthPlugin from "./mercurius-auth/plugin"; import hasPermission from "./middlewares/hasPermission"; @@ -11,13 +13,14 @@ import invitationsRoutes from "./model/invitations/controller"; import permissionsRoutes from "./model/permissions/controller"; import rolesRoutes from "./model/roles/controller"; import usersRoutes from "./model/users/controller"; -import supertokensPlugin from "./supertokens"; import userContext from "./userContext"; const userPlugin: FastifyPluginAsync = async (fastify) => { const { graphql, user } = fastify.config; - await fastify.register(supertokensPlugin); + const providerName = user.authProvider || "supertokens"; + + await initAuth(fastify, getAuthProvider(providerName)); fastify.addHook("onReady", async () => { await seedRoles(user); diff --git a/packages/user/src/supertokens/index.ts b/packages/user/src/supertokens/index.ts index 22045a56f..99ef9b720 100644 --- a/packages/user/src/supertokens/index.ts +++ b/packages/user/src/supertokens/index.ts @@ -1,13 +1,15 @@ -import Session from "supertokens-node/lib/build/recipe/session/sessionClass"; -import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; +import type { FastifyReply, FastifyRequest } from "fastify"; + +import type { AuthSession, GetSessionOptions } from "../auth/adapter"; declare module "fastify" { interface FastifyInstance { - verifySession: typeof verifySession; - } - - interface FastifyRequest { - session?: Session; + verifySession( + options?: GetSessionOptions, + ): ( + request: FastifyRequest, + reply: FastifyReply, + ) => Promise; } } diff --git a/packages/user/src/supertokens/init.ts b/packages/user/src/supertokens/init.ts index 05fb5b751..2d3454b72 100644 --- a/packages/user/src/supertokens/init.ts +++ b/packages/user/src/supertokens/init.ts @@ -9,7 +9,7 @@ const init = (fastify: FastifyInstance) => { supertokens.init({ appInfo: { - apiBasePath: config.user.supertokens.apiBasePath, + apiBasePath: config.user.supertokens!.apiBasePath, apiDomain: config.baseUrl as string, appName: config.appName as string, websiteDomain: config.appOrigin[0] as string, @@ -17,7 +17,7 @@ const init = (fastify: FastifyInstance) => { framework: "fastify", recipeList: getRecipeList(fastify), supertokens: { - connectionURI: config.user.supertokens.connectionUri as string, + connectionURI: config.user.supertokens!.connectionUri as string, }, }); }; diff --git a/packages/user/src/supertokens/plugin.ts b/packages/user/src/supertokens/plugin.ts index 291068e05..a773a9f08 100644 --- a/packages/user/src/supertokens/plugin.ts +++ b/packages/user/src/supertokens/plugin.ts @@ -2,8 +2,8 @@ import type { FastifyInstance } from "fastify"; import FastifyPlugin from "fastify-plugin"; import { plugin as supertokensPlugin } from "supertokens-node/framework/fastify"; -import { verifySession } from "supertokens-node/recipe/session/framework/fastify"; +import { auth } from "../auth/adapter"; import { errorHandler } from "./errorHandler"; import init from "./init"; @@ -14,7 +14,7 @@ const plugin = async (fastify: FastifyInstance) => { init(fastify); - if (config.user.supertokens.setErrorHandler !== false) { + if (config.user.supertokens!.setErrorHandler !== false) { fastify.setErrorHandler(errorHandler); } @@ -22,12 +22,14 @@ const plugin = async (fastify: FastifyInstance) => { log.info("Registering supertokens plugin complete"); - fastify.decorate("verifySession", verifySession); + fastify.decorate("verifySession", (options) => + auth.session.getVerifySession(options), + ); // [RL 2024-06-11] change sRefreshToken cookie path from config fastify.addHook("onSend", async (request, reply) => { const refreshTokenCookiePath = - request.server.config.user.supertokens.refreshTokenCookiePath; + request.server.config.user.supertokens!.refreshTokenCookiePath; const setCookieHeader = reply.getHeader("set-cookie"); diff --git a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts index b500b9d0e..6e69b1463 100644 --- a/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts +++ b/packages/user/src/supertokens/recipes/config/email-verification/sendEmailVerificationEmail.ts @@ -32,7 +32,7 @@ const sendEmailVerificationEmail = ( const emailVerifyLink = input.emailVerifyLink.replace( websiteDomain + "/auth/verify-email", origin + - (fastify.config.user.supertokens.emailVerificationPath || + (fastify.config.user.supertokens!.emailVerificationPath || EMAIL_VERIFICATION_PATH), ); diff --git a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts index e2cee9212..8b72ae3c2 100644 --- a/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/emailVerificationRecipeConfig.ts @@ -21,8 +21,8 @@ const getEmailVerificationRecipeConfig = ( let emailVerification: EmailVerificationRecipe = {}; - if (typeof config.user.supertokens.recipes?.emailVerification === "object") { - emailVerification = config.user.supertokens.recipes.emailVerification; + if (typeof config.user.supertokens!.recipes?.emailVerification === "object") { + emailVerification = config.user.supertokens!.recipes.emailVerification; } return { diff --git a/packages/user/src/supertokens/recipes/config/session/getSession.ts b/packages/user/src/supertokens/recipes/config/session/getSession.ts index 874a0869b..1087f5f4e 100644 --- a/packages/user/src/supertokens/recipes/config/session/getSession.ts +++ b/packages/user/src/supertokens/recipes/config/session/getSession.ts @@ -17,7 +17,7 @@ const getSession = ( .request as FastifyRequest; input.options = { - checkDatabase: config.user.supertokens.checkSessionInDatabase ?? true, + checkDatabase: config.user.supertokens!.checkSessionInDatabase ?? true, ...input.options, }; diff --git a/packages/user/src/supertokens/recipes/config/session/verifySession.ts b/packages/user/src/supertokens/recipes/config/session/verifySession.ts index dc036428e..25486781c 100644 --- a/packages/user/src/supertokens/recipes/config/session/verifySession.ts +++ b/packages/user/src/supertokens/recipes/config/session/verifySession.ts @@ -13,7 +13,7 @@ const verifySession = ( input.verifySessionOptions = { checkDatabase: - fastify.config.user.supertokens.checkSessionInDatabase ?? true, + fastify.config.user.supertokens!.checkSessionInDatabase ?? true, ...input.verifySessionOptions, }; diff --git a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts index be9245a9c..958748731 100644 --- a/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/sessionRecipeConfig.ts @@ -19,8 +19,8 @@ const getSessionRecipeConfig = ( let session: SessionRecipe = {}; - if (typeof config.user.supertokens.recipes?.session === "object") { - session = config.user.supertokens.recipes.session; + if (typeof config.user.supertokens!.recipes?.session === "object") { + session = config.user.supertokens!.recipes.session; } return { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts index 68d5eb6c3..267ee9593 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignIn.ts @@ -3,8 +3,6 @@ import type { RecipeInterface } from "supertokens-node/recipe/thirdpartyemailpas import { formatDate } from "@prefabs.tech/fastify-slonik"; -import type { AuthUser } from "../../../../types"; - import getUserService from "../../../../lib/getUserService"; const emailPasswordSignIn = ( @@ -45,7 +43,7 @@ const emailPasswordSignIn = ( log.error(error); }); - const authUser: AuthUser = { + const authUser = { ...originalResponse.user, ...user, }; diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts index 77f33cfc1..e87dac20d 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/emailPasswordSignUp.ts @@ -100,7 +100,7 @@ const emailPasswordSignUp = ( } if ( - config.user.supertokens.sendUserAlreadyExistsWarning && + config.user.supertokens!.sendUserAlreadyExistsWarning && originalResponse.status === "EMAIL_ALREADY_EXISTS_ERROR" ) { try { diff --git a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts index 892c6506f..9664ca9b0 100644 --- a/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts +++ b/packages/user/src/supertokens/recipes/config/third-party-email-password/sendPasswordResetEmail.ts @@ -39,7 +39,7 @@ const sendPasswordResetEmail = ( const passwordResetLink = input.passwordResetLink.replace( websiteDomain + "/auth/reset-password", origin + - (fastify.config.user.supertokens.resetPasswordPath || + (fastify.config.user.supertokens!.resetPasswordPath || RESET_PASSWORD_PATH), ); diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts index d504068a3..822e5fb04 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyEmailPasswordRecipeConfig.ts @@ -29,10 +29,11 @@ const getThirdPartyEmailPasswordRecipeConfig = ( let thirdPartyEmailPassword: ThirdPartyEmailPasswordRecipe = {}; if ( - typeof config.user.supertokens.recipes?.thirdPartyEmailPassword === "object" + typeof config.user.supertokens!.recipes?.thirdPartyEmailPassword === + "object" ) { thirdPartyEmailPassword = - config.user.supertokens.recipes.thirdPartyEmailPassword; + config.user.supertokens!.recipes.thirdPartyEmailPassword; } return { diff --git a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts index 609c9c2a2..e6e53cd3a 100644 --- a/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts +++ b/packages/user/src/supertokens/recipes/config/thirdPartyProviders.ts @@ -5,7 +5,7 @@ import ThirdPartyEmailPassword from "supertokens-node/recipe/thirdpartyemailpass const getThirdPartyProviders = (config: ApiConfig) => { const { Apple, Facebook, Github, Google } = ThirdPartyEmailPassword; - const providersConfig = config.user.supertokens.providers; + const providersConfig = config.user.supertokens!.providers; const providers: TypeProvider[] = []; const providerFunctions = [ diff --git a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts index 2568a2bb7..fb4a476d2 100644 --- a/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts +++ b/packages/user/src/supertokens/recipes/initEmailVerificationRecipe.ts @@ -8,7 +8,7 @@ import getEmailVerificationRecipeConfig from "./config/emailVerificationRecipeCo const init = (fastify: FastifyInstance) => { const emailVerification: SupertokensRecipes["emailVerification"] = - fastify.config.user.supertokens.recipes?.emailVerification; + fastify.config.user.supertokens!.recipes?.emailVerification; if (typeof emailVerification === "function") { return EmailVerification.init(emailVerification(fastify)); diff --git a/packages/user/src/supertokens/recipes/initSessionRecipe.ts b/packages/user/src/supertokens/recipes/initSessionRecipe.ts index 3adf21646..19210690c 100644 --- a/packages/user/src/supertokens/recipes/initSessionRecipe.ts +++ b/packages/user/src/supertokens/recipes/initSessionRecipe.ts @@ -8,7 +8,7 @@ import getSessionRecipeConfig from "./config/sessionRecipeConfig"; const init = (fastify: FastifyInstance) => { const session: SupertokensRecipes["session"] = - fastify.config.user.supertokens.recipes?.session; + fastify.config.user.supertokens!.recipes?.session; if (typeof session === "function") { return Session.init(session(fastify)); diff --git a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts index 4484d89d7..06cf03ad6 100644 --- a/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts +++ b/packages/user/src/supertokens/recipes/initThirdPartyEmailPasswordRecipe.ts @@ -8,7 +8,7 @@ import getThirdPartyEmailPasswordRecipeConfig from "./config/thirdPartyEmailPass const init = (fastify: FastifyInstance) => { const thirdPartyEmailPassword: SupertokensRecipes["thirdPartyEmailPassword"] = - fastify.config.user.supertokens.recipes?.thirdPartyEmailPassword; + fastify.config.user.supertokens!.recipes?.thirdPartyEmailPassword; if (typeof thirdPartyEmailPassword === "function") { return ThirdPartyEmailPassword.init(thirdPartyEmailPassword(fastify)); diff --git a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts index c5b651ac1..a93f1a909 100644 --- a/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts +++ b/packages/user/src/supertokens/recipes/initUserRolesRecipe.ts @@ -7,7 +7,8 @@ import type { SupertokensRecipes } from "../types"; import getUserRolesRecipeConfig from "./config/userRolesRecipeConfig"; const init = (fastify: FastifyInstance) => { - const recipes = fastify.config.user.supertokens.recipes as SupertokensRecipes; + const recipes = fastify.config.user.supertokens! + .recipes as SupertokensRecipes; if (recipes && recipes.userRoles) { return UserRoles.init(recipes.userRoles(fastify)); diff --git a/packages/user/src/supertokens/utils/profileValidationClaim.ts b/packages/user/src/supertokens/utils/profileValidationClaim.ts index be4fd61dc..a81279203 100644 --- a/packages/user/src/supertokens/utils/profileValidationClaim.ts +++ b/packages/user/src/supertokens/utils/profileValidationClaim.ts @@ -10,6 +10,10 @@ import type { SessionClaimValidator } from "supertokens-node/recipe/session"; import { getRequestFromUserContext } from "supertokens-node"; import { SessionClaim } from "supertokens-node/lib/build/recipe/session/claims"; +import type { ProfileValidationConfig } from "../../auth/claims/profileValidation"; + +import { checkProfileValidation } from "../../auth/claims/profileValidation"; + interface Response { gracePeriodEndsAt?: number; isVerified: boolean; @@ -91,7 +95,8 @@ class ProfileValidationClaim extends SessionClaim { throw new Error("Request not set in userContext"); } - const profileValidation = request.config.user?.features?.profileValidation; + const profileValidation = request.config.user?.features + ?.profileValidation as ProfileValidationConfig | undefined; if (!profileValidation?.enabled) { throw new Error("Profile validation is not enabled"); @@ -103,22 +108,7 @@ class ProfileValidationClaim extends SessionClaim { throw new Error("User not found"); } - const fields = profileValidation.fields || []; - - // Verify that none of the specified fields in the user are null - const isVerified = !fields.some((field) => user[field] === null); - - // Calculate the grace period expiry date if the user is not verified - const gracePeriodEndsAt = - !isVerified && profileValidation.gracePeriodInDays - ? user.signedUpAt + - profileValidation.gracePeriodInDays * (24 * 60 * 60 * 1000) - : undefined; - - return { - gracePeriodEndsAt, - isVerified, - }; + return checkProfileValidation(user, profileValidation); }; getLastRefetchTime(payload: any, _userContext: any): number | undefined { diff --git a/packages/user/src/types/config.ts b/packages/user/src/types/config.ts index 1b965494f..87d8fdcf6 100644 --- a/packages/user/src/types/config.ts +++ b/packages/user/src/types/config.ts @@ -15,7 +15,9 @@ interface EmailOptions { subject?: string; templateName?: string; } + interface UserConfig { + authProvider?: string; email?: IsEmailOptions; emailOverrides?: { duplicateEmail?: EmailOptions; @@ -114,7 +116,7 @@ interface UserConfig { invitation?: typeof InvitationService; user?: typeof UserService; }; - supertokens: SupertokensConfig; + supertokens?: SupertokensConfig; tables?: { invitations?: { name?: string; diff --git a/packages/user/src/types/index.ts b/packages/user/src/types/index.ts index ba9be6735..d27650c2e 100644 --- a/packages/user/src/types/index.ts +++ b/packages/user/src/types/index.ts @@ -39,9 +39,10 @@ export type { Resolver, }; -export type { EmailVerificationRecipe } from "../supertokens/types/emailVerificationRecipe"; -export type { SessionRecipe } from "../supertokens/types/sessionRecipe"; -export type { ThirdPartyEmailPasswordRecipe } from "../supertokens/types/thirdPartyEmailPasswordRecipe"; +/* + * @deprecated Import auth types from "@prefabs.tech/fastify-user/auth" instead. + * These supertokens-specific types will be removed in a future release. + */ export * from "./config"; export * from "./invitation"; diff --git a/packages/user/src/types/user.ts b/packages/user/src/types/user.ts index 6c846d0e3..f96b76722 100644 --- a/packages/user/src/types/user.ts +++ b/packages/user/src/types/user.ts @@ -1,7 +1,4 @@ import type { Multipart } from "@prefabs.tech/fastify-s3"; -import type { User as SupertokensUser } from "supertokens-node/recipe/thirdpartyemailpassword"; - -interface AuthUser extends SupertokensUser, User {} interface Photo { id: number; @@ -46,4 +43,4 @@ type UserUpdateInput = Partial< photo?: Multipart; }; -export type { AuthUser, User, UserCreateInput, UserUpdateInput }; +export type { User, UserCreateInput, UserUpdateInput }; diff --git a/packages/user/src/userContext.ts b/packages/user/src/userContext.ts index ef482fd68..0c56daa3e 100644 --- a/packages/user/src/userContext.ts +++ b/packages/user/src/userContext.ts @@ -1,11 +1,7 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import type { MercuriusContext } from "mercurius"; -import { wrapResponse } from "supertokens-node/framework/fastify"; -import { EmailVerificationClaim } from "supertokens-node/recipe/emailverification"; -import Session from "supertokens-node/recipe/session"; - -import ProfileValidationClaim from "./supertokens/utils/profileValidationClaim"; +import { auth } from "./auth/adapter"; const userContext = async ( context: MercuriusContext, @@ -13,18 +9,13 @@ const userContext = async ( reply: FastifyReply, ) => { try { - request.session = (await Session.getSession(request, wrapResponse(reply), { - overrideGlobalClaimValidators: async (globalValidators) => - globalValidators.filter( - (sessionClaimValidator) => - ![EmailVerificationClaim.key, ProfileValidationClaim.key].includes( - sessionClaimValidator.id, - ), - ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (request as any).session = await auth.session.getSession(request, reply, { sessionRequired: false, - })) as (typeof request)["session"]; + skipClaims: ["emailVerification", "profileValidation"], + }); } catch (error) { - if (!Session.Error.isErrorFromSuperTokens(error)) { + if (!auth.errors.isAuthError(error)) { throw error; } }