> ## Documentation Index
> Fetch the complete documentation index at: https://docs.safeonlineexam.com/llms.txt
> Use this file to discover all available pages before exploring further.

# LTI 1.3 Routes and Public Endpoints — Safe Online Exam

> Reference for Safe Online Exam's public status, health, JWKS, LTI config, OIDC initiation, and signed LTI 1.3 launch endpoints used by Canvas.

Safe Online Exam exposes a set of public and LTI 1.3 routes that Canvas and browsers use during tool discovery, OIDC login initiation, and signed launch validation. This page documents every route in the **Public and LTI** section of the route reference, including the parameters each endpoint validates, who calls it, and what authentication is required.

<Note>
  Routes marked with a **compatibility contract** — `GET /lti/config`, `GET|POST /lti/login`, `GET|POST /lti/launch`, and `GET /.well-known/jwks.json` — are stable public surfaces. Canvas registrations and SEB clients depend on them remaining at the same paths across upgrades.
</Note>

***

## Service Status and Canvas Fallback

### `GET /`

Returns the Safe Online Exam service-status shell page. In normal operation this route is the application root; it renders a lightweight React view that includes optional testbed diagnostics when the testbed feature is enabled.

**Caller:** Browser, operator, health monitoring.

**Auth:** None.

**Response:** `200 text/html` — the React app shell with view `service-status`.

***

### `GET /login`

Returns a plain HTML fallback page that instructs users to launch the tool from Canvas. Canvas may redirect unauthenticated users here when an existing session cookie has expired or when a browser navigates directly to the tool URL.

**Caller:** Browser (direct navigation or Canvas redirect).

**Auth:** None.

**Response:** `200 text/html` — static fallback instructing the user to open Canvas.

***

## Health Endpoints

The service exposes three independent health routes covering the HTTP layer, the login path, and the static JavaScript delivery path. All three return the same lightweight JSON body.

<Tabs>
  <Tab title="GET /health">
    **Purpose:** Primary liveness check. Used by load balancers, Cloud Run, and Docker Compose health checks.

    **Caller:** Infrastructure health probes.

    **Auth:** None.

    **Response:**

    ```json theme={null}
    { "status": "UP" }
    ```
  </Tab>

  <Tab title="GET /login/health">
    **Purpose:** Health check scoped to the Canvas login path. Confirms the login route is reachable.

    **Caller:** Infrastructure health probes.

    **Auth:** None.

    **Response:**

    ```json theme={null}
    { "status": "UP" }
    ```
  </Tab>

  <Tab title="GET /js/health">
    **Purpose:** Health check for the static JavaScript delivery path that serves the Canvas detector script.

    **Caller:** Infrastructure health probes.

    **Auth:** None.

    **Response:**

    ```json theme={null}
    { "status": "UP" }
    ```
  </Tab>
</Tabs>

<Tip>
  Use `GET /ready` (not `GET /health`) for readiness checks that must confirm database migrations are applied. `/ready` returns `503` if any checked-in migration has not been run.
</Tip>

***

## Setup Handoff Pages

### `GET /setup` and `GET /setup/guide`

Render a role-oriented setup guide for Canvas administrators installing the tool. Both routes return the React app shell with view `admin-setup`. The `/setup/guide` variant enables a more detailed walkthrough mode (`detailed: true`); `/setup` shows the summary view.

The app shell includes the service's current `toolUrl`, `healthUrl`, `ltiConfigUrl`, `jwksUrl`, and `detectorUrl` values so that the person reading the page can copy the correct endpoint URLs into Canvas without referencing separate documentation.

**Caller:** Browser (Canvas or service administrator during initial configuration).

**Auth:** None.

**Response:** `200 text/html` — React app shell with view `admin-setup`.

***

## LTI Signing Keys

### `GET /.well-known/jwks.json`

Returns the service's active RSA public key set in JSON Web Key Set format. Canvas fetches this endpoint when validating the tool's registration and when verifying any signed assertion the service issues during the OIDC flow.

**Caller:** Canvas LTI platform (during developer key registration and on-demand key refresh).

**Auth:** None — this endpoint is intentionally public.

**Compatibility contract:** This path must remain stable. Canvas caches the JWKS URL from the tool's developer key registration; changing the path requires updating every Canvas installation.

**Response:**

```json theme={null}
{
  "keys": [
    {
      "kty": "RSA",
      "alg": "RS256",
      "use": "sig",
      "kid": "<key-id>",
      "n": "<base64url-encoded modulus>",
      "e": "<base64url-encoded exponent>"
    }
  ]
}
```

<ResponseField name="keys" type="array" required>
  Array of public JWK objects. The service only publishes RSA keys. Each key has at minimum `kty`, `alg`, `use`, `kid`, `n`, and `e` fields. The private exponent `d` is never included.
</ResponseField>

The service enforces a minimum RSA modulus of **2048 bits** at startup. If Canvas presents a platform signing key smaller than 2048 bits during a launch, the token validation stage rejects it and returns an error page explaining the requirement to the user.

***

## LTI Configuration Document

### `GET /lti/config`

Returns the Canvas LTI 1.3 JSON configuration document that a Canvas administrator uses to register or update the tool's developer key. The document describes both the course navigation placement (for instructors and students) and the account navigation placement (for root-account administrators).

**Caller:** Canvas administrator during developer key setup, or automated registration tooling.

**Auth:** None — this document contains only public tool metadata.

**Compatibility contract:** This path must remain stable.

**Response:** `200 application/json`

<Accordion title="Full configuration document shape">
  ```json theme={null}
  {
    "title": "Safe Online Exam",
    "description": "Require Safe Online Exam with Safe Exam Browser for Canvas Classic Quizzes and New Quizzes.",
    "oidc_initiation_url": "https://<tool-url>/lti/login",
    "target_link_uri": "https://<tool-url>/lti/launch",
    "public_jwk_url": "https://<tool-url>/.well-known/jwks.json",
    "scopes": [],
    "extensions": [
      {
        "platform": "canvas.instructure.com",
        "privacy_level": "public",
        "settings": {
          "text": "Safe Online Exam",
          "placements": [
            {
              "placement": "course_navigation",
              "message_type": "LtiResourceLinkRequest",
              "target_link_uri": "https://<tool-url>/lti/launch",
              "visibility": "members",
              "default": "enabled",
              "enabled": true,
              "custom_fields": { ... }
            },
            {
              "placement": "account_navigation",
              "message_type": "LtiResourceLinkRequest",
              "target_link_uri": "https://<tool-url>/lti/launch",
              "text": "Safe Online Exam Admin",
              "visibility": "admins",
              "required_permissions": "manage_course_content_edit",
              "default": "enabled",
              "enabled": true,
              "display_type": "full_width_in_context",
              "root_account_only": true,
              "custom_fields": { ... }
            }
          ]
        }
      }
    ],
    "custom_fields": { ... }
  }
  ```
</Accordion>

The `visibility` of the course navigation placement is controlled by the `LTI_COURSE_NAVIGATION_VISIBLE_TO_STUDENTS` configuration flag. When set to `false`, the placement is visible only to Canvas admins and teachers; when unset or `true`, it is visible to all course members.

**Custom fields requested per placement:**

<Tabs>
  <Tab title="Course placement">
    | Field                           | Canvas substitution                   |
    | ------------------------------- | ------------------------------------- |
    | `canvas_user_id`                | `$Canvas.user.id`                     |
    | `canvas_membership_roles`       | `$Canvas.membership.roles`            |
    | `canvas_lis_membership_roles`   | `$com.Instructure.membership.roles`   |
    | `seb_launch_surface`            | `"course"` (literal)                  |
    | `canvas_course_id`              | `$Canvas.course.id`                   |
    | `canvas_membership_permissions` | `$Canvas.membership.permissions<...>` |
  </Tab>

  <Tab title="Account admin placement">
    | Field                               | Canvas substitution               |
    | ----------------------------------- | --------------------------------- |
    | `canvas_user_id`                    | `$Canvas.user.id`                 |
    | `seb_launch_surface`                | `"account_admin"` (literal)       |
    | `canvas_account_id`                 | `$Canvas.account.id`              |
    | `canvas_root_account_id`            | `$Canvas.rootAccount.id`          |
    | `canvas_user_is_root_account_admin` | `$Canvas.user.isRootAccountAdmin` |
  </Tab>
</Tabs>

<Warning>
  A Canvas administrator must refresh the LTI Developer Key configuration from this endpoint any time the tool URL changes or a new Canvas deployment is added to the service. Stale registrations will produce `CanvasLtiConfigurationError` at launch time if required signed substitution fields are absent.
