Update CBS Mobile App codebase
This commit is contained in:
@@ -1,96 +1,151 @@
|
||||
import type { AuthTokens, UserProfile } from '../../../types/app';
|
||||
import { demoUser } from '../../../constants/mockData';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { env } from '../../../config/env';
|
||||
import { apiClient } from '../../../services/api/apiClient';
|
||||
import { baseApi } from '../../../services/api/baseApi';
|
||||
import { resolveWithMock } from '../../../services/api/serviceHelpers';
|
||||
import { isCbsApiError } from '../../../services/api/cbsErrors';
|
||||
import { endpoints } from '../../../services/api/endpoints';
|
||||
import { deviceIdentity } from '../../../services/security/deviceIdentity';
|
||||
import { mpinCrypto } from '../../../services/security/mpinCrypto';
|
||||
import type {
|
||||
CreateMpinResult,
|
||||
LoginResult,
|
||||
SessionIdentity,
|
||||
StartRegistrationResult,
|
||||
VerifyOtpResult,
|
||||
} from '../../../types/cbs';
|
||||
|
||||
interface LoginRequest {
|
||||
phone: string;
|
||||
password: string;
|
||||
}
|
||||
/**
|
||||
* `MALFORMED_REQUEST` on an MPIN call is what a stale or wrong public key looks
|
||||
* like from the client — the server will not say more, because saying more
|
||||
* would leak whether the profile exists. So drop the cached key, re-fetch, and
|
||||
* try exactly once more. A second failure is a real problem worth surfacing.
|
||||
*/
|
||||
const withKeyRetry = async <T>(call: () => Promise<T>): Promise<T> => {
|
||||
try {
|
||||
return await call();
|
||||
} catch (error) {
|
||||
if (isCbsApiError(error) && error.code === 'MALFORMED_REQUEST') {
|
||||
mpinCrypto.invalidate();
|
||||
return call();
|
||||
}
|
||||
|
||||
interface LoginResponse {
|
||||
challengeId: string;
|
||||
phone: string;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
interface VerifyOtpRequest {
|
||||
challengeId: string;
|
||||
otp: string;
|
||||
phone: string;
|
||||
}
|
||||
export const authService = {
|
||||
/**
|
||||
* Looks the customer up in their bank's core system, links every account it
|
||||
* returns and sends an OTP to the handset.
|
||||
*/
|
||||
async startRegistration(mobileNumber: string, orgId: string = env.orgId) {
|
||||
const response = await apiClient.post<StartRegistrationResult>(
|
||||
endpoints.registration.start,
|
||||
{ orgId, mobileNumber },
|
||||
);
|
||||
|
||||
interface VerifyOtpResponse {
|
||||
verified: true;
|
||||
phone: string;
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
||||
interface SetupMpinRequest {
|
||||
phone: string;
|
||||
mpin: string;
|
||||
}
|
||||
/**
|
||||
* Verifies the OTP, binds the profile to this device and allocates an MMID to
|
||||
* every linked account. Returns the single-use, five-minute setup token that
|
||||
* MPIN creation requires.
|
||||
*/
|
||||
async verifyRegistrationOtp({
|
||||
mobileNumber,
|
||||
otp,
|
||||
orgId = env.orgId,
|
||||
}: {
|
||||
mobileNumber: string;
|
||||
otp: string;
|
||||
orgId?: string;
|
||||
}) {
|
||||
const deviceId = await deviceIdentity.get();
|
||||
|
||||
interface SetupMpinResponse {
|
||||
tokens: AuthTokens;
|
||||
user: UserProfile;
|
||||
}
|
||||
const response = await apiClient.post<VerifyOtpResult>(endpoints.registration.verifyOtp, {
|
||||
orgId,
|
||||
mobileNumber,
|
||||
otp,
|
||||
deviceId,
|
||||
});
|
||||
|
||||
const createDemoTokens = (): AuthTokens => ({
|
||||
accessToken: 'demo-access-token',
|
||||
refreshToken: 'demo-refresh-token',
|
||||
expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24).toISOString(),
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
export const authApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
login: builder.mutation<LoginResponse, LoginRequest>({
|
||||
queryFn: ({ phone }) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
challengeId: 'otp_challenge_001',
|
||||
phone,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post<LoginResponse>(endpoints.auth.login, { phone });
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
verifyOtp: builder.mutation<VerifyOtpResponse, VerifyOtpRequest>({
|
||||
queryFn: ({ phone }) =>
|
||||
resolveWithMock({
|
||||
mockData: {
|
||||
verified: true,
|
||||
phone,
|
||||
},
|
||||
request: async () => {
|
||||
const response = await apiClient.post<VerifyOtpResponse>(endpoints.auth.verifyOtp, {
|
||||
phone,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
setupMpin: builder.mutation<SetupMpinResponse, SetupMpinRequest>({
|
||||
queryFn: () =>
|
||||
resolveWithMock({
|
||||
mockData: () => ({
|
||||
tokens: createDemoTokens(),
|
||||
user: demoUser,
|
||||
}),
|
||||
request: async () => {
|
||||
const response = await apiClient.post<SetupMpinResponse>(endpoints.auth.setupMpin);
|
||||
return response.data;
|
||||
},
|
||||
}),
|
||||
invalidatesTags: ['Auth', 'User'],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
/**
|
||||
* Creates the MPIN and completes registration. There is deliberately no
|
||||
* orgId and no mobile number in the request — both come from the setup token,
|
||||
* which is what stops a caller setting someone else's MPIN. The deviceId must
|
||||
* match the one sent at verify-otp.
|
||||
*/
|
||||
async createMpin({ setupToken, mpin }: { setupToken: string; mpin: string }) {
|
||||
return withKeyRetry(async () => {
|
||||
const deviceId = await deviceIdentity.get();
|
||||
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
|
||||
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useVerifyOtpMutation,
|
||||
useSetupMpinMutation,
|
||||
} = authApi;
|
||||
const response = await apiClient.post<CreateMpinResult>(endpoints.registration.mpin, {
|
||||
setupToken,
|
||||
deviceId,
|
||||
encryptedMpin,
|
||||
});
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Verifies the MPIN and opens a session. The MPIN is encrypted exactly as it
|
||||
* was at setup — same key, same payload shape.
|
||||
*/
|
||||
async login({
|
||||
mobileNumber,
|
||||
mpin,
|
||||
orgId = env.orgId,
|
||||
setupToken,
|
||||
}: {
|
||||
mobileNumber: string;
|
||||
mpin: string;
|
||||
orgId?: string;
|
||||
/** See `LoginRequest.setupToken` — a device re-bind, and only that. */
|
||||
setupToken?: string | null;
|
||||
}) {
|
||||
return withKeyRetry(async () => {
|
||||
const deviceId = await deviceIdentity.get();
|
||||
const encryptedMpin = await mpinCrypto.encryptMpin(mpin);
|
||||
|
||||
const response = await apiClient.post<LoginResult>(endpoints.auth.login, {
|
||||
orgId,
|
||||
mobileNumber,
|
||||
deviceId,
|
||||
encryptedMpin,
|
||||
// Spread rather than set: an ordinary sign-in must not carry the key at
|
||||
// all, and `setupToken: undefined` is a key once this goes through
|
||||
// JSON.stringify on some clients.
|
||||
...(setupToken ? { setupToken } : {}),
|
||||
});
|
||||
|
||||
return response.data;
|
||||
});
|
||||
},
|
||||
|
||||
/** Who the current session belongs to. Doubles as a keep-alive. */
|
||||
async getIdentity() {
|
||||
const response = await apiClient.get<SessionIdentity>(endpoints.auth.me);
|
||||
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Revokes the session immediately. The endpoint always answers 200, even for
|
||||
* a token that was already dead — a different answer would tell a caller
|
||||
* whether a token they hold is live. Nothing that happens here may block the
|
||||
* local sign-out, so every failure is swallowed: the customer asked to be
|
||||
* signed out, and a network error must not leave them signed in.
|
||||
*/
|
||||
async logout() {
|
||||
try {
|
||||
await apiClient.post(endpoints.auth.logout);
|
||||
} catch {
|
||||
// Intentionally ignored. The local session is cleared regardless.
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user