mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:01:09 +02:00
🔒️ 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:
parent
1d5f49a6d0
commit
942c588e15
20 changed files with 1126 additions and 314 deletions
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue