Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
import { ForgotPasswordProvider } from './providers/forgot-password.provider';
import { ResetPasswordProvider } from './providers/reset-password.provider';
import { MailService } from './providers/mail.service';
import { NonceService } from './providers/nonce.service';

@Module({
imports: [
Expand All @@ -44,6 +45,7 @@ import { MailService } from './providers/mail.service';
ForgotPasswordProvider,
ResetPasswordProvider,
MailService,
NonceService,
{
provide: HashingProvider, // Use the abstract class as a token
useClass: BcryptProvider, // Bind it to the concrete implementation
Expand All @@ -58,6 +60,7 @@ import { MailService } from './providers/mail.service';
AuthService,
HashingProvider,
GoogleAuthenticationService,
NonceService,
],
})
export class AuthModule {}
4 changes: 2 additions & 2 deletions backend/src/auth/authConfig/jwt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import { registerAs } from '@nestjs/config';
export default registerAs('jwt', () => {
return {
secret: process.env.JWT_SECRET,
audience: process.env.JWT_TOKEN_AUDIENCE,
audience: process.env.JWT_TOKEN_AUDIENCE ?? 'localhost',
googleClient_id: process.env.GOOGLE_CLIENT_ID,
googleClient_secret: process.env.GOOGLE_CLIENT_SECRET,
issuer: process.env.JWT_TOKEN_ISSUER,
issuer: process.env.JWT_TOKEN_ISSUER ?? 'localhost',
ttl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600'),
};
});
53 changes: 11 additions & 42 deletions backend/src/auth/providers/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,10 @@ import { ForgotPasswordProvider } from './forgot-password.provider';
import { ResetPasswordProvider } from './reset-password.provider';
import { ForgotPasswordDto } from '../dtos/forgot-password.dto';
import { ResetPasswordDto } from '../dtos/reset-password.dto';
import { NonceService } from './nonce.service';

@Injectable()
export class AuthService {
private nonces = new Map<
string,
{ walletAddress: string; expiresAt: number; used: boolean }
>();

constructor(
/**
* inject signInProvider
Expand All @@ -44,6 +40,11 @@ export class AuthService {
* inject resetPasswordProvider
*/
private readonly resetPasswordProvider: ResetPasswordProvider,

/**
* inject nonceService
*/
private readonly nonceService: NonceService,
) {}

public async SignIn(signInDto: LoginDto) {
Expand All @@ -65,22 +66,18 @@ export class AuthService {
const nonce = this.createSecureNonce(walletAddress);
const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes from now

// Store nonce
this.nonces.set(nonce, {
walletAddress,
expiresAt,
used: false,
});
// Store nonce via NonceService
this.nonceService.storeNonce(nonce, walletAddress, expiresAt);

// Clean up expired nonces periodically
this.cleanupExpiredNonces();
this.nonceService.cleanupExpiredNonces();

return { nonce, expiresAt };
}

// Check nonce status (useful for debugging)
public checkNonceStatus(nonce: string) {
const nonceData = this.nonces.get(nonce);
const nonceData = this.nonceService.getNonce(nonce);

if (!nonceData) {
return { valid: false, reason: 'Nonce not found' };
Expand All @@ -103,27 +100,7 @@ export class AuthService {

// Verify and mark nonce as used (called by StellarWalletLoginProvider)
public verifyAndUseNonce(nonce: string, walletAddress: string): void {
const nonceData = this.nonces.get(nonce);

if (!nonceData) {
throw new BadRequestException('Invalid nonce');
}

if (nonceData.used) {
throw new BadRequestException('Nonce already used');
}

if (Date.now() > nonceData.expiresAt) {
throw new BadRequestException('Nonce expired');
}

if (nonceData.walletAddress !== walletAddress) {
throw new BadRequestException('Nonce wallet address mismatch');
}

// Mark as used
nonceData.used = true;
this.nonces.set(nonce, nonceData);
this.nonceService.verifyAndUseNonce(nonce, walletAddress);
}

private createSecureNonce(walletAddress: string): string {
Expand All @@ -139,14 +116,6 @@ export class AuthService {
return /^[GM][A-Z2-7]{55}$/.test(address);
}

private cleanupExpiredNonces(): void {
const now = Date.now();
for (const [nonce, data] of this.nonces.entries()) {
if (now > data.expiresAt) {
this.nonces.delete(nonce);
}
}
}

/**
* Handles refreshing of access tokens
Expand Down
70 changes: 70 additions & 0 deletions backend/src/auth/providers/nonce.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Injectable, BadRequestException } from '@nestjs/common';

@Injectable()
export class NonceService {
private nonces = new Map<
string,
{ walletAddress: string; expiresAt: number; used: boolean }
>();

public storeNonce(nonce: string, walletAddress: string, expiresAt: number): void {
this.nonces.set(nonce, {
walletAddress,
expiresAt,
used: false,
});
console.log(`[NonceService] Stored nonce: ${nonce}. Total: ${this.nonces.size}`);
}

public getNonce(nonce: string) {
return this.nonces.get(nonce);
}

public markAsUsed(nonce: string): void {
const data = this.nonces.get(nonce);
if (data) {
data.used = true;
this.nonces.set(nonce, data);
}
}

public verifyAndUseNonce(nonce: string, walletAddress: string): void {
console.log(`[NonceService] Verifying nonce: ${nonce} for wallet ${walletAddress}`);
const nonceData = this.nonces.get(nonce);

if (!nonceData) {
const known = Array.from(this.nonces.keys()).join(', ');
console.error(`[NonceService] Nonce NOT FOUND: ${nonce}. Map size: ${this.nonces.size}. Known nonces: ${known}`);
throw new BadRequestException('Invalid nonce');
}

if (nonceData.used) {
throw new BadRequestException('Nonce already used');
}

if (Date.now() > nonceData.expiresAt) {
throw new BadRequestException('Nonce expired');
}

if (nonceData.walletAddress !== walletAddress) {
throw new BadRequestException('Nonce wallet address mismatch');
}

// Mark as used
this.markAsUsed(nonce);
}

public cleanupExpiredNonces(): void {
const now = Date.now();
let count = 0;
for (const [nonce, data] of this.nonces.entries()) {
if (now > data.expiresAt) {
this.nonces.delete(nonce);
count++;
}
}
if (count > 0) {
console.log(`[NonceService] Cleaned up ${count} expired nonces.`);
}
}
}
86 changes: 49 additions & 37 deletions backend/src/auth/providers/wallet-login.provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt';
import { ConfigType } from '@nestjs/config';
import { UsersService } from '../../users/providers/users.service';
import jwtConfig from '../authConfig/jwt.config';
import { AuthService } from './auth.service';
import { NonceService } from './nonce.service';
import * as StellarSdk from 'stellar-sdk';
import * as crypto from 'crypto';
import { StellarWalletLoginDto } from '../dtos/walletLogin.dto';
Expand All @@ -24,8 +24,7 @@ export class StellarWalletLoginProvider {
// inject jwt service
private readonly jwtService: JwtService,

@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService,
private readonly nonceService: NonceService,

// inject jwt
@Inject(jwtConfig.KEY)
Expand All @@ -35,7 +34,7 @@ export class StellarWalletLoginProvider {
public async StellarWalletLogin(dto: StellarWalletLoginDto) {
try {
// 1. Verify nonce hasn't been used and use it (synchronous method)
this.authService.verifyAndUseNonce(dto.nonce, dto.walletAddress);
this.nonceService.verifyAndUseNonce(dto.nonce, dto.walletAddress);

// 2. Create proper message to sign
const message = this.createLoginMessage(dto.walletAddress, dto.nonce);
Expand All @@ -59,19 +58,28 @@ export class StellarWalletLoginProvider {
}

// Check if user exists in db
let user = await this.userService.getOneByWallet(dto.walletAddress);
let user: any = null;
try {
user = await this.userService.getOneByWallet(dto.walletAddress);
} catch (error) {
// If user doesn't exist, getOneByWallet throws UnauthorizedException
// We catch it here so we can proceed to auto-create the user
console.log(`User not found for wallet ${dto.walletAddress}, will attempt auto-creation.`);
}

if (!user) {
// Auto-create a new user
user = await this.userService.create({
walletAddress: dto.walletAddress,
publicKey: dto.publicKey,
username: `stellar_user_${dto.walletAddress.slice(0, 6)}`,
provider: 'stellar_wallet',
username: `stellar_user_${dto.walletAddress.slice(-6)}`,
fullname: `Stellar User ${dto.walletAddress.slice(0, 4)}...${dto.walletAddress.slice(-4)}`,
provider: 'wallet',
challengeLevel: ChallengeLevel.BEGINNER,
challengeTypes: [],
ageGroup: AgeGroup.TEENS,
});
console.log(`Successfully created new wallet user: ${user.id}`);
}

const accessToken = await this.jwtService.signAsync(
Expand All @@ -90,50 +98,54 @@ export class StellarWalletLoginProvider {
}

private createLoginMessage(walletAddress: string, nonce: string): string {
// Create a standardized message format for Stellar
return `Login to MyApp\nWallet: ${walletAddress}\nNonce: ${nonce}\nTimestamp: ${Date.now()}`;
// Simply sign the nonce to ensure determinism between frontend and backend
return nonce;
}

private verifySignature(
message: string,
signature: string,
publicKey: string,
): boolean {
const prefix = 'Stellar Signed Message:\n';
const fullMessage = prefix + message;

console.log(`[Signature Verification]
Public Key: ${publicKey}
Raw Message: ${message}
Full Message (SEP-0053): ${JSON.stringify(fullMessage)}
Signature (base64): ${signature}`);

try {
// Convert message to buffer
const messageBuffer = Buffer.from(message, 'utf8');
// 1. Convert combined message to UTF-8 bytes
const messageBuffer = Buffer.from(fullMessage, 'utf8');

// 2. Compute SHA-256 hash of the prefixed message (Standard for SEP-0053)
const messageHash = crypto
.createHash('sha256')
.update(messageBuffer)
.digest();

// Convert signature from base64 to buffer
// 3. Convert signature from base64 to buffer
const signatureBuffer = Buffer.from(signature, 'base64');

// Convert public key from Stellar format to PEM encoded ed25519 public key
const stellarKeypair = StellarSdk.Keypair.fromPublicKey(publicKey);
const publicKeyBuffer = stellarKeypair.rawPublicKey();

// Convert raw public key to PEM format
const publicKeyPem =
'-----BEGIN PUBLIC KEY-----\n' +
Buffer.from(publicKeyBuffer)
.toString('base64')
.match(/.{1,64}/g)
?.join('\n') +
'\n-----END PUBLIC KEY-----\n';

// Verify signature using ed25519
const isValid = crypto.verify(
null, // algorithm is null for ed25519
messageBuffer,
{
key: publicKeyPem,
format: 'pem',
type: 'spki',
},
signatureBuffer,
);
// 4. Use Stellar SDK's native verification on the HASH
const keypair = StellarSdk.Keypair.fromPublicKey(publicKey);
const isValid = keypair.verify(messageHash, signatureBuffer);

if (!isValid) {
console.error(
`[Signature Verification Result] FAILED for ${publicKey}`,
);
} else {
console.log(
`[Signature Verification Result] SUCCESS for ${publicKey}`,
);
}

return isValid;
} catch (error) {
console.error('Signature verification error:', error);
console.error('[Signature Verification Error]', error);
return false;
}
}
Expand Down
Loading
Loading