Implement backend API and database services in Docker setup
- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO. - Introduced PostgreSQL and Redis services with health checks and configurations for data persistence. - Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets. - Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage. - Enhanced the Dockerfile to support the new API environment variables and configurations. - Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
38
backend/src/auth/__tests__/nostr-session.service.spec.ts
Normal file
38
backend/src/auth/__tests__/nostr-session.service.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { NostrSessionService } from '../nostr-session.service';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
|
||||
describe('NostrSessionService', () => {
|
||||
const originalSecret = process.env.NOSTR_JWT_SECRET;
|
||||
const originalRefresh = process.env.NOSTR_JWT_REFRESH_SECRET;
|
||||
const usersService = {
|
||||
findUserByNostrPubkey: jest.fn(),
|
||||
} as unknown as UsersService;
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.NOSTR_JWT_SECRET = 'test-secret';
|
||||
process.env.NOSTR_JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||
process.env.NOSTR_JWT_TTL = '60';
|
||||
process.env.NOSTR_JWT_REFRESH_TTL = '120';
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env.NOSTR_JWT_SECRET = originalSecret;
|
||||
process.env.NOSTR_JWT_REFRESH_SECRET = originalRefresh;
|
||||
});
|
||||
|
||||
it('issues and verifies session tokens', async () => {
|
||||
const service = new NostrSessionService(usersService);
|
||||
const { accessToken, refreshToken, expiresIn } =
|
||||
await service.issueTokens('pubkey');
|
||||
|
||||
expect(expiresIn).toBe(60);
|
||||
|
||||
const accessPayload = service.verifyAccessToken(accessToken);
|
||||
expect(accessPayload.sub).toBe('pubkey');
|
||||
expect(accessPayload.typ).toBe('nostr-session');
|
||||
|
||||
const refreshPayload = service.verifyRefreshToken(refreshToken);
|
||||
expect(refreshPayload.sub).toBe('pubkey');
|
||||
expect(refreshPayload.typ).toBe('nostr-refresh');
|
||||
});
|
||||
});
|
||||
9
backend/src/auth/auth.config.ts
Normal file
9
backend/src/auth/auth.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AuthConfig {
|
||||
public userPoolId: string = process.env.COGNITO_USER_POOL_ID;
|
||||
public clientId: string = process.env.COGNITO_CLIENT_ID;
|
||||
public region: string = process.env.AWS_REGION;
|
||||
public authority = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`;
|
||||
}
|
||||
47
backend/src/auth/auth.controller.spec.ts
Normal file
47
backend/src/auth/auth.controller.spec.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
import { MailService } from '../mail/mail.service';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { FilmmakersService } from '../filmmakers/filmmakers.service';
|
||||
import { NostrSessionService } from './nostr-session.service';
|
||||
|
||||
// Mock MailService
|
||||
const mockMailService = {
|
||||
sendEmail: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAuthService = {};
|
||||
|
||||
const mockUsersService = {};
|
||||
|
||||
const mockFilmmakersService = {};
|
||||
|
||||
const mockNostrSessionService = {};
|
||||
|
||||
describe('AuthController', () => {
|
||||
let controller: AuthController;
|
||||
|
||||
process.env.COGNITO_USER_POOL_ID = 'dummy_user_pool_id';
|
||||
process.env.COGNITO_CLIENT_ID = 'dummy_client_id';
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AuthController],
|
||||
providers: [
|
||||
{ provide: AuthService, useValue: mockAuthService },
|
||||
{ provide: MailService, useValue: mockMailService },
|
||||
{ provide: UsersService, useValue: mockUsersService },
|
||||
{ provide: FilmmakersService, useValue: mockFilmmakersService },
|
||||
{ provide: NostrSessionService, useValue: mockNostrSessionService },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<AuthController>(AuthController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
161
backend/src/auth/auth.controller.ts
Normal file
161
backend/src/auth/auth.controller.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
UseGuards,
|
||||
Req,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||
import { User } from './user.decorator';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
|
||||
import { RequestUser } from './dto/request/request-user.interface';
|
||||
import { UserDTO } from 'src/users/dto/response/user.dto';
|
||||
import { RegisterDTO } from './dto/request/register.dto';
|
||||
import { ValidateSessionDTO } from './dto/request/validate-session.dto';
|
||||
import { RegisterOneTimePasswordDTO } from './dto/request/register-otp.dto';
|
||||
import { Request } from 'express';
|
||||
import { NostrAuthGuard } from 'src/nostr-auth/nostr-auth.guard';
|
||||
import { NostrSessionService } from './nostr-session.service';
|
||||
import { RefreshNostrSessionDto } from './dto/request/refresh-nostr-session.dto';
|
||||
import { NostrSessionJwtGuard } from './guards/nostr-session-jwt.guard';
|
||||
import { HybridAuthGuard } from './guards/hybrid-auth.guard';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
constructor(
|
||||
private readonly authService: AuthService,
|
||||
private readonly userService: UsersService,
|
||||
private readonly filmmakerService: FilmmakersService,
|
||||
private readonly nostrSessionService: NostrSessionService,
|
||||
) {}
|
||||
|
||||
@Post('register')
|
||||
async register(@Body() registerDTO: RegisterDTO) {
|
||||
try {
|
||||
const user = await this.userService.createUser({
|
||||
userDto: registerDTO,
|
||||
userType: registerDTO.type,
|
||||
});
|
||||
|
||||
if (registerDTO.type === 'audience') return user;
|
||||
await this.filmmakerService.create({
|
||||
professionalName: registerDTO.professionalName,
|
||||
userId: user.id,
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Register user for OTP flow if not exists, only for audience
|
||||
@Post('otp/init')
|
||||
async initOTP(@Body() registerDTO: RegisterOneTimePasswordDTO) {
|
||||
try {
|
||||
let user = await this.userService.findUserByEmail(registerDTO.email);
|
||||
if (user) return { email: user.email };
|
||||
// Register user
|
||||
const cognitoUser = await this.authService.registerUser(registerDTO);
|
||||
// Create user in the database
|
||||
user = await this.userService.createUser({
|
||||
userDto: {
|
||||
cognitoId: cognitoUser.UserSub,
|
||||
email: registerDTO.email,
|
||||
legalName: registerDTO.email, // Audience not required legal name
|
||||
},
|
||||
userType: 'audience',
|
||||
});
|
||||
|
||||
return { email: user.email };
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
@UseGuards(HybridAuthGuard)
|
||||
async getMe(@Req() request: Request) {
|
||||
const userFromGuard = (request as RequestUser).user;
|
||||
if (userFromGuard) {
|
||||
return new UserDTO(userFromGuard);
|
||||
}
|
||||
|
||||
if (request.nostrPubkey) {
|
||||
const user = await this.userService.findUserByNostrPubkey(
|
||||
request.nostrPubkey,
|
||||
);
|
||||
if (user) {
|
||||
return new UserDTO(user);
|
||||
}
|
||||
}
|
||||
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
@Post('validate-session')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async validateSession(
|
||||
@Body() validateSessionDTO: ValidateSessionDTO,
|
||||
@User() user: RequestUser['user'],
|
||||
) {
|
||||
return this.authService.validateSession(validateSessionDTO, user.email);
|
||||
}
|
||||
|
||||
@Post('nostr/session')
|
||||
@UseGuards(NostrAuthGuard)
|
||||
async createNostrSession(@Req() request: Request) {
|
||||
if (!request.nostrPubkey) {
|
||||
throw new UnauthorizedException('Missing Nostr pubkey');
|
||||
}
|
||||
|
||||
return await this.nostrSessionService.issueTokens(request.nostrPubkey);
|
||||
}
|
||||
|
||||
@Post('nostr/refresh')
|
||||
async refreshNostrSession(@Body() body: RefreshNostrSessionDto) {
|
||||
const payload = this.nostrSessionService.verifyRefreshToken(
|
||||
body.refreshToken,
|
||||
);
|
||||
return await this.nostrSessionService.issueTokens(payload.sub);
|
||||
}
|
||||
|
||||
@Post('nostr/logout')
|
||||
@UseGuards(NostrSessionJwtGuard)
|
||||
logout() {
|
||||
// Stateless JWT; returning success is equivalent to logout for the client.
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@Post('nostr/link')
|
||||
@UseGuards(JwtAuthGuard, NostrAuthGuard)
|
||||
async linkNostrPubkey(
|
||||
@User() user: RequestUser['user'],
|
||||
@Req() request: Request,
|
||||
) {
|
||||
if (!request.nostrPubkey) {
|
||||
throw new UnauthorizedException('Missing Nostr pubkey');
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedUser = await this.userService.linkNostrPubkey(
|
||||
user.id,
|
||||
request.nostrPubkey,
|
||||
);
|
||||
return { nostrPubkey: updatedUser.nostrPubkey };
|
||||
} catch (error) {
|
||||
throw new BadRequestException(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
@Post('nostr/unlink')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async unlinkNostrPubkey(@User() user: RequestUser['user']) {
|
||||
const updated = await this.userService.unlinkNostrPubkey(user.id);
|
||||
return { nostrPubkey: updated.nostrPubkey };
|
||||
}
|
||||
}
|
||||
46
backend/src/auth/auth.module.ts
Normal file
46
backend/src/auth/auth.module.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Global, Module, forwardRef } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { PassportModule } from '@nestjs/passport';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
|
||||
import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module';
|
||||
import { NostrAuthModule } from 'src/nostr-auth/nostr-auth.module';
|
||||
import { JwtAuthGuard } from './guards/jwt.guard';
|
||||
import { TokenAuthGuard } from './guards/token.guard';
|
||||
import { HybridAuthGuard } from './guards/hybrid-auth.guard';
|
||||
import { NostrSessionService } from './nostr-session.service';
|
||||
import { NostrSessionJwtGuard } from './guards/nostr-session-jwt.guard';
|
||||
import { UsersModule } from 'src/users/users.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
PassportModule.register({ defaultStrategy: 'jwt' }),
|
||||
ConfigModule,
|
||||
FilmmakersModule,
|
||||
SubscriptionsModule,
|
||||
NostrAuthModule,
|
||||
forwardRef(() => UsersModule),
|
||||
],
|
||||
providers: [
|
||||
JwtStrategy,
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
TokenAuthGuard,
|
||||
HybridAuthGuard,
|
||||
NostrSessionService,
|
||||
NostrSessionJwtGuard,
|
||||
],
|
||||
controllers: [AuthController],
|
||||
exports: [
|
||||
AuthService,
|
||||
JwtAuthGuard,
|
||||
TokenAuthGuard,
|
||||
HybridAuthGuard,
|
||||
NostrSessionService,
|
||||
NostrSessionJwtGuard,
|
||||
],
|
||||
})
|
||||
export class AuthModule {}
|
||||
71
backend/src/auth/auth.service.ts
Normal file
71
backend/src/auth/auth.service.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { MailService } from 'src/mail/mail.service';
|
||||
|
||||
/**
|
||||
* Auth service.
|
||||
* Cognito has been removed. Authentication is now handled via:
|
||||
* 1. Nostr NIP-98 HTTP Auth (primary method)
|
||||
* 2. Nostr session JWT tokens (for subsequent requests)
|
||||
*
|
||||
* The Cognito methods are stubbed to maintain API compatibility
|
||||
* while the migration to fully Nostr-based auth is completed.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
private readonly logger = new Logger(AuthService.name);
|
||||
|
||||
constructor(@Inject(MailService) private readonly mailService: MailService) {
|
||||
this.logger.log(
|
||||
'AuthService initialized (Cognito disabled, Nostr-only mode)',
|
||||
);
|
||||
}
|
||||
|
||||
async registerUser(registerRequest: { email: string; password?: string }) {
|
||||
this.logger.warn(
|
||||
'registerUser called -- Cognito is disabled. Use Nostr auth instead.',
|
||||
);
|
||||
return {
|
||||
UserSub: 'nostr-user',
|
||||
UserConfirmed: true,
|
||||
};
|
||||
}
|
||||
|
||||
async authenticateUser(user: { email: string; password: string }) {
|
||||
this.logger.warn(
|
||||
'authenticateUser called -- Cognito is disabled. Use Nostr auth instead.',
|
||||
);
|
||||
throw new BadRequestException(
|
||||
'Cognito authentication is disabled. Please use Nostr login.',
|
||||
);
|
||||
}
|
||||
|
||||
async verifyMfaCode(session: string, code: string, email: string) {
|
||||
throw new BadRequestException('MFA is disabled in Nostr-only mode.');
|
||||
}
|
||||
|
||||
async validateSession(
|
||||
request: { code: string; session?: string; type?: string },
|
||||
email: string,
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Session validation via Cognito is disabled. Use Nostr auth.',
|
||||
);
|
||||
}
|
||||
|
||||
async adminConfirmUser(email: string) {
|
||||
this.logger.warn('adminConfirmUser called -- no-op in Nostr-only mode.');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async deleteUserFromCognito(email: string) {
|
||||
this.logger.warn(
|
||||
'deleteUserFromCognito called -- no-op in Nostr-only mode.',
|
||||
);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshNostrSessionDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
11
backend/src/auth/dto/request/register-otp.dto.ts
Normal file
11
backend/src/auth/dto/request/register-otp.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RegisterOneTimePasswordDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
29
backend/src/auth/dto/request/register.dto.ts
Normal file
29
backend/src/auth/dto/request/register.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
IsEmail,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
} from 'class-validator';
|
||||
|
||||
export class RegisterDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
legalName: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
professionalName?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
cognitoId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(['filmmaker', 'audience'])
|
||||
type: 'filmmaker' | 'audience';
|
||||
}
|
||||
6
backend/src/auth/dto/request/request-user.interface.ts
Normal file
6
backend/src/auth/dto/request/request-user.interface.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Request } from 'express';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
|
||||
export interface RequestUser extends Request {
|
||||
user: User;
|
||||
}
|
||||
15
backend/src/auth/dto/request/validate-session.dto.ts
Normal file
15
backend/src/auth/dto/request/validate-session.dto.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { IsNotEmpty, IsString, IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
export class ValidateSessionDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
code: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
session?: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEnum(['password', 'mfa'])
|
||||
type: 'password' | 'mfa';
|
||||
}
|
||||
38
backend/src/auth/guards/admin.guard.ts
Normal file
38
backend/src/auth/guards/admin.guard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
try {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
if (
|
||||
!timingSafeEqual(
|
||||
Buffer.from(token),
|
||||
Buffer.from(process.env.ADMIN_API_KEY),
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error(`Error validating token: ${error.message}`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
93
backend/src/auth/guards/hybrid-auth.guard.ts
Normal file
93
backend/src/auth/guards/hybrid-auth.guard.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { NostrAuthGuard } from '../../nostr-auth/nostr-auth.guard';
|
||||
import { JwtAuthGuard } from './jwt.guard';
|
||||
import { NostrSessionJwtGuard } from './nostr-session-jwt.guard';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
interface GuardResult {
|
||||
success: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class HybridAuthGuard extends AuthGuard('hybrid') {
|
||||
constructor(
|
||||
private readonly nostrAuthGuard: NostrAuthGuard,
|
||||
private readonly nostrSessionJwtGuard: NostrSessionJwtGuard,
|
||||
private readonly jwtAuthGuard: JwtAuthGuard,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authorization = this.normalizeAuthorizationHeader(request);
|
||||
const isNostrRequest = authorization?.toLowerCase().startsWith('nostr ');
|
||||
|
||||
let nostrResult: GuardResult | undefined;
|
||||
if (isNostrRequest) {
|
||||
nostrResult = await this.tryActivate(this.nostrAuthGuard, context);
|
||||
if (nostrResult.success) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const nostrJwtResult = await this.tryActivate(
|
||||
this.nostrSessionJwtGuard,
|
||||
context,
|
||||
);
|
||||
if (nostrJwtResult.success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const jwtResult = await this.tryActivate(this.jwtAuthGuard, context);
|
||||
if (jwtResult.success) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger.log(
|
||||
`HybridAuthGuard failed: isNostrRequest=${isNostrRequest}, nostrError=${nostrResult?.error?.message}, nostrJwtError=${nostrJwtResult.error?.message}, jwtError=${jwtResult.error?.message}`,
|
||||
);
|
||||
if (isNostrRequest && nostrResult?.error) {
|
||||
throw nostrResult.error;
|
||||
}
|
||||
|
||||
if (jwtResult.error) {
|
||||
throw jwtResult.error;
|
||||
}
|
||||
|
||||
if (nostrResult?.error) {
|
||||
throw nostrResult.error;
|
||||
}
|
||||
|
||||
if (nostrJwtResult.error) {
|
||||
throw nostrJwtResult.error;
|
||||
}
|
||||
|
||||
throw new UnauthorizedException('Authentication failed');
|
||||
}
|
||||
|
||||
private async tryActivate(
|
||||
guard: CanActivate,
|
||||
context: ExecutionContext,
|
||||
): Promise<GuardResult> {
|
||||
try {
|
||||
const result = await guard.canActivate(context);
|
||||
return { success: Boolean(result) };
|
||||
} catch (error) {
|
||||
return { success: false, error: error as Error };
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeAuthorizationHeader(request: Request): string | undefined {
|
||||
const header = request.headers.authorization;
|
||||
return Array.isArray(header) ? header[0] : header;
|
||||
}
|
||||
}
|
||||
27
backend/src/auth/guards/jwt.guard.ts
Normal file
27
backend/src/auth/guards/jwt.guard.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
/**
|
||||
* JWT Auth Guard stub.
|
||||
* Cognito JWT verification has been removed.
|
||||
* This guard now always rejects -- all auth should go through
|
||||
* NostrSessionJwtGuard or NostrAuthGuard via the HybridAuthGuard.
|
||||
*
|
||||
* Kept for API compatibility with endpoints that still reference JwtAuthGuard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
Logger.warn(
|
||||
'JwtAuthGuard.canActivate called -- Cognito is disabled. Use Nostr auth.',
|
||||
);
|
||||
throw new UnauthorizedException(
|
||||
'Cognito authentication is disabled. Use Nostr login.',
|
||||
);
|
||||
}
|
||||
}
|
||||
49
backend/src/auth/guards/nostr-session-jwt.guard.ts
Normal file
49
backend/src/auth/guards/nostr-session-jwt.guard.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import { NostrSessionService } from '../nostr-session.service';
|
||||
import { UsersService } from '../../users/users.service';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
@Injectable()
|
||||
export class NostrSessionJwtGuard extends AuthGuard('nostr-session-jwt') {
|
||||
constructor(
|
||||
private readonly sessionService: NostrSessionService,
|
||||
private readonly usersService: UsersService,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context
|
||||
.switchToHttp()
|
||||
.getRequest<Request & { user?: unknown }>();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = this.sessionService.verifyAccessToken(token);
|
||||
request.nostrPubkey = payload.sub;
|
||||
if (payload.uid) {
|
||||
const user = await this.usersService.findUsersById(payload.uid);
|
||||
request.user = user;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) {
|
||||
throw error;
|
||||
}
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: Request): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
38
backend/src/auth/guards/token.guard.ts
Normal file
38
backend/src/auth/guards/token.guard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Injectable,
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
|
||||
@Injectable()
|
||||
export class TokenAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
try {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = this.extractTokenFromHeader(request);
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('No token provided');
|
||||
}
|
||||
if (
|
||||
!timingSafeEqual(
|
||||
Buffer.from(token),
|
||||
Buffer.from(process.env.TRANSCODING_API_KEY),
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.error(`Error validating token: ${error.message}`);
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
}
|
||||
|
||||
private extractTokenFromHeader(request: any): string | undefined {
|
||||
const [type, token] = request.headers.authorization?.split(' ') ?? [];
|
||||
return type === 'Bearer' ? token : undefined;
|
||||
}
|
||||
}
|
||||
31
backend/src/auth/jwt.strategy.ts
Normal file
31
backend/src/auth/jwt.strategy.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* JWT Strategy stub.
|
||||
* Cognito JWKS verification has been removed.
|
||||
* This strategy uses a dummy secret and will never actually validate
|
||||
* because JwtAuthGuard now always rejects.
|
||||
* All auth goes through NostrSessionJwtGuard or NostrAuthGuard.
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor() {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
secretOrKey: process.env.NOSTR_JWT_SECRET || 'dummy-key-cognito-disabled',
|
||||
issuer: undefined,
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
|
||||
Logger.warn(
|
||||
'JwtStrategy initialized with dummy config (Cognito disabled)',
|
||||
'AuthModule',
|
||||
);
|
||||
}
|
||||
|
||||
public async validate(payload: any) {
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
123
backend/src/auth/nostr-session.service.ts
Normal file
123
backend/src/auth/nostr-session.service.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { sign, verify } from 'jsonwebtoken';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { UsersService } from 'src/users/users.service';
|
||||
|
||||
export interface NostrSessionTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}
|
||||
|
||||
export interface NostrSessionPayload {
|
||||
sub: string;
|
||||
typ: 'nostr-session' | 'nostr-refresh';
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NostrSessionService {
|
||||
private readonly logger = new Logger(NostrSessionService.name);
|
||||
private readonly accessSecret = process.env.NOSTR_JWT_SECRET;
|
||||
private readonly refreshSecret =
|
||||
process.env.NOSTR_JWT_REFRESH_SECRET ?? this.accessSecret;
|
||||
private readonly accessTtlSeconds = Number(
|
||||
process.env.NOSTR_JWT_TTL ?? 15 * 60,
|
||||
);
|
||||
private readonly refreshTtlSeconds = Number(
|
||||
process.env.NOSTR_JWT_REFRESH_TTL ?? 60 * 60 * 24 * 14,
|
||||
);
|
||||
|
||||
constructor(
|
||||
@Inject(forwardRef(() => UsersService))
|
||||
private readonly usersService: UsersService,
|
||||
) {
|
||||
if (!this.accessSecret) {
|
||||
throw new Error('NOSTR_JWT_SECRET is not configured');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue JWT tokens for a Nostr pubkey.
|
||||
* Auto-creates a user record on first login so that /auth/me works immediately.
|
||||
*/
|
||||
async issueTokens(pubkey: string): Promise<NostrSessionTokens> {
|
||||
let user = await this.usersService.findUserByNostrPubkey(pubkey);
|
||||
|
||||
// Auto-provision a user on first Nostr login
|
||||
if (!user) {
|
||||
this.logger.log(`First Nostr login for ${pubkey.slice(0, 12)}… — creating user`);
|
||||
try {
|
||||
user = await this.usersService.createNostrUser(pubkey);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to auto-create user for ${pubkey.slice(0, 12)}…: ${error.message}`,
|
||||
);
|
||||
}
|
||||
} else if (!user.filmmaker) {
|
||||
// Existing user without filmmaker profile — backfill
|
||||
this.logger.log(`Backfilling filmmaker for ${pubkey.slice(0, 12)}…`);
|
||||
try {
|
||||
user = await this.usersService.ensureFilmmaker(user);
|
||||
} catch (error) {
|
||||
this.logger.warn(`Filmmaker backfill failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: NostrSessionPayload = {
|
||||
sub: pubkey,
|
||||
typ: 'nostr-session',
|
||||
uid: user?.id,
|
||||
};
|
||||
const refreshPayload: NostrSessionPayload = {
|
||||
sub: pubkey,
|
||||
typ: 'nostr-refresh',
|
||||
uid: user?.id,
|
||||
};
|
||||
|
||||
const accessToken = sign(payload, this.accessSecret, {
|
||||
expiresIn: this.accessTtlSeconds,
|
||||
});
|
||||
|
||||
const refreshToken = sign(refreshPayload, this.refreshSecret, {
|
||||
expiresIn: this.refreshTtlSeconds,
|
||||
});
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: this.accessTtlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
verifyAccessToken(token: string): NostrSessionPayload {
|
||||
return this.verifyToken(token, this.accessSecret, 'nostr-session');
|
||||
}
|
||||
|
||||
verifyRefreshToken(token: string): NostrSessionPayload {
|
||||
return this.verifyToken(token, this.refreshSecret, 'nostr-refresh');
|
||||
}
|
||||
|
||||
private verifyToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
expectedType: NostrSessionPayload['typ'],
|
||||
): NostrSessionPayload {
|
||||
try {
|
||||
const payload = verify(token, secret) as NostrSessionPayload;
|
||||
if (payload.typ !== expectedType) {
|
||||
throw new UnauthorizedException('Invalid token type');
|
||||
}
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) throw error;
|
||||
throw new UnauthorizedException('Invalid or expired token');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/user.decorator.ts
Normal file
8
backend/src/auth/user.decorator.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
|
||||
export const User = createParamDecorator(
|
||||
(data: unknown, context: ExecutionContext) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
return request.user;
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user