Skip to content

CodeIgniter Sample Implementation

Davo edited this page May 2, 2025 · 1 revision

CodeIgniter Implementation Guide for LTI 1.3 PHP Library

This guide provides detailed implementation instructions for using the Packback LTI 1.3 PHP library with CodeIgniter 4 applications.

WARNING: This implementation was generated using an LLM based on the Laravel implementation. The author has not worked with CodeIgniter in a decade and makes no guarantee of the accuracy of this document. Please direct specific feedback to the issues page for this repo.

Installation

  1. Install the library via Composer:
composer require packbackbooks/lti-1p3-tool
  1. Configure JWT leeway in a bootstrap file or Base Controller:
<?php
// app/Config/Boot/production.php or in a base controller's constructor
\Firebase\JWT\JWT::$leeway = 5;

Implementation Overview

In CodeIgniter 4, we'll use:

  • Services for implementing the LTI interfaces
  • Models for database interactions
  • A dedicated LTI library to centralize functionality

Interface Implementations

Create a Service Directory

First, create a directory for your LTI services:

mkdir -p app/Services/Lti

Cache Interface Implementation

Create app/Services/Lti/CiCache.php:

<?php

namespace App\Services\Lti;

use Packback\Lti1p3\Interfaces\ICache;
use CodeIgniter\Cache\Cache;

class CiCache implements ICache
{
    // Cache durations
    private const NONCE_EXPIRY = 3600; // 1 hour
    private const LAUNCH_DATA_EXPIRY = 86400; // 24 hours
    private const ACCESS_TOKEN_EXPIRY = 3600; // 1 hour
    
    private $cache;
    
    public function __construct()
    {
        $this->cache = \Config\Services::cache();
    }
    
    public function getLaunchData(string $key): ?array
    {
        return $this->cache->get("lti1p3-launch-data-{$key}");
    }

    public function cacheLaunchData(string $key, array $jwtBody): void
    {
        $this->cache->save("lti1p3-launch-data-{$key}", $jwtBody, self::LAUNCH_DATA_EXPIRY);
    }

    public function cacheNonce(string $nonce, string $state): void
    {
        $this->cache->save("lti1p3-nonce-{$nonce}", $state, self::NONCE_EXPIRY);
    }

    public function checkNonceIsValid(string $nonce, string $state): bool
    {
        $cachedState = $this->cache->get("lti1p3-nonce-{$nonce}");
        if (!$cachedState) {
            return false;
        }

        // Remove the nonce after validation (one-time use)
        $this->cache->delete("lti1p3-nonce-{$nonce}");

        return $cachedState === $state;
    }

    public function cacheAccessToken(string $key, string $accessToken): void
    {
        $this->cache->save("lti1p3-access-token-{$key}", $accessToken, self::ACCESS_TOKEN_EXPIRY);
    }

    public function getAccessToken(string $key): ?string
    {
        return $this->cache->get("lti1p3-access-token-{$key}");
    }

    public function clearAccessToken(string $key): void
    {
        $this->cache->delete("lti1p3-access-token-{$key}");
    }
}

Cookie Interface Implementation

Create app/Services/Lti/CiCookie.php:

<?php

namespace App\Services\Lti;

use Packback\Lti1p3\Interfaces\ICookie;
use Config\Services;

class CiCookie implements ICookie
{
    private $response;
    
    public function __construct()
    {
        $this->response = Services::response();
    }
    
    public function getCookie(string $name): ?string
    {
        $request = Services::request();
        $cookieValue = $request->getCookie($name);
        
        return $cookieValue ?: null;
    }

    public function setCookie(string $name, string $value, int $exp = 3600, array $options = []): void
    {
        $this->response->setCookie(
            $name,
            $value,
            $exp,
            '', // path
            '', // domain
            false, // secure (adjust based on your needs)
            true, // httpOnly
            'Lax' // sameSite
        );
        
        Services::response($this->response);
    }
}

Database Models

Create database models for Issuers and Deployments:

Issuer Model

Create app/Models/IssuerModel.php:

<?php

namespace App\Models;

use CodeIgniter\Model;

class IssuerModel extends Model
{
    protected $table = 'issuers';
    protected $primaryKey = 'id';
    protected $useAutoIncrement = true;
    protected $returnType = 'array';
    protected $useSoftDeletes = false;
    protected $allowedFields = [
        'issuer',
        'client_id',
        'key_set_url',
        'auth_token_url',
        'auth_login_url',
        'auth_server',
        'tool_private_key',
        'kid'
    ];

    public function findByIssuer(string $issuer, ?string $clientId = null)
    {
        $query = $this->where('issuer', $issuer);
        
        if ($clientId) {
            $query->where('client_id', $clientId);
        }
        
        return $query->first();
    }
}

Deployment Model

Create app/Models/DeploymentModel.php:

<?php

namespace App\Models;

use CodeIgniter\Model;

class DeploymentModel extends Model
{
    protected $table = 'deployments';
    protected $primaryKey = 'id';
    protected $useAutoIncrement = true;
    protected $returnType = 'array';
    protected $useSoftDeletes = false;
    protected $allowedFields = [
        'issuer_id',
        'deployment_id'
    ];

    public function findByDeploymentId(string $issuer, string $deploymentId, ?string $clientId = null)
    {
        $builder = $this->db->table('deployments d')
            ->select('d.*')
            ->join('issuers i', 'i.id = d.issuer_id')
            ->where('i.issuer', $issuer)
            ->where('d.deployment_id', $deploymentId);
            
        if ($clientId) {
            $builder->where('i.client_id', $clientId);
        }
        
        return $builder->get()->getRowArray();
    }
}

Database Interface Implementation

Create app/Services/Lti/CiDatabase.php:

<?php

namespace App\Services\Lti;

use App\Models\IssuerModel;
use App\Models\DeploymentModel;
use Packback\Lti1p3\Interfaces\IDatabase;
use Packback\Lti1p3\Interfaces\ILtiDeployment;
use Packback\Lti1p3\Interfaces\ILtiRegistration;
use Packback\Lti1p3\LtiDeployment;
use Packback\Lti1p3\LtiRegistration;

class CiDatabase implements IDatabase
{
    private $issuerModel;
    private $deploymentModel;
    
    public function __construct()
    {
        $this->issuerModel = new IssuerModel();
        $this->deploymentModel = new DeploymentModel();
    }
    
    /**
     * Find an LTI registration by issuer and optional client ID
     */
    public function findRegistrationByIssuer(string $iss, ?string $clientId = null): ?ILtiRegistration
    {
        $issuer = $this->issuerModel->findByIssuer($iss, $clientId);
        
        if (!$issuer) {
            return null;
        }
        
        return LtiRegistration::new([
            'issuer' => $issuer['issuer'],
            'clientId' => $issuer['client_id'],
            'keySetUrl' => $issuer['key_set_url'],
            'authTokenUrl' => $issuer['auth_token_url'],
            'authLoginUrl' => $issuer['auth_login_url'],
            'authServer' => $issuer['auth_server'],
            'toolPrivateKey' => $issuer['tool_private_key'],
            'kid' => $issuer['kid'],
        ]);
    }

    /**
     * Find a deployment by issuer, deployment ID, and optional client ID
     */
    public function findDeployment(string $iss, string $deploymentId, ?string $clientId = null): ?ILtiDeployment
    {
        $deployment = $this->deploymentModel->findByDeploymentId($iss, $deploymentId, $clientId);
        
        if (!$deployment) {
            return null;
        }
        
        return LtiDeployment::new($deployment['deployment_id']);
    }
}

Optional: Migration Database Interface

If you need LTI 1.1 to 1.3 migration support, extend the Database implementation:

<?php

namespace App\Services\Lti;

use App\Models\Lti1p1KeyModel;
use Packback\Lti1p3\Interfaces\ILtiDeployment;
use Packback\Lti1p3\Interfaces\IMigrationDatabase;
use Packback\Lti1p3\LtiConstants;
use Packback\Lti1p3\Lti1p1Key as Lti1p1KeyClass;
use Packback\Lti1p3\LtiDeployment;
use Packback\Lti1p3\LtiMessageLaunch;

class CiDatabase implements IDatabase, IMigrationDatabase
{
    // Include existing IDatabase methods from above, then add:
    
    /**
     * Return LTI 1.1 keys that match this launch
     */
    public function findLti1p1Keys(LtiMessageLaunch $launch): array
    {
        $keyModel = new Lti1p1KeyModel();
        $keys = $keyModel->findAll();
        
        // Convert to library Lti1p1Key objects
        $result = [];
        foreach ($keys as $key) {
            $result[] = new Lti1p1KeyClass($key['oauth_consumer_key'], $key['secret']);
        }
        
        return $result;
    }

    /**
     * Determine if this launch should migrate from LTI 1.1 to 1.3
     */
    public function shouldMigrate(LtiMessageLaunch $launch): bool
    {
        $body = $launch->getLaunchData();
        
        // Check if LTI 1.1 data exists
        return isset($body[LtiConstants::LTI1P1]);
    }

    /**
     * Create a 1.3 deployment based on 1.1 data
     */
    public function migrateFromLti1p1(LtiMessageLaunch $launch): ?ILtiDeployment
    {
        $body = $launch->getLaunchData();
        $lti1p1Data = $body[LtiConstants::LTI1P1] ?? null;
        
        if (!$lti1p1Data) {
            return null;
        }

        // Find or create issuer
        $issuer = $this->issuerModel->findByIssuer($body['iss'], $body['aud'][0] ?? $body['aud']);
        if (!$issuer) {
            // Create issuer logic here
            // $issuerId = $this->issuerModel->insert([...]);
        } else {
            $issuerId = $issuer['id'];
        }
        
        // Create new deployment record
        $deploymentId = $body[LtiConstants::DEPLOYMENT_ID];
        $this->deploymentModel->insert([
            'issuer_id' => $issuerId,
            'deployment_id' => $deploymentId,
        ]);
        
        return LtiDeployment::new($deploymentId);
    }
}

LTI Service Connector

Create app/Services/Lti/CiServiceConnector.php:

<?php

namespace App\Services\Lti;

use GuzzleHttp\Client;
use Packback\Lti1p3\Interfaces\ICache;
use Packback\Lti1p3\LtiServiceConnector;

class CiServiceConnector extends LtiServiceConnector
{
    public function __construct(ICache $cache)
    {
        $client = new Client();
        parent::__construct($cache, $client);
        
        // Enable debugging based on environment
        if (ENVIRONMENT == 'development') {
            $this->setDebuggingMode(true);
        }
    }
}

Create an LTI Library

Create a central LTI library class to coordinate your LTI operations:

<?php

namespace App\Libraries;

use App\Services\Lti\CiCache;
use App\Services\Lti\CiCookie;
use App\Services\Lti\CiDatabase;
use App\Services\Lti\CiServiceConnector;
use Packback\Lti1p3\DeepLinkResources\Resource;
use Packback\Lti1p3\JwksEndpoint;
use Packback\Lti1p3\LtiException;
use Packback\Lti1p3\LtiGrade;
use Packback\Lti1p3\LtiMessageLaunch;
use Packback\Lti1p3\LtiOidcLogin;

class LtiLibrary
{
    private $database;
    private $cache;
    private $cookie;
    private $connector;
    
    public function __construct()
    {
        $this->database = new CiDatabase();
        $this->cache = new CiCache();
        $this->cookie = new CiCookie();
        $this->connector = new CiServiceConnector($this->cache);
    }
    
    /**
     * Handle OIDC login request from LMS
     */
    public function handleLogin(array $request, string $launchUrl)
    {
        $login = new LtiOidcLogin($this->database, $this->cache, $this->cookie);
        
        try {
            $redirectUrl = $login->getRedirectUrl($launchUrl, $request);
            return redirect()->to($redirectUrl);
        } catch (LtiException $e) {
            log_message('error', 'LTI login error: ' . $e->getMessage());
            throw $e;
        }
    }
    
