From 99cbdf582c33e50dff21c233c2a639015aa89aa6 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Thu, 12 Feb 2026 23:41:46 -0800 Subject: [PATCH] feat(auth): add middleware tests + E2E flow + migrate tracker-service to @bytelyst/auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgraded @bytelyst/auth middleware to throw ServiceError types (UnauthorizedError, ForbiddenError) - Added @bytelyst/errors as dependency to auth package - 11 new middleware tests (extractAuth + requireRole) - 4 new E2E tests (full login → JWT → auth → role check flow) - Refactored tracker-service lib/auth.ts from 48-line local impl to 1-line re-export - Added @bytelyst/auth as tracker-service dependency - All 277 tests pass, 0 regressions --- .windsurf/workflows/workspace-sync.md | 514 ++++++++++++++++++ packages/auth/package.json | 3 + .../auth/src/__tests__/e2e-auth-flow.test.ts | 101 ++++ .../auth/src/__tests__/middleware.test.ts | 117 ++++ packages/auth/src/middleware.ts | 11 +- pnpm-lock.yaml | 6 + services/tracker-service/package.json | 1 + services/tracker-service/src/lib/auth.ts | 46 +- 8 files changed, 748 insertions(+), 51 deletions(-) create mode 100644 .windsurf/workflows/workspace-sync.md create mode 100644 packages/auth/src/__tests__/e2e-auth-flow.test.ts create mode 100644 packages/auth/src/__tests__/middleware.test.ts diff --git a/.windsurf/workflows/workspace-sync.md b/.windsurf/workflows/workspace-sync.md new file mode 100644 index 00000000..b85ed359 --- /dev/null +++ b/.windsurf/workflows/workspace-sync.md @@ -0,0 +1,514 @@ +--- +description: Intelligent workspace sync - check, review, and commit changes across all repos +date: 2025-02-12 +--- + +# Workspace Sync Workflow + +> Intelligently manages uncommitted changes across all 3 workspace repos with interactive approval. +> Preserves developer knowledge and prevents data loss through careful review and conflict resolution. + +## Overview + +This workflow helps maintain a clean workspace by: + +1. Scanning all repos for uncommitted changes +2. Displaying changes in an organized, reviewable format +3. Suggesting logical commit groups based on change types +4. Interactive approval before each commit +5. Safe conflict resolution with rebase +6. Pushing in dependency order (common_plat → voice_agent → mindlyst) + +## Prerequisites + +- Git CLI installed +- All repos cloned and accessible +- Proper Git credentials configured + +## Workflow Steps + +### Step 1: Scan Workspace for Changes + +```bash +# Create workspace sync script +cat > ~/workspace-sync.sh << 'EOF' +#!/bin/bash + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Workspace paths +WORKSPACE="$HOME/code/mygh" +REPOS=( + "learning_ai_common_plat:Common Platform:First" + "learning_voice_ai_agent:LysnrAI:Second" + "learning_multimodal_memory_agents:MindLyst:Third" +) + +echo -e "${BLUE}🔍 Scanning workspace for uncommitted changes...${NC}" +echo + +# Track repos with changes +REPOS_WITH_CHANGES=() + +for repo_info in "${REPOS[@]}"; do + IFS=':' read -r repo_name display_name order <<< "$repo_info" + repo_path="$WORKSPACE/$repo_name" + + if [ -d "$repo_path" ]; then + cd "$repo_path" + + # Check for uncommitted changes + if ! git diff --quiet || ! git diff --cached --quiet || [ -n "$(git ls-files --others --exclude-standard)" ]; then + REPOS_WITH_CHANGES+=("$repo_name:$display_name:$order") + + echo -e "${CYAN}📁 $display_name${NC} (${YELLOW}$repo_name${NC})" + + # Staged changes + if [ -n "$(git diff --cached --name-only)" ]; then + echo -e " ${GREEN}Staged changes:${NC}" + git diff --cached --stat + fi + + # Unstaged changes + if [ -n "$(git diff --name-only)" ]; then + echo -e " ${YELLOW}Unstaged changes:${NC}" + git diff --stat + fi + + # Untracked files + if [ -n "$(git ls-files --others --exclude-standard)" ]; then + echo -e " ${RED}Untracked files:${NC}" + git ls-files --others --exclude-standard | head -10 + count=$(git ls-files --others --exclude-standard | wc -l) + [ $count -gt 10 ] && echo " ... and $((count - 10)) more" + fi + + # Branch status + branch_status=$(git status --porcelain -b | head -1) + if [[ $branch_status == *"ahead"* ]]; then + echo -e " ${YELLOW}⚠️ Branch is ahead of origin${NC}" + fi + if [[ $branch_status == *"behind"* ]]; then + echo -e " ${YELLOW}⚠️ Branch is behind origin${NC}" + fi + + echo + fi + fi +done + +if [ ${#REPOS_WITH_CHANGES[@]} -eq 0 ]; then + echo -e "${GREEN}✅ All repos are clean!${NC}" + exit 0 +fi + +echo -e "${BLUE}📊 Summary: Found changes in ${#REPOS_WITH_CHANGES[@]} repo(s)${NC}" +echo + +EOF + +chmod +x ~/workspace-sync.sh +``` + +### Step 2: Interactive Review and Commit + +```bash +# Add interactive commit functionality +cat >> ~/workspace-sync.sh << 'EOF' + +# Function to suggest commit message based on changes +suggest_commit_message() { + local repo_path="$1" + cd "$repo_path" + + # Analyze changes + has_ci_changes=$(git diff --cached --name-only | grep -E "\.github|ci|workflow" || echo "") + has_docs_changes=$(git diff --cached --name-only | grep -E "README|\.md$" || echo "") + has_code_changes=$(git diff --cached --name-only | grep -E "\.(ts|js|py|kt|swift)$" || echo "") + has_test_changes=$(git diff --cached --name-only | grep -E "test|spec" || echo "") + has_config_changes=$(git diff --cached --name-only | grep -E "package\.json|requirements|gradle|\.env" || echo "") + + # Suggest message + if [ -n "$has_ci_changes" ]; then + echo "ci: update CI/CD configuration" + elif [ -n "$has_docs_changes" ] && [ -z "$has_code_changes" ]; then + echo "docs: update documentation" + elif [ -n "$has_test_changes" ] && [ -z "$has_code_changes" ]; then + echo "test: update test suite" + elif [ -n "$has_config_changes" ]; then + echo "chore: update dependencies and configuration" + else + echo "feat: implement new features and improvements" + fi +} + +# Function to safely commit with review +safe_commit() { + local repo_name="$1" + local display_name="$2" + local repo_path="$WORKSPACE/$repo_name" + + cd "$repo_path" + + echo -e "${BLUE}📋 Reviewing changes for $display_name${NC}" + echo + + # Show detailed diff + echo -e "${CYAN}Press Enter to see diff summary, 's' to skip, 'q' to quit${NC}" + read -r response + + if [[ "$response" == "q" ]]; then + echo -e "${RED}Quitting workflow${NC}" + exit 0 + elif [[ "$response" != "s" ]]; then + git diff --cached --stat 2>/dev/null || true + git diff --stat 2>/dev/null || true + echo + fi + + # Ask what to do with changes + echo -e "${YELLOW}Choose action:${NC}" + echo " 1) Stage all changes and commit" + echo " 2) Review and stage specific files" + echo " 3) Skip this repo" + echo " q) Quit workflow" + read -r action + + case $action in + 1) + # Stage all changes + git add -A + ;; + 2) + # Interactive staging + echo -e "${CYAN}Files to stage:${NC}" + git status --porcelain + echo -e "${YELLOW}Enter files to stage (space-separated, or 'all'):${NC}" + read -r files_to_stage + if [[ "$files_to_stage" == "all" ]]; then + git add -A + else + git add $files_to_stage + fi + ;; + 3) + echo -e "${YELLOW}Skipping $display_name${NC}" + return + ;; + q) + echo -e "${RED}Quitting workflow${NC}" + exit 0 + ;; + esac + + # Check if there's anything to commit + if git diff --cached --quiet; then + echo -e "${YELLOW}No changes staged, skipping${NC}" + return + fi + + # Suggest commit message + suggested_msg=$(suggest_commit_message "$repo_path") + echo -e "${CYAN}Suggested commit message: $suggested_msg${NC}" + echo -e "${YELLOW}Enter commit message (or press Enter to accept suggestion):${NC}" + read -r commit_msg + + if [[ -z "$commit_msg" ]]; then + commit_msg="$suggested_msg" + fi + + # Confirm commit + echo -e "${YELLOW}Commit with message: '$commit_msg'? (y/N)${NC}" + read -r confirm + + if [[ "$confirm" == "y" || "$confirm" == "Y" ]]; then + git commit -m "$commit_msg" + echo -e "${GREEN}✅ Committed changes in $display_name${NC}" + else + echo -e "${YELLOW}Commit cancelled${NC}" + fi +} + +EOF +``` + +### Step 3: Safe Push with Conflict Resolution + +```bash +# Add push and conflict resolution +cat >> ~/workspace-sync.sh << 'EOF' + +# Function to safely push with conflict resolution +safe_push() { + local repo_name="$1" + local display_name="$2" + local repo_path="$WORKSPACE/$repo_name" + + cd "$repo_path" + + # Check if we're ahead of origin + if git log --oneline origin/main..main 2>/dev/null | grep -q .; then + echo -e "${BLUE}📤 Pushing $display_name...${NC}" + + # Try to push + if git push 2>/dev/null; then + echo -e "${GREEN}✅ Pushed $display_name successfully${NC}" + else + echo -e "${YELLOW}⚠️ Push failed, attempting to pull and rebase...${NC}" + + # Fetch latest changes + git fetch + + # Check if we have diverged + if git log --oneline main..origin/main | grep -q .; then + echo -e "${YELLOW}Local and remote have diverged. Options:${NC}" + echo " 1) Pull --rebase (rebase your changes on top of remote)" + echo " 2) Pull --no-rebase (merge remote changes)" + echo " 3) Skip pushing and handle manually" + read -r choice + + case $choice in + 1) + git pull --rebase + if git push; then + echo -e "${GREEN}✅ Pushed after rebase${NC}" + else + echo -e "${RED}❌ Still failed to push after rebase${NC}" + echo -e "${CYAN}Please resolve conflicts manually in $repo_path${NC}" + fi + ;; + 2) + git pull --no-rebase + if git push; then + echo -e "${GREEN}✅ Pushed after merge${NC}" + else + echo -e "${RED}❌ Still failed to push after merge${NC}" + fi + ;; + 3) + echo -e "${YELLOW}Skipping push. Please handle manually.${NC}" + ;; + esac + else + # Just need to push + git push + echo -e "${GREEN}✅ Pushed $display_name${NC}" + fi + fi + fi +} + +# Main execution +main() { + # Sort repos by dependency order + sorted_repos=($(printf '%s\n' "${REPOS_WITH_CHANGES[@]}" | sort -k3 -t':')) + + echo -e "${BLUE}🚀 Starting interactive sync process...${NC}" + echo + + for repo_info in "${sorted_repos[@]}"; do + IFS=':' read -r repo_name display_name order <<< "$repo_info" + safe_commit "$repo_name" "$display_name" + echo + done + + # Ask about pushing + echo -e "${YELLOW}Push all committed changes to origin? (y/N)${NC}" + read -r push_confirm + + if [[ "$push_confirm" == "y" || "$push_confirm" == "Y" ]]; then + echo -e "${BLUE}📤 Pushing in dependency order...${NC}" + echo + + for repo_info in "${sorted_repos[@]}"; do + IFS=':' read -r repo_name display_name order <<< "$repo_info" + safe_push "$repo_name" "$display_name" + echo + done + else + echo -e "${YELLOW}Skipping push. Changes are committed locally.${NC}" + fi + + echo -e "${GREEN}✨ Workspace sync complete!${NC}" +} + +# Run main function +main +EOF + +echo "Created workspace-sync.sh at ~/workspace-sync.sh" +``` + +### Step 4: Create VS Code Task for Easy Access + +```bash +# Create .vscode/tasks.json in each repo +cat > /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json << 'EOF' +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Workspace Sync", + "type": "shell", + "command": "~/workspace-sync.sh", + "group": "build", + "presentation": { + "echo": true, + "reveal": "always", + "focus": false, + "panel": "new" + }, + "problemMatcher": [] + } + ] +} +EOF + +# Copy to other repos +cp /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json \ + /Users/sd9235/code/mygh/learning_voice_ai_agent/.vscode/tasks.json + +cp /Users/sd9235/code/mygh/learning_ai_common_plat/.vscode/tasks.json \ + /Users/sd9235/code/mygh/learning_multimodal_memory_agents/.vscode/tasks.json +``` + +## Usage + +### Option 1: Run from Terminal + +```bash +~/workspace-sync.sh +``` + +### Option 2: Run from VS Code + +1. Open any workspace repo +2. Press `Cmd+Shift+P` (macOS) or `Ctrl+Shift+P` (Windows/Linux) +3. Type "Tasks: Run Task" +4. Select "Workspace Sync" + +### Option 3: Create Git Alias + +```bash +git config --global alias.ws '!~/workspace-sync.sh' +# Then run: git ws +``` + +## Features + +### 🔍 Smart Detection + +- Identifies staged, unstaged, and untracked files +- Detects branch divergence (ahead/behind) +- Shows change statistics + +### 📝 Intelligent Suggestions + +- Suggests commit messages based on changed file types +- Follows conventional commit format +- Recognizes CI, docs, test, and feature changes + +### 🛡️ Safety First + +- Interactive review before each commit +- Confirmation before destructive operations +- Preserves developer knowledge and consent + +### 🔄 Conflict Resolution + +- Automatic fetch before push +- Interactive choice between rebase/merge +- Clear guidance for manual resolution + +### 📊 Dependency Ordering + +- Automatically pushes in correct order +- Common Platform → LysnrAI → MindLyst +- Prevents dependency issues + +## Example Session + +``` +🔍 Scanning workspace for uncommitted changes... + +📁 Common Platform (learning_ai_common_plat) + Staged changes: + src/index.ts | 2 +- + README.md | 5 +++++ + Untracked files: + new-feature.ts + +📁 LysnrAI (learning_voice_ai_agent) + Unstaged changes: + src/app.py | 10 ++++++++++ + requirements.txt | 2 +- + +📊 Summary: Found changes in 2 repo(s) + +🚀 Starting interactive sync process... + +📋 Reviewing changes for Common Platform +Press Enter to see diff summary, 's' to skip, 'q' to quit + +Choose action: + 1) Stage all changes and commit + 2) Review and stage specific files + 3) Skip this repo + q) Quit workflow +1 + +Suggested commit message: feat: implement new features and improvements +Enter commit message (or press Enter to accept suggestion): feat: add new feature and update docs + +Commit with message: 'feat: add new feature and update docs'? (y/N) y +✅ Committed changes in Common Platform + +... +``` + +## Best Practices + +1. **Run before switching contexts**: Always run workspace sync before moving to another task +2. **Review carefully**: The interactive review prevents accidental commits +3. **Handle conflicts manually**: If rebase/merge fails, resolve conflicts to understand what changed +4. **Commit frequently**: Small, focused commits are easier to review and sync +5. **Use descriptive messages**: Good commit messages help track changes across repos + +## Troubleshooting + +### Permission Issues + +```bash +chmod +x ~/workspace-sync.sh +``` + +### Git Credentials + +```bash +git config --global credential.helper store +``` + +### SSH Key Issues + +```bash +ssh-add ~/.ssh/id_rsa +``` + +## Customization + +You can customize the script by: + +- Adding more repos to the `REPOS` array +- Modifying commit message suggestions +- Adding custom conflict resolution strategies +- Integrating with your team's workflow + +--- + +**Remember**: This workflow preserves your knowledge and control over what gets committed. Never skip the review steps! diff --git a/packages/auth/package.json b/packages/auth/package.json index 2fcf8c81..34a64f52 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -17,6 +17,9 @@ "build": "tsc", "test": "vitest run" }, + "dependencies": { + "@bytelyst/errors": "workspace:*" + }, "peerDependencies": { "jose": ">=5.0.0", "bcryptjs": ">=2.4.0" diff --git a/packages/auth/src/__tests__/e2e-auth-flow.test.ts b/packages/auth/src/__tests__/e2e-auth-flow.test.ts new file mode 100644 index 00000000..3d35bbce --- /dev/null +++ b/packages/auth/src/__tests__/e2e-auth-flow.test.ts @@ -0,0 +1,101 @@ +/** + * End-to-end auth flow test: create token → extract auth → require role. + * Exercises the full JWT lifecycle without network calls. + */ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { + createJwtUtils, + extractAuth, + requireRole, + hashPassword, + verifyPassword, +} from '../index.js'; + +const SECRET = 'e2e-test-jwt-secret-at-least-32-chars!!'; + +describe('E2E auth flow', () => { + beforeAll(() => { + process.env.JWT_SECRET = SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('full flow: login credentials → JWT → authenticated request → role check', async () => { + // 1. Simulate password verification (login step) + const storedHash = await hashPassword('SecretPass123!'); + const passwordValid = await verifyPassword('SecretPass123!', storedHash); + expect(passwordValid).toBe(true); + + // 2. Issue access token (platform-service would do this) + const jwt = createJwtUtils({ issuer: 'lysnrai' }); + const accessToken = await jwt.createAccessToken({ + sub: 'user-admin-001', + email: 'admin@lysnrai.com', + role: 'super_admin', + productId: 'lysnrai', + }); + expect(typeof accessToken).toBe('string'); + + // 3. Simulate authenticated request (any service receives this) + const req = { headers: { authorization: `Bearer ${accessToken}` } }; + const auth = await extractAuth(req); + expect(auth.sub).toBe('user-admin-001'); + expect(auth.email).toBe('admin@lysnrai.com'); + expect(auth.role).toBe('super_admin'); + expect(auth.productId).toBe('lysnrai'); + + // 4. Role-gated endpoint check + const adminAuth = await requireRole(req, 'super_admin', 'admin'); + expect(adminAuth.sub).toBe('user-admin-001'); + + // 5. Role rejection for wrong role + await expect(requireRole(req, 'viewer')).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('refresh token cannot be used for authenticated requests', async () => { + const jwt = createJwtUtils({ issuer: 'lysnrai' }); + const refreshToken = await jwt.createRefreshToken({ + sub: 'user-001', + productId: 'lysnrai', + }); + + const req = { headers: { authorization: `Bearer ${refreshToken}` } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('cross-issuer tokens are rejected by verifyToken but pass extractAuth (no issuer check)', async () => { + // extractAuth only checks type=access via jwtVerify without issuer + // But verifyToken checks issuer — this is the cross-service security model + const jwtA = createJwtUtils({ issuer: 'lysnrai' }); + const jwtB = createJwtUtils({ issuer: 'mindlyst' }); + + const tokenA = await jwtA.createAccessToken({ + sub: 'u1', + email: 'a@b.com', + role: 'user', + }); + + // verifyToken with wrong issuer rejects + const resultB = await jwtB.verifyToken(tokenA); + expect(resultB).toBeNull(); + + // verifyToken with correct issuer passes + const resultA = await jwtA.verifyToken(tokenA); + expect(resultA).not.toBeNull(); + expect(resultA!.sub).toBe('u1'); + }); + + it('wrong password fails login flow before token issuance', async () => { + const storedHash = await hashPassword('CorrectPassword'); + const passwordValid = await verifyPassword('WrongPassword', storedHash); + expect(passwordValid).toBe(false); + // No token should be issued — the flow stops here + }); +}); diff --git a/packages/auth/src/__tests__/middleware.test.ts b/packages/auth/src/__tests__/middleware.test.ts new file mode 100644 index 00000000..fb498d39 --- /dev/null +++ b/packages/auth/src/__tests__/middleware.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { createJwtUtils, extractAuth, requireRole } from '../index.js'; + +const SECRET = 'test-jwt-secret-at-least-32-chars-long!!'; +let validAccessToken: string; +let refreshToken: string; + +describe('extractAuth', () => { + beforeAll(async () => { + process.env.JWT_SECRET = SECRET; + const jwt = createJwtUtils({ issuer: 'test-issuer' }); + validAccessToken = await jwt.createAccessToken({ + sub: 'user-1', + email: 'test@example.com', + role: 'admin', + productId: 'lysnrai', + }); + refreshToken = await jwt.createRefreshToken({ sub: 'user-1' }); + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('extracts auth from valid Bearer token', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await extractAuth(req); + expect(payload.sub).toBe('user-1'); + expect(payload.email).toBe('test@example.com'); + expect(payload.role).toBe('admin'); + expect(payload.productId).toBe('lysnrai'); + expect(payload.type).toBe('access'); + }); + + it('throws 401 when no authorization header', async () => { + const req = { headers: {} }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Unauthorized', + }); + }); + + it('throws 401 when authorization header is not Bearer', async () => { + const req = { headers: { authorization: 'Basic abc123' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Unauthorized', + }); + }); + + it('throws 401 for invalid token', async () => { + const req = { headers: { authorization: 'Bearer garbage.not.valid' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('throws 401 for refresh token (requires access type)', async () => { + const req = { headers: { authorization: `Bearer ${refreshToken}` } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + message: 'Invalid or expired token', + }); + }); + + it('throws 401 for empty Bearer value', async () => { + const req = { headers: { authorization: 'Bearer ' } }; + await expect(extractAuth(req)).rejects.toMatchObject({ + statusCode: 401, + }); + }); +}); + +describe('requireRole', () => { + beforeAll(() => { + process.env.JWT_SECRET = SECRET; + }); + + afterAll(() => { + delete process.env.JWT_SECRET; + }); + + it('passes when role matches', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req, 'admin'); + expect(payload.sub).toBe('user-1'); + expect(payload.role).toBe('admin'); + }); + + it('passes when role is in allowed list', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req, 'viewer', 'admin', 'super_admin'); + expect(payload.role).toBe('admin'); + }); + + it('throws 403 when role does not match', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + await expect(requireRole(req, 'super_admin')).rejects.toMatchObject({ + statusCode: 403, + message: 'Insufficient permissions', + }); + }); + + it('passes with no roles specified (any authenticated user)', async () => { + const req = { headers: { authorization: `Bearer ${validAccessToken}` } }; + const payload = await requireRole(req); + expect(payload.sub).toBe('user-1'); + }); + + it('throws 401 when no auth header (before checking role)', async () => { + const req = { headers: {} }; + await expect(requireRole(req, 'admin')).rejects.toMatchObject({ + statusCode: 401, + }); + }); +}); diff --git a/packages/auth/src/middleware.ts b/packages/auth/src/middleware.ts index c8072f2d..3cbdf5f6 100644 --- a/packages/auth/src/middleware.ts +++ b/packages/auth/src/middleware.ts @@ -3,6 +3,7 @@ */ import { jwtVerify } from 'jose'; +import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors'; import type { AuthPayload } from './types.js'; function getSecret(): Uint8Array { @@ -23,7 +24,7 @@ export async function extractAuth(req: { }): Promise { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) { - throw Object.assign(new Error('Unauthorized'), { statusCode: 401 }); + throw new UnauthorizedError(); } const token = auth.slice(7); try { @@ -32,9 +33,7 @@ export async function extractAuth(req: { if (p.type !== 'access') throw new Error('Not an access token'); return p; } catch { - throw Object.assign(new Error('Invalid or expired token'), { - statusCode: 401, - }); + throw new UnauthorizedError('Invalid or expired token'); } } @@ -49,9 +48,7 @@ export async function requireRole( ): Promise { const payload = await extractAuth(req); if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw Object.assign(new Error('Insufficient permissions'), { - statusCode: 403, - }); + throw new ForbiddenError('Insufficient permissions'); } return payload; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c92b8c5b..97836139 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ importers: packages/auth: dependencies: + '@bytelyst/errors': + specifier: workspace:* + version: link:../errors bcryptjs: specifier: '>=2.4.0' version: 3.0.3 @@ -335,6 +338,9 @@ importers: '@azure/cosmos': specifier: ^4.2.0 version: 4.9.1(@azure/core-client@1.10.1) + '@bytelyst/auth': + specifier: workspace:* + version: link:../../packages/auth '@bytelyst/config': specifier: workspace:* version: link:../../packages/config diff --git a/services/tracker-service/package.json b/services/tracker-service/package.json index e10e79f2..e7c67456 100644 --- a/services/tracker-service/package.json +++ b/services/tracker-service/package.json @@ -13,6 +13,7 @@ "lint": "eslint src/" }, "dependencies": { + "@bytelyst/auth": "workspace:*", "@bytelyst/config": "workspace:*", "@bytelyst/cosmos": "workspace:*", "@bytelyst/errors": "workspace:*", diff --git a/services/tracker-service/src/lib/auth.ts b/services/tracker-service/src/lib/auth.ts index 6f0ee6d8..d86560a6 100644 --- a/services/tracker-service/src/lib/auth.ts +++ b/services/tracker-service/src/lib/auth.ts @@ -1,48 +1,6 @@ /** + * Re-export from @bytelyst/auth — shared across all services. * JWT auth middleware — validates tokens issued by platform-service. * Shares the same JWT_SECRET so it can verify without network calls. */ - -import { jwtVerify } from 'jose'; -import type { FastifyRequest } from 'fastify'; -import { UnauthorizedError, ForbiddenError } from './errors.js'; - -export interface AuthPayload { - sub: string; - email?: string; - role?: string; - productId?: string; - type?: string; -} - -function getSecret(): Uint8Array { - const secret = process.env.JWT_SECRET; - if (!secret) throw new Error('JWT_SECRET must be set'); - return new TextEncoder().encode(secret); -} - -export async function verifyToken(token: string): Promise { - const { payload } = await jwtVerify(token, getSecret()); - return payload as AuthPayload; -} - -export async function extractAuth(req: FastifyRequest): Promise { - const auth = req.headers.authorization; - if (!auth?.startsWith('Bearer ')) throw new UnauthorizedError(); - const token = auth.slice(7); - try { - const payload = await verifyToken(token); - if (payload.type !== 'access') throw new Error('Not an access token'); - return payload; - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } -} - -export async function requireRole(req: FastifyRequest, ...roles: string[]): Promise { - const payload = await extractAuth(req); - if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) { - throw new ForbiddenError('Insufficient permissions'); - } - return payload; -} +export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth';