# BGC Candidate Background Check Flow

> Favro: [Ref-19996](https://favro.com/organization/3483439b3ba44f8b9cfa0c46/e57278728abcb8c86239273a?card=Ref-19996)
> PRs: [#5031](https://github.com/ref-app/refapp/pull/5031) (shared + BGC), [#5047](https://github.com/ref-app/refapp/pull/5047) (refapp server-side redirect), Phase 3 (refapp token verification)

## Overview

This document covers the candidate's journey through a background check in three
parts:

1. **Start the background check** — the candidate verifies their identity with an
   eID and the check is created and handed to the BGC app.
2. **Review & share** — when the report is ready and candidate approval is
   enabled, the candidate reviews it and approves or declines sharing it with the
   recruiter.
3. **Sensitive report data (GDPR Art. 10)** — when enabled, the recruiter
   receives a report with Art. 10 (criminal-conviction) data removed, while the
   candidate sees the full version.

The flow spans three packages: **shared** (JWT utilities), **backgroundcheck**
(report viewer + decision UI + automation), and **refapp** (candidate identity,
redirect orchestration, decision recording).

> **Route consolidation (Ref-22102) & session cookie (Ref-22083).** The three
> former candidate routes (`candidate-identification`, `review`, `review-callback`
> / `review-identity-gate`) are now a single page at
> `/candidate-portal/background-check/:accessId`. The candidate links we email/SMS
> point directly at it (`getRelativeUrlToBackgroundCheckIdentityVerification` etc.
> in [`refapp-urls.ts`](../refapp/server/utils/emails/refapp-urls.ts)).
> `GetCandidateBackgroundCheckHttp` resolves the candidate-facing state and, for
> identity-collection outcomes, gates them behind a `bgc_candidate_session` cookie.
> That state model — and exactly which states require a valid cookie or live eID
> verification — is documented in
> [`candidateBackgroundCheckStates.html`](candidateBackgroundCheckStates.html).
> The sequences below focus on the cross-package choreography and JWT tokens.
>
> **Where the routing lives.** The unified page is a **client route**: its action
> (in [`client/routes.tsx`](../refapp/client/routes.tsx)) runs in the browser,
> calls the server HTTP APIs, and handles `report-ready` (a client-side
> `location.href` redirect to the BGC app) and `not-found`. The **server-side**
> routes are the HTTP APIs it calls — `GET /get-candidate-background-check` plus
> the identity/decision `POST` endpoints (registered in
> [`background-checks-method-registrations.ts`](../refapp/server/background-checks/background-checks-method-registrations.ts))
> — where the state resolution and cookie gate actually run. Legacy links still
> resolve: `/background-check/review/:accessCode` via a **server-side 301**
> (`backgroundCheckReviewRedirectHandler`), and `candidate-identification` /
> `review-callback` / `review-identity-gate` via **client-side** redirects. All
> can be removed once pre-Ref-22102 email links have expired.

## Part 1 — Start the background check

The recruiter starts the check; the candidate verifies their identity with an
eID; the check transitions to `ordered` and is sent to the BGC app, which runs
the automatic background check (ABC, see
[`automatic-background-check.md`](../backgroundcheck/docs/automatic-background-check.md)).

```mermaid
sequenceDiagram
    participant C as Candidate browser
    participant R as Refapp server
    participant E as eID provider<br/>(BankID/MitID/FTN via ZignSec, Freja via OIDC)
    participant B as BGC App

    Note over R: Recruiter calls startBackgroundCheck<br/>(identity method = candidate-eidentity)<br/>status → awaiting-candidate-supplemental-data
    R->>C: Invite email / SMS with link

    C->>R: GET /candidate-portal/background-check/:accessCode<br/>(old /background-check/candidate-identification/:accessCode redirects here)
    R->>C: identity-collection-required + verification options<br/>(GetCandidateBackgroundCheckHttp)

    C->>R: Open /oauth/{bankidse|bankidno|mitid|ftn|frejaid}
    R->>E: Redirect to provider authorize endpoint
    E->>R: Callback /oauth/{provider}/callback
    Note over R: respondWithCallbackScript signs a JWT<br/>envelope (verifiedPersonalData, PIN)
    R->>C: Posts signed envelope to opener / BroadcastChannel

    C->>R: SubmitCandidateIdentificationForRequestHttp
    Note over R: Verify claims — write PIN, pinType,<br/>verifiedPersonalData, personalIdentifierSource<br/>status awaiting-candidate-supplemental-data → ordered
    R->>C: Set-Cookie bgc_candidate_session (HttpOnly, 24h)<br/>so the outcome stays visible on return (Ref-22083)

    Note over R: ordered transition fires bgcStatusChange →<br/>sendCandidateToBgc → storeCandidateInBgc
    R->>B: POST /api/find-or-create-candidate<br/>(Bearer BGC_API_CONFIG.token)
    Note over B: FindOrCreateCandidate creates the check and<br/>defers runBackgroundCheckAutomation<br/>(see automatic-background-check.md)
```

> **Other identity methods** — when the project's identity method is
> `recruiter-manual` the recruiter supplies the PIN and the check goes straight
> to `ordered` (no candidate eID step). `International`-jurisdiction packages
> skip identity entirely.

### Start status transitions

```
(recruiter starts check, identity method = candidate-eidentity)
  status → awaiting-candidate-supplemental-data, invite sent

(candidate completes eID verification)
  submitCandidateIdentificationForRequest
  status awaiting-candidate-supplemental-data → ordered
  PIN, pinType, verifiedPersonalData, personalIdentifierSource written

(ordered transition)
  bgcStatusChange → sendCandidateToBgc → storeCandidateInBgc
  POST /api/find-or-create-candidate → BGC FindOrCreateCandidate
  → runBackgroundCheckAutomation (see automatic-background-check.md)
```

## Part 2 — Review & share

### Redirect Flow

```mermaid
sequenceDiagram
    participant Email
    participant Page as Refapp unified page<br/>/candidate-portal/background-check/:accessId
    participant BGC as BGC App<br/>/shared/:shareId?approval=true

    Email->>Page: Candidate clicks review link<br/>(old /review, /review-callback, /review-identity-gate redirect here)

    alt Pending review (candidate-review status)

        Note over Page: Route action calls<br/>GetCandidateBackgroundCheckHttp

        alt legalChecks or OCSSD gate → report-review-required ¹
            Page->>Page: IdentityVerificationGate — candidate verifies eID<br/>(SubmitBackgroundCheckReportReviewVerifiedIdentityHttp)
            Note over Page: On success, builds authenticated report URL<br/>(Share URL JWT, purpose=candidate-review, callbackUrl embedded)
        else approval-no-gate (requiresCandidateApproval, no legalChecks) → report-ready
            Note over Page: Route action returns report-ready with reportUrl<br/>(Share URL JWT) and redirects the browser
        end

        Page->>BGC: Redirect with ?auth=<Share URL JWT>&approval=true&locale=xx

        Note over BGC: Verifies Share URL JWT<br/>Decrypts claims (sub, userId,<br/>purpose, callbackUrl)<br/>Renders report with approval footer

        BGC->>BGC: Candidate reviews report
        BGC->>BGC: Clicks Approve or Decline
        BGC->>BGC: Confirmation dialog

        Note over BGC: Calls SubmitCandidateDecision<br/>Server verifies original JWT,<br/>builds Decision Redirect Token<br/>(5 min expiry)

        BGC->>Page: Redirect to callbackUrl (unified page)<br/>?token=<Decision Redirect Token>

        Note over Page: ?token present → route action calls<br/>SubmitCandidateBackgroundCheckReportDecisionHttp<br/>with accessCode + token

        Note over Page: Server verifies token<br/>(signature + expiry + sub cross-check),<br/>extracts decision from claims,<br/>transitions BGC status,<br/>logs decision, notifies recruiter

        Page->>Page: submitted (approved / report-declined) → EndOfFlow

    else Already decided (approved or declined)
        Note over Page: GetCandidateBackgroundCheckHttp returns<br/>submitted (approved / report-declined) directly

        Page->>Page: EndOfFlow page (already-submitted with decision)
    end
```

> **¹ eID identity gate** — When a background check package includes legal
> checks, the candidate must verify their identity via eID before viewing
> the report.

### JWT Tokens

Two JWT tokens are used in this flow. Both derive their signing (and
encryption) keys from the same shared secret via HKDF-SHA256, but with
different `info` strings to produce distinct keys.

The shared secret is `BGC_API_CONFIG.token` in refapp and
`BGC_INTERNAL_API_CONFIG.token` in the BGC app (same value, different
env var names). This is distinct from `REFAPP_API_CONFIG`, which is
used only for BGC-to-refapp bearer-token API authentication.

#### Share URL JWT

Authenticates the candidate's session in the BGC app and carries the callback
URL back to refapp.

| Property | Value |
|---|---|
| **Created by** | Refapp (`buildAuthenticatedReportUrl` in [`background-checks-queries.ts`](../refapp/server/background-checks/background-checks-queries.ts)) |
| **Verified by** | BGC (`verifyShareUrlJwt` in [`share-url-jwt.ts`](../shared/server/share-url-jwt.ts)) |
| **Expiry** | 1 hour (3600 s) |
| **Signing algorithm** | HS256 |
| **Signing key derivation** | `HKDF-SHA256(secret, "share-url-jwt-signing-v1")` |
| **Encryption** | Sensitive claims encrypted with AES-256-GCM (JWE compact, `dir` + `A256GCM`) |
| **Encryption key derivation** | `HKDF-SHA256(secret, "share-url-jwt-encryption-v1")` |
| **Travels in** | URL query parameter `?auth=<token>` |

**Claims (outer JWT, visible):**

| Claim | Description |
|---|---|
| `iss` | Platform identifier (`app`, `sec`, `test`, `local`) |
| `aud` | Always `"bgc"` |
| `enc` | JWE-encrypted blob containing sensitive claims |

**Claims (inner JWE, encrypted):**

| Claim | Description |
|---|---|
| `sub` | `refappBackgroundCheckId` |
| `userId` | `"CANDIDATE"` for candidate-review, a recruiter userId for recruiter-view, or an operator userId for operator-view |
| `purpose` | `"recruiter-view"`, `"candidate-review"`, or `"operator-view"` |
| `callbackUrl` | Refapp URL to redirect back to after decision (only for `candidate-review`) |

> **Note:** Tokens created before the `purpose` claim became mandatory default
> to `"recruiter-view"` for backward compatibility.

#### Decision Redirect Token

Short-lived token encoding the candidate's approve/decline decision. Created by
the BGC app after the candidate confirms their choice, and verified by refapp
on the redirect back.

| Property | Value |
|---|---|
| **Created by** | BGC (`buildDecisionRedirectToken` in [`decision-redirect-token.ts`](../shared/decision-redirect-token.ts), called from [`files.ts`](../backgroundcheck/server/collections/files.ts)) |
| **Verified by** | Refapp (`verifyDecisionRedirectToken` in [`decision-redirect-token.ts`](../refapp/server/utils/decision-redirect-token.ts)) |
| **Expiry** | 5 minutes (300 s) |
| **Signing algorithm** | HS256 |
| **Signing key derivation** | `HKDF-SHA256(secret, "decision-redirect-token-signing-v1")` |
| **Encryption** | None (short-lived, HTTPS only) |
| **Travels in** | URL query parameter `?token=<token>` on the callback URL |

**Claims:**

| Claim | Description |
|---|---|
| `iss` | Platform identifier |
| `sub` | `refappBackgroundCheckId` |
| `decision` | `"approved"` or `"declined"` |

### HTTP Endpoints

#### GET `/get-candidate-background-check`

`GetCandidateBackgroundCheckHttp` is the single endpoint behind the unified
candidate page. Given an `accessCode` it returns the candidate-facing state
(`identity-collection-required`, `report-review-required`, `report-ready`,
`submitted`, or `not-found`). It replaces the former
`GetBackgroundCheckReportReviewDataHttp` / `/get-background-check-report-review`
endpoint used before Ref-22102. Its full state model, and which states require a
valid session cookie or live eID verification, are documented in
[`candidateBackgroundCheckStates.html`](candidateBackgroundCheckStates.html).

#### POST `/submit-candidate-background-check-report-decision`

Processes a signed Decision Redirect Token from the BGC app.

| Input | Description |
|---|---|
| `accessCode` | Identifies the BGC entry; cross-validated against the token's `sub` claim |
| `token` | Decision Redirect Token (JWT) from the BGC app |

The decision (`approved` / `declined`) is extracted from the verified token's claims,
not from client input. This prevents tampering with the decision.

**Verification steps:**

1. Derive signing key from shared secret via `HKDF-SHA256(secret, "decision-redirect-token-signing-v1")`
2. Verify JWT signature and expiry (5-minute window)
3. Look up BGC entry by `accessCode` with a candidate-review status
4. Cross-validate: token `sub` must match BGC entry `_id`
5. Extract `decision` from token claims
6. Transition BGC status (approved &rarr; base status, declined &rarr; `candidate-review-declined`)

### Background Check Status Transitions

When the candidate makes a decision, the background check status transitions:

- **Approved**: `*-candidate-review` status reverts to its base status
  (e.g., `completed-ok-candidate-review` &rarr; `completed-ok`)
- **Declined**: status becomes `candidate-review-declined`

See [`background-check-entities.md`](../refapp/imports/api/entities/background-check-entities.md)
for the full state diagram.

## Part 3 — Sensitive report data (GDPR Art. 10)

Under GDPR Art. 10, criminal-conviction data is shown only to the candidate. The
recruiter's report has that data removed; the candidate sees the full report.

> PRs: [#5363](https://github.com/ref-app/refapp/pull/5363),
> Favro: [Ref-21014](https://favro.com/organization/3483439b3ba44f8b9cfa0c46/e57278728abcb8c86239273a?card=Ref-21014)

When a background check package has `onlyCandidateCanSeeSensitiveData` enabled,
the report is split into two views:

- **Main report (redacted)** — all categories preserved; non-judgment
  categories (personalDetails, economy) pass through unchanged. Judgment checks
  with anomalies have their content replaced and document-link handling varies
  by viewer: recruiter sees "NOT APPROVED"/"APPROVED" with links stripped;
  candidate (with approval) sees "Attached appendix" with an `#appendix` link
  added; operator sees a generic message with links stripped.
- **Appendix (sensitive data)** — contains only the judgments category with
  full content and document links.

Which views a viewer receives depends on their JWT `purpose` and the package's
`candidateApproval` flag.

### Report View Resolution

```mermaid
flowchart TD
    Start{onlyCandidateCanSeeSensitiveData?}
    Start -->|No| FullReport["Return full report as main\n(no appendix)"]

    Start -->|Yes| Purpose{JWT purpose?}

    Purpose -->|recruiter-view| Recruiter["main = redacted\nappendix = absent"]
    Purpose -->|operator-view| Operator["main = redacted\nappendix = full judgments"]
    Purpose -->|candidate-review| Approval{candidateApproval?}

    Approval -->|No| CandNoApproval["main = absent\nappendix = full judgments"]
    Approval -->|Yes| CandApproval["main = redacted\nappendix = full judgments"]
```

### WebReport Structure

```mermaid
flowchart LR
    subgraph WebReport
        Header["header\n(candidate details,\ndate, package info)"]
        Main["main?\n(WebReportContents)"]
        Appendix["appendix?\n(WebReportContents)"]
    end

    subgraph "main (redacted)"
        M_PD["personalDetails\n(full)"]
        M_EC["economy\n(full)"]
        M_JD["judgments\n(status only,\ncontent replaced,\ndocument links vary by viewer)"]
    end

    subgraph "appendix (sensitive)"
        A_JD["judgments\n(full content +\ndocument links)"]
    end

    Main --> M_PD
    Main --> M_EC
    Main --> M_JD
    Appendix --> A_JD
```

### Viewer Summary

| Viewer | `main` | `appendix` | File access |
|--------|--------|------------|-------------|
| **Recruiter** | Redacted | Absent | Blocked from `actapublica-hit` files |
| **Candidate** (no approval) | Absent | Full judgments | Full |
| **Candidate** (with approval) | Redacted | Full judgments | Full |
| **Operator** | Redacted | Full judgments (UI toggle) | Full |

When both `main` and `appendix` are present, the BGC app renders a
`ReportViewTabs` toggle (ButtonGroup) to switch between the two views.

### Redaction Details

Three distinct redaction functions in `report-view-utils.ts` handle the
judgments category differently per viewer. All three pass non-judgment
categories through unchanged.

**`createRecruiterRedactedContents`** (`recruiter-view`):

- Judgment checks with `status: "completed-anomalies"`:
  - **Preserved:** check type, title, status, lastUpdateDate
  - **Replaced:** `resultText` with "**NOT APPROVED**" + explanatory message
  - **Removed:** `documentLinks` array
- Judgment checks without anomalies (except `awaiting`):
  - **Replaced:** `resultText` with "**APPROVED**" + explanatory message
  - **Removed:** `documentLinks` array
- `awaiting` checks pass through unchanged

**`createCandidateRedactedContents`** (`candidate-review`, with approval):

- Judgment checks with `status: "completed-anomalies"`:
  - **Preserved:** check type, title, status, lastUpdateDate
  - **Replaced:** `resultText` with "Attached appendix"
  - **Added:** a `documentLinks` entry pointing to `#appendix`
- Judgment checks without anomalies pass through unchanged

**`createRedactedContents`** (`operator-view`):

- Judgment checks with `status: "completed-anomalies"`:
  - **Preserved:** check type, title, status, lastUpdateDate
  - **Replaced:** `resultText` with a generic message directing to the
    candidate's link
  - **Removed:** `documentLinks` array
- Judgment checks without anomalies pass through unchanged

## Key Files

### shared/

| File | Purpose |
|---|---|
| [`server/share-url-jwt.ts`](../shared/server/share-url-jwt.ts) | `buildShareUrlJwt` / `verifyShareUrlJwt` + type definitions |
| [`server/shared-jwt-utils.ts`](../shared/server/shared-jwt-utils.ts) | Low-level JWT helpers: `deriveKey`, `signJwt`, `verifyJwt`, `encryptClaims`, `decryptClaims` |
| [`decision-redirect-token.ts`](../shared/decision-redirect-token.ts) | `buildDecisionRedirectToken` / `verifyDecisionRedirectToken` |

### backgroundcheck/

| File | Purpose |
|---|---|
| [`client/pages/shared/SharedFilePage.tsx`](../backgroundcheck/client/pages/shared/SharedFilePage.tsx) | Report viewer; renders approval footer + confirmation dialog when `?approval=true` |
| [`client/components/ApprovalFooter.tsx`](../backgroundcheck/client/components/ApprovalFooter.tsx) | Sticky Approve/Decline button bar |
| [`client/components/ConfirmDialog.tsx`](../backgroundcheck/client/components/ConfirmDialog.tsx) | Modal confirmation before submitting decision |
| [`server/collections/files.ts`](../backgroundcheck/server/collections/files.ts) | `submitCandidateDecision` server handler: verifies original JWT, builds Decision Redirect Token, returns redirect URL; blocks recruiter access to `actapublica-hit` files when `onlyCandidateCanSeeSensitiveData` is enabled |
| [`server/utils/report-view-utils.ts`](../backgroundcheck/server/utils/report-view-utils.ts) | `resolveReportViews`: determines main/appendix split by role; `createRecruiterRedactedContents` / `createCandidateRedactedContents` / `createRedactedContents` (operator) / `createAppendixContents` |
| [`client/components/ReportViewTabs.tsx`](../backgroundcheck/client/components/ReportViewTabs.tsx) | ButtonGroup toggle between redacted main report and appendix views |
| [`imports/api/report-types.ts`](../backgroundcheck/imports/api/report-types.ts) | `WebReport` schema with optional `main` and `appendix` (WebReportContents) |
| [`imports/api/files.ts`](../backgroundcheck/imports/api/files.ts) | `SubmitCandidateDecision` HTTP API definition |
| [`server/background-checks/find-or-create-background-check-methods.ts`](../backgroundcheck/server/background-checks/find-or-create-background-check-methods.ts) | `FindOrCreateCandidate` handler: creates the check, defers automation |
| [`server/background-checks/runBackgroundCheckAutomation.ts`](../backgroundcheck/server/background-checks/runBackgroundCheckAutomation.ts) | Automation runner: executes the configured checks and auto-publishes the report |

### refapp/

| File | Purpose |
|---|---|
| [`server/utils/decision-redirect-token.ts`](../refapp/server/utils/decision-redirect-token.ts) | Symlink &rarr; `shared/decision-redirect-token.ts`; provides `buildDecisionRedirectToken` / `verifyDecisionRedirectToken` |
| [`server/background-checks/build-candidate-approval-redirect-url.ts`](../refapp/server/background-checks/build-candidate-approval-redirect-url.ts) | Builds the initial redirect URL with Share URL JWT |
| [`server/background-checks/background-checks-queries.ts`](../refapp/server/background-checks/background-checks-queries.ts) | `buildAuthenticatedReportUrl`: creates + signs the Share URL JWT |
| [`server/background-checks/background-checks-methods-server.ts`](../refapp/server/background-checks/background-checks-methods-server.ts) | Start + review server methods: `startBackgroundCheck`, `determineIdentityVerificationSetup`, `submitCandidateIdentificationForRequest`, `bgcStatusChange`, `sendCandidateToBgc` (Part 1); `getCandidateBackgroundCheck`, `submitCandidateBackgroundCheckReportDecision` HTTP handlers (Part 2); the `bgc_candidate_session` cookie (`mintCandidateSession`, `getHasValidSession`) |
| [`server/background-checks/send-identity-verification-request.ts`](../refapp/server/background-checks/send-identity-verification-request.ts) | Builds the candidate invite with the identity-verification link |
| [`client/pages/background-check/BackgroundCheckCandidateSubmitIdentificationPage.tsx`](../refapp/client/pages/background-check/BackgroundCheckCandidateSubmitIdentificationPage.tsx) | Candidate eID identity page |
| [`server/integrations/refapp-bgc/refappBgc.ts`](../refapp/server/integrations/refapp-bgc/refappBgc.ts) | `storeCandidateInBgc`: POST to BGC `/api/find-or-create-candidate` |
| [`server/integrations/identity-verification/verification.ts`](../refapp/server/integrations/identity-verification/verification.ts) | `respondWithCallbackScript`: signs + posts the eID envelope |
| [`client/pages/background-check/BackgroundCheckCandidatePage.tsx`](../refapp/client/pages/background-check/BackgroundCheckCandidatePage.tsx) | Unified candidate page at `/candidate-portal/background-check/:accessId`. Pure renderer that dispatches by resolved state: `IdentityVerificationGate` (report-review-required), `BackgroundCheckCandidateSubmitIdentificationPage` (identity-collection / submitted identity outcomes), or `EndOfFlow` (submitted approved / report-declined). `not-found` and `report-ready` are handled by the route action |
| [`server/background-checks/candidate-background-check-result.ts`](../refapp/server/background-checks/candidate-background-check-result.ts) | Resolves the candidate-facing state (`resolveCandidateState`, `buildCandidateBackgroundCheckResult`) and applies the session-cookie gate (`buildCandidateBackgroundCheckResultGated`). See [`candidateBackgroundCheckStates.html`](candidateBackgroundCheckStates.html) |
| [`imports/api/background-check-methods.ts`](../refapp/imports/api/background-check-methods.ts) | `GetCandidateBackgroundCheckHttp` and `SubmitCandidateBackgroundCheckReportDecisionHttp` API definitions |
| [`imports/utils/routes/routeDefinitions.ts`](../refapp/imports/utils/routes/routeDefinitions.ts) | `CandidateBackgroundCheckPage` route (`/candidate-portal/background-check/:accessId`, accepts `?token=`); the old `candidate-identification` / `review` / `review-callback` / `review-identity-gate` routes are `@deprecated` and redirect here |
