> ## 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.

# Canvas OAuth Authorization Routes — Safe Online Exam API

> Reference for Safe Online Exam's Canvas OAuth endpoints — instructor, student, and admin authorization flows, the shared callback, and the status check.

Safe Online Exam uses a separate Canvas OAuth 2.0 authorization grant to make Canvas REST and New Quiz API calls on behalf of instructors, students, and root-account administrators. An LTI launch identifies and authenticates a person, but it does not authorize API access — the OAuth flow provides that second capability. This page documents every route under the **Canvas OAuth routes** section, the security controls each endpoint enforces, the state it creates or consumes, and how the single shared grant model works.

***

## Canvas OAuth Grant Model

Understanding the grant model is essential before reading the individual endpoints.

Safe Online Exam maintains **one durable OAuth token record per Canvas user ID** in the `canvas_oauth_tokens` table. It never creates a second record for the same user. When an administrator upgrades an existing instructor grant with account scopes, the same row is updated in-place to hold the expanded scope set.

<Accordion title="Why one grant covers all roles">
  Every Canvas OAuth connection — instructor, student session, and administrator — requests the same complete application scope set. This includes `url:GET|/api/v1/courses` (used for teacher-scoped tool duplication) and `url:GET|/api/v1/login/session_token` (required for SEB session handoff). The product decision is explicit: a single grant must remain valid when a person is an instructor in one course and a student in another, and when that same person later becomes a root-account administrator.

  Canvas still enforces the user's actual course and account permissions for every API call. Possessing a broad scope set does not grant access the user does not hold in Canvas. The application never treats scope possession as an LTI role check or course-authorization check.
</Accordion>

**Scope sets in use:**

| Profile                      | Scopes added                                                                                                                                                                                                                                                                                                                                                    |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Instructor / student session | `url:GET\|/api/v1/courses`, `url:GET\|/api/v1/courses/:course_id/quizzes`, `url:GET\|/api/v1/courses/:course_id/assignments`, `url:GET\|/api/quiz/v1/courses/:course_id/quizzes/:assignment_id`, `url:PUT\|/api/v1/courses/:course_id/quizzes/:id`, `url:PATCH\|/api/quiz/v1/courses/:course_id/quizzes/:assignment_id`, `url:GET\|/api/v1/login/session_token` |
| Administrator (additive)     | Instructor scopes **plus** `url:GET\|/api/v1/accounts/:id`, `url:GET\|/api/v1/accounts/:account_id/permissions`, `url:GET\|/api/v1/accounts/:account_id/courses`, `url:GET\|/api/v1/accounts/:account_id/terms`, `url:GET\|/api/v1/courses/:id`, `url:GET\|/api/v1/courses/:course_id/quizzes/:id`                                                              |

If a user already holds an administrator grant and then reauthorizes as an instructor, the service detects the existing admin scope profile and issues the full admin scope set again rather than replacing it with a narrower grant. This prevents accidental scope downgrade.

**Token encryption at rest:**

Access and refresh tokens are encrypted before PostgreSQL persistence using AES-256-GCM with an independent keyring. The record ID and Canvas user ID are authenticated as associated data (AAD). Nonsecret scope and identity metadata remains in plaintext columns for queryability. Legacy plaintext rows written before the encryption keyring was provisioned are read in a compatibility mode and are rewritten by the explicit `migrate-tokens` maintenance command.

<Note>
  The OAuth callback never renders or redirects into an authenticated management view. After code exchange and token storage, instructor and administrator callbacks render a non-privileged completion page that signals the same-origin opener window. The opener verifies both the message origin and the popup window reference before performing a same-origin refresh. If a popup is unavailable the user returns to Canvas manually. Student callbacks render a connected page that provides a local return URL derived from the signed LTI session, not from query parameters.
</Note>

***

## Authorization Endpoints

All three authorization-start endpoints enforce the same **Fetch Metadata** guard before doing anything else.

<Warning>
  Requests whose `sec-fetch-site` header is present and set to any value other than `same-origin` are rejected with `403 Open Canvas authorization from the Safe Online Exam tool.` This prevents third-party sites from initiating an OAuth authorization by embedding or linking to these endpoints.
</Warning>

### `GET /api/oauth2authorize`

Starts a Canvas OAuth authorization flow for an instructor. Redirects to Canvas to request the standard application scope set (or the full admin set if the user already holds an admin grant).

**Caller:** The Safe Online Exam React UI, as a same-origin Fetch or navigation triggered by the instructor in the course management view.

**Auth required:** A verified LTI principal with the `Instructor` (or `TeacherEnrollment`, `DesignerEnrollment`) role. A student or unauthenticated request is rejected.

**When to use:** Called when the instructor launches the tool and no access token is on file, or when the token has been revoked and the tool cannot recover automatically.

**Query parameters:**

<ParamField query="course_id" type="string">
  Optional. When present, must exactly match the course ID in the verified LTI principal. A mismatch returns `403`.
