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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,8 @@ CANVAS_CLIENT_SECRET=your-canvas-client-secret-here
CANVAS_REDIRECT_URI=http://localhost:8051/api/create/canvas/oauth/callback
LTI_CLIENT_ID=10000000000002
LTI_KEY=replace-with-a-long-random-secret
LTI_PORT=7737
LTI_PUBLIC_URL=http://localhost:7737
# The LTI tool is served by the main server under /lti; no separate port.
LTI_PUBLIC_URL=http://localhost:8051/lti
# Public origin of the main CREATE server used by the Canvas LTI player to load
# H5P core, library, font and media assets. Set this when Canvas cannot reach
# the API through localhost (for example, a hosted or container deployment).
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ Defaults come from `.env.example`:
- Qdrant: `http://localhost:6333`, collection `quiz-materials`
- Local SimpleSAMLphp IdP: `http://localhost:6122`
- Ollama fallback: `http://localhost:11434`
- Optional Canvas: `http://localhost`; optional LTI public runtime on port 7737
- Optional Canvas: `http://localhost`; optional LTI tool under `/lti` on the backend port

The app can use OpenAI-compatible endpoints or Ollama. `llmService.js` resolves
the effective provider/model/key and supports per-user credentials. Redis is
Expand Down
16 changes: 9 additions & 7 deletions canvas-lti-local-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

This document records the errors encountered when setting up the TLEF-CREATE LTI 1.3 integration with a local Canvas (`canvas-lms-docker`) instance, and the exact fix for each one.

> **Note:** the LTI tool used to run on its own port (7737). It is now served by the main server under `/lti`, so `http://localhost:7737/…` in the history below corresponds to `http://localhost:8051/lti/…` today. The Final Working Configuration section uses the current URLs.

---

## Overview of the Flow
Expand Down Expand Up @@ -131,7 +133,7 @@ Canvas responds with the installed tool's `id`, which is then used for creating
**Fix:** Switch the Developer Key's JWK method from **Public JWK URL** to **Paste Public JWK**, and paste the static JWK JSON directly. Retrieve the JWK from our server:

```bash
curl http://localhost:7737/keys
curl http://localhost:8051/lti/keys
```

Copy the result and paste it into the Canvas Developer Key editor under **Paste Public JWK**.
Expand All @@ -143,7 +145,7 @@ Copy the result and paste it into the Canvas Developer Key editor under **Paste
### `.env` (tlef-create)
```env
LTI_CLIENT_ID=10000000000002
LTI_PUBLIC_URL=http://localhost:7737
LTI_PUBLIC_URL=http://localhost:8051/lti
CANVAS_BASE_URL=http://localhost
CANVAS_CLIENT_ID=10000000000001
CANVAS_CLIENT_SECRET=<your-canvas-secret>
Expand All @@ -162,12 +164,12 @@ default: &default
- **Key type:** LTI Key
- **Redirect URIs:**
```
http://localhost:7737/
http://localhost:7737/login
http://localhost:8051/lti/
http://localhost:8051/lti/login
```
- **Target Link URI:** `http://localhost:7737/`
- **OpenID Connect Initiation URL:** `http://localhost:7737/login`
- **JWK Method:** Paste Public JWK (from `curl http://localhost:7737/keys`)
- **Target Link URI:** `http://localhost:8051/lti/`
- **OpenID Connect Initiation URL:** `http://localhost:8051/lti/login`
- **JWK Method:** Paste Public JWK (from `curl http://localhost:8051/lti/keys`)

### After any Canvas reset
If you reset or re-create the Canvas Docker environment, run these steps:
Expand Down
2 changes: 1 addition & 1 deletion docs/canvas-toolkit-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ A local Canvas instance can continue to use `CANVAS_BASE_URL=http://localhost` a

4. Exporting a playable CREATE activity also requires a separate **LTI Developer Key and tool installation**. Configure `LTI_CLIENT_ID`, `LTI_PUBLIC_URL`, and the other LTI settings. The OAuth client ID and LTI client ID serve different purposes. Canvas and learners' browsers must be able to reach the public HTTPS address; do not use localhost in a hosted deployment.

The LTI server listens on `LTI_PORT` (7737 by default). In a hosted deployment, reverse proxy a public HTTPS LTI host to that port. Set the Canvas LTI Key's OpenID Connect initiation URL, Target Link URI, Redirect URI, and Public JWK URL to `<LTI_PUBLIC_URL>/login`, `<LTI_PUBLIC_URL>/`, `<LTI_PUBLIC_URL>/`, and `<LTI_PUBLIC_URL>/keys`, respectively. Set a random `LTI_KEY` and set `H5P_ASSETS_URL` to the public origin of CREATE's main API. The public `/keys` endpoint must return JSON with a `keys` array, not CREATE's frontend HTML.
The LTI tool is served by the main CREATE server under `/lti`, so it needs no separate port or hostname: set `LTI_PUBLIC_URL` to `<CREATE public origin>/lti` (for example `https://create.apps.ltic.ubc.ca/lti`, or `http://localhost:8051/lti` locally). Set the Canvas LTI Key's OpenID Connect initiation URL, Target Link URI, Redirect URI, and Public JWK URL to `<LTI_PUBLIC_URL>/login`, `<LTI_PUBLIC_URL>/`, `<LTI_PUBLIC_URL>/`, and `<LTI_PUBLIC_URL>/keys`, respectively. Set a random `LTI_KEY` and set `H5P_ASSETS_URL` to the public origin of CREATE's main API. The public `/keys` endpoint must return JSON with a `keys` array, not CREATE's frontend HTML.

For Instructure-hosted Canvas, OAuth and REST APIs continue to use the institution's Canvas domain. Configure LTI registration with Canvas's shared issuer and SSO/JWKS endpoints. Self-hosted Canvas uses its own instance endpoints.