    /**
     * Handle and validate the LTI message launch
     */
    public function validateLaunch(array $request)
    {
        $launch = LtiMessageLaunch::new(
            $this->database,
            $this->cache,
            $this->cookie,
            $this->connector
        );
        
        try {
            return $launch->initialize($request);
        } catch (LtiException $e) {
            log_message('error', 'LTI launch error: ' . $e->getMessage());
            throw $e;
        }
    }
    
    /**
     * Retrieve a previously validated launch
     */
    public function getLaunchFromCache(string $launchId)
    {
        return LtiMessageLaunch::fromCache(
            $launchId,
            $this->database,
            $this->cache,
            $this->cookie,
            $this->connector
        );
    }
    
    /**
     * Create deep link response
     */
    public function createDeepLinkResponse(string $launchId, array $resources = [])
    {
        $launch = $this->getLaunchFromCache($launchId);
        
        if (!$launch->isDeepLinkLaunch()) {
            throw new LtiException('Not a deep linking launch');
        }
        
        $dl = $launch->getDeepLink();
        
        // Create resources if none provided
        if (empty($resources)) {
            $resources = [
                Resource::new()
                    ->setUrl(site_url('lti/launch'))
                    ->setTitle('My Resource')
                    ->setText('Resource description')
            ];
        }
        
        // Get JWT for the response
        $jwt = $dl->getResponseJwt($resources);
        $returnUrl = $dl->returnUrl();
        
        return [
            'jwt' => $jwt,
            'return_url' => $returnUrl
        ];
    }
    
    /**
     * Submit a grade using AGS
     */
    public function submitGrade(string $launchId, float $score, string $userId)
    {
        $launch = $this->getLaunchFromCache($launchId);
        
        if (!$launch->hasAgs()) {
            throw new LtiException('Assignments and Grades service not available');
        }
        
        $ags = $launch->getAgs();
        
        $grade = LtiGrade::new()
            ->setScoreGiven($score)
            ->setScoreMaximum(100)
            ->setUserId($userId)
            ->setTimestamp(date('c'))
            ->setActivityProgress('Completed')
            ->setGradingProgress('FullyGraded');
        
        return $ags->putGrade($grade);
    }
    
    /**
     * Get grades for a user (or all users)
     */
    public function getGrades(string $launchId, ?string $userId = null)
    {
        $launch = $this->getLaunchFromCache($launchId);
        
        if (!$launch->hasAgs()) {
            throw new LtiException('Assignments and Grades service not available');
        }
        
        $ags = $launch->getAgs();
        return $ags->getGrades(null, $userId);
    }
    
    /**
     * Get user roster using NRPS
     */
    public function getMembers(string $launchId)
    {
        $launch = $this->getLaunchFromCache($launchId);
        
        if (!$launch->hasNrps()) {
            return []; // Service not available
        }
        
        $nrps = $launch->getNrps();
        return $nrps->getMembers();
    }
    
    /**
     * Get course groups
     */
    public function getGroups(string $launchId)
    {
        $launch = $this->getLaunchFromCache($launchId);
        
        if (!$launch->hasGs()) {
            return []; // Service not available
        }
        
        $gs = $launch->getGs();
        return $gs->getGroups();
    }
    
    /**
     * Get JWKS to verify signatures
     */
    public function getPublicJwks()
    {
        return JwksEndpoint::fromIssuer($this->database, base_url())->getPublicJwks();
    }
}

Database Migrations

Create a migration file for the required tables:

<?php

namespace App\Database\Migrations;

use CodeIgniter\Database\Migration;

