Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions __mocks__/expo-crypto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Jest stand-in for expo-crypto, whose native AES classes cannot load under Jest. Randomness and digests use Node's
// crypto so PKCE values are real; tests that need fixed values still mock expo-crypto themselves, which takes precedence.
import { createHash, randomBytes, randomUUID as nodeRandomUUID } from 'crypto';

export const CryptoDigestAlgorithm = { SHA1: 'SHA-1', SHA256: 'SHA-256', SHA384: 'SHA-384', SHA512: 'SHA-512', MD5: 'MD5' } as const;
export const CryptoEncoding = { HEX: 'hex', BASE64: 'base64' } as const;

const nodeAlgorithm = (algorithm: string) => algorithm.replace('-', '').toLowerCase();

export const getRandomBytes = (byteCount: number): Uint8Array => new Uint8Array(randomBytes(byteCount));
export const getRandomBytesAsync = async (byteCount: number): Promise<Uint8Array> => getRandomBytes(byteCount);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kody code-review Kody Rules high

getRandomBytesAsync invokes the synchronous getRandomBytes implementation from async code, blocking the event loop and misrepresenting the API's asynchronous behavior. Use the awaitable randomBytesAsync API and return its result as a Uint8Array.

Kody rule violation: Use Awaitable Methods in Async Code

export const getRandomBytesAsync = async (byteCount: number): Promise<Uint8Array> => {
  return new Uint8Array(await randomBytesAsync(byteCount));
};
Prompt for LLM

File __mocks__/expo-crypto.ts:

Line 11:

`getRandomBytesAsync` invokes the synchronous `getRandomBytes` implementation from async code, blocking the event loop and misrepresenting the API's asynchronous behavior. Use the awaitable `randomBytesAsync` API and return its result as a `Uint8Array`.

Suggested Code:

export const getRandomBytesAsync = async (byteCount: number): Promise<Uint8Array> => {
  return new Uint8Array(await randomBytesAsync(byteCount));
};

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

​

​

export const randomUUID = (): string => nodeRandomUUID();
export const digestStringAsync = async (algorithm: string, data: string, options?: { encoding?: string }): Promise<string> =>
createHash(nodeAlgorithm(algorithm))
.update(data)
.digest(options?.encoding === 'base64' ? 'base64' : 'hex');
export const digest = async (algorithm: string, data: ArrayBuffer | Uint8Array): Promise<ArrayBuffer> => {
// A copy into a fresh ArrayBuffer: Node's pooled Buffer may sit on a shared backing store.
return new Uint8Array(createHash(nodeAlgorithm(algorithm)).update(Buffer.from(data as ArrayBuffer)).digest()).buffer;
};
export const aesEncryptAsync = jest.fn();
export const aesDecryptAsync = jest.fn();
export class AESEncryptionKey {}
export class AESSealedData {}
8 changes: 8 additions & 0 deletions __mocks__/react-native-passkey.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// Jest stand-in for react-native-passkey (a native module). Tests set the results they need on these mocks.
export const Passkey = {
isSupported: jest.fn(() => true),
get: jest.fn(),
create: jest.fn(),
getPlatformKey: jest.fn(),
createPlatformKey: jest.fn(),
};
5 changes: 5 additions & 0 deletions app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
bundleIdentifier: Env.BUNDLE_ID,
...(Env.IOS_APPLE_TEAM_ID ? { appleTeamId: Env.IOS_APPLE_TEAM_ID } : {}),
requireFullScreen: true,
// Passkeys: Responder's own relying-party hosts, one per region (passkey workbook section 5.1). Each host serves an
// apple-app-site-association listing only this app. Staging and development builds use their own hosts, not these.
...((Env.APP_ENV === 'production' || Env.APP_ENV === 'internal') && {
associatedDomains: ['webcredentials:responder.resgrid.com', 'webcredentials:responder-eu-central.resgrid.com'],
}),
infoPlist: {
UIBackgroundModes: ['remote-notification', 'audio', 'bluetooth-central', 'voip'],
ITSAppUsesNonExemptEncryption: false,
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"react-native-keyboard-controller": "1.21.9",
"react-native-logs": "~5.3.0",
"react-native-mmkv": "3.3.3",
"react-native-passkey": "3.6.2",
"react-native-permissions": "^5.4.1",
"react-native-reanimated": "4.5.1",
"react-native-restart": "0.0.27",
Expand Down
21 changes: 21 additions & 0 deletions src/api/common/__tests__/client-token-refresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,27 @@ describe('API Client - Token Refresh', () => {
});
});