</ParamField>

<ParamField query="user_id" type="string">
  Optional. When present, must exactly match the Canvas user ID in the verified LTI principal. A mismatch returns `403`.
</ParamField>

<ParamField query="redirect_url" type="string">
  Optional. A local path to return to after authorization completes (used by the popup flow). Must be a valid same-origin path; arbitrary external URLs are silently replaced with `/lti/launch`.
</ParamField>

**State created:** An encrypted state record is written to `transient_states` with a 10-minute TTL. The record includes: `purpose: "canvas-oauth-v2"`, `canvasUserId`, `ltiSubject`, `courseId`, `issuer`, `deploymentId`, a SHA-256 session binding hash, an `oauthScopeProfile` (`"admin"` or `"application"`), and the safe-local `redirectUrl`.

**Response:** `302` redirect to `<canvas-domain>/login/oauth2/auth` with `client_id`, `response_type=code`, `redirect_uri`, `state`, and `scope`.

***

### `GET /api/oauth2reauthorize`

Identical behavior to `GET /api/oauth2authorize`. The separate path allows the client to signal intent (reauthorizing after a revoked or expired grant) without changing the authorization flow.

**Caller:** Same as `/api/oauth2authorize` — the instructor UI, after detecting a Canvas API authorization error.

**Auth required:** Same as `/api/oauth2authorize`.

**State and response:** Identical to `/api/oauth2authorize`.

***

### `GET /api/student-session-authorize`

Starts the Canvas OAuth authorization flow for a student. The student scope set is the same as the instructor scope set — the product decision described in the grant model applies here too. After authorization, the callback renders a student-connected view with a local return URL derived from the signed LTI session.

**Caller:** The Safe Online Exam React UI (student view), as a same-origin navigation when the student's Canvas user ID has no session-token access on file.

**Auth required:** A verified LTI principal with a student role (`StudentEnrollment`). An instructor or unauthenticated request is rejected.

**When to use:** Called when a student launches the tool and the service cannot generate a Canvas session URL because no OAuth token is stored for that user. This is the first time that student has used the tool on this Canvas instance.

**Return URL derivation:** The return URL after authorization is derived **only** from the signed LTI launch data stored in the session — never from query parameters. If the launch included a direct SEB content target (`/seb/launch/:contentId`), the return URL is `/seb/launch/:contentId?connected=1`; otherwise it is `/lti/launch?connected=1`. This prevents the student OAuth flow from being turned into an open redirect or used to switch courses after consent.

**State created:** Same structure as instructor state with `purpose: "canvas-student-session-oauth-v1"`.

**Response:** `302` redirect to Canvas authorization.

***

### `GET /api/admin/oauth2authorize`

Starts the Canvas OAuth authorization flow for a root-account administrator, requesting the full combined administrator-plus-application scope set. The resulting grant upgrades the user's existing token record (whether it was previously instructor-only or had no record) to include all administrator scopes.

**Caller:** The Safe Online Exam React UI (admin dashboard), as a same-origin navigation after a root-account administrator launches the tool and no admin-scoped token is on file.

**Auth required:** A verified LTI principal that is a root-account administrator — the session must carry a principal with both a signed `LTI_SYSTEM_ROLE` administrator claim and Canvas's `$Canvas.user.isRootAccountAdmin` substitution returning `true`. A course-only instructor principal is rejected with `403`.

**When to use:** Called when the admin dashboard detects that no token with the administrator scope profile is stored for the user. An administrator who previously authorized as an instructor is prompted to upgrade here without disconnecting their existing instructor grant.

**State created:** Same structure as other authorization states with `purpose: "canvas-admin-oauth-v1"` and an additional `accountId` field bound to the verified root account ID.

**Response:** `302` redirect to Canvas authorization with the combined admin+application scope set.

***

## OAuth Callback

### `GET /api/oauth2callback`

Receives the Canvas authorization code redirect after the user approves (or denies) the OAuth authorization prompt. Validates state, exchanges the code for tokens, verifies token ownership, stores the encrypted grant, and renders a completion page.

**Caller:** Canvas — the browser is redirected to this URL by Canvas after the user authorizes (or denies) the OAuth request.

**Auth required:** An existing verified LTI principal **from the same session that initiated authorization**. The callback validates that the principal's `canvasUserId`, `ltiSubject`, `courseId`, `issuer`, `deploymentId`, and a SHA-256 session binding hash all match what was stored in the OIDC state record.

**Compatibility contract:** This path must remain stable. Canvas stores the redirect URI when the developer key is registered; changing it requires re-registering every Canvas OAuth application and invalidating all existing grants.

**Query parameters:**

<ParamField query="code" type="string">
  The authorization code issued by Canvas. Exchanged server-side for tokens; never exposed to the browser.
</ParamField>

<ParamField query="state" type="string">
  The encrypted state token created at the authorization-start endpoint. Consumed atomically on first use — a replayed callback is rejected.
