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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Soroban RPC Settings
SOROBAN_RPC_URL=https://soroban-testnet.stellar.org
SOROBAN_NETWORK_PASSPHRASE="Test SDF Network ; September 2015"

# Session encryption for wallet-based auth (required for /api/auth/*).
# Generate with: openssl rand -base64 32
SESSION_PASSWORD=your-32-char-minimum-secret-here
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ jobs:
env:
TEST_WALLET_ADDRESS: "GDEMOXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
TEST_SIGNATURE: "mock-signature"
# --- ADDED MOCK ENV VARS FOR CI ---
SESSION_PASSWORD: "dummy-test-session-password-must-be-32-chars"
SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org"
SOROBAN_NETWORK_PASSPHRASE: "Test SDF Network ; September 2015"
ANCHOR_WEBHOOK_SECRET: "dummy-test-secret"

steps:
- name: Checkout code
Expand All @@ -24,7 +29,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20

- name: Install dependencies
run: npm ci
Expand All @@ -41,4 +46,4 @@ jobs:
with:
name: playwright-report
path: playwright-report/
retention-days: 14
retention-days: 14
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ The frontend includes placeholder pages and components for:

## Getting Started

### Environment Configuration
The application requires connection to a Soroban RPC node.
- **Testnet:** `https://soroban-testnet.stellar.org` (Passphrase: `Test SDF Network ; September 2015`)
- **Mainnet:** `https://soroban-rpc.stellar.org` (Passphrase: `Public Global Stellar Network ; September 2015`)

### Prerequisites

- Node.js 18+
Expand Down
113 changes: 21 additions & 92 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,116 +8,45 @@ export async function POST(request: Request) {
const { address, signature } = body;

if (!address || !signature) {
return NextResponse.json({ error: 'Address and signature are required' }, { status: 400 });
return NextResponse.json(
{ error: 'Address and signature are required' },
{ status: 400 }
);
}



// Retrieve and clear nonce
// Retrieve and clear nonce — returns null if missing or expired
const nonce = getAndClearNonce(address);
if (!nonce) {
return NextResponse.json({ error: 'Nonce expired or missing. Please request a new nonce.' }, { status: 401 });
return NextResponse.json(
{ error: 'Nonce expired or missing. Please request a new nonce.' },
{ status: 401 }
);
}

// Verify signature
try {
const keypair = Keypair.fromPublicKey(address);
// Nonce is stored as hex String. Message to verify must match.
// Signature is assumed to be base64 from the client.
const isValid = keypair.verify(Buffer.from(nonce, 'hex'), Buffer.from(signature, 'base64'));
// Nonce is stored as hex string; signature is base64 from the client.
const isValid = keypair.verify(
Buffer.from(nonce, 'hex'),
Buffer.from(signature, 'base64')
);

if (!isValid) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
} catch (verifError) {
return NextResponse.json({ error: 'Signature verification failed' }, { status: 401 });
} catch {
return NextResponse.json(
{ error: 'Signature verification failed' },
{ status: 401 }
);
}

const response = NextResponse.json({ success: true, token: 'mock-session-token' });
// Set a mock session cookie for subsequent protected requests
response.cookies.set('session', 'mock-session-cookie', { httpOnly: true, path: '/' });
return response;

} catch (error) {
return NextResponse.json({ error: 'Bad Request' }, { status: 400 });
import { NextRequest } from 'next/server';
import { Keypair } from '@stellar/stellar-sdk';
import {
createSession,
getSessionCookieHeader,
} from '../../../../lib/session';

export const dynamic = 'force-dynamic';

/**
* Wallet-based auth flow:
* 1. Frontend: user connects wallet (e.g. Freighter), gets address.
* 2. Frontend: build a nonce message (e.g. "Sign in to Remitwise at {timestamp}").
* 3. Frontend: sign message with wallet (Keypair.fromSecretKey(secret).sign(Buffer.from(message, 'utf8'))), encode as base64.
* 4. Frontend: POST /api/auth/login with { address, message, signature } (credentials sent in body; cookie set in response).
* 5. Backend: verify with Keypair.fromPublicKey(address).verify(messageBuffer, signatureBuffer); create encrypted session cookie.
* Env: SESSION_PASSWORD (min 32 chars, e.g. openssl rand -base64 32).
*/

export interface LoginBody {
address: string;
signature: string;
message: string;
}

function verifyStellarSignature(
address: string,
message: string,
signatureBase64: string
): boolean {
try {
const keypair = Keypair.fromPublicKey(address);
const data = Buffer.from(message, 'utf8');
const signature = Buffer.from(signatureBase64, 'base64');
return keypair.verify(data, signature);
} catch {
return false;
}
}

export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as LoginBody;
const { address, signature, message } = body;

if (!address || !signature || !message) {
return Response.json(
{ error: 'Bad request', message: 'address, signature, and message are required' },
{ status: 400 }
);
}

if (!verifyStellarSignature(address, message, signature)) {
return Response.json(
{ error: 'Unauthorized', message: 'Invalid signature' },
{ status: 401 }
);
}

const sealed = await createSession(address);
const cookieHeader = getSessionCookieHeader(sealed);

return new Response(
JSON.stringify({ ok: true, address }),
{
status: 200,
headers: {
'Content-Type': 'application/json',
'Set-Cookie': cookieHeader,
},
}
);
} catch (err) {
console.error('Login error:', err);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
return NextResponse.json({ error: 'Bad Request' }, { status: 400 });
}
}

}
57 changes: 57 additions & 0 deletions app/api/health/soroban/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* app/api/health/route.ts
*
* GET /api/health
*
* Returns 200 when the Soroban RPC node is reachable, 503 otherwise.
* This satisfies the acceptance criterion:
* "Client used in at least one route (e.g. health or contract read)"
*/