</Warning>

***

## OIDC Initiation

### `GET /lti/login` and `POST /lti/login`

Handles the LTI 1.3 OIDC third-party initiation request. Canvas sends this request before the signed launch to establish a nonce and state token that the service will later verify at `/lti/launch`. Both GET and POST methods are accepted; the service normalizes query parameters and body fields identically.

**Caller:** Canvas LTI platform — the first step in every LTI launch.

**Auth:** None at this stage (the request is from Canvas, not a logged-in user). The service validates issuer and client ID before creating state.

**Compatibility contract:** This path must remain stable.

**Rate limiting:** Process-local and distributed IP-based admission budgets apply. Exceeding the budget returns `429` with a `retry-after: 60` header.

**Request parameters** (query string for GET, form body for POST):

<ParamField query="iss" type="string" required>
  Canvas issuer URL. Must exactly match the configured `LTI_ISSUER` value (e.g., `https://canvas.instructure.com`).
</ParamField>

<ParamField query="login_hint" type="string" required>
  Opaque Canvas user hint used to pre-select the user in Canvas's authorization step. Passed through unchanged to Canvas.
</ParamField>

<ParamField query="target_link_uri" type="string" required>
  The URI Canvas wants the service to launch. Must match the tool origin and be either `/lti/launch` or a `/seb/launch/:contentId` path. Maximum 2048 characters.
</ParamField>

<ParamField query="client_id" type="string">
  Optional Canvas LTI client ID. When present, must match `LTI_CLIENT_ID`.
</ParamField>

<ParamField query="lti_deployment_id" type="string">
  Optional Canvas deployment ID. When deployment ID checking is enabled (`LTI_DEPLOYMENT_ID_CHECKING_ENABLED` is not `false`), this value must appear in the `LTI_DEPLOYMENT_ID` allowlist. When absent and checking is enabled, the login is rejected.
</ParamField>

<ParamField query="lti_message_hint" type="string">
  Optional Canvas message hint forwarded unchanged to the Canvas authorization URL. Maximum 8192 characters.
</ParamField>

**On success:**

The service:

1. Validates envelope size bounds on all parameters.
2. Verifies `iss`, optional `client_id`, `target_link_uri`, and optional `lti_deployment_id`.
3. Generates a cryptographic nonce (`randomUUID()`).
4. Creates a browser transaction cookie: a short-lived `HttpOnly` secure cookie containing a transaction ID and binding hash, scoped to the launch lifetime.
5. Encrypts and stores state (nonce, target URI, issuer, transaction binding, optional deployment ID) in PostgreSQL with a **10-minute TTL**.
6. Redirects `302` to Canvas's OIDC authorization endpoint with:
   * `scope=openid`
   * `response_type=id_token`
   * `response_mode=form_post`
   * `prompt=none`
   * `redirect_uri=<toolUrl>/lti/launch`
   * `nonce`, `state`, `login_hint`, and any forwarded hints

**Error responses:**

| Status | Cause                                                                                        |
| ------ | -------------------------------------------------------------------------------------------- |
| `400`  | Missing required parameters, invalid issuer, disallowed target URI, or unknown deployment ID |
| `429`  | Rate limit exceeded                                                                          |

***

## Signed LTI Launch

### `POST /lti/launch`

Receives and validates the Canvas-signed LTI ID token after the OIDC authorization step. This is the most security-critical endpoint in the service; it performs the complete validation chain before creating a verified LTI principal in the session.

**Caller:** Canvas LTI platform — Canvas POSTs an HTML form to this URL as the final step of every LTI launch.

**Auth:** Canvas RS256-signed ID token (validated by the service against the configured Canvas JWKS).

**Compatibility contract:** This path must remain stable.

**Rate limiting:** Process-local and distributed IP-based budgets apply independently from the login budget.

**Request body** (form POST):

<ParamField body="id_token" type="string" required>
  The RS256-signed LTI ID token issued by Canvas. The service validates signing, issuer, audience, nonce, token age, timestamps, LTI version and message type, deployment ID, target link URI, and the OIDC state tuple.
</ParamField>

<ParamField body="state" type="string" required>
  The encrypted state token created during `/lti/login`. Used to look up the stored nonce, target URI, and browser transaction binding.
