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
20 changes: 19 additions & 1 deletion apps/api/src/data/accounts.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,15 @@
// Mock data for accounts
// Mock data for accounts.
//
// Every account carries a `customerId` referencing the owning customer. This
// is used by the resource server to enforce account-ownership checks on
// authenticated requests so a user can only access accounts they own.
//
// For the demo flow all accounts are assigned to the demo user (sub
// "user_123"; see apps/auth/src/index.ts USERS map and apps/api/src/data/customers.ts).
export const accounts = [
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-123",
accountNumberDisplay: "0123",
nickname: "My Checking",
Expand All @@ -16,6 +24,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-456",
accountNumberDisplay: "0456",
nickname: "Emergency Fund",
Expand All @@ -30,6 +39,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-789",
accountNumberDisplay: "0789",
nickname: "House Down Payment",
Expand All @@ -44,6 +54,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-101",
accountNumberDisplay: "0101",
nickname: "Home Escrow",
Expand All @@ -58,6 +69,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-202",
accountNumberDisplay: "0202",
nickname: "Investment Buffer",
Expand All @@ -72,6 +84,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-303",
accountNumberDisplay: "0303",
nickname: "Rainy Day Fund",
Expand All @@ -86,6 +99,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-404",
accountNumberDisplay: "0404",
nickname: "Dream Home",
Expand All @@ -100,6 +114,7 @@ export const accounts = [
},
{
accountCategory: "DEPOSIT_ACCOUNT",
customerId: "user_123",
accountId: "account-505",
accountNumberDisplay: "0505",
nickname: "Vacation Club",
Expand All @@ -114,6 +129,7 @@ export const accounts = [
},
{
accountCategory: "LOC_ACCOUNT",
customerId: "user_123",
accountId: "account-601",
accountNumberDisplay: "4532",
nickname: "Rewards Card",
Expand All @@ -129,6 +145,7 @@ export const accounts = [
},
{
accountCategory: "LOAN_ACCOUNT",
customerId: "user_123",
accountId: "account-602",
accountNumberDisplay: "9876",
nickname: "Home Loan",
Expand All @@ -148,6 +165,7 @@ export const accounts = [
},
{
accountCategory: "LOAN_ACCOUNT",
customerId: "user_123",
accountId: "account-603",
accountNumberDisplay: "1234",
nickname: "Car Payment",
Expand Down
41 changes: 37 additions & 4 deletions apps/api/src/data/accountsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ interface Currency {
interface Account {
accountCategory: string;
accountId: string;
// Owning customer - used for authorization checks. Must equal the
// authenticated user's `sub` claim for the user to access this account.
customerId: string;
accountNumberDisplay: string;
productName: string;
status: string;
Expand Down Expand Up @@ -136,21 +139,33 @@ interface PaginatedAssetTransferNetworksResult {
// Simulating async database operations with promises

/**
* Get all accounts with pagination support
* Get all accounts owned by a specific customer with pagination support.
*
* Always scope account lookups by the authenticated user's customer id to
* prevent cross-customer data leakage.
*/
export async function getAccounts( offset = 0, limit = 10 ): Promise<PaginatedAccountsResult> {
export async function getAccounts( customerId: string, offset = 0, limit = 10 ): Promise<PaginatedAccountsResult> {
// Simulate database query delay
return new Promise<PaginatedAccountsResult>( ( resolve ) => {
setTimeout( () => {
const paginatedAccounts = accounts.slice( offset, offset + limit );
const owned = accounts.filter( ( acc: Account ) => acc.customerId === customerId );
const paginatedAccounts = owned.slice( offset, offset + limit );
resolve( {
accounts: paginatedAccounts,
total: accounts.length
total: owned.length
} );
}, 100 ); // Simulate 100ms delay
} );
}

/**
* Look up an account by id without applying ownership filtering.
*
* Callers in route handlers MUST verify `account.customerId` matches the
* authenticated user before returning the record. Prefer
* `getAccountForCustomer` when you have a customer id available - it
* collapses the lookup + ownership check into a single repository call.
*/
export async function getAccountById( accountId: string ): Promise<Account | null> {
// Simulate database query delay
return new Promise<Account | null>( ( resolve ) => {
Expand All @@ -161,6 +176,24 @@ export async function getAccountById( accountId: string ): Promise<Account | nul
} );
}

/**
* Look up an account by id and verify it belongs to the supplied customer.
*
* Returns the account when both the account exists and is owned by
* `customerId`; returns null otherwise. Treats not-found and not-owned
* identically so we don't leak the existence of accounts owned by other
* customers.
*/
export async function getAccountForCustomer(
accountId: string,
customerId: string
): Promise<Account | null> {
const account = await getAccountById( accountId );
if ( !account ) return null;
if ( account.customerId !== customerId ) return null;
return account;
}

/**
* Get account contact information by account ID
*/
Expand Down
13 changes: 9 additions & 4 deletions apps/api/src/data/customers.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
// Mock customer data
// Mock customer data.
//
// The first record's `customerId` is intentionally set to the demo user's
// OIDC `sub` claim ("user_123" — see apps/auth/src/index.ts USERS map) so the
// resource server can resolve an authenticated user to their customer record
// using the JWT subject directly.
export const customers = [
{
customerId: "customer-123",
name: "Current Customer",
email: "customer@example.com",
customerId: "user_123",
name: "Dev User",
email: "user@example.test",
status: "active",
createdDate: "2021-05-15",
preferences: {
Expand Down
13 changes: 7 additions & 6 deletions apps/api/src/data/customersRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,17 @@ interface CustomerFilters {
}

/**
* Get the current customer (simulating a logged-in user)
* Get the current customer for an authenticated user.
*
* The `userId` argument should be the verified JWT subject (`sub` claim) of
* the authenticated request. The repository looks up the customer record
* keyed on this id; never trust unauthenticated input here.
*/
export async function getCurrentCustomer(): Promise<Customer | null> {
// In a real implementation, this would use authentication context
// For now, we'll just return the first customer as the "current" one

export async function getCurrentCustomer( userId: string ): Promise<Customer | null> {
// Simulate database query delay
return new Promise<Customer | null>( ( resolve ) => {
setTimeout( () => {
const currentCustomer = customers.find( ( c: Customer ) => c.customerId === "customer-123" );
const currentCustomer = customers.find( ( c: Customer ) => c.customerId === userId );
resolve( currentCustomer || null );
}, 75 ); // Simulate 75ms delay
} );
Expand Down
36 changes: 15 additions & 21 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import "dotenv/config";
import express, { Request, Response, NextFunction } from "express";
import { createRemoteJWKSet, jwtVerify, JWTPayload } from "jose";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { webcrypto } from "crypto";

// Polyfill for crypto global in Node.js
Expand All @@ -20,11 +20,8 @@ import {
createApiSecurityHeaders,
setupBasicExpress
} from "@apps/shared";

// Extend Request interface to include user payload
interface AuthenticatedRequest extends Request {
user?: JWTPayload;
}
import { AuthenticatedRequest, requireScope } from "./utils/auth.js";
import { FDXErrors } from "./utils/errors.js";

// Create logger for API service
const logger = createLogger( "api" );
Expand Down Expand Up @@ -119,26 +116,23 @@ app.get( "/public/health", ( _req: Request, res: Response ) =>
);

// Routes
//
// `/customers/current` is mounted without an `accounts:read` scope check
// because resolving the authenticated user's identity only requires the
// `openid` scope (already implicit in any authenticated session). All
// account/data routes require `accounts:read`.
app.use( "/api/fdx/v6", customersRouter );
app.use( "/api/fdx/v6", accountsRouter );

// app.get( "/accounts", ( req: Request, res: Response ) => {
// const scope = String( ( req as any ).user?.scope || "" ).split( " " );
// if ( !scope.includes( "accounts:read" ) )
// return res.status( 403 ).json( { error: "insufficient_scope" } );
// return res.json( [ { id: "acc_123", name: "Primary Checking" } ] );
// } );
app.use( "/api/fdx/v6", requireScope( "accounts:read" ), accountsRouter );

// 404 route handler for undefined routes
app.use( ( req, res ) => {
res.status( 404 ).json( {
error: "not_found",
message: "Requested resource not found"
} );
app.use( ( _req: Request, res: Response ) => {
res.status( 404 ).json( FDXErrors.accountNotFound( "Requested resource not found" ) );
} );

// Global error handler
app.use( ( error: unknown, req: Request, res: Response ) => {
// Global error handler. The 4-arg signature is required for Express to treat
// this as an error-handling middleware.
// eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars
app.use( ( error: unknown, req: Request, res: Response, _next: NextFunction ) => {
logError( logger, error, { path: req.path, method: req.method } );
const sanitized = sanitizeError( error );
const statusCode = typeof error === "object" && error !== null && "statusCode" in error
Expand Down
Loading