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
59 changes: 38 additions & 21 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,30 +1,47 @@
# ============================================
# Anchor API variables
# ============================================
ANCHOR_API_BASE_URL=https://api.example.com/anchor
STELLAR_NETWORK="testnet"
STELLAR_RPC_URL="https://soroban-testnet.stellar.org"
INSURANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
REMITTANCE_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
SAVINGS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"
BILLS_CONTRACT_ID="CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN"

# Session encryption for wallet-based auth (required for /api/auth/*).
# Generate with: openssl rand -base64 32
SESSION_PASSWORD=your-32-char-minimum-secret-here

# Shared secret used to verify Anchor webhooks
ANCHOR_WEBHOOK_SECRET=your_shared_anchor_secret_here

# Soroban/Stellar Configuration
# ============================================
# Stellar / Soroban Network
# ============================================
STELLAR_NETWORK=testnet
STELLAR_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_NETWORK_PASSPHRASE=Test SDF Network ; September 2015

# USDC Token Configuration (optional, defaults to testnet USDC issuer)
# ============================================
# Contract IDs (Testnet placeholders)
# ============================================
INSURANCE_CONTRACT_ID=CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN
REMITTANCE_CONTRACT_ID=CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN
SAVINGS_CONTRACT_ID=CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN
BILLS_CONTRACT_ID=CACDYF3CYMJEJTIVFESQYZTN67GO2R5D5IUABTCUG3HXQSRXCSOROBAN

# ============================================
# Session Encryption (MUST be 32+ characters)
# ============================================
SESSION_PASSWORD=supersecurelongsessionpasswordatleast32characters!!

# ============================================
# Auth Secret (used in tests / API)
# ============================================
AUTH_SECRET=test-secret-for-local-dev-only

# ============================================
# Anchor Webhook Secret
# ============================================
ANCHOR_WEBHOOK_SECRET=local-anchor-webhook-secret

# ============================================
# USDC Token (Testnet)
# ============================================
USDC_ISSUER_ADDRESS=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
# API Middleware Configuration
# Frontend application URL for CORS policy (required for /api routes)
# Requests from this origin will be allowed by CORS middleware
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Maximum request body size for POST/PUT/PATCH in bytes (default 1MB)
# Requests exceeding this size will receive a 413 Payload Too Large response
# ============================================
# Frontend / API Config
# ============================================
NEXT_PUBLIC_APP_URL=http://localhost:3000
API_MAX_BODY_SIZE=1048576

1 change: 1 addition & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ NEXT_PUBLIC_STELLAR_RPC_URL=https://soroban-testnet.stellar.org

# Soroban Contract Addresses
NEXT_PUBLIC_SAVINGS_GOALS_CONTRACT_ID=your_contract_id_here

4 changes: 1 addition & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
{
"extends": "next/core-web-vitals"
}
"extends": "next/core-web-vitals"
"extends": ["next/core-web-vitals"]
}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts

# Prisma / SQLite
prisma/dev.db
prisma/dev.db-journal
*.db
140 changes: 47 additions & 93 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,8 @@
import { NextRequest, NextResponse } from 'next/server';
import { Keypair } from '@stellar/stellar-sdk';
import { getAndClearNonce } from '@/lib/auth-cache';
import {
createSession,
getSessionCookieHeader,
} from '@/lib/session';

import { Keypair, StrKey } from '@stellar/stellar-sdk';
import { getNonce, deleteNonce } from '@/lib/auth/nonce-store';
import { getTranslator } from '@/lib/i18n';
import {
createSession,
getSessionCookieHeader,
} from '../../../../lib/session';
import { getAndClearNonce } from '@/lib/auth-cache';
import { createSession, getSessionCookieHeader } from '@/lib/session';
import { prisma } from '@/lib/prisma';

// Force dynamic rendering for this route
export const dynamic = 'force-dynamic';
Expand All @@ -21,123 +11,87 @@ export const runtime = 'nodejs';
/**
* POST /api/auth/login
* Verify a signature and authenticate user
*
* Request Body:
* - address: Stellar public key
* - message: The nonce that was signed
* - signature: Base64-encoded signature
*/

