diff --git a/.gitignore b/.gitignore index f696615..b08a842 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. +.env + # dependencies /node_modules /.pnp diff --git a/app/api/_lib/incode.ts b/app/api/_lib/incode.ts new file mode 100644 index 0000000..0e9e39a --- /dev/null +++ b/app/api/_lib/incode.ts @@ -0,0 +1,48 @@ +const getRequiredEnv = (value: string | undefined, name: string) => { + if (!value) { + throw new Error(`${name} is not set`); + } + + return value; +}; + +const getApiBaseUrl = () => getRequiredEnv(process.env.INCODE_API_URL, "INCODE_API_URL"); + +const getApiHeaders = (token?: string) => ({ + "Content-Type": "application/json", + "api-version": "1.0", + "x-api-key": getRequiredEnv(process.env.INCODE_API_KEY, "INCODE_API_KEY"), + ...(token ? { "X-Incode-Hardware-Id": token } : {}), +}); + +const postJson = async (path: string, body: unknown, token?: string): Promise => { + const response = await fetch(`${getApiBaseUrl()}${path}`, { + method: "POST", + headers: getApiHeaders(token), + body: JSON.stringify(body), + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `Request failed with code ${response.status}`); + } + + return data as T; +}; + +const getJson = async (path: string, token: string): Promise => { + const response = await fetch(`${getApiBaseUrl()}${path}`, { + method: "GET", + headers: getApiHeaders(token), + }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.message || `Request failed with code ${response.status}`); + } + + return data as T; +}; + +export { getJson, postJson }; +export { getRequiredEnv }; diff --git a/app/api/auth/results/route.ts b/app/api/auth/results/route.ts new file mode 100644 index 0000000..4908e6f --- /dev/null +++ b/app/api/auth/results/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server"; +import { getJson, postJson } from "../../_lib/incode"; + +type ResultsRequest = { + token?: string; + identityId?: string; +}; + +type ScoreResponse = { + authentication?: { + identityId?: string; + }; + overall?: { + status?: string; + }; +}; + +const finishStatus = (token: string) => + postJson<{ redirectionUrl?: string; action?: string }>( + "finish-status", + {}, + token + ); + +const closeSession = (token: string) => + postJson<{ sessionStatus?: string }>( + "session/status/set?action=Closed", + {}, + token + ); + +export async function POST(request: Request) { + const body = (await request.json()) as ResultsRequest; + const token = body.token?.trim(); + const identityId = body.identityId?.trim(); + + if (!token || !identityId) { + return NextResponse.json( + { message: "token and identityId are required" }, + { status: 400 } + ); + } + + await finishStatus(token); + await closeSession(token); + + const score = await getJson("get/score", token); + const scoreIdentityId = score.authentication?.identityId; + const scoreStatus = score.overall?.status; + + if (scoreIdentityId !== identityId) { + return NextResponse.json( + { + message: "candidate does not match score identityId", + isValid: false, + identityId: scoreIdentityId, + }, + { status: 200 } + ); + } + + if (scoreStatus !== "OK") { + return NextResponse.json( + { + message: "Score for this session is not OK", + isValid: false, + identityId: scoreIdentityId, + }, + { status: 200 } + ); + } + + return NextResponse.json({ + message: "Successful validation", + isValid: true, + identityId: scoreIdentityId, + }); +} diff --git a/app/api/auth/start/route.ts b/app/api/auth/start/route.ts new file mode 100644 index 0000000..cdc7aff --- /dev/null +++ b/app/api/auth/start/route.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { postJson } from "../../_lib/incode"; +import { getRequiredEnv } from "../../_lib/incode"; + +type StartAuthRequest = { + identityId?: string; +}; + +type StartAuthResponse = { + token: string; + identityId?: string; +}; + +export async function POST(request: Request) { + const body = (await request.json()) as StartAuthRequest; + const identityId = body.identityId?.trim(); + + if (!identityId) { + return NextResponse.json( + { message: "identityId is required" }, + { status: 400 } + ); + } + + const session = await postJson<{ token: string }>("start", { + countryCode: "ALL", + configurationId: getRequiredEnv( + process.env.INCODE_AUTH_CONFIG_ID || process.env.INCODE_CONFIG_ID, + "INCODE_AUTH_CONFIG_ID (or INCODE_CONFIG_ID)" + ), + }); + + return NextResponse.json({ + token: session.token, + identityId, + } satisfies StartAuthResponse); +} diff --git a/app/api/incode/[...path]/route.ts b/app/api/incode/[...path]/route.ts new file mode 100644 index 0000000..13846b4 --- /dev/null +++ b/app/api/incode/[...path]/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; + +const upstreamBaseUrl = () => process.env.INCODE_API_URL; + +const forward = async ( + request: NextRequest, + params: { path: string[] } +) => { + const baseUrl = upstreamBaseUrl(); + + if (!baseUrl) { + return NextResponse.json( + { message: "INCODE_API_URL is not set" }, + { status: 500 } + ); + } + + const upstreamUrl = new URL(params.path.join("/"), baseUrl); + upstreamUrl.search = request.nextUrl.search; + + const headers = new Headers(request.headers); + headers.delete("host"); + + const response = await fetch(upstreamUrl, { + method: request.method, + headers, + body: request.method === "GET" || request.method === "HEAD" ? undefined : request.body, + duplex: "half", + } as RequestInit); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-encoding"); + responseHeaders.delete("transfer-encoding"); + responseHeaders.delete("connection"); + + return new NextResponse(response.body, { + status: response.status, + headers: responseHeaders, + }); +}; + +export const GET = forward; +export const POST = forward; +export const PUT = forward; +export const PATCH = forward; +export const DELETE = forward; +export const OPTIONS = forward; diff --git a/app/globals.css b/app/globals.css index f2c9865..d1f51a6 100644 --- a/app/globals.css +++ b/app/globals.css @@ -26,6 +26,11 @@ body { rgb(var(--background-start-rgb)); } +.incode-sdk-surface, +.incode-sdk-surface > div { + background-color: #fff !important; +} + @layer utilities { .text-balance { text-wrap: balance; diff --git a/app/incode/demo-shell.tsx b/app/incode/demo-shell.tsx new file mode 100644 index 0000000..59c5eed --- /dev/null +++ b/app/incode/demo-shell.tsx @@ -0,0 +1,82 @@ +'use client'; +import { useState } from "react"; +import { Box, Button, Paper, Stack, ToggleButton, ToggleButtonGroup, Typography } from "@mui/material"; +import { FaceAuth } from "./face-auth"; +import { Incode, type SessionType } from "./incode"; + +type DemoShellProps = { + baseUrl: string; +}; + +function DemoShell({ baseUrl }: DemoShellProps) { + const [flow, setFlow] = useState<"onboarding" | "face-auth" | null>(null); + const [session, setSession] = useState(null); + const [onboardingStatus, setOnboardingStatus] = useState("Idle"); + + const startOnboarding = async () => { + setOnboardingStatus("Starting onboarding session..."); + + const response = await fetch("/api/start", { method: "GET" }); + const data = await response.json(); + + if (!response.ok) { + throw new Error(data.error || data.message || "Unable to start onboarding session"); + } + + setSession(data); + setOnboardingStatus("Onboarding session started."); + }; + + return ( + + + + + Select flow + + { + setFlow(nextFlow); + }} + color="primary" + size="small" + > + Onboarding + Face authentication + + + + + {flow ? ( + + {flow === "onboarding" ? ( + + + {onboardingStatus} + {session && } + + ) : ( + + )} + + ) : ( + + Choose a flow to begin. + + )} + + ); +} + +export { DemoShell }; diff --git a/app/incode/face-auth.tsx b/app/incode/face-auth.tsx new file mode 100644 index 0000000..8fc65c2 --- /dev/null +++ b/app/incode/face-auth.tsx @@ -0,0 +1,186 @@ +'use client'; +import { useEffect, useRef, useState } from "react"; +import { + Alert, + Box, + Button, + Card, + CardContent, + Chip, + Stack, + TextField, + Typography, +} from "@mui/material"; + +type AuthResult = { + message: string; + isValid: boolean; + identityId?: string; +}; + +type FaceAuthProps = { + baseUrl: string; +}; + +function FaceAuth({ baseUrl }: FaceAuthProps) { + const containerRef = useRef(null); + const widgetRef = useRef<{ close: () => void } | null>(null); + const [identityId, setIdentityId] = useState(""); + const [sessionToken, setSessionToken] = useState(""); + const [status, setStatus] = useState("Idle"); + const [isStartingSession, setIsStartingSession] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!sessionToken || !identityId || !window.OnBoarding || !containerRef.current) { + return; + } + + setStatus("Starting face authentication..."); + setResult(null); + + const sdk = window.OnBoarding.create({ apiURL: baseUrl }); + + const start = async () => { + await sdk.initialize(); + + widgetRef.current?.close(); + containerRef.current!.innerHTML = ""; + + widgetRef.current = sdk.renderAuthFace(containerRef.current!, { + session: { token: sessionToken }, + authHint: identityId, + onSuccess: async () => { + try { + setStatus("Validating result..."); + const response = await fetch("/api/auth/results", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: sessionToken, identityId }), + }); + const data = (await response.json()) as AuthResult; + if (!response.ok) { + throw new Error(data.message || "Authentication validation failed"); + } + setResult(data); + setStatus(data.message); + } catch (error: any) { + setStatus("Authentication validation failed"); + setResult({ + message: error?.message || "Authentication validation failed", + isValid: false, + }); + } + }, + onError: (error: any) => { + setStatus("Face authentication failed"); + setResult({ + message: error?.message || "Face authentication failed", + isValid: false, + }); + }, + }); + }; + + start().catch((error: any) => { + setStatus("Face authentication failed"); + setResult({ + message: error?.message || "Face authentication failed", + isValid: false, + }); + }); + + return () => { + widgetRef.current?.close(); + widgetRef.current = null; + }; + }, [baseUrl, identityId, sessionToken]); + + const startAuth = async () => { + setStatus("Starting session..."); + setResult(null); + setIsStartingSession(true); + + try { + const response = await fetch("/api/auth/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identityId }), + }); + const data = (await response.json()) as { token?: string; message?: string }; + + if (!response.ok) { + throw new Error(data.message || "Unable to start authentication session"); + } + + if (!data.token) { + throw new Error("Session token missing from auth start response"); + } + + setSessionToken(data.token); + } finally { + setIsStartingSession(false); + } + }; + + return ( + + + + + Face authentication + + Enter the Identity ID and start a session to launch auth face capture. + + + + + setIdentityId(event.target.value)} + placeholder="Enter identityId" + /> + + + + + + Status: + + + + + {result && ( + + {result.message} + {result.identityId ? ` (identityId: ${result.identityId})` : ""} + + )} + + + + + + ); +} + +export { FaceAuth }; diff --git a/app/incode/incode.tsx b/app/incode/incode.tsx index 0502d86..363295d 100644 --- a/app/incode/incode.tsx +++ b/app/incode/incode.tsx @@ -1,44 +1,29 @@ 'use client'; -import { useEffect, useRef } from "react" - +import { useEffect, useRef, useState } from "react"; function Incode({ session, baseUrl }:UserConsentPropTypes) { const containerRef = useRef(null); - const isMounted = useRef(false); - let isOnboardingFinished = false; - console.log(session) - let incode: any; + const captureHandleRef = useRef<{ close: () => void } | null>(null); + const [isOnboardingFinished, setIsOnboardingFinished] = useState(false); useEffect(() => { - if (window && window.OnBoarding) { - // Initialize the SDK - incode = window.OnBoarding.create({ - apiURL: baseUrl - }); - } - - if (incode && isMounted.current) { - return; + if (!window.OnBoarding || !session?.token || !containerRef.current) { + return; } - function captureIdFrontSide() { - incode.renderCamera("front", containerRef.current, { - token: session, - numberOfTries: 3, - onSuccess: captureIdBackSide, - onError: console.log, - showTutorial: true - }) - } + setIsOnboardingFinished(false); - function captureIdBackSide() { - incode.renderCamera("back", containerRef.current, { - token: session, - numberOfTries: 3, - onSuccess: processId, - onError: console.log, - showTutorial: true - }) + const incode = window.OnBoarding.create({ + apiURL: baseUrl + }); + + function captureId() { + captureHandleRef.current?.close(); + captureHandleRef.current = incode.renderCaptureId(containerRef.current!, { + session: session, + onSuccess: processId, + onError: console.log + }); } function processId() { @@ -50,19 +35,27 @@ function Incode({ session, baseUrl }:UserConsentPropTypes) { console.log(error); }); } - + function captureSelfie() { - incode.renderCamera("selfie", containerRef.current, { - token: session, - numberOfTries: 3, - onSuccess: finishOnboarding, - onError: console.log, - showTutorial: true, - }); + incode.renderCaptureFace(containerRef.current, { + session: session, + onSuccess: processFace, + onError: console.log + }); + } + + function processFace() { + return incode.processFace(session) + .then(() => { + finishOnboarding(); + }) + .catch((error: any) => { + console.log(error); + }); } function finishOnboarding() { - console.log("faceMatch") + setIsOnboardingFinished(true); incode .getFinishStatus(null, { token: session.token }) .then((response: any) => { @@ -76,24 +69,27 @@ function Incode({ session, baseUrl }:UserConsentPropTypes) { function saveDeviceData() { incode.sendGeolocation({ token: session.token }); incode.sendFingerprint({ token: session.token }); - captureIdFrontSide(); + captureId(); } saveDeviceData(); - isMounted.current = true; + return () => { + captureHandleRef.current?.close(); + captureHandleRef.current = null; + if (containerRef.current) { + containerRef.current.innerHTML = ""; + } + }; - }, [session]); + }, [session, baseUrl]); return <> {!session && (

Starting session...

)} -
- { - !isOnboardingFinished && ( -

Onboarding Finished.

- )} +
+ {isOnboardingFinished &&

Onboarding Finished.

} ; } @@ -108,5 +104,13 @@ type SessionType = { uuid?: string }; +declare global { + interface Window { + OnBoarding?: { + create: (options: { apiURL: string }) => any; + }; + } +} + export { Incode }; export type { SessionType }; diff --git a/app/page.tsx b/app/page.tsx index 040bac5..33927aa 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,33 +1,19 @@ -import { Incode } from "./incode/incode"; import Script from "next/script"; -import startOnboardingSession from "./incode/session"; - -// Declare the SDK object -declare global { - interface Window { - OnBoarding:any; - } -} +import { DemoShell } from "./incode/demo-shell"; export default async function Home() { - const session: any = await startOnboardingSession(); const baseUrl: string = process.env.INCODE_SDK_URL || ""; return ( <> - { - /* Load the sdk library using "beforeInteractive" strategy */ -