# Implementing LTI 1.3 in a Node.js Portal (Moodle Sandbox Walkthrough)

You've built a training portal. A client says:

> "We use Moodle. Can our employees click a link inside a Moodle course and land in your portal — already logged in — without a separate signup?"

That is exactly what **LTI 1.3** solves.

---

## How It Works

**Step 1 —** Student clicks the tool link inside their Moodle course.

**Step 2 —** Moodle sends a login hint to your server: `GET https://yourapi.com/lti/login`

**Step 3 —** Your server redirects back to Moodle's auth endpoint to request a signed token.

**Step 4 —** Moodle POSTs a signed `id_token` (JWT) to your server: `POST https://yourapi.com/lti`

**Step 5 —** Your server validates the token, finds or creates the user, mints your own app JWT, and redirects to your frontend.

**Step 6 —** Student lands on your portal — already authenticated, no password entered.

---

## Part 1 — Backend Setup

### Install

```bash
npm install ltijs ltijs-sequelize
```

### Three URLs to expose

| Moodle calls it      | Your URL                        | Purpose                   |
| -------------------- | ------------------------------- | ------------------------- |
| Tool URL             | `https://yourapi.com/lti`       | Receives the signed token |
| Login Initiation URL | `https://yourapi.com/lti/login` | Starts OIDC flow          |
| Public Keyset URL    | `https://yourapi.com/lti/keys`  | Your JWKS public keys     |

---

### `src/lti/ltijsProvider.js`

```js
const lti = require("ltijs").Provider;
const Database = require("ltijs-sequelize");
const ltiLaunchController = require("./lti_launch_controller");

async function setupLTI(app) {
  const db = new Database(
    process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD,
    { host: process.env.DB_HOST, dialect: "postgres", logging: false }
  );

  await lti.setup(
    process.env.LTI_ENCRYPTION_KEY,
    { plugin: db },
    {
      appRoute: "/",        // relative to /lti mount → /lti
      loginRoute: "/login", // → /lti/login
      keysetRoute: "/keys", // → /lti/keys
      cookies: {
        secure: process.env.NODE_ENV === "production",
        sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
        httpOnly: true,
      },
      devMode: process.env.NODE_ENV !== "production",
      tokenMaxAge: 3600,
    }
  );

  lti.onConnect(async (token, req, res) => {
    ltiLaunchController.ltiLaunch(req, res, token);
  });

  await lti.deploy({ serverless: true });
  app.use("/lti", lti.app);
}

module.exports = setupLTI;
```

### `src/app.js` — mount ltijs FIRST

```js
const setupLTI = require("./lti/ltijsProvider");

(async () => {
  await setupLTI(app); // must come before all other routes

  app.use(publicRoutes);
  app.use(privateRoutes);

  app.listen(8000);
})();
```

### Helmet — allow iframe embedding

```js
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      ...helmet.contentSecurityPolicy.getDefaultDirectives(),
      "frame-ancestors": ["'self'", "*"], // allow any LMS to embed you
    },
  },
}));
```

---

### `src/lti/lti_launch_controller.js`

```js
const jwt = require("jsonwebtoken");
const User = require("../models/user");
const Client = require("../models/client");
const Role = require("../models/role");
const LtiSession = require("../models/lti_session");
const db = require("../config/database");

const ltiLaunch = async (req, res, token) => {
  const frontendUrl = process.env.FRONTEND_URL || "http://localhost:3000";
  const transaction = await db.transaction();

  try {
    // 1. Extract user info from the LTI token
    const email       = token.userInfo?.email || token.email || "";
    const name        = token.userInfo?.name  || token.name  || "LTI User";
    const ltiUserId   = token.user || token.sub;
    const platformId  = token.platformId || token.iss;
    const ltiClientId = token.clientId   || token.aud;

    // 2. Match LTI client_id to a client record in your database
    //    (you store the client_id Moodle gave you when registering the tool)
    const client = await Client.findOne({ where: { lti_client_id: ltiClientId } });
    if (!client) {
      await transaction.rollback();
      return res.redirect(`${frontendUrl}/lti-error?errorCode=CLIENT_NOT_FOUND`);
    }

    // 3. Find existing user by their LTI identity, or auto-create on first launch
    let user = await User.findOne({
      where: { lti_user_id: ltiUserId, lti_platform_id: platformId },
    });

    if (!user) {
      const role = await Role.findOne({ where: { name: "lti_user" } });

      user = await User.create({
        name,
        email: email || `no-email+${Date.now()}@placeholder.com`,
        username: `lti-${email || Date.now()}`,
        lti_user_id: ltiUserId,
        lti_platform_id: platformId,
        role_id: role.id,
        active: true,
      }, { transaction });

      // Link the user to the client they launched from
      await user.addClient(client, { transaction });
    }

    // 4. Record the LTI session (useful for audit and content scoping)
    const ltiSession = await LtiSession.create({
      user_id:            user.id,
      platform_id:        platformId,
      context_id:         token["https://purl.imsglobal.org/spec/lti/claim/context"]?.id,
      context_title:      token["https://purl.imsglobal.org/spec/lti/claim/context"]?.title,
      session_started_at: new Date(),
    }, { transaction });

    await transaction.commit();

    // 5. Mint your own app JWT and send the user to the frontend
    const appToken = jwt.sign(
      { userId: user.id, lti_session_id: ltiSession.id },
      process.env.JWT_SECRET,
      { expiresIn: "1h" }
    );

    return res.redirect(`${frontendUrl}?token=${appToken}`);

  } catch (err) {
    await transaction.rollback();
    console.error("[LTI] Launch error:", err);
    return res.redirect(`${frontendUrl}/lti-error?errorCode=INTERNAL`);
  }
};

module.exports = { ltiLaunch };
```

---

### `src/lti/registerLTIPlatform.js` — run once per LMS client

```js
require("dotenv").config();
const lti      = require("ltijs").Provider;
const Database = require("ltijs-sequelize");

(async () => {
  const db = new Database(
    process.env.DB_NAME, process.env.DB_USER, process.env.DB_PASSWORD,
    { host: process.env.DB_HOST, dialect: "postgres", logging: false }
  );

  await lti.setup(process.env.LTI_ENCRYPTION_KEY, { plugin: db }, {
    appRoute: "/lti", loginRoute: "/lti/login", keysetRoute: "/lti/keys",
  });
  await lti.deploy({ serverless: true });

  await lti.registerPlatform({
    url:                    "https://sandbox.moodledemo.net", // issuer — must match exactly, no trailing slash
    name:                   "Moodle Sandbox",
    clientId:               "PASTE_CLIENT_ID_FROM_MOODLE_HERE",
    authenticationEndpoint: "https://sandbox.moodledemo.net/mod/lti/auth.php",
    accesstokenEndpoint:    "https://sandbox.moodledemo.net/mod/lti/token.php",
    authConfig: {
      method: "JWK_SET",
      key:    "https://sandbox.moodledemo.net/mod/lti/certs.php",
    },
  });

  console.log("Platform registered.");
  process.exit(0);
})();
```

Add to `package.json`:

```json
"register-lti-platform": "node src/lti/registerLTIPlatform.js"
```

### Environment variables

```env
LTI_ENCRYPTION_KEY=any-random-32-char-string
JWT_SECRET=your-existing-jwt-secret
FRONTEND_URL=https://yourportal.com
```

---

## Part 2 — Moodle Configuration

### Step 1 — Add External Tool

Go to: **Site administration > Plugins > Activity modules > External tool > Manage tools**

Then click **"configure a tool manually"**.

| Field | Value |
|---|---|
| Tool name | `My Training Portal` |
| Tool URL | `https://yourapi.com/lti` |
| LTI version | `LTI 1.3` |
| Public key type | `Keyset URL` |
| Public keyset | `https://yourapi.com/lti/keys` |
| Initiate login URL | `https://yourapi.com/lti/login` |
| Redirection URI(s) | `https://yourapi.com/lti` |

Under **Privacy**: set both name and email sharing to **Always**.

**Save** — Moodle generates a **Client ID**. Copy it.

### Step 2 — Paste Client ID and register

Paste the Client ID from Moodle into `registerLTIPlatform.js`, then:

```bash
npm run register-lti-platform
```

### Step 3 — Add to a course

Course → Turn editing on → Add activity → External Tool → select your tool.

Students clicking it will trigger the full launch.

---

## Part 3 — Token Normalizer

Different LMS platforms (Moodle, Canvas, Blackboard) put claims in slightly different places. Write a normalizer once so your launch controller never needs to care which LMS sent the token:

