🔒️ feat(auth): centralize JWT validation via mana-core-auth

- Create @manacore/shared-nestjs-auth package with JwtAuthGuard
- Update @mana-core/nestjs-integration to validate tokens via auth service
- Replace insecure local JWT decode with server-side validation
- Integrate Zitare, Presi, ManaDeck backends with centralized auth
- Add DEV_BYPASS_AUTH support for development mode
- Document auth architecture in CLAUDE.md
This commit is contained in:
Wuesteon 2025-12-01 17:16:21 +01:00
parent 1d5f49a6d0
commit 942c588e15
20 changed files with 1126 additions and 314 deletions

View file

@ -39,20 +39,12 @@ import {
ignoreEnvFile: process.env.NODE_ENV === 'production',
}),
// Mana Core authentication
// Mana Core authentication (validates JWT via mana-core-auth service)
ManaCoreModule.forRootAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => ({
manaServiceUrl: configService.get<string>(
'MANA_SERVICE_URL',
'https://mana-core-middleware-111768794939.europe-west3.run.app'
),
appId: configService.get<string>('APP_ID', 'cea4bfc6-a4de-4e17-91e2-54275940156e'),
serviceKey: configService.get<string>('MANA_SUPABASE_SECRET_KEY', ''), // Required for service-to-service communication
signupRedirectUrl: configService.get<string>(
'SIGNUP_REDIRECT_URL',
'https://manadeck.com/welcome'
),
serviceKey: configService.get<string>('MANA_CORE_SERVICE_KEY', ''),
debug: configService.get('NODE_ENV') === 'development',
}),
inject: [ConfigService],

View file