class CreateLtiTables extends Migration
{
    public function up()
    {
        // Issuers table
        $this->forge->addField([
            'id' => [
                'type' => 'INT',
                'constraint' => 11,
                'unsigned' => true,
                'auto_increment' => true,
            ],
            'issuer' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'client_id' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'key_set_url' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'auth_token_url' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'auth_login_url' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'auth_server' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
                'null' => true,
            ],
            'tool_private_key' => [
                'type' => 'TEXT',
            ],
            'kid' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
                'null' => true,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);
        $this->forge->addKey('id', true);
        $this->forge->addKey(['issuer', 'client_id']);
        $this->forge->createTable('issuers');

        // Deployments table
        $this->forge->addField([
            'id' => [
                'type' => 'INT',
                'constraint' => 11,
                'unsigned' => true,
                'auto_increment' => true,
            ],
            'issuer_id' => [
                'type' => 'INT',
                'constraint' => 11,
                'unsigned' => true,
            ],
            'deployment_id' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);
        $this->forge->addKey('id', true);
        $this->forge->addKey(['issuer_id', 'deployment_id']);
        $this->forge->addForeignKey('issuer_id', 'issuers', 'id', 'CASCADE', 'CASCADE');
        $this->forge->createTable('deployments');
        
        // Optional: LTI 1.1 keys table for migration support
        $this->forge->addField([
            'id' => [
                'type' => 'INT',
                'constraint' => 11,
                'unsigned' => true,
                'auto_increment' => true,
            ],
            'oauth_consumer_key' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'secret' => [
                'type' => 'VARCHAR',
                'constraint' => 255,
            ],
            'created_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
            'updated_at' => [
                'type' => 'DATETIME',
                'null' => true,
            ],
        ]);
        $this->forge->addKey('id', true);
        $this->forge->addKey('oauth_consumer_key');
        $this->forge->createTable('lti1p1_keys');
    }

    public function down()
    {
        $this->forge->dropTable('deployments');
        $this->forge->dropTable('issuers');
        $this->forge->dropTable('lti1p1_keys');
    }
}

Controllers

Base LTI Controller

Create a base controller for common LTI functionality:

<?php

namespace App\Controllers;

use App\Libraries\LtiLibrary;
use CodeIgniter\Controller;
use Packback\Lti1p3\LtiException;

class LtiBaseController extends Controller
{
    protected $ltiLibrary;
    
    public function __construct()
    {
        $this->ltiLibrary = new LtiLibrary();
    }
    
    protected function handleLtiException(LtiException $e)
    {
        return $this->response->setStatusCode(400)
            ->setJSON(['error' => $e->getMessage()]);
    }
    
