mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( redirect ) add redirect url based on the domain name
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "FaydaVerificationSession" ADD COLUMN "redirectUri" TEXT;
|
||||
@@ -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?
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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>([
|
||||
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<InitiateResponseDto> {
|
||||
async initiatePayment(
|
||||
dto: InitiatePaymentDto,
|
||||
requestOrigin?: string | null,
|
||||
): Promise<InitiateResponseDto> {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user