Add TPIN reset via OTP, with server-clock countdowns and redacted API logs
- Reset TPIN: empty-body initiate -> OTP -> encrypted new TPIN via /tpin/reset/*, from a Services tile, search, and "Forgot your TPIN?" on the transfer's TPIN sheet (returns to the same transfer review) - Handle every documented code: SESSION_INVALID signs out, TPIN_NOT_SET offers Create TPIN, CBS customer/account/org refusals stop the flow, outages retry from the start, expired or spent OTP/token restarts, policy VALIDATION_ERROR stays on the new-TPIN step - Secrets live in screen state only and are dropped as each step ends - Countdowns use the envelope timestamp, kept aside by the interceptor, so a wrong device clock cannot skew OTP or token expiry - New TPINs refuse any three-digit run (1235, 5987), for create too - Dev API logger redacts the session token, OTPs, PINs, encrypted payloads and one-time tokens - Tests: request bodies, RSA-OAEP SHA-256 payload decrypted with Node, fresh nonce on key retry, server-clock deadlines, log redaction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f1def81eb7
commit
5fbd13ba12
@@ -184,6 +184,7 @@ export enum FeatureKey {
|
||||
SERVICES_MINI_STATEMENT = 'services.miniStatement',
|
||||
SERVICES_MPIN_CHANGE = 'services.mpinChange',
|
||||
SERVICES_TPIN_CHANGE = 'services.tpinChange',
|
||||
SERVICES_TPIN_RESET = 'services.tpinReset',
|
||||
SERVICES_FATE_OF_CHEQUE = 'services.fateOfCheque',
|
||||
|
||||
// --------------------------------------------------------------- Loans ----
|
||||
|
||||
@@ -191,6 +191,7 @@ export const DEFAULT_FEATURE_CONFIG: Record<FeatureKey, FeatureDefinition> = {
|
||||
},
|
||||
[FeatureKey.SERVICES_FATE_OF_CHEQUE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_TPIN_CHANGE]: { enabled: true },
|
||||
[FeatureKey.SERVICES_TPIN_RESET]: { enabled: true, description: 'Reset a forgotten TPIN with an OTP' },
|
||||
// Service-desk entry points to screens the home and profile stacks already
|
||||
// own. Switching one off takes the tile away, not the screen.
|
||||
[FeatureKey.SERVICES_MINI_STATEMENT]: { enabled: true },
|
||||
|
||||
@@ -0,0 +1,700 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
|
||||
import { Button, Card, InfoBanner, Stepper } from '../../../components';
|
||||
import { useTheme } from '../../../theme';
|
||||
import { useToast } from '../../../hooks/useToast';
|
||||
import { describeError } from '../../../services/api/cbsErrors';
|
||||
import { secondsUntilDeadline } from '../../../services/api/serverClock';
|
||||
import { toApiError } from '../../../services/api/serviceHelpers';
|
||||
import type { ApiError } from '../../../types/api';
|
||||
import { isValidOtp } from '../../../utils/validation';
|
||||
import { CodeInput } from '../../auth/components/CodeInput';
|
||||
import { tpinService } from '../services/tpinService';
|
||||
import { TPIN_LENGTH, describeTpinProblem, isAcceptableNewTpin } from '../utils/tpinValidation';
|
||||
|
||||
interface TpinResetFlowProps {
|
||||
/**
|
||||
* The session's registered mobile. Feeds the "not the last four digits of
|
||||
* your mobile" rule and the masked OTP copy; never sent — the server reads
|
||||
* it from the session.
|
||||
*/
|
||||
mobileNumber?: string;
|
||||
/** Shown above the first step, e.g. why a transfer sent the customer here. */
|
||||
notice?: string;
|
||||
/** Runs once the new TPIN is active and every secret here is cleared. */
|
||||
onSuccess: () => void;
|
||||
/** Leaves the flow when it cannot go on — no TPIN to reset, or the bank refused. */
|
||||
onExit: () => void;
|
||||
/**
|
||||
* TPIN_NOT_SET: there is nothing to reset, so offer to create one. Left out
|
||||
* where Create TPIN is not reachable, and the customer gets Done instead.
|
||||
*/
|
||||
onCreateTpin?: () => void;
|
||||
}
|
||||
|
||||
type Step = 'start' | 'otp' | 'tpin' | 'notSet' | 'stopped';
|
||||
|
||||
/** How long before "send a new code" is offered. There is no resend endpoint. */
|
||||
const RESEND_COOLDOWN_SECONDS = 30;
|
||||
|
||||
const OTP_LENGTH = 6;
|
||||
|
||||
const STEPS: ReadonlyArray<{ key: Step; label: string }> = [
|
||||
{ key: 'start', label: 'Start' },
|
||||
{ key: 'otp', label: 'OTP' },
|
||||
{ key: 'tpin', label: 'New TPIN' },
|
||||
];
|
||||
|
||||
const COPY = {
|
||||
otpExpired: 'That code expired. Send a new code to continue.',
|
||||
attemptsExceeded: 'Too many wrong codes. Send a new code to try again.',
|
||||
requestInvalid: 'This reset request is no longer valid. Send a new code to start again.',
|
||||
tokenSpent: 'Your time to set a new TPIN ran out. Send a new code to start again.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The initiate refusals that end the flow for good. The shared copy for these
|
||||
* is written for registration ("No accounts to link"), so a reset gets its
|
||||
* own; the server's message, when there is one, still takes precedence.
|
||||
*/
|
||||
const STOP_COPY: Record<string, { title: string; description: string }> = {
|
||||
CUSTOMER_NOT_FOUND: {
|
||||
title: 'We could not verify you with your bank',
|
||||
description:
|
||||
"Your bank's records do not match this profile, so your TPIN cannot be reset here. Contact your branch.",
|
||||
},
|
||||
NO_ACCOUNTS_FOUND: {
|
||||
title: 'No accounts found',
|
||||
description:
|
||||
'Your bank returned no accounts for this profile, so your TPIN cannot be reset here. Contact your branch.',
|
||||
},
|
||||
ORG_NOT_CONFIGURED: {
|
||||
title: 'TPIN reset is unavailable',
|
||||
description: 'Your bank has not set this service up yet. Contact your branch.',
|
||||
},
|
||||
};
|
||||
|
||||
const formatCountdown = (seconds: number) =>
|
||||
`${Math.floor(seconds / 60)}:${`${seconds % 60}`.padStart(2, '0')}`;
|
||||
|
||||
const messageFor = (apiError: ApiError) =>
|
||||
apiError.message || describeError(apiError.code).description;
|
||||
|
||||
/**
|
||||
* Resetting the TPIN, end to end: send OTP → verify OTP → new TPIN.
|
||||
*
|
||||
* Unlike creating one, a reset needs no MPIN: the signed-in session is the
|
||||
* first factor, and initiate goes out with an empty body — the server takes
|
||||
* org, mobile and device from the session and verifies the customer with the
|
||||
* bank before any OTP is sent. The session must stay the same for all three
|
||||
* calls; SESSION_INVALID anywhere signs the customer out (the interceptor does
|
||||
* that) and this flow drops everything it holds.
|
||||
*
|
||||
* Shared by Services' Reset TPIN and the transfer sheet's "Forgot TPIN?",
|
||||
* which differ only in where the customer goes afterwards — so the screens own
|
||||
* the navigation, and this owns the flow.
|
||||
*
|
||||
* ── WHY LOCAL STATE, AND WHY NO REDUX ───────────────────────────────────────
|
||||
* The OTP, the `tpinResetId`, the one-time `tpinResetToken` and the TPIN
|
||||
* itself must live in memory only, and be gone on back-navigation, restart,
|
||||
* failure and success. Component state gives exactly that — leaving the screen
|
||||
* unmounts it — and keeps them out of the Redux store, where dev tooling would
|
||||
* record them. Each is also dropped the moment its step is done with it: the
|
||||
* reset id once the OTP is verified, the token once it is spent or expires.
|
||||
* Nothing here is persisted, logged or sent to analytics.
|
||||
*
|
||||
* ── TIME ────────────────────────────────────────────────────────────────────
|
||||
* Both countdowns run to deadlines worked out from the server's expiry and the
|
||||
* server's own clock (see `serverClock`), not the handset's idea of the time.
|
||||
* Reaching either one ends the attempt here, without waiting for the server to
|
||||
* refuse a code or token that is already dead.
|
||||
*/
|
||||
export const TpinResetFlow = ({
|
||||
mobileNumber,
|
||||
notice,
|
||||
onSuccess,
|
||||
onExit,
|
||||
onCreateTpin,
|
||||
}: TpinResetFlowProps) => {
|
||||
const theme = useTheme();
|
||||
const toast = useToast();
|
||||
const [step, setStep] = useState<Step>('start');
|
||||
const [otp, setOtp] = useState('');
|
||||
const [tpin, setTpin] = useState('');
|
||||
const [confirmTpin, setConfirmTpin] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
/** A failure about the flow as a whole, shown as a banner. */
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** A failure about the field on screen, shown under it. */
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
/** Why the flow stopped for good, on the `stopped` step. */
|
||||
const [stopped, setStopped] = useState<{ title: string; message: string } | null>(null);
|
||||
|
||||
// Secrets of the flow. Screen state only — see the note above.
|
||||
const [tpinResetId, setTpinResetId] = useState<string | null>(null);
|
||||
const [otpDeadline, setOtpDeadline] = useState<number | null>(null);
|
||||
const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
|
||||
const [tpinResetToken, setTpinResetToken] = useState<string | null>(null);
|
||||
const [tokenDeadline, setTokenDeadline] = useState<number | null>(null);
|
||||
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
const [cooldown, setCooldown] = useState(0);
|
||||
const mounted = useRef(true);
|
||||
/**
|
||||
* Set synchronously when a call goes out, so a double tap cannot send two
|
||||
* before `busy` has re-rendered the button disabled.
|
||||
*/
|
||||
const inFlight = useRef(false);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
mounted.current = false;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// One tick drives both countdowns and the resend cooldown.
|
||||
useEffect(() => {
|
||||
if (step !== 'otp' && step !== 'tpin') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setNow(Date.now());
|
||||
setCooldown((value) => (value > 0 ? value - 1 : 0));
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [step]);
|
||||
|
||||
const clearSecrets = useCallback(() => {
|
||||
setTpinResetId(null);
|
||||
setOtpDeadline(null);
|
||||
setAttemptsRemaining(null);
|
||||
setTpinResetToken(null);
|
||||
setTokenDeadline(null);
|
||||
setOtp('');
|
||||
setTpin('');
|
||||
setConfirmTpin('');
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Throws the whole attempt away and returns to the start. An OTP can only be
|
||||
* reissued by a fresh initiate, which is one tap from there.
|
||||
*/
|
||||
const restart = useCallback(
|
||||
(message?: string) => {
|
||||
clearSecrets();
|
||||
setFieldError(null);
|
||||
setStep('start');
|
||||
setError(message ?? null);
|
||||
},
|
||||
[clearSecrets],
|
||||
);
|
||||
|
||||
/** TPIN_NOT_SET: nothing to reset, so the flow stops and offers Create. */
|
||||
const endAsNotSet = useCallback(() => {
|
||||
clearSecrets();
|
||||
setFieldError(null);
|
||||
setError(null);
|
||||
setStep('notSet');
|
||||
}, [clearSecrets]);
|
||||
|
||||
/** The bank cannot match or serve this customer; retrying will not change that. */
|
||||
const endAsStopped = useCallback(
|
||||
(apiError: ApiError) => {
|
||||
const copy = (apiError.code && STOP_COPY[apiError.code]) || describeError(apiError.code);
|
||||
|
||||
clearSecrets();
|
||||
setFieldError(null);
|
||||
setError(null);
|
||||
setStopped({ title: copy.title, message: apiError.message || copy.description });
|
||||
setStep('stopped');
|
||||
},
|
||||
[clearSecrets],
|
||||
);
|
||||
|
||||
/**
|
||||
* The default failure path. VALIDATION_ERROR's field map is split: the entry
|
||||
* for the field on screen goes under it, the rest go in the banner. Any
|
||||
* other failure is a banner with the server's message.
|
||||
*/
|
||||
const showFailure = useCallback((apiError: ApiError, field: string) => {
|
||||
const fields = apiError.fieldErrors ?? {};
|
||||
const own = fields[field];
|
||||
const others = Object.entries(fields)
|
||||
.filter(([key]) => key !== field)
|
||||
.map(([, message]) => message);
|
||||
|
||||
setFieldError(own ?? null);
|
||||
setError(others.length > 0 ? others.join('\n') : own ? null : messageFor(apiError));
|
||||
}, []);
|
||||
|
||||
// A code or token that has run out is dead whatever the server would say, so
|
||||
// the attempt ends here. Never mid-call: a confirm sent a moment before the
|
||||
// deadline may still be succeeding, and its answer decides what happens.
|
||||
useEffect(() => {
|
||||
if (inFlight.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (step === 'otp' && secondsUntilDeadline(otpDeadline, now) === 0) {
|
||||
restart(COPY.otpExpired);
|
||||
} else if (step === 'tpin' && secondsUntilDeadline(tokenDeadline, now) === 0) {
|
||||
restart(COPY.tokenSpent);
|
||||
}
|
||||
}, [now, otpDeadline, restart, step, tokenDeadline]);
|
||||
|
||||
/* ── Step 1: initiate — also the resend ───────────────────────────────── */
|
||||
|
||||
const handleInitiate = useCallback(async () => {
|
||||
if (inFlight.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
// A new code replaces any code already sent.
|
||||
setOtp('');
|
||||
|
||||
try {
|
||||
const result = await tpinService.initiateReset();
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
setTpinResetId(result.tpinResetId);
|
||||
setOtpDeadline(result.otpDeadline);
|
||||
setAttemptsRemaining(result.attemptsRemaining);
|
||||
setCooldown(RESEND_COOLDOWN_SECONDS);
|
||||
setNow(Date.now());
|
||||
setStep('otp');
|
||||
toast('Code sent', 'success', 'Enter the 6-digit code we sent to your registered mobile.');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
// The interceptor has already signed the customer out.
|
||||
clearSecrets();
|
||||
return;
|
||||
case 'TPIN_NOT_SET':
|
||||
endAsNotSet();
|
||||
return;
|
||||
case 'CUSTOMER_NOT_FOUND':
|
||||
case 'NO_ACCOUNTS_FOUND':
|
||||
case 'ORG_NOT_CONFIGURED':
|
||||
endAsStopped(apiError);
|
||||
return;
|
||||
default:
|
||||
// CBS_UNAVAILABLE, CBS_ERROR, CONNECTOR_UNAVAILABLE,
|
||||
// OTP_PROVIDER_UNAVAILABLE, SMS_DELIVERY_FAILED and the rest: back to
|
||||
// the start with the server's message, where Send code retries. A
|
||||
// failed resend lands here too — the code it replaced is abandoned.
|
||||
clearSecrets();
|
||||
setStep('start');
|
||||
setError(messageFor(apiError));
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [clearSecrets, endAsNotSet, endAsStopped, toast]);
|
||||
|
||||
/* ── Step 2: verify OTP ───────────────────────────────────────────────── */
|
||||
|
||||
const handleVerifyOtp = useCallback(async () => {
|
||||
if (!tpinResetId || !isValidOtp(otp) || inFlight.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (secondsUntilDeadline(otpDeadline) === 0) {
|
||||
restart(COPY.otpExpired);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
|
||||
try {
|
||||
const result = await tpinService.verifyResetOtp({ tpinResetId, otp });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
// Only the token goes forward; the OTP and the reset id are done with.
|
||||
setOtp('');
|
||||
setTpinResetId(null);
|
||||
setOtpDeadline(null);
|
||||
setAttemptsRemaining(null);
|
||||
setTpinResetToken(result.tpinResetToken);
|
||||
setTokenDeadline(result.tokenDeadline);
|
||||
setNow(Date.now());
|
||||
setStep('tpin');
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
setOtp('');
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
clearSecrets();
|
||||
return;
|
||||
case 'TPIN_NOT_SET':
|
||||
endAsNotSet();
|
||||
return;
|
||||
// An expired, exhausted or voided request cannot be retried — a fresh
|
||||
// initiate can.
|
||||
case 'OTP_EXPIRED':
|
||||
restart(COPY.otpExpired);
|
||||
return;
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
restart(COPY.attemptsExceeded);
|
||||
return;
|
||||
case 'OTP_NOT_VERIFIED':
|
||||
restart(COPY.requestInvalid);
|
||||
return;
|
||||
case 'OTP_INVALID': {
|
||||
const remaining =
|
||||
attemptsRemaining === null ? null : Math.max(0, attemptsRemaining - 1);
|
||||
|
||||
// Retry only while attempts remain; with none left the request is
|
||||
// spent and only a new code will do.
|
||||
if (remaining === 0) {
|
||||
restart(COPY.attemptsExceeded);
|
||||
return;
|
||||
}
|
||||
|
||||
setAttemptsRemaining(remaining);
|
||||
setFieldError(
|
||||
remaining
|
||||
? `That code is incorrect. ${remaining} ${remaining === 1 ? 'attempt' : 'attempts'} left.`
|
||||
: 'That code is incorrect.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
// VALIDATION_ERROR paints its `otp` entry under the field.
|
||||
showFailure(apiError, 'otp');
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [
|
||||
attemptsRemaining,
|
||||
clearSecrets,
|
||||
endAsNotSet,
|
||||
otp,
|
||||
otpDeadline,
|
||||
restart,
|
||||
showFailure,
|
||||
tpinResetId,
|
||||
]);
|
||||
|
||||
/* ── Step 3: encrypt the new TPIN and confirm ─────────────────────────── */
|
||||
|
||||
const handleConfirm = useCallback(async () => {
|
||||
if (
|
||||
!tpinResetToken ||
|
||||
!isAcceptableNewTpin(tpin, mobileNumber) ||
|
||||
confirmTpin !== tpin ||
|
||||
inFlight.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// An expired token is refused anyway; say so without the round trip.
|
||||
if (secondsUntilDeadline(tokenDeadline) === 0) {
|
||||
restart(COPY.tokenSpent);
|
||||
return;
|
||||
}
|
||||
|
||||
inFlight.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
|
||||
try {
|
||||
// Fetches the current public key and encrypts {tpin, nonce, ts} fresh.
|
||||
await tpinService.confirmReset({ tpinResetToken, tpin });
|
||||
|
||||
if (!mounted.current) return;
|
||||
|
||||
clearSecrets();
|
||||
toast('TPIN reset', 'success', 'Use your new TPIN to authorise fund transfers.');
|
||||
onSuccess();
|
||||
} catch (caught) {
|
||||
if (!mounted.current) return;
|
||||
|
||||
const apiError = toApiError(caught);
|
||||
setTpin('');
|
||||
setConfirmTpin('');
|
||||
|
||||
switch (apiError.code) {
|
||||
case 'SESSION_INVALID':
|
||||
clearSecrets();
|
||||
return;
|
||||
case 'TPIN_NOT_SET':
|
||||
endAsNotSet();
|
||||
return;
|
||||
// A spent or expired token: this attempt is over.
|
||||
case 'OTP_NOT_VERIFIED':
|
||||
case 'OTP_EXPIRED':
|
||||
case 'OTP_ATTEMPTS_EXCEEDED':
|
||||
restart(COPY.tokenSpent);
|
||||
return;
|
||||
case 'INTERNAL_ERROR':
|
||||
// Nothing sensitive is kept past an unexplained failure — token
|
||||
// included. The customer starts again with a new code.
|
||||
restart(messageFor(apiError));
|
||||
return;
|
||||
case 'VALIDATION_ERROR':
|
||||
// A policy refusal — a sequence, the mobile's last four, the date of
|
||||
// birth, the current or a recent TPIN — comes as a message with no
|
||||
// field map. It is about the TPIN, so it goes under the TPIN, and the
|
||||
// same token takes a different one.
|
||||
if (!apiError.fieldErrors) {
|
||||
setFieldError(messageFor(apiError));
|
||||
return;
|
||||
}
|
||||
|
||||
showFailure(apiError, 'encryptedTpin');
|
||||
return;
|
||||
default:
|
||||
// MALFORMED_REQUEST has already been retried once with a new key and
|
||||
// payload; Reset TPIN again builds another. The token stays.
|
||||
showFailure(apiError, 'encryptedTpin');
|
||||
}
|
||||
} finally {
|
||||
inFlight.current = false;
|
||||
if (mounted.current) setBusy(false);
|
||||
}
|
||||
}, [
|
||||
clearSecrets,
|
||||
confirmTpin,
|
||||
endAsNotSet,
|
||||
mobileNumber,
|
||||
onSuccess,
|
||||
restart,
|
||||
showFailure,
|
||||
toast,
|
||||
tokenDeadline,
|
||||
tpin,
|
||||
tpinResetToken,
|
||||
]);
|
||||
|
||||
/* ── render ───────────────────────────────────────────────────────────── */
|
||||
|
||||
if (step === 'notSet') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
title="You don't have a TPIN yet"
|
||||
message="There is nothing to reset. Create a TPIN to authorise your fund transfers."
|
||||
/>
|
||||
<View style={styles.actions}>
|
||||
{onCreateTpin ? <Button title="Create TPIN" onPress={onCreateTpin} /> : null}
|
||||
<Button
|
||||
title={onCreateTpin ? 'Not now' : 'Done'}
|
||||
variant={onCreateTpin ? 'ghost' : 'primary'}
|
||||
onPress={onExit}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === 'stopped') {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<InfoBanner
|
||||
tone="danger"
|
||||
title={stopped?.title}
|
||||
message={stopped?.message ?? describeError(undefined).description}
|
||||
/>
|
||||
<Button title="Done" onPress={onExit} style={styles.submit} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
||||
const sentTo = mobileDigits.length >= 4 ? `+91 XXXXXX${mobileDigits.slice(-4)}` : null;
|
||||
|
||||
const otpSecondsLeft = step === 'otp' ? secondsUntilDeadline(otpDeadline, now) : null;
|
||||
const otpExpired = otpSecondsLeft === 0;
|
||||
const tokenSecondsLeft = step === 'tpin' ? secondsUntilDeadline(tokenDeadline, now) : null;
|
||||
|
||||
const tpinProblem = describeTpinProblem(tpin, mobileNumber);
|
||||
const confirmProblem =
|
||||
confirmTpin.length === TPIN_LENGTH && confirmTpin !== tpin ? 'Both entries must match' : undefined;
|
||||
|
||||
const stepIndex = STEPS.findIndex((item) => item.key === step);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Stepper steps={STEPS.map((item) => item.label)} current={stepIndex} />
|
||||
|
||||
{error ? <InfoBanner tone="danger" message={error} /> : null}
|
||||
|
||||
{step === 'start' ? (
|
||||
<>
|
||||
{notice ? <InfoBanner tone="info" message={notice} /> : null}
|
||||
|
||||
<Card style={styles.card}>
|
||||
<Text style={[theme.text.bodyStrong, { color: theme.colors.textPrimary }]}>
|
||||
Forgot your TPIN?
|
||||
</Text>
|
||||
<Text style={[theme.text.body, { color: theme.colors.textSecondary }]}>
|
||||
{sentTo
|
||||
? `We'll confirm your details with your bank, then text a one-time code to ${sentTo}. Enter it to set a new ${TPIN_LENGTH}-digit TPIN.`
|
||||
: `We'll confirm your details with your bank, then text a one-time code to your registered mobile. Enter it to set a new ${TPIN_LENGTH}-digit TPIN.`}
|
||||
</Text>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textMuted }]}>
|
||||
Your current TPIN stays active until the new one is set.
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Send code"
|
||||
onPress={handleInitiate}
|
||||
disabled={busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'otp' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label="6-digit code"
|
||||
value={otp}
|
||||
onChangeText={(value) => {
|
||||
setOtp(value);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
length={OTP_LENGTH}
|
||||
autoFocus
|
||||
editable={!busy && !otpExpired}
|
||||
error={fieldError ?? undefined}
|
||||
onSubmit={handleVerifyOtp}
|
||||
/>
|
||||
|
||||
<View style={styles.metaRow}>
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{otpSecondsLeft === null
|
||||
? `Sent to ${sentTo ?? 'your registered mobile'}`
|
||||
: otpExpired
|
||||
? 'This code has expired'
|
||||
: `Expires in ${formatCountdown(otpSecondsLeft)}`}
|
||||
</Text>
|
||||
{attemptsRemaining !== null ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{`${attemptsRemaining} ${attemptsRemaining === 1 ? 'attempt' : 'attempts'} left`}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Button
|
||||
title="Verify code"
|
||||
onPress={handleVerifyOtp}
|
||||
disabled={!isValidOtp(otp) || busy || otpExpired}
|
||||
loading={busy}
|
||||
/>
|
||||
|
||||
<Button
|
||||
title={cooldown > 0 && !otpExpired ? `Send a new code in ${cooldown}s` : 'Send a new code'}
|
||||
variant="ghost"
|
||||
onPress={handleInitiate}
|
||||
disabled={(cooldown > 0 && !otpExpired) || busy}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{step === 'tpin' ? (
|
||||
<>
|
||||
<Card style={styles.card}>
|
||||
<CodeInput
|
||||
label={`New ${TPIN_LENGTH}-digit TPIN`}
|
||||
value={tpin}
|
||||
onChangeText={(value) => {
|
||||
setTpin(value);
|
||||
setError(null);
|
||||
setFieldError(null);
|
||||
}}
|
||||
length={TPIN_LENGTH}
|
||||
secure
|
||||
autoFocus
|
||||
editable={!busy}
|
||||
error={fieldError ?? tpinProblem}
|
||||
/>
|
||||
<CodeInput
|
||||
label="Confirm new TPIN"
|
||||
value={confirmTpin}
|
||||
onChangeText={(value) => {
|
||||
setConfirmTpin(value);
|
||||
setError(null);
|
||||
}}
|
||||
length={TPIN_LENGTH}
|
||||
secure
|
||||
editable={!busy}
|
||||
error={confirmProblem}
|
||||
onSubmit={handleConfirm}
|
||||
/>
|
||||
{tokenSecondsLeft !== null ? (
|
||||
<Text style={[theme.text.caption, { color: theme.colors.textSecondary }]}>
|
||||
{`Set your new TPIN within ${formatCountdown(tokenSecondsLeft)}`}
|
||||
</Text>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<InfoBanner
|
||||
tone="info"
|
||||
message="Avoid runs like 123 or 987, your date of birth and the last four digits of your mobile. It must also differ from your current TPIN and your last three."
|
||||
/>
|
||||
|
||||
<Button
|
||||
title="Reset TPIN"
|
||||
onPress={handleConfirm}
|
||||
disabled={!isAcceptableNewTpin(tpin, mobileNumber) || confirmTpin !== tpin || busy}
|
||||
loading={busy}
|
||||
style={styles.submit}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
gap: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
card: {
|
||||
padding: 16,
|
||||
gap: 14,
|
||||
},
|
||||
metaRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
submit: {
|
||||
marginTop: 'auto',
|
||||
},
|
||||
actions: {
|
||||
marginTop: 'auto',
|
||||
gap: 12,
|
||||
},
|
||||
});
|
||||
@@ -194,6 +194,15 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
navigation.navigate('CreateTpin');
|
||||
};
|
||||
|
||||
/** "Forgot TPIN?" — the same round trip as above, to reset one instead. */
|
||||
const openResetTpin = () => {
|
||||
requestIdRef.current = null;
|
||||
setTpin('');
|
||||
setTpinError(undefined);
|
||||
setStep('review');
|
||||
navigation.navigate('ResetTpin');
|
||||
};
|
||||
|
||||
/**
|
||||
* Saving the payee is deliberately *after* the transfer succeeds, and
|
||||
* deliberately not allowed to fail it: a payee the customer asked to keep is
|
||||
@@ -537,6 +546,12 @@ export function PaymentConfirmationScreen({ navigation, route }: Props) {
|
||||
disabled={!isValidTpinEntry(tpin) || isInitiating}
|
||||
loading={isInitiating}
|
||||
/>
|
||||
<Button
|
||||
title="Forgot your TPIN? Reset it"
|
||||
variant="ghost"
|
||||
onPress={openResetTpin}
|
||||
disabled={isInitiating}
|
||||
/>
|
||||
<Button
|
||||
title="Don't have a TPIN? Create one"
|
||||
variant="ghost"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import type { PaymentsStackParamList } from '../../../navigation/types';
|
||||
import { TpinResetFlow } from '../components/TpinResetFlow';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<PaymentsStackParamList, 'ResetTpin'>>;
|
||||
|
||||
/**
|
||||
* Reset TPIN, reached from the transfer screen's "Forgot TPIN?".
|
||||
*
|
||||
* Registered in the payments stack rather than borrowed from Services, so
|
||||
* going back lands on the same transfer review — amount, payee and all — and
|
||||
* the customer can confirm it again with the TPIN they just set.
|
||||
*/
|
||||
export default function ResetTpinScreen({ navigation }: Props) {
|
||||
const session = useAppSelector((state) => state.auth.session);
|
||||
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Reset TPIN"
|
||||
subtitle="Set a new TPIN, then confirm your transfer"
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<TpinResetFlow
|
||||
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||
notice="Your transfer is waiting. Once your new TPIN is set, you'll return to it to confirm."
|
||||
onSuccess={() => navigation.goBack()}
|
||||
onExit={() => navigation.goBack()}
|
||||
// Swapped in place, so going back from Create still lands on the review.
|
||||
onCreateTpin={() => navigation.replace('CreateTpin')}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import { constants, generateKeyPairSync, privateDecrypt } from 'crypto';
|
||||
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { CbsApiError } from '../../../services/api/cbsErrors';
|
||||
import { rememberServerTime } from '../../../services/api/serverClock';
|
||||
import { tpinService } from './tpinService';
|
||||
|
||||
jest.mock('../../../services/api/apiClient', () => ({
|
||||
apiClient: { get: jest.fn(), post: jest.fn() },
|
||||
}));
|
||||
|
||||
// Node supplies crypto.getRandomValues; the native polyfill has nothing to do here.
|
||||
jest.mock('react-native-get-random-values', () => ({}));
|
||||
|
||||
// The react-native preset resolves uuid to its ES-module browser build, which
|
||||
// Jest will not parse. Node's own v4 generator stands in for it.
|
||||
jest.mock('uuid', () => ({ v4: () => jest.requireActual('crypto').randomUUID() }));
|
||||
|
||||
const get = apiClient.get as jest.Mock;
|
||||
const post = apiClient.post as jest.Mock;
|
||||
|
||||
/**
|
||||
* A throwaway key pair standing in for the server's. Decrypting with Node's
|
||||
* own OAEP, SHA-256 for both the digest and MGF1, is an independent check of
|
||||
* the contract the backend decrypts with — not the same library agreeing with
|
||||
* itself.
|
||||
*/
|
||||
const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const publicKeyBase64 = publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
||||
|
||||
const decrypt = (encrypted: string) =>
|
||||
JSON.parse(
|
||||
privateDecrypt(
|
||||
{ key: privateKey, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||||
Buffer.from(encrypted, 'base64'),
|
||||
).toString('utf8'),
|
||||
);
|
||||
|
||||
/** A response as the interceptor would hand it back: unwrapped, timestamp filed. */
|
||||
const answer = <T,>(data: T, timestamp?: string) => {
|
||||
const response = { data };
|
||||
rememberServerTime(response, timestamp);
|
||||
return response;
|
||||
};
|
||||
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
beforeEach(() => {
|
||||
get.mockReset();
|
||||
post.mockReset();
|
||||
get.mockResolvedValue({
|
||||
data: {
|
||||
algorithm: 'RSA',
|
||||
transformation: 'RSA/ECB/OAEPWithSHA-256AndMGF1Padding',
|
||||
publicKey: publicKeyBase64,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('tpinService.initiateReset', () => {
|
||||
it('sends an empty body — org, mobile and device come from the session', async () => {
|
||||
post.mockResolvedValue(
|
||||
answer(
|
||||
{
|
||||
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||
status: 'PENDING_OTP',
|
||||
otpExpiresAt: '2026-09-30T13:50:31.1653828+05:30',
|
||||
attemptsRemaining: 3,
|
||||
},
|
||||
'2026-09-30T13:45:31.1653828+05:30',
|
||||
),
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
const result = await tpinService.initiateReset();
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith('/tpin/reset/initiate', {});
|
||||
expect(result.tpinResetId).toBe('e79eb452-32c9-4690-955b-81d8bb223ec3');
|
||||
expect(result.attemptsRemaining).toBe(3);
|
||||
// Five minutes by the server's clock, from now on this one — whatever
|
||||
// the date on this machine.
|
||||
expect(result.otpDeadline).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||
expect(result.otpDeadline).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('passes a refusal straight through for the flow to branch on', async () => {
|
||||
post.mockRejectedValue(
|
||||
new CbsApiError({ status: 404, code: 'TPIN_NOT_SET', message: 'No active TPIN' }),
|
||||
);
|
||||
|
||||
await expect(tpinService.initiateReset()).rejects.toMatchObject({ code: 'TPIN_NOT_SET' });
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tpinService.verifyResetOtp', () => {
|
||||
it('sends the reset id and the OTP, and nothing else', async () => {
|
||||
post.mockResolvedValue(
|
||||
answer(
|
||||
{
|
||||
tpinResetToken: 'one-time-reset-token',
|
||||
status: 'OTP_VERIFIED',
|
||||
tokenExpiresAt: '2026-09-30T13:51:02.766123+05:30',
|
||||
},
|
||||
'2026-09-30T13:46:02.766123+05:30',
|
||||
),
|
||||
);
|
||||
|
||||
const before = Date.now();
|
||||
const result = await tpinService.verifyResetOtp({
|
||||
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||
otp: '123456',
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith('/tpin/reset/verify-otp', {
|
||||
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||
otp: '123456',
|
||||
});
|
||||
expect(result.tpinResetToken).toBe('one-time-reset-token');
|
||||
expect(result.tokenDeadline).toBeGreaterThanOrEqual(before + 5 * 60 * 1000);
|
||||
expect(result.tokenDeadline).toBeLessThanOrEqual(Date.now() + 5 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tpinService.confirmReset', () => {
|
||||
it('sends the token and the TPIN encrypted as {tpin, nonce, ts} — never the TPIN itself', async () => {
|
||||
post.mockResolvedValue(answer({ status: 'ACTIVE', resetAt: '2026-09-30T13:47:34+05:30' }));
|
||||
|
||||
const before = Math.floor(Date.now() / 1000);
|
||||
const result = await tpinService.confirmReset({
|
||||
tpinResetToken: 'one-time-reset-token',
|
||||
tpin: '5826',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('ACTIVE');
|
||||
// The key is fetched fresh, immediately before encrypting.
|
||||
expect(get).toHaveBeenCalledWith('/crypto/public-key');
|
||||
|
||||
const [path, body] = post.mock.calls[0];
|
||||
expect(path).toBe('/tpin/reset/confirm');
|
||||
expect(Object.keys(body).sort()).toEqual(['encryptedTpin', 'tpinResetToken']);
|
||||
expect(body.tpinResetToken).toBe('one-time-reset-token');
|
||||
expect(body.encryptedTpin).not.toContain('5826');
|
||||
|
||||
const payload = decrypt(body.encryptedTpin);
|
||||
expect(Object.keys(payload).sort()).toEqual(['nonce', 'tpin', 'ts']);
|
||||
expect(payload.tpin).toBe('5826');
|
||||
expect(payload.nonce).toMatch(UUID_V4);
|
||||
// Seconds, not milliseconds.
|
||||
expect(payload.ts).toBeGreaterThanOrEqual(before);
|
||||
expect(payload.ts).toBeLessThanOrEqual(Math.floor(Date.now() / 1000));
|
||||
});
|
||||
|
||||
it('retries MALFORMED_REQUEST once with a fresh key and a fresh payload', async () => {
|
||||
post
|
||||
.mockRejectedValueOnce(
|
||||
new CbsApiError({ status: 400, code: 'MALFORMED_REQUEST', message: 'Payload expired' }),
|
||||
)
|
||||
.mockResolvedValueOnce(answer({ status: 'ACTIVE', resetAt: null }));
|
||||
|
||||
await tpinService.confirmReset({ tpinResetToken: 'one-time-reset-token', tpin: '5826' });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
expect(post).toHaveBeenCalledTimes(2);
|
||||
|
||||
const first = post.mock.calls[0][1].encryptedTpin;
|
||||
const second = post.mock.calls[1][1].encryptedTpin;
|
||||
expect(second).not.toBe(first);
|
||||
expect(decrypt(second).nonce).not.toBe(decrypt(first).nonce);
|
||||
});
|
||||
|
||||
it('does not retry a policy refusal — the customer picks a different TPIN', async () => {
|
||||
post.mockRejectedValue(
|
||||
new CbsApiError({
|
||||
status: 400,
|
||||
code: 'VALIDATION_ERROR',
|
||||
message: 'TPIN cannot contain sequential digits',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
tpinService.confirmReset({ tpinResetToken: 'one-time-reset-token', tpin: '1235' }),
|
||||
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,25 @@
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { serverTimeOf, toLocalDeadline } from '../../../services/api/serverClock';
|
||||
import { mpinCrypto, withKeyRetry } from '../../../services/security/mpinCrypto';
|
||||
import type {
|
||||
TpinCreateConfirmResult,
|
||||
TpinCreateInitiateResult,
|
||||
TpinCreateVerifyResult,
|
||||
TpinResetConfirmResult,
|
||||
TpinResetInitiateResult,
|
||||
TpinResetVerifyResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
/**
|
||||
* Creating the TPIN. Every call rides on the signed-in session — the
|
||||
* interceptor attaches the Bearer token, and the server derives org, customer,
|
||||
* mobile, device and profile from it, so none of those are sent here.
|
||||
* Creating and resetting the TPIN. Every call rides on the signed-in session —
|
||||
* the interceptor attaches the Bearer token, and the server derives org,
|
||||
* customer, mobile, device and profile from it, so none of those are sent here.
|
||||
*
|
||||
* Deliberately not RTK Query: a mutation keeps its arguments and result in the
|
||||
* store, where dev tooling records them, and the create id, the one-time token
|
||||
* and the OTP must live in screen state only. See `TpinCreateFlow`.
|
||||
* store, where dev tooling records them, and the create and reset ids, the
|
||||
* one-time tokens and the OTP must live in screen state only. See
|
||||
* `TpinCreateFlow` and `TpinResetFlow`.
|
||||
*/
|
||||
export const tpinService = {
|
||||
/**
|
||||
@@ -59,4 +64,62 @@ export const tpinService = {
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
/* ── Reset ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* Reset step 1: the bank re-verifies the signed-in customer and an OTP goes
|
||||
* to the session's mobile. The body is empty on purpose — org, mobile and
|
||||
* device come from the session and the server will not take them from the
|
||||
* client. There is no resend endpoint; calling this again is the resend.
|
||||
*
|
||||
* `otpDeadline` is the OTP's expiry on this device's clock, worked out from
|
||||
* the server's own `timestamp` so a wrong handset clock cannot skew it.
|
||||
*/
|
||||
async initiateReset() {
|
||||
const response = await apiClient.post<TpinResetInitiateResult>(
|
||||
endpoints.tpin.resetInitiate,
|
||||
{},
|
||||
);
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
otpDeadline: toLocalDeadline(response.data.otpExpiresAt, serverTimeOf(response)),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset step 2: swaps the OTP for a one-time reset token. `tokenDeadline` is
|
||||
* the token's expiry on this device's clock, as above.
|
||||
*/
|
||||
async verifyResetOtp({ tpinResetId, otp }: { tpinResetId: string; otp: string }) {
|
||||
const response = await apiClient.post<TpinResetVerifyResult>(
|
||||
endpoints.tpin.resetVerifyOtp,
|
||||
{ tpinResetId, otp },
|
||||
);
|
||||
|
||||
return {
|
||||
...response.data,
|
||||
tokenDeadline: toLocalDeadline(response.data.tokenExpiresAt, serverTimeOf(response)),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset step 3: spends the token on the new TPIN. The public key is fetched
|
||||
* and `{tpin, nonce, ts}` encrypted afresh inside the retry, so the one
|
||||
* MALFORMED_REQUEST retry — a rotated key, a payload past its 120 seconds —
|
||||
* goes out with a new key, nonce and timestamp, never a reused ciphertext.
|
||||
*/
|
||||
async confirmReset({ tpinResetToken, tpin }: { tpinResetToken: string; tpin: string }) {
|
||||
return withKeyRetry(async () => {
|
||||
const encryptedTpin = await mpinCrypto.encryptTpin(tpin);
|
||||
|
||||
const response = await apiClient.post<TpinResetConfirmResult>(
|
||||
endpoints.tpin.resetConfirm,
|
||||
{ tpinResetToken, encryptedTpin },
|
||||
);
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
TPIN_LENGTH,
|
||||
containsSequentialRun,
|
||||
describeTpinProblem,
|
||||
isAcceptableNewTpin,
|
||||
isValidTpinEntry,
|
||||
@@ -38,6 +39,22 @@ describe('describeTpinProblem', () => {
|
||||
expect(describeTpinProblem('6789')).toMatch(/run of digits/);
|
||||
});
|
||||
|
||||
// The policy refuses "a sequence of three digits", so a run of three inside
|
||||
// the four counts as much as one spanning all of them.
|
||||
it('refuses a run of three anywhere in the TPIN', () => {
|
||||
expect(describeTpinProblem('1235')).toMatch(/run of digits/);
|
||||
expect(describeTpinProblem('5987')).toMatch(/run of digits/);
|
||||
expect(describeTpinProblem('0124')).toMatch(/run of digits/);
|
||||
expect(describeTpinProblem('7210')).toMatch(/run of digits/);
|
||||
});
|
||||
|
||||
it('allows a TPIN with no run of three', () => {
|
||||
expect(describeTpinProblem('1243')).toBeUndefined();
|
||||
expect(describeTpinProblem('1357')).toBeUndefined();
|
||||
// No wrap-around from 9 to 0.
|
||||
expect(describeTpinProblem('8901')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses the same digit repeated', () => {
|
||||
expect(describeTpinProblem('1111')).toMatch(/same digit/);
|
||||
});
|
||||
@@ -65,3 +82,16 @@ describe('isAcceptableNewTpin', () => {
|
||||
expect(isAcceptableNewTpin('0101', '9300000101')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsSequentialRun', () => {
|
||||
it('finds a run of three at the start, the end, or spanning the whole value', () => {
|
||||
expect(containsSequentialRun('1239')).toBe(true);
|
||||
expect(containsSequentialRun('9321')).toBe(true);
|
||||
expect(containsSequentialRun('3456')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a run of two', () => {
|
||||
expect(containsSequentialRun('1290')).toBe(false);
|
||||
expect(containsSequentialRun('12')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,26 @@ import { isRepeatedDigits, isSequentialDigits } from '../../../utils/validation'
|
||||
/** A TPIN is always exactly four digits. */
|
||||
export const TPIN_LENGTH = 4;
|
||||
|
||||
/** The shortest run up or down the backend's TPIN policy refuses. */
|
||||
const SEQUENCE_RUN_LENGTH = 3;
|
||||
|
||||
/**
|
||||
* True when any three neighbouring digits run straight up or down — 1235 (123),
|
||||
* 5987 (987) and 1234 alike. The TPIN policy refuses "a sequence of three
|
||||
* digits" and answers "TPIN cannot contain sequential digits", so a run
|
||||
* anywhere counts, not only one spanning all four. No wrap-around: 8901 is not
|
||||
* a run.
|
||||
*/
|
||||
export const containsSequentialRun = (value: string) => {
|
||||
for (let start = 0; start + SEQUENCE_RUN_LENGTH <= value.length; start += 1) {
|
||||
if (isSequentialDigits(value.slice(start, start + SEQUENCE_RUN_LENGTH))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Any four digits — for ENTERING the TPIN on a transfer. Deliberately loose:
|
||||
* whether they are the right four is the server's to say, with INVALID_TPIN.
|
||||
@@ -19,8 +39,9 @@ export const isValidTpinEntry = (value: string) =>
|
||||
* than after one.
|
||||
*
|
||||
* The backend stays the final authority. It also refuses a TPIN matching the
|
||||
* customer's date of birth, which the app never holds — so that one can only
|
||||
* arrive as a VALIDATION_ERROR on confirm.
|
||||
* customer's date of birth, which the app never holds, and — on a reset — the
|
||||
* current TPIN or any of the previous three, which only the server's history
|
||||
* knows. Those can only arrive as a VALIDATION_ERROR on confirm.
|
||||
*/
|
||||
export const describeTpinProblem = (value: string, mobileNumber?: string): string | undefined => {
|
||||
if (value.length === 0) {
|
||||
@@ -39,8 +60,8 @@ export const describeTpinProblem = (value: string, mobileNumber?: string): strin
|
||||
return 'Pick a TPIN that is not the same digit repeated';
|
||||
}
|
||||
|
||||
if (isSequentialDigits(value)) {
|
||||
return 'Pick a TPIN that is not a run of digits like 1234 or 4321';
|
||||
if (containsSequentialRun(value)) {
|
||||
return 'Pick a TPIN without a run of digits like 123 or 987';
|
||||
}
|
||||
|
||||
const mobileDigits = mobileNumber?.replace(/\D/g, '') ?? '';
|
||||
|
||||
@@ -272,6 +272,17 @@ export function buildUserSearchCatalog(
|
||||
keywords: ['tpin', 'transaction pin', 'set tpin', 'transfer pin', 'payment pin'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'SetTpin' }),
|
||||
},
|
||||
{
|
||||
id: 'services-reset-tpin',
|
||||
feature: FeatureKey.SERVICES_TPIN_RESET,
|
||||
title: 'Reset TPIN',
|
||||
subtitle: 'Set a new TPIN with an OTP',
|
||||
module: 'Services',
|
||||
icon: 'refresh-circle-outline',
|
||||
hue: MODULE_HUES.services,
|
||||
keywords: ['tpin', 'reset tpin', 'forgot tpin', 'new tpin', 'change tpin', 'transaction pin'],
|
||||
navigate: (nav) => nav.navigate('Services', { screen: 'ResetTpin' }),
|
||||
},
|
||||
{
|
||||
id: 'services-stop-payment',
|
||||
feature: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import type { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||||
|
||||
import { ScreenShell } from '../../../components';
|
||||
import { useAppSelector } from '../../../app/hooks';
|
||||
import { FeatureKey } from '../../../constants/featureKeys';
|
||||
import { useFeature } from '../../../features';
|
||||
import type { ServicesStackParamList } from '../../../navigation/types';
|
||||
import { TpinResetFlow } from '../../payments/components/TpinResetFlow';
|
||||
|
||||
type Props = Readonly<NativeStackScreenProps<ServicesStackParamList, 'ResetTpin'>>;
|
||||
|
||||
/**
|
||||
* Resets a forgotten transaction PIN, from the service desk.
|
||||
*
|
||||
* The signed-in session stands in for the MPIN that creating a TPIN asks for;
|
||||
* the bank re-verifies the customer and an OTP to the registered mobile is the
|
||||
* second factor.
|
||||
*/
|
||||
export default function ResetTpinScreen({ navigation }: Props) {
|
||||
const session = useAppSelector((state) => state.auth.session);
|
||||
const lastProfile = useAppSelector((state) => state.auth.lastProfile);
|
||||
// Create TPIN is offered on TPIN_NOT_SET only where its route exists.
|
||||
const canCreateTpin = useFeature(FeatureKey.SERVICES_TPIN_CHANGE);
|
||||
|
||||
return (
|
||||
<ScreenShell
|
||||
title="Reset TPIN"
|
||||
subtitle="Set a new PIN for authorising fund transfers"
|
||||
contentContainerStyle={styles.container}
|
||||
>
|
||||
<TpinResetFlow
|
||||
mobileNumber={session?.mobileNumber ?? lastProfile?.mobileNumber}
|
||||
onSuccess={() => navigation.goBack()}
|
||||
onExit={() => navigation.goBack()}
|
||||
onCreateTpin={canCreateTpin ? () => navigation.replace('SetTpin') : undefined}
|
||||
/>
|
||||
</ScreenShell>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 16,
|
||||
flexGrow: 1,
|
||||
},
|
||||
});
|
||||
@@ -88,6 +88,16 @@ export default function ServicesHubScreen({ navigation }: Props) {
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('SetTpin'),
|
||||
},
|
||||
{
|
||||
id: 'tpin_reset',
|
||||
feature: FeatureKey.SERVICES_TPIN_RESET,
|
||||
// For a TPIN that exists but is forgotten; the flow sends anyone
|
||||
// without one on to Create TPIN.
|
||||
title: 'Reset TPIN',
|
||||
icon: 'refresh-circle-outline',
|
||||
iconColor: theme.colors.category.pink.tone,
|
||||
onPress: () => navigation.navigate('ResetTpin'),
|
||||
},
|
||||
{
|
||||
id: 'fate_of_cheque',
|
||||
feature: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
||||
|
||||
@@ -13,6 +13,7 @@ import TransferToOwnAccountScreen from '../modules/payments/screens/TransferToOw
|
||||
import ImpsMmidScreen from '../modules/payments/screens/ImpsMmidScreen';
|
||||
import { PaymentConfirmationScreen } from '../modules/payments/screens/PaymentConfirmationScreen';
|
||||
import CreateTpinScreen from '../modules/payments/screens/CreateTpinScreen';
|
||||
import ResetTpinScreen from '../modules/payments/screens/ResetTpinScreen';
|
||||
import { TransactionSuccessScreen } from '../modules/payments/screens/TransactionSuccessScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator<PaymentsStackParamList>();
|
||||
@@ -54,6 +55,7 @@ export const PaymentsNavigator = () => {
|
||||
<Stack.Screen name="PaymentConfirmation" component={PaymentConfirmationScreen} />
|
||||
)}
|
||||
{canRoute('CreateTpin') && <Stack.Screen name="CreateTpin" component={CreateTpinScreen} />}
|
||||
{canRoute('ResetTpin') && <Stack.Screen name="ResetTpin" component={ResetTpinScreen} />}
|
||||
{canRoute('TransactionSuccess') && (
|
||||
<Stack.Screen
|
||||
name="TransactionSuccess"
|
||||
|
||||
@@ -10,6 +10,7 @@ import ServicesHubScreen from '../modules/services/screens/ServicesHubScreen';
|
||||
import BalanceEnquiryScreen from '../modules/services/screens/BalanceEnquiryScreen';
|
||||
import FateOfChequeScreen from '../modules/services/screens/FateOfChequeScreen';
|
||||
import SetTpinScreen from '../modules/services/screens/SetTpinScreen';
|
||||
import ResetTpinScreen from '../modules/services/screens/ResetTpinScreen';
|
||||
|
||||
// New Modules
|
||||
import ChequeServicesHubScreen from '../modules/services/screens/ChequeServicesHubScreen';
|
||||
@@ -55,6 +56,7 @@ export const ServicesNavigator = () => {
|
||||
{canRoute('BalanceEnquiry') && <Stack.Screen name="BalanceEnquiry" component={BalanceEnquiryScreen} />}
|
||||
{canRoute('FateOfCheque') && <Stack.Screen name="FateOfCheque" component={FateOfChequeScreen} />}
|
||||
{canRoute('SetTpin') && <Stack.Screen name="SetTpin" component={SetTpinScreen} />}
|
||||
{canRoute('ResetTpin') && <Stack.Screen name="ResetTpin" component={ResetTpinScreen} />}
|
||||
|
||||
{/* Cheque Services */}
|
||||
{canRoute('ChequeServicesHub') && <Stack.Screen name="ChequeServicesHub" component={ChequeServicesHubScreen} />}
|
||||
|
||||
@@ -70,6 +70,9 @@ export const PAYMENTS_ROUTE_FEATURES: RouteFeatureMap<PaymentsStackParamList> =
|
||||
// Part of the transfer, not of Services: a transfer that needs a TPIN must
|
||||
// be able to create one even with the Services entry switched off.
|
||||
CreateTpin: FeatureKey.PAYMENTS_TRANSFER,
|
||||
// Likewise: a customer who has forgotten the TPIN mid-transfer must be able
|
||||
// to reset it there, whatever the service desk shows.
|
||||
ResetTpin: FeatureKey.PAYMENTS_TRANSFER,
|
||||
TransactionSuccess: FeatureKey.PAYMENTS_TRANSFER,
|
||||
};
|
||||
|
||||
@@ -90,6 +93,7 @@ export const SERVICES_ROUTE_FEATURES: RouteFeatureMap<ServicesStackParamList> =
|
||||
BalanceEnquiry: FeatureKey.SERVICES_BALANCE_ENQUIRY,
|
||||
FateOfCheque: FeatureKey.SERVICES_FATE_OF_CHEQUE,
|
||||
SetTpin: FeatureKey.SERVICES_TPIN_CHANGE,
|
||||
ResetTpin: FeatureKey.SERVICES_TPIN_RESET,
|
||||
ChequeServicesHub: FeatureKey.SERVICES_CHEQUE_HUB,
|
||||
ChequeBookRequest: FeatureKey.SERVICES_CHEQUE_BOOK_REQUEST,
|
||||
StopPayment: FeatureKey.SERVICES_CHEQUE_STOP_PAYMENT,
|
||||
|
||||
@@ -110,6 +110,8 @@ export type PaymentsStackParamList = {
|
||||
};
|
||||
/** Reached from PaymentConfirmation on TPIN_NOT_SET; returns there when done. */
|
||||
CreateTpin: undefined;
|
||||
/** Reached from the TPIN sheet's "Forgot TPIN?"; returns there when done. */
|
||||
ResetTpin: undefined;
|
||||
TransactionSuccess: {
|
||||
transactionId: string;
|
||||
amount: number;
|
||||
@@ -138,6 +140,7 @@ export type ServicesStackParamList = {
|
||||
BalanceEnquiry: undefined;
|
||||
FateOfCheque: undefined;
|
||||
SetTpin: undefined;
|
||||
ResetTpin: undefined;
|
||||
ChequeServicesHub: undefined;
|
||||
ChequeBookRequest: undefined;
|
||||
StopPayment: undefined;
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { AxiosResponse, InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
import { apiLogger, redactSecrets } from './apiLogger';
|
||||
|
||||
describe('redactSecrets', () => {
|
||||
it('hides OTPs, PINs, encrypted payloads and every kind of token', () => {
|
||||
expect(
|
||||
redactSecrets({
|
||||
otp: '123456',
|
||||
debugOtp: '123456',
|
||||
mpin: '2580',
|
||||
tpin: '5826',
|
||||
encryptedMpin: 'base64…',
|
||||
encryptedTpin: 'base64…',
|
||||
sessionToken: 'live-session',
|
||||
setupToken: 'setup',
|
||||
resetToken: 'mpin-reset',
|
||||
tpinCreateToken: 'create',
|
||||
tpinResetToken: 'reset',
|
||||
}),
|
||||
).toEqual({
|
||||
otp: '<redacted>',
|
||||
debugOtp: '<redacted>',
|
||||
mpin: '<redacted>',
|
||||
tpin: '<redacted>',
|
||||
encryptedMpin: '<redacted>',
|
||||
encryptedTpin: '<redacted>',
|
||||
sessionToken: '<redacted>',
|
||||
setupToken: '<redacted>',
|
||||
resetToken: '<redacted>',
|
||||
tpinCreateToken: '<redacted>',
|
||||
tpinResetToken: '<redacted>',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the fields that help debugging — expiries, statuses, ids', () => {
|
||||
const data = {
|
||||
tpinResetId: 'e79eb452-32c9-4690-955b-81d8bb223ec3',
|
||||
status: 'PENDING_OTP',
|
||||
otpExpiresAt: '2026-09-30T13:50:31+05:30',
|
||||
tokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||
resetTokenExpiresAt: '2026-09-30T13:51:02+05:30',
|
||||
attemptsRemaining: 3,
|
||||
mpinStatus: 'ACTIVE',
|
||||
};
|
||||
|
||||
expect(redactSecrets(data)).toEqual(data);
|
||||
});
|
||||
|
||||
it('reaches into the envelope and into arrays', () => {
|
||||
expect(
|
||||
redactSecrets({
|
||||
success: true,
|
||||
data: { tpinResetToken: 'reset', items: [{ otp: '1' }, { name: 'x' }] },
|
||||
}),
|
||||
).toEqual({
|
||||
success: true,
|
||||
data: { tpinResetToken: '<redacted>', items: [{ otp: '<redacted>' }, { name: 'x' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves an absent secret visibly absent, and non-objects alone', () => {
|
||||
expect(redactSecrets({ debugOtp: null })).toEqual({ debugOtp: null });
|
||||
expect(redactSecrets('(no body)')).toBe('(no body)');
|
||||
expect(redactSecrets(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apiLogger', () => {
|
||||
let log: jest.SpyInstance;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
log = jest.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
log.mockRestore();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
const config = (data: unknown) =>
|
||||
({
|
||||
method: 'post',
|
||||
baseURL: 'https://bank.example/api/v1',
|
||||
url: '/tpin/reset/confirm',
|
||||
headers: { Authorization: 'Bearer live-session-token', 'Content-Type': 'application/json' },
|
||||
data,
|
||||
}) as unknown as InternalAxiosRequestConfig;
|
||||
|
||||
it('prints the curl without the session token or the secrets in the body', () => {
|
||||
apiLogger.request(config({ tpinResetToken: 'one-time-reset-token', encryptedTpin: 'cipher' }));
|
||||
|
||||
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(printed).toContain('/tpin/reset/confirm');
|
||||
expect(printed).toContain('Authorization: Bearer <session token>');
|
||||
expect(printed).not.toContain('live-session-token');
|
||||
expect(printed).not.toContain('one-time-reset-token');
|
||||
expect(printed).not.toContain('cipher');
|
||||
});
|
||||
|
||||
it('redacts a body the caller serialised themselves', () => {
|
||||
apiLogger.request(config(JSON.stringify({ tpinResetId: 'id-1', otp: '654321' })));
|
||||
|
||||
const printed = log.mock.calls.map((call) => String(call[0])).join('\n');
|
||||
expect(printed).toContain('id-1');
|
||||
expect(printed).not.toContain('654321');
|
||||
});
|
||||
|
||||
it('redacts secrets in what came back, success or failure', () => {
|
||||
const request = config({});
|
||||
apiLogger.response({
|
||||
status: 200,
|
||||
config: request,
|
||||
data: { success: true, data: { tpinResetToken: 'one-time-reset-token' } },
|
||||
} as AxiosResponse);
|
||||
apiLogger.failure({
|
||||
status: 400,
|
||||
config: request,
|
||||
data: { success: false, data: { otp: '654321' }, errorCode: 'VALIDATION_ERROR' },
|
||||
} as AxiosResponse);
|
||||
|
||||
const printed = [...log.mock.calls, ...warn.mock.calls]
|
||||
.map((call) => String(call[0]))
|
||||
.join('\n');
|
||||
expect(printed).toContain('VALIDATION_ERROR');
|
||||
expect(printed).not.toContain('one-time-reset-token');
|
||||
expect(printed).not.toContain('654321');
|
||||
});
|
||||
});
|
||||
@@ -3,23 +3,88 @@ import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axio
|
||||
/**
|
||||
* Dev-only request/response logging for the CBS client.
|
||||
*
|
||||
* Every request is printed as a runnable `curl`, so a failure can be reproduced
|
||||
* against the backend outside the app — which is usually the fastest way to
|
||||
* settle whether a problem is the handset, the client, or the server.
|
||||
* Every request is printed as a `curl`, so a failure can be reproduced against
|
||||
* the backend outside the app — which is usually the fastest way to settle
|
||||
* whether a problem is the handset, the client, or the server.
|
||||
*
|
||||
* ── WHY THIS IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ───────────────────────
|
||||
* The curl carries whatever the request carried: the `Authorization` header
|
||||
* with a live session token, and the RSA-encrypted MPIN blob on the
|
||||
* registration calls. That is exactly what makes it useful, and exactly why it
|
||||
* must never run in a release build — a bank's production logs are not a place
|
||||
* to put a working credential. There is deliberately no runtime flag to turn
|
||||
* this on outside development; `__DEV__` is compiled out, so none of this code
|
||||
* ships.
|
||||
* ── SECRETS ARE REDACTED, EVEN HERE ─────────────────────────────────────────
|
||||
* The bank's integration contracts forbid logging the session token, OTPs,
|
||||
* PINs, encrypted PIN payloads and one-time tokens anywhere — the console
|
||||
* included — and Metro logs get pasted into tickets and chats. So the
|
||||
* `Authorization` header prints as a placeholder, and any body field named like
|
||||
* a secret prints as `<redacted>`, on the way out and on the way back. The rest
|
||||
* of the curl still runs: paste a session token in place of the placeholder.
|
||||
*
|
||||
* ── AND IT IS BEHIND `__DEV__`, WITHOUT AN OVERRIDE ─────────────────────────
|
||||
* Redaction is by field name, so it is a safety net rather than a guarantee.
|
||||
* The log must still never run in a release build: there is deliberately no
|
||||
* runtime flag to turn it on outside development, and `__DEV__` is compiled
|
||||
* out, so none of this code ships.
|
||||
*/
|
||||
|
||||
/** Set false to silence the log without unpicking the interceptors. */
|
||||
const ENABLED = __DEV__;
|
||||
|
||||
/**
|
||||
* Body fields never printed: OTPs (a dev `debugOtp` included), plain and
|
||||
* encrypted PINs, and every token — session, setup, MPIN reset, TPIN create
|
||||
* and reset. Their `…ExpiresAt` companions stay: when a code expired is often
|
||||
* the whole question.
|
||||
*/
|
||||
const SECRET_FIELD = /^(otp|debugOtp|mpin|tpin|password)$|^encrypted|token$/i;
|
||||
|
||||
const REDACTED = '<redacted>';
|
||||
|
||||
/** What an `Authorization` header prints as. Paste a real token to run the curl. */
|
||||
const AUTHORIZATION_PLACEHOLDER = 'Bearer <session token>';
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (typeof value !== 'object' || value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A copy of `value` with every secret-named field replaced, at any depth.
|
||||
* Anything that is not a plain object or array — FormData, a Blob — is left
|
||||
* as it is.
|
||||
*/
|
||||
export const redactSecrets = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(redactSecrets);
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, field]) => [
|
||||
key,
|
||||
SECRET_FIELD.test(key) && field !== null && field !== undefined
|
||||
? REDACTED
|
||||
: redactSecrets(field),
|
||||
]),
|
||||
);
|
||||
};
|
||||
|
||||
/** A JSON string, redacted; any other string as it came. */
|
||||
const redactString = (value: string): string => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
|
||||
return typeof parsed === 'object' && parsed !== null
|
||||
? JSON.stringify(redactSecrets(parsed))
|
||||
: value;
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-request bookkeeping, so a response can be matched to its request in a
|
||||
* busy log and timed.
|
||||
@@ -109,11 +174,11 @@ const readBody = (config: InternalAxiosRequestConfig): string | undefined => {
|
||||
}
|
||||
|
||||
if (typeof data === 'string') {
|
||||
return data;
|
||||
return redactString(data);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(data);
|
||||
return JSON.stringify(redactSecrets(data));
|
||||
} catch {
|
||||
return String(data);
|
||||
}
|
||||
@@ -124,7 +189,8 @@ const toCurl = (config: InternalAxiosRequestConfig): string => {
|
||||
const lines = [`curl -i -X ${method} ${quote(readUrl(config))}`];
|
||||
|
||||
for (const [key, value] of Object.entries(readHeaders(config))) {
|
||||
lines.push(`-H ${quote(`${key}: ${value}`)}`);
|
||||
const shown = key.toLowerCase() === 'authorization' ? AUTHORIZATION_PLACEHOLDER : value;
|
||||
lines.push(`-H ${quote(`${key}: ${shown}`)}`);
|
||||
}
|
||||
|
||||
const body = readBody(config);
|
||||
@@ -194,7 +260,7 @@ export const apiLogger = {
|
||||
console.log(
|
||||
`\n┌── API ← #${idOf(response.config)} ${response.status} ${label(
|
||||
response.config as InternalAxiosRequestConfig,
|
||||
)} (${elapsed(response.config)})\n${pretty(response.data)}\n└──`,
|
||||
)} (${elapsed(response.config)})\n${pretty(redactSecrets(response.data))}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -212,7 +278,7 @@ export const apiLogger = {
|
||||
)} (${elapsed(response?.config)})`;
|
||||
|
||||
console.warn(
|
||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(response?.data ?? '(no body)')}\n└──`,
|
||||
`\n${head}${note ? `\n${note}` : ''}\n${pretty(redactSecrets(response?.data ?? '(no body)'))}\n└──`,
|
||||
);
|
||||
},
|
||||
|
||||
|
||||
@@ -41,11 +41,20 @@ export const endpoints = {
|
||||
* token; `createConfirm` spends that token on the new, encrypted TPIN. There
|
||||
* is no resend endpoint: a fresh `createInitiate` — MPIN and all — is the
|
||||
* resend.
|
||||
*
|
||||
* Resetting it follows the same three steps under `/tpin/reset`, with two
|
||||
* differences: `resetInitiate` takes an empty body — the session alone
|
||||
* identifies the customer, and the bank re-verifies them before the OTP goes
|
||||
* out — and it needs a TPIN to already be active. The same session must carry
|
||||
* all three calls; the server refuses a flow that changes user or device.
|
||||
*/
|
||||
tpin: {
|
||||
createInitiate: '/tpin/create/initiate',
|
||||
createVerifyOtp: '/tpin/create/verify-otp',
|
||||
createConfirm: '/tpin/create/confirm',
|
||||
resetInitiate: '/tpin/reset/initiate',
|
||||
resetVerifyOtp: '/tpin/reset/verify-otp',
|
||||
resetConfirm: '/tpin/reset/confirm',
|
||||
},
|
||||
accounts: {
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import { sessionManager } from '../security/sessionManager';
|
||||
import { secureStorage } from '../storage/secureStorage';
|
||||
import { apiLogger } from './apiLogger';
|
||||
import { CbsApiError, isCbsErrorCode, type FieldErrors } from './cbsErrors';
|
||||
import { rememberServerTime } from './serverClock';
|
||||
|
||||
const unauthorizedHandlers = new Set<() => void>();
|
||||
|
||||
@@ -83,8 +84,9 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
config.headers = nextHeaders as never;
|
||||
}
|
||||
|
||||
// Logged here, at the end of the interceptor, so the curl carries the
|
||||
// Authorization header the request will actually be sent with.
|
||||
// Logged here, at the end of the interceptor, so the curl shows the
|
||||
// headers the request will actually be sent with — the token itself as a
|
||||
// placeholder.
|
||||
apiLogger.request(config);
|
||||
|
||||
return config;
|
||||
@@ -117,7 +119,12 @@ export const attachInterceptors = (client: AxiosInstance) => {
|
||||
}
|
||||
|
||||
// Unwrap so callers read the payload directly rather than the envelope.
|
||||
return { ...response, data: envelope.data };
|
||||
// The server's `timestamp` is kept aside for anything counting down to
|
||||
// a server expiry — see `serverClock`.
|
||||
const unwrapped = { ...response, data: envelope.data };
|
||||
rememberServerTime(unwrapped, envelope.timestamp);
|
||||
|
||||
return unwrapped;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
if (error instanceof CbsApiError) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
parseServerInstant,
|
||||
rememberServerTime,
|
||||
secondsUntilDeadline,
|
||||
serverTimeOf,
|
||||
toLocalDeadline,
|
||||
} from './serverClock';
|
||||
|
||||
describe('parseServerInstant', () => {
|
||||
it('reads the backend timestamp, seven fractional digits and an offset included', () => {
|
||||
expect(parseServerInstant('2026-09-30T13:50:31.1653828+05:30')).toBe(
|
||||
Date.UTC(2026, 8, 30, 8, 20, 31, 165),
|
||||
);
|
||||
});
|
||||
|
||||
it('reads timestamps with fewer fractional digits, none, or a Z', () => {
|
||||
expect(parseServerInstant('2026-09-30T08:20:31.5Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 500));
|
||||
expect(parseServerInstant('2026-09-30T13:50:31+05:30')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31));
|
||||
expect(parseServerInstant('2026-09-30T08:20:31.123Z')).toBe(Date.UTC(2026, 8, 30, 8, 20, 31, 123));
|
||||
});
|
||||
|
||||
it('gives null for nothing, or for something that is not a date', () => {
|
||||
expect(parseServerInstant(null)).toBeNull();
|
||||
expect(parseServerInstant(undefined)).toBeNull();
|
||||
expect(parseServerInstant('')).toBeNull();
|
||||
expect(parseServerInstant('soon')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('toLocalDeadline', () => {
|
||||
// The initiate response from the TPIN reset guide: five minutes to go.
|
||||
const serverNow = '2026-09-30T13:45:31.1653828+05:30';
|
||||
const otpExpiresAt = '2026-09-30T13:50:31.1653828+05:30';
|
||||
|
||||
it('counts the server’s time left from when the answer arrived, ignoring the device clock', () => {
|
||||
// A handset an hour and a bit fast. The deadline still lands five minutes
|
||||
// after the answer arrived, not five minutes after 13:45 server time.
|
||||
const receivedAt = Date.UTC(2026, 8, 30, 9, 30, 0);
|
||||
|
||||
expect(toLocalDeadline(otpExpiresAt, serverNow, receivedAt)).toBe(receivedAt + 5 * 60 * 1000);
|
||||
});
|
||||
|
||||
it('takes the expiry at face value when the server sent no timestamp', () => {
|
||||
expect(toLocalDeadline(otpExpiresAt, undefined, 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||
expect(toLocalDeadline(otpExpiresAt, 'garbage', 0)).toBe(parseServerInstant(otpExpiresAt));
|
||||
});
|
||||
|
||||
it('has no deadline without an expiry', () => {
|
||||
expect(toLocalDeadline(null, serverNow)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('secondsUntilDeadline', () => {
|
||||
it('counts down in whole seconds and stops at zero', () => {
|
||||
expect(secondsUntilDeadline(10_000, 0)).toBe(10);
|
||||
expect(secondsUntilDeadline(10_000, 9_700)).toBe(0);
|
||||
expect(secondsUntilDeadline(10_000, 60_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('is unknown when the deadline is', () => {
|
||||
expect(secondsUntilDeadline(null, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('rememberServerTime', () => {
|
||||
it('files the envelope timestamp against the response it came with', () => {
|
||||
const response = {};
|
||||
rememberServerTime(response, '2026-09-30T13:45:31+05:30');
|
||||
|
||||
expect(serverTimeOf(response)).toBe('2026-09-30T13:45:31+05:30');
|
||||
expect(serverTimeOf({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a missing or non-string timestamp', () => {
|
||||
const response = {};
|
||||
rememberServerTime(response, undefined);
|
||||
rememberServerTime(response, 1790000000);
|
||||
|
||||
expect(serverTimeOf(response)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The server's clock, as each response reported it.
|
||||
*
|
||||
* Every envelope carries a `timestamp` — the server's "now" when it answered.
|
||||
* The interceptor unwraps the envelope, so it files that reading here, keyed on
|
||||
* the unwrapped response it hands back, rather than adding a field to axios's
|
||||
* response type. The entry goes when axios drops the response.
|
||||
*
|
||||
* Why it matters: an OTP or a one-time token comes back with an absolute
|
||||
* `expiresAt`. Counting down to that against the handset's clock is wrong by
|
||||
* however far the handset's clock is off — minutes, on a phone with automatic
|
||||
* time switched off. The gap between `expiresAt` and the server's own
|
||||
* `timestamp` is the true time left, whatever the device thinks the time is.
|
||||
*/
|
||||
const serverTimes = new WeakMap<object, string>();
|
||||
|
||||
/** Called by the interceptor with the envelope's `timestamp`. */
|
||||
export const rememberServerTime = (response: object, timestamp: unknown) => {
|
||||
if (typeof timestamp === 'string' && timestamp.length > 0) {
|
||||
serverTimes.set(response, timestamp);
|
||||
}
|
||||
};
|
||||
|
||||
/** The server's `timestamp` for this response, when it sent one. */
|
||||
export const serverTimeOf = (response: object): string | undefined => serverTimes.get(response);
|
||||
|
||||
/**
|
||||
* Epoch milliseconds from the backend's ISO-8601 offset timestamps.
|
||||
*
|
||||
* Java writes up to nine fractional digits (`13:50:31.1653828+05:30`), and the
|
||||
* ECMAScript date format only promises three. V8 is lenient about the rest;
|
||||
* other engines need not be — so the fraction is cut to exactly three first.
|
||||
*/
|
||||
export const parseServerInstant = (iso: string | null | undefined): number | null => {
|
||||
if (!iso) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalised = iso.replace(/\.(\d+)/, (_match, fraction: string) =>
|
||||
`.${`${fraction}00`.slice(0, 3)}`,
|
||||
);
|
||||
const ms = Date.parse(normalised);
|
||||
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A server expiry as a deadline on this device's clock: the time the server
|
||||
* says is left, counted from when the answer arrived. Only the elapsed time is
|
||||
* measured locally, so a wrong device clock does not move the deadline.
|
||||
*
|
||||
* Without the server's `timestamp` the expiry is taken at face value — which is
|
||||
* right exactly when the device clock is.
|
||||
*/
|
||||
export const toLocalDeadline = (
|
||||
expiresAt: string | null | undefined,
|
||||
serverNow: string | null | undefined,
|
||||
receivedAt: number = Date.now(),
|
||||
): number | null => {
|
||||
const expiry = parseServerInstant(expiresAt);
|
||||
|
||||
if (expiry === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = parseServerInstant(serverNow);
|
||||
|
||||
return now === null ? expiry : receivedAt + (expiry - now);
|
||||
};
|
||||
|
||||
/** Whole seconds until a local deadline, never below zero. `null` when unknown. */
|
||||
export const secondsUntilDeadline = (deadline: number | null, now: number = Date.now()) =>
|
||||
deadline === null ? null : Math.max(0, Math.round((deadline - now) / 1000));
|
||||
@@ -106,7 +106,7 @@ export const mpinCrypto = {
|
||||
return this.encryptPin('mpin', mpin);
|
||||
},
|
||||
|
||||
/** Travels as `encryptedTpin`: TPIN creation and every fund transfer. */
|
||||
/** Travels as `encryptedTpin`: TPIN creation and reset, and every fund transfer. */
|
||||
encryptTpin(tpin: string): Promise<string> {
|
||||
return this.encryptPin('tpin', tpin);
|
||||
},
|
||||
|
||||
@@ -792,3 +792,34 @@ export interface TpinCreateConfirmResult {
|
||||
status: string;
|
||||
createdAt: string | null;
|
||||
}
|
||||
|
||||
/* ── TPIN reset ────────────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* `POST /tpin/reset/initiate`, sent with an empty body. The bank re-verified
|
||||
* the signed-in customer and an OTP is on its way to the session's mobile.
|
||||
*/
|
||||
export interface TpinResetInitiateResult {
|
||||
/** Held in screen state only, and only until the OTP is verified. */
|
||||
tpinResetId: string;
|
||||
/** `PENDING_OTP`. */
|
||||
status: string;
|
||||
otpExpiresAt: string | null;
|
||||
attemptsRemaining: number | null;
|
||||
}
|
||||
|
||||
/** `POST /tpin/reset/verify-otp`. */
|
||||
export interface TpinResetVerifyResult {
|
||||
/** One-time. Never stored, logged or put in Redux. */
|
||||
tpinResetToken: string;
|
||||
/** `OTP_VERIFIED`. */
|
||||
status: string;
|
||||
tokenExpiresAt: string | null;
|
||||
}
|
||||
|
||||
/** `POST /tpin/reset/confirm`. */
|
||||
export interface TpinResetConfirmResult {
|
||||
/** `ACTIVE` — the new TPIN authorises transfers from now on. */
|
||||
status: string;
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user