feat(auth): add middleware tests + E2E flow + migrate tracker-service to @bytelyst/auth
- 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
This commit is contained in:
parent
33a646c0fe
commit
99cbdf582c
514
.windsurf/workflows/workspace-sync.md
Normal file
514
.windsurf/workflows/workspace-sync.md
Normal file
@ -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!
|
||||
@ -17,6 +17,9 @@
|
||||
"build": "tsc",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/errors": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jose": ">=5.0.0",
|
||||
"bcryptjs": ">=2.4.0"
|
||||
|
||||
101
packages/auth/src/__tests__/e2e-auth-flow.test.ts
Normal file
101
packages/auth/src/__tests__/e2e-auth-flow.test.ts
Normal file
@ -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
|
||||
});
|
||||
});
|
||||
117
packages/auth/src/__tests__/middleware.test.ts
Normal file
117
packages/auth/src/__tests__/middleware.test.ts
Normal file
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<AuthPayload> {
|
||||
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<AuthPayload> {
|
||||
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;
|
||||
}
|
||||
|
||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bytelyst/auth": "workspace:*",
|
||||
"@bytelyst/config": "workspace:*",
|
||||
"@bytelyst/cosmos": "workspace:*",
|
||||
"@bytelyst/errors": "workspace:*",
|
||||
|
||||
@ -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<AuthPayload> {
|
||||
const { payload } = await jwtVerify(token, getSecret());
|
||||
return payload as AuthPayload;
|
||||
}
|
||||
|
||||
export async function extractAuth(req: FastifyRequest): Promise<AuthPayload> {
|
||||
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<AuthPayload> {
|
||||
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';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user