export const dynamic = 'force-dynamic';

/**
* Wallet-based auth flow:
* 1. Frontend: user connects wallet (e.g. Freighter), gets address.
* 2. Frontend: GET /api/auth/nonce?address={address} to get a random nonce.
* 3. Frontend: sign the hex nonce with wallet, encode as base64.
* 4. Frontend: POST /api/auth/login with { address, signature }.
* 5. Backend: verify signature with Keypair; create encrypted session cookie.
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { address, message, signature } = body;
const t = getTranslator(request.headers.get('accept-language'));

if (!address || !message || !signature) {
return NextResponse.json(
{ error: t('errors.address_signature_required') || 'Missing required fields: address, message, signature' },
{ error: 'Missing required fields: address, message, signature' },
{ status: 400 }
);
}

// Validate Stellar address format
if (!StrKey.isValidEd25519PublicKey(address)) {
return NextResponse.json(
{ error: t('errors.invalid_address_format') || 'Invalid Stellar address format' },
{ error: 'Invalid Stellar address format' },
{ status: 400 }
);
}

// Verify nonce exists and hasn't expired
const storedNonce = getNonce(address);
// Verify nonce exists and matches (atomic read + delete)
const storedNonce = getAndClearNonce(address);
if (!storedNonce || storedNonce !== message) {
return NextResponse.json(
{ error: t('errors.nonce_expired') || 'Invalid or expired nonce' },
{ error: 'Invalid or expired nonce' },
{ status: 401 }
);
}

try {
// Verify the signature
const keypair = Keypair.fromPublicKey(address);
const messageBuffer = Buffer.from(message, 'utf8');
const signatureBuffer = Buffer.from(signature, 'base64');

const isValid = keypair.verify(messageBuffer, signatureBuffer);

if (!isValid) {
return NextResponse.json(
{ error: t('errors.invalid_signature') || 'Invalid signature' },
{ status: 401 }
);
}
// Verify signature
// The client signs Buffer.from(nonce, 'utf8') so we must decode the same way
const keypair = Keypair.fromPublicKey(address);
const messageBuffer = Buffer.from(message, 'utf8');
const signatureBuffer = Buffer.from(signature, 'base64');

// Delete used nonce (one-time use)
deleteNonce(address);
const isValid = keypair.verify(messageBuffer, signatureBuffer);

// Create session cookie like from HEAD
const sealed = await createSession(address);
const cookieHeader = getSessionCookieHeader(sealed);

return new Response(
JSON.stringify({
success: true,
token: `mock-jwt-${address.substring(0, 10)}`, // Keeping this property for compatibility with main branch frontend changes
address
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': cookieHeader,
},
}
);

} catch (verifyError) {
console.error('Signature verification error:', verifyError);
if (!isValid) {
return NextResponse.json(
{ error: t('errors.signature_verification_failed') || 'Invalid signature' },
{ error: 'Invalid signature' },
{ status: 401 }
);
}

// Upsert user in database (best-effort — don't fail login if DB is unavailable)
try {
await prisma.user.upsert({
where: { stellar_address: address },
update: {},
create: {
stellar_address: address,
preferences: {
create: {},
},
},
});
} catch (dbErr) {
console.warn('DB upsert skipped (non-fatal):', dbErr);
}

// Create encrypted session
const sealed = await createSession(address);
const cookieHeader = getSessionCookieHeader(sealed);

return new Response(
JSON.stringify({ success: true, address, token: sealed }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': cookieHeader,
},
}
);
} catch (err) {
console.error('Login error:', err);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
const response = NextResponse.json({
success: true,
address,
});

response.headers.set(
'Set-Cookie',
getSessionCookieHeader(sealed)
);

return response;

} catch (error) {
console.error('Error during login:', error);
const t = getTranslator(request.headers.get('accept-language'));
console.error('Login error:', error);
return NextResponse.json(
{ error: t('errors.internal_server_error') || 'Internal Server Error' },
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}
}
Loading
Loading