</ParamField>

<ParamField body="error" type="string">
  When Canvas itself encounters an error, it POSTs an `error` field instead of an ID token. The service returns `400` with an explanatory HTML page.
</ParamField>

**Validation chain (executed in order):**

<Steps>
  <Step title="Envelope check">
    Verifies that `state` and `id_token` meet bounded-size requirements before any cryptographic work.
  </Step>

  <Step title="Rate admission">
    Consumes process-local and distributed IP admission budgets. Returns `429` if exhausted.
  </Step>

  <Step title="State peek">
    Decrypts the `state` token from PostgreSQL to retrieve the stored nonce and browser transaction binding. The state record has not been consumed yet.
  </Step>

  <Step title="Browser transaction cookie">
    Verifies that the browser's transaction cookie matches the binding hash stored in state. Fails if the cookie is absent (e.g., blocked third-party cookies) or does not match.
  </Step>

  <Step title="Distributed state admission">
    Consumes a per-state distributed admission budget before proceeding to token validation, preventing distributed brute-force attempts on a single state token.
  </Step>

  <Step title="Token validation">
    Validates the ID token RS256 signature against the Canvas JWKS, then checks issuer, audience, nonce, token age, issued/expiry timestamps, LTI version, message type, and deployment ID.
  </Step>

  <Step title="Login–launch binding">
    Confirms that the token's issuer, deployment ID, and target link URI match the values stored in the OIDC state. Prevents state substitution between logins.
  </Step>

  <Step title="Atomic state claim">
    Atomically marks the state token as consumed in PostgreSQL. Prevents replay of the same state/token pair from a second browser.
  </Step>

  <Step title="Session regeneration">
    Calls Express `regenerateSession` to issue a fresh session ID before writing any principal, preventing session fixation.
  </Step>

  <Step title="Principal creation">
    Creates a `VerifiedLtiPrincipal` from the validated launch data — either a course principal (instructor/student) or an account admin principal — and writes it to the new session.
  </Step>
</Steps>

**After validation — role routing:**

| Role                                                                          | Destination                                                                         |
| ----------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| Root-account administrator (signed `account_admin` surface + root-admin flag) | Admin dashboard shell (`view: "admin"`) or admin OAuth authorization prompt         |
| Instructor (`TeacherEnrollment`, `DesignerEnrollment`)                        | Course management shell (`view: "teacher"`)                                         |
| Student (`StudentEnrollment`)                                                 | SEB download or student listing shell (`view: "student"` or `view: "seb-required"`) |
| `LtiDeepLinkingRequest` message type                                          | `410` — deep linking is not supported                                               |
| Any other role                                                                | `200` with a "role not authorized" fallback page                                    |

**Error responses:**

| Status | Cause                                                                                                                                                                                     |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | Missing `id_token`, invalid envelope, browser transaction mismatch, token validation failure, issuer/deployment mismatch, or `CanvasLtiConfigurationError` (missing signed substitutions) |
| `429`  | Rate limit exceeded                                                                                                                                                                       |

***

### `GET /lti/launch`

Handles same-origin browser reloads and redirects within an active verified session. Canvas never calls this route directly — it is used by the React client when the user navigates within the already-authenticated tool (e.g., after Canvas OAuth completes and the tool redirects back with `?connected=1`).

**Caller:** Browser (same-origin navigation within an active tool session).

**Auth:** Existing verified LTI principal in the Express session. Returns `302 /login` if no principal is present.

**Fetch Metadata guard:** Requests where `sec-fetch-dest` is `iframe` and `sec-fetch-site` is `cross-site` are rejected with `403`. This prevents Canvas from re-entering a live session through a cross-site iframe navigation.

**Query parameters:**

<ParamField query="course_id" type="string">
  Optional. When present, must exactly match `principal.courseId`. Returns `403` on mismatch.
</ParamField>

<ParamField query="user_id" type="string">
  Optional. When present, must exactly match `principal.canvasUserId`. Returns `403` on mismatch.
</ParamField>

<ParamField query="connected" type="string">
  Optional. Signal from the OAuth callback that Canvas authorization just completed. Not validated; the role-routing logic re-reads the principal from session.
</ParamField>

**Response:** Same role-routing logic as `POST /lti/launch` but sources the principal entirely from the existing session rather than from a new token.
