diff --git a/apps/edr-passenger-web/portal/src/app/go/page.tsx b/apps/edr-passenger-web/portal/src/app/go/page.tsx index b8fc8769f..2b1f9bc9b 100644 --- a/apps/edr-passenger-web/portal/src/app/go/page.tsx +++ b/apps/edr-passenger-web/portal/src/app/go/page.tsx @@ -3,36 +3,47 @@ import { Suspense, useEffect, useMemo } from "react"; import { useSearchParams } from "next/navigation"; -import { isDMoneyCheckoutUrl } from "@/lib/payment-redirect"; +import { isBounceableCheckoutUrl } from "@/lib/payment-redirect"; /** - * /go — payment redirect bounce page for D-Money web checkout. + * /go — payment redirect bounce page for provider web checkout (D-Money, telebirr). * * The redirect is done CLIENT-SIDE on purpose: the navigation must originate * from the loaded https://edrpassenger.triaplc.com/go document so the browser - * sends `Referer: https://edrpassenger.triaplc.com` to D-Money. D-Money only + * sends `Referer: https://edrpassenger.triaplc.com` to the paygate. D-Money only * whitelists that origin, so a server-side 307 (whose referrer on the redirect * hop is browser-dependent and can be stripped) must NOT be used here. * + * Telebirr's paygate has the same requirement — its checkout URL opens when the + * portal navigates to it but returns "the required parameter of the request is + * empty" when opened with no Referer at all (address-bar paste, or a native + * WebView's `loadRequest`). Native clients therefore bounce telebirr through + * here too. The portal's own web flow does NOT: it already navigates from the + * payment page, so it supplies a Referer without the extra hop. + * * It fires immediately (no delay) and paints a bare white full-screen cover * above the sidebar/tab bar (z-40) — no portal chrome, no text on the happy * path. A short message shows only when the link is missing/untrusted. * * `?url=` MUST be percent-encoded by the caller (Uri.encodeComponent() in the * Flutter app / encodeURIComponent() on web); otherwise the query parser - * truncates the D-Money URL at its first `&` and merch_code/sign are lost. - * See scripts/test-go-redirect.mjs. + * truncates the checkout URL at its first `&`. For telebirr this also matters + * for the base64 `sign`, whose `+` would otherwise decode to a space. */ function RedirectView() { const searchParams = useSearchParams(); const raw = searchParams.get("url"); - const target = useMemo(() => (isDMoneyCheckoutUrl(raw) ? raw : null), [raw]); + const target = useMemo( + () => (isBounceableCheckoutUrl(raw) ? raw : null), + [raw], + ); useEffect(() => { if (!target) return; - // Navigate from this document so the D-Money request carries - // Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists). + // Navigate from this document so the paygate request carries + // Referer: https://edrpassenger.triaplc.com (the origin D-Money whitelists, + // and the one telebirr's paygate needs present). window.location.replace(target); }, [target]); diff --git a/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts b/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts index b235401d1..60207b73b 100644 --- a/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts +++ b/apps/edr-passenger-web/portal/src/lib/payment-redirect.ts @@ -8,17 +8,60 @@ * Every place that follows a `clientAction.url` from `/payments/initiate` runs * it through `resolvePaymentRedirectUrl()`; non-D-Money providers (Telebirr, * Waafi, card) pass through untouched. + * + * Telebirr's paygate turns out to behave the same way — the checkout URL loads + * when the portal navigates to it with `window.location.href` but fails with + * "the required parameter of the request is empty" when opened with no Referer + * (address-bar paste, or a native WebView's `loadRequest`). It is therefore + * accepted by `/go` (see `isBounceableCheckoutUrl`) so non-browser clients can + * borrow the portal's origin. + * + * On web, telebirr is deliberately NOT auto-wrapped: `resolvePaymentRedirectUrl` + * still returns telebirr URLs unchanged. The browser flow already supplies a + * Referer for free by navigating from the payment page, it is live, and putting + * an extra hop in front of it buys nothing. */ -const ALLOWED_HOSTS = ( +/** Comma-separated env list → normalized, non-empty, lowercase hosts. */ +function parseHosts(raw: string): string[] { + return raw + .split(",") + .map((h) => h.trim().toLowerCase()) + .filter(Boolean); +} + +const ALLOWED_HOSTS = parseHosts( // Default allows both D-Money environments: // test/sandbox → pgtest.d-money.dj (base domain d-money.dj) // production → pg.d-moneyservice.dj (base domain d-moneyservice.dj) - process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj" -) - .split(",") - .map((h) => h.trim().toLowerCase()) - .filter(Boolean); + process.env.NEXT_PUBLIC_DMONEY_ALLOWED_HOSTS ?? "d-money.dj,d-moneyservice.dj", +); + +const TELEBIRR_ALLOWED_HOSTS = parseHosts( + // Default allows both telebirr environments: + // test/sandbox → developerportal.ethiotelebirr.et (base domain ethiotelebirr.et) + // production → superapp.ethiomobilemoney.et (base domain ethiomobilemoney.et) + // Both serve the paygate on port 38443; the port is not part of the match. + process.env.NEXT_PUBLIC_TELEBIRR_ALLOWED_HOSTS ?? + "ethiotelebirr.et,ethiomobilemoney.et", +); + +/** True for https URLs whose host is (or is a subdomain of) one of `allowed`. */ +function isHttpsHostMatch( + raw: string | null | undefined, + allowed: string[], +): raw is string { + if (!raw) return false; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + if (parsed.protocol !== "https:") return false; + const host = parsed.hostname.toLowerCase(); + return allowed.some((a) => host === a || host.endsWith(`.${a}`)); +} /** * Origin the `/go` bounce page is served from — must be the exact origin D-Money @@ -34,18 +77,27 @@ const BOUNCE_ORIGIN = ( /** True only for https URLs whose host is (or is a subdomain of) an allowlisted D-Money host. */ export function isDMoneyCheckoutUrl(raw: string | null | undefined): raw is string { - if (!raw) return false; - let parsed: URL; - try { - parsed = new URL(raw); - } catch { - return false; - } - if (parsed.protocol !== "https:") return false; - const host = parsed.hostname.toLowerCase(); - return ALLOWED_HOSTS.some( - (allowed) => host === allowed || host.endsWith(`.${allowed}`), - ); + return isHttpsHostMatch(raw, ALLOWED_HOSTS); +} + +/** True only for https URLs whose host is (or is a subdomain of) an allowlisted telebirr host. */ +export function isTelebirrCheckoutUrl( + raw: string | null | undefined, +): raw is string { + return isHttpsHostMatch(raw, TELEBIRR_ALLOWED_HOSTS); +} + +/** + * Providers whose paygate `/go` is allowed to bounce to. + * + * This is the allowlist that keeps `/go` from being an open redirect, so it must + * stay tight: only hosts that (a) we actually initiate payments against and + * (b) need a portal-origin `Referer` to open. + */ +export function isBounceableCheckoutUrl( + raw: string | null | undefined, +): raw is string { + return isDMoneyCheckoutUrl(raw) || isTelebirrCheckoutUrl(raw); } /**