describe('Response Interceptor - application refusals', () => {
it('passes a 401 problem refusal straight through: no refresh, no replay of the code', async () => {
useAuthStore.setState({
accessToken: 'access-token',
refreshToken: 'refresh-token',
accessTokenObtainedAt: Date.now() - 1000,
refreshTokenObtainedAt: Date.now() - 1000,
status: 'signedIn',
profile: { sub: 'test-user' } as any,
userId: 'test-user',
});
mockAxios.onPost('/Mfa/VerifyStepUp').reply(401, { type: 'invalid_totp', title: 'The code is invalid', status: 401 });

await expect(api.post('/Mfa/VerifyStepUp', { Code: '000000' })).rejects.toMatchObject({ response: { status: 401, data: { type: 'invalid_totp' } } });

expect(mockedRefreshTokenRequest).not.toHaveBeenCalled();
expect(mockAxios.history.post).toHaveLength(1);
expect(useAuthStore.getState().status).toBe('signedIn');
});
});

describe('Token Expiry Helper Methods', () => {
it('should correctly identify expiring soon tokens', () => {
const now = Date.now();
Expand Down
10 changes: 10 additions & 0 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios, { type AxiosError, type AxiosInstance, type InternalAxiosRequestCo

import { readProtectedGrantHeaders } from '@/lib/data-protection/grant-provider';
import { logger } from '@/lib/logging';
import { CLIENT_HEADER, RESGRID_CLIENT } from '@/lib/mfa/client-app';
import { getBaseApiUrl } from '@/lib/storage/app';
import useAuthStore from '@/stores/auth/store';

Expand All @@ -15,6 +16,8 @@ const axiosInstance: AxiosInstance = axios.create({
timeout: DEFAULT_REQUEST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
// Which app is calling (passkey plan section 10.4): passkeys, brokered SSO and approvals are bound to it.
[CLIENT_HEADER]: RESGRID_CLIENT,
},
});

Expand Down Expand Up @@ -131,6 +134,13 @@ axiosInstance.interceptors.response.use(
return Promise.reject(error);
}

// A 401 carrying a problem `type` is the application refusing this request (a wrong code, a session that ended),
// not an expired token: the authentication layer answers those with an empty body. Refreshing and replaying would
// count a wrong code twice toward the lockout.
if (error.response?.status === 401 && typeof (error.response.data as { type?: unknown } | undefined)?.type === 'string') {
return Promise.reject(error);
}

// Handle 401 errors
if (error.response?.status === 401 && !(originalRequest as InternalAxiosRequestConfig & { _retry?: boolean })._retry) {
const authStore = useAuthStore.getState();
Expand Down
79 changes: 79 additions & 0 deletions src/api/data-protection/data-protection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,82 @@ export const verifyStepUp = async (code: string) => {
const response = await api.post<StepUpResult>(`${DATA_PROTECTION}/VerifyStepUp`, { Code: code });
return response.data;
};

// ---------------------------------------------------------------------------
// Step-up by other methods (passkey plan sections 7.8, 7.9 and 8.1): a passkey for this app, approval from the member's
// Responder, or the department's identity provider. Every method ends in the same StepUpResult grant; the server
// decides which ones this department and member may use.
// ---------------------------------------------------------------------------

export interface StepUpMethodsData {
/** What this member has and the department accepts for protected data, in the server's order. */
Methods: string[];
Preferred: string | null;
EnrolledMethods: string[];
AllowedMethods: string[];
}

interface PasskeyCeremony {
RequestId: string;
Options: Record<string, unknown>;
}

export const getStepUpMethods = async () => {
const response = await api.get<{ Data: StepUpMethodsData }>(`${DATA_PROTECTION}/StepUpMethods`);
return response.data.Data;
};

export const getStepUpPasskeyOptions = async () => {
const response = await api.post<{ Data: PasskeyCeremony }>(`${DATA_PROTECTION}/PasskeyOptions`, {});
return response.data.Data;
};

export const verifyStepUpPasskey = async (requestId: string, credential: Record<string, unknown>) => {
const response = await api.post<StepUpResult>(`${DATA_PROTECTION}/VerifyPasskey`, { RequestId: requestId, Credential: credential });
return response.data;
};

/** Asks the member's Responder to approve access to protected data; the number is shown on this screen only. */
export const requestStepUpApproval = async () => {
const response = await api.post<{ Data: { ApprovalRequestId: string; MatchNumber: string; ExpiresIn: number } }>('/MfaApproval/Request', { Purpose: 'adp' });
return response.data.Data;
};

export const getStepUpApprovalStatus = async (approvalRequestId: string) => {
const response = await api.post<{ Data: { State: 'pending' | 'approved' | 'denied' | 'expired' | 'canceled' | 'consumed'; ExpiresAt: string | null } }>('/MfaApproval/Status', { ApprovalRequestId: approvalRequestId });
return response.data.Data;
};

export const cancelStepUpApproval = async (approvalRequestId: string) => {
await api.post('/MfaApproval/Cancel', { ApprovalRequestId: approvalRequestId });
};

export const completeStepUpApproval = async (approvalRequestId: string) => {
const response = await api.post<StepUpResult>(`${DATA_PROTECTION}/CompleteApproval`, { ApprovalRequestId: approvalRequestId });
return response.data;
};

export interface StepUpSsoSecrets {
returnTarget: string;
state: string;
codeChallenge: string;
platform: string;
}

/** Begins a provider step-up for protected data, bound to this session (the broker reads the app from the session). */
export const beginStepUpSso = async (secrets: StepUpSsoSecrets) => {
const response = await api.post<{ Data: { AuthorizeUrl: string; SsoTransactionId: string; ExpiresIn: number } }>('/Sso/Begin', {
Purpose: 'adp_step_up',
ReturnTarget: secrets.returnTarget,
State: secrets.state,
CodeChallenge: secrets.codeChallenge,
CodeChallengeMethod: 'S256',
Platform: secrets.platform,
});
return response.data.Data;
};

export const completeStepUpFederated = async (ssoTransactionId: string, ssoCode: string, codeVerifier: string) => {
const response = await api.post<StepUpResult>(`${DATA_PROTECTION}/CompleteFederated`, { SsoTransactionId: ssoTransactionId, SsoCode: ssoCode, CodeVerifier: codeVerifier });
return response.data;
};
171 changes: 171 additions & 0 deletions src/api/mfa/account-security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { PasskeyCeremonyData, PasskeyData, SsoBeginData, SsoRedeemData } from '@/lib/mfa/types';

import { api } from '../common/client';

// ---------------------------------------------------------------------------
// The signed-in side of sign-in methods (passkey plan sections 6.5, 7.6 and 7.9): the account methods view, passkeys for
// this app, Responder approval as the approver, and the fresh verification the server asks for before any change.
// Bodies are PascalCase; results carry their payload in `Data`.
// ---------------------------------------------------------------------------

const data = async <T>(request: Promise<{ data: { Data: T } }>): Promise<T> => (await request).data.Data;

// ---- Approving another app's sign-in (Responder is the approver) -----------------------------------------------------

export interface PendingApprovalData {
ApprovalRequestId: string;
RequestingApp: string | null;
InstallationLabel: string | null;
Shared: boolean;
Department: string | null;
Purpose: 'login' | 'step_up' | 'adp' | 'unlock' | 'department_entry';
Operation: string | null;
OriginRegion: string | null;
CreatedAt: string | null;
ExpiresAt: string | null;
AttemptsRemaining: number;
}

export const getPendingApproval = () => data<PendingApprovalData | null>(api.get('/MfaApproval/Pending'));

export const getApprovalOptions = (approvalRequestId: string) => data<PasskeyCeremonyData>(api.post('/MfaApproval/Options', { ApprovalRequestId: approvalRequestId }));

export const approveRequest = (approvalRequestId: string, matchNumber: string, requestId: string, credential: Record<string, unknown>) =>
data<{ State: string }>(api.post('/MfaApproval/Approve', { ApprovalRequestId: approvalRequestId, MatchNumber: matchNumber, RequestId: requestId, Credential: credential }));

export const denyRequest = (approvalRequestId: string, reason: 'declined' | 'not_me') => data<{ State: string }>(api.post('/MfaApproval/Deny', { ApprovalRequestId: approvalRequestId, Reason: reason }));

// ---- Account methods view (plan section 6.5) --------------------------------------------------------------------------

export interface TotpMethodData {
Enrolled: boolean;
EnrolledOn: string | null;
LastUsedOn: string | null;
RecoveryCodesRemaining: number;
RecoveryCodeWarning: boolean;
SetUpOnSharedInstallation: boolean;
CanTurnOff: boolean;
}

export interface PasskeyGroupData {
Client: string;
RegistrationAvailable: boolean;
Passkeys: PasskeyData[];
}

export interface ApprovalInstallationData {
InstallationId: string;
Label: string | null;
Platform: string | null;
IsCurrent: boolean;
ApprovalsOn: boolean;
StoppedOn: string | null;
LastDecisionOn: string | null;
LastDecision: 'approved' | 'denied' | null;
}

export interface LinkedIdentityData {
DepartmentId: number;
DepartmentName: string | null;
ProviderType: string | null;
LinkedOn: string | null;
AcceptsProviderStepUp: boolean;
LastProviderStepUpOn: string | null;
}

export interface MfaActivityData {
ActivityId: string;
OccurredOn: string;
Method: string;
Purpose: string;
Successful: boolean;
Client: string | null;
Installation: string | null;
SharedInstallation: boolean;
IsCurrentSession: boolean;
ReportedOn: string | null;
}

export interface AccountMethodsData {
CurrentClient: string | null;
Totp: TotpMethodData;
PasskeyGroups: PasskeyGroupData[];
ApprovalInstallations: ApprovalInstallationData[];
LinkedIdentities: LinkedIdentityData[];
RecentActivity: MfaActivityData[];
}

export const getAccountMethods = () => data<AccountMethodsData>(api.get('/AccountSecurity/Methods'));

/** "This wasn't me": ends the session a verification served and sends a notice. */
export const reportActivity = (activityId: string) => data<{ SessionEnded: boolean; NextSteps: string[] }>(api.post('/AccountSecurity/ReportActivity', { ActivityId: activityId }));

// ---- Fresh proof before a change (plan sections 6.2 and 7.6 row 14) ----------------------------------------------------

export const reauthenticateWithPassword = (password: string) => data<{ VerifiedAt: string }>(api.post('/AccountSecurity/Reauthenticate', { Password: password }));

export interface SsoReauthSecrets {
returnTarget: string;
state: string;
codeChallenge: string;
platform: string;
}

/** For a member whose department signs in through its identity provider: reauthenticate there instead of with a password. */
export const beginSsoReauthentication = (secrets: SsoReauthSecrets) =>
data<SsoBeginData>(
api.post('/Sso/Begin', {
Purpose: 'reauth',
ReturnTarget: secrets.returnTarget,
State: secrets.state,
CodeChallenge: secrets.codeChallenge,
CodeChallengeMethod: 'S256',
Platform: secrets.platform,
})
);

export const redeemSsoReauthentication = (ssoTransactionId: string, ssoCode: string, codeVerifier: string) =>
data<SsoRedeemData>(api.post('/Sso/Redeem', { SsoTransactionId: ssoTransactionId, SsoCode: ssoCode, CodeVerifier: codeVerifier }));

export interface StepUpOptionsData {
Methods: string[];
Preferred: string | null;
EnrollmentRequired: boolean;
WindowMinutes: number;
Passkey: PasskeyCeremonyData | null;
}

export const getStepUpOptions = (operation: string) => data<StepUpOptionsData>(api.get('/Mfa/StepUpOptions', { params: { operation } }));

export interface VerifyStepUpInput {
Operation: string;
Method: 'totp' | 'passkey';
Code?: string;
RequestId?: string;
Credential?: Record<string, unknown>;
}

export const verifyStepUp = (input: VerifyStepUpInput) => data<{ VerifiedAt: string; ExpiresAt: string }>(api.post('/Mfa/VerifyStepUp', input as unknown as Record<string, unknown>));

// ---- Passkeys for this app (plan section 6.1) --------------------------------------------------------------------------

export const getPasskeyRegistrationOptions = () => data<PasskeyCeremonyData>(api.post('/Passkeys/RegistrationOptions', {}));

export const completePasskeyRegistration = (requestId: string, credential: Record<string, unknown>, displayName: string) =>
data<PasskeyData>(api.post('/Passkeys/CompleteRegistration', { RequestId: requestId, Credential: credential, DisplayName: displayName }));

export interface PasskeyChangeData {
Revoked: number;
SessionsEnded: number;
CurrentSessionEnded: boolean;
}

export const renamePasskey = (passkeyId: string, displayName: string) => data<PasskeyChangeData>(api.post('/Passkeys/Rename', { PasskeyId: passkeyId, DisplayName: displayName }));

export const revokePasskey = (passkeyId: string) => data<PasskeyChangeData>(api.post('/Passkeys/Revoke', { PasskeyId: passkeyId }));

/** Responder passkeys only: whether this passkey may approve other apps' sign-ins. */
export const setPasskeyApproval = (passkeyId: string, enabled: boolean) => data<PasskeyChangeData>(api.post('/Passkeys/SetApproval', { PasskeyId: passkeyId, Enabled: enabled }));

export const stopApprovalInstallations = (installationId: string | null) =>
data<{ InstallationsStopped: number; PasskeysStopped: number }>(api.post('/MfaApproval/Installations/Disable', installationId ? { InstallationId: installationId, All: false } : { All: true }));
Loading
Loading