Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion packages/user/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 .",
Expand Down Expand Up @@ -78,5 +80,10 @@
},
"engines": {
"node": ">=20"
},
"peerDependenciesMeta": {
"supertokens-node": {
"optional": true
}
}
}
31 changes: 31 additions & 0 deletions packages/user/src/auth/__test__/providers.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
235 changes: 235 additions & 0 deletions packages/user/src/auth/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

export type AuthResult<T = AuthUser> =
| { error: string; success: false }
| { success: true; user: T };

export interface AuthSession {
getAccessTokenPayload(userContext?: AuthUserContext): unknown;
getUserId(userContext?: AuthUserContext): string;
revokeSession(userContext?: AuthUserContext): Promise<void>;
}

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<ClaimValidationError[] | undefined>;

readonly keys: {
emailVerification: string;
profileValidation: string;
};

refreshSessionClaims(
session: AuthSession,
request: FastifyRequest,
claims: RefreshableClaim[],
userContext?: AuthUserContext,
): Promise<void>;
}

export interface EmailPasswordProvider {
createResetPasswordToken?(userId: string): Promise<string>;

emailPasswordSignIn(
email: string,
password: string,
userContext?: AuthUserContext,
): Promise<AuthResult>;

emailPasswordSignUp(
email: string,
password: string,
userContext?: AuthUserContext,
): Promise<AuthResult>;

getUserById(userId: string): Promise<AuthUser | undefined>;

getUsersByEmail?(email: string): Promise<AuthUser[]>;

resetPasswordUsingToken?(
token: string,
newPassword: string,
): Promise<ResetPasswordResult>;

updateEmailOrPassword(input: {
email?: string;
password?: string;
userId: string;
}): Promise<UpdateEmailOrPasswordResult>;
}

export interface EmailVerificationProvider {
createEmailVerificationToken(
userId: string,
email?: string,
userContext?: AuthUserContext,
): Promise<string>;

isEmailVerified(userId: string, email?: string): Promise<boolean>;

sendVerificationEmail?(input: {
appOrigin: string;
email: string;
token: string;
userContext?: AuthUserContext;
userId: string;
}): Promise<{ status: string; success: boolean }>;

unverifyEmail?(userId: string, email?: string): Promise<void>;

verifyEmailUsingToken(
token: string,
userContext?: AuthUserContext,
): Promise<boolean>;
}

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<void>;

createNewRoleOrAddPermissions(
role: string,
permissions: string[],
): Promise<boolean>;

deleteRole(role: string): Promise<boolean>;

getAllRoles(): Promise<string[]>;

getPermissionsForRole(role: string): Promise<string[]>;

getRolesForUser(userId: string): Promise<string[]>;

getUsersThatHaveRole(role: string): Promise<string[]>;

PermissionClaim?: {
key: string;
};

removePermissionsFromRole(role: string, permissions: string[]): Promise<void>;

rolesExist(roles: string[]): Promise<boolean>;
}

export interface SessionProvider {
createNewSession(
request: FastifyRequest,
reply: FastifyReply,
userId: string,
accessTokenPayload?: Record<string, unknown>,
sessionData?: Record<string, unknown>,
userContext?: AuthUserContext,
): Promise<AuthSession>;

getSession(
request: FastifyRequest,
reply: FastifyReply,
options?: GetSessionOptions,
): Promise<AuthSession | undefined>;

/**
* 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<AuthSession | undefined>;

revokeAllSessionsForUser(
userId: string,
userContext?: AuthUserContext,
): Promise<void>;
}

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<AuthAdapter> {
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];
},
});
32 changes: 32 additions & 0 deletions packages/user/src/auth/claims/profileValidation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { UserUpdateInput } from "../../types";

export interface ProfileValidationConfig {
enabled?: boolean;
fields?: Array<keyof UserUpdateInput>;
gracePeriodInDays?: number;
}

export interface ProfileValidationResult {
gracePeriodEndsAt?: number;
isVerified: boolean;
}

export function checkProfileValidation<T extends { signedUpAt: number }>(
user: T,
config: ProfileValidationConfig,
): ProfileValidationResult {
const fields = config.fields ?? [];

const isVerified = !fields.some(
(field) =>
(user as Record<string, unknown>)[field as string] === null ||
(user as Record<string, unknown>)[field as string] === undefined,
);

const gracePeriodEndsAt =
!isVerified && config.gracePeriodInDays
? user.signedUpAt + config.gracePeriodInDays * 24 * 60 * 60 * 1000
: undefined;

return { gracePeriodEndsAt, isVerified };
}
28 changes: 28 additions & 0 deletions packages/user/src/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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";
27 changes: 27 additions & 0 deletions packages/user/src/auth/providers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { AuthProvider } from "../adapter";

import { supertokensProvider } from "../supertokens";

const providers: Record<string, AuthProvider> = {
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";
Loading
Loading