Expand Down
2 changes: 1 addition & 1 deletion routes/create/controllers/canvasController.js
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ router.post('/export/:quizId', asyncHandler(async (req, res) => {
const externalToolId = await canvasApiService.ensureLtiToolInstalled(req.user.id, courseId);

// Step 5: Build LTI launch URL with custom param to identify the quiz
const ltiPublicUrl = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.LTI_PORT || 7737}`;
const ltiPublicUrl = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.PORT || 8051}/lti`;
const launchUrl = `${ltiPublicUrl}/?quizExportId=${resourceLinkId}`;

// Step 6: Create ExternalTool module item (Canvas launches via LTI)
Expand Down
23 changes: 13 additions & 10 deletions routes/create/services/ltiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ const require = createRequire(import.meta.url);
const lti = require('ltijs').Provider;

const LTI_KEY = process.env.LTI_KEY || 'LTIKEY-default-change-in-production';
const LTI_PORT = parseInt(process.env.LTI_PORT || '7737', 10);
const MONGODB_URI = process.env.MONGODB_URI;
// LTI_PUBLIC_URL must be reachable from Canvas. If Canvas is in Docker, use host.docker.internal
const LTI_PUBLIC_URL = process.env.LTI_PUBLIC_URL || `http://localhost:${LTI_PORT}`;
// LTI_PUBLIC_URL must be reachable from Canvas: the main server's origin plus
// the /lti mount path. If Canvas is in Docker, use host.docker.internal
const LTI_PUBLIC_URL = process.env.LTI_PUBLIC_URL || `http://localhost:${process.env.PORT || 8051}/lti`;

/**
* Start the LTI 1.3 server on a separate port
* Set up the LTI 1.3 tool and return its Express app, which server.js mounts
* at /lti. Links in pages it renders use req.baseUrl so they keep that prefix.
*/
export async function startLtiServer() {
if (!MONGODB_URI) {
Expand Down Expand Up @@ -83,7 +84,7 @@ export async function startLtiServer() {
const snapshot = exportRecord.canvasExport.mixedActivitySnapshot;
const items = (snapshot?.questions || []).map((question, index) => ({
title: `Question ${index + 1}`,
url: `/h5p/mixed/${encodeURIComponent(quizExportId)}/${encodeURIComponent(String(question._id))}?token=${encodeURIComponent(createH5PPreviewToken({
url: `${req.baseUrl}/h5p/mixed/${encodeURIComponent(quizExportId)}/${encodeURIComponent(String(question._id))}?token=${encodeURIComponent(createH5PPreviewToken({
userId: quiz.createdBy,
quizId: quiz._id,
questionId: question._id
Expand All @@ -96,8 +97,8 @@ export async function startLtiServer() {
title: snapshot.title || quiz.name,
items,
scoreEndpoint: typeof req.query?.ltik === 'string'
? `/score?ltik=${encodeURIComponent(req.query.ltik)}`
: '/score'
? `${req.baseUrl}/score?ltik=${encodeURIComponent(req.query.ltik)}`
: `${req.baseUrl}/score`
}));
}

Expand Down Expand Up @@ -178,15 +179,17 @@ export async function startLtiServer() {
}
});

// Deploy on separate port
await lti.deploy({ port: LTI_PORT });
// Serverless: no listener of its own; the main server mounts lti.app
await lti.deploy({ serverless: true, silent: true });

// Register Canvas platform (if not already registered)
await registerCanvasPlatform();

console.log(`✅ LTI 1.3 server running on port ${LTI_PORT}`);
console.log(`✅ LTI 1.3 tool ready at ${LTI_PUBLIC_URL}`);
console.log(` JWKS: ${LTI_PUBLIC_URL}/keys`);
console.log(` Launch: ${LTI_PUBLIC_URL}/`);

return lti.app;
}

/**
Expand Down
2 changes: 1 addition & 1 deletion routes/create/services/lumiService.js
Original file line number Diff line number Diff line change
Expand Up @@ -342,7 +342,7 @@ export async function renderContent(contentId, user = systemUser, options) {
let html = await readyPlayer.render(contentId, user, 'en', options);

if (typeof html === 'string') {
// When rendered inside Canvas LTI iframe, relative paths go to :7737 instead of :8051
// The Canvas LTI iframe may be served from a different origin than the API
// Convert all /api/create/h5p/ paths to absolute URLs pointing to the main server
const mainServerUrl = process.env.H5P_ASSETS_URL || `http://localhost:${process.env.PORT || 8051}`;
html = html.replace(/(['"])(\/api\/create\/h5p\/)/g, `$1${mainServerUrl}/api/create/h5p/`);
Expand Down
14 changes: 13 additions & 1 deletion server.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,13 @@ import('./routes/create/services/lumiService.js')
console.error('💡 Canvas H5P export may not work properly');
});

// Start LTI 1.3 server (separate port) — only if configured
// Start LTI 1.3 tool (mounted at /lti below) — only if configured
let ltiApp = null;
if (process.env.LTI_CLIENT_ID) {
console.log('🔧 Starting LTI 1.3 server...');
import('./routes/create/services/ltiService.js')
.then(module => module.startLtiServer())
.then(app => { ltiApp = app; })
.catch(err => {
console.error('❌ Failed to start LTI server:', err.message);
console.error('💡 Canvas LTI integration may not work properly');
Expand All @@ -91,6 +93,16 @@ if (process.env.LTI_CLIENT_ID) {
const app = express();
const PORT = process.env.PORT || 7736;

// Canvas reaches the LTI tool under /lti. It must run ahead of the app's own
// body, cookie and session middleware: ltijs signs cookies with its own key,
// and cookie-parser skips requests whose cookies were already parsed.
if (process.env.LTI_CLIENT_ID) {
app.use('/lti', (req, res, next) => {
if (!ltiApp) return res.status(503).send('LTI tool is starting, please retry');
return ltiApp(req, res, next);
});
}

// CORS configuration for frontend integration
// In production, strip port numbers from FRONTEND_URL as they shouldn't be in browser requests
let corsOrigin;
Expand Down
Loading