    protected function getLaunchFromSession()
    {
        $session = \Config\Services::session();
        $launchId = $session->get('lti_launch_id');
        
        if (!$launchId) {
            throw new LtiException('No LTI launch found in session');
        }
        
        return $this->ltiLibrary->getLaunchFromCache($launchId);
    }
}

Login Controller

<?php

namespace App\Controllers;

use Packback\Lti1p3\LtiException;

class LtiLoginController extends LtiBaseController
{
    public function index()
    {
        try {
            return $this->ltiLibrary->handleLogin(
                $this->request->getGet(),
                site_url('lti/launch')
            );
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
}

Launch Controller

<?php

namespace App\Controllers;

use Packback\Lti1p3\LtiException;

class LtiLaunchController extends LtiBaseController
{
    public function index()
    {
        $session = \Config\Services::session();
        
        try {
            $launch = $this->ltiLibrary->validateLaunch($this->request->getPost());
            
            // Store launch ID in session
            $session->set('lti_launch_id', $launch->getLaunchId());
            
            if ($launch->isDeepLinkLaunch()) {
                return redirect()->to('lti/deep-link');
            }
            
            if ($launch->isResourceLaunch()) {
                return redirect()->to('lti/resource');
            }
            
            if ($launch->isSubmissionReviewLaunch()) {
                return redirect()->to('lti/submission-review');
            }
            
            return $this->response->setStatusCode(400)
                ->setJSON(['error' => 'Unknown launch type']);
                
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
}

Deep Link Controller

<?php

namespace App\Controllers;

use Packback\Lti1p3\LtiException;

class LtiDeepLinkController extends LtiBaseController
{
    public function index()
    {
        try {
            $launch = $this->getLaunchFromSession();
            
            if (!$launch->isDeepLinkLaunch()) {
                throw new LtiException('Not a deep linking launch');
            }
            
            $settings = $launch->getDeepLink()->settings();
            
            return view('lti/deep_link', [
                'launch' => $launch,
                'settings' => $settings
            ]);
            
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
    
    public function response()
    {
        try {
            $session = \Config\Services::session();
            $launchId = $session->get('lti_launch_id');
            
            $response = $this->ltiLibrary->createDeepLinkResponse($launchId);
            
            return view('lti/auto_submit', [
                'jwt' => $response['jwt'],
                'return_url' => $response['return_url']
            ]);
            
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
}

JWKS Controller

<?php

namespace App\Controllers;

class JwksController extends LtiBaseController
{
    public function index()
    {
        return $this->response->setJSON($this->ltiLibrary->getPublicJwks());
    }
}

Resource Controller

<?php

namespace App\Controllers;

use Packback\Lti1p3\LtiException;

class LtiResourceController extends LtiBaseController
{
    public function index()
    {
        try {
            $launch = $this->getLaunchFromSession();
            
            $data = [
                'launch' => $launch,
                'launchData' => $launch->getLaunchData(),
                'members' => [],
                'hasNrps' => $launch->hasNrps(),
                'hasAgs' => $launch->hasAgs(),
                'hasGs' => $launch->hasGs(),
            ];
            
            if ($launch->hasNrps()) {
                $data['members'] = $this->ltiLibrary->getMembers($launch->getLaunchId());
            }
            
            return view('lti/resource', $data);
            
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
    
    public function submitGrade()
    {
        try {
            $launchId = \Config\Services::session()->get('lti_launch_id');
            $score = $this->request->getPost('score');
            $userId = $this->request->getPost('user_id');
            
            $result = $this->ltiLibrary->submitGrade($launchId, $score, $userId);
            
            return $this->response->setJSON(['success' => true, 'result' => $result]);
            
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
    
    public function getGrades()
    {
        try {
            $launchId = \Config\Services::session()->get('lti_launch_id');
            $userId = $this->request->getGet('user_id');
            
            $grades = $this->ltiLibrary->getGrades($launchId, $userId);
            
            return $this->response->setJSON(['success' => true, 'grades' => $grades]);
            
        } catch (LtiException $e) {
            return $this->handleLtiException($e);
        }
    }
}

Views

Deep Linking View

Create app/Views/lti/deep_link.php:

<!DOCTYPE html>
<html>
<head>
    <title>Deep Linking</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        .card {
            border: 1px solid #ddd;
            border-radius: 5px;
            padding: 20px;
            margin-bottom: 20px;
        }
        button {
            background-color: #4CAF50;
            color: white;
            padding: 10px 15px;
            border: none;
            border-radius: 5px;
            cursor: pointer;
        }
    </style>
</head>
<body>
    <h1>LTI Deep Linking</h1>
    
    <div class="card">
        <h2>Settings</h2>
        <p>Title: <?= $settings['title'] ?? 'N/A' ?></p>
        <p>Accept Types: <?= implode(', ', $settings['accept_types'] ?? []) ?></p>
        <p>Accept Multiple: <?= $settings['accept_multiple'] ?? false ? 'Yes' : 'No' ?></p>
    </div>
    
    <div class="card">
        <h2>Select Resource</h2>
        <p>Choose a resource to send back to the platform:</p>
        
        <form action="<?= site_url('lti/deep-link/response') ?>" method="post">
            <div>
                <input type="radio" id="resource1" name="resource" value="resource1" checked>
                <label for="resource1">Basic Resource</label>
            </div>
            <div>
                <input type="radio" id="resource2" name="resource" value="resource2">
                <label for="resource2">Quiz Resource</label>
            </div>
            <div>
                <input type="radio" id="resource3" name="resource" value="resource3">
                <label for="resource3">Assignment Resource</label>
            </div>
            <br>
            <button type="submit">Select Resource</button>
        </form>
    </div>
</body>
</html>

Auto-Submit Form

Create app/Views/lti/auto_submit.php:

<!DOCTYPE html>
<html>
<head>
    <title>Submitting to LMS...</title>
    <script>
        window.onload = function() {
            document.getElementById('form').submit();
        }
    </script>
</head>
<body>
    <form id="form" action="<?= $return_url ?>" method="post">
        <input type="hidden" name="JWT" value="<?= $jwt ?>">
        <p>Submitting response to LMS. If you are not automatically redirected, please click the button below.</p>
        <button type="submit">Continue to LMS</button>
    </form>
</body>
</html>

Resource View

Create app/Views/lti/resource.php:

<!DOCTYPE html>
<html>
<head>
    <title>LTI Resource</title>
    <style>
        body {
            font-family: Arial, sans-serif;
            margin: 20px;
        }
        .card {
            border: 1px solid #ddd;
            border-radius: 5px;
            padding: 20px;
            margin-bottom: 20px;
        }
        table {
            width: 100%;
            border-collapse: collapse;
        }
        table, th, td {
            border: 1px solid #ddd;
        }
        th, td {
            padding: 8px;
            text-align: left;
        }
        th {
            background-color: #f2f2f2;
        }
    </style>
</head>
<body>
    <h1>LTI Resource</h1>
    
    <div class="card">
        <h2>Launch Information</h2>
        <p>Message Type: <?= $launchData['https://purl.imsglobal.org/spec/lti/claim/message_type'] ?></p>
        <p>Launch ID: <?= $launch->getLaunchId() ?></p>
        <p>User: <?= $launchData['name'] ?? 'Anonymous' ?></p>
        <p>Email: <?= $launchData['email'] ?? 'N/A' ?></p>
    </div>
    
    <?php if ($hasNrps && !empty($members)): ?>
    <div class="card">
        <h2>Class Roster</h2>
        <table>
            <thead>
                <tr>
                    <th>Name</th>
                    <th>Email</th>
                    <th>Roles</th>
                    <?php if ($hasAgs): ?>
                    <th>Actions</th>
                    <?php endif; ?>
                </tr>
            </thead>
            <tbody>
                <?php foreach ($members as $member): ?>
                <tr>
                    <td><?= $member['name'] ?? 'Unknown' ?></td>
                    <td><?= $member['email'] ?? 'N/A' ?></td>
                    <td><?= implode(', ', $member['roles'] ?? []) ?></td>
                    <?php if ($hasAgs): ?>
                    <td>
                        <button onclick="submitGrade('<?= $member['user_id'] ?>', 85)">Submit Grade</button>
                    </td>
                    <?php endif; ?>
                </tr>
                <?php endforeach; ?>
            </tbody>
        </table>
    </div>
    <?php endif; ?>
    
    <?php if ($hasAgs): ?>
    <div class="card">
        <h2>Grades</h2>
        <button onclick="getGrades()">View All Grades</button>
        <div id="grades-container"></div>
    </div>
    
    <script>
        function submitGrade(userId, score) {
            fetch('<?= site_url('lti/resource/submit-grade') ?>', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                body: new URLSearchParams({
                    user_id: userId,
                    score: score
                })
            })
            .then(response => response.json())
            .then(data => {
                alert(data.success ? 'Grade submitted successfully!' : 'Error submitting grade');
            });
        }
        
        function getGrades(userId = null) {
            let url = '<?= site_url('lti/resource/get-grades') ?>';
            if (userId) {
                url += '?user_id=' + userId;
            }
            
            fetch(url)
            .then(response => response.json())
            .then(data => {
                if (data.success) {
                    let html = '<h3>Retrieved Grades</h3><pre>' + 
                        JSON.stringify(data.grades, null, 2) + '</pre>';
                    document.getElementById('grades-container').innerHTML = html;
                } else {
                    alert('Error retrieving grades');
                }
            });
        }
    </script>
    <?php endif; ?>
</body>
</html>

Routes

Add these routes to your app/Config/Routes.php:

// LTI routes
$routes->get('lti/login', 'LtiLoginController::index');
$routes->post('lti/launch', 'LtiLaunchController::index');
$routes->get('lti/deep-link', 'LtiDeepLinkController::index');
$routes->post('lti/deep-link/response', 'LtiDeepLinkController::response');
$routes->get('lti/resource', 'LtiResourceController::index');
$routes->post('lti/resource/submit-grade', 'LtiResourceController::submitGrade');
$routes->get('lti/resource/get-grades', 'LtiResourceController::getGrades');

// JWKS endpoint
$routes->get('.well-known/jwks.json', 'JwksController::index');

Admin Controller for Managing LTI Configurations

<?php

namespace App\Controllers;

use App\Models\IssuerModel;
use App\Models\DeploymentModel;
use CodeIgniter\Controller;

class LtiAdminController extends Controller
{
    protected $issuerModel;
    protected $deploymentModel;
    
    public function __construct()
    {
        $this->issuerModel = new IssuerModel();
        $this->deploymentModel = new DeploymentModel();
    }
    
    public function index()
    {
        $issuers = $this->issuerModel->findAll();
        
        return view('lti/admin/index', [
            'issuers' => $issuers
        ]);
    }
    
    public function createIssuer()
    {
        if ($this->request->getMethod() === 'post') {
            $this->issuerModel->insert([
                'issuer' => $this->request->getPost('issuer'),
                'client_id' => $this->request->getPost('client_id'),
                'key_set_url' => $this->request->getPost('key_set_url'),
                'auth_token_url' => $this->request->getPost('auth_token_url'),
                'auth_login_url' => $this->request->getPost('auth_login_url'),
                'auth_server' => $this->request->getPost('auth_server'),
                'tool_private_key' => $this->request->getPost('tool_private_key'),
                'kid' => $this->request->getPost('kid'),
            ]);
            
            return redirect()->to('lti/admin')->with('message', 'Issuer created successfully');
        }
        
        return view('lti/admin/create_issuer');
    }
    
    public function viewDeployments($issuerId)
    {
        $issuer = $this->issuerModel->find($issuerId);
        $deployments = $this->deploymentModel->where('issuer_id', $issuerId)->findAll();
        
        return view('lti/admin/deployments', [
            'issuer' => $issuer,
            'deployments' => $deployments
        ]);
    }
    
    public function createDeployment($issuerId)
    {
        if ($this->request->getMethod() === 'post') {
            $this->deploymentModel->insert([
                'issuer_id' => $issuerId,
                'deployment_id' => $this->request->getPost('deployment_id')
            ]);
            
            return redirect()->to('lti/admin/issuer/'.$issuerId.'/deployments')
                ->with('message', 'Deployment created successfully');
        }
        
        $issuer = $this->issuerModel->find($issuerId);
        
        return view('lti/admin/create_deployment', [
            'issuer' => $issuer
        ]);
    }
    
    public function deleteIssuer($issuerId)
    {
        $this->issuerModel->delete($issuerId);
        return redirect()->to('lti/admin')->with('message', 'Issuer deleted successfully');
    }
    
    public function deleteDeployment($deploymentId)
    {
        $deployment = $this->deploymentModel->find($deploymentId);
        $issuerId = $deployment['issuer_id'];
        
        $this->deploymentModel->delete($deploymentId);
        
        return redirect()->to('lti/admin/issuer/'.$issuerId.'/deployments')
            ->with('message', 'Deployment deleted successfully');
    }
}

Add routes for the admin controller:

// LTI Admin routes
$routes->group('lti/admin', ['filter' => 'auth'], function($routes) {
    $routes->get('/', 'LtiAdminController::index');
    $routes->get('issuer/create', 'LtiAdminController::createIssuer');
    $routes->post('issuer/create', 'LtiAdminController::createIssuer');
    $routes->get('issuer/(:num)/deployments', 'LtiAdminController::viewDeployments/$1');
    $routes->get('issuer/(:num)/deployment/create', 'LtiAdminController::createDeployment/$1');
    $routes->post('issuer/(:num)/deployment/create', 'LtiAdminController::createDeployment/$1');
    $routes->get('issuer/(:num)/delete', 'LtiAdminController::deleteIssuer/$1');
    $routes->get('deployment/(:num)/delete', 'LtiAdminController::deleteDeployment/$1');
});

Setup Instructions

  1. Create the database tables using the migration:

    php spark migrate
  2. Generate a private/public key pair:

    openssl genrsa -out private.key 2048
    openssl rsa -in private.key -pubout -out public.key
  3. Configure your LMS with:

    • Login URL: https://your-domain.com/lti/login
    • Launch URL: https://your-domain.com/lti/launch
    • JWKS URL: https://your-domain.com/.well-known/jwks.json
  4. Create an issuer and deployment in your admin panel.

  5. Test the LTI launch from your LMS.