</ParamField>

<ParamField query="error" type="string">
  Canvas sets this when the user denies authorization or an error occurs. When present, the service renders an `oauth-error` view immediately without attempting code exchange.
</ParamField>

<ParamField query="error_description" type="string">
  Optional error description from Canvas, included in the error view when present.
</ParamField>

**Processing steps:**

<Steps>
  <Step title="Error check">
    If `error` is present, renders the error view and returns immediately. No token exchange is attempted.
  </Step>

  <Step title="State peek">
    Decrypts the state record to read the stored purpose, user binding, and session hash. The state is not consumed yet.
  </Step>

  <Step title="Principal verification">
    Reads the verified LTI principal from the current session. Rejects if absent.
  </Step>

  <Step title="Role match">
    Checks that the session principal's role matches the stored purpose: `canvas-oauth-v2` requires instructor, `canvas-student-session-oauth-v1` requires student, `canvas-admin-oauth-v1` requires root-account administrator.
  </Step>

  <Step title="Full binding verification">
    Verifies `canvasUserId`, `ltiSubject`, `courseId`, `issuer`, `deploymentId`, session binding hash, and (for admin) `accountId` against the stored state. Any mismatch throws.
  </Step>

  <Step title="Atomic state claim">
    Atomically consumes the state record in PostgreSQL. A replayed callback (same `state` value) is rejected here.
  </Step>

  <Step title="Code exchange">
    POSTs to `<canvas-domain>/login/oauth2/token` with `grant_type=authorization_code`, `client_id`, `client_secret`, `redirect_uri`, and `code`. The response is size-bounded at 64 KB and has an upstream deadline.
  </Step>

  <Step title="Token owner verification">
    Parses the Canvas token response and confirms that `token.user.id` matches the verified `canvasUserId`. Mismatched token ownership rejects the exchange.
  </Step>

  <Step title="Grant storage">
    Writes the AES-256-GCM encrypted access token, refresh token, scope, and metadata to `canvas_oauth_tokens`. The grant type is `"account_admin"` when the scope set includes admin scopes, otherwise `"instructor"`. One row per Canvas user ID — upsert semantics preserve the wider scope profile if an admin grant already exists.
  </Step>

  <Step title="Completion response">
    Renders a non-privileged completion page. For students (`canvas-student-session-oauth-v1`), the view is `student-session-connected` with a safe-local `returnUrl`. For instructors and administrators, the view is `canvas-oauth-connected` with a `canvasReturnUrl` pointing to the user's Canvas course or account. The callback **never** renders or redirects into a management view.
  </Step>
</Steps>

**Error response:** Any failure in state peek, binding verification, code exchange, or token ownership returns `400` with the `oauth-error` view.

***

## Authorization Status

### `GET /api/oauth2status`

Returns a lightweight JSON object indicating whether a valid Canvas OAuth token is stored for the current instructor. Used by the React UI to poll or check authorization state without triggering a full page reload.

**Caller:** The Safe Online Exam React UI (instructor course management view), via a same-origin Fetch request.

**Auth required:** A verified LTI principal with the instructor role. Returns `{ authorized: false }` for unauthenticated requests or non-instructor principals rather than an error status, so the client UI can treat this as a readable status check.

**Query parameters:**

<ParamField query="user_id" type="string">
  Optional. When present, must match the Canvas user ID in the verified LTI principal. Returns `{ authorized: false }` on mismatch rather than `403`, consistent with this endpoint's status-check semantics.
</ParamField>

**Response:**

```json theme={null}
{ "authorized": true }
```

```json theme={null}
{ "authorized": false }
```

<ResponseField name="authorized" type="boolean" required>
  `true` when a valid, non-expired Canvas OAuth access token is stored for the authenticated instructor's Canvas user ID. `false` in all other cases (no token, expired token, non-instructor session, or user ID mismatch).
</ResponseField>

***

## Security Summary

<CardGroup cols={2}>
  <Card title="Fetch Metadata enforcement" icon="shield-halved" href="/architecture">
    All three authorization-start endpoints reject any request whose `sec-fetch-site` header is present and not `same-origin`. This prevents cross-site initiation of the OAuth flow.
  </Card>

  <Card title="Session binding" icon="lock" href="/architecture">
    Every state record stores a SHA-256 hash of the Express session ID. The callback verifies this binding before consuming the state, preventing CSRF and cross-session injection.
  </Card>

  <Card title="Token encryption" icon="key" href="/configuration/secrets">
    Access and refresh tokens are encrypted at rest with AES-256-GCM. The record ID and Canvas user ID are authenticated as associated data, ensuring tokens cannot be moved between records.
  </Card>

  <Card title="One grant per user" icon="user-check" href="/architecture">
    A single `canvas_oauth_tokens` row per Canvas user ID prevents grant proliferation. Administrator scope upgrades widen the existing row rather than creating a parallel record.
  </Card>
</CardGroup>