```js
// src/lti/ltiTokenNormalizer.js
class LTITokenNormalizer {
  static normalize(token) {
    return {
      user: {
        id:    token.user || token.sub,
        name:  token.userInfo?.name  || token.name  || "Unknown",
        email: token.userInfo?.email || token.email || "",
      },
      platform: {
        id:           token.platformId || token.iss,
        clientId:     token.clientId   || token.aud,
        deploymentId: token["https://purl.imsglobal.org/spec/lti/claim/deployment_id"],
      },
      context: {
        id:    token["https://purl.imsglobal.org/spec/lti/claim/context"]?.id,
        title: token["https://purl.imsglobal.org/spec/lti/claim/context"]?.title,
      },
      custom: token["https://purl.imsglobal.org/spec/lti/claim/custom"] || {},
    };
  }
}

module.exports = LTITokenNormalizer;
```

---

## Part 4 — Admin API for Managing Platforms

So you never need to redeploy to connect a new client's LMS:

| Method | Route | Action |
|---|---|---|
| POST | `/admin/lti/platforms` | Register new platform |
| GET | `/admin/lti/platforms` | List all platforms |
| PUT | `/admin/lti/platforms/:id` | Update platform |
| DELETE | `/admin/lti/platforms/:id` | Remove platform |

```js
// POST /admin/lti/platforms
const registerPlatform = async (req, res) => {
  const { url, name, clientId, authenticationEndpoint, accesstokenEndpoint, keysetUrl } = req.body;

  const platform = await lti.registerPlatform({
    url, name, clientId,
    authenticationEndpoint,
    accesstokenEndpoint,
    authConfig: { method: "JWK_SET", key: keysetUrl },
  });

  return res.json({ success: true, platformId: await platform.platformId() });
};

// GET /admin/lti/platforms
const getAllPlatforms = async (req, res) => {
  const platforms = await lti.getAllPlatforms();
  const data = await Promise.all(platforms.map(async p => ({
    id:       await p.platformId(),
    url:      await p.platformUrl(),
    name:     await p.platformName(),
    clientId: await p.platformClientId(),
  })));
  return res.json({ success: true, data });
};
```

Protect these routes with a super-admin guard — only your own team should touch them.

---

## Part 5 — Per-Course Config (Route users to specific content)

Clients want: *"When a student launches from the Sales course, open the Sales scenario."*

Store a `lti_course_configs` table:

| column | meaning |
|---|---|
| `lti_client_id` | which LMS installation |
| `deployment_id` | which site instance |
| `context_id` | which course |
| `scenario_code` | scenario to open |
| `group_id` | group to auto-enrol into |

At launch, do a scored lookup — most specific match wins:

```js
const match =
  configs.find(c => c.deployment_id === deployId && c.context_id === contextId) ||
  configs.find(c => c.deployment_id === deployId && !c.context_id) ||
  configs.find(c => c.context_id === contextId   && !c.deployment_id);

if (match?.group_id) {
  await user.addGroup(match.group_id, { transaction });
}
// store match.scenario_code in the LTI session so the frontend opens it directly
```

The `deployment_id` + `context_id` pair uniquely identifies a course inside a specific Moodle installation. Moodle sends both in every launch token.

---

## Common Gotchas

**Cookies blocked**
- Use `sameSite: "None"` + `secure: true` in production
- API must be on HTTPS — `sameSite: None` requires it
- In dev: use `devMode: true` in ltijs (disables the state cookie check)

**"Platform not found" error**
- The `url` you register must match Moodle's `iss` claim **exactly** — no trailing slash difference, same protocol

**POST /lti never arrives**
- Add a log before `app.use("/lti", lti.app)` to confirm the POST hits your server
- Usually caused by an iframe blocking the cross-origin form_post redirect from Moodle

**No email from Moodle**
- Check Moodle tool Privacy settings → Share launcher's email → Always
- If still empty, generate a placeholder: `` `no-email+${Date.now()}@placeholder.com` ``

---

## Pre-Launch Checklist

- [ ] `LTI_ENCRYPTION_KEY` is 32+ chars and different from `JWT_SECRET`
- [ ] API is on HTTPS
- [ ] `cookies.secure = true` and `sameSite = "None"` in production
- [ ] `devMode: false` in production
- [ ] Helmet `frame-ancestors` allows the client's Moodle domain
- [ ] Platform registered in DB via `npm run register-lti-platform`
- [ ] Moodle Privacy → Share name + email → Always
- [ ] Moodle Redirection URI matches your `/lti` URL exactly
- [ ] `lti_client_id` stored on the client record in your database

