From 79f2d1cf48174d6b91d9430df2e5554ee560865a Mon Sep 17 00:00:00 2001 From: Dan Pickett Date: Fri, 11 Jul 2025 22:34:47 -0400 Subject: [PATCH 1/3] Implement configurable PasswordIdentity schema factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add createPasswordIdentitySchema with email, username, phone support - Compile-time type safety with readonly arrays and const assertions - Custom validation schemas for each identifier type - Comprehensive test coverage for all identifier combinations - Update user story documentation with phone identifier examples Closes #2 ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .ai/shared/architectural-context.md | 4 +- .../20250709_user-sign-up.md | 100 ++++++++++++------ .github/workflows/ci.yml | 11 +- .prettierrc | 1 + libs/password-authentication/.spec.swcrc | 22 ++++ libs/password-authentication/README.md | 11 ++ .../password-authentication/eslint.config.mjs | 19 ++++ libs/password-authentication/jest.config.ts | 19 ++++ libs/password-authentication/package.json | 21 ++++ .../createPasswordIdentitySchema.test.ts | 100 ++++++++++++++++++ .../src/createPasswordIdentitySchema.ts | 29 +++++ libs/password-authentication/src/index.ts | 0 libs/password-authentication/tsconfig.json | 13 +++ .../password-authentication/tsconfig.lib.json | 14 +++ .../tsconfig.spec.json | 13 +++ nx.json | 5 + package.json | 4 +- pnpm-lock.yaml | 20 ++++ pnpm-workspace.yaml | 5 +- tsconfig.json | 3 + 20 files changed, 369 insertions(+), 45 deletions(-) create mode 100644 libs/password-authentication/.spec.swcrc create mode 100644 libs/password-authentication/README.md create mode 100644 libs/password-authentication/eslint.config.mjs create mode 100644 libs/password-authentication/jest.config.ts create mode 100644 libs/password-authentication/package.json create mode 100644 libs/password-authentication/src/__tests__/createPasswordIdentitySchema.test.ts create mode 100644 libs/password-authentication/src/createPasswordIdentitySchema.ts create mode 100644 libs/password-authentication/src/index.ts create mode 100644 libs/password-authentication/tsconfig.json create mode 100644 libs/password-authentication/tsconfig.lib.json create mode 100644 libs/password-authentication/tsconfig.spec.json diff --git a/.ai/shared/architectural-context.md b/.ai/shared/architectural-context.md index 4074a78..8e163c0 100644 --- a/.ai/shared/architectural-context.md +++ b/.ai/shared/architectural-context.md @@ -38,7 +38,6 @@ This repository treats AI prompts and context as code. The AI agents are expecte - **No Java Conventions**: Avoid "I" prefix for interfaces - types should be intuitively expressive - **TypeScript Native**: Use TypeScript conventions and patterns - ## ๐Ÿงช Testing Philosophy - TDD-first: tests must be generated before implementations. @@ -86,6 +85,7 @@ Following **convention over configuration** principles, the library should: - **Error guidance**: Clear error messages when configuration is required or invalid Example initialization patterns: + ```typescript // Zero config - uses all defaults const auth = boosterAuth(); @@ -96,7 +96,7 @@ const auth = boosterAuth({ }); // Advanced config - full control when needed -const auth = createAuth({ +const auth = boosterAuth({ session: { store: new RedisSessionStore() }, password: { hashing: new ArgonHashing() } }); diff --git a/.ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md b/.ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md index 66e9ee7..1891671 100644 --- a/.ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md +++ b/.ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md @@ -181,34 +181,49 @@ export const simplePasswordSchema: PasswordSchema = z.string() .min(6, 'Password must be at least 6 characters'); ``` -### Shared Authentication Schemas +### Password Identity Schema Factory ```typescript -// @booster-auth/password-auth/schemas +// @booster-auth/password-authentication import { z } from 'zod'; -import { defaultPasswordSchema, PasswordSchema } from '@booster-auth/password-policy'; -export const createPasswordAuthSchemas = (passwordSchema: PasswordSchema = defaultPasswordSchema) => { - const signUpInputSchema = z.object({ - email: z.string().email('Invalid email format'), - password: passwordSchema, +export type IdentifierType = 'email' | 'username' | 'phone'; +export type IdentifierArray = readonly IdentifierType[]; + +// Schema builder with compile-time knowledge +export const createPasswordIdentitySchema = ( + identifiers: T, + schema?: { + email?: z.ZodString; + username?: z.ZodString; + phone?: z.ZodString; + } +) => { + const base = z.object({ + password: z.string(), passwordConfirmation: z.string(), - }).refine((data) => data.password === data.passwordConfirmation, { - message: 'Passwords do not match', - path: ['passwordConfirmation'], }); - const signUpOutputSchema = z.object({ - success: z.boolean(), - userId: z.string().optional(), - error: z.string().optional(), - }); + const hasEmail = identifiers.includes('email'); + const hasUsername = identifiers.includes('username'); + const hasPhone = identifiers.includes('phone'); - return { - signUpInput: signUpInputSchema, - signUpOutput: signUpOutputSchema, - }; + return base.extend({ + ...(hasEmail && { email: schema?.email || z.string().email() }), + ...(hasUsername && { username: schema?.username || z.string() }), + ...(hasPhone && { phone: schema?.phone || z.string().regex(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number' }) }), + }); }; + +// Usage examples: +const emailOnlySchema = createPasswordIdentitySchema(['email'] as const); +const phoneOnlySchema = createPasswordIdentitySchema(['phone'] as const); +const emailUsernameSchema = createPasswordIdentitySchema(['email', 'username'] as const, { + username: z.string().min(3).max(20) +}); +const allIdentifiersSchema = createPasswordIdentitySchema(['email', 'username', 'phone'] as const, { + phone: z.string().regex(/^\+1[0-9]{10}$/, { message: 'Must be US phone number with +1' }) +}); ``` ### tRPC Router Implementation @@ -217,19 +232,31 @@ export const createPasswordAuthSchemas = (passwordSchema: PasswordSchema = defau // @booster-auth/trpc/password-auth import { router, publicProcedure } from '@trpc/server'; import { TRPCError } from '@trpc/server'; -import { createPasswordAuthSchemas } from '@booster-auth/password-auth/schemas'; -import { defaultPasswordSchema, PasswordSchema } from '@booster-auth/password-policy'; +import { createPasswordIdentitySchema } from '@booster-auth/password-authentication'; +import { z } from 'zod'; -export const createPasswordAuthRouter = (passwordSchema: PasswordSchema = defaultPasswordSchema) => { - const schemas = createPasswordAuthSchemas(passwordSchema); +export const createPasswordAuthRouter = ( + identifiers: readonly ('email' | 'username' | 'phone')[], + customSchemas?: { + email?: z.ZodString; + username?: z.ZodString; + phone?: z.ZodString; + password?: z.ZodString; + } +) => { + const identitySchema = createPasswordIdentitySchema(identifiers, { + email: customSchemas?.email, + username: customSchemas?.username, + phone: customSchemas?.phone, + }).extend({ + password: customSchemas?.password || z.string().min(8), + }); return router({ signUp: publicProcedure - .input(schemas.signUpInput) - .output(schemas.signUpOutput) + .input(identitySchema) .mutation(async ({ input, ctx }) => { const passwordAuthService = ctx.passwordAuthService; - const result = await passwordAuthService.signUp(input); if (!result.success) { @@ -245,13 +272,17 @@ export const createPasswordAuthRouter = (passwordSchema: PasswordSchema = defaul }; // Usage examples -const customPasswordSchema = z.string() - .min(10) - .refine((password) => !password.includes('password'), { - message: 'Password cannot contain the word "password"', - }); - -export const customPasswordAuthRouter = createPasswordAuthRouter(customPasswordSchema); +export const emailOnlyRouter = createPasswordAuthRouter(['email'] as const); +export const phoneOnlyRouter = createPasswordAuthRouter(['phone'] as const); +export const emailUsernameRouter = createPasswordAuthRouter(['email', 'username'] as const, { + username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/), + password: z.string().min(12) +}); +export const allIdentifiersRouter = createPasswordAuthRouter(['email', 'username', 'phone'] as const, { + phone: z.string().regex(/^\+1[0-9]{10}$/, { message: 'Must be US phone number' }), + username: z.string().min(3).max(20), + password: z.string().min(12) +}); ``` ### Architectural Considerations @@ -313,6 +344,7 @@ export const customPasswordAuthRouter = createPasswordAuthRouter(customPasswordS ## Notes ### **Current Scope** + - This story focuses on core sign-up functionality with tRPC integration - Email verification will be addressed in separate story - Session creation after sign-up is handled by sign-in flow @@ -320,11 +352,13 @@ export const customPasswordAuthRouter = createPasswordAuthRouter(customPasswordS - **Rate limiting** is handled in separate user story (20250709_rate-limiting.md) ### **Business Impact** + - **Developer Productivity**: 5-minute integration saves 2-3 days of auth implementation - **Security Posture**: Enterprise-grade security reduces compliance risk - **Time-to-Market**: Faster auth implementation accelerates product launches ### **Technical Decisions** + - tRPC router is designed to be mountable to existing applications - Future support for REST and GraphQL will follow similar patterns - Business logic remains transport-agnostic for maximum flexibility diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 24a3ded..a561d26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,9 +16,7 @@ jobs: steps: - uses: actions/checkout@v4 with: - filter: tree:0 fetch-depth: 0 - - uses: pnpm/action-setup@v4 name: Install pnpm with: @@ -38,8 +36,7 @@ jobs: - run: pnpm install --frozen-lockfile - uses: nrwl/nx-set-shas@v4 - - # Prepend any command with "nx-cloud record --" to record its logs to Nx Cloud - # - run: pnpm exec nx-cloud record -- echo Hello World - # Nx Affected runs only tasks affected by the changes in this PR/commit. Learn more: https://nx.dev/ci/features/affected - - run: pnpm exec nx affected -t lint test build + id: set-shas + with: + main-branch-name: main + - run: pnpm exec nx affected -t lint test build --base=${{ steps.set-shas.outputs.base }} --head=${{ steps.set-shas.outputs.head }} --parallel --maxParallel=3 --skip-nx-cache diff --git a/.prettierrc b/.prettierrc index d7a7b09..743a5fc 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,6 @@ { "singleQuote": true, + "semi": false, "proseWrap": "always", "printWidth": 120 } diff --git a/libs/password-authentication/.spec.swcrc b/libs/password-authentication/.spec.swcrc new file mode 100644 index 0000000..3b52a53 --- /dev/null +++ b/libs/password-authentication/.spec.swcrc @@ -0,0 +1,22 @@ +{ + "jsc": { + "target": "es2017", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "decoratorMetadata": true, + "legacyDecorator": true + }, + "keepClassNames": true, + "externalHelpers": true, + "loose": true + }, + "module": { + "type": "es6" + }, + "sourceMaps": true, + "exclude": [] +} diff --git a/libs/password-authentication/README.md b/libs/password-authentication/README.md new file mode 100644 index 0000000..6781358 --- /dev/null +++ b/libs/password-authentication/README.md @@ -0,0 +1,11 @@ +# password-auth + +This library was generated with [Nx](https://nx.dev). + +## Building + +Run `nx build password-auth` to build the library. + +## Running unit tests + +Run `nx test password-auth` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/password-authentication/eslint.config.mjs b/libs/password-authentication/eslint.config.mjs new file mode 100644 index 0000000..c334bc0 --- /dev/null +++ b/libs/password-authentication/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from '../../eslint.config.mjs'; + +export default [ + ...baseConfig, + { + files: ['**/*.json'], + rules: { + '@nx/dependency-checks': [ + 'error', + { + ignoredFiles: ['{projectRoot}/eslint.config.{js,cjs,mjs,ts,cts,mts}'], + }, + ], + }, + languageOptions: { + parser: await import('jsonc-eslint-parser'), + }, + }, +]; diff --git a/libs/password-authentication/jest.config.ts b/libs/password-authentication/jest.config.ts new file mode 100644 index 0000000..72a0703 --- /dev/null +++ b/libs/password-authentication/jest.config.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +import { readFileSync } from 'fs'; + +// Reading the SWC compilation config for the spec files +const swcJestConfig = JSON.parse(readFileSync(`${__dirname}/.spec.swcrc`, 'utf-8')); + +// Disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves +swcJestConfig.swcrc = false; + +export default { + displayName: '@booster-auth/password-auth', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['@swc/jest', swcJestConfig], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: 'test-output/jest/coverage', +}; diff --git a/libs/password-authentication/package.json b/libs/password-authentication/package.json new file mode 100644 index 0000000..a9e89c7 --- /dev/null +++ b/libs/password-authentication/package.json @@ -0,0 +1,21 @@ +{ + "name": "@booster-auth/password-authentication", + "version": "0.0.1", + "private": true, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "dependencies": { + "tslib": "^2.3.0", + "zod": "^4.0.5" + } +} diff --git a/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.test.ts b/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.test.ts new file mode 100644 index 0000000..c19899d --- /dev/null +++ b/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.test.ts @@ -0,0 +1,100 @@ +import z from 'zod' +import { createPasswordIdentitySchema } from '../createPasswordIdentitySchema.js' + +describe('createPasswordIdentitySchema', () => { + describe('email only', () => { + it('defines an email property', () => { + const schema = createPasswordIdentitySchema(['email']) + expect(schema.shape).toHaveProperty('email') + expect(schema.shape.email).toBeDefined() + }) + + it('does not define a username property', () => { + const schema = createPasswordIdentitySchema(['email']) + expect(schema.shape).not.toHaveProperty('username') + }) + }) + + describe('username only', () => { + it('defines a username property', () => { + const schema = createPasswordIdentitySchema(['username']) + expect(schema.shape).toHaveProperty('username') + expect(schema.shape.username).toBeDefined() + }) + + it('does not define an email property', () => { + const schema = createPasswordIdentitySchema(['username']) + expect(schema.shape).not.toHaveProperty('email') + }) + }) + + describe('phone only', () => { + it('defines a phone property', () => { + const schema = createPasswordIdentitySchema(['phone']) + expect(schema.shape).toHaveProperty('phone') + expect(schema.shape.phone).toBeDefined() + }) + + it('does not define email or username properties', () => { + const schema = createPasswordIdentitySchema(['phone']) + expect(schema.shape).not.toHaveProperty('email') + expect(schema.shape).not.toHaveProperty('username') + }) + }) + + describe('multiple identifiers', () => { + it('defines both email and username properties', () => { + const schema = createPasswordIdentitySchema(['email', 'username']) + expect(schema.shape).toHaveProperty('email') + expect(schema.shape.email).toBeDefined() + expect(schema.shape).toHaveProperty('username') + expect(schema.shape.username).toBeDefined() + }) + + it('defines email and phone properties', () => { + const schema = createPasswordIdentitySchema(['email', 'phone']) + expect(schema.shape).toHaveProperty('email') + expect(schema.shape).toHaveProperty('phone') + expect(schema.shape).not.toHaveProperty('username') + }) + + it('defines all three identifier properties', () => { + const schema = createPasswordIdentitySchema(['email', 'username', 'phone']) + expect(schema.shape).toHaveProperty('email') + expect(schema.shape).toHaveProperty('username') + expect(schema.shape).toHaveProperty('phone') + }) + }) + + describe('with custom schemas', () => { + it('uses custom email schema if provided', () => { + const customEmailSchema = { email: z.string().min(5) } + const schema = createPasswordIdentitySchema(['email'], customEmailSchema) + expect(schema.shape.email).toEqual(customEmailSchema.email) + }) + + it('uses custom username schema if provided', () => { + const customUsernameSchema = { username: z.string().min(3) } + const schema = createPasswordIdentitySchema(['username'], customUsernameSchema) + expect(schema.shape.username).toEqual(customUsernameSchema.username) + }) + + it('uses custom phone schema if provided', () => { + const customPhoneSchema = { phone: z.string().min(10) } + const schema = createPasswordIdentitySchema(['phone'], customPhoneSchema) + expect(schema.shape.phone).toEqual(customPhoneSchema.phone) + }) + + it('uses multiple custom schemas', () => { + const customSchemas = { + email: z.string().min(5), + phone: z.string().min(10), + username: z.string().min(3) + } + const schema = createPasswordIdentitySchema(['email', 'phone', 'username'], customSchemas) + expect(schema.shape.email).toEqual(customSchemas.email) + expect(schema.shape.phone).toEqual(customSchemas.phone) + expect(schema.shape.username).toEqual(customSchemas.username) + }) + }) +}) diff --git a/libs/password-authentication/src/createPasswordIdentitySchema.ts b/libs/password-authentication/src/createPasswordIdentitySchema.ts new file mode 100644 index 0000000..be78eb9 --- /dev/null +++ b/libs/password-authentication/src/createPasswordIdentitySchema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +export type IdentifierType = 'email' | 'username' | 'phone'; +export type IdentifierArray = readonly IdentifierType[]; + +// Schema builder with compile-time knowledge +export const createPasswordIdentitySchema = ( + identifiers: T, + schema?: { + email?: z.ZodString; + username?: z.ZodString; + phone?: z.ZodString; + } +) => { + const base = z.object({ + password: z.string(), + passwordConfirmation: z.string(), + }); + + const hasEmail = identifiers.includes('email'); + const hasUsername = identifiers.includes('username'); + const hasPhone = identifiers.includes('phone'); + + return base.extend({ + ...(hasEmail && { email: schema?.email || z.string().email() }), + ...(hasUsername && { username: schema?.username || z.string() }), + ...(hasPhone && { phone: schema?.phone || z.string().regex(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number' }) }), + }); +}; diff --git a/libs/password-authentication/src/index.ts b/libs/password-authentication/src/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/libs/password-authentication/tsconfig.json b/libs/password-authentication/tsconfig.json new file mode 100644 index 0000000..62ebbd9 --- /dev/null +++ b/libs/password-authentication/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/password-authentication/tsconfig.lib.json b/libs/password-authentication/tsconfig.lib.json new file mode 100644 index 0000000..5e98972 --- /dev/null +++ b/libs/password-authentication/tsconfig.lib.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "references": [], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/password-authentication/tsconfig.spec.json b/libs/password-authentication/tsconfig.spec.json new file mode 100644 index 0000000..c67ac29 --- /dev/null +++ b/libs/password-authentication/tsconfig.spec.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./out-tsc/jest", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/nx.json b/nx.json index 91fe706..4741d24 100644 --- a/nx.json +++ b/nx.json @@ -52,6 +52,11 @@ }, "test": { "dependsOn": ["^build"] + }, + "@nx/js:tsc": { + "cache": true, + "dependsOn": ["^build"], + "inputs": ["production", "^production"] } } } diff --git a/package.json b/package.json index 08068d2..522de7d 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "private": true, "dependencies": { "axios": "^1.6.0", - "express": "^4.21.2" + "express": "^4.21.2", + "zod": "^4.0.5" }, "devDependencies": { "@eslint/js": "^9.8.0", @@ -29,6 +30,7 @@ "eslint-config-prettier": "^10.0.0", "jest": "^29.7.0", "jest-environment-node": "^29.7.0", + "jsonc-eslint-parser": "^2.1.0", "nx": "21.2.2", "prettier": "^2.6.2", "ts-jest": "^29.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d72f5cc..c21f298 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: express: specifier: ^4.21.2 version: 4.21.2 + zod: + specifier: ^4.0.5 + version: 4.0.5 devDependencies: '@eslint/js': specifier: ^9.8.0 @@ -75,6 +78,9 @@ importers: jest-environment-node: specifier: ^29.7.0 version: 29.7.0 + jsonc-eslint-parser: + specifier: ^2.1.0 + version: 2.4.0 nx: specifier: 21.2.2 version: 21.2.2(@swc-node/register@1.9.2(@swc/core@1.5.29(@swc/helpers@0.5.17))(@swc/types@0.1.23)(typescript@5.8.3))(@swc/core@1.5.29(@swc/helpers@0.5.17)) @@ -101,6 +107,15 @@ importers: apps/auth-backend-example-e2e: {} + libs/password-authentication: + dependencies: + tslib: + specifier: ^2.3.0 + version: 2.8.1 + zod: + specifier: ^4.0.5 + version: 4.0.5 + packages: '@ampproject/remapping@2.3.0': @@ -3199,6 +3214,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zod@4.0.5: + resolution: {integrity: sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==} + snapshots: '@ampproject/remapping@2.3.0': @@ -6884,3 +6902,5 @@ snapshots: yn@3.1.1: {} yocto-queue@0.1.0: {} + + zod@4.0.5: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 22adb69..e7448ac 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,2 +1,3 @@ -packages: - - "apps/*" +packages: + - 'apps/*' + - 'libs/password-authentication' diff --git a/tsconfig.json b/tsconfig.json index b4f6f9c..8d86dac 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,9 @@ }, { "path": "./apps/auth-backend-example" + }, + { + "path": "./libs/password-authentication" } ] } From a331b12d3357197bb3d4cb04466c07a5ab552df4 Mon Sep 17 00:00:00 2001 From: Dan Pickett Date: Sun, 13 Jul 2025 10:01:03 -0400 Subject: [PATCH 2/3] reorganize and refine prd for additional context --- .vscode/settings.json | 5 +- CLAUDE.md | 13 ++++ {.ai/shared => brain}/agents/pm.md | 0 .../shared => brain}/architectural-context.md | 0 ...20250709_traditional-web-authentication.md | 0 {.ai/shared => brain}/library-prd.md | 61 +++++++++++++++++++ {.ai/shared => brain}/system-prompt.md | 0 .../20250709_rate-limiting.md | 0 .../20250709_user-sign-up.md | 0 {.ai/shared => brain}/web/system-prompt.md | 0 10 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 CLAUDE.md rename {.ai/shared => brain}/agents/pm.md (100%) rename {.ai/shared => brain}/architectural-context.md (100%) rename {.ai/shared => brain}/epics/20250709_traditional-web-authentication.md (100%) rename {.ai/shared => brain}/library-prd.md (66%) rename {.ai/shared => brain}/system-prompt.md (100%) rename {.ai/shared => brain}/user-stories/traditional-web-authentication/20250709_rate-limiting.md (100%) rename {.ai/shared => brain}/user-stories/traditional-web-authentication/20250709_user-sign-up.md (100%) rename {.ai/shared => brain}/web/system-prompt.md (100%) diff --git a/.vscode/settings.json b/.vscode/settings.json index 8e21401..fab946f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,6 @@ { - "nxConsole.generateAiAgentRules": true + "nxConsole.generateAiAgentRules": true, + "cSpell.words": [ + "Multitenancy" + ] } diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..654315a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,13 @@ +# CLAUDE.md + +This is an open source, Node and React-based library that is meant to be used as an exploration in AI assisted coding. + +It uses a central `./brain` directory that should be considered the main source of project context. + +## Key Files + +- `brain/architectural-context.md`: Describes the overall architecture of the system. +- `brain/library-prd.md`: Contains product requirements for the library. +- `brain/epics/`: Directory containing epic specifications. +- `brain/user-stories/`: Directory containing user story specifications. +- `brain/active-context.md`: It tracks what you are doing right now and what's next. diff --git a/.ai/shared/agents/pm.md b/brain/agents/pm.md similarity index 100% rename from .ai/shared/agents/pm.md rename to brain/agents/pm.md diff --git a/.ai/shared/architectural-context.md b/brain/architectural-context.md similarity index 100% rename from .ai/shared/architectural-context.md rename to brain/architectural-context.md diff --git a/.ai/shared/epics/20250709_traditional-web-authentication.md b/brain/epics/20250709_traditional-web-authentication.md similarity index 100% rename from .ai/shared/epics/20250709_traditional-web-authentication.md rename to brain/epics/20250709_traditional-web-authentication.md diff --git a/.ai/shared/library-prd.md b/brain/library-prd.md similarity index 66% rename from .ai/shared/library-prd.md rename to brain/library-prd.md index 3cc54ee..bf1a990 100644 --- a/.ai/shared/library-prd.md +++ b/brain/library-prd.md @@ -70,6 +70,67 @@ Different email providers should be considered. Basic SMTP support is required, Upon resetting their password, the user should receive a confirmation email indicating that their password has been successfully changed. This email should not contain the new password but simply confirm the change and offer an opportunity to take action if the user did not initiate the change. +### Magic Links + +Users can sign in using magic links sent to their email address. When a user requests a magic link, the system generates a secure, time-limited token and sends it via email. Clicking the link authenticates the user without requiring a password. + +Magic links should: + +- Expire after a configurable time period (default 15 minutes) +- Be single-use only (invalidated after successful authentication) +- Include CSRF protection to prevent misuse +- Optionally support custom redirect URLs after successful authentication + +The system should rate-limit magic link requests to prevent abuse and protect against email flooding attacks. + +### OAuth / OIDC Providers + +Users can authenticate using external OAuth 2.0 and OpenID Connect (OIDC) providers such as Google, GitHub, Microsoft, and custom enterprise providers. The system should support the standard OAuth 2.0 authorization code flow with PKCE for enhanced security. + +OAuth/OIDC integration should include: + +- Support for multiple concurrent providers (users can link multiple accounts) +- Automatic user profile synchronization from provider claims +- Configurable scopes and permissions per provider +- Secure state parameter validation to prevent CSRF attacks +- Support for custom provider configurations (client ID, secret, endpoints) +- Proper token refresh handling for long-lived sessions +- Account linking capabilities (associate OAuth accounts with existing email/password accounts) + +The system should handle edge cases such as: + +- Provider account email changes +- Revoked provider access +- Provider service outages (graceful fallback to other authentication methods) +- Duplicate accounts across providers with the same email address + +### Organization Support (Multitenancy) + +The system supports organizations as a configurable multitenancy feature (enabled by default). Organizations allow grouping users and isolating data, permissions, and authentication settings per tenant. + +When organization support is enabled: + +- Users belong to one or more organizations +- Authentication can be scoped to specific organizations +- Each organization can have independent configuration (password policies, OAuth providers, branding) +- Users can switch between organizations they belong to within the same session +- Organization-specific user directories and permissions +- Support for organization invitations and user provisioning +- Organization admins can manage users, settings, and authentication methods + +Organization features include: + +- Configurable organization creation (self-service vs admin-only) +- Organization-specific rate limiting and security policies + +When organization support is disabled: + +- All users exist in a single global tenant +- Authentication settings apply globally +- Simplified data model without organization isolation + +The organization feature can be toggled via configuration, allowing developers to choose between single-tenant and multi-tenant architectures based on their needs. + ### Sign Out An authenticated user can sign out by terminating their session. The system should invalidate the user's session token, preventing further access to protected resources until the user signs in again. Systemically, the user should be forgotten until they sign in again, at which point a new session is created. diff --git a/.ai/shared/system-prompt.md b/brain/system-prompt.md similarity index 100% rename from .ai/shared/system-prompt.md rename to brain/system-prompt.md diff --git a/.ai/shared/user-stories/traditional-web-authentication/20250709_rate-limiting.md b/brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md similarity index 100% rename from .ai/shared/user-stories/traditional-web-authentication/20250709_rate-limiting.md rename to brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md diff --git a/.ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md b/brain/user-stories/traditional-web-authentication/20250709_user-sign-up.md similarity index 100% rename from .ai/shared/user-stories/traditional-web-authentication/20250709_user-sign-up.md rename to brain/user-stories/traditional-web-authentication/20250709_user-sign-up.md diff --git a/.ai/shared/web/system-prompt.md b/brain/web/system-prompt.md similarity index 100% rename from .ai/shared/web/system-prompt.md rename to brain/web/system-prompt.md From 545d2a14e429886df344d6108b526c05cd65cb53 Mon Sep 17 00:00:00 2001 From: Dan Pickett Date: Fri, 8 Aug 2025 22:20:25 -0400 Subject: [PATCH 3/3] implement identity schema with tunable password policy --- CLAUDE.md | 19 ++ brain/active-context.md | 32 +++ .../0001.traditional-web-authentication.md} | 0 .../0001.user-sign-up.md} | 0 .../20250709_rate-limiting.md | 206 -------------- ...PasswordIdentitySchema.integration.test.ts | 266 ++++++++++++++++++ .../src/createPasswordIdentitySchema.ts | 43 +-- .../src/defaultPhoneNumberRegex.ts | 1 + libs/password-authentication/src/index.ts | 2 + .../PasswordPolicyConfiguration.ts | 49 ++++ .../createPasswordPolicySchema.test.ts | 172 +++++++++++ .../createPasswordPolicySchema.ts | 51 ++++ .../defaultPasswordPolicyConfiguration.ts | 20 ++ .../src/password-policy/index.ts | 3 + 14 files changed, 640 insertions(+), 224 deletions(-) create mode 100644 brain/active-context.md rename brain/{epics/20250709_traditional-web-authentication.md => requirements/epics/0001.traditional-web-authentication.md} (100%) rename brain/{user-stories/traditional-web-authentication/20250709_user-sign-up.md => requirements/user-stories/0001.traditional-web-authentication/0001.user-sign-up.md} (100%) delete mode 100644 brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md create mode 100644 libs/password-authentication/src/__tests__/createPasswordIdentitySchema.integration.test.ts create mode 100644 libs/password-authentication/src/defaultPhoneNumberRegex.ts create mode 100644 libs/password-authentication/src/password-policy/PasswordPolicyConfiguration.ts create mode 100644 libs/password-authentication/src/password-policy/__tests__/createPasswordPolicySchema.test.ts create mode 100644 libs/password-authentication/src/password-policy/createPasswordPolicySchema.ts create mode 100644 libs/password-authentication/src/password-policy/defaultPasswordPolicyConfiguration.ts create mode 100644 libs/password-authentication/src/password-policy/index.ts diff --git a/CLAUDE.md b/CLAUDE.md index 654315a..9573ad4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,16 @@ This is an open source, Node and React-based library that is meant to be used as It uses a central `./brain` directory that should be considered the main source of project context. +## Writing Markdown + +When writing markdown, please use the following conventions: + +- Use headings (`#`, `##`, `###`) to organize content hierarchically. +- Use bullet points for lists and keep them concise. +- Use code blocks (```) for code snippets and examples. Be sure to include the language for syntax highlighting. +- Use links to reference other documents or resources. +- Adhere to prettier markdown rules for formatting consistency. + ## Key Files - `brain/architectural-context.md`: Describes the overall architecture of the system. @@ -11,3 +21,12 @@ It uses a central `./brain` directory that should be considered the main source - `brain/epics/`: Directory containing epic specifications. - `brain/user-stories/`: Directory containing user story specifications. - `brain/active-context.md`: It tracks what you are doing right now and what's next. + +## Testing Conventions + +When writing tests, use assertive language in test descriptions: +- Use `it('returns a ZodString type')` instead of `it('should return ZodString type')` +- Use `it('validates password length')` instead of `it('should validate password length')` +- Use `it('throws an error when invalid')` instead of `it('should throw an error when invalid')` + +Tests describe what the code does, not what it should do. diff --git a/brain/active-context.md b/brain/active-context.md new file mode 100644 index 0000000..d18344a --- /dev/null +++ b/brain/active-context.md @@ -0,0 +1,32 @@ +# Active Context + +## Current Focus: Password Policy Implementation + +**User Story**: 20250709_user-sign-up.md - User Sign Up +**Epic**: Traditional Web-Based Authentication +**Priority**: High +**Story Points**: 8 + +### Specific Task + +Working on the **password policy aspect** of the user sign-up story, specifically implementing configurable password validation schemas as outlined in the Password Policy Library section. + +### Key Implementation Areas + +- Password policy validation using Zod schemas +- Configurable password requirements (length, complexity) +- Multiple password schema options (default, strict, simple) +- Integration with the password identity schema factory + +### Next Steps + +1. Implement the password policy library with configurable schemas +2. Create tests for password validation scenarios +3. Integrate with the existing password authentication system +4. Ensure password policies work with the identity schema factory + +### Related Files + +- `libs/password-policy/` - Target implementation directory +- `libs/password-authentication/` - Existing password auth library +- User story: `brain/requirements/user-stories/0001.traditional-web-authentication/0001.user-sign-up.md` diff --git a/brain/epics/20250709_traditional-web-authentication.md b/brain/requirements/epics/0001.traditional-web-authentication.md similarity index 100% rename from brain/epics/20250709_traditional-web-authentication.md rename to brain/requirements/epics/0001.traditional-web-authentication.md diff --git a/brain/user-stories/traditional-web-authentication/20250709_user-sign-up.md b/brain/requirements/user-stories/0001.traditional-web-authentication/0001.user-sign-up.md similarity index 100% rename from brain/user-stories/traditional-web-authentication/20250709_user-sign-up.md rename to brain/requirements/user-stories/0001.traditional-web-authentication/0001.user-sign-up.md diff --git a/brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md b/brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md deleted file mode 100644 index c8b96a5..0000000 --- a/brain/user-stories/traditional-web-authentication/20250709_rate-limiting.md +++ /dev/null @@ -1,206 +0,0 @@ -# User Story: Rate Limiting for Authentication - -**Epic**: Traditional Web-Based Authentication -**Story ID**: 20250709_rate-limiting -**Priority**: High -**Story Points**: 5 - -## User Story -**As a** developer integrating BoosterAuth into my application -**I want** configurable rate limiting across all authentication endpoints -**So that** my application is protected from brute force attacks and abuse while maintaining good user experience - -## Acceptance Criteria - -### Functional Requirements -- [ ] **Configurable Limits**: Rate limits configurable per endpoint type (sign-up, sign-in, password reset) -- [ ] **Multiple Strategies**: Support IP-based, user-based, and global rate limiting -- [ ] **Transport Agnostic**: Works across tRPC, REST, and GraphQL implementations -- [ ] **Graceful Degradation**: Clear error messages with retry timing information -- [ ] **Bypass Mechanisms**: Allow whitelisting of trusted IPs or user agents -- [ ] **Sliding Window**: Implement sliding window algorithm for fair rate limiting - -### Technical Requirements -- [ ] **Core Interface**: Define rate limiting interface in `@booster-auth/core` -- [ ] **Multiple Implementations**: Memory-based and Redis-based rate limiters -- [ ] **Middleware Integration**: Easy integration with tRPC, Express, and GraphQL -- [ ] **Configurable Storage**: Pluggable storage backends for rate limit data -- [ ] **Monitoring Support**: Metrics and logging for rate limit events -- [ ] **TypeScript**: Full type safety for all rate limiting configurations - -### Security Requirements -- [ ] **Attack Prevention**: Prevent brute force attacks on authentication endpoints -- [ ] **DDoS Protection**: Protect against distributed denial of service attacks -- [ ] **Enumeration Protection**: Rate limit email validation to prevent user enumeration -- [ ] **Adaptive Limits**: Optionally increase limits for verified users -- [ ] **Audit Logging**: Log all rate limit violations with context - -## Technical Implementation - -### Package Structure -```no-highlight -libs/ -โ”œโ”€โ”€ rate-limiting/ # Core rate limiting logic -โ”‚ โ””โ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ interfaces/ -โ”‚ โ”‚ โ”œโ”€โ”€ RateLimiter.ts -โ”‚ โ”‚ โ””โ”€โ”€ RateLimitStore.ts -โ”‚ โ”œโ”€โ”€ strategies/ -โ”‚ โ”‚ โ”œโ”€โ”€ SlidingWindowLimiter.ts -โ”‚ โ”‚ โ””โ”€โ”€ TokenBucketLimiter.ts -โ”‚ โ”œโ”€โ”€ stores/ -โ”‚ โ”‚ โ”œโ”€โ”€ MemoryRateLimitStore.ts -โ”‚ โ”‚ โ””โ”€โ”€ RedisRateLimitStore.ts -โ”‚ โ””โ”€โ”€ __tests__/ -โ”‚ โ””โ”€โ”€ RateLimiter.test.ts -โ”œโ”€โ”€ trpc/ -โ”‚ โ””โ”€โ”€ rate-limiting/ # tRPC middleware -โ”‚ โ””โ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ middleware/ -โ”‚ โ”‚ โ””โ”€โ”€ rateLimitMiddleware.ts -โ”‚ โ””โ”€โ”€ __tests__/ -โ”‚ โ””โ”€โ”€ rateLimitMiddleware.test.ts -โ”œโ”€โ”€ express/ -โ”‚ โ””โ”€โ”€ rate-limiting/ # Express middleware (future) -โ”‚ โ””โ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ middleware/ -โ”‚ โ”‚ โ””โ”€โ”€ rateLimitMiddleware.ts -โ”‚ โ””โ”€โ”€ __tests__/ -โ”‚ โ””โ”€โ”€ rateLimitMiddleware.test.ts -โ””โ”€โ”€ graphql/ - โ””โ”€โ”€ rate-limiting/ # GraphQL middleware (future) - โ””โ”€โ”€ src/ - โ”œโ”€โ”€ middleware/ - โ”‚ โ””โ”€โ”€ rateLimitMiddleware.ts - โ””โ”€โ”€ __tests__/ - โ””โ”€โ”€ rateLimitMiddleware.test.ts -``` - -### Core Interfaces -```typescript -// @booster-auth/core -export interface RateLimiter { - checkLimit(key: string, limit: number, windowMs: number): Promise; - reset(key: string): Promise; -} - -export interface RateLimitResult { - allowed: boolean; - remaining: number; - resetTime: Date; - retryAfter?: number; -} - -export interface RateLimitConfig { - maxAttempts: number; - windowMs: number; - keyGenerator: (req: any) => string; - skipSuccessfulRequests: boolean; - skipFailedRequests: boolean; -} -``` - -### tRPC Implementation -```typescript -// @booster-auth/trpc/rate-limiting -import { TRPCError } from '@trpc/server'; -import { RateLimiter, RateLimitConfig } from '@booster-auth/core'; - -export const createRateLimitMiddleware = ( - rateLimiter: RateLimiter, - config: RateLimitConfig -) => { - return async ({ ctx, next, type, path }) => { - const key = config.keyGenerator(ctx); - const result = await rateLimiter.checkLimit( - key, - config.maxAttempts, - config.windowMs - ); - - if (!result.allowed) { - throw new TRPCError({ - code: 'TOO_MANY_REQUESTS', - message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`, - }); - } - - // Add rate limit info to context - ctx.rateLimitInfo = { - remaining: result.remaining, - resetTime: result.resetTime, - }; - - return next(); - }; -}; -``` - -### Usage Example -```typescript -// Example: Apply rate limiting to password auth router -import { createRateLimitMiddleware } from '@booster-auth/trpc/rate-limiting'; -import { MemoryRateLimitStore } from '@booster-auth/rate-limiting'; - -const rateLimiter = new MemoryRateLimitStore(); - -const authRateLimitConfig = { - maxAttempts: 5, - windowMs: 60 * 1000, // 1 minute - keyGenerator: (ctx) => ctx.req.ip, - skipSuccessfulRequests: false, - skipFailedRequests: false, -}; - -const rateLimitMiddleware = createRateLimitMiddleware(rateLimiter, authRateLimitConfig); - -export const passwordAuthRouter = router({ - signUp: publicProcedure - .use(rateLimitMiddleware) - .input(schemas.signUpInput) - .mutation(async ({ input, ctx }) => { - // Sign-up logic - }), -}); -``` - -## Data Flow -1. **Request Received**: Middleware intercepts request -2. **Key Generation**: Generate rate limit key (IP, user ID, etc.) -3. **Limit Check**: Check current usage against configured limits -4. **Decision**: Allow or reject request based on limits -5. **Response**: Return rate limit headers or error -6. **Cleanup**: Optional cleanup of expired rate limit data - -## Error Scenarios -- [ ] **Rate Limit Exceeded**: Clear error with retry timing -- [ ] **Storage Failure**: Graceful fallback behavior -- [ ] **Configuration Error**: Validation of rate limit configurations -- [ ] **Key Generation Failure**: Fallback key generation strategies - -## Definition of Done -- [ ] **Core Interfaces**: Rate limiting interfaces defined in core package -- [ ] **Memory Implementation**: In-memory rate limiter for development -- [ ] **Redis Implementation**: Redis-based rate limiter for production -- [ ] **tRPC Middleware**: Working middleware for tRPC procedures -- [ ] **Configuration**: Flexible configuration options -- [ ] **Testing**: Unit and integration tests with >90% coverage -- [ ] **Documentation**: Usage examples and configuration guide -- [ ] **Performance**: Rate limiting adds <10ms latency -- [ ] **Monitoring**: Metrics and logging for rate limit events - -## Dependencies -- Redis (optional, for distributed rate limiting) -- tRPC server -- Logging framework -- Metrics collection system - -## Notes -- This story focuses on tRPC implementation first -- REST and GraphQL implementations will follow similar patterns -- Storage backends are pluggable for different deployment scenarios -- Rate limiting is applied as middleware, not embedded in business logic -- Future enhancements: adaptive limits, machine learning-based detection - ---- -**Related Epic**: 20250709_traditional-web-authentication.md \ No newline at end of file diff --git a/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.integration.test.ts b/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.integration.test.ts new file mode 100644 index 0000000..1c4e328 --- /dev/null +++ b/libs/password-authentication/src/__tests__/createPasswordIdentitySchema.integration.test.ts @@ -0,0 +1,266 @@ +import { createPasswordIdentitySchema } from '../createPasswordIdentitySchema.js' +import { createPasswordPolicySchema } from '../password-policy/createPasswordPolicySchema.js' +import { z } from 'zod' + +describe('createPasswordIdentitySchema', () => { + describe('basic functionality', () => { + it('creates schema with email identifier', () => { + const schema = createPasswordIdentitySchema(['email'] as const) + + const validData = { + email: 'test@example.com', + password: 'Password123', + passwordConfirmation: 'Password123', + } + + expect(schema.safeParse(validData).success).toBe(true) + }) + + it('creates schema with username identifier', () => { + const schema = createPasswordIdentitySchema(['username'] as const) + + const validData = { + username: 'testuser', + password: 'Password123', + passwordConfirmation: 'Password123', + } + + expect(schema.safeParse(validData).success).toBe(true) + }) + + it('creates schema with phone identifier', () => { + const schema = createPasswordIdentitySchema(['phone'] as const) + + const validData = { + phone: '+1234567890', + password: 'Password123', + passwordConfirmation: 'Password123', + } + + expect(schema.safeParse(validData).success).toBe(true) + }) + + it('creates schema with multiple identifiers', () => { + const schema = createPasswordIdentitySchema(['email', 'username'] as const) + + const validData = { + email: 'test@example.com', + username: 'testuser', + password: 'Password123', + passwordConfirmation: 'Password123', + } + + expect(schema.safeParse(validData).success).toBe(true) + }) + + it('creates schema with all identifiers', () => { + const schema = createPasswordIdentitySchema(['email', 'username', 'phone'] as const) + + const validData = { + email: 'test@example.com', + username: 'testuser', + phone: '+1234567890', + password: 'Password123', + passwordConfirmation: 'Password123', + } + + expect(schema.safeParse(validData).success).toBe(true) + }) + }) + + describe('default password policy', () => { + it('enforces secure password defaults', () => { + const schema = createPasswordIdentitySchema(['email'] as const) + + // Too short + expect(schema.safeParse({ + email: 'test@example.com', + password: 'Pass1', + passwordConfirmation: 'Pass1', + }).success).toBe(false) + + // Missing uppercase + expect(schema.safeParse({ + email: 'test@example.com', + password: 'password123', + passwordConfirmation: 'password123', + }).success).toBe(false) + + // Valid password + expect(schema.safeParse({ + email: 'test@example.com', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + }) + + describe('custom schemas', () => { + it('accepts custom email schema', () => { + const customEmailSchema = z.string().email().refine( + email => email.endsWith('@company.com'), + { message: 'Must be a company email' } + ) + + const schema = createPasswordIdentitySchema(['email'] as const, { + email: customEmailSchema, + }) + + expect(schema.safeParse({ + email: 'test@gmail.com', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + expect(schema.safeParse({ + email: 'test@company.com', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('accepts custom username schema', () => { + const customUsernameSchema = z.string().min(5).max(15).regex(/^[a-zA-Z0-9_]+$/) + + const schema = createPasswordIdentitySchema(['username'] as const, { + username: customUsernameSchema, + }) + + expect(schema.safeParse({ + username: 'ab', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) // Too short + + expect(schema.safeParse({ + username: 'valid_user123', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('accepts custom phone schema', () => { + const customPhoneSchema = z.string().regex(/^\+1[0-9]{10}$/, { + message: 'Must be US phone number with +1', + }) + + const schema = createPasswordIdentitySchema(['phone'] as const, { + phone: customPhoneSchema, + }) + + expect(schema.safeParse({ + phone: '+44123456789', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) // UK number + + expect(schema.safeParse({ + phone: '+11234567890', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('accepts custom password schema', () => { + const customPasswordSchema = z.string().min(6).max(12) + + const schema = createPasswordIdentitySchema(['email'] as const, { + password: customPasswordSchema, + }) + + expect(schema.safeParse({ + email: 'test@example.com', + password: 'short', + passwordConfirmation: 'short', + }).success).toBe(false) // Too short + + expect(schema.safeParse({ + email: 'test@example.com', + password: 'verylongpassword', + passwordConfirmation: 'verylongpassword', + }).success).toBe(false) // Too long + + expect(schema.safeParse({ + email: 'test@example.com', + password: 'goodpass', + passwordConfirmation: 'goodpass', + }).success).toBe(true) + }) + }) + + describe('validation behavior', () => { + it('validates email format by default', () => { + const schema = createPasswordIdentitySchema(['email'] as const) + + expect(schema.safeParse({ + email: 'invalid-email', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + expect(schema.safeParse({ + email: 'valid@example.com', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('validates phone format by default', () => { + const schema = createPasswordIdentitySchema(['phone'] as const) + + expect(schema.safeParse({ + phone: 'not-a-phone', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + expect(schema.safeParse({ + phone: '+1234567890', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('requires all specified identifiers', () => { + const schema = createPasswordIdentitySchema(['email', 'username'] as const) + + // Missing username + expect(schema.safeParse({ + email: 'test@example.com', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + // Missing email + expect(schema.safeParse({ + username: 'testuser', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + // Both present + expect(schema.safeParse({ + email: 'test@example.com', + username: 'testuser', + password: 'Password123', + passwordConfirmation: 'Password123', + }).success).toBe(true) + }) + + it('always requires password and passwordConfirmation', () => { + const schema = createPasswordIdentitySchema(['email'] as const) + + // Missing password + expect(schema.safeParse({ + email: 'test@example.com', + passwordConfirmation: 'Password123', + }).success).toBe(false) + + // Missing passwordConfirmation + expect(schema.safeParse({ + email: 'test@example.com', + password: 'Password123', + }).success).toBe(false) + }) + }) +}) \ No newline at end of file diff --git a/libs/password-authentication/src/createPasswordIdentitySchema.ts b/libs/password-authentication/src/createPasswordIdentitySchema.ts index be78eb9..c3ad36c 100644 --- a/libs/password-authentication/src/createPasswordIdentitySchema.ts +++ b/libs/password-authentication/src/createPasswordIdentitySchema.ts @@ -1,29 +1,36 @@ -import { z } from 'zod'; +import { z } from 'zod' +import { defaultPhoneNumberRegex } from './defaultPhoneNumberRegex.js' +import { createPasswordPolicySchema } from './password-policy/createPasswordPolicySchema.js' +import { defaultPasswordPolicyConfiguration } from './password-policy/defaultPasswordPolicyConfiguration.js' -export type IdentifierType = 'email' | 'username' | 'phone'; -export type IdentifierArray = readonly IdentifierType[]; +export type IdentifierType = 'email' | 'username' | 'phone' +export type IdentifierArray = readonly IdentifierType[] -// Schema builder with compile-time knowledge -export const createPasswordIdentitySchema = ( +export function createPasswordIdentitySchema( identifiers: T, schema?: { - email?: z.ZodString; - username?: z.ZodString; - phone?: z.ZodString; + email?: z.ZodString + username?: z.ZodString + phone?: z.ZodString + password?: z.ZodString } -) => { +) { + const passwordSchema = schema?.password || createPasswordPolicySchema(defaultPasswordPolicyConfiguration) + const base = z.object({ - password: z.string(), + password: passwordSchema, passwordConfirmation: z.string(), - }); + }) - const hasEmail = identifiers.includes('email'); - const hasUsername = identifiers.includes('username'); - const hasPhone = identifiers.includes('phone'); + const hasEmail = identifiers.includes('email') + const hasUsername = identifiers.includes('username') + const hasPhone = identifiers.includes('phone') return base.extend({ - ...(hasEmail && { email: schema?.email || z.string().email() }), + ...(hasEmail && { email: schema?.email || z.email() }), ...(hasUsername && { username: schema?.username || z.string() }), - ...(hasPhone && { phone: schema?.phone || z.string().regex(/^\+?[1-9]\d{1,14}$/, { message: 'Invalid phone number' }) }), - }); -}; + ...(hasPhone && { + phone: schema?.phone || z.string().regex(defaultPhoneNumberRegex, { message: 'Invalid phone number' }), + }), + }) +} diff --git a/libs/password-authentication/src/defaultPhoneNumberRegex.ts b/libs/password-authentication/src/defaultPhoneNumberRegex.ts new file mode 100644 index 0000000..64f6912 --- /dev/null +++ b/libs/password-authentication/src/defaultPhoneNumberRegex.ts @@ -0,0 +1 @@ +export const defaultPhoneNumberRegex = /^\+?[1-9]\d{1,14}$/ diff --git a/libs/password-authentication/src/index.ts b/libs/password-authentication/src/index.ts index e69de29..728d8da 100644 --- a/libs/password-authentication/src/index.ts +++ b/libs/password-authentication/src/index.ts @@ -0,0 +1,2 @@ +export * from './createPasswordIdentitySchema.js' +export * from './password-policy/index.js' \ No newline at end of file diff --git a/libs/password-authentication/src/password-policy/PasswordPolicyConfiguration.ts b/libs/password-authentication/src/password-policy/PasswordPolicyConfiguration.ts new file mode 100644 index 0000000..64f1f10 --- /dev/null +++ b/libs/password-authentication/src/password-policy/PasswordPolicyConfiguration.ts @@ -0,0 +1,49 @@ +export interface PasswordPolicyConfiguration { + /** + * Minimum password length + * @default 8 + */ + minLength?: number + + /** + * Maximum password length + * @default 128 + */ + maxLength?: number + + /** + * Require at least one uppercase letter + * @default true + */ + requireUppercase?: boolean + + /** + * Require at least one lowercase letter + * @default true + */ + requireLowercase?: boolean + + /** + * Require at least one number + * @default true + */ + requireNumbers?: boolean + + /** + * Require at least one special character + * @default false + */ + requireSpecialChars?: boolean + + /** + * Custom error messages for validation failures + */ + messages?: { + minLength?: string + maxLength?: string + requireUppercase?: string + requireLowercase?: string + requireNumbers?: string + requireSpecialChars?: string + } +} diff --git a/libs/password-authentication/src/password-policy/__tests__/createPasswordPolicySchema.test.ts b/libs/password-authentication/src/password-policy/__tests__/createPasswordPolicySchema.test.ts new file mode 100644 index 0000000..4af6a8f --- /dev/null +++ b/libs/password-authentication/src/password-policy/__tests__/createPasswordPolicySchema.test.ts @@ -0,0 +1,172 @@ +import { createPasswordPolicySchema } from '../createPasswordPolicySchema.js' +import { z } from 'zod' + +describe('createPasswordPolicySchema', () => { + describe('with default configuration', () => { + it('creates schema with secure defaults', () => { + const schema = createPasswordPolicySchema() + + // Valid password meeting all default requirements + expect(schema.safeParse('Password123').success).toBe(true) + + // Too short (< 8 characters) + expect(schema.safeParse('Pass1').success).toBe(false) + + // Missing uppercase + expect(schema.safeParse('password123').success).toBe(false) + + // Missing lowercase + expect(schema.safeParse('PASSWORD123').success).toBe(false) + + // Missing number + expect(schema.safeParse('Password').success).toBe(false) + }) + + it('does not require special characters by default', () => { + const schema = createPasswordPolicySchema() + + expect(schema.safeParse('Password123').success).toBe(true) + }) + + it('enforces maximum length', () => { + const schema = createPasswordPolicySchema() + const longPassword = 'P'.repeat(129) + 'a1' // 131 characters + + expect(schema.safeParse(longPassword).success).toBe(false) + }) + }) + + describe('with custom configuration', () => { + it('respects custom minimum length', () => { + const schema = createPasswordPolicySchema({ minLength: 12 }) + + expect(schema.safeParse('Password123').success).toBe(false) // 11 chars + expect(schema.safeParse('Password1234').success).toBe(true) // 12 chars + }) + + it('respects custom maximum length', () => { + const schema = createPasswordPolicySchema({ maxLength: 10 }) + + expect(schema.safeParse('Password12').success).toBe(true) // 10 chars + expect(schema.safeParse('Password123').success).toBe(false) // 11 chars + }) + + it('allows disabling uppercase requirement', () => { + const schema = createPasswordPolicySchema({ requireUppercase: false }) + + expect(schema.safeParse('password123').success).toBe(true) + }) + + it('allows disabling lowercase requirement', () => { + const schema = createPasswordPolicySchema({ requireLowercase: false }) + + expect(schema.safeParse('PASSWORD123').success).toBe(true) + }) + + it('allows disabling number requirement', () => { + const schema = createPasswordPolicySchema({ requireNumbers: false }) + + expect(schema.safeParse('Password').success).toBe(true) + }) + + it('enforces special character requirement when enabled', () => { + const schema = createPasswordPolicySchema({ requireSpecialChars: true }) + + expect(schema.safeParse('Password123').success).toBe(false) + expect(schema.safeParse('Password123!').success).toBe(true) + expect(schema.safeParse('Password123@').success).toBe(true) + expect(schema.safeParse('Password123#').success).toBe(true) + }) + + it('supports relaxed configuration', () => { + const schema = createPasswordPolicySchema({ + minLength: 6, + requireUppercase: false, + requireNumbers: false, + }) + + expect(schema.safeParse('password').success).toBe(true) + }) + + it('supports strict configuration', () => { + const schema = createPasswordPolicySchema({ + minLength: 12, + requireSpecialChars: true, + }) + + expect(schema.safeParse('Password123!').success).toBe(true) + expect(schema.safeParse('Password123').success).toBe(false) // No special char + expect(schema.safeParse('Password12!').success).toBe(false) // Too short + }) + }) + + describe('custom error messages', () => { + it('uses custom error messages', () => { + const schema = createPasswordPolicySchema({ + minLength: 10, + messages: { + minLength: 'Must be at least 10 characters', + requireUppercase: 'Needs uppercase letter', + }, + }) + + const result = schema.safeParse('short') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Must be at least 10 characters') + } + + const result2 = schema.safeParse('lowercase123') + expect(result2.success).toBe(false) + if (!result2.success) { + const uppercaseError = result2.error.issues.find(issue => + issue.message === 'Needs uppercase letter' + ) + expect(uppercaseError).toBeDefined() + } + }) + + it('interpolates values in default messages', () => { + const schema = createPasswordPolicySchema({ minLength: 15, maxLength: 20 }) + + const shortResult = schema.safeParse('short') + expect(shortResult.success).toBe(false) + if (!shortResult.success) { + expect(shortResult.error.issues[0].message).toBe('Password must be at least 15 characters long') + } + + const longResult = schema.safeParse('a'.repeat(25)) + expect(longResult.success).toBe(false) + if (!longResult.success) { + expect(longResult.error.issues[0].message).toBe('Password must be no more than 20 characters long') + } + }) + }) + + describe('edge cases', () => { + it('handles empty configuration object', () => { + const schema = createPasswordPolicySchema({}) + + expect(schema.safeParse('Password123').success).toBe(true) + }) + + it('handles partial message overrides', () => { + const schema = createPasswordPolicySchema({ + messages: { + minLength: 'Custom min length message', + }, + }) + + const result = schema.safeParse('short') + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error.issues[0].message).toBe('Custom min length message') + } + }) + + it('returns a ZodString type', () => { + const schema = createPasswordPolicySchema() + expect(schema).toBeInstanceOf(z.ZodString) + }) + }) +}) \ No newline at end of file diff --git a/libs/password-authentication/src/password-policy/createPasswordPolicySchema.ts b/libs/password-authentication/src/password-policy/createPasswordPolicySchema.ts new file mode 100644 index 0000000..eb191e1 --- /dev/null +++ b/libs/password-authentication/src/password-policy/createPasswordPolicySchema.ts @@ -0,0 +1,51 @@ +import { z } from 'zod' +import type { PasswordPolicyConfiguration } from './PasswordPolicyConfiguration.js' +import { defaultPasswordPolicyConfiguration } from './defaultPasswordPolicyConfiguration.js' + +/** + * Creates a Zod password schema based on the provided policy configuration. + * + * @param config - Password policy configuration + * @returns Zod string schema with password validation rules + */ +export function createPasswordPolicySchema(config: PasswordPolicyConfiguration = {}): z.ZodString { + const mergedConfig = { + ...defaultPasswordPolicyConfiguration, + ...config, + messages: { + ...defaultPasswordPolicyConfiguration.messages, + ...config.messages, + }, + } + + let schema = z.string() + + // Apply length constraints + schema = schema.min( + mergedConfig.minLength, + mergedConfig.messages.minLength.replace('{minLength}', mergedConfig.minLength.toString()) + ) + + schema = schema.max( + mergedConfig.maxLength, + mergedConfig.messages.maxLength.replace('{maxLength}', mergedConfig.maxLength.toString()) + ) + + if (mergedConfig.requireUppercase) { + schema = schema.regex(/[A-Z]/, mergedConfig.messages.requireUppercase) + } + + if (mergedConfig.requireLowercase) { + schema = schema.regex(/[a-z]/, mergedConfig.messages.requireLowercase) + } + + if (mergedConfig.requireNumbers) { + schema = schema.regex(/[0-9]/, mergedConfig.messages.requireNumbers) + } + + if (mergedConfig.requireSpecialChars) { + schema = schema.regex(/[!@#$%^&*(),.?":{}|<>]/, mergedConfig.messages.requireSpecialChars) + } + + return schema +} diff --git a/libs/password-authentication/src/password-policy/defaultPasswordPolicyConfiguration.ts b/libs/password-authentication/src/password-policy/defaultPasswordPolicyConfiguration.ts new file mode 100644 index 0000000..8eff331 --- /dev/null +++ b/libs/password-authentication/src/password-policy/defaultPasswordPolicyConfiguration.ts @@ -0,0 +1,20 @@ +import { PasswordPolicyConfiguration } from './PasswordPolicyConfiguration.js' + +export const defaultPasswordPolicyConfiguration: Required> & { + messages: Required> +} = { + minLength: 8, + maxLength: 128, + requireUppercase: true, + requireLowercase: true, + requireNumbers: true, + requireSpecialChars: false, + messages: { + minLength: 'Password must be at least {minLength} characters long', + maxLength: 'Password must be no more than {maxLength} characters long', + requireUppercase: 'Password must contain at least one uppercase letter', + requireLowercase: 'Password must contain at least one lowercase letter', + requireNumbers: 'Password must contain at least one number', + requireSpecialChars: 'Password must contain at least one special character', + }, +} diff --git a/libs/password-authentication/src/password-policy/index.ts b/libs/password-authentication/src/password-policy/index.ts new file mode 100644 index 0000000..7846eb3 --- /dev/null +++ b/libs/password-authentication/src/password-policy/index.ts @@ -0,0 +1,3 @@ +export { createPasswordPolicySchema } from './createPasswordPolicySchema.js' +export type { PasswordPolicyConfiguration } from './PasswordPolicyConfiguration.js' +export { defaultPasswordPolicyConfiguration } from './defaultPasswordPolicyConfiguration.js' \ No newline at end of file