import { NextResponse } from "next/server";
import {
getLatestLedger,
getNetworkPassphrase,
SorobanClientError,
} from "@/lib/soroban/client";

export const runtime = "nodejs";

export async function GET() {
try {
const ledger = await getLatestLedger();

return NextResponse.json(
{
status: "ok",
soroban: {
rpcReachable: true,
latestLedger: ledger.sequence,
protocolVersion: ledger.protocolVersion,
networkPassphrase: getNetworkPassphrase(),
},
timestamp: new Date().toISOString(),
},
{ status: 200 }
);
} catch (err) {
const message =
err instanceof SorobanClientError
? err.message
: "Unexpected error contacting Soroban RPC";

console.error("[/api/health] Soroban RPC unreachable:", err);

return NextResponse.json(
{
status: "degraded",
soroban: {
rpcReachable: false,
error: message,
},
timestamp: new Date().toISOString(),
},
{ status: 503 }
);
}
}
71 changes: 55 additions & 16 deletions lib/auth-cache.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,63 @@
export const nonceCache = new Map<string, { nonce: string; expiresAt: number }>();
/**
* In-memory nonce cache for wallet-based authentication.
*
* Each nonce is single-use: getAndClearNonce() atomically reads and
* deletes it so replay attacks are impossible.
*
* TTL: nonces expire after 5 minutes. A background timer sweeps
* stale entries every minute so memory doesn't grow unbounded.
*/

export const NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes

export function setNonce(address: string, nonce: string) {
nonceCache.set(address, {
nonce,
expiresAt: Date.now() + NONCE_TTL_MS,
});
interface NonceEntry {
nonce: string;
expiresAt: number; // ms epoch
}

export function getAndClearNonce(address: string): string | null {
const data = nonceCache.get(address);
if (!data) return null;
const NONCE_TTL_MS = 5 * 60 * 1000; // 5 minutes
const SWEEP_INTERVAL_MS = 60 * 1000; // 1 minute

// Always delete the nonce after retrieving it to prevent replay attacks
nonceCache.delete(address);
const cache = new Map<string, NonceEntry>();

if (Date.now() > data.expiresAt) {
return null; // Expired
// ── Sweep expired entries ──────────────────────
// setInterval keeps the module alive in long-running processes;
// unref() prevents it from blocking process exit in tests/CLI.
const sweepTimer = setInterval(() => {
const now = Date.now();
for (const [address, entry] of cache.entries()) {
if (entry.expiresAt <= now) {
cache.delete(address);
}
}
}, SWEEP_INTERVAL_MS);

if (typeof sweepTimer.unref === 'function') {
sweepTimer.unref();
}

// ── Public API ─────────────────────────────────

return data.nonce;
/**
* Stores a nonce for `address`, overwriting any previous entry.
* The nonce expires after NONCE_TTL_MS milliseconds.
*/
export function setNonce(address: string, nonce: string): void {
cache.set(address, { nonce, expiresAt: Date.now() + NONCE_TTL_MS });
}

/**
* Retrieves and immediately deletes the nonce for `address`.
* Returns `null` if no nonce exists or it has expired.
*
* The atomic read-then-delete prevents replay attacks.
*/
export function getAndClearNonce(address: string): string | null {
const entry = cache.get(address);
if (!entry) return null;

// Always delete — even if expired — to keep cache clean.
cache.delete(address);

if (entry.expiresAt <= Date.now()) return null;

return entry.nonce;
}
Loading