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
19 changes: 19 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,22 @@ TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER=
# Set to 1 on staging/preview to disable outbound texts entirely.
SMS_DISABLED=

# --- RayVerify identity verification (AWS Rekognition) ---
# Set to "rekognition" to turn on selfie identity matching at clock-in. Any
# other value (or unset) leaves the no-op provider in place, which reports
# `not_configured` and never a pass.
#
# BEFORE ENABLING THIS, understand what it collects. A face image and anything
# derived from it is a biometric identifier: PHI under HIPAA, and separately
# governed by state biometric-privacy statutes (Illinois BIPA, Texas CUBI,
# Washington and others) that require informed written consent BEFORE
# collection, a published retention and destruction schedule, and no sale or
# disclosure. BIPA carries a private right of action. The app enforces the
# consent gate in code, but the retention schedule and the consent wording are
# yours to own, and you need an AWS BAA covering Rekognition.
#
# Images are stored in DOCUMENTS_S3_BUCKET alongside other PHI documents.
IDENTITY_VERIFICATION_PROVIDER=
# Region for Rekognition. Falls back to AWS_REGION, then us-east-1.
IDENTITY_REKOGNITION_REGION=
446 changes: 126 additions & 320 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
},
"dependencies": {
"@ai-sdk/amazon-bedrock": "^5.0.31",
"@aws-sdk/client-rekognition": "^3.1103.0",
"@aws-sdk/client-sesv2": "^3.1054.0",
"@rayhealth/core": "file:../core",
"@simplewebauthn/server": "^13.3.2",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import pushTokenRoutes from './routes/push-token-routes.js';
import mileageRoutes from './routes/mileage-routes.js';
import availabilityRoutes from './routes/availability-routes.js';
import messageRoutes from './routes/message-routes.js';
import identityRoutes from './routes/identity-routes.js';
import agencySandataConfigRoutes from './routes/agency-sandata-config-routes.js';
import agencyHhaexchangeConfigRoutes from './routes/agency-hhaexchange-config-routes.js';
import agencyClearinghouseConfigRoutes from './routes/agency-clearinghouse-config-routes.js';
Expand Down Expand Up @@ -354,6 +355,7 @@ export function createApp(options: { mobileSessionStore?: MobileSessionStore } =
app.use(`${prefix}/mileage`, mileageRoutes);
app.use(`${prefix}/availability`, availabilityRoutes);
app.use(`${prefix}/messages`, messageRoutes);
app.use(`${prefix}/identity`, identityRoutes);
app.use(`${prefix}/compliance-engine`, complianceEngineRoutes);
app.use(`${prefix}/command-center`, copilotLimiter, commandCenterRoutes);
app.use(`${prefix}/documents`, documentRoutes);
Expand Down
119 changes: 119 additions & 0 deletions packages/app/src/identity/__tests__/face-match-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
FACE_MATCH_THRESHOLD,
createFaceMatchClient,
isIdentityVerificationConfigured,
resetFaceMatchClient,
} from '../face-match-client.js';

const OLD_ENV = { ...process.env };
const IMAGE = Buffer.from('fake-jpeg-bytes');

beforeEach(() => {
delete process.env.IDENTITY_VERIFICATION_PROVIDER;
resetFaceMatchClient();
});

afterEach(() => {
process.env = { ...OLD_ENV };
vi.restoreAllMocks();
resetFaceMatchClient();
});

describe('provider selection', () => {
it('falls back to a no-op that reports not_configured, never a pass', async () => {
// A verification product that reports success when it verified nothing is
// worse than one that reports nothing at all.
const result = await createFaceMatchClient().compare(IMAGE, IMAGE);

expect(result.outcome).toBe('not_configured');
expect(result.similarity).toBeNull();
expect(isIdentityVerificationConfigured()).toBe(false);
});

it('reports configured only when the provider is explicitly selected', () => {
expect(isIdentityVerificationConfigured()).toBe(false);
process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition';
expect(isIdentityVerificationConfigured()).toBe(true);
});

it('never claims a liveness check it did not perform', async () => {
const result = await createFaceMatchClient().compare(IMAGE, IMAGE);
// Face match answers who is in the frame, not whether a person was there.
expect(result.livenessChecked).toBe(false);
});
});

describe('match threshold', () => {
it('is stricter than the AWS default, because a false accept starts a paid shift', () => {
expect(FACE_MATCH_THRESHOLD).toBeGreaterThan(80);
});
});

describe('rekognition result mapping', () => {
/**
* Exercises the mapping by driving the real client against a stubbed AWS
* transport, so the outcome logic is covered without a network call.
*/
async function compareWith(response: Record<string, unknown>) {
process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition';
const rekognition = await import('@aws-sdk/client-rekognition');
vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockResolvedValue(
response as never,
);
return createFaceMatchClient().compare(IMAGE, IMAGE);
}

it('matches above the threshold', async () => {
const result = await compareWith({ FaceMatches: [{ Similarity: 97.4 }] });
expect(result).toMatchObject({ outcome: 'matched', similarity: 97, provider: 'rekognition' });
});

it('rejects a similar-but-below-threshold face', async () => {
const result = await compareWith({ FaceMatches: [{ Similarity: 88 }] });
expect(result).toMatchObject({ outcome: 'not_matched', similarity: 88 });
});

it('takes the strongest match when several faces are returned', async () => {
const result = await compareWith({ FaceMatches: [{ Similarity: 41 }, { Similarity: 95 }] });
expect(result).toMatchObject({ outcome: 'matched', similarity: 95 });
});

it('separates "a different person" from "no face in frame"', async () => {
// A face that simply is not the enrolled person.
const different = await compareWith({ FaceMatches: [], UnmatchedFaces: [{}] });
expect(different.outcome).toBe('not_matched');

vi.restoreAllMocks();
// Nothing face-like at all: a dark or blurred capture, which should ask
// for a retake rather than accuse anyone.
const empty = await compareWith({ FaceMatches: [], UnmatchedFaces: [] });
expect(empty.outcome).toBe('no_face');
expect(empty.similarity).toBeNull();
});

it('treats an unreadable source image as a retake, not a system error', async () => {
process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition';
const rekognition = await import('@aws-sdk/client-rekognition');
const err = new Error('no face');
err.name = 'InvalidParameterException';
vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockRejectedValue(err as never);

const result = await createFaceMatchClient().compare(IMAGE, IMAGE);

expect(result.outcome).toBe('no_face');
});

it('reports an error rather than a pass when the provider fails', async () => {
process.env.IDENTITY_VERIFICATION_PROVIDER = 'rekognition';
const rekognition = await import('@aws-sdk/client-rekognition');
vi.spyOn(rekognition.RekognitionClient.prototype, 'send').mockRejectedValue(
new Error('service unavailable') as never,
);

const result = await createFaceMatchClient().compare(IMAGE, IMAGE);

expect(result.outcome).toBe('error');
expect(result.similarity).toBeNull();
});
});
173 changes: 173 additions & 0 deletions packages/app/src/identity/face-match-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* Face matching for RayVerify identity verification.
*
* Compares a clock-in selfie against the caregiver's enrolled reference face
* and reports how confident the provider is that they are the same person.
*
* Provider selection (first match wins), same shape as email-client.ts:
* 1. AWS Rekognition, when IDENTITY_VERIFICATION_PROVIDER=rekognition.
* AWS is already the project's BAA-covered vendor for SES, Bedrock, and
* PHI document storage, so no new vendor relationship is needed.
* 2. No-op fallback, which reports `not_configured` rather than pretending
* to have verified anybody.
*
* A stub that returns a confident score is the single most dangerous thing
* this module could contain: it would let the product claim verified identity
* while checking nothing. The fallback therefore fails loudly-in-data
* (`not_configured`) and callers must not treat that as a pass.
*
* LIVENESS IS NOT IMPLEMENTED. See `docs/rayverify-integration.md` §7: face
* and liveness must be presented as rolling out, not live, until a real
* provider is wired. AWS Rekognition Face Liveness requires the Amplify
* FaceLivenessDetector client SDK, which needs a custom native build the
* managed Expo app does not have today. Without it, a still photograph of a
* photograph passes face match, so this module verifies WHO is in the frame
* and says nothing about whether they were physically present. The
* `livenessChecked` flag is on the result so no caller can silently assume
* otherwise.
*/

import {
RekognitionClient,
CompareFacesCommand,
type CompareFacesCommandOutput,
} from '@aws-sdk/client-rekognition';
import { safeError } from '../security/safe-log.js';

/**
* Similarity below which a comparison is not a match, as a percentage.
*
* 90 rather than AWS's 80 default: this gates a caregiver's ability to start a
* paid shift, so a false accept (someone else clocking in) is worse than a
* false reject (a retake). Agencies see rejected checks and can override the
* visit through the existing exception path.
*/
export const FACE_MATCH_THRESHOLD = 90;

export type FaceMatchOutcome =
| 'matched'
| 'not_matched'
/** No face found in one of the images, e.g. a dark or blurred capture. */
| 'no_face'
| 'error'
| 'not_configured';

export interface FaceMatchResult {
outcome: FaceMatchOutcome;
/** Provider-reported similarity 0..100, null when no comparison happened. */
similarity: number | null;
provider: string;
/**
* Always false today. Present so a caller can never mistake face match for
* proof of physical presence. See the module note on liveness.
*/
livenessChecked: boolean;
}

export interface FaceMatchClient {
compare(reference: Buffer, capture: Buffer): Promise<FaceMatchResult>;
}

function createRekognitionClient(): FaceMatchClient {
const region =
process.env.IDENTITY_REKOGNITION_REGION?.trim() ||
process.env.AWS_REGION?.trim() ||
'us-east-1';
const accessKeyId = process.env.AWS_ACCESS_KEY_ID?.trim();
const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY?.trim();

const client = new RekognitionClient({
region,
...(accessKeyId && secretAccessKey ? { credentials: { accessKeyId, secretAccessKey } } : {}),
});

return {
async compare(reference: Buffer, capture: Buffer): Promise<FaceMatchResult> {
try {
const response: CompareFacesCommandOutput = await client.send(
new CompareFacesCommand({
// Copied into plain Uint8Arrays: the SDK's Bytes field is typed
// against ArrayBuffer, and Node's Buffer can be backed by a
// SharedArrayBuffer, which the type will not accept.
SourceImage: { Bytes: Uint8Array.from(reference) },
TargetImage: { Bytes: Uint8Array.from(capture) },
// Ask below our own threshold so a near-miss comes back with a
// score we can record, rather than as an empty result we cannot
// tell apart from "no face in frame".
SimilarityThreshold: 1,
QualityFilter: 'AUTO',
}),
);

const best = (response.FaceMatches ?? []).reduce<number | null>((max, match) => {
const similarity = match.Similarity ?? 0;
return max == null || similarity > max ? similarity : max;
}, null);

if (best == null) {
// Rekognition reports unmatched faces separately from "no face at
// all". An empty target set means nothing face-like was found.
const sawAFace = (response.UnmatchedFaces ?? []).length > 0;
return {
outcome: sawAFace ? 'not_matched' : 'no_face',
similarity: sawAFace ? 0 : null,
provider: 'rekognition',
livenessChecked: false,
};
}

const rounded = Math.round(best);
return {
outcome: rounded >= FACE_MATCH_THRESHOLD ? 'matched' : 'not_matched',
similarity: rounded,
provider: 'rekognition',
livenessChecked: false,
};
} catch (err) {
// InvalidParameterException is what Rekognition raises when it cannot
// find a face in the SOURCE image, which is a capture-quality problem
// rather than a system failure, and the caregiver should be asked to
// retake rather than shown an error.
const name = err instanceof Error ? err.name : '';
if (name === 'InvalidParameterException') {
return { outcome: 'no_face', similarity: null, provider: 'rekognition', livenessChecked: false };
}
safeError('rekognition compare failed', err);
return { outcome: 'error', similarity: null, provider: 'rekognition', livenessChecked: false };
}
},
};
}

function createNoopClient(): FaceMatchClient {
return {
async compare(): Promise<FaceMatchResult> {
// Deliberately NOT a pass. A verification product that reports success
// when it verified nothing is worse than one that reports nothing.
return { outcome: 'not_configured', similarity: null, provider: 'none', livenessChecked: false };
},
};
}

export function createFaceMatchClient(): FaceMatchClient {
if (process.env.IDENTITY_VERIFICATION_PROVIDER?.trim() === 'rekognition') {
return createRekognitionClient();
}
return createNoopClient();
}

let cached: FaceMatchClient | null = null;

export function getFaceMatchClient(): FaceMatchClient {
if (!cached) cached = createFaceMatchClient();
return cached;
}

export function resetFaceMatchClient(): void {
cached = null;
}

/** True when a real provider is wired up, for readiness reporting. */
export function isIdentityVerificationConfigured(): boolean {
return process.env.IDENTITY_VERIFICATION_PROVIDER?.trim() === 'rekognition';
}
Loading
Loading