mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 14:26:41 +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
|
|
@ -30,14 +30,9 @@
|
|||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/config": "^3.0.0 || ^4.0.0",
|
||||
"@nestjs/core": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/axios": "^3.0.0 || ^4.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0",
|
||||
"rxjs": "^7.8.0"
|
||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -6,20 +6,68 @@ import {
|
|||
Inject,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
|
||||
interface TokenValidationResponse {
|
||||
valid: boolean;
|
||||
payload?: {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
sid?: string;
|
||||
app_id?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Default development test user ID
|
||||
const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* JWT Authentication Guard for NestJS backends.
|
||||
*
|
||||
* Validates JWT tokens by calling the Mana Core Auth service.
|
||||
* Supports development mode bypass via DEV_BYPASS_AUTH=true.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MANA_CORE_OPTIONS)
|
||||
private readonly options?: ManaCoreModuleOptions
|
||||
private readonly options?: ManaCoreModuleOptions,
|
||||
@Optional()
|
||||
private readonly reflector?: Reflector,
|
||||
@Optional()
|
||||
private readonly configService?: ConfigService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
// Check if route is marked as public
|
||||
if (this.reflector) {
|
||||
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (isPublic) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
|
||||
if (this.shouldBypassAuth()) {
|
||||
request.user = this.getDevUser();
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
|
|
@ -27,33 +75,19 @@ export class AuthGuard implements CanActivate {
|
|||
}
|
||||
|
||||
try {
|
||||
// Decode the token to extract user information
|
||||
// The actual verification happens at the Mana Core middleware level
|
||||
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
|
||||
|
||||
if (!decoded || !decoded.sub) {
|
||||
throw new UnauthorizedException('Invalid token structure');
|
||||
}
|
||||
|
||||
// Attach user info to request
|
||||
request.user = {
|
||||
sub: decoded.sub,
|
||||
email: decoded.email || '',
|
||||
role: decoded.role || 'user',
|
||||
app_id: decoded.app_id,
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp,
|
||||
};
|
||||
|
||||
// Store raw token for downstream services
|
||||
const userData = await this.validateToken(token);
|
||||
request.user = userData;
|
||||
request.accessToken = token;
|
||||
|
||||
if (this.options?.debug) {
|
||||
console.log('[AuthGuard] User authenticated:', decoded.sub);
|
||||
console.log('[AuthGuard] User authenticated:', userData.sub);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
if (this.options?.debug) {
|
||||
console.error('[AuthGuard] Token validation failed:', error);
|
||||
}
|
||||
|
|
@ -61,6 +95,75 @@ export class AuthGuard implements CanActivate {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auth should be bypassed (development mode)
|
||||
*/
|
||||
private shouldBypassAuth(): boolean {
|
||||
const isDev =
|
||||
this.configService?.get<string>('NODE_ENV') === 'development' ||
|
||||
process.env.NODE_ENV === 'development';
|
||||
const bypassAuth =
|
||||
this.configService?.get<string>('DEV_BYPASS_AUTH') === 'true' ||
|
||||
process.env.DEV_BYPASS_AUTH === 'true';
|
||||
return isDev && bypassAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get development user data
|
||||
*/
|
||||
private getDevUser() {
|
||||
const devUserId =
|
||||
this.configService?.get<string>('DEV_USER_ID') ||
|
||||
process.env.DEV_USER_ID ||
|
||||
DEFAULT_DEV_USER_ID;
|
||||
return {
|
||||
sub: devUserId,
|
||||
email: 'dev@example.com',
|
||||
role: 'user',
|
||||
app_id: this.options?.appId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token with Mana Core Auth service
|
||||
*/
|
||||
private async validateToken(token: string): Promise<any> {
|
||||
const authUrl =
|
||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
process.env.MANA_CORE_AUTH_URL ||
|
||||
'http://localhost:3001';
|
||||
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
if (this.options?.debug) {
|
||||
console.error('[AuthGuard] Token validation failed:', response.status, errorText);
|
||||
}
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const result = (await response.json()) as TokenValidationResponse;
|
||||
|
||||
if (!result.valid || !result.payload) {
|
||||
throw new UnauthorizedException(result.error || 'Invalid token');
|
||||
}
|
||||
|
||||
return {
|
||||
sub: result.payload.sub,
|
||||
email: result.payload.email,
|
||||
role: result.payload.role,
|
||||
app_id: result.payload.app_id || this.options?.appId,
|
||||
sessionId: result.payload.sessionId || result.payload.sid,
|
||||
iat: result.payload.iat,
|
||||
exp: result.payload.exp,
|
||||
};
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
|
|
|
|||
|
|
@ -5,19 +5,36 @@ import {
|
|||
Inject,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import * as jwt from 'jsonwebtoken';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
||||
|
||||
interface TokenValidationResponse {
|
||||
valid: boolean;
|
||||
payload?: {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
sid?: string;
|
||||
app_id?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional auth guard - allows unauthenticated requests but still extracts user info if token is present
|
||||
* Optional auth guard - allows unauthenticated requests but still validates and extracts user info if token is present
|
||||
*/
|
||||
@Injectable()
|
||||
export class OptionalAuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Optional()
|
||||
@Inject(MANA_CORE_OPTIONS)
|
||||
private readonly options?: ManaCoreModuleOptions
|
||||
private readonly options?: ManaCoreModuleOptions,
|
||||
@Optional()
|
||||
private readonly configService?: ConfigService
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
|
|
@ -31,39 +48,69 @@ export class OptionalAuthGuard implements CanActivate {
|
|||
}
|
||||
|
||||
try {
|
||||
// Decode the token to extract user information
|
||||
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
|
||||
const userData = await this.validateToken(token);
|
||||
|
||||
if (decoded && decoded.sub) {
|
||||
// Attach user info to request
|
||||
request.user = {
|
||||
sub: decoded.sub,
|
||||
email: decoded.email || '',
|
||||
role: decoded.role || 'user',
|
||||
app_id: decoded.app_id,
|
||||
iat: decoded.iat,
|
||||
exp: decoded.exp,
|
||||
};
|
||||
|
||||
// Store raw token for downstream services
|
||||
if (userData) {
|
||||
request.user = userData;
|
||||
request.accessToken = token;
|
||||
|
||||
if (this.options?.debug) {
|
||||
console.log('[OptionalAuthGuard] User authenticated:', decoded.sub);
|
||||
console.log('[OptionalAuthGuard] User authenticated:', userData.sub);
|
||||
}
|
||||
} else {
|
||||
request.user = null;
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.options?.debug) {
|
||||
console.error('[OptionalAuthGuard] Token decode failed:', error);
|
||||
console.error('[OptionalAuthGuard] Token validation failed:', error);
|
||||
}
|
||||
// For optional auth, we allow the request to proceed even if token validation fails
|
||||
request.user = null;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token with Mana Core Auth service
|
||||
*/
|
||||
private async validateToken(token: string): Promise<any | null> {
|
||||
const authUrl =
|
||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
process.env.MANA_CORE_AUTH_URL ||
|
||||
'http://localhost:3001';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = (await response.json()) as TokenValidationResponse;
|
||||
|
||||
if (!result.valid || !result.payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
sub: result.payload.sub,
|
||||
email: result.payload.email,
|
||||
role: result.payload.role,
|
||||
app_id: result.payload.app_id || this.options?.appId,
|
||||
sessionId: result.payload.sessionId || result.payload.sid,
|
||||
iat: result.payload.iat,
|
||||
exp: result.payload.exp,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
import { ModuleMetadata, Type } from '@nestjs/common';
|
||||
|
||||
export interface ManaCoreModuleOptions {
|
||||
manaServiceUrl: string;
|
||||
/**
|
||||
* @deprecated No longer used - auth URL is read from MANA_CORE_AUTH_URL env variable
|
||||
*/
|
||||
manaServiceUrl?: string;
|
||||
appId: string;
|
||||
serviceKey?: string;
|
||||
signupRedirectUrl?: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { DynamicModule, Module, Global, Provider } from '@nestjs/common';
|
||||
import { HttpModule, HttpService } from '@nestjs/axios';
|
||||
import {
|
||||
ManaCoreModuleOptions,
|
||||
ManaCoreModuleAsyncOptions,
|
||||
|
|
@ -16,7 +15,6 @@ export class ManaCoreModule {
|
|||
static forRoot(options: ManaCoreModuleOptions): DynamicModule {
|
||||
return {
|
||||
module: ManaCoreModule,
|
||||
imports: [HttpModule],
|
||||
providers: [
|
||||
{
|
||||
provide: MANA_CORE_OPTIONS,
|
||||
|
|
@ -34,7 +32,7 @@ export class ManaCoreModule {
|
|||
|
||||
return {
|
||||
module: ManaCoreModule,
|
||||
imports: [...(options.imports || []), HttpModule],
|
||||
imports: options.imports || [],
|
||||
providers: [...asyncProviders, AuthGuard, CreditClientService],
|
||||
exports: [MANA_CORE_OPTIONS, AuthGuard, CreditClientService],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { Injectable, Inject, Optional, Logger } from '@nestjs/common';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MANA_CORE_OPTIONS } from '../mana-core.module';
|
||||
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
|
||||
|
||||
|
|
@ -26,9 +25,35 @@ export class CreditClientService {
|
|||
@Inject(MANA_CORE_OPTIONS)
|
||||
private readonly options?: ManaCoreModuleOptions,
|
||||
@Optional()
|
||||
private readonly httpService?: HttpService
|
||||
private readonly configService?: ConfigService
|
||||
) {}
|
||||
|
||||
private getAuthUrl(): string {
|
||||
return (
|
||||
this.configService?.get<string>('MANA_CORE_AUTH_URL') ||
|
||||
process.env.MANA_CORE_AUTH_URL ||
|
||||
'http://localhost:3001'
|
||||
);
|
||||
}
|
||||
|
||||
private getServiceKey(): string {
|
||||
return (
|
||||
this.options?.serviceKey ||
|
||||
this.configService?.get<string>('MANA_CORE_SERVICE_KEY') ||
|
||||
process.env.MANA_CORE_SERVICE_KEY ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
private getAppId(): string {
|
||||
return (
|
||||
this.options?.appId ||
|
||||
this.configService?.get<string>('APP_ID') ||
|
||||
process.env.APP_ID ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
async validateCredits(
|
||||
userId: string,
|
||||
operation: string,
|
||||
|
|
@ -56,10 +81,11 @@ export class CreditClientService {
|
|||
}
|
||||
|
||||
async getBalance(userId: string): Promise<CreditBalance> {
|
||||
if (!this.httpService || !this.options?.manaServiceUrl) {
|
||||
this.logger.warn(
|
||||
'HTTP service or Mana service URL not configured, returning default balance'
|
||||
);
|
||||
const authUrl = this.getAuthUrl();
|
||||
const serviceKey = this.getServiceKey();
|
||||
|
||||
if (!serviceKey) {
|
||||
this.logger.warn('Service key not configured, returning default balance');
|
||||
return {
|
||||
balance: 1000,
|
||||
freeCreditsRemaining: 100,
|
||||
|
|
@ -69,28 +95,30 @@ export class CreditClientService {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await firstValueFrom(
|
||||
this.httpService.get<CreditBalance>(
|
||||
`${this.options.manaServiceUrl}/credits/balance/${userId}`,
|
||||
{
|
||||
headers: {
|
||||
'X-Service-Key': this.options.serviceKey || '',
|
||||
'X-App-Id': this.options.appId || '',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
const response = await fetch(`${authUrl}/api/v1/credits/balance/${userId}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': serviceKey,
|
||||
'X-App-Id': this.getAppId(),
|
||||
},
|
||||
});
|
||||
|
||||
return response.data;
|
||||
if (!response.ok) {
|
||||
this.logger.warn(`Credit balance request failed: ${response.status}`);
|
||||
return this.getDefaultBalance();
|
||||
}
|
||||
|
||||
const data = (await response.json()) as CreditBalance;
|
||||
return {
|
||||
balance: data.balance || 0,
|
||||
freeCreditsRemaining: data.freeCreditsRemaining || 0,
|
||||
totalEarned: data.totalEarned || 0,
|
||||
totalSpent: data.totalSpent || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get balance for user ${userId}:`, error);
|
||||
// Return default balance on error
|
||||
return {
|
||||
balance: 1000,
|
||||
freeCreditsRemaining: 100,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
};
|
||||
return this.getDefaultBalance();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -101,37 +129,41 @@ export class CreditClientService {
|
|||
description: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (!this.httpService || !this.options?.manaServiceUrl) {
|
||||
this.logger.warn(
|
||||
'HTTP service or Mana service URL not configured, skipping credit consumption'
|
||||
);
|
||||
const authUrl = this.getAuthUrl();
|
||||
const serviceKey = this.getServiceKey();
|
||||
|
||||
if (!serviceKey) {
|
||||
this.logger.warn('Service key not configured, skipping credit consumption');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.options.manaServiceUrl}/credits/use`,
|
||||
{
|
||||
userId,
|
||||
amount,
|
||||
appId: this.options.appId,
|
||||
description,
|
||||
metadata: {
|
||||
operation,
|
||||
...metadata,
|
||||
},
|
||||
const response = await fetch(`${authUrl}/api/v1/credits/use`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': serviceKey,
|
||||
'X-App-Id': this.getAppId(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
amount,
|
||||
appId: this.getAppId(),
|
||||
description,
|
||||
metadata: {
|
||||
operation,
|
||||
...metadata,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Service-Key': this.options.serviceKey || '',
|
||||
'X-App-Id': this.options.appId || '',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
}),
|
||||
});
|
||||
|
||||
if (this.options.debug) {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
this.logger.error(`Failed to consume credits: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.options?.debug) {
|
||||
this.logger.log(`Consumed ${amount} credits for user ${userId}: ${description}`);
|
||||
}
|
||||
|
||||
|
|
@ -148,32 +180,38 @@ export class CreditClientService {
|
|||
description: string,
|
||||
metadata?: Record<string, any>
|
||||
): Promise<boolean> {
|
||||
if (!this.httpService || !this.options?.manaServiceUrl) {
|
||||
this.logger.warn('HTTP service or Mana service URL not configured, skipping credit refund');
|
||||
const authUrl = this.getAuthUrl();
|
||||
const serviceKey = this.getServiceKey();
|
||||
|
||||
if (!serviceKey) {
|
||||
this.logger.warn('Service key not configured, skipping credit refund');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
await firstValueFrom(
|
||||
this.httpService.post(
|
||||
`${this.options.manaServiceUrl}/credits/refund`,
|
||||
{
|
||||
userId,
|
||||
amount,
|
||||
appId: this.options.appId,
|
||||
description,
|
||||
metadata,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
'X-Service-Key': this.options.serviceKey || '',
|
||||
'X-App-Id': this.options.appId || '',
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
const response = await fetch(`${authUrl}/api/v1/credits/refund`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Service-Key': serviceKey,
|
||||
'X-App-Id': this.getAppId(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
amount,
|
||||
appId: this.getAppId(),
|
||||
description,
|
||||
metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
if (this.options.debug) {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
this.logger.error(`Failed to refund credits: ${response.status} ${errorText}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.options?.debug) {
|
||||
this.logger.log(`Refunded ${amount} credits for user ${userId}: ${description}`);
|
||||
}
|
||||
|
||||
|
|
@ -183,4 +221,13 @@ export class CreditClientService {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private getDefaultBalance(): CreditBalance {
|
||||
return {
|
||||
balance: 1000,
|
||||
freeCreditsRemaining: 100,
|
||||
totalEarned: 0,
|
||||
totalSpent: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
142
packages/shared-nestjs-auth/README.md
Normal file
142
packages/shared-nestjs-auth/README.md
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
# @manacore/shared-nestjs-auth
|
||||
|
||||
Shared authentication utilities for NestJS backends in the Mana Core ecosystem.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pnpm add @manacore/shared-nestjs-auth
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### 1. Configure Environment Variables
|
||||
|
||||
```env
|
||||
# Required: Mana Core Auth service URL
|
||||
MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
|
||||
# Optional: Development mode auth bypass
|
||||
NODE_ENV=development
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=your-test-user-id
|
||||
```
|
||||
|
||||
### 2. Use in Controllers
|
||||
|
||||
```typescript
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Controller('api')
|
||||
export class MyController {
|
||||
// Public endpoint
|
||||
@Get('health')
|
||||
health() {
|
||||
return { status: 'ok' };
|
||||
}
|
||||
|
||||
// Protected endpoint
|
||||
@Get('profile')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getProfile(@CurrentUser() user: CurrentUserData) {
|
||||
return {
|
||||
userId: user.userId,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Apply Guard Globally (Optional)
|
||||
|
||||
```typescript
|
||||
// app.module.ts
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
|
||||
|
||||
@Module({
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useClass: JwtAuthGuard,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### JwtAuthGuard
|
||||
|
||||
A NestJS guard that validates JWT tokens via the Mana Core Auth service.
|
||||
|
||||
- Extracts Bearer token from `Authorization` header
|
||||
- Calls `POST /api/v1/auth/validate` on auth service
|
||||
- Attaches user data to request object
|
||||
- Supports `DEV_BYPASS_AUTH=true` for development
|
||||
|
||||
### CurrentUser Decorator
|
||||
|
||||
Parameter decorator to extract the authenticated user from the request.
|
||||
|
||||
```typescript
|
||||
@Get('me')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
getMe(@CurrentUser() user: CurrentUserData) {
|
||||
return user;
|
||||
}
|
||||
```
|
||||
|
||||
### CurrentUserData Interface
|
||||
|
||||
```typescript
|
||||
interface CurrentUserData {
|
||||
userId: string; // User ID (from JWT sub claim)
|
||||
email: string; // User email
|
||||
role: string; // User role (user, admin, service)
|
||||
sessionId?: string; // Session ID (optional)
|
||||
}
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Client sends request with `Authorization: Bearer <token>` header
|
||||
2. JwtAuthGuard extracts the token
|
||||
3. Guard calls Mana Core Auth service to validate token
|
||||
4. On success, user data is attached to `request.user`
|
||||
5. Controller receives user via `@CurrentUser()` decorator
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌────────────────┐
|
||||
│ Client │────>│ Your API │────>│ mana-core-auth │
|
||||
│ │ │ (NestJS) │ │ (port 3001) │
|
||||
└─────────────┘ └─────────────┘ └────────────────┘
|
||||
│ │ │
|
||||
│ Bearer token │ POST /validate │
|
||||
│ │ {token} │
|
||||
│ │<────────────────────│
|
||||
│ │ {valid, payload} │
|
||||
│<──────────────────│ │
|
||||
│ Response │ │
|
||||
```
|
||||
|
||||
## Development Mode
|
||||
|
||||
Set `DEV_BYPASS_AUTH=true` in development to skip token validation:
|
||||
|
||||
```env
|
||||
NODE_ENV=development
|
||||
DEV_BYPASS_AUTH=true
|
||||
DEV_USER_ID=17cb0be7-058a-4964-9e18-1fe7055fd014
|
||||
```
|
||||
|
||||
This will use a mock user for all authenticated requests.
|
||||
|
||||
## Related Packages
|
||||
|
||||
- `@manacore/shared-auth` - Client-side auth for web/mobile apps
|
||||
- `@manacore/shared-types` - Shared TypeScript types
|
||||
33
packages/shared-nestjs-auth/package.json
Normal file
33
packages/shared-nestjs-auth/package.json
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"name": "@manacore/shared-nestjs-auth",
|
||||
"version": "1.0.0",
|
||||
"description": "Shared authentication utilities for NestJS backends - JWT validation via Mana Core Auth",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "pnpm build"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@nestjs/common": "^10.0.0 || ^11.0.0",
|
||||
"@nestjs/config": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/common": "^10.0.0",
|
||||
"@nestjs/config": "^3.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"nestjs",
|
||||
"auth",
|
||||
"jwt",
|
||||
"manacore"
|
||||
],
|
||||
"author": "Mana Core Team",
|
||||
"license": "MIT"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { CurrentUserData } from '../types';
|
||||
|
||||
/**
|
||||
* Parameter decorator to extract the current user from the request.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* @Get('profile')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* getProfile(@CurrentUser() user: CurrentUserData) {
|
||||
* return { userId: user.userId };
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const CurrentUser = createParamDecorator(
|
||||
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
|
||||
const request = ctx.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
}
|
||||
);
|
||||
133
packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts
Normal file
133
packages/shared-nestjs-auth/src/guards/jwt-auth.guard.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { TokenValidationResponse, CurrentUserData } from '../types';
|
||||
|
||||
// Default development test user ID
|
||||
const DEFAULT_DEV_USER_ID = '00000000-0000-0000-0000-000000000000';
|
||||
|
||||
/**
|
||||
* JWT Authentication Guard for NestJS backends.
|
||||
*
|
||||
* Validates JWT tokens by calling the Mana Core Auth service.
|
||||
* Supports development mode bypass via DEV_BYPASS_AUTH=true.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // In your controller
|
||||
* @Controller('api')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* export class MyController {
|
||||
* @Get('protected')
|
||||
* getProtected(@CurrentUser() user: CurrentUserData) {
|
||||
* return { userId: user.userId };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Environment variables
|
||||
* MANA_CORE_AUTH_URL=http://localhost:3001
|
||||
* DEV_BYPASS_AUTH=true // Optional: for development
|
||||
* DEV_USER_ID=your-test-user-id // Optional: custom dev user
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(private configService: ConfigService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
|
||||
// Development mode: bypass auth if DEV_BYPASS_AUTH is set
|
||||
if (this.shouldBypassAuth()) {
|
||||
request.user = this.getDevUser();
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const userData = await this.validateToken(token);
|
||||
request.user = userData;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
console.error('[JwtAuthGuard] Error validating token:', error);
|
||||
throw new UnauthorizedException('Token validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if auth should be bypassed (development mode)
|
||||
*/
|
||||
private shouldBypassAuth(): boolean {
|
||||
const isDev = this.configService.get<string>('NODE_ENV') === 'development';
|
||||
const bypassAuth = this.configService.get<string>('DEV_BYPASS_AUTH') === 'true';
|
||||
return isDev && bypassAuth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get development user data
|
||||
*/
|
||||
private getDevUser(): CurrentUserData {
|
||||
return {
|
||||
userId: this.configService.get<string>('DEV_USER_ID') || DEFAULT_DEV_USER_ID,
|
||||
email: 'dev@example.com',
|
||||
role: 'user',
|
||||
sessionId: 'dev-session',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate token with Mana Core Auth service
|
||||
*/
|
||||
private async validateToken(token: string): Promise<CurrentUserData> {
|
||||
const authUrl =
|
||||
this.configService.get<string>('MANA_CORE_AUTH_URL') || 'http://localhost:3001';
|
||||
|
||||
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
console.error('[JwtAuthGuard] Token validation failed:', response.status, errorText);
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
const result: TokenValidationResponse = await response.json();
|
||||
|
||||
if (!result.valid || !result.payload) {
|
||||
throw new UnauthorizedException(result.error || 'Invalid token');
|
||||
}
|
||||
|
||||
return {
|
||||
userId: result.payload.sub,
|
||||
email: result.payload.email,
|
||||
role: result.payload.role,
|
||||
sessionId: result.payload.sessionId || result.payload.sid,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Bearer token from Authorization header
|
||||
*/
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const authHeader = request.headers.authorization;
|
||||
if (!authHeader) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const [type, token] = authHeader.split(' ');
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
29
packages/shared-nestjs-auth/src/index.ts
Normal file
29
packages/shared-nestjs-auth/src/index.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* @manacore/shared-nestjs-auth
|
||||
*
|
||||
* Shared authentication utilities for NestJS backends.
|
||||
* Validates JWT tokens via the central Mana Core Auth service.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
|
||||
*
|
||||
* @Controller('api')
|
||||
* @UseGuards(JwtAuthGuard)
|
||||
* export class MyController {
|
||||
* @Get('profile')
|
||||
* getProfile(@CurrentUser() user: CurrentUserData) {
|
||||
* return { userId: user.userId, email: user.email };
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Guards
|
||||
export { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
|
||||
// Decorators
|
||||
export { CurrentUser } from './decorators/current-user.decorator';
|
||||
|
||||
// Types
|
||||
export type { CurrentUserData, AuthModuleConfig, TokenValidationResponse } from './types';
|
||||
40
packages/shared-nestjs-auth/src/types/index.ts
Normal file
40
packages/shared-nestjs-auth/src/types/index.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
/**
|
||||
* User data extracted from JWT token
|
||||
*/
|
||||
export interface CurrentUserData {
|
||||
userId: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for the auth module
|
||||
*/
|
||||
export interface AuthModuleConfig {
|
||||
/** URL of the Mana Core Auth service (default: http://localhost:3001) */
|
||||
authServiceUrl?: string;
|
||||
/** Whether to bypass auth in development mode (default: false) */
|
||||
devBypassAuth?: boolean;
|
||||
/** Test user ID for development mode */
|
||||
devUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from token validation endpoint
|
||||
*/
|
||||
export interface TokenValidationResponse {
|
||||
valid: boolean;
|
||||
payload?: {
|
||||
sub: string;
|
||||
email: string;
|
||||
role: string;
|
||||
sessionId?: string;
|
||||
sid?: string;
|
||||
iat?: number;
|
||||
exp?: number;
|
||||
iss?: string;
|
||||
aud?: string;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
24
packages/shared-nestjs-auth/tsconfig.json
Normal file
24
packages/shared-nestjs-auth/tsconfig.json
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue