diff --git a/apps/api/src/data/accounts.ts b/apps/api/src/data/accounts.ts index 3633fca..2a38bc0 100644 --- a/apps/api/src/data/accounts.ts +++ b/apps/api/src/data/accounts.ts @@ -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", @@ -16,6 +24,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-456", accountNumberDisplay: "0456", nickname: "Emergency Fund", @@ -30,6 +39,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-789", accountNumberDisplay: "0789", nickname: "House Down Payment", @@ -44,6 +54,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-101", accountNumberDisplay: "0101", nickname: "Home Escrow", @@ -58,6 +69,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-202", accountNumberDisplay: "0202", nickname: "Investment Buffer", @@ -72,6 +84,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-303", accountNumberDisplay: "0303", nickname: "Rainy Day Fund", @@ -86,6 +99,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-404", accountNumberDisplay: "0404", nickname: "Dream Home", @@ -100,6 +114,7 @@ export const accounts = [ }, { accountCategory: "DEPOSIT_ACCOUNT", + customerId: "user_123", accountId: "account-505", accountNumberDisplay: "0505", nickname: "Vacation Club", @@ -114,6 +129,7 @@ export const accounts = [ }, { accountCategory: "LOC_ACCOUNT", + customerId: "user_123", accountId: "account-601", accountNumberDisplay: "4532", nickname: "Rewards Card", @@ -129,6 +145,7 @@ export const accounts = [ }, { accountCategory: "LOAN_ACCOUNT", + customerId: "user_123", accountId: "account-602", accountNumberDisplay: "9876", nickname: "Home Loan", @@ -148,6 +165,7 @@ export const accounts = [ }, { accountCategory: "LOAN_ACCOUNT", + customerId: "user_123", accountId: "account-603", accountNumberDisplay: "1234", nickname: "Car Payment", diff --git a/apps/api/src/data/accountsRepository.ts b/apps/api/src/data/accountsRepository.ts index 819ebfa..0414ed8 100644 --- a/apps/api/src/data/accountsRepository.ts +++ b/apps/api/src/data/accountsRepository.ts @@ -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; @@ -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 { +export async function getAccounts( customerId: string, offset = 0, limit = 10 ): Promise { // Simulate database query delay return new Promise( ( 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 { // Simulate database query delay return new Promise( ( resolve ) => { @@ -161,6 +176,24 @@ export async function getAccountById( accountId: string ): Promise { + const account = await getAccountById( accountId ); + if ( !account ) return null; + if ( account.customerId !== customerId ) return null; + return account; +} + /** * Get account contact information by account ID */ diff --git a/apps/api/src/data/customers.ts b/apps/api/src/data/customers.ts index 7b78bec..5484b4d 100644 --- a/apps/api/src/data/customers.ts +++ b/apps/api/src/data/customers.ts @@ -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: { diff --git a/apps/api/src/data/customersRepository.ts b/apps/api/src/data/customersRepository.ts index 518750e..9bf6d09 100644 --- a/apps/api/src/data/customersRepository.ts +++ b/apps/api/src/data/customersRepository.ts @@ -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 { - // 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 { // Simulate database query delay return new Promise( ( 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 } ); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 50793f9..a1bee4a 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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 @@ -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" ); @@ -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 diff --git a/apps/api/src/routes/accounts.ts b/apps/api/src/routes/accounts.ts index 6972ee6..2cf8fbc 100644 --- a/apps/api/src/routes/accounts.ts +++ b/apps/api/src/routes/accounts.ts @@ -1,6 +1,14 @@ -import express, { Request, Response } from "express"; -import { getAccounts, getAccountById, getAccountContactById, getAccountStatements, getAccountStatementById, getAccountTransactions, getPaymentNetworks, getAssetTransferNetworks } from "../data/accountsRepository.js"; -import pino from "pino"; +import express, { Response } from "express"; +import { + getAccounts, + getAccountForCustomer, + getAccountContactById, + getAccountStatements, + getAccountStatementById, + getAccountTransactions, + getPaymentNetworks, + getAssetTransferNetworks +} from "../data/accountsRepository.js"; import { paginationSchema, dateRangePaginationSchema, @@ -11,15 +19,12 @@ import { type PaginationParams, type DateRangePaginationParams } from "@apps/shared/validation"; +import { createLogger } from "@apps/shared"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { AuthenticatedRequest, getSubject } from "../utils/auth.js"; +import { FDXErrors } from "../utils/errors.js"; -const logger = pino( { - transport: { - target: "pino-pretty", - options: { - colorize: true - } - } -} ); +const logger = createLogger( "api:accounts" ); const router = express.Router(); @@ -72,158 +77,152 @@ function validateStatementId( statementId: string ): { success: true; data: stri return { success: true, data: result.data }; } -// Shared helper to validate account existence and send appropriate HTTP responses -// Returns the account object if found; otherwise handles the response and returns null -async function verifyAccount( accountId: string, res: Response, notFoundCode = 701 ) { - try { - const account = await getAccountById( accountId ); - if ( !account ) { - res.status( 404 ).json( { code: notFoundCode, error: "An account with the provided account ID could not be found" } ); - return null; - } - return account; - } catch ( error ) { - logger.error( error, "Error validating account" ); - res.status( 500 ).json( { error: "Internal server error" } ); +/** + * Resolve and authorize an account against the authenticated user. + * + * Returns the account when (a) the user is authenticated, (b) the account + * exists, and (c) the account is owned by the authenticated user. Otherwise + * writes the appropriate FDX error response and returns null. Treats + * not-owned identically to not-found (404) so we don't leak existence of + * other customers' accounts. + */ +async function resolveOwnedAccount( + req: AuthenticatedRequest<{ accountId: string }>, + res: Response, + accountId: string +) { + const userId = getSubject( req ); + if ( !userId ) { + res.status( 401 ).json( FDXErrors.insufficientScope( "Missing authenticated subject" ) ); return null; } + + const account = await getAccountForCustomer( accountId, userId ); + if ( !account ) { + res.status( 404 ).json( FDXErrors.accountNotFound() ); + return null; + } + return account; } -// GET /accounts with pagination support -router.get( "/accounts", async ( req: Request, res: Response ) => { - // Validate and extract pagination parameters with bounds checking - const { offset, limit } = validatePagination( req.query ); +// GET /accounts - lists accounts owned by the authenticated user only +router.get( + "/accounts", + asyncHandler( async ( req: AuthenticatedRequest, res: Response ) => { + const userId = getSubject( req ); + if ( !userId ) { + return res.status( 401 ).json( FDXErrors.insufficientScope( "Missing authenticated subject" ) ); + } + + const { offset, limit } = validatePagination( req.query ); - try { - // Get accounts using the repository - const result = await getAccounts( offset, limit ); + const result = await getAccounts( userId, offset, limit ); // Calculate pagination metadata const hasMore = offset + limit < result.total; const page = hasMore ? { nextOffset: String( offset + limit ) } : {}; - // Construct response - const response = { + return res.json( { page, accounts: result.accounts - }; - - res.json( response ); - } catch ( error ) { - logger.error( error, "Error retrieving accounts" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ); + } ) +); + +router.get( + "/accounts/:accountId", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; -router.get( "/accounts/:accountId", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { - const account = await getAccountById( accountId ); + return res.json( account ); + } ) +); - if ( !account ) { - return res.status( 404 ).json( { code: 701, error: "An account with the provided account ID could not be found" } ); +router.get( + "/accounts/:accountId/contact", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); } + const accountId = accountIdResult.data; - res.json( account ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving account" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); - -router.get( "/accounts/:accountId/contact", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; - - const account = await verifyAccount( accountId, res, 701 ); - if ( !account ) return; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { const contact = await getAccountContactById( accountId ); - if ( !contact ) { - return res.status( 404 ).json( { code: 601, error: "An account with the provided account ID could not be found" } ); + return res.status( 404 ).json( FDXErrors.accountNotFound() ); } - res.json( contact ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving account contact" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + return res.json( contact ); + } ) +); // GET /accounts/:accountId/statements with pagination support -router.get( "/accounts/:accountId/statements", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; +router.get( + "/accounts/:accountId/statements", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; - // Validate query parameters including date range and pagination - const queryResult = validateDateRangePagination( req.query ); - if ( !queryResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: queryResult.error } ); - } - const { offset, limit, startTime, endTime } = queryResult.data; + const queryResult = validateDateRangePagination( req.query ); + if ( !queryResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( queryResult.error ) ); + } + const { offset, limit, startTime, endTime } = queryResult.data; - const account = await verifyAccount( accountId, res, 701 ); - if ( !account ) return; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { const result = await getAccountStatements( accountId, offset, limit, startTime || "", endTime || "" ); - // Calculate pagination metadata const hasMore = offset + limit < result.total; const page = hasMore ? { nextOffset: String( offset + limit ) } : {}; - // Construct response - const response = { + return res.json( { page, statements: result.statements - }; - - res.json( response ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving statements" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ); + } ) +); // GET /accounts/:accountId/statements/:statementId - simulate returning a PDF -router.get( "/accounts/:accountId/statements/:statementId", async ( req: Request<{ accountId: string; statementId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; +router.get( + "/accounts/:accountId/statements/:statementId", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string; statementId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; - // Validate statementId path parameter - const statementIdResult = validateStatementId( req.params.statementId ); - if ( !statementIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: statementIdResult.error } ); - } - const statementId = statementIdResult.data; + const statementIdResult = validateStatementId( req.params.statementId ); + if ( !statementIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( statementIdResult.error ) ); + } + const statementId = statementIdResult.data; - try { - const account = await verifyAccount( accountId, res, 701 ); + const account = await resolveOwnedAccount( req, res, accountId ); if ( !account ) return; const statement = await getAccountStatementById( accountId, statementId ); if ( !statement ) { - return res.status( 404 ).json( { code: 601, error: "Statement not found for the provided accountId/statementId" } ); + logger.debug( { + accountId: sanitizeForLogging( accountId ), + statementId: sanitizeForLogging( statementId ) + }, "Statement not found for account" ); + return res.status( 404 ).json( FDXErrors.statementNotFound() ); } // Minimal valid PDF bytes: %PDF-1.4 ... %%EOF @@ -234,32 +233,28 @@ router.get( "/accounts/:accountId/statements/:statementId", async ( req: Request res.setHeader( "Content-Disposition", `inline; filename=statement-${ statementId }.pdf` ); res.setHeader( "Content-Length", buffer.length.toString() ); return res.status( 200 ).send( buffer ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ), statementId: sanitizeForLogging( statementId ) }, "Error retrieving statement PDF" ); - return res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ) +); // GET /accounts/:accountId/transactions with pagination support -router.get( "/accounts/:accountId/transactions", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; +router.get( + "/accounts/:accountId/transactions", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; - // Validate query parameters including date range and pagination - const queryResult = validateDateRangePagination( req.query ); - if ( !queryResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: queryResult.error } ); - } - const { offset, limit, startTime, endTime } = queryResult.data; + const queryResult = validateDateRangePagination( req.query ); + if ( !queryResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( queryResult.error ) ); + } + const { offset, limit, startTime, endTime } = queryResult.data; - const account = await verifyAccount( accountId, res, 701 ); - if ( !account ) return; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { const result = await getAccountTransactions( accountId, offset, limit, startTime || "", endTime || "" ); const hasMore = offset + limit < result.total; const page = hasMore ? { nextOffset: String( offset + limit ) } : {}; @@ -267,64 +262,50 @@ router.get( "/accounts/:accountId/transactions", async ( req: Request<{ accountI page, transactions: result.transactions } ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving transactions" ); - return res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ) +); // GET /accounts/:accountId/payment-networks with pagination support -router.get( "/accounts/:accountId/payment-networks", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; +router.get( + "/accounts/:accountId/payment-networks", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; - // Validate pagination parameters with bounds checking - const { offset, limit } = validatePagination( req.query ); + const { offset, limit } = validatePagination( req.query ); - const account = await verifyAccount( accountId, res, 701 ); - if ( !account ) return; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { - // Get accounts using the repository const result = await getPaymentNetworks( accountId, offset, limit ); - - // Calculate pagination metadata const hasMore = offset + limit < result.total; const page = hasMore ? { nextOffset: String( offset + limit ) } : {}; - // Construct response - const response = { + return res.json( { page, paymentNetworks: result.paymentNetworks - }; - - res.json( response ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving payment networks" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ); + } ) +); // GET /accounts/:accountId/asset-transfer-networks with pagination support -router.get( "/accounts/:accountId/asset-transfer-networks", async ( req: Request<{ accountId: string }>, res: Response ) => { - // Validate accountId path parameter - const accountIdResult = validateAccountId( req.params.accountId ); - if ( !accountIdResult.success ) { - return res.status( 400 ).json( { error: "Validation failed", details: accountIdResult.error } ); - } - const accountId = accountIdResult.data; +router.get( + "/accounts/:accountId/asset-transfer-networks", + asyncHandler( async ( req: AuthenticatedRequest<{ accountId: string }>, res: Response ) => { + const accountIdResult = validateAccountId( req.params.accountId ); + if ( !accountIdResult.success ) { + return res.status( 400 ).json( FDXErrors.validationFailed( accountIdResult.error ) ); + } + const accountId = accountIdResult.data; - // Validate pagination parameters with bounds checking - const { offset, limit } = validatePagination( req.query ); + const { offset, limit } = validatePagination( req.query ); - const account = await verifyAccount( accountId, res, 701 ); - if ( !account ) return; + const account = await resolveOwnedAccount( req, res, accountId ); + if ( !account ) return; - try { const result = await getAssetTransferNetworks( accountId, offset, limit ); const hasMore = offset + limit < result.total; const page = hasMore ? { nextOffset: String( offset + limit ) } : {}; @@ -332,10 +313,7 @@ router.get( "/accounts/:accountId/asset-transfer-networks", async ( req: Request page, assetTransferNetworks: result.assetTransferNetworks } ); - } catch { - logger.error( { accountId: sanitizeForLogging( accountId ) }, "Error retrieving asset transfer networks" ); - return res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + } ) +); export default router; diff --git a/apps/api/src/routes/customers.ts b/apps/api/src/routes/customers.ts index 177e7be..e469b91 100644 --- a/apps/api/src/routes/customers.ts +++ b/apps/api/src/routes/customers.ts @@ -1,37 +1,31 @@ -import express, { Request, Response } from "express"; +import express, { Response } from "express"; import { getCurrentCustomer } from "../data/customersRepository.js"; -import pino from "pino"; +import { asyncHandler } from "../utils/asyncHandler.js"; +import { AuthenticatedRequest, getSubject } from "../utils/auth.js"; +import { FDXErrors } from "../utils/errors.js"; +import { createLogger } from "@apps/shared"; -const logger = pino( { - transport: { - target: "pino-pretty", - options: { - colorize: true - } - } -} ); +const logger = createLogger( "api:customers" ); const router = express.Router(); // Get current customer -router.get( "/customers/current", async ( req: Request, res: Response ) => { - try { - // Get current customer using the repository - const customer = await getCurrentCustomer(); +router.get( + "/customers/current", + asyncHandler( async ( req: AuthenticatedRequest, res: Response ) => { + const userId = getSubject( req ); + if ( !userId ) { + logger.warn( "GET /customers/current - missing authenticated subject" ); + return res.status( 401 ).json( FDXErrors.insufficientScope( "Missing authenticated subject" ) ); + } - //HTTP status and error code are not always the same, check the API documentation for specifics + const customer = await getCurrentCustomer( userId ); if ( !customer ) { - return res.status( 404 ).json( { - code: 601, - message: "A customer with the provided customer ID could not be found" - } ); + return res.status( 404 ).json( FDXErrors.customerNotFound() ); } - res.json( customer ); - } catch ( error ) { - logger.error( error, "Error retrieving current customer" ); - res.status( 500 ).json( { error: "Internal server error" } ); - } -} ); + return res.json( customer ); + } ) +); export default router; diff --git a/apps/api/src/utils/asyncHandler.ts b/apps/api/src/utils/asyncHandler.ts new file mode 100644 index 0000000..2edff05 --- /dev/null +++ b/apps/api/src/utils/asyncHandler.ts @@ -0,0 +1,19 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; + +/** + * Wrap an async Express route handler so any rejected promise is forwarded + * to Express's error handling middleware via `next(err)` instead of bubbling + * up as an unhandled rejection. + * + * The generic parameters allow callers to use narrower request/response + * types (for example `AuthenticatedRequest<{ accountId: string }>`) while + * still producing a standard Express `RequestHandler`. + */ +export function asyncHandler( + // eslint-disable-next-line no-unused-vars + fn: ( req: R, res: S, next: NextFunction ) => Promise +): RequestHandler { + return ( req, res, next ) => { + Promise.resolve( fn( req as R, res as S, next ) ).catch( next ); + }; +} diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts new file mode 100644 index 0000000..b4f9aa2 --- /dev/null +++ b/apps/api/src/utils/auth.ts @@ -0,0 +1,75 @@ +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { JWTPayload } from "jose"; +import { FDXErrors } from "./errors.js"; + +/** + * Augment Express's `Request` so route handlers can read the verified JWT + * payload at `req.user` without per-route casts. Populated by the JWT + * verification middleware in apps/api/src/index.ts. + */ +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +/** + * Convenience alias for handlers that require an authenticated request. + * Uses Express's standard generics so it remains assignable to plain + * `RequestHandler` parameters and to `asyncHandler`. + */ +export type AuthenticatedRequest< + P = Record, + ResBody = unknown, + ReqBody = unknown, + // Use `any` (matching express-serve-static-core's defaults) so this type + // remains assignable wherever a plain `Request` is expected without + // triggering ParsedQs incompatibilities. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ReqQuery = any +> = Request & { user?: JWTPayload }; + +/** + * Extract the authenticated subject (user id) from the verified JWT payload. + * Returns null when no user is attached or `sub` is missing. + */ +export function getSubject( req: Request ): string | null { + const user = req.user; + return typeof user?.sub === "string" && user.sub.length > 0 ? user.sub : null; +} + +/** + * Parse the space-separated `scope` claim from a verified JWT into a Set. + */ +function parseScopes( scopeClaim: unknown ): Set { + if ( typeof scopeClaim !== "string" ) return new Set(); + return new Set( scopeClaim.split( " " ).filter( Boolean ) ); +} + +/** + * Express middleware factory that requires the authenticated token's `scope` + * claim to contain every scope in `requiredScopes`. Responds with HTTP 403 + * and an FDX error body when scopes are missing. + * + * Must run after the JWT-verification middleware that attaches `req.user`. + */ +export function requireScope( ...requiredScopes: string[] ): RequestHandler { + return ( req: Request, res: Response, next: NextFunction ) => { + const user = req.user; + if ( !user ) { + return res.status( 401 ).json( FDXErrors.insufficientScope( "Missing authentication context" ) ); + } + + const grantedScopes = parseScopes( user.scope ); + const missing = requiredScopes.filter( ( s ) => !grantedScopes.has( s ) ); + if ( missing.length > 0 ) { + return res.status( 403 ).json( + FDXErrors.forbidden( `Missing required scope(s): ${ missing.join( " " ) }` ) + ); + } + + return next(); + }; +} diff --git a/apps/api/src/utils/errors.ts b/apps/api/src/utils/errors.ts new file mode 100644 index 0000000..816a9c5 --- /dev/null +++ b/apps/api/src/utils/errors.ts @@ -0,0 +1,53 @@ +/** + * FDX-compliant error response helpers. + * + * Per the FDX spec error responses must contain a numeric `code` and a + * `message`, and may optionally include a `debugMessage`. The `code` is the + * FDX error code (long-term persistent identifier) and is allowed to differ + * from the HTTP status code. + */ + +export interface FDXError { + code: number; + message: string; + debugMessage?: string; +} + +/** + * Build an FDX-compliant error body. + */ +export function fdxError( code: number, message: string, debugMessage?: string ): FDXError { + const body: FDXError = { code, message }; + if ( debugMessage ) body.debugMessage = debugMessage; + return body; +} + +/** + * Common FDX errors used across routes. + * + * Codes loosely follow the FDX spec ranges: + * - 6xx: customer / authorization-related errors + * - 7xx: account / data-related errors + */ +export const FDXErrors = { + insufficientScope: ( debugMessage?: string ): FDXError => + fdxError( 401, "Insufficient scope for this operation", debugMessage ), + + forbidden: ( debugMessage?: string ): FDXError => + fdxError( 403, "Forbidden", debugMessage ), + + customerNotFound: ( debugMessage?: string ): FDXError => + fdxError( 601, "A customer with the provided customer ID could not be found", debugMessage ), + + accountNotFound: ( debugMessage?: string ): FDXError => + fdxError( 701, "An account with the provided account ID could not be found", debugMessage ), + + statementNotFound: ( debugMessage?: string ): FDXError => + fdxError( 702, "A statement with the provided statement ID could not be found", debugMessage ), + + validationFailed: ( debugMessage?: string ): FDXError => + fdxError( 400, "Validation failed", debugMessage ), + + internalError: ( debugMessage?: string ): FDXError => + fdxError( 500, "Internal server error", debugMessage ) +}; diff --git a/apps/api/tsconfig.tsbuildinfo b/apps/api/tsconfig.tsbuildinfo index 26021a1..067cb86 100644 --- a/apps/api/tsconfig.tsbuildinfo +++ b/apps/api/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/index.ts","./src/data/accounts.ts","./src/data/accountsrepository.ts","./src/data/customers.ts","./src/data/customersrepository.ts","./src/routes/accounts.ts","./src/routes/customers.ts","./src/utils/validation.ts"],"version":"6.0.2"} \ No newline at end of file +{"root":["./src/index.ts","./src/data/accounts.ts","./src/data/accountsrepository.ts","./src/data/customers.ts","./src/data/customersrepository.ts","./src/routes/accounts.ts","./src/routes/customers.ts","./src/utils/asynchandler.ts","./src/utils/auth.ts","./src/utils/errors.ts","./src/utils/validation.ts"],"version":"6.0.3"} \ No newline at end of file diff --git a/apps/app/public/styles.css b/apps/app/public/styles.css index 2ff68ab..e6dfb23 100644 --- a/apps/app/public/styles.css +++ b/apps/app/public/styles.css @@ -1,4 +1,4 @@ -/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { diff --git a/apps/app/src/index.ts b/apps/app/src/index.ts index 813411f..7d92fd1 100644 --- a/apps/app/src/index.ts +++ b/apps/app/src/index.ts @@ -36,6 +36,7 @@ const logger = createLogger( "app" ); interface OidcState { state: string; code_verifier: string; + nonce: string; } interface TokenSet { @@ -48,8 +49,21 @@ interface CookieRequest extends Request { cookies: { [key: string]: string; }; + signedCookies: { + [key: string]: string; + }; } +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +const SIGNED_COOKIE_OPTIONS = { + httpOnly: true, + sameSite: "lax", + secure: true, + signed: true, + path: "/" +} as const; + // Environment configuration const HOST = getRequiredEnv( "APP_HOST" ); @@ -61,6 +75,18 @@ const REDIRECT_URI = getRequiredEnv( "REDIRECT_URI" ); const API_BASE_URL = getRequiredEnv( "API_BASE_URL" ); const API_AUDIENCE = getRequiredEnv( "API_AUDIENCE" ); const COOKIE_SECRET = getRequiredEnv( "COOKIE_SECRET" ); +const POST_LOGOUT_REDIRECT_URI = process.env.POST_LOGOUT_REDIRECT_URI || HOST; + +// Validate ISSUER_URL is an absolute URL — used to build links in views. +// Throwing here fails fast on misconfiguration rather than rendering an +// attacker-controlled string into an attribute later. +const ISSUER_URL_OBJ = ( () => { + const url = new URL( ISSUER_URL ); + if ( url.protocol !== "https:" && url.protocol !== "http:" ) { + throw new Error( `OP_ISSUER must be an http(s) URL, got: ${ url.protocol }` ); + } + return url; +} )(); const app = express(); setupBasicExpress( app ); @@ -101,22 +127,139 @@ function parseTokensCookie( cookieValue: string | undefined ): TokenSet | null { * Returns empty object if parsing fails. */ function parseOidcCookie( cookieValue: string | undefined ): OidcState { - if ( !cookieValue ) return {} as OidcState; + if ( !cookieValue ) return { state: "", code_verifier: "", nonce: "" }; try { const parsed = JSON.parse( cookieValue ); // Basic validation for expected fields if ( typeof parsed !== "object" || parsed === null ) { - return {} as OidcState; + return { state: "", code_verifier: "", nonce: "" }; } return { state: typeof parsed.state === "string" ? parsed.state : "", - code_verifier: typeof parsed.code_verifier === "string" ? parsed.code_verifier : "" + code_verifier: typeof parsed.code_verifier === "string" ? parsed.code_verifier : "", + nonce: typeof parsed.nonce === "string" ? parsed.nonce : "" }; } catch { logger.warn( "Invalid OIDC state cookie format" ); - return {} as OidcState; + return { state: "", code_verifier: "", nonce: "" }; + } +} + +/** + * Mask a token for display, showing only the first and last 4 characters. + */ +function maskToken( token: string | undefined ): string { + if ( !token ) return ""; + if ( token.length <= 8 ) return "***"; + return `${ token.slice( 0, 4 ) }…${ token.slice( -4 ) }`; +} + +/** + * Decode a JWT without verifying its signature. + * Returns null if the token is malformed. + */ +function decodeJwtPayload( token: string ): Record | null { + try { + const parts = token.split( "." ); + if ( parts.length !== 3 ) return null; + return JSON.parse( Buffer.from( parts[1], "base64url" ).toString() ); + } catch { + return null; + } +} + +/** + * Determine whether an access token is expired (or close to expiring). + * Uses a 30 second clock-skew buffer. + */ +function isAccessTokenExpired( accessToken: string ): boolean { + const payload = decodeJwtPayload( accessToken ); + if ( !payload || typeof payload.exp !== "number" ) { + // If we cannot read the expiry, treat the token as expired and force a refresh. + return true; + } + const nowSec = Math.floor( Date.now() / 1000 ); + const skewSec = 30; + return payload.exp <= ( nowSec + skewSec ); +} + +/** + * Persist the token set in a signed, httpOnly cookie. Always read existing + * tokens via this helper's counterpart `parseTokensCookie( req.signedCookies )`. + */ +function writeTokensCookie( res: Response, tokens: TokenSet ): void { + res.cookie( "tokens", JSON.stringify( { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token + } ), SIGNED_COOKIE_OPTIONS ); +} + +/** + * Force a token refresh against the authorization server using the refresh + * token from the signed cookie. Persists the new token set back into the + * signed cookie and returns the new access token (or `null` when no refresh + * token is available). + */ +async function refreshTokensAndPersist( req: Request, res: Response ): Promise { + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); + if ( !tokens?.refresh_token ) return null; + + const cfg = await ensureConfig(); + const refreshed = await client.refreshTokenGrant( cfg, tokens.refresh_token, { + resource: API_AUDIENCE + } ); + + const nextTokens: TokenSet = { + access_token: refreshed.access_token, + refresh_token: refreshed.refresh_token || tokens.refresh_token, + id_token: refreshed.id_token || tokens.id_token + }; + + writeTokensCookie( res, nextTokens ); + + logger.info( { + rotated: !!refreshed.refresh_token, + expiresIn: refreshed.expiresIn?.() + }, "Tokens refreshed and persisted" ); + + return nextTokens.access_token; +} + +/** + * Returns a valid (non-expired) access token, refreshing it when needed. + * + * 1. Reads tokens from the signed cookie. + * 2. Checks `exp` on the access token (jose decode + 30s skew buffer). + * 3. If expired and a refresh token is available, calls the auth server's + * token endpoint via openid-client's refresh helper. + * 4. Atomically writes the new tokens back to the signed cookie before + * returning the access token to the caller. + * + * Throws when no tokens are present or when refresh fails — callers are + * expected to map those errors onto an HTTP 401 / re-login flow. + */ +async function getValidAccessToken( req: Request, res: Response ): Promise { + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); + if ( !tokens?.access_token ) { + throw new Error( "No access token in cookie" ); } + + if ( !isAccessTokenExpired( tokens.access_token ) ) { + return tokens.access_token; + } + + if ( !tokens.refresh_token ) { + throw new Error( "Access token expired and no refresh token available" ); + } + + logger.debug( "Access token expired — attempting refresh" ); + const newAccessToken = await refreshTokensAndPersist( req, res ); + if ( !newAccessToken ) { + throw new Error( "Refresh did not return a new access token" ); + } + return newAccessToken; } async function delay( ms: number ) { @@ -167,7 +310,7 @@ async function ensureConfig(): Promise { // Discovery is performed lazily on demand by routes via ensureConfig() app.get( "/", async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); res.render( "index", { tokens } ); } ); @@ -178,13 +321,9 @@ app.get( "/login", async ( _req: Request, res: Response ) => { const state = client.randomState(); const code_verifier = client.randomPKCECodeVerifier(); const code_challenge = await client.calculatePKCECodeChallenge( code_verifier ); + const nonce = client.randomNonce(); - res.cookie( "oidc", JSON.stringify( { state, code_verifier } ), { - httpOnly: true, - sameSite: "lax", - secure: true, - path: "/" - } ); + res.cookie( "oidc", JSON.stringify( { state, code_verifier, nonce } ), SIGNED_COOKIE_OPTIONS ); // RFC 8707 - Resource Indicators for OAuth 2.0 // The 'resource' parameter specifies the target API (audience) for the access token @@ -195,6 +334,7 @@ app.get( "/login", async ( _req: Request, res: Response ) => { state, code_challenge, code_challenge_method: "S256", + nonce, prompt: "login consent", resource: API_AUDIENCE // Resource indicator - must be absolute URI without fragment } ); @@ -208,8 +348,8 @@ app.get( "/callback", async ( req: Request, res: Response ) => { // Force HTTPS protocol since we're behind a proxy const currentUrl = new URL( req.originalUrl, `https://${ req.get( "host" ) }` ); - // Safely parse OIDC state cookie - const cookieVal = parseOidcCookie( ( req as CookieRequest ).cookies["oidc"] ); + // Safely parse OIDC state cookie (signed) + const cookieVal = parseOidcCookie( ( req as CookieRequest ).signedCookies["oidc"] ); logger.debug( { currentUrl: currentUrl.href }, "Callback - Current URL" ); logger.debug( { redirectUri: REDIRECT_URI }, "Callback - Expected Redirect URI" ); @@ -226,7 +366,7 @@ app.get( "/callback", async ( req: Request, res: Response ) => { logger.info( { error: sanitizeForLogging( error ), errorDescription: sanitizeForLogging( rawErrorDescription ) }, "Callback - OAuth error received" ); // Clear the OIDC state cookie - res.clearCookie( "oidc" ); + res.clearCookie( "oidc", { path: "/" } ); return res.status( 400 ).send( ` @@ -264,6 +404,9 @@ app.get( "/callback", async ( req: Request, res: Response ) => { if ( !cookieVal.state ) { throw new Error( "Missing state in cookie" ); } + if ( !cookieVal.nonce ) { + throw new Error( "Missing nonce in cookie" ); + } const authCode = currentUrl.searchParams.get( "code" ); const receivedState = currentUrl.searchParams.get( "state" ); @@ -311,23 +454,22 @@ app.get( "/callback", async ( req: Request, res: Response ) => { currentUrl, { pkceCodeVerifier: cookieVal.code_verifier, - expectedState: cookieVal.state + expectedState: cookieVal.state, + expectedNonce: cookieVal.nonce // Validates `nonce` claim in the ID Token }, { resource: API_AUDIENCE // Resource indicator for token exchange (RFC 8707) } ); - // Persist minimal tokens in an httpOnly cookie (for demo only). - res.cookie( - "tokens", - JSON.stringify( { - access_token: tokenSet.access_token, - refresh_token: tokenSet.refresh_token, - id_token: tokenSet.id_token - } ), - { httpOnly: true, sameSite: "lax", secure: true, path: "/" } - ); + // Persist minimal tokens in a signed, httpOnly cookie (for demo only). + writeTokensCookie( res, { + access_token: tokenSet.access_token, + refresh_token: tokenSet.refresh_token, + id_token: tokenSet.id_token + } ); + // One-shot OIDC state cookie is no longer needed once the code is exchanged. + res.clearCookie( "oidc", { path: "/" } ); res.redirect( "/api-explorer" ); } catch ( error ) { logError( logger, error, { context: "OAuth callback" } ); @@ -338,7 +480,7 @@ app.get( "/callback", async ( req: Request, res: Response ) => { // Refresh token endpoint - manually trigger a token refresh app.post( "/refresh", async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); logger.debug( { hasTokens: !!tokens, @@ -351,7 +493,7 @@ app.post( "/refresh", async ( req: Request, res: Response ) => { } try { - await ensureConfig(); + const cfg = await ensureConfig(); logger.debug( { refreshTokenPrefix: tokens.refresh_token.substring( 0, 10 ), @@ -360,7 +502,7 @@ app.post( "/refresh", async ( req: Request, res: Response ) => { // Use refreshTokenGrant to exchange refresh token for new tokens // The resource parameter must be included here too for the same reasons as above - const tokenSet = await client.refreshTokenGrant( config!, tokens.refresh_token, { + const tokenSet = await client.refreshTokenGrant( cfg, tokens.refresh_token, { resource: API_AUDIENCE // Resource indicator for refresh token exchange (RFC 8707) } ); @@ -370,16 +512,12 @@ app.post( "/refresh", async ( req: Request, res: Response ) => { newIdTokenIssued: !!tokenSet.id_token }, "POST /refresh - Refresh successful" ); - // Update tokens in cookie - res.cookie( - "tokens", - JSON.stringify( { - access_token: tokenSet.access_token, - refresh_token: tokenSet.refresh_token || tokens.refresh_token, // Keep old refresh token if no new one - id_token: tokenSet.id_token || tokens.id_token // Keep old ID token if no new one - } ), - { httpOnly: true, sameSite: "lax", secure: true, path: "/" } - ); + // Update tokens in the signed cookie + writeTokensCookie( res, { + access_token: tokenSet.access_token, + refresh_token: tokenSet.refresh_token || tokens.refresh_token, // Keep old refresh token if no new one + id_token: tokenSet.id_token || tokens.id_token // Keep old ID token if no new one + } ); res.json( { success: true, @@ -400,7 +538,7 @@ app.post( "/refresh", async ( req: Request, res: Response ) => { } ); app.get( "/token", async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); if ( !tokens?.access_token || !tokens?.id_token ) return res.redirect( "/login" ); try { @@ -504,13 +642,18 @@ app.get( "/token", async ( req: Request, res: Response ) => { annotatedPayload[key] = { value: decodedPayload[key], comment }; } ); + // Build a validated absolute JWKS URL up front so the view never has to + // touch process.env / unvalidated strings inside an `href` attribute. + const jwksUrl = new URL( "/.well-known/jwks.json", ISSUER_URL_OBJ ).href; + // Render token inspector view with decoded token data return res.render( "token", { tokens, rawToken: tokens.id_token, header: annotatedHeader, payload: annotatedPayload, - signature + signature, + jwksUrl } ); } catch ( error ) { logError( logger, error, { context: "ID token verification" } ); @@ -522,14 +665,14 @@ app.get( "/token", async ( req: Request, res: Response ) => { } ); app.get( "/api-explorer", async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); if ( !tokens?.access_token ) return res.redirect( "/login" ); res.render( "api-explorer", { tokens } ); } ); app.post( "/api-call", express.json(), async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); if ( !tokens?.access_token ) return res.status( 401 ).json( { error: "No access token" } ); // Validate API call request against allow-list of endpoints and methods @@ -548,18 +691,44 @@ app.post( "/api-call", express.json(), async ( req: Request, res: Response ) => const { endpoint, method } = validationResult.data; + // Strip fragment but preserve query string for pagination/filtering + const sanitizedEndpoint = endpoint.split( "#" )[0]; + const upstreamUrl = `${ API_BASE_URL }${ sanitizedEndpoint }`; + + const callApi = async ( accessToken: string ) => fetch( upstreamUrl, { + method, + headers: { + Authorization: `Bearer ${ accessToken }`, + "Content-Type": "application/json" + } + } ); + try { - const accessToken = tokens.access_token as string; - // Strip fragment but preserve query string for pagination/filtering - const sanitizedEndpoint = endpoint.split( "#" )[0]; - - const apiResponse = await fetch( `${ API_BASE_URL }${ sanitizedEndpoint }`, { - method, - headers: { - Authorization: `Bearer ${ accessToken }`, - "Content-Type": "application/json" + // Auto-refresh on expired access tokens (proactive: based on `exp`). + let accessToken: string; + try { + accessToken = await getValidAccessToken( req, res ); + } catch ( refreshErr ) { + logError( logger, refreshErr, { context: "Pre-call token refresh" } ); + return res.status( 401 ).json( { error: "Session expired — please log in again" } ); + } + + let apiResponse = await callApi( accessToken ); + + // Reactive fallback: the access token looked valid but the API rejected + // it (e.g. server clock skew, revoked grant). Try one refresh-and-retry. + if ( apiResponse.status === 401 ) { + logger.info( "API returned 401 — attempting one refresh-and-retry" ); + try { + const refreshedToken = await refreshTokensAndPersist( req, res ); + if ( refreshedToken ) { + apiResponse = await callApi( refreshedToken ); + } + } catch ( retryErr ) { + logError( logger, retryErr, { context: "401 refresh-and-retry" } ); + // Fall through with the original 401 response } - } ); + } const contentType = apiResponse.headers.get( "content-type" ); let responseData; @@ -587,38 +756,44 @@ app.post( "/api-call", express.json(), async ( req: Request, res: Response ) => } ); app.get( "/debug/tokens", async ( req: Request, res: Response ) => { - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + // Gate behind non-production environments — raw tokens (even masked) are + // debug-only data and should never be reachable in production. + if ( IS_PRODUCTION ) { + logger.warn( "GET /debug/tokens - blocked in production" ); + return res.status( 404 ).send( "Not Found" ); + } + + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); if ( !tokens?.access_token ) { return res.redirect( "/login" ); } - // Decode JWT tokens to show payload - const decodeJwt = ( token: string ) => { - try { - const parts = token.split( "." ); - if ( parts.length !== 3 ) return null; - const payload = JSON.parse( Buffer.from( parts[1], "base64url" ).toString() ); - return payload; - } catch { - return null; - } - }; + const accessTokenPayload = tokens.access_token ? decodeJwtPayload( tokens.access_token ) : null; + const idTokenPayload = tokens.id_token ? decodeJwtPayload( tokens.id_token ) : null; - const accessTokenPayload = tokens.access_token ? decodeJwt( tokens.access_token ) : null; - const idTokenPayload = tokens.id_token ? decodeJwt( tokens.id_token ) : null; + // Mask raw token strings so the rendered HTML never contains the actual + // access/refresh/id tokens. Decoded payloads are still shown — those are + // not credentials on their own. + const maskedTokens = { + access_token: maskToken( tokens.access_token ), + refresh_token: tokens.refresh_token ? maskToken( tokens.refresh_token ) : undefined, + id_token: tokens.id_token ? maskToken( tokens.id_token ) : undefined + }; return res.render( "debug-tokens", { - tokens, + tokens: maskedTokens, accessTokenPayload, - idTokenPayload + idTokenPayload, + // Pass through to the navigation partial so "Authenticated" badge stays correct + hasTokens: true } ); } ); app.get( "/logout", async ( req: Request, res: Response ) => { logger.info( "Logout route called" ); const config = await ensureConfig(); - const tokens = parseTokensCookie( ( req as CookieRequest ).cookies["tokens"] ); + const tokens = parseTokensCookie( ( req as CookieRequest ).signedCookies["tokens"] ); logger.debug( { tokensPresent: !!tokens }, "Logout - tokens present" ); logger.debug( { idTokenPresent: !!tokens?.id_token }, "Logout - id_token present" ); @@ -630,17 +805,29 @@ app.get( "/logout", async ( req: Request, res: Response ) => { has_end_session: !!serverMetadata.end_session_endpoint }, "Logout - server metadata" ); - // Clear local cookies first + // Clear local cookies up front. Even if the RP-initiated logout below + // fails, the local session is already gone. res.clearCookie( "tokens", { path: "/" } ); res.clearCookie( "oidc", { path: "/" } ); - // For now, skip OIDC logout and just do local logout - // The complex OIDC logout flow is having issues with the authorization server - // TODO: Fix OIDC logout flow later - logger.info( "Logout - performing local logout only" ); + // RP-initiated logout per OIDC spec: + // ?id_token_hint=…&post_logout_redirect_uri=…&state=… + // The auth server clears its session and redirects back to our app, where + // `post_logout_redirect_uri` should be a registered URI on the client. + const endSessionEndpoint = serverMetadata.end_session_endpoint; + if ( endSessionEndpoint && tokens?.id_token ) { + const endSessionUrl = new URL( endSessionEndpoint ); + endSessionUrl.searchParams.set( "id_token_hint", tokens.id_token ); + endSessionUrl.searchParams.set( "post_logout_redirect_uri", POST_LOGOUT_REDIRECT_URI ); + endSessionUrl.searchParams.set( "client_id", CLIENT_ID ); + endSessionUrl.searchParams.set( "state", client.randomState() ); + logger.info( { endSession: endSessionUrl.origin + endSessionUrl.pathname }, "Logout - redirecting to end_session_endpoint" ); + return res.redirect( endSessionUrl.href ); + } - logger.info( "Logout - falling back to local redirect" ); - // Fallback to local redirect if no proper logout endpoint + // Fallback: no end_session_endpoint (auth server doesn't support RP-initiated logout) + // or no id_token to use as a hint. Local logout is the best we can do. + logger.info( "Logout - end_session_endpoint or id_token missing, performing local logout only" ); res.redirect( "/" ); } ); diff --git a/apps/app/views/debug-tokens.ejs b/apps/app/views/debug-tokens.ejs index 5596213..527c2a5 100644 --- a/apps/app/views/debug-tokens.ejs +++ b/apps/app/views/debug-tokens.ejs @@ -17,13 +17,18 @@

Token Debug

-

View raw and decoded tokens for debugging

+

View masked and decoded tokens for debugging

+
+

+ Note: Tokens are masked (first/last 4 characters only). Full token values are never sent to the browser. This page is disabled in production. +

+
-

Access Token (Raw)

+

Access Token (Masked)

<% } %> diff --git a/apps/app/views/token.ejs b/apps/app/views/token.ejs index e4a87c3..9639a7c 100644 --- a/apps/app/views/token.ejs +++ b/apps/app/views/token.ejs @@ -194,7 +194,7 @@
  • The Authorization Server uses its private key (from JWKS) to sign the token
  • The signature is created from the header and payload using the RS256 algorithm
  • Anyone can verify the signature using the public key from - + /.well-known/jwks.json
  • diff --git a/apps/auth/package.json b/apps/auth/package.json index a231386..402d49e 100644 --- a/apps/auth/package.json +++ b/apps/auth/package.json @@ -12,15 +12,19 @@ }, "dependencies": { "@apps/shared": "workspace:*", + "cookie-parser": "^1.4.7", + "csrf-csrf": "^4.0.3", "dotenv": "^17.4.2", "ejs": "^5.0.2", "express": "^5.2.1", + "express-rate-limit": "^8.5.0", "oidc-provider": "^9.8.3", "pino": "^10.3.1", "pino-pretty": "^13.1.3" }, "devDependencies": { "@tailwindcss/cli": "^4.2.4", + "@types/cookie-parser": "^1.4.10", "@types/ejs": "^3.1.5", "@types/express": "^5.0.6", "@types/node": "^25.6.0", diff --git a/apps/auth/public/styles.css b/apps/auth/public/styles.css index 69d0929..923e1c2 100644 --- a/apps/auth/public/styles.css +++ b/apps/auth/public/styles.css @@ -1,4 +1,4 @@ -/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ +/*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ @layer properties; @layer theme, base, components, utilities; @layer theme { @@ -231,6 +231,9 @@ .flex { display: flex; } + .hidden { + display: none; + } .h-5 { height: calc(var(--spacing) * 5); } diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 57b0ec1..9f06a69 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,6 +1,14 @@ import "dotenv/config"; -import express, { Request, Response } from "express"; -import { Provider, errors } from "oidc-provider"; +import express, { Request, Response, NextFunction } from "express"; +import { + Provider, + errors, + type OIDCAuthorizationCode, + type OIDCClient +} from "oidc-provider"; +import cookieParser from "cookie-parser"; +import { doubleCsrf } from "csrf-csrf"; +import rateLimit from "express-rate-limit"; import { readFileSync, existsSync } from "fs"; import { resolve } from "path"; import { @@ -155,6 +163,12 @@ setupBasicExpress( app ); // Security headers app.use( createWebSecurityHeaders() ); +// Cookie parser - required for csrf-csrf double-submit cookie pattern. +// The `COOKIE_SECRET` is also reused below as the CSRF HMAC secret when +// `CSRF_SECRET` is not separately configured. +const COOKIE_SECRET = getRequiredEnv( "COOKIE_SECRET", "dev-cookie-secret-CHANGE-ME" ); +app.use( cookieParser( COOKIE_SECRET ) ); + // Body parsers (needed to log token request parameters) app.use( express.urlencoded( { extended: false } ) ); app.use( express.json() ); @@ -162,9 +176,148 @@ app.use( express.json() ); // Template engine setupEJSTemplates( app, new URL( "../views", import.meta.url ).pathname ); +// Expose NODE_ENV-derived flags to all templates so views can gate +// development-only UI (e.g. demo credentials) without re-checking env vars. +app.locals.isProduction = process.env.NODE_ENV === "production"; + // Serve static files (CSS, etc.) app.use( "/public", express.static( new URL( "../public", import.meta.url ).pathname ) ); +// --------------------------------------------------------------------------- +// CSRF protection (double-submit cookie via csrf-csrf, the maintained +// replacement for the deprecated `csurf` middleware). +// +// Tokens are bound to the interaction UID when present so each user's +// interaction has its own CSRF binding; for non-interaction routes the +// session identifier falls back to a stable cookie value or the request IP. +// --------------------------------------------------------------------------- +const CSRF_SECRET = process.env.CSRF_SECRET || COOKIE_SECRET; +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + +const { + generateCsrfToken, + doubleCsrfProtection +} = doubleCsrf( { + getSecret: () => CSRF_SECRET, + getSessionIdentifier: ( req ) => { + // Prefer the per-interaction UID so tokens are scoped to a single login flow. + const uidParam = ( req.params as { uid?: string } | undefined )?.uid; + if ( typeof uidParam === "string" && uidParam.length > 0 ) { + return uidParam; + } + // Fallback to a stable per-client identifier so non-interaction routes + // still receive a deterministic session id. + return req.ip ?? "anonymous"; + }, + cookieName: IS_PRODUCTION ? "__Host-psifi.x-csrf-token" : "x-csrf-token", + cookieOptions: { + httpOnly: true, + sameSite: "lax", + secure: IS_PRODUCTION, + path: "/" + }, + getCsrfTokenFromRequest: ( req ) => { + const headerToken = req.headers[ "x-csrf-token" ]; + if ( typeof headerToken === "string" && headerToken.length > 0 ) { + return headerToken; + } + const bodyToken = ( req.body as { _csrf?: unknown } | undefined )?._csrf; + return typeof bodyToken === "string" ? bodyToken : undefined; + } +} ); + +// --------------------------------------------------------------------------- +// Login rate limiting +// +// 10 attempts per 15 minutes per IP balances normal user retries (typo'd +// password, autofill mishaps) against brute-force probing. Adjust via the +// `LOGIN_RATE_LIMIT_*` env vars if a deployment needs a different ceiling. +// --------------------------------------------------------------------------- +const LOGIN_RATE_LIMIT_WINDOW_MS = Number( process.env.LOGIN_RATE_LIMIT_WINDOW_MS ?? 15 * 60 * 1000 ); +const LOGIN_RATE_LIMIT_MAX = Number( process.env.LOGIN_RATE_LIMIT_MAX ?? 10 ); + +const loginRateLimiter = rateLimit( { + windowMs: LOGIN_RATE_LIMIT_WINDOW_MS, + max: LOGIN_RATE_LIMIT_MAX, + standardHeaders: true, + legacyHeaders: false, + handler: ( req: Request, res: Response ) => { + logger.warn( { + path: req.path, + ip: req.ip, + uid: req.params?.uid + }, "Login rate limit exceeded" ); + + const uidParam = req.params?.uid; + const uid = typeof uidParam === "string" ? uidParam : ""; + + let csrfToken = ""; + try { + csrfToken = generateCsrfToken( req, res ); + } catch { + // non-fatal; render without a fresh token + } + + try { + return res.status( 429 ).render( "interaction", { + uid, + prompt: "login", + scopes: [], + error: "Too many login attempts. Please wait a few minutes and try again.", + email: undefined, + csrfToken + } ); + } catch { + return res.status( 429 ).json( { + error: "too_many_requests", + error_description: "Too many login attempts. Please try again later." + } ); + } + } +} ); + +/** + * Wraps `doubleCsrfProtection` with a friendly EJS error page when the token + * fails to validate. Without this users would see a raw 403 JSON/HTML body. + */ +function csrfProtection( req: Request, res: Response, next: NextFunction ): void { + doubleCsrfProtection( req, res, ( err ) => { + if ( err ) { + logger.warn( { + path: req.path, + ip: req.ip, + error: err instanceof Error ? err.message : String( err ) + }, "CSRF validation failed" ); + + const uidParam = req.params?.uid; + const uid = typeof uidParam === "string" ? uidParam : ""; + + // Try to render the interaction page with a friendly error. If the + // view fails to render (e.g. on non-interaction routes), fall back + // to a plain 403. + try { + let csrfToken = ""; + try { + csrfToken = generateCsrfToken( req, res ); + } catch { + // ignore - we'll render without a fresh token + } + return res.status( 403 ).render( "interaction", { + uid, + prompt: "login", + scopes: [], + error: "Your session expired or the request could not be verified. Please try again.", + email: undefined, + csrfToken + } ); + } catch { + return res.status( 403 ).json( { error: "invalid_csrf_token" } ); + } + } + return next(); + } ); +} + // Very minimal in-memory user store const USERS = new Map< string, @@ -225,8 +378,10 @@ function validateInteractionUid( uid: string | string[] ): { success: true; data return { success: true, data: result.data }; } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -const configuration: any = { +// `oidc-provider` does not ship a complete public Configuration type, so we +// type our config object structurally. The provider validates the shape at +// runtime; we only need TypeScript to keep the values we set well-typed. +const configuration = { clients: SANITIZED_CLIENTS, // Use custom JWKS if provided, otherwise oidc-provider generates ephemeral keys ...( JWKS ? { jwks: JWKS } : {} ), @@ -245,8 +400,7 @@ const configuration: any = { IdToken: 60 * 60, // 1 hour RefreshToken: 14 * 24 * 60 * 60 // 14 days }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - issueRefreshToken: async ( _ctx: unknown, client: any, code: any ) => { + issueRefreshToken: async ( _ctx: unknown, client: OIDCClient, code: OIDCAuthorizationCode ) => { // Issue refresh token if client supports refresh_token grant and either: // - offline_access scope is requested (standard behavior), or // - client has force_refresh_token flag set in .env.clients.json @@ -367,8 +521,7 @@ const configuration: any = { }; }, interactions: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - url: ( _ctx: unknown, interaction: any ) => `/interaction/${ interaction.uid }` + url: ( _ctx: unknown, interaction: { uid: string } ) => `/interaction/${ interaction.uid }` } }; @@ -472,12 +625,15 @@ async function main() { session: details.session }, "GET /interaction/:uid - Interaction details loaded" ); + const csrfToken = generateCsrfToken( req, res ); + res.render( "interaction", { uid, prompt, scopes: requestedScopes, error: undefined, - email: undefined + email: undefined, + csrfToken } ); } catch ( error ) { logError( logger, error, { context: "GET /interaction/:uid" } ); @@ -501,6 +657,8 @@ async function main() { app.post( "/interaction/:uid/login", express.urlencoded( { extended: false } ), + loginRateLimiter, + csrfProtection, async ( req: Request, res: Response ) => { try { // Validate interaction UID path parameter @@ -523,12 +681,14 @@ async function main() { .filter( Boolean ); // Re-render login form with validation error + const csrfToken = generateCsrfToken( req, res ); return res.render( "interaction", { uid, prompt: "login", scopes: requestedScopes, error: "Invalid email or password format.", - email: String( req.body?.email || "" ).slice( 0, 254 ) // Preserve truncated email + email: String( req.body?.email || "" ).slice( 0, 254 ), // Preserve truncated email + csrfToken } ); } @@ -552,12 +712,14 @@ async function main() { .filter( Boolean ); // Re-render login form with error message + const csrfToken = generateCsrfToken( req, res ); return res.render( "interaction", { uid, prompt: "login", scopes: requestedScopes, error: "Invalid email or password. Please try again.", - email // Preserve the email field + email, // Preserve the email field + csrfToken } ); } @@ -633,6 +795,7 @@ async function main() { app.post( "/interaction/:uid/confirm", express.urlencoded( { extended: false } ), + csrfProtection, async ( req: Request, res: Response ) => { try { // Validate interaction UID path parameter @@ -750,6 +913,7 @@ async function main() { app.post( "/interaction/:uid/cancel", express.urlencoded( { extended: false } ), + csrfProtection, async ( req: Request, res: Response ) => { // Validate interaction UID path parameter const uidResult = validateInteractionUid( req.params.uid ); @@ -814,15 +978,38 @@ async function main() { }, "GET /auth - Authorization request received" ); } - // Intercept response to log token response - const originalSend = res.send.bind( res ); - const originalEnd = res.end.bind( res ); + // Intercept response to log token response. Use Response['send']/['end'] + // directly so the wrappers preserve Express's overloaded signatures. + const originalSend = res.send.bind( res ) as Response[ "send" ]; + const originalEnd = res.end.bind( res ) as Response[ "end" ]; + + interface TokenResponseBody { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + expires_in?: number; + scope?: string; + } + + const parseTokenBody = ( value: unknown ): TokenResponseBody | null => { + if ( typeof value === "string" ) { + try { + return JSON.parse( value ) as TokenResponseBody; + } catch { + return null; + } + } + if ( value && typeof value === "object" ) { + return value as TokenResponseBody; + } + return null; + }; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res.send = function ( body: any ) { + res.send = function ( body: unknown ) { if ( req.path === "/token" && req.method === "POST" ) { - try { - const parsed = typeof body === "string" ? JSON.parse( body ) : body; + const parsed = parseTokenBody( body ); + if ( parsed ) { logger.debug( { path: req.path, accessTokenIssued: !!parsed.access_token, @@ -832,48 +1019,46 @@ async function main() { expiresIn: parsed.expires_in, scope: parsed.scope }, "POST /token - Token response sent (via send)" ); - } catch { + } else { logger.debug( { path: req.path }, "POST /token - Response sent (could not parse)" ); } } return originalSend( body ); - }; + } as Response[ "send" ]; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - res.end = function ( chunk?: any, ...args: any[] ) { + res.end = function ( chunk?: unknown, ...args: unknown[] ) { if ( req.path === "/token" && req.method === "POST" && chunk ) { - try { - const parsed = typeof chunk === "string" ? JSON.parse( chunk ) : chunk; - if ( parsed.access_token ) { - // Count dots to determine JWT format (should have 2 dots = 3 parts) - const accessTokenParts = parsed.access_token ? parsed.access_token.split( "." ).length : 0; - const idTokenParts = parsed.id_token ? parsed.id_token.split( "." ).length : 0; - const refreshTokenParts = parsed.refresh_token ? parsed.refresh_token.split( "." ).length : 0; - - logger.debug( { - path: req.path, - accessTokenIssued: !!parsed.access_token, - accessTokenLength: parsed.access_token ? parsed.access_token.length : 0, - accessTokenParts, - accessTokenPrefix: parsed.access_token ? parsed.access_token.substring( 0, 20 ) : "", - idTokenIssued: !!parsed.id_token, - idTokenLength: parsed.id_token ? parsed.id_token.length : 0, - idTokenParts, - refreshTokenIssued: !!parsed.refresh_token, - refreshTokenLength: parsed.refresh_token ? parsed.refresh_token.length : 0, - refreshTokenParts, - refreshTokenPrefix: parsed.refresh_token ? parsed.refresh_token.substring( 0, 20 ) : "", - tokenType: parsed.token_type, - expiresIn: parsed.expires_in, - scope: parsed.scope - }, "POST /token - Token response sent (via end)" ); - } - } catch { - // Ignore parsing errors + const parsed = parseTokenBody( chunk ); + if ( parsed?.access_token ) { + // Count dots to determine JWT format (should have 2 dots = 3 parts) + const accessTokenParts = parsed.access_token.split( "." ).length; + const idTokenParts = parsed.id_token ? parsed.id_token.split( "." ).length : 0; + const refreshTokenParts = parsed.refresh_token ? parsed.refresh_token.split( "." ).length : 0; + + logger.debug( { + path: req.path, + accessTokenIssued: !!parsed.access_token, + accessTokenLength: parsed.access_token.length, + accessTokenParts, + accessTokenPrefix: parsed.access_token.substring( 0, 20 ), + idTokenIssued: !!parsed.id_token, + idTokenLength: parsed.id_token ? parsed.id_token.length : 0, + idTokenParts, + refreshTokenIssued: !!parsed.refresh_token, + refreshTokenLength: parsed.refresh_token ? parsed.refresh_token.length : 0, + refreshTokenParts, + refreshTokenPrefix: parsed.refresh_token ? parsed.refresh_token.substring( 0, 20 ) : "", + tokenType: parsed.token_type, + expiresIn: parsed.expires_in, + scope: parsed.scope + }, "POST /token - Token response sent (via end)" ); } } - return originalEnd( chunk, ...args ); - }; + // Express's `end` is heavily overloaded; we delegate to the original + // implementation via Reflect.apply so we don't have to enumerate + // every overload in our types. + return Reflect.apply( originalEnd, res, [ chunk, ...args ] ); + } as Response[ "end" ]; next(); } ); diff --git a/apps/auth/src/oidc-provider.d.ts b/apps/auth/src/oidc-provider.d.ts index ffee34c..1f2cfce 100644 --- a/apps/auth/src/oidc-provider.d.ts +++ b/apps/auth/src/oidc-provider.d.ts @@ -1 +1,77 @@ -declare module "oidc-provider"; +/** + * Minimal type declarations for `oidc-provider` v9.x. + * + * The upstream package does not currently ship its own types, and the + * community-maintained `@types/oidc-provider` only covers v8 APIs that no + * longer match what we use. We declare narrow shapes for just the pieces of + * the provider surface that this app actually touches so we can avoid + * `any` while keeping the declaration footprint small. + * + * Parameter names below are documentation-only (declarations have no body), + * so silence the unused-name lint that would otherwise fire on every method. + */ +/* eslint-disable no-unused-vars */ +declare module "oidc-provider" { + import type { IncomingMessage, ServerResponse } from "http"; + + export interface OIDCInteractionPrompt { + name: string; + details?: Record; + } + + export interface OIDCInteractionDetails { + uid: string; + prompt: OIDCInteractionPrompt; + params: Record; + session?: { accountId?: string }; + grantId?: string; + } + + export interface OIDCInteractionResult { + login?: { accountId: string }; + consent?: { grantId: string }; + } + + export interface OIDCGrantInstance { + addOIDCScope( scope: string ): void; + addOIDCClaims( claims: string[] ): void; + addResourceScope( resource: string, scope: string ): void; + save(): Promise; + } + + export interface OIDCGrantConstructor { + new ( props: { accountId: string; clientId: string } ): OIDCGrantInstance; + find( grantId: string ): Promise; + } + + export interface OIDCClient { + clientId: string; + client_id?: string; + grantTypeAllowed( grantType: string ): boolean; + } + + export interface OIDCAuthorizationCode { + scopes: Set; + } + + export class Provider { + constructor( issuer: string, configuration?: unknown ); + proxy: boolean; + // `callback()` returns a Node.js style request handler that works both + // as Express middleware and when invoked directly with (req, res). + callback(): ( req: IncomingMessage, res: ServerResponse, next?: ( err?: unknown ) => void ) => void; + interactionDetails( req: IncomingMessage, res: ServerResponse ): Promise; + interactionResult( + req: IncomingMessage, + res: ServerResponse, + result: OIDCInteractionResult, + options?: { mergeWithLastSubmission?: boolean } + ): Promise; + Grant: OIDCGrantConstructor; + } + + export const errors: { + OIDCProviderError: ErrorConstructor; + [key: string]: ErrorConstructor; + }; +} diff --git a/apps/auth/views/interaction.ejs b/apps/auth/views/interaction.ejs index 9c73e0c..43ee48e 100644 --- a/apps/auth/views/interaction.ejs +++ b/apps/auth/views/interaction.ejs @@ -43,6 +43,7 @@ <% } %>
    +