@ -1,67 +1,84 @@
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as crypto from 'crypto';
/**
* JWT Auth Guard - Validates tokens via Mana Core Auth service
*
* Uses Better Auth with EdDSA algorithm (not RS256).
* Validates tokens by calling the central auth service's /validate endpoint.
*/
@Injectable()
export class AuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const authHeader = request.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
const isDev = this.configService.get<string>('NODE_ENV') === 'development';
const bypassAuth = this.configService.get<string>('DEV_BYPASS_AUTH') === 'true';
if (isDev && bypassAuth) {
request.user = {
sub: '00000000-0000-0000-0000-000000000000',
email: 'dev@example.com',
role: 'user',
};
return true;
}
const token = authHeader.substring(7);
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const payload = this.verifyToken(token);
request.user = payload;
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
sub: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId || payload.sid,
};
return true;
} catch {
throw new UnauthorizedException('Invalid token');
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('[AuthGuard] Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private verifyToken(token: string): { sub: string; email: string; role: string } {
const publicKeyPem = this.configService.get<string>('JWT_PUBLIC_KEY');
if (!publicKeyPem) {
throw new Error('JWT_PUBLIC_KEY not configured');
private extractTokenFromHeader(request: any): string | undefined {
const authHeader = request.headers.authorization;
if (!authHeader) {
return undefined;
}
// Decode token parts
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature using RS256
const verifier = crypto.createVerify('RSA-SHA256');
verifier.update(`${headerB64}.${payloadB64}`);
const publicKey = publicKeyPem.replace(/\\n/g, '\n');
const signature = Buffer.from(signatureB64.replace(/-/g, '+').replace(/_/g, '/'), 'base64');
if (!verifier.verify(publicKey, signature)) {
throw new Error('Invalid signature');
}
// Decode and parse payload
const payloadJson = Buffer.from(
payloadB64.replace(/-/g, '+').replace(/_/g, '/'),
'base64'
).toString('utf-8');
const payload = JSON.parse(payloadJson);
// Check expiration
if (payload.exp && payload.exp < Date.now() / 1000) {
throw new Error('Token expired');
}
return payload;
const [type, token] = authHeader.split(' ');
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -18,6 +18,7 @@
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-nestjs-auth": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",

View file

@ -5,10 +5,10 @@ import {
Delete,
Body,
Param,
Headers,
UnauthorizedException,
UseGuards,
ConflictException,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { FavoriteService } from './favorite.service';
import { IsString, IsNotEmpty } from 'class-validator';
@ -18,56 +18,35 @@ class CreateFavoriteDto {
quoteId!: string;
}
// Simple JWT extraction - in production, use proper auth middleware
function extractUserId(authHeader?: string): string {
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
@Controller('favorites')
@UseGuards(JwtAuthGuard)
export class FavoriteController {
constructor(private readonly favoriteService: FavoriteService) {}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const favorites = await this.favoriteService.findByUserId(userId);
async findAll(@CurrentUser() user: CurrentUserData) {
const favorites = await this.favoriteService.findByUserId(user.userId);
return { favorites };
}
@Post()
async create(@Headers('authorization') authHeader: string, @Body() dto: CreateFavoriteDto) {
const userId = extractUserId(authHeader);
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateFavoriteDto) {
// Check if already favorited
const exists = await this.favoriteService.exists(userId, dto.quoteId);
const exists = await this.favoriteService.exists(user.userId, dto.quoteId);
if (exists) {
throw new ConflictException('Quote already in favorites');
}
const favorite = await this.favoriteService.create({
userId,
userId: user.userId,
quoteId: dto.quoteId,
});
return { favorite };
}
@Delete(':quoteId')
async delete(@Headers('authorization') authHeader: string, @Param('quoteId') quoteId: string) {
const userId = extractUserId(authHeader);
await this.favoriteService.delete(userId, quoteId);
async delete(@CurrentUser() user: CurrentUserData, @Param('quoteId') quoteId: string) {
await this.favoriteService.delete(user.userId, quoteId);
return { success: true };
}
}

View file

@ -6,9 +6,9 @@ import {
Delete,
Body,
Param,
Headers,
UnauthorizedException,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { ListService } from './list.service';
import { IsString, IsNotEmpty, IsOptional, IsArray } from 'class-validator';
@ -43,47 +43,27 @@ class AddQuoteDto {
quoteId!: string;
}
// Simple JWT extraction - in production, use proper auth middleware
function extractUserId(authHeader?: string): string {
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedException('Missing or invalid authorization header');
}
try {
const token = authHeader.substring(7);
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
if (!payload.sub) {
throw new UnauthorizedException('Invalid token payload');
}
return payload.sub;
} catch {
throw new UnauthorizedException('Invalid token');
}
}
@Controller('lists')
@UseGuards(JwtAuthGuard)
export class ListController {
constructor(private readonly listService: ListService) {}
@Get()
async findAll(@Headers('authorization') authHeader: string) {
const userId = extractUserId(authHeader);
const lists = await this.listService.findByUserId(userId);
async findAll(@CurrentUser() user: CurrentUserData) {
const lists = await this.listService.findByUserId(user.userId);
return { lists };
}
@Get(':id')
async findOne(@Headers('authorization') authHeader: string, @Param('id') id: string) {
const userId = extractUserId(authHeader);
const list = await this.listService.findById(userId, id);
async findOne(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
const list = await this.listService.findById(user.userId, id);
return { list };
}
@Post()
async create(@Headers('authorization') authHeader: string, @Body() dto: CreateListDto) {
const userId = extractUserId(authHeader);
async create(@CurrentUser() user: CurrentUserData, @Body() dto: CreateListDto) {
const list = await this.listService.create({
userId,
userId: user.userId,
name: dto.name,
description: dto.description,
});
@ -92,41 +72,37 @@ export class ListController {
@Put(':id')
async update(
@Headers('authorization') authHeader: string,
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateListDto
) {
const userId = extractUserId(authHeader);
const list = await this.listService.update(userId, id, dto);
const list = await this.listService.update(user.userId, id, dto);
return { list };
}
@Delete(':id')
async delete(@Headers('authorization') authHeader: string, @Param('id') id: string) {
const userId = extractUserId(authHeader);
await this.listService.delete(userId, id);
async delete(@CurrentUser() user: CurrentUserData, @Param('id') id: string) {
await this.listService.delete(user.userId, id);
return { success: true };
}
@Post(':id/quotes')
async addQuote(
@Headers('authorization') authHeader: string,
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: AddQuoteDto
) {
const userId = extractUserId(authHeader);
const list = await this.listService.addQuoteToList(userId, id, dto.quoteId);
const list = await this.listService.addQuoteToList(user.userId, id, dto.quoteId);
return { list };
}
@Delete(':id/quotes/:quoteId')
async removeQuote(
@Headers('authorization') authHeader: string,
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Param('quoteId') quoteId: string
) {
const userId = extractUserId(authHeader);
const list = await this.listService.removeQuoteFromList(userId, id, quoteId);
const list = await this.listService.removeQuoteFromList(user.userId, id, quoteId);
return { list };
}
}