From ef5d643ba4bba187fdd330519881596969cadff1 Mon Sep 17 00:00:00 2001 From: Abubeker Yasin Date: Tue, 14 Jul 2026 14:16:48 +0300 Subject: [PATCH] feat: ( redirect ) add redirect url based on the domain name --- apps/edr-passenger-api/.env.example | 6 ++ .../migration.sql | 2 + apps/edr-passenger-api/prisma/schema.prisma | 1 + .../src/common/utils/redirect-origin.util.ts | 82 +++++++++++++++++++ .../modules/payments/payments.controller.ts | 17 +++- .../src/modules/payments/payments.service.ts | 38 +++++++-- .../modules/verifayda/verifayda.controller.ts | 10 +++ .../modules/verifayda/verifayda.service.ts | 34 +++++++- 8 files changed, 179 insertions(+), 11 deletions(-) create mode 100644 apps/edr-passenger-api/prisma/migrations/20260714111430_add_redirect_url/migration.sql create mode 100644 apps/edr-passenger-api/src/common/utils/redirect-origin.util.ts diff --git a/apps/edr-passenger-api/.env.example b/apps/edr-passenger-api/.env.example index fcca698e9..215490361 100644 --- a/apps/edr-passenger-api/.env.example +++ b/apps/edr-passenger-api/.env.example @@ -124,6 +124,12 @@ WAAFI_INSECURE_TLS=false # Payment Configuration PAYMENT_PROVIDERS_ENABLED=TELEBIRR,CBE_BIRR,EBIRR,CARD,WALLET,WAAFI +# Portal domains the browser-facing redirects (telebirr, waafi, Fayda WEB) may be rebased onto: +# the API swaps the configured URL's host for whichever of these the request came from. Comma- +# separated exact origins (scheme + host, no trailing slash). Leave empty to always use the +# configured URLs below. DMONEY is a server webhook and is never rebased. +PAYMENT_REDIRECT_ALLOWED_ORIGINS= + # Browser return targets after a hosted payment page (UX only — payment is confirmed by the # webhook/queryStatus, never this redirect). Global fallback used when a method-specific URL # below is unset. Most providers use a single redirect; Waafi takes separate success/failure. diff --git a/apps/edr-passenger-api/prisma/migrations/20260714111430_add_redirect_url/migration.sql b/apps/edr-passenger-api/prisma/migrations/20260714111430_add_redirect_url/migration.sql new file mode 100644 index 000000000..c844b3257 --- /dev/null +++ b/apps/edr-passenger-api/prisma/migrations/20260714111430_add_redirect_url/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "FaydaVerificationSession" ADD COLUMN "redirectUri" TEXT; diff --git a/apps/edr-passenger-api/prisma/schema.prisma b/apps/edr-passenger-api/prisma/schema.prisma index 00c5a8194..2721bb156 100644 --- a/apps/edr-passenger-api/prisma/schema.prisma +++ b/apps/edr-passenger-api/prisma/schema.prisma @@ -1395,6 +1395,7 @@ model FaydaVerificationSession { codeVerifier String purpose String @default("VERIFY") // VERIFY | LOGIN platform String @default("WEB") // WEB | MOBILE — recorded for audit + redirectUri String? // exact OAuth redirect_uri sent to eSignet at start; reused verbatim at token exchange (domain-aware for WEB) saveToAccount Boolean @default(false) status String @default("PENDING") errorCode String? diff --git a/apps/edr-passenger-api/src/common/utils/redirect-origin.util.ts b/apps/edr-passenger-api/src/common/utils/redirect-origin.util.ts new file mode 100644 index 000000000..437ccee69 --- /dev/null +++ b/apps/edr-passenger-api/src/common/utils/redirect-origin.util.ts @@ -0,0 +1,82 @@ +/** + * Domain-aware redirect helpers. + * + * The passenger portal is served from more than one public domain + * (e.g. https://bookingedr.et and https://passenger.edrsc.com). Payment + * providers and Fayda need a browser-facing return/redirect URL, and that URL + * must live on the SAME domain the user is currently browsing — otherwise the + * user is bounced to the "other" site mid-flow. + * + * We derive the target domain from the incoming request's Origin (falling back + * to Referer), but only trust it when it matches an explicit allowlist + * (PAYMENT_REDIRECT_ALLOWED_ORIGINS) so a spoofed Origin can't turn these into + * an open redirect. When the origin is absent or not allowlisted, the + * configured (env) URL is left untouched. + * + * NOTE: this only rebases the ORIGIN (scheme + host + port). The path/query of + * the configured URL is preserved, so the redirect always keeps its known + * suffix (e.g. /booking/payment/telebirr/success). For Fayda this matters — the + * full redirect_uri, host included, must be pre-registered with eSignet for + * BOTH domains, or the login is rejected. + */ + +/** Normalize to a bare origin ("https://host[:port]"), or null if unparseable. */ +function toOrigin(value?: string | null): string | null { + if (!value) return null; + try { + return new URL(value.trim()).origin; + } catch { + return null; + } +} + +/** Allowlisted origins from PAYMENT_REDIRECT_ALLOWED_ORIGINS (comma-separated). */ +function allowedOrigins(): string[] { + return (process.env.PAYMENT_REDIRECT_ALLOWED_ORIGINS ?? "") + .split(",") + .map((o) => toOrigin(o)) + .filter((o): o is string => o !== null); +} + +/** + * Pick the origin the request came from, restricted to the allowlist. Sources + * are tried in order of trustworthiness: + * 1. `origin` — the Origin header (most reliable on cross-origin requests). + * 2. `referer` — Referer fallback (browsers omit Origin on some same-site + * navigations but usually still send Referer). + * 3. `frontendBaseUrl` — the X-Frontend-Base-URL header the portal sets + * explicitly, used as a last resort when neither of the above is present. + * Returns null when none is present or allowlisted — callers then keep the + * configured URL. + */ +export function resolveAllowedOrigin( + origin?: string | null, + referer?: string | null, + frontendBaseUrl?: string | null, +): string | null { + const candidate = + toOrigin(origin) ?? toOrigin(referer) ?? toOrigin(frontendBaseUrl); + if (!candidate) return null; + return allowedOrigins().includes(candidate) ? candidate : null; +} + +/** + * Return `configuredUrl` with its origin swapped to `allowedOrigin`, preserving + * path, query and hash. If `allowedOrigin` is null or either URL is unparseable, + * the configured URL is returned unchanged. + */ +export function rebaseUrlOrigin( + configuredUrl: string | undefined, + allowedOrigin: string | null, +): string | undefined { + if (!configuredUrl || !allowedOrigin) return configuredUrl; + try { + const target = new URL(configuredUrl); + const base = new URL(allowedOrigin); + target.protocol = base.protocol; + target.host = base.host; + return target.toString(); + } catch { + return configuredUrl; + } +} diff --git a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts index 3c3135832..8917c75d3 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.controller.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.controller.ts @@ -3,6 +3,7 @@ import { Controller, Delete, Get, + Headers, HttpStatus, Param, Patch, @@ -37,6 +38,7 @@ import { } from "./payments.dto"; import { PassengerStaff } from "../../common/passenger-guards"; import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry"; +import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; @ApiTags("Payment") @Controller("payments") @@ -83,8 +85,19 @@ export class PaymentsController { summary: "Initiate payment with nationality-based payment methods", description: `Initiates payment for a booking with support for multiple payment providers:\n\n**Ethiopian Payment Methods:**\n- TELEBIRR - Ethiopia's leading mobile money\n- CBE_BIRR - Commercial Bank of Ethiopia\n- EBIRR - Electronic payment gateway\n\n**Djiboutian Payment Methods:**\n- WAAFI - Djibouti's mobile money service\n\n**International Payment Methods:**\n- CARD - Visa, Mastercard\n- WALLET - Internal wallet balance\n\n**Multi-Currency:**\n- All transactions processed in ETB\n- Display amounts in ETB, DJF, or USD\n- Real-time exchange rate conversion`, }) - initiatePayment(@Body() dto: InitiatePaymentDto) { - return this.service.initiatePayment(dto); + initiatePayment( + @Body() dto: InitiatePaymentDto, + @Headers("origin") origin?: string, + @Headers("referer") referer?: string, + @Headers("x-frontend-base-url") frontendBaseUrl?: string, + ) { + // Browser-facing return URLs (telebirr/waafi) follow the domain the user is + // on — derived from Origin, then Referer, then the X-Frontend-Base-URL + // header the portal sets, all validated against the allowlist. + return this.service.initiatePayment( + dto, + resolveAllowedOrigin(origin, referer, frontendBaseUrl), + ); } @Get("intents/:bookingId") diff --git a/apps/edr-passenger-api/src/modules/payments/payments.service.ts b/apps/edr-passenger-api/src/modules/payments/payments.service.ts index 4a02941aa..20fac61a5 100644 --- a/apps/edr-passenger-api/src/modules/payments/payments.service.ts +++ b/apps/edr-passenger-api/src/modules/payments/payments.service.ts @@ -26,6 +26,7 @@ import { import { PaymentEventDto, MarkPaidResponseDto } from "./internal-payments.dto"; import { PaymentClientService } from "./payment-client.service"; import { CurrencyService } from "../currency/currency.service"; +import { rebaseUrlOrigin } from "../../common/utils/redirect-origin.util"; import { PaymentService as PaymentServiceEnum, PaymentReferenceType, @@ -41,6 +42,15 @@ const NON_TERMINAL_STATUSES: PaymentIntentStatus[] = [ PaymentIntentStatus.SUCCEEDED, ]; +// Methods whose return/failure URLs are browser-facing pages on the passenger +// portal, so they should follow whichever domain the user came in on. DMONEY is +// deliberately excluded — its return URL is a server-to-server webhook host, not +// a page the browser lands on. +const DOMAIN_AWARE_METHODS = new Set([ + PaymentMethodType.TELEBIRR, + PaymentMethodType.WAAFI, +]); + @Injectable() export class PaymentsService { private readonly logger = new Logger(PaymentsService.name); @@ -172,7 +182,10 @@ export class PaymentsService { return correctTotal; } - async initiatePayment(dto: InitiatePaymentDto): Promise { + async initiatePayment( + dto: InitiatePaymentDto, + requestOrigin?: string | null, + ): Promise { const booking = await this.prisma.booking.findUnique({ where: { id: dto.bookingId }, include: { seats: true }, @@ -213,7 +226,10 @@ export class PaymentsService { return this.initiateWalletPayment(booking); } - const { returnUrl, failureUrl } = this.resolveReturnUrls(method); + const { returnUrl, failureUrl } = this.resolveReturnUrls( + method, + requestOrigin, + ); // The selected method's settlement currency lives in the PaymentMethod table (WAAFI/DMONEY // settle in DJF, CARD in USD, Ethiopian wallets in ETB). Convert the ETB booking total into @@ -294,7 +310,10 @@ export class PaymentsService { return this.formatIntentStatus(intent); } - private resolveReturnUrls(method: PaymentMethodType): { + private resolveReturnUrls( + method: PaymentMethodType, + requestOrigin?: string | null, + ): { returnUrl?: string; failureUrl?: string; } { @@ -323,9 +342,18 @@ export class PaymentsService { }; const m = perMethod[method] ?? {}; - const returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined; - const failureUrl = + let returnUrl = m.returnUrl || process.env.PAYMENT_RETURN_URL || undefined; + let failureUrl = m.failureUrl || process.env.PAYMENT_FAILURE_URL || returnUrl; + + // For browser-facing methods, swap the configured URL's host for whichever + // allowlisted domain the user is currently on (bookingedr.et vs + // passenger.edrsc.com). `requestOrigin` is already validated against the + // allowlist by the controller; when it's null the configured URL is kept. + if (DOMAIN_AWARE_METHODS.has(method) && requestOrigin) { + returnUrl = rebaseUrlOrigin(returnUrl, requestOrigin); + failureUrl = rebaseUrlOrigin(failureUrl, requestOrigin); + } return { returnUrl, failureUrl }; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts index 13de29616..4fd37f8a6 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + Headers, HttpCode, HttpStatus, Post, @@ -19,6 +20,7 @@ import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/curre import { IsPublic } from "@tria-plc/api-common/modules/auth/decorators/public.decorator"; import { JwtGuard } from "../../common/jwt.guard"; import { OptionalJwtGuard } from "./optional-jwt.guard"; +import { resolveAllowedOrigin } from "../../common/utils/redirect-origin.util"; import { CompleteVerificationResultDto, StartVerificationDto, @@ -68,12 +70,20 @@ export class VerifaydaController { async start( @Body() dto: StartVerificationDto, @Req() req: RequestWithOptionalUser, + @Headers("origin") origin?: string, + @Headers("referer") referer?: string, + @Headers("x-frontend-base-url") frontendBaseUrl?: string, ): Promise<{ authorizationUrl: string }> { const authorizationUrl = await this.service.startVerification({ purpose: dto.purpose ?? "VERIFY", platform: dto.platform ?? "WEB", userId: req.user?.id, wantsPasswordSetup: dto.wantsPasswordSetup ?? false, + // WEB redirect_uri host follows the domain the user is on — Origin, then + // Referer, then the X-Frontend-Base-URL header — validated against the + // allowlist. Both domain variants must be registered with eSignet or the + // login is rejected. + requestOrigin: resolveAllowedOrigin(origin, referer, frontendBaseUrl), }); return { authorizationUrl }; } diff --git a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts index 99b1d1d7c..89edf25a6 100644 --- a/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts +++ b/apps/edr-passenger-api/src/modules/verifayda/verifayda.service.ts @@ -18,6 +18,7 @@ import { generateState, } from './utils/pkce.util'; import { generateClientAssertion } from './utils/client-assertion.util'; +import { rebaseUrlOrigin } from '../../common/utils/redirect-origin.util'; import { VerifaydaCallbackDto, VerificationStatusDto } from './verifayda.dto'; import { FaydaTokenExchangeException, @@ -49,6 +50,13 @@ export interface StartVerificationInput { platform?: FaydaPlatform; userId?: string; // iamUserId of the authenticated user, if any wantsPasswordSetup?: boolean; + /** + * Allowlisted browser origin the request came from (e.g. https://bookingedr.et + * or https://passenger.edrsc.com), already validated by the controller. For + * WEB sessions the eSignet redirect_uri host is swapped to this so the user + * lands back on the same domain. Null/undefined keeps the configured default. + */ + requestOrigin?: string | null; } export interface FaydaUserSummary { @@ -145,12 +153,26 @@ export class VerifaydaService { Date.now() + this.faydaConfig.sessionTtlMinutes * 60_000, ); + const platform = input.platform ?? 'WEB'; + // WEB sessions follow the domain the user came in on (bookingedr.et vs + // passenger.edrsc.com); MOBILE always uses the native base redirect_uri. + // Persist the exact URI so token exchange at /complete reuses it verbatim — + // eSignet requires the redirect_uri to match between authorize and token. + const redirectUri = + platform === 'MOBILE' + ? this.redirectUriForPlatform('MOBILE') + : rebaseUrlOrigin( + this.faydaConfig.webRedirectUri, + input.requestOrigin ?? null, + ) ?? this.faydaConfig.webRedirectUri; + await this.prisma.faydaVerificationSession.create({ data: { state, codeVerifier, purpose: input.purpose, - platform: input.platform ?? 'WEB', + platform, + redirectUri, saveToAccount: input.wantsPasswordSetup ?? false, iamUserId: input.userId ?? null, expiresAt, @@ -158,13 +180,13 @@ export class VerifaydaService { }); this.logger.log( - `Fayda verification started: purpose=${input.purpose} platform=${input.platform ?? 'WEB'} userId=${input.userId ?? 'none'}`, + `Fayda verification started: purpose=${input.purpose} platform=${platform} userId=${input.userId ?? 'none'} redirectUri=${redirectUri}`, ); return this.buildAuthorizationUrl({ state, codeChallenge, - redirectUri: this.redirectUriForPlatform(input.platform ?? 'WEB'), + redirectUri, }); } @@ -224,7 +246,11 @@ export class VerifaydaService { const tokens = await this.exchangeCodeForTokens( query.code, session.codeVerifier, - this.redirectUriForPlatform(session.platform as FaydaPlatform), + // Reuse the exact redirect_uri sent at authorize (persisted on the + // session). Fall back to the platform default for sessions created + // before this column existed. + session.redirectUri ?? + this.redirectUriForPlatform(session.platform as FaydaPlatform), ); const userInfo = await this.fetchUserInfo(tokens.access_token); const normalized = this.normalizeUserInfo(userInfo);