From d52c871bb6a880bf9fd40311b5e79c7fb81f6032 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 22 May 2026 09:06:08 -0400 Subject: [PATCH 01/28] Add LTI tables --- .../Version20260521190425.php | 57 ++++++ src/Entity/Deployment.php | 62 +++++++ src/Entity/Registration.php | 168 ++++++++++++++++++ src/Entity/SigningKey.php | 94 ++++++++++ src/Entity/SigningKeySet.php | 83 +++++++++ 5 files changed, 464 insertions(+) create mode 100644 src/DoctrineMigrations/Version20260521190425.php create mode 100644 src/Entity/Deployment.php create mode 100644 src/Entity/Registration.php create mode 100644 src/Entity/SigningKey.php create mode 100644 src/Entity/SigningKeySet.php diff --git a/src/DoctrineMigrations/Version20260521190425.php b/src/DoctrineMigrations/Version20260521190425.php new file mode 100644 index 000000000..c095542f6 --- /dev/null +++ b/src/DoctrineMigrations/Version20260521190425.php @@ -0,0 +1,57 @@ +addSql('CREATE TABLE deployment (id INT AUTO_INCREMENT NOT NULL, lms_deployment_id VARCHAR(2048) NOT NULL, registration_id INT NOT NULL, INDEX IDX_EB1255BE833D8F43 (registration_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE lti_session (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, nonce VARCHAR(255) NOT NULL, iss VARCHAR(255) NOT NULL, data_temporary JSON NOT NULL, created DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, service_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE signing_key (id INT AUTO_INCREMENT NOT NULL, public_key LONGTEXT NOT NULL, private_key LONGTEXT NOT NULL, algorithm VARCHAR(200) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_3DAB554EA409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE signing_key_set (id INT AUTO_INCREMENT NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('ALTER TABLE deployment ADD CONSTRAINT FK_EB1255BE833D8F43 FOREIGN KEY (registration_id) REFERENCES registration (id)'); + $this->addSql('ALTER TABLE registration ADD CONSTRAINT FK_62A8A7A7A409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE RESTRICT'); + $this->addSql('ALTER TABLE signing_key ADD CONSTRAINT FK_3DAB554EA409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE SET NULL'); + $this->addSql('ALTER TABLE institution CHANGE api_client_secret api_client_secret_encrypted VARCHAR(255) DEFAULT NULL'); + $this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages'); + $this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages'); + $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages'); + $this->addSql('ALTER TABLE messenger_messages CHANGE created_at created_at DATETIME NOT NULL, CHANGE available_at available_at DATETIME NOT NULL, CHANGE delivered_at delivered_at DATETIME DEFAULT NULL'); + $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages (queue_name, available_at, delivered_at, id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE deployment DROP FOREIGN KEY FK_EB1255BE833D8F43'); + $this->addSql('ALTER TABLE registration DROP FOREIGN KEY FK_62A8A7A7A409F300'); + $this->addSql('ALTER TABLE signing_key DROP FOREIGN KEY FK_3DAB554EA409F300'); + $this->addSql('DROP TABLE deployment'); + $this->addSql('DROP TABLE lti_session'); + $this->addSql('DROP TABLE registration'); + $this->addSql('DROP TABLE signing_key'); + $this->addSql('DROP TABLE signing_key_set'); + $this->addSql('ALTER TABLE institution CHANGE api_client_secret_encrypted api_client_secret VARCHAR(255) DEFAULT NULL'); + $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages'); + $this->addSql('ALTER TABLE messenger_messages CHANGE created_at created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE available_at available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE delivered_at delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\''); + $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); + $this->addSql('CREATE INDEX IDX_75EA56E0E3BD61CE ON messenger_messages (available_at)'); + $this->addSql('CREATE INDEX IDX_75EA56E0FB7336F0 ON messenger_messages (queue_name)'); + } +} diff --git a/src/Entity/Deployment.php b/src/Entity/Deployment.php new file mode 100644 index 000000000..19fcf0bf7 --- /dev/null +++ b/src/Entity/Deployment.php @@ -0,0 +1,62 @@ +registration = $registration; + $this->lmsDeploymentId = $lmsDeploymentId; + } + + public function getId(): int + { + return $this->id; + } + + public function getRegistration(): Registration + { + return $this->registration; + } + + public function setRegistration(Registration $registration): static + { + $this->registration = $registration; + + return $this; + } + + public function getLmsDeploymentId(): string + { + return $this->lmsDeploymentId; + } + + public function setLmsDeploymentId(string $lmsDeploymentId): static + { + $this->lmsDeploymentId = $lmsDeploymentId; + + return $this; + } +} diff --git a/src/Entity/Registration.php b/src/Entity/Registration.php new file mode 100644 index 000000000..fbab3e1c4 --- /dev/null +++ b/src/Entity/Registration.php @@ -0,0 +1,168 @@ +issuer = $issuer; + $this->clientId = $clientId; + $this->loginAuthEndpoint = $loginAuthEndpoint; + $this->serviceAuthEndpoint = $serviceAuthEndpoint; + $this->jwksEndpoint = $jwksEndpoint; + $this->signingKeySet = $signingKeySet; + $this->deployments = new ArrayCollection(); + $this->encryptionService = new SodiumEncryptionService(); + } + + + public function getId(): int + { + return $this->id; + } + + public function getIssuer(): string + { + return $this->issuer; + } + + public function setIssuer(string $issuer): static + { + $this->issuer = $issuer; + + return $this; + } + + public function getClientId(): string + { + return $this->clientId; + } + + public function setClientId(string $clientId): static + { + $this->clientId = $clientId; + + return $this; + } + + public function getLoginAuthEndpoint(): string + { + return $this->loginAuthEndpoint; + } + + public function setLoginAuthEndpoint(string $loginAuthEndpoint): static + { + $this->loginAuthEndpoint = $loginAuthEndpoint; + + return $this; + } + + public function getServiceAuthEndpoint(): string + { + return $this->serviceAuthEndpoint; + } + + public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static + { + $this->serviceAuthEndpoint = $serviceAuthEndpoint; + + return $this; + } + + public function getJwksEndpoint(): string + { + return $this->jwksEndpoint; + } + + public function setJwksEndpoint(string $jwksEndpoint): static + { + $this->jwksEndpoint = $jwksEndpoint; + + return $this; + } + + public function getSigningKeySet(): SigningKeySet + { + return $this->signingKeySet; + } + + public function setSigningKeySet(SigningKeySet $signingKeySet): static + { + $this->signingKeySet = $signingKeySet; + + return $this; + } + + public function getDeployments(): Collection + { + return $this->deployments; + } + + public function addDeployment(Deployment $deployment): static + { + if (!$this->deployments->contains($deployment)) { + $this->deployments->add($deployment); + $deployment->setRegistration($this); + } + + return $this; + } + + public function removeDeployment(Deployment $deployment): static + { + $this->deployments->removeElement($deployment); + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/SigningKey.php b/src/Entity/SigningKey.php new file mode 100644 index 000000000..ecbc8dd57 --- /dev/null +++ b/src/Entity/SigningKey.php @@ -0,0 +1,94 @@ +publicKey = $publicKey; + $this->privateKey = $privateKey; + $this->algorithm = $algorithm; + $this->signingKeySet = $signingKeySet; + } + + public function getId(): int + { + return $this->id; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function setPublicKey(string $publicKey): static + { + $this->publicKey = $publicKey; + + return $this; + } + + public function getPrivateKey(): string + { + return $this->privateKey; + } + + public function setPrivateKey(string $privateKey): static + { + $this->privateKey = $privateKey; + + return $this; + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function setAlgorithm(string $algorithm): static + { + $this->algorithm = $algorithm; + + return $this; + } + + public function getSigningKeySet(): ?SigningKeySet + { + return $this->signingKeySet; + } + + public function setSigningKeySet(?SigningKeySet $signingKeySet): static + { + $this->signingKeySet = $signingKeySet; + + return $this; + } +} \ No newline at end of file diff --git a/src/Entity/SigningKeySet.php b/src/Entity/SigningKeySet.php new file mode 100644 index 000000000..a40a8df6e --- /dev/null +++ b/src/Entity/SigningKeySet.php @@ -0,0 +1,83 @@ +signingKeys = new ArrayCollection(); + $this->registrations = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getSigningKeys(): Collection + { + return $this->signingKeys; + } + + public function addSigningKey(SigningKey $signingKey): static + { + if (!$this->signingKeys->contains($signingKey)) { + $this->signingKeys->add($signingKey); + $signingKey->setSigningKeySet($this); + } + + return $this; + } + + public function removeSigningKey(SigningKey $signingKey): static + { + if ($this->signingKeys->removeElement($signingKey)) { + if ($signingKey->getSigningKeySet() === $this) { + $signingKey->setSigningKeySet(null); + } + } + + return $this; + } + + public function getRegistrations(): Collection + { + return $this->registrations; + } + + public function addRegistration(Registration $registration): static + { + if (!$this->registrations->contains($registration)) { + $this->registrations->add($registration); + $registration->setSigningKeySet($this); + } + + return $this; + } + + public function removeRegistration(Registration $registration): static + { + $this->registrations->removeElement($registration); + + return $this; + } +} \ No newline at end of file From 7ca3cccc318b46e942d2f2848d694fe3c5b0d17b Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 22 May 2026 09:08:18 -0400 Subject: [PATCH 02/28] Abstract API key encryption --- src/Controller/AuthController.php | 12 +++++--- src/Entity/Institution.php | 10 +++---- .../Encryption/EncryptionServiceInterface.php | 10 +++++++ .../InstitutionEncryptionService.php | 25 +++++++++++++++++ .../Encryption/SodiumEncryptionService.php | 28 +++++++++++++++++++ src/Services/LmsUserService.php | 18 ++++++++---- 6 files changed, 89 insertions(+), 14 deletions(-) create mode 100644 src/Services/Encryption/EncryptionServiceInterface.php create mode 100644 src/Services/Encryption/InstitutionEncryptionService.php create mode 100644 src/Services/Encryption/SodiumEncryptionService.php diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index f12cad7d7..4235e8e12 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -2,6 +2,7 @@ namespace App\Controller; +use App\Services\Encryption\InstitutionEncryptionService; use App\Services\LmsApiService; use App\Services\LmsUserService; use App\Services\SessionService; @@ -25,11 +26,14 @@ class AuthController extends AbstractController private ManagerRegistry $doctrine; + private InstitutionEncryptionService $institutionEncryptionService; + private $session; - public function __construct(ManagerRegistry $doctrine) + public function __construct(ManagerRegistry $doctrine, InstitutionEncryptionService $institutionEncryptionService) { $this->doctrine = $doctrine; + $this->institutionEncryptionService = $institutionEncryptionService; } #[Route('/authorize', name: 'authorize')] @@ -129,13 +133,13 @@ protected function requestApiKeyFromLms(): mixed $user = $this->getUser(); $institution = $user->getInstitution(); $code = $this->request->query->get('code'); - $clientSecret = $institution->getApiClientSecret(); + $clientSecret = $this->institutionEncryptionService->getClientSecret($institution); $userAgent = 'UDOIT/' . !empty($_ENV['VERSION_NUMBER']) ? $_ENV['VERSION_NUMBER'] : '4.0.0'; if (empty($clientSecret)) { $institution->encryptDeveloperKey(); $this->doctrine->getManager()->flush(); - $clientSecret = $institution->getApiClientSecret(); + $clientSecret = $this->institutionEncryptionService->getClientSecret($institution); } $options = [ @@ -143,7 +147,7 @@ protected function requestApiKeyFromLms(): mixed 'grant_type' => 'authorization_code', 'client_id' => $institution->getApiClientId(), 'redirect_uri' => LmsUserService::getOauthRedirectUri(), - 'client_secret' => $institution->getApiClientSecret(), + 'client_secret' => $this->institutionEncryptionService->getClientSecret($institution), 'code' => $code, ], 'headers' => [ diff --git a/src/Entity/Institution.php b/src/Entity/Institution.php index 88183bc23..fa5be9965 100644 --- a/src/Entity/Institution.php +++ b/src/Entity/Institution.php @@ -56,7 +56,7 @@ class Institution implements JsonSerializable private $apiClientId; #[ORM\Column(type: "string", length: 255, nullable: true)] - private $apiClientSecret; + private string $apiClientSecretEncrypted; // Constructor @@ -271,14 +271,14 @@ public function setApiClientId(?int $apiClientId): self return $this; } - public function getApiClientSecret(): ?string + public function getApiClientSecretEncrypted(): ?string { - return $this->decryptData($this->apiClientSecret); + return $this->apiClientSecretEncrypted; } - public function setApiClientSecret(?string $apiClientSecret): self + public function setApiClientSecretEncrypted(?string $apiClientSecretEncrypted): self { - $this->apiClientSecret = $this->encryptData($apiClientSecret); + $this->apiClientSecretEncrypted = $apiClientSecretEncrypted; return $this; } diff --git a/src/Services/Encryption/EncryptionServiceInterface.php b/src/Services/Encryption/EncryptionServiceInterface.php new file mode 100644 index 000000000..04faa15ec --- /dev/null +++ b/src/Services/Encryption/EncryptionServiceInterface.php @@ -0,0 +1,10 @@ +setApiClientSecretEncrypted($this->encryptionService->encrypt($secret)); + } + + public function getClientSecret(Institution $institution): string + { + return $this->encryptionService->decrypt($institution->getApiClientSecretEncrypted()); + } + +} \ No newline at end of file diff --git a/src/Services/Encryption/SodiumEncryptionService.php b/src/Services/Encryption/SodiumEncryptionService.php new file mode 100644 index 000000000..c00705d56 --- /dev/null +++ b/src/Services/Encryption/SodiumEncryptionService.php @@ -0,0 +1,28 @@ +encodedKey); + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $encrypted_data = sodium_crypto_secretbox($plaintext, $nonce, $key); + + return base64_encode($nonce . $encrypted_data); + } + + public function decrypt(string $ciphertext): string + { + $key = base64_decode($this->encodedKey); + $decoded = base64_decode($ciphertext); + $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); + $encrypted_text = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, NULL, '8bit'); + + return sodium_crypto_secretbox_open($encrypted_text, $nonce, $key); + } +} \ No newline at end of file diff --git a/src/Services/LmsUserService.php b/src/Services/LmsUserService.php index bcc3b6127..7026954f3 100644 --- a/src/Services/LmsUserService.php +++ b/src/Services/LmsUserService.php @@ -3,11 +3,12 @@ namespace App\Services; use App\Entity\User; +use App\Services\Encryption\InstitutionEncryptionService; use App\Services\LmsApiService; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\HttpClient\HttpClient; -use Symfony\Component\HttpClient\Exception\TimeoutException; use Symfony\Component\Console\Output\ConsoleOutput; +use Symfony\Component\HttpClient\Exception\TimeoutException; +use Symfony\Component\HttpClient\HttpClient; class LmsUserService { @@ -21,11 +22,18 @@ class LmsUserService { /** @var UtilityService $util */ protected $util; - public function __construct(LmsApiService $lmsApi, ManagerRegistry $doctrine, UtilityService $util) - { + protected InstitutionEncryptionService $institutionEncryptionService; + + public function __construct( + LmsApiService $lmsApi, + ManagerRegistry $doctrine, + UtilityService $util, + InstitutionEncryptionService $institutionEncryptionService + ) { $this->lmsApi = $lmsApi; $this->doctrine = $doctrine; $this->util = $util; + $this->institutionEncryptionService = $institutionEncryptionService; } public static function getOauthRedirectUri() @@ -84,7 +92,7 @@ public function refreshApiKey(User $user) 'grant_type' => 'refresh_token', 'client_id' => $institution->getApiClientId(), 'redirect_uri' => self::getOauthRedirectUri(), - 'client_secret' => $institution->getApiClientSecret(), + 'client_secret' => $this->institutionEncryptionService->getClientSecret($institution), 'refresh_token' => $refreshToken, ], 'headers' => [ From 53360f85b39d1f9351a1b318ee9da1e7b10e2ddd Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 22 May 2026 09:09:05 -0400 Subject: [PATCH 03/28] Add CLI to create registration --- Makefile | 3 + docker-compose.nginx.yml | 3 + src/Command/CreateRegistrationCommand.php | 244 ++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 src/Command/CreateRegistrationCommand.php diff --git a/Makefile b/Makefile index f11bba5f8..03725b2ed 100644 --- a/Makefile +++ b/Makefile @@ -36,3 +36,6 @@ ins-mysql: # fill your institutions table data with the variables in your institutions.env file. Use this command if you are using postgresql. ins-psql: docker exec -it -e PGPASSWORD=root udoit3-db psql -U root -d udoit3 -w -c "INSERT INTO institution (title, lms_domain, lms_id, lms_account_id, created, status, vanity_url, metadata, api_client_id, api_client_secret) VALUES ('$(TITLE)', '$(LMS_DOMAIN)', '$(LMS_ID)', '$(LMS_ACCOUNT_ID)', '$(CREATED)', '$(STATUS)', '$(VANITY_URL)', '$(API_CLIENT_ID)', '$(API_CLIENT_SECRET)');" + +create-registration: + docker compose -f docker-compose.nginx.yml run --rm php php bin/console app:create-registration diff --git a/docker-compose.nginx.yml b/docker-compose.nginx.yml index 9f3071c55..9e3a51141 100644 --- a/docker-compose.nginx.yml +++ b/docker-compose.nginx.yml @@ -40,6 +40,9 @@ services: build: context: ./build/nginx dockerfile: Dockerfile.php.pdo.mysql + depends_on: + db: + condition: service_started volumes: - ./:/var/www/html - type: bind diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php new file mode 100644 index 000000000..fb6e7525b --- /dev/null +++ b/src/Command/CreateRegistrationCommand.php @@ -0,0 +1,244 @@ +getFormatter()->setStyle('header', $titleStyle); + + $io = new SymfonyStyle($input, $output); + + $io->writeln(''); + $io->writeln('
LTI Registration Creation Station
'); + $io->writeln('
---------------------------------
'); + $io->writeln(''); + + $platform = $this->getPlatform($io); + $clientId = $this->getClientId($io); + $keyset = $this->getKeyset($io); + $apiClientSecret = $this->getApiClientSecret($io); + + + $registration = new Registration( + $platform['issuer'], + $clientId, + $platform['loginAuthEndpoint'], + $platform['serviceAuthEndpoint'], + $platform['jwkEndpoint'], + $keyset, + ); + + $this->doctrine->getManager()->persist($registration); + $this->doctrine->getManager()->flush(); + + + return Command::SUCCESS; + + } + + + private function getPlatform(SymfonyStyle $io) + { + $platformPresets = [ + [ + 'name' => 'Production Canvas', + 'issuer' => 'https://canvas.instructure.com', + 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', + 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + ], + [ + 'name' => 'Test Canvas', + 'issuer' => 'https://canvas.test.instructure.com', + 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', + 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + ], + [ + 'name' => 'Beta Canvas', + 'issuer' => 'https://canvas.beta.instructure.com', + 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', + 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + ], + [ + 'name' => 'Devhub', + 'issuer' => 'https://canvas.instructure.com', + 'loginAuthEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/token', + 'jwkEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/security/jwks' + ], + ]; + + $customPlatformOption = 'Custom'; + + $platformOptions = array_map(fn($preset): string => $preset['name'], $platformPresets); + + $platformName = $io->choice('What is the platform?', [...$platformOptions, $customPlatformOption]); + + if ($platformName !== $customPlatformOption) { + $preset = array_find($platformPresets, fn($preset) => $preset['name'] === $platformName); + return [ + 'issuer' => $preset['issuer'], + 'loginAuthEndpoint' => $preset['loginAuthEndpoint'], + 'serviceAuthEndpoint' => $preset['serviceAuthEndpoint'], + 'jwkEndpoint' => $preset['jwkEndpoint'], + ]; + } + + $iss = $io->ask('Enter issuer'); + $loginAuthEndpoint = $io->ask('Enter login auth endpoint'); + $serviceAuthEndpoint = $io->ask('Enter service auth endpoint'); + $jwkEndpoint = $io->ask('Enter JWK endpoint'); + + return [ + 'issuer' => $iss, + 'loginAuthEndpoint' => $loginAuthEndpoint, + 'serviceAuthEndpoint' => $serviceAuthEndpoint, + 'jwkEndpoint' => $jwkEndpoint, + ]; + } + + private function getIss(SymfonyStyle $io) + { + $canvasIss = 'https://canvas.instructure.com'; + $devhubIss = 'https://devhub.cdl.ucf.edu'; + $customIss = 'Custom'; + + $issuerOptions = [ + $canvasIss, + $devhubIss, + $customIss, + ]; + + $iss = $io->choice('What is the issuer?', $issuerOptions); + + if ($iss === $customIss) + { + $iss = $io->ask('Enter custom issuer'); + } + + return $iss; + } + + private function getClientId(SymfonyStyle $io) + { + return $io->ask('Enter client ID'); + } + + private function getKeyset(SymfonyStyle $io) + { + $existingKeySets = $this->signingKeySetRepo->getAllKeySets(); + + $signingKeySet = null; + + $shouldGenerate = true; + if (!empty($existingKeySets)) + { + $createNewKeys = 'Create new keyset'; + $useExistingKeys = 'Use existing keyset'; + $keyChoice = $io->choice('What keys do you want to use?', [$createNewKeys, $useExistingKeys]); + if ($keyChoice === $useExistingKeys) + { + $shouldGenerate = false; + $existingKeySetChoices = array_reduce($existingKeySets, function ($carry, $keySet) { + $keySetId = $keySet->getId(); + $keySetKeyCount = count($keySet->getSigningKeys()); + $carry[$keySetId] = "Key set (ID {$keySetId}) with {$keySetKeyCount} keys"; + return $carry; + }); + + $keySetChoice = $io->choice('Select your existing key set', $existingKeySetChoices); + + $keySetChoiceId = array_search($keySetChoice, $existingKeySetChoices); + + if ($keySetChoiceId === false) + { + throw new \Exception('Invalid key set ID'); + } + + $signingKeySet = array_find($existingKeySets, fn($keySet) => $keySet->getId() === $keySetChoiceId); + } else + { + $shouldGenerate = true; + } + } + else + { + $io->writeln('No existing key sets found.'); + } + + if ($shouldGenerate) + { + $io->writeln('Generating new key key pair...'); + $keyPair = $this->generateRsaKeyPair(); + $signingKeySet = new SigningKeySet(); + $signingKey = new SigningKey( + $keyPair['publicKey'], + $keyPair['privateKey'], + $keyPair['alg'], + $signingKeySet, + ); + + $this->doctrine->getManager()->persist($signingKeySet); + $this->doctrine->getManager()->persist($signingKey); + $this->doctrine->getManager()->flush(); + } + + return $signingKeySet; + + } + + private function generateRsaKeyPair() + { + $config = [ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA + ]; + + $res = openssl_pkey_new($config); + + openssl_pkey_export($res, $privateKey); + + $details = openssl_pkey_get_details($res); + $publicKey = $details['key']; + + return [ + 'publicKey' => $publicKey, + 'privateKey' => $privateKey, + 'alg' => 'RS256', + ]; + } + + private function getApiClientSecret($io) + { + return $io->ask('Enter the API client secret'); + } + +} \ No newline at end of file From c9dffdf2ce7a339999b2a69cefdf1f59652976f3 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 22 May 2026 09:17:56 -0400 Subject: [PATCH 04/28] Add missing LTI repositories --- src/Repository/RegistrationRepository.php | 29 +++++++++ src/Repository/SigningKeySetRepository.php | 69 ++++++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 src/Repository/RegistrationRepository.php create mode 100644 src/Repository/SigningKeySetRepository.php diff --git a/src/Repository/RegistrationRepository.php b/src/Repository/RegistrationRepository.php new file mode 100644 index 000000000..dac89c2a2 --- /dev/null +++ b/src/Repository/RegistrationRepository.php @@ -0,0 +1,29 @@ +getEntityManager()->createQueryBuilder() + ->select('r') + ->from(Registration::class, 'r') + ->where('r.issuer = :iss') + ->andWhere('r.clientId = :clientId') + ->setParameter('iss', $iss) + ->setParameter('clientId', $clientId) + ->getQuery() + ->getResult(); + } +} \ No newline at end of file diff --git a/src/Repository/SigningKeySetRepository.php b/src/Repository/SigningKeySetRepository.php new file mode 100644 index 000000000..81f10c966 --- /dev/null +++ b/src/Repository/SigningKeySetRepository.php @@ -0,0 +1,69 @@ +getEntityManager()->createQueryBuilder() + ->select('ks', 'k') + ->from(SigningKeySet::class, 'ks') + ->leftJoin('ks.signingKeys', 'k') + ->getQuery() + ->getResult(); + } + + // public function deleteContentItemIssues(ContentItem $contentItem) + // { + // $this->getEntityManager()->createQueryBuilder() + // ->delete(Issue::class, 'i') + // ->where('i.contentItem = :contentItem AND i.status = :status') + // ->setParameter('contentItem', $contentItem) + // ->setParameter('status', Issue::$issueStatusActive) + // ->getQuery() + // ->getResult(); + // } + + // Returns an array of Issue objects + /* + public function findByExampleField($value): array + { + return $this->createQueryBuilder('i') + ->andWhere('i.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('i.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?Issue + { + return $this->createQueryBuilder('i') + ->andWhere('i.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} From fb7963c1f5f91d3bc54f51d0b489daceb982c2ec Mon Sep 17 00:00:00 2001 From: CJ Young Date: Wed, 27 May 2026 16:23:06 -0400 Subject: [PATCH 05/28] Add institution-registration relationship --- ...21190425.php => Version20260527202211.php} | 12 +++---- src/Entity/Institution.php | 32 ++++++++++++++++++- src/Entity/Registration.php | 27 ++++++++-------- 3 files changed, 51 insertions(+), 20 deletions(-) rename src/DoctrineMigrations/{Version20260521190425.php => Version20260527202211.php} (78%) diff --git a/src/DoctrineMigrations/Version20260521190425.php b/src/DoctrineMigrations/Version20260527202211.php similarity index 78% rename from src/DoctrineMigrations/Version20260521190425.php rename to src/DoctrineMigrations/Version20260527202211.php index c095542f6..8aba30562 100644 --- a/src/DoctrineMigrations/Version20260521190425.php +++ b/src/DoctrineMigrations/Version20260527202211.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260521190425 extends AbstractMigration +final class Version20260527202211 extends AbstractMigration { public function getDescription(): string { @@ -21,14 +21,14 @@ public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE TABLE deployment (id INT AUTO_INCREMENT NOT NULL, lms_deployment_id VARCHAR(2048) NOT NULL, registration_id INT NOT NULL, INDEX IDX_EB1255BE833D8F43 (registration_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); - $this->addSql('CREATE TABLE lti_session (id INT AUTO_INCREMENT NOT NULL, uuid VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, nonce VARCHAR(255) NOT NULL, iss VARCHAR(255) NOT NULL, data_temporary JSON NOT NULL, created DATETIME NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); - $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, service_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, institution_id INT NOT NULL, signing_key_set_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_62A8A7A710405986 (institution_id), INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key (id INT AUTO_INCREMENT NOT NULL, public_key LONGTEXT NOT NULL, private_key LONGTEXT NOT NULL, algorithm VARCHAR(200) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_3DAB554EA409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key_set (id INT AUTO_INCREMENT NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('ALTER TABLE deployment ADD CONSTRAINT FK_EB1255BE833D8F43 FOREIGN KEY (registration_id) REFERENCES registration (id)'); + $this->addSql('ALTER TABLE registration ADD CONSTRAINT FK_62A8A7A710405986 FOREIGN KEY (institution_id) REFERENCES institution (id)'); $this->addSql('ALTER TABLE registration ADD CONSTRAINT FK_62A8A7A7A409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE RESTRICT'); $this->addSql('ALTER TABLE signing_key ADD CONSTRAINT FK_3DAB554EA409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE SET NULL'); - $this->addSql('ALTER TABLE institution CHANGE api_client_secret api_client_secret_encrypted VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE institution ADD service_auth_endpoint VARCHAR(2048) NOT NULL, CHANGE api_client_secret api_client_secret_encrypted VARCHAR(255) DEFAULT NULL'); $this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages'); $this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages'); $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages'); @@ -40,14 +40,14 @@ public function down(Schema $schema): void { // this down() migration is auto-generated, please modify it to your needs $this->addSql('ALTER TABLE deployment DROP FOREIGN KEY FK_EB1255BE833D8F43'); + $this->addSql('ALTER TABLE registration DROP FOREIGN KEY FK_62A8A7A710405986'); $this->addSql('ALTER TABLE registration DROP FOREIGN KEY FK_62A8A7A7A409F300'); $this->addSql('ALTER TABLE signing_key DROP FOREIGN KEY FK_3DAB554EA409F300'); $this->addSql('DROP TABLE deployment'); - $this->addSql('DROP TABLE lti_session'); $this->addSql('DROP TABLE registration'); $this->addSql('DROP TABLE signing_key'); $this->addSql('DROP TABLE signing_key_set'); - $this->addSql('ALTER TABLE institution CHANGE api_client_secret_encrypted api_client_secret VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE institution DROP service_auth_endpoint, CHANGE api_client_secret_encrypted api_client_secret VARCHAR(255) DEFAULT NULL'); $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages'); $this->addSql('ALTER TABLE messenger_messages CHANGE created_at created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE available_at available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE delivered_at delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); diff --git a/src/Entity/Institution.php b/src/Entity/Institution.php index fa5be9965..fad577f5d 100644 --- a/src/Entity/Institution.php +++ b/src/Entity/Institution.php @@ -52,6 +52,12 @@ class Institution implements JsonSerializable private $encodedKey = 'niLb/WbAODNi7E4ccHHa/pPU3Bd9h6z1NXmjA981D4o='; + #[ORM\Column(type: "string", length: 2048)] + private string $serviceAuthEndpoint; + + #[ORM\OneToOne(targetEntity: Registration::class, mappedBy: 'institution')] + private ?Registration $registration; + #[ORM\Column(type: "string", nullable: true)] private $apiClientId; @@ -70,7 +76,7 @@ public function __construct() // Public Methods public function encryptDeveloperKey(): self { - $this->setApiClientSecret($this->apiClientSecret); + $this->apiClientSecretEncrypted = $this->encryptData($this->apiClientSecretEncrypted); return $this; } @@ -259,6 +265,30 @@ public function removeUser(User $user): self return $this; } + public function getServiceAuthEndpoint(): ?string + { + return $this->serviceAutHEndpoint; + } + + public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static + { + $this->serviceAuthEndpoint = $serviceAuthEndpoint; + + return $this; + } + + public function getRegistration(): ?Registration + { + return $this->registration; + } + + public function setRegistration(Registration $registration): static + { + $this->registration = $registration; + + return $this; + } + public function getApiClientId(): ?string { return $this->apiClientId; diff --git a/src/Entity/Registration.php b/src/Entity/Registration.php index fbab3e1c4..5946b9e1b 100644 --- a/src/Entity/Registration.php +++ b/src/Entity/Registration.php @@ -26,13 +26,14 @@ class Registration #[ORM\Column(type: "string", length: 2048)] private string $loginAuthEndpoint; - - #[ORM\Column(type: "string", length: 2048)] - private string $serviceAuthEndpoint; #[ORM\Column(type: "string", length: 2048)] private string $jwksEndpoint; + #[ORM\OneToOne(targetEntity: Institution::class, inversedBy: 'registration')] + #[ORM\JoinColumn(nullable: false, unique: true)] + private Institution $institution; + #[ORM\ManyToOne( targetEntity: SigningKeySet::class, inversedBy: "registrations", @@ -51,17 +52,17 @@ public function __construct( string $issuer, string $clientId, string $loginAuthEndpoint, - string $serviceAuthEndpoint, string $jwksEndpoint, SigningKeySet $signingKeySet, + Institution $institution ) { $this->issuer = $issuer; $this->clientId = $clientId; $this->loginAuthEndpoint = $loginAuthEndpoint; - $this->serviceAuthEndpoint = $serviceAuthEndpoint; $this->jwksEndpoint = $jwksEndpoint; $this->signingKeySet = $signingKeySet; + $this->institution = $institution; $this->deployments = new ArrayCollection(); $this->encryptionService = new SodiumEncryptionService(); } @@ -108,26 +109,26 @@ public function setLoginAuthEndpoint(string $loginAuthEndpoint): static return $this; } - public function getServiceAuthEndpoint(): string + public function getJwksEndpoint(): string { - return $this->serviceAuthEndpoint; + return $this->jwksEndpoint; } - public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static + public function setJwksEndpoint(string $jwksEndpoint): static { - $this->serviceAuthEndpoint = $serviceAuthEndpoint; + $this->jwksEndpoint = $jwksEndpoint; return $this; } - public function getJwksEndpoint(): string + public function getInstitution(): Institution { - return $this->jwksEndpoint; + return $this->institution; } - public function setJwksEndpoint(string $jwksEndpoint): static + public function setInstitution(Institution $institution): static { - $this->jwksEndpoint = $jwksEndpoint; + $this->institution = $institution; return $this; } From 16c348d0132079521cc4d152b39ec693a63ae8f2 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Thu, 28 May 2026 11:23:31 -0400 Subject: [PATCH 06/28] Move all LMS config info to registration table --- src/Controller/AuthController.php | 34 +++++---- src/Controller/LtiController.php | 53 ++++++++++--- ...27202211.php => Version20260528151648.php} | 8 +- src/Entity/Institution.php | 75 ------------------ src/Entity/Registration.php | 76 +++++++++++++++++-- src/Lms/Canvas/CanvasLms.php | 5 +- src/Lms/D2l/D2lLms.php | 5 +- src/Lms/LmsInterface.php | 3 +- src/Repository/RegistrationRepository.php | 11 +++ .../InstitutionEncryptionService.php | 25 ------ .../RegistrationEncryptionService.php | 32 ++++++++ src/Services/LmsUserService.php | 16 ++-- 12 files changed, 196 insertions(+), 147 deletions(-) rename src/DoctrineMigrations/{Version20260527202211.php => Version20260528151648.php} (84%) delete mode 100644 src/Services/Encryption/InstitutionEncryptionService.php create mode 100644 src/Services/Encryption/RegistrationEncryptionService.php diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 4235e8e12..7175b8f24 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -2,7 +2,8 @@ namespace App\Controller; -use App\Services\Encryption\InstitutionEncryptionService; +use App\Repository\RegistrationRepository; +use App\Services\Encryption\RegistrationEncryptionService; use App\Services\LmsApiService; use App\Services\LmsUserService; use App\Services\SessionService; @@ -26,14 +27,14 @@ class AuthController extends AbstractController private ManagerRegistry $doctrine; - private InstitutionEncryptionService $institutionEncryptionService; + private RegistrationEncryptionService $registrationEncryptionService; private $session; - public function __construct(ManagerRegistry $doctrine, InstitutionEncryptionService $institutionEncryptionService) + public function __construct(ManagerRegistry $doctrine, RegistrationEncryptionService $registrationEncryptionService) { $this->doctrine = $doctrine; - $this->institutionEncryptionService = $institutionEncryptionService; + $this->registrationEncryptionService = $registrationEncryptionService; } #[Route('/authorize', name: 'authorize')] @@ -60,7 +61,7 @@ public function authorize( $this->session->set('oauthAttempted', true); - $oauthUri = $lmsApi->getLms()->getOauthUri($institution, $this->session); + $oauthUri = $lmsApi->getLms()->getOauthUri($institution->getRegistration(), $this->session); return $this->redirect($oauthUri); } @@ -116,11 +117,15 @@ public function authorizeCheck( // Pass in the institution ID and this will encrypt the developer key. #[Route('/encrypt/key', name: 'encrypt_developer_key')] - public function encryptDeveloperKey(Request $request, UtilityService $util) - { + public function encryptDeveloperKey( + Request $request, + RegistrationRepository $registrationRepository, + ) { $instId = $request->query->get('id'); - $institution = $util->getInstitutionById($instId); - $institution->encryptDeveloperKey(); + + $registration = $registrationRepository->getByInstitutionId($instId); + $registrationEncryptionService->encryptKey($registration); + $this->doctrine->getManager()->flush(); return new Response('Updated.'); @@ -132,22 +137,23 @@ protected function requestApiKeyFromLms(): mixed /** @var \App\Entity\User */ $user = $this->getUser(); $institution = $user->getInstitution(); + $registration = $institution->getRegistration(); $code = $this->request->query->get('code'); - $clientSecret = $this->institutionEncryptionService->getClientSecret($institution); + $clientSecret = $this->registrationEncryptionService->getClientSecret($registration); $userAgent = 'UDOIT/' . !empty($_ENV['VERSION_NUMBER']) ? $_ENV['VERSION_NUMBER'] : '4.0.0'; if (empty($clientSecret)) { - $institution->encryptDeveloperKey(); + $registrationEncryptionService->encryptKey($registration); $this->doctrine->getManager()->flush(); - $clientSecret = $this->institutionEncryptionService->getClientSecret($institution); + $clientSecret = $this->registrationEncryptionService->getClientSecret($registration); } $options = [ 'body' => [ 'grant_type' => 'authorization_code', - 'client_id' => $institution->getApiClientId(), + 'client_id' => $registration->getApiClientId(), 'redirect_uri' => LmsUserService::getOauthRedirectUri(), - 'client_secret' => $this->institutionEncryptionService->getClientSecret($institution), + 'client_secret' => $this->registrationEncryptionService->getClientSecret($registration), 'code' => $code, ], 'headers' => [ diff --git a/src/Controller/LtiController.php b/src/Controller/LtiController.php index 00778660c..800ab9810 100644 --- a/src/Controller/LtiController.php +++ b/src/Controller/LtiController.php @@ -4,12 +4,14 @@ use App\Entity\Institution; use App\Entity\User; +use App\Repository\RegistrationRepository; use App\Services\LmsApiService; use App\Services\SessionService; use App\Services\UtilityService; use Doctrine\Persistence\ManagerRegistry; use Firebase\JWT\JWK; use Firebase\JWT\JWT; +use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Cookie; @@ -32,10 +34,17 @@ class LtiController extends AbstractController private ManagerRegistry $doctrine; - public function __construct(ManagerRegistry $doctrine, SessionService $sessionService) - { + private RegistrationRepository $registrationRepository; + + public function __construct( + ManagerRegistry $doctrine, + SessionService $sessionService, + RegistrationRepository $registrationRepository, + private LoggerInterface $logger + ) { $this->doctrine = $doctrine; $this->sessionService = $sessionService; + $this->registrationRepository = $registrationRepository; } #[Route('/lti/authorize', name: 'lti_authorize')] @@ -52,7 +61,13 @@ public function ltiAuthorize( $this->saveRequestToSession(); - return $this->redirect($this->getLtiAuthResponseUrl()); + $postParams = $request->request->all(); + $getParams = $request->query->all(); + $allParams = array_merge($postParams, $getParams); + $iss = $allParams['iss']; + $clientId = $allParams['client_id']; + + return $this->redirect($this->getLtiAuthResponseUrl($iss, $clientId)); } #[Route('/lti/authorize/check', name: 'lti_authorize_check')] @@ -71,6 +86,7 @@ public function ltiAuthorizeCheck( $this->saveRequestToSession(); $clientId = $this->session->get('client_id'); + $iss = $this->session->get('iss'); $jwt = $this->session->get('id_token'); if (!$jwt) { @@ -78,7 +94,7 @@ public function ltiAuthorizeCheck( } // Create token from JWT and public JWKs - $jwks = $this->getPublicJwks(); + $jwks = $this->getPublicJwks($iss, $clientId); $publicKey = JWK::parseKeySet($jwks); JWT::$leeway = 60; $token = JWT::decode($jwt, $publicKey); @@ -108,6 +124,8 @@ public function ltiAuthorizeCheck( // Remove old sessions $sessionService->removeExpiredSessions(); + $this->logger->info(json_encode($this->session)); + $authCookie = Cookie::create('AUTH_TOKEN') ->withValue($this->session->getUuid()) ->withExpires(0) @@ -310,11 +328,15 @@ protected function saveRequestToSession() return; } - protected function getPublicJwks() + protected function getPublicJwks(string $iss, string $clientId) { $httpClient = HttpClient::create(); - /* URL will be different for other LMSes */ - $url = $this->lmsApi->getLms()->getKeysetUrl(); + + $registrations = $this->registrationRepository->getByIssAndClientId($iss, $clientId); + if (empty($registrations)) $this->util->exitWithMessage('Invalid LTI registration.'); + $registration = $registrations[0]; + + $url = $registration->getJwksEndpoint(); $userAgent = 'UDOIT/' . (!empty($_ENV['VERSION_NUMBER']) ? $_ENV['VERSION_NUMBER'] : '4.0.0'); $response = $httpClient->request('GET', $url, [ 'headers' => [ @@ -326,7 +348,7 @@ protected function getPublicJwks() return $keys; } - protected function getLtiAuthResponseUrl() + protected function getLtiAuthResponseUrl(string $iss, string $clientId) { $lms = $this->lmsApi->getLms(); $server = $this->request->server; @@ -336,8 +358,17 @@ protected function getLtiAuthResponseUrl() throw new \Exception("No UUID found!"); } + $registrations = $this->registrationRepository->getByIssAndClientId($iss, $clientId); + + if (empty($registrations)) { + $this->util->exitWithMessage('Invalid LTI registration.'); + } + + $registration = $registrations[0]; + + $params = [ - 'client_id' => $this->session->get('client_id'), + 'client_id' => $clientId, 'state' => $uuid, 'scope' => 'openid', 'response_type' => 'id_token', @@ -357,7 +388,9 @@ protected function getLtiAuthResponseUrl() $params['login_hint'] = $loginHint; } - return $lms->getLtiAuthUrl($params); + $queryStr = http_build_query($params); + + return "{$registration->getLoginAuthEndpoint()}?{$queryStr}"; } // Get institution before the user is authenticated. diff --git a/src/DoctrineMigrations/Version20260527202211.php b/src/DoctrineMigrations/Version20260528151648.php similarity index 84% rename from src/DoctrineMigrations/Version20260527202211.php rename to src/DoctrineMigrations/Version20260528151648.php index 8aba30562..a0bccbccf 100644 --- a/src/DoctrineMigrations/Version20260527202211.php +++ b/src/DoctrineMigrations/Version20260528151648.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260527202211 extends AbstractMigration +final class Version20260528151648 extends AbstractMigration { public function getDescription(): string { @@ -21,14 +21,14 @@ public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE TABLE deployment (id INT AUTO_INCREMENT NOT NULL, lms_deployment_id VARCHAR(2048) NOT NULL, registration_id INT NOT NULL, INDEX IDX_EB1255BE833D8F43 (registration_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); - $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, institution_id INT NOT NULL, signing_key_set_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_62A8A7A710405986 (institution_id), INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, service_auth_endpoint VARCHAR(2048) NOT NULL, service_login_endpoint VARCHAR(2048) NOT NULL, api_client_id VARCHAR(255) NOT NULL, api_client_secret_encrypted VARCHAR(255) NOT NULL, institution_id INT NOT NULL, signing_key_set_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_62A8A7A710405986 (institution_id), INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key (id INT AUTO_INCREMENT NOT NULL, public_key LONGTEXT NOT NULL, private_key LONGTEXT NOT NULL, algorithm VARCHAR(200) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_3DAB554EA409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key_set (id INT AUTO_INCREMENT NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('ALTER TABLE deployment ADD CONSTRAINT FK_EB1255BE833D8F43 FOREIGN KEY (registration_id) REFERENCES registration (id)'); $this->addSql('ALTER TABLE registration ADD CONSTRAINT FK_62A8A7A710405986 FOREIGN KEY (institution_id) REFERENCES institution (id)'); $this->addSql('ALTER TABLE registration ADD CONSTRAINT FK_62A8A7A7A409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE RESTRICT'); $this->addSql('ALTER TABLE signing_key ADD CONSTRAINT FK_3DAB554EA409F300 FOREIGN KEY (signing_key_set_id) REFERENCES signing_key_set (id) ON DELETE SET NULL'); - $this->addSql('ALTER TABLE institution ADD service_auth_endpoint VARCHAR(2048) NOT NULL, CHANGE api_client_secret api_client_secret_encrypted VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE institution DROP api_client_id, DROP api_client_secret'); $this->addSql('DROP INDEX IDX_75EA56E016BA31DB ON messenger_messages'); $this->addSql('DROP INDEX IDX_75EA56E0E3BD61CE ON messenger_messages'); $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0 ON messenger_messages'); @@ -47,7 +47,7 @@ public function down(Schema $schema): void $this->addSql('DROP TABLE registration'); $this->addSql('DROP TABLE signing_key'); $this->addSql('DROP TABLE signing_key_set'); - $this->addSql('ALTER TABLE institution DROP service_auth_endpoint, CHANGE api_client_secret_encrypted api_client_secret VARCHAR(255) DEFAULT NULL'); + $this->addSql('ALTER TABLE institution ADD api_client_id VARCHAR(255) DEFAULT NULL, ADD api_client_secret VARCHAR(255) DEFAULT NULL'); $this->addSql('DROP INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 ON messenger_messages'); $this->addSql('ALTER TABLE messenger_messages CHANGE created_at created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE available_at available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', CHANGE delivered_at delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\''); $this->addSql('CREATE INDEX IDX_75EA56E016BA31DB ON messenger_messages (delivered_at)'); diff --git a/src/Entity/Institution.php b/src/Entity/Institution.php index fad577f5d..e9d64a691 100644 --- a/src/Entity/Institution.php +++ b/src/Entity/Institution.php @@ -52,18 +52,9 @@ class Institution implements JsonSerializable private $encodedKey = 'niLb/WbAODNi7E4ccHHa/pPU3Bd9h6z1NXmjA981D4o='; - #[ORM\Column(type: "string", length: 2048)] - private string $serviceAuthEndpoint; - #[ORM\OneToOne(targetEntity: Registration::class, mappedBy: 'institution')] private ?Registration $registration; - #[ORM\Column(type: "string", nullable: true)] - private $apiClientId; - - #[ORM\Column(type: "string", length: 255, nullable: true)] - private string $apiClientSecretEncrypted; - // Constructor public function __construct() @@ -73,36 +64,6 @@ public function __construct() } - // Public Methods - public function encryptDeveloperKey(): self - { - $this->apiClientSecretEncrypted = $this->encryptData($this->apiClientSecretEncrypted); - - return $this; - } - - - // Private Methods - private function encryptData($data): string - { - $key = base64_decode($this->encodedKey); - $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); - $encrypted_data = sodium_crypto_secretbox($data, $nonce, $key); - - return base64_encode($nonce . $encrypted_data); - } - - private function decryptData($encrypted): bool | string - { - $key = base64_decode($this->encodedKey); - $decoded = base64_decode($encrypted); - $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); - $encrypted_text = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, NULL, '8bit'); - - return sodium_crypto_secretbox_open($encrypted_text, $nonce, $key); - } - - // Getters and Setters public function getId(): ?int { @@ -265,18 +226,6 @@ public function removeUser(User $user): self return $this; } - public function getServiceAuthEndpoint(): ?string - { - return $this->serviceAutHEndpoint; - } - - public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static - { - $this->serviceAuthEndpoint = $serviceAuthEndpoint; - - return $this; - } - public function getRegistration(): ?Registration { return $this->registration; @@ -289,30 +238,6 @@ public function setRegistration(Registration $registration): static return $this; } - public function getApiClientId(): ?string - { - return $this->apiClientId; - } - - public function setApiClientId(?int $apiClientId): self - { - $this->apiClientId = $apiClientId; - - return $this; - } - - public function getApiClientSecretEncrypted(): ?string - { - return $this->apiClientSecretEncrypted; - } - - public function setApiClientSecretEncrypted(?string $apiClientSecretEncrypted): self - { - $this->apiClientSecretEncrypted = $apiClientSecretEncrypted; - - return $this; - } - public function jsonSerialize(): array { return [ diff --git a/src/Entity/Registration.php b/src/Entity/Registration.php index 5946b9e1b..a20805d53 100644 --- a/src/Entity/Registration.php +++ b/src/Entity/Registration.php @@ -5,8 +5,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use App\Services\Encryption\EncryptionServiceInterface; -use App\Services\Encryption\SodiumEncryptionService; #[ORM\Entity(repositoryClass: "App\Repository\RegistrationRepository")] @@ -24,12 +22,28 @@ class Registration #[ORM\Column(type: "string", length: 255)] private string $clientId; + // The redirect URL during LTI launch #[ORM\Column(type: "string", length: 2048)] private string $loginAuthEndpoint; + // The LTI platform JWK URL #[ORM\Column(type: "string", length: 2048)] private string $jwksEndpoint; + // The OAuth2 access token endpoint + #[ORM\Column(type: "string", length: 2048)] + private string $serviceAuthEndpoint; + + // The OAuth2 authorization endpoint + #[ORM\Column(type: "string", length: 2048)] + private string $serviceLoginEndpoint; + + #[ORM\Column(type: "string", length: 255)] + private $apiClientId; + + #[ORM\Column(type: "string", length: 255)] + private ?string $apiClientSecretEncrypted; + #[ORM\OneToOne(targetEntity: Institution::class, inversedBy: 'registration')] #[ORM\JoinColumn(nullable: false, unique: true)] private Institution $institution; @@ -45,14 +59,14 @@ class Registration private Collection $deployments; - private EncryptionServiceInterface $encryptionService; - - public function __construct( string $issuer, string $clientId, string $loginAuthEndpoint, string $jwksEndpoint, + string $serviceAuthEndpoint, + string $serviceLoginEndpoint, + string $apiClientId, SigningKeySet $signingKeySet, Institution $institution ) @@ -61,10 +75,12 @@ public function __construct( $this->clientId = $clientId; $this->loginAuthEndpoint = $loginAuthEndpoint; $this->jwksEndpoint = $jwksEndpoint; + $this->serviceAuthEndpoint = $serviceAuthEndpoint; + $this->serviceLoginEndpoint = $serviceLoginEndpoint; + $this->apiClientId = $apiClientId; $this->signingKeySet = $signingKeySet; $this->institution = $institution; $this->deployments = new ArrayCollection(); - $this->encryptionService = new SodiumEncryptionService(); } @@ -121,6 +137,54 @@ public function setJwksEndpoint(string $jwksEndpoint): static return $this; } + public function getServiceAuthEndpoint(): string + { + return $this->serviceAuthEndpoint; + } + + public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static + { + $this->serviceAuthEndpoint = $serviceAuthEndpoint; + + return $this; + } + + public function getServiceLoginEndpoint(): string + { + return $this->$serviceLoginEndpoint; + } + + public function setServiceLoginEndpoint(string $serviceLoginEndpoint): static + { + $this->serviceLoginEndpoint = $serviceLoginEndpoint; + + return $this; + } + + public function getApiClientId(): string + { + return $this->apiClientId; + } + + public function setApiClientId(string $apiClientId): static + { + $this->apiClientId = $apiClientId; + + return $this; + } + + public function getApiClientSecretEncrypted(): ?string + { + return $this->apiClientSecretEncrypted; + } + + public function setApiClientSecretEncrypted(string $apiClientSecretEncrypted): static + { + $this->apiClientSecretEncrypted = $apiClientSecretEncrypted; + + return $this; + } + public function getInstitution(): Institution { return $this->institution; diff --git a/src/Lms/Canvas/CanvasLms.php b/src/Lms/Canvas/CanvasLms.php index a7126a902..282f3243f 100644 --- a/src/Lms/Canvas/CanvasLms.php +++ b/src/Lms/Canvas/CanvasLms.php @@ -6,6 +6,7 @@ use App\Entity\Course; use App\Entity\FileItem; use App\Entity\Institution; +use App\Entity\Registration; use App\Entity\User; use App\Entity\UserSession; use App\Lms\LmsInterface; @@ -95,10 +96,10 @@ public function saveTokenToSession($token) * ******************** */ - public function getOauthUri(Institution $institution, UserSession $session) + public function getOauthUri(Registration $registration, UserSession $session) { $query = [ - 'client_id' => $institution->getApiClientId(), + 'client_id' => $registration->getApiClientId(), 'scope' => $this->getScopes(), 'response_type' => 'code', 'redirect_uri' => LmsUserService::getOauthRedirectUri(), diff --git a/src/Lms/D2l/D2lLms.php b/src/Lms/D2l/D2lLms.php index c9a47d1fe..ee684f3da 100644 --- a/src/Lms/D2l/D2lLms.php +++ b/src/Lms/D2l/D2lLms.php @@ -6,6 +6,7 @@ use App\Entity\Course; use App\Entity\FileItem; use App\Entity\Institution; +use App\Entity\Registration; use App\Entity\User; use App\Entity\UserSession; use App\Lms\LmsInterface; @@ -97,10 +98,10 @@ public function testApiConnection(User $user) return ($response->getStatusCode() < 400); } - public function getOauthUri(Institution $institution, UserSession $session) + public function getOauthUri(Registration $registration, UserSession $session) { $query = [ - 'client_id' => $institution->getApiClientId(), + 'client_id' => $registration->getApiClientId(), 'scope' => $this->getScopes(), 'response_type' => 'code', 'redirect_uri' => LmsUserService::getOauthRedirectUri(), diff --git a/src/Lms/LmsInterface.php b/src/Lms/LmsInterface.php index 25abcb7dd..145358b08 100644 --- a/src/Lms/LmsInterface.php +++ b/src/Lms/LmsInterface.php @@ -5,6 +5,7 @@ use App\Entity\ContentItem; use App\Entity\Course; use App\Entity\FileItem; +use App\Entity\Registration; use App\Entity\Institution; use App\Entity\User; use App\Entity\UserSession; @@ -18,7 +19,7 @@ public function updateFileItem(Course $course, $file); public function updateContentItem(ContentItem $contentItem); public function postContentItem(ContentItem $contentItem); public function postFileItem(FileItem $file, string $newFileName); - public function getOauthUri(Institution $institution, UserSession $session); + public function getOauthUri(Registration $registration, UserSession $session); public function getAccountData(User $user, $accountId); public function getCourseUrl(Course $course, User $user); public function getCourseSections(Course $course, User $user); diff --git a/src/Repository/RegistrationRepository.php b/src/Repository/RegistrationRepository.php index dac89c2a2..e35710004 100644 --- a/src/Repository/RegistrationRepository.php +++ b/src/Repository/RegistrationRepository.php @@ -26,4 +26,15 @@ public function getByIssAndClientId(string $iss, string $clientId) ->getQuery() ->getResult(); } + + public function getByInstitutionId(string $id) + { + return $this->getEntityManager()->createQueryBuilder() + ->select('r') + ->from(Registration::class, 'r') + ->where('r.institution = :id') + ->setParameter('id', $id) + ->getQuery() + ->getResult(); + } } \ No newline at end of file diff --git a/src/Services/Encryption/InstitutionEncryptionService.php b/src/Services/Encryption/InstitutionEncryptionService.php deleted file mode 100644 index 3026653fe..000000000 --- a/src/Services/Encryption/InstitutionEncryptionService.php +++ /dev/null @@ -1,25 +0,0 @@ -setApiClientSecretEncrypted($this->encryptionService->encrypt($secret)); - } - - public function getClientSecret(Institution $institution): string - { - return $this->encryptionService->decrypt($institution->getApiClientSecretEncrypted()); - } - -} \ No newline at end of file diff --git a/src/Services/Encryption/RegistrationEncryptionService.php b/src/Services/Encryption/RegistrationEncryptionService.php new file mode 100644 index 000000000..25d0bd9fe --- /dev/null +++ b/src/Services/Encryption/RegistrationEncryptionService.php @@ -0,0 +1,32 @@ +setApiClientSecretEncrypted($this->encryptionService->encrypt($secret)); + } + + public function getClientSecret(Registration $registration): string + { + return $this->encryptionService->decrypt($registration->getApiClientSecretEncrypted()); + } + + public function encryptKey(Registration $registration) + { + $currentKey = $registration->getApiClientSecretEncrypted(); + $encryptedKey = $this->encryptionService->encrypt($currentKey); + $registration->setApiClientSecretEncrypted($encryptedKey); + } + +} \ No newline at end of file diff --git a/src/Services/LmsUserService.php b/src/Services/LmsUserService.php index 7026954f3..97c5f675b 100644 --- a/src/Services/LmsUserService.php +++ b/src/Services/LmsUserService.php @@ -3,10 +3,9 @@ namespace App\Services; use App\Entity\User; -use App\Services\Encryption\InstitutionEncryptionService; +use App\Services\Encryption\RegistrationEncryptionService; use App\Services\LmsApiService; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\HttpClient\Exception\TimeoutException; use Symfony\Component\HttpClient\HttpClient; @@ -22,18 +21,18 @@ class LmsUserService { /** @var UtilityService $util */ protected $util; - protected InstitutionEncryptionService $institutionEncryptionService; + protected RegistrationEncryptionService $registrationEncryptionService; public function __construct( LmsApiService $lmsApi, ManagerRegistry $doctrine, UtilityService $util, - InstitutionEncryptionService $institutionEncryptionService + RegistrationEncryptionService $registrationEncryptionService ) { $this->lmsApi = $lmsApi; $this->doctrine = $doctrine; $this->util = $util; - $this->institutionEncryptionService = $institutionEncryptionService; + $this->registrationEncryptionService = $registrationEncryptionService; } public static function getOauthRedirectUri() @@ -80,7 +79,8 @@ public function validateApiKey(User $user) public function refreshApiKey(User $user) { $refreshToken = $user->getRefreshToken(); - $institution = $user->getInstitution();; + $institution = $user->getInstitution(); + $registration = $institution->getRegistration(); $userAgent = 'UDOIT/' . !empty($_ENV['VERSION_NUMBER']) ? $_ENV['VERSION_NUMBER'] : '4.0.0'; if (empty($refreshToken)) { @@ -90,9 +90,9 @@ public function refreshApiKey(User $user) $options = [ 'body' => [ 'grant_type' => 'refresh_token', - 'client_id' => $institution->getApiClientId(), + 'client_id' => $registration->getApiClientId(), 'redirect_uri' => self::getOauthRedirectUri(), - 'client_secret' => $this->institutionEncryptionService->getClientSecret($institution), + 'client_secret' => $this->registrationEncryptionService->getClientSecret($registration), 'refresh_token' => $refreshToken, ], 'headers' => [ From d18e4f8bfb715518cc164fbac099e22a3032b78a Mon Sep 17 00:00:00 2001 From: CJ Young Date: Thu, 28 May 2026 11:26:17 -0400 Subject: [PATCH 07/28] Add file-based LTI registration initiation --- Makefile | 4 +- src/Command/CreateRegistrationCommand.php | 475 +++++++++++++++++++--- 2 files changed, 413 insertions(+), 66 deletions(-) diff --git a/Makefile b/Makefile index 03725b2ed..2b8f059dd 100644 --- a/Makefile +++ b/Makefile @@ -22,7 +22,7 @@ down: docker compose -f docker-compose.nginx.yml down # rebuild the containers from the ground up -build: +rebuild: docker compose -f docker-compose.nginx.yml up --build # clear the Symfony cache @@ -38,4 +38,4 @@ ins-psql: docker exec -it -e PGPASSWORD=root udoit3-db psql -U root -d udoit3 -w -c "INSERT INTO institution (title, lms_domain, lms_id, lms_account_id, created, status, vanity_url, metadata, api_client_id, api_client_secret) VALUES ('$(TITLE)', '$(LMS_DOMAIN)', '$(LMS_ID)', '$(LMS_ACCOUNT_ID)', '$(CREATED)', '$(STATUS)', '$(VANITY_URL)', '$(API_CLIENT_ID)', '$(API_CLIENT_SECRET)');" create-registration: - docker compose -f docker-compose.nginx.yml run --rm php php bin/console app:create-registration + docker compose -f docker-compose.nginx.yml run --rm php php bin/console app:create-registration $(if $(FILE),--file=$(FILE),) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index fb6e7525b..8c7a2a80e 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -2,113 +2,413 @@ namespace App\Command; +use App\Entity\Institution; use App\Entity\Registration; use App\Entity\SigningKey; use App\Entity\SigningKeySet; use App\Repository\SigningKeySetRepository; -use App\Services\Encryption\InstitutionEncryptionService; +use App\Services\Encryption\RegistrationEncryptionService; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Yaml\Yaml; + #[AsCommand(name: 'app:create-registration')] -class CreateRegistrationCommand extends Command +class CreateRegistrationCommand extends Command implements SignalableCommandInterface { public function __construct( private ManagerRegistry $doctrine, private SigningKeySetRepository $signingKeySetRepo, - private InstitutionEncryptionService $institutionEncryptionService + private RegistrationEncryptionService $registrationEncryptionService ) { parent::__construct(); } + protected function configure() + { + $this->addOption( + 'file', + 'f', + InputOption::VALUE_REQUIRED, + 'Path to a JSON or YAML file containing one or more registrations to create in batch' + ); + } + public function __invoke(InputInterface $input, OutputInterface $output): int { $titleStyle = new OutputFormatterStyle('#2c5dd1', null, ['bold']); $output->getFormatter()->setStyle('header', $titleStyle); - $io = new SymfonyStyle($input, $output); + $io = new InterruptibleSymfonyStyle($input, $output); $io->writeln(''); + $io->writeln('
---------------------------------
'); $io->writeln('
LTI Registration Creation Station
'); $io->writeln('
---------------------------------
'); $io->writeln(''); + $filePath = $input->getOption('file'); + + if ($filePath !== null) + { + return $this->runFromFile($filePath, $io); + } + + return $this->runInteractive($io); + } + + private function runInteractive($io) + { + $title = $this->getTitle($io); + $lmsDomain = $this->getLmsDomain($io); + $vanityUrl = $this->getVanityUrl($io); + $lmsId = $this->getLmsId($io); + $lmsAccountId = $this->getLmsAccountId($io); + $platform = $this->getPlatform($io); - $clientId = $this->getClientId($io); + $ltiClientId = $this->getLtiClientId($io); + $apiClientId = $this->getApiClientId($io); $keyset = $this->getKeyset($io); $apiClientSecret = $this->getApiClientSecret($io); + $status = true; + + $institution = new Institution(); + $institution->setTitle($title); + $institution->setLmsDomain($lmsDomain); + $institution->setLmsId($lmsId); + $institution->setLmsAccountId($lmsAccountId); + $institution->setStatus($status); + $institution->setVanityUrl($vanityUrl); + $institution->setCreated(new \DateTime()); + - $registration = new Registration( $platform['issuer'], - $clientId, + $ltiClientId, $platform['loginAuthEndpoint'], - $platform['serviceAuthEndpoint'], $platform['jwkEndpoint'], + $platform['serviceAuthEndpoint'], + $platform['serviceLoginEndpoint'], + $apiClientId, $keyset, + $institution ); + $this->registrationEncryptionService->setClientSecret($registration, $apiClientSecret); + + $institution->setRegistration($registration); + + $this->doctrine->getManager()->persist($institution); $this->doctrine->getManager()->persist($registration); $this->doctrine->getManager()->flush(); return Command::SUCCESS; + } + + /** + * Process one or more registrations defined in a JSON or YAML file. + * + * Expected top-level structure (array of registration objects): + * + * JSON example (registrations.json): + * [ + * { + * "title": "My University", + * "lms_domain": "myuniversity.instructure.com", + * "lms_id": "canvas", + * "vanity_url": "myuniversity.instructure.com", + * "lms_account_id": "12345", + * "lti_client_id": "abc123", + * "api_client_id": "def456", + * "api_client_secret": "supersecret", + * "platform": { + * "preset": "Production Canvas" + * // OR supply all four fields manually: + * // "issuer": "https://canvas.instructure.com", + * // "login_auth_endpoint": "...", + * // "service_auth_endpoint": "...", + * // "service_login_endpoint: "...", + * // "jwk_endpoint": "..." + * }, + * "keyset": { + * "generate": true + * // OR use an existing keyset by ID: + * // "existing_id": 7 + * } + * } + * ] + * + * YAML files follow the same structure with YAML syntax. + * A single object (not wrapped in an array) is also accepted. + */ + private function runFromFile(string $filePath, SymfonyStyle $io) + { + if (!file_exists($filePath)) { + $io->error("File not found: {$filePath}"); + return Command::FAILURE; + } + + $extension = strtolower(pathinfo($filePath, PATHINFO_EXTENSION)); + $content = file_get_contents($filePath); + + try { + $data = match ($extension) { + 'json' => json_decode($content, true, 512, JSON_THROW_ON_ERROR), + 'yaml', 'yml' => Yaml::parse($content), + default => throw new \InvalidArgumentException( + "Unsupported file extension \".{$extension}\". Use .json, .yaml, or .yml." + ), + }; + } catch (\Throwable $e) { + $io->error("Failed to parse file: " . $e->getMessage()); + return Command::FAILURE; + } + + // Allow a single object or an array of objects. + if (isset($data['title'])) { + $registrations = [$data]; + } else { + $registrations = $data; + } + + if (empty($registrations) || !is_array($registrations)) { + $io->error('The file must contain at least one registration entry.'); + return Command::FAILURE; + } + + $io->writeln(sprintf('Found %d registration(s) to process.', count($registrations))); + $io->writeln(''); + + $successCount = 0; + $failCount = 0; + + foreach ($registrations as $index => $entry) { + $label = sprintf( + 'Registration %d/%d: %s', + $index + 1, + count($registrations), + $entry['title'] ?? '(no title)' + ); + + $io->section($label); + + try { + $this->validateEntry($entry); + $this->createRegistrationFromEntry($entry, $io); + $io->success("Created successfully."); + $successCount++; + } catch (\Throwable $e) { + $io->error("Failed: " . $e->getMessage()); + $failCount++; + } + } + + $io->writeln(''); + $io->writeln(sprintf('Done. %d succeeded, %d failed.', $successCount, $failCount)); + + return $failCount === 0 ? Command::SUCCESS : Command::FAILURE; + } + private function validateEntry(array $entry): void + { + $required = ['title', 'lms_domain', 'lms_id', 'vanity_url', 'lms_account_id', + 'lti_client_id', 'api_client_id', 'api_client_secret', 'platform', 'keyset']; + + foreach ($required as $field) { + if (!array_key_exists($field, $entry) || $entry[$field] === null || $entry[$field] === '') { + throw new \InvalidArgumentException("Missing required field: \"{$field}\"."); + } + } + + $validLmsIds = ['canvas', 'd2l']; + if (!in_array($entry['lms_id'], $validLmsIds, true)) { + throw new \InvalidArgumentException( + "Invalid lms_id \"{$entry['lms_id']}\". Allowed values: " . implode(', ', $validLmsIds) + ); + } + + // Platform validation: either a preset name or all four manual fields. + $platform = $entry['platform']; + if (!isset($platform['preset'])) { + foreach (['issuer', 'login_auth_endpoint', 'service_auth_endpoint', 'service_login_endpoint', 'jwk_endpoint'] as $f) { + if (empty($platform[$f])) { + throw new \InvalidArgumentException( + "Platform must have either \"preset\" or all four manual fields " . + "(issuer, login_auth_endpoint, service_auth_endpoint, service_login_endpoint, jwk_endpoint). Missing: \"{$f}\"." + ); + } + } + } + + // Keyset validation: either generate:true or existing_id. + $keyset = $entry['keyset']; + if (empty($keyset['generate']) && empty($keyset['existing_id'])) { + throw new \InvalidArgumentException( + 'Keyset must specify either "generate": true or "existing_id": .' + ); + } } + /** + * Build and persist an Institution + Registration from a validated file entry. + */ + private function createRegistrationFromEntry(array $entry, SymfonyStyle $io): void + { + $platform = $this->resolvePlatformFromEntry($entry['platform']); + $keyset = $this->resolveKeysetFromEntry($entry['keyset'], $io); + + $institution = new Institution(); + $institution->setTitle($entry['title']); + $institution->setLmsDomain($entry['lms_domain']); + $institution->setLmsId($entry['lms_id']); + $institution->setLmsAccountId($entry['lms_account_id']); + $institution->setStatus(true); + $institution->setVanityUrl($entry['vanity_url']); + $institution->setCreated(new \DateTime()); + + $registration = new Registration( + $platform['issuer'], + $entry['lti_client_id'], + $platform['loginAuthEndpoint'], + $platform['jwkEndpoint'], + $platform['serviceAuthEndpoint'], + $platform['serviceLoginEndpoint'], + $entry['api_client_id'], + $keyset, + $institution + ); + + $this->registrationEncryptionService->setClientSecret($registration, $entry['api_client_secret']); + + $institution->setRegistration($registration); + + $this->doctrine->getManager()->persist($institution); + $this->doctrine->getManager()->persist($registration); + $this->doctrine->getManager()->flush(); + } - private function getPlatform(SymfonyStyle $io) + /** + * Resolve platform config from a file entry's "platform" block. + */ + private function resolvePlatformFromEntry(array $platformEntry): array { - $platformPresets = [ + if (isset($platformEntry['preset'])) { + $preset = $this->findPlatformPreset($platformEntry['preset']); + if ($preset === null) { + $names = implode(', ', array_column($this->getPlatformPresets(), 'name')); + throw new \InvalidArgumentException( + "Unknown platform preset \"{$platformEntry['preset']}\". Valid options: {$names}" + ); + } + return [ + 'issuer' => $preset['issuer'], + 'loginAuthEndpoint' => $preset['loginAuthEndpoint'], + 'serviceAuthEndpoint' => $preset['serviceAuthEndpoint'], + 'serviceLoginEndpoint' => $preset['serviceLoginEndpoint'], + 'jwkEndpoint' => $preset['jwkEndpoint'], + ]; + } + + return [ + 'issuer' => $platformEntry['issuer'], + 'loginAuthEndpoint' => $platformEntry['login_auth_endpoint'], + 'serviceAuthEndpoint' => $platformEntry['service_auth_endpoint'], + 'serviceLoginEndpoint' => $platformEntry['service_login_endpoint'], + 'jwkEndpoint' => $platformEntry['jwk_endpoint'], + ]; + } + + /** + * Resolve (or generate) a SigningKeySet from a file entry's "keyset" block. + */ + private function resolveKeysetFromEntry(array $keysetEntry, SymfonyStyle $io): SigningKeySet + { + if (!empty($keysetEntry['existing_id'])) { + $existingKeySets = $this->signingKeySetRepo->getAllKeySets(); + $id = (int) $keysetEntry['existing_id']; + $found = array_find($existingKeySets, fn($ks) => $ks->getId() === $id); + + if ($found === null) { + throw new \InvalidArgumentException("No SigningKeySet found with ID {$id}."); + } + + $io->writeln("Using existing SigningKeySet ID {$id}."); + return $found; + } + + $io->writeln('Generating new RSA key pair...'); + return $this->generateAndPersistKeyset(); + } + + private function getPlatformPresets(): array + { + return [ [ - 'name' => 'Production Canvas', - 'issuer' => 'https://canvas.instructure.com', - 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', - 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', - 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + 'name' => 'Production Canvas', + 'issuer' => 'https://canvas.instructure.com', + 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', + 'serviceLoginEndpoint' => 'https://sso.canvaslms.com/login/oauth2/auth', + 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks', ], [ - 'name' => 'Test Canvas', - 'issuer' => 'https://canvas.test.instructure.com', - 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', - 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', - 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + 'name' => 'Test Canvas', + 'issuer' => 'https://canvas.test.instructure.com', + 'loginAuthEndpoint' => 'https://sso.test.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.test.canvaslms.com/login/oauth2/token', + 'serviceLoginEndpoint' => 'https://sso.test.canvaslms.com/login/oauth2/auth', + 'jwkEndpoint' => 'https://sso.test.canvaslms.com/api/lti/security/jwks', ], [ - 'name' => 'Beta Canvas', - 'issuer' => 'https://canvas.beta.instructure.com', - 'loginAuthEndpoint' => 'https://sso.canvaslms.com/api/lti/authorize_redirect', - 'serviceAuthEndpoint' => 'https://sso.canvaslms.com/login/oauth2/token', - 'jwkEndpoint' => 'https://sso.canvaslms.com/api/lti/security/jwks' + 'name' => 'Beta Canvas', + 'issuer' => 'https://canvas.beta.instructure.com', + 'loginAuthEndpoint' => 'https://sso.beta.canvaslms.com/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://sso.beta.canvaslms.com/login/oauth2/token', + 'serviceLoginEndpoint' => 'https://sso.beta.canvaslms.com/login/oauth2/auth', + 'jwkEndpoint' => 'https://sso.beta.canvaslms.com/api/lti/security/jwks', ], [ - 'name' => 'Devhub', - 'issuer' => 'https://canvas.instructure.com', - 'loginAuthEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/authorize_redirect', - 'serviceAuthEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/token', - 'jwkEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/security/jwks' + 'name' => 'Devhub', + 'issuer' => 'https://canvas.instructure.com', + 'loginAuthEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/authorize_redirect', + 'serviceAuthEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/token', + 'serviceLoginEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/auth', + 'jwkEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/security/jwks', ], ]; + } + + private function findPlatformPreset(string $name): ?array + { + return array_find($this->getPlatformPresets(), fn($p) => $p['name'] === $name); + } - $customPlatformOption = 'Custom'; + private function getPlatform(SymfonyStyle $io) + { + $platformPresets = $this->getPlatformPresets(); + $customPlatformOption = 'Custom'; $platformOptions = array_map(fn($preset): string => $preset['name'], $platformPresets); - $platformName = $io->choice('What is the platform?', [...$platformOptions, $customPlatformOption]); + $platformName = $io->choice('Select your platform or create a new one', [...$platformOptions, $customPlatformOption]); if ($platformName !== $customPlatformOption) { $preset = array_find($platformPresets, fn($preset) => $preset['name'] === $platformName); return [ - 'issuer' => $preset['issuer'], - 'loginAuthEndpoint' => $preset['loginAuthEndpoint'], - 'serviceAuthEndpoint' => $preset['serviceAuthEndpoint'], - 'jwkEndpoint' => $preset['jwkEndpoint'], + 'issuer' => $preset['issuer'], + 'loginAuthEndpoint' => $preset['loginAuthEndpoint'], + 'serviceAuthEndpoint' => $preset['serviceAuthEndpoint'], + 'serviceLoginEndpoint' => $preset['serviceLoginEndpoint'], + 'jwkEndpoint' => $preset['jwkEndpoint'], ]; } @@ -118,13 +418,33 @@ private function getPlatform(SymfonyStyle $io) $jwkEndpoint = $io->ask('Enter JWK endpoint'); return [ - 'issuer' => $iss, - 'loginAuthEndpoint' => $loginAuthEndpoint, - 'serviceAuthEndpoint' => $serviceAuthEndpoint, - 'jwkEndpoint' => $jwkEndpoint, + 'issuer' => $iss, + 'loginAuthEndpoint' => $loginAuthEndpoint, + 'serviceAuthEndpoint' => $serviceAuthEndpoint, + 'serviceLoginEndpoint' => $serviceLoginEndpoint, + 'jwkEndpoint' => $jwkEndpoint, ]; } + private function generateAndPersistKeyset(): SigningKeySet + { + $keyPair = $this->generateRsaKeyPair(); + $signingKeySet = new SigningKeySet(); + $signingKey = new SigningKey( + $keyPair['publicKey'], + $keyPair['privateKey'], + $keyPair['alg'], + $signingKeySet, + ); + + $this->doctrine->getManager()->persist($signingKeySet); + $this->doctrine->getManager()->persist($signingKey); + $this->doctrine->getManager()->flush(); + + return $signingKeySet; + } + + private function getIss(SymfonyStyle $io) { $canvasIss = 'https://canvas.instructure.com'; @@ -147,9 +467,14 @@ private function getIss(SymfonyStyle $io) return $iss; } - private function getClientId(SymfonyStyle $io) + private function getLtiClientId(SymfonyStyle $io) + { + return $io->ask('Enter your LTI client ID'); + } + + private function getApiClientId(SymfonyStyle $io) { - return $io->ask('Enter client ID'); + return $io->ask('Enter your API client ID'); } private function getKeyset(SymfonyStyle $io) @@ -183,10 +508,7 @@ private function getKeyset(SymfonyStyle $io) throw new \Exception('Invalid key set ID'); } - $signingKeySet = array_find($existingKeySets, fn($keySet) => $keySet->getId() === $keySetChoiceId); - } else - { - $shouldGenerate = true; + return array_find($existingKeySets, fn($keySet) => $keySet->getId() === $keySetChoiceId); } } else @@ -194,24 +516,8 @@ private function getKeyset(SymfonyStyle $io) $io->writeln('No existing key sets found.'); } - if ($shouldGenerate) - { - $io->writeln('Generating new key key pair...'); - $keyPair = $this->generateRsaKeyPair(); - $signingKeySet = new SigningKeySet(); - $signingKey = new SigningKey( - $keyPair['publicKey'], - $keyPair['privateKey'], - $keyPair['alg'], - $signingKeySet, - ); - - $this->doctrine->getManager()->persist($signingKeySet); - $this->doctrine->getManager()->persist($signingKey); - $this->doctrine->getManager()->flush(); - } - - return $signingKeySet; + $io->writeln('Generating new key key pair...'); + return $this->generateAndPersistKeyset(); } @@ -241,4 +547,45 @@ private function getApiClientSecret($io) return $io->ask('Enter the API client secret'); } + private function getTitle($io) + { + return $io->ask('Give your institution a name'); + } + + private function getLmsDomain($io) + { + return $io->ask('Enter the domain of your LMS (e.g. myschool.instructure.com). DO NOT include `https://` or a trailing slash.'); + } + + private function getVanityUrl($io) + { + return $io->ask('Enter the vanity URL (may be same as domain)'); + } + + private function getLmsAccountId($io) + { + return $io->ask('Enter the associated LMS account ID'); + } + + private function getLmsId($io) + { + $canvasId = 'Canvas'; + $d2lId = 'D2L'; + + $idChoices = [ + $canvasId, + $d2lId + ]; + + $idNameToId = [ + $canvasId => 'canvas', + $d2lId => 'd2l' + ]; + + $idChoice = $io->choice('Select your LMS platform', $idChoices); + + return $idNameToId[$idChoice]; + + } + } \ No newline at end of file From ae9d9d5b5459478bb7cbde7557d361541743fde1 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Thu, 28 May 2026 11:34:21 -0400 Subject: [PATCH 08/28] Fix registration command using wrong IO class --- src/Command/CreateRegistrationCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 8c7a2a80e..3f09430bb 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -47,7 +47,7 @@ public function __invoke(InputInterface $input, OutputInterface $output): int $titleStyle = new OutputFormatterStyle('#2c5dd1', null, ['bold']); $output->getFormatter()->setStyle('header', $titleStyle); - $io = new InterruptibleSymfonyStyle($input, $output); + $io = new SymfonyStyle($input, $output); $io->writeln(''); $io->writeln('
---------------------------------
'); From 1b7a276e19ac83c41883a2b821e6f5049c4455f4 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Thu, 28 May 2026 11:48:51 -0400 Subject: [PATCH 09/28] Replace LTI and OAuth URLs with DB endpoints --- src/Controller/AuthController.php | 2 +- src/Entity/Registration.php | 2 +- src/Lms/Canvas/CanvasLms.php | 35 ++++--------------------------- src/Lms/D2l/D2lLms.php | 26 +++-------------------- src/Lms/LmsInterface.php | 5 +---- src/Services/LmsUserService.php | 2 +- 6 files changed, 11 insertions(+), 61 deletions(-) diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index 7175b8f24..cb25351a8 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -164,7 +164,7 @@ protected function requestApiKeyFromLms(): mixed ]; $client = HttpClient::create(); - $requestUrl = $this->lmsApi->getLms()->getOauthTokenUri($institution); + $requestUrl = $this->lmsApi->getLms()->getOauthTokenUri($registration); $response = $client->request('POST', $requestUrl, $options); $contentStr = $response->getContent(false); diff --git a/src/Entity/Registration.php b/src/Entity/Registration.php index a20805d53..eae132b38 100644 --- a/src/Entity/Registration.php +++ b/src/Entity/Registration.php @@ -151,7 +151,7 @@ public function setServiceAuthEndpoint(string $serviceAuthEndpoint): static public function getServiceLoginEndpoint(): string { - return $this->$serviceLoginEndpoint; + return $this->serviceLoginEndpoint; } public function setServiceLoginEndpoint(string $serviceLoginEndpoint): static diff --git a/src/Lms/Canvas/CanvasLms.php b/src/Lms/Canvas/CanvasLms.php index 282f3243f..a6709e760 100644 --- a/src/Lms/Canvas/CanvasLms.php +++ b/src/Lms/Canvas/CanvasLms.php @@ -5,7 +5,6 @@ use App\Entity\ContentItem; use App\Entity\Course; use App\Entity\FileItem; -use App\Entity\Institution; use App\Entity\Registration; use App\Entity\User; use App\Entity\UserSession; @@ -67,26 +66,6 @@ public function getId() * ******************** */ - public function getLtiAuthUrl($params) - { - $session = $this->sessionService->getSession(); - $baseUrl = !empty(getenv('JWK_BASE_URL')) ? getenv('JWK_BASE_URL') : $session->get('iss'); - $baseUrl = rtrim($baseUrl, '/'); - - $queryStr = http_build_query($params); - - return "{$baseUrl}/api/lti/authorize_redirect?{$queryStr}"; - } - - public function getKeysetUrl() - { - $session = $this->sessionService->getSession(); - $baseUrl = !empty(getenv('JWK_BASE_URL')) ? getenv('JWK_BASE_URL') : $session->get('iss'); - $baseUrl = rtrim($baseUrl, '/'); - - return "{$baseUrl}/api/lti/security/jwks"; - } - public function saveTokenToSession($token) {} @@ -105,19 +84,13 @@ public function getOauthUri(Registration $registration, UserSession $session) 'redirect_uri' => LmsUserService::getOauthRedirectUri(), 'state' => $session->getUuid() ]; - $baseUrl = $this->util->getCurrentDomain(); - return "https://{$baseUrl}/login/oauth2/auth?" . http_build_query($query); + return "{$registration->getServiceLoginEndpoint()}?" . http_build_query($query); } - public function getOauthTokenUri(Institution $institution) + public function getOauthTokenUri(Registration $registration) { - $baseUrl = $this->util->getCurrentDomain(); - if (!$baseUrl) { - $baseUrl = $institution->getLmsDomain(); - } - - return "https://{$baseUrl}/login/oauth2/token"; + return $registration->getServiceAuthEndpoint(); } @@ -1083,7 +1056,7 @@ protected function getCourseContentItemUrls($courseId, $contentType, $lmsContent return $lmsContentTypeUrls[$contentType]; } - protected function getScopes() + public function getScopes() { $scopes = [ // Accounts diff --git a/src/Lms/D2l/D2lLms.php b/src/Lms/D2l/D2lLms.php index ee684f3da..4a3e88570 100644 --- a/src/Lms/D2l/D2lLms.php +++ b/src/Lms/D2l/D2lLms.php @@ -5,7 +5,6 @@ use App\Entity\ContentItem; use App\Entity\Course; use App\Entity\FileItem; -use App\Entity\Institution; use App\Entity\Registration; use App\Entity\User; use App\Entity\UserSession; @@ -108,12 +107,12 @@ public function getOauthUri(Registration $registration, UserSession $session) 'state' => $session->getUuid(), ]; - return 'https://auth.brightspace.com/oauth2/auth?' . http_build_query($query); + return "{$registration->getServiceLoginEndpoint()}?" . http_build_query($query); } - public function getOauthTokenUri(Institution $institution) + public function getOauthTokenUri(Registration $registration) { - return 'https://auth.brightspace.com/core/connect/token'; + return $registration->getServiceAuthEndpoint(); } /** @@ -121,25 +120,6 @@ public function getOauthTokenUri(Institution $institution) * LTI Functions * ************* */ - public function getLtiAuthUrl($params) - { - $session = $this->sessionService->getSession(); - $baseUrl = !empty(getenv('JWK_BASE_URL')) ? getenv('JWK_BASE_URL') : $session->get('iss'); - $baseUrl = rtrim($baseUrl, '/'); - - $queryStr = http_build_query($params); - - return "{$baseUrl}/d2l/lti/authenticate?{$queryStr}"; - } - - public function getKeysetUrl() - { - $session = $this->sessionService->getSession(); - $baseUrl = !empty(getenv('JWK_BASE_URL')) ? getenv('JWK_BASE_URL') : $session->get('iss'); - $baseUrl = rtrim($baseUrl, '/'); - - return $baseUrl . '/d2l/.well-known/jwks'; - } public function saveTokenToSession($token) { diff --git a/src/Lms/LmsInterface.php b/src/Lms/LmsInterface.php index 145358b08..853d2b370 100644 --- a/src/Lms/LmsInterface.php +++ b/src/Lms/LmsInterface.php @@ -6,7 +6,6 @@ use App\Entity\Course; use App\Entity\FileItem; use App\Entity\Registration; -use App\Entity\Institution; use App\Entity\User; use App\Entity\UserSession; @@ -23,9 +22,7 @@ public function getOauthUri(Registration $registration, UserSession $session); public function getAccountData(User $user, $accountId); public function getCourseUrl(Course $course, User $user); public function getCourseSections(Course $course, User $user); - public function getLtiAuthUrl($params); - public function getOauthTokenUri(Institution $institution); - public function getKeysetUrl(); + public function getOauthTokenUri(Registration $registration); public function saveTokenToSession($token); public function getContentTypes(); } \ No newline at end of file diff --git a/src/Services/LmsUserService.php b/src/Services/LmsUserService.php index 97c5f675b..32460d16a 100644 --- a/src/Services/LmsUserService.php +++ b/src/Services/LmsUserService.php @@ -107,7 +107,7 @@ public function refreshApiKey(User $user) } $client = HttpClient::create(); - $requestUrl = $this->lmsApi->getLms()->getOauthTokenUri($institution); + $requestUrl = $this->lmsApi->getLms()->getOauthTokenUri($registration); try { $response = $client->request('POST', $requestUrl, $options); $contentStr = $response->getContent(false); From 75a51f5e900e807e7f5c234d7989a21d7709c4f6 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 29 May 2026 15:39:55 -0400 Subject: [PATCH 10/28] Allow registration command to be stopped --- build/nginx/Dockerfile.php.pdo.mysql | 2 + src/Command/CreateRegistrationCommand.php | 45 ++++++++++++++++++++--- 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/build/nginx/Dockerfile.php.pdo.mysql b/build/nginx/Dockerfile.php.pdo.mysql index 95df817bf..c5a457f17 100644 --- a/build/nginx/Dockerfile.php.pdo.mysql +++ b/build/nginx/Dockerfile.php.pdo.mysql @@ -3,3 +3,5 @@ FROM php:8.5-fpm # PHP extensions RUN apt-get update && apt-get install -y libpng-dev zlib1g-dev git unzip RUN docker-php-ext-install gd pdo pdo_mysql +RUN docker-php-ext-configure pcntl --enable-pcntl \ + && docker-php-ext-install pcntl; diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 3f09430bb..9fbf9dd73 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -24,6 +24,11 @@ class CreateRegistrationCommand extends Command implements SignalableCommandInterface { + + private bool $isRunningFromFile = false; + private int $totalFileRegistrations = 0; + private int $finishedFileRegistrations = 0; + public function __construct( private ManagerRegistry $doctrine, private SigningKeySetRepository $signingKeySetRepo, @@ -58,7 +63,8 @@ public function __invoke(InputInterface $input, OutputInterface $output): int $filePath = $input->getOption('file'); if ($filePath !== null) - { + { + $this->isRunningFromFile = true; return $this->runFromFile($filePath, $io); } @@ -108,8 +114,10 @@ private function runInteractive($io) $this->doctrine->getManager()->persist($institution); $this->doctrine->getManager()->persist($registration); + + // This should be the only flush so that no DB operation is + // committed if the user exits the progam before it finishes. $this->doctrine->getManager()->flush(); - return Command::SUCCESS; } @@ -184,8 +192,10 @@ private function runFromFile(string $filePath, SymfonyStyle $io) $io->error('The file must contain at least one registration entry.'); return Command::FAILURE; } + + $this->totalFileRegistrations = count($registrations); - $io->writeln(sprintf('Found %d registration(s) to process.', count($registrations))); + $io->writeln(sprintf('Found %d registration(s) to process.', $this->totalFileRegistrations)); $io->writeln(''); $successCount = 0; @@ -206,6 +216,7 @@ private function runFromFile(string $filePath, SymfonyStyle $io) $this->createRegistrationFromEntry($entry, $io); $io->success("Created successfully."); $successCount++; + $this->finishedFileRegistrations = $successCount; } catch (\Throwable $e) { $io->error("Failed: " . $e->getMessage()); $failCount++; @@ -293,6 +304,8 @@ private function createRegistrationFromEntry(array $entry, SymfonyStyle $io): vo $this->doctrine->getManager()->persist($institution); $this->doctrine->getManager()->persist($registration); + + // Save changes after every successful registration. $this->doctrine->getManager()->flush(); } @@ -439,8 +452,10 @@ private function generateAndPersistKeyset(): SigningKeySet $this->doctrine->getManager()->persist($signingKeySet); $this->doctrine->getManager()->persist($signingKey); - $this->doctrine->getManager()->flush(); - + + // Don't flush here so that if user cancels the program, the keys + // won't be saved. + return $signingKeySet; } @@ -588,4 +603,24 @@ private function getLmsId($io) } + + public function getSubscribedSignals(): array + { + return [\SIGINT]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + if ($signal === \SIGINT) { + if ($this->isRunningFromFile) { + exit( + "\n\n\Operation stopped. {$this->finishedFileRegistrations}/{$this->totalFileRegistrations} registrations have been successfully added.\n\n",); + } else { + exit("\n\nRegistration cancelled. \n\n"); + } + } + + return $previousExitCode; + } + } \ No newline at end of file From 455fd4c92ed29f64b655731958586db5d411eb3f Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 29 May 2026 16:01:48 -0400 Subject: [PATCH 11/28] Add conditional keyset generation in registration --- src/Command/CreateRegistrationCommand.php | 20 ++++++++++++++++---- src/Repository/SigningKeySetRepository.php | 19 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 9fbf9dd73..54fa85ff3 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -148,7 +148,9 @@ private function runInteractive($io) * // "jwk_endpoint": "..." * }, * "keyset": { - * "generate": true + * "generate": true + * // true will ALWAYS generate new set, false only generates if no key available + * * // OR use an existing keyset by ID: * // "existing_id": 7 * } @@ -262,9 +264,9 @@ private function validateEntry(array $entry): void // Keyset validation: either generate:true or existing_id. $keyset = $entry['keyset']; - if (empty($keyset['generate']) && empty($keyset['existing_id'])) { + if (!isset($keyset['generate']) && empty($keyset['existing_id'])) { throw new \InvalidArgumentException( - 'Keyset must specify either "generate": true or "existing_id": .' + 'Keyset must specify either "generate": true|false or "existing_id": .' ); } } @@ -357,11 +359,21 @@ private function resolveKeysetFromEntry(array $keysetEntry, SymfonyStyle $io): S $io->writeln("Using existing SigningKeySet ID {$id}."); return $found; } - + + if (!$keysetEntry['generate']) { + $existingKey = $this->getFirstKeyset(); + if ($existingKey) return $existingKey; + } + $io->writeln('Generating new RSA key pair...'); return $this->generateAndPersistKeyset(); } + private function getFirstKeySet(): ?SigningKeySet { + $existing = $this->signingKeySetRepo->getFirstKeySet(); + return empty($existing) ? null : $existing[0]; + } + private function getPlatformPresets(): array { return [ diff --git a/src/Repository/SigningKeySetRepository.php b/src/Repository/SigningKeySetRepository.php index 81f10c966..f4bf1cbb8 100644 --- a/src/Repository/SigningKeySetRepository.php +++ b/src/Repository/SigningKeySetRepository.php @@ -7,10 +7,10 @@ use Doctrine\Persistence\ManagerRegistry; /** - * @method Issue|null find($id, $lockMode = null, $lockVersion = null) - * @method Issue|null findOneBy(array $criteria, array $orderBy = null) - * @method Issue[] findAll() - * @method Issue[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) + * @method SigningKeySet|null find($id, $lockMode = null, $lockVersion = null) + * @method SigningKeySet|null findOneBy(array $criteria, array $orderBy = null) + * @method SigningKeySet[] findAll() + * @method SigningKeySet[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null) */ class SigningKeySetRepository extends ServiceEntityRepository { @@ -29,6 +29,17 @@ public function getAllKeySets() ->getResult(); } + public function getFirstKeySet() + { + return $this->getEntityManager()->createQueryBuilder() + ->select('ks') + ->from(SigningKeySet::class, 'ks') + ->orderBy('ks.id', 'ASC') + ->setMaxResults(1) + ->getQuery() + ->getResult(); + } + // public function deleteContentItemIssues(ContentItem $contentItem) // { // $this->getEntityManager()->createQueryBuilder() From 25f603fcedb82b15f137b414e9f2dd35b8370a64 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 29 May 2026 16:02:54 -0400 Subject: [PATCH 12/28] Fix registration command comma syntax error --- src/Command/CreateRegistrationCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 54fa85ff3..514340aab 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -626,7 +626,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int| if ($signal === \SIGINT) { if ($this->isRunningFromFile) { exit( - "\n\n\Operation stopped. {$this->finishedFileRegistrations}/{$this->totalFileRegistrations} registrations have been successfully added.\n\n",); + "\n\n\Operation stopped. {$this->finishedFileRegistrations}/{$this->totalFileRegistrations} registrations have been successfully added.\n\n"); } else { exit("\n\nRegistration cancelled. \n\n"); } From 8d49868240c123009b4d251c1401066b371a1b4b Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 29 May 2026 16:10:00 -0400 Subject: [PATCH 13/28] Fix tab width inconsistencies --- src/Entity/Deployment.php | 100 +++++++++++------------ src/Entity/SigningKey.php | 166 +++++++++++++++++++------------------- 2 files changed, 133 insertions(+), 133 deletions(-) diff --git a/src/Entity/Deployment.php b/src/Entity/Deployment.php index 19fcf0bf7..e5bab0837 100644 --- a/src/Entity/Deployment.php +++ b/src/Entity/Deployment.php @@ -9,54 +9,54 @@ class Deployment { - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column(type: "integer")] - private int $id; - - #[ORM\ManyToOne( - targetEntity: Registration::class, - inversedBy: "deployments", - )] - #[ORM\JoinColumn(nullable: false)] - private Registration $registration; - - // The LMS-supplied ID for the deployment - #[ORM\Column(type: "string", length: 2048, nullable: false)] - private string $lmsDeploymentId; - - public function __construct(Registration $registration, string $lmsDeploymentId) - { - $this->registration = $registration; - $this->lmsDeploymentId = $lmsDeploymentId; - } - - public function getId(): int - { - return $this->id; - } - - public function getRegistration(): Registration - { - return $this->registration; - } - - public function setRegistration(Registration $registration): static - { - $this->registration = $registration; - - return $this; - } - - public function getLmsDeploymentId(): string - { - return $this->lmsDeploymentId; - } - - public function setLmsDeploymentId(string $lmsDeploymentId): static - { - $this->lmsDeploymentId = $lmsDeploymentId; - - return $this; - } + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: "integer")] + private int $id; + + #[ORM\ManyToOne( + targetEntity: Registration::class, + inversedBy: "deployments", + )] + #[ORM\JoinColumn(nullable: false)] + private Registration $registration; + + // The LMS-supplied ID for the deployment + #[ORM\Column(type: "string", length: 2048, nullable: false)] + private string $lmsDeploymentId; + + public function __construct(Registration $registration, string $lmsDeploymentId) + { + $this->registration = $registration; + $this->lmsDeploymentId = $lmsDeploymentId; + } + + public function getId(): int + { + return $this->id; + } + + public function getRegistration(): Registration + { + return $this->registration; + } + + public function setRegistration(Registration $registration): static + { + $this->registration = $registration; + + return $this; + } + + public function getLmsDeploymentId(): string + { + return $this->lmsDeploymentId; + } + + public function setLmsDeploymentId(string $lmsDeploymentId): static + { + $this->lmsDeploymentId = $lmsDeploymentId; + + return $this; + } } diff --git a/src/Entity/SigningKey.php b/src/Entity/SigningKey.php index ecbc8dd57..8ada6eef3 100644 --- a/src/Entity/SigningKey.php +++ b/src/Entity/SigningKey.php @@ -8,87 +8,87 @@ #[ORM\Table(name: "signing_key")] class SigningKey { - #[ORM\Id] - #[ORM\GeneratedValue] - #[ORM\Column(type: "integer")] - private ?int $id = null; - - #[ORM\Column(type: "text")] - private string $publicKey; - - #[ORM\Column(type: "text")] - private string $privateKey; - - #[ORM\Column(type: "string", length: 200)] - private string $algorithm; - - #[ORM\ManyToOne(targetEntity: SigningKeySet::class, inversedBy: "signingKeys")] - #[ORM\JoinColumn(nullable: true, onDelete: "SET NULL")] - private ?SigningKeySet $signingKeySet = null; - - public function __construct( - string $publicKey, - string $privateKey, - string $algorithm, - ?SigningKeySet $signingKeySet = null - ) - { - $this->publicKey = $publicKey; - $this->privateKey = $privateKey; - $this->algorithm = $algorithm; - $this->signingKeySet = $signingKeySet; - } - - public function getId(): int - { - return $this->id; - } - - public function getPublicKey(): string - { - return $this->publicKey; - } - - public function setPublicKey(string $publicKey): static - { - $this->publicKey = $publicKey; - - return $this; - } - - public function getPrivateKey(): string - { - return $this->privateKey; - } - - public function setPrivateKey(string $privateKey): static - { - $this->privateKey = $privateKey; - - return $this; - } - - public function getAlgorithm(): string - { - return $this->algorithm; - } - - public function setAlgorithm(string $algorithm): static - { - $this->algorithm = $algorithm; - - return $this; - } - - public function getSigningKeySet(): ?SigningKeySet - { - return $this->signingKeySet; - } - - public function setSigningKeySet(?SigningKeySet $signingKeySet): static - { - $this->signingKeySet = $signingKeySet; - - return $this; - } + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: "integer")] + private ?int $id = null; + + #[ORM\Column(type: "text")] + private string $publicKey; + + #[ORM\Column(type: "text")] + private string $privateKey; + + #[ORM\Column(type: "string", length: 200)] + private string $algorithm; + + #[ORM\ManyToOne(targetEntity: SigningKeySet::class, inversedBy: "signingKeys")] + #[ORM\JoinColumn(nullable: true, onDelete: "SET NULL")] + private ?SigningKeySet $signingKeySet = null; + + public function __construct( + string $publicKey, + string $privateKey, + string $algorithm, + ?SigningKeySet $signingKeySet = null + ) + { + $this->publicKey = $publicKey; + $this->privateKey = $privateKey; + $this->algorithm = $algorithm; + $this->signingKeySet = $signingKeySet; + } + + public function getId(): int + { + return $this->id; + } + + public function getPublicKey(): string + { + return $this->publicKey; + } + + public function setPublicKey(string $publicKey): static + { + $this->publicKey = $publicKey; + + return $this; + } + + public function getPrivateKey(): string + { + return $this->privateKey; + } + + public function setPrivateKey(string $privateKey): static + { + $this->privateKey = $privateKey; + + return $this; + } + + public function getAlgorithm(): string + { + return $this->algorithm; + } + + public function setAlgorithm(string $algorithm): static + { + $this->algorithm = $algorithm; + + return $this; + } + + public function getSigningKeySet(): ?SigningKeySet + { + return $this->signingKeySet; + } + + public function setSigningKeySet(?SigningKeySet $signingKeySet): static + { + $this->signingKeySet = $signingKeySet; + + return $this; + } } \ No newline at end of file From bbf9b4e624e63949efce47ba4b992db7ee26ebaf Mon Sep 17 00:00:00 2001 From: CJ Young Date: Fri, 29 May 2026 16:10:21 -0400 Subject: [PATCH 14/28] Add documentation comments --- src/Controller/LtiController.php | 107 +++++++++++++++++++++++++++++++ src/Entity/SigningKey.php | 8 +++ src/Entity/SigningKeySet.php | 9 +++ 3 files changed, 124 insertions(+) diff --git a/src/Controller/LtiController.php b/src/Controller/LtiController.php index dc731d3a6..7707bd887 100644 --- a/src/Controller/LtiController.php +++ b/src/Controller/LtiController.php @@ -47,6 +47,44 @@ public function __construct( $this->registrationRepository = $registrationRepository; } + + /** + * OIDC Login Initiation Request Handler + * + * Handles the third-party initiated login from an LMS. Both GET and POST + * requests must be supported, as the LMS may use either method. + * + * --- + * + * Request Parameters (from LMS): + * + * From IMS Security Framework 1.0, Section 5.1.1.1 + * {@link https://www.imsglobal.org/spec/security/v1p0/#step-1-third-party-initiated-login} + * + * iss The platform issuer + * login_hint Opaque value that should be returned untouched to the LMS + * target_link_uri The target endpoint that should be retrieved at the end of the authentication flow + * + * From LTI 1.3, Section 4.1 + * {@link https://www.imsglobal.org/spec/lti/v1p3#additional-login-parameters} + * + * lti_message_hint Opaque value that should be returned untouched to the LMS + * lti_deployment_id The ID of the specific deployment of the tool + * client_id The client ID for the tool + * + * --- + * + * The tool must redirect the user agent to the OIDC Authentication endpoint + * registered in the LMS. The redirect may be issued as either GET or POST. + * + * Redirect Parameters (to OIDC endpoint): + * + * From IMS Security Framework 1.0, Section 5.1.1.2 + * {@link https://www.imsglobal.org/spec/security/v1p0/#step-2-authentication-request} + * + * scope, response_type, client_id, redirect_uri, login_hint, + * state, response_mode, nonce, prompt + */ #[Route('/lti/authorize', name: 'lti_authorize')] public function ltiAuthorize( Request $request, @@ -70,6 +108,75 @@ public function ltiAuthorize( return $this->redirect($this->getLtiAuthResponseUrl($iss, $clientId)); } + + /** + * LTI Resource Link Launch Request Handler + * + * Handles the final step of the LTI 1.3 launch flow. After the OIDC login + * initiation and authentication redirect, the LMS POSTs the authentication + * response to this endpoint containing the LTI message claims. + * + * + * --- + * + * Request parameters (POST body): + * + * From IMS Security Framework 1.0, Section 5.1.1.3 + * {@link https://www.imsglobal.org/spec/security/v1p0/#step-3-authentication-response} + * + * state Must match the value sent in the auth request (CSRF protection) + * id_token Signed JWT containing the user identity and LTI message claims + * + * --- + * + * REQUIRED claims inside the id_token JWT: + * + * + * From IMS Security Framework 1.0, Section 5.1.2 + * {@link https://www.imsglobal.org/spec/security/v1p0/#id-token} + * iss Issuer (the platform's identifier) + * sub Subject (the user's unique identifier on the platform) + * aud Audience (this tool's client_id) + * iat Issued-at timestamp + * exp Expiry timestamp + * nonce Must match the value sent in the auth request + * + * From LTI 1.3 Specification, Section 5.3 + * {@link https://www.imsglobal.org/spec/lti/v1p3#required-message-claims} + * + * https://purl.imsglobal.org/spec/lti/claim/message_type Must be "LtiResourceLinkRequest" + * https://purl.imsglobal.org/spec/lti/claim/version Must be "1.3.0" + * https://purl.imsglobal.org/spec/lti/claim/deployment_id Identifies the tool deployment within the platform + * https://purl.imsglobal.org/spec/lti/claim/target_link_uri The URL the tool should redirect to after launch + * https://purl.imsglobal.org/spec/lti/claim/resource_link { + * id Opaque platform-unique identifier for this resource link (required) + * title Descriptive title (optional) + * description (optional) + * } + * https://purl.imsglobal.org/spec/lti/claim/roles Array of LIS role URIs for the launching user (may be empty) + + * --- + * + * OPTIONAL claims inside the id_token JWT: + * + * From LTI 1.3 Core Specification, Section 5.4 + * {@link https://www.imsglobal.org/spec/lti/v1p3#optional-message-claims} + * + * https://purl.imsglobal.org/spec/lti/claim/context Course/context info (id, label, title, type) + * https://purl.imsglobal.org/spec/lti/claim/tool_platform Platform info (guid, name, version, etc.) + * https://purl.imsglobal.org/spec/lti/claim/role_scope_mentor Mentor role mappings + * https://purl.imsglobal.org/spec/lti/claim/launch_presentation Presentation hints (locale, target, return URL) + * https://purl.imsglobal.org/spec/lti/claim/lis LIS person and course data + * https://purl.imsglobal.org/spec/lti/claim/custom Custom variables defined in the tool placement + * + * --- + * + * The tool MUST validate the state parameter, JWT signature, nonce, and expiry + * before trusting any claims and establishing a user session. + * + * See IMS Security Framework 1.0, Section 5.1.3 for validation requirements: + * {@link https://www.imsglobal.org/spec/security/v1p0/#authentication-response-validation} + */ #[Route('/lti/authorize/check', name: 'lti_authorize_check')] public function ltiAuthorizeCheck( Request $request, diff --git a/src/Entity/SigningKey.php b/src/Entity/SigningKey.php index 8ada6eef3..deeaf7d5f 100644 --- a/src/Entity/SigningKey.php +++ b/src/Entity/SigningKey.php @@ -1,5 +1,13 @@ Date: Fri, 29 May 2026 16:23:25 -0400 Subject: [PATCH 15/28] Add encryption to signing key private key column --- src/Command/CreateRegistrationCommand.php | 8 +++--- src/Entity/SigningKey.php | 8 +++--- .../SigningKeyEncryptionService.php | 25 +++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) create mode 100644 src/Services/Encryption/SigningKeyEncryptionService.php diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 514340aab..66972304f 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -8,6 +8,7 @@ use App\Entity\SigningKeySet; use App\Repository\SigningKeySetRepository; use App\Services\Encryption\RegistrationEncryptionService; +use App\Services\Encryption\SigningKeyEncryptionService; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -32,7 +33,8 @@ class CreateRegistrationCommand extends Command implements SignalableCommandInte public function __construct( private ManagerRegistry $doctrine, private SigningKeySetRepository $signingKeySetRepo, - private RegistrationEncryptionService $registrationEncryptionService + private RegistrationEncryptionService $registrationEncryptionService, + private SigningKeyEncryptionService $signingKeyEncryptionService ) { parent::__construct(); } @@ -306,7 +308,6 @@ private function createRegistrationFromEntry(array $entry, SymfonyStyle $io): vo $this->doctrine->getManager()->persist($institution); $this->doctrine->getManager()->persist($registration); - // Save changes after every successful registration. $this->doctrine->getManager()->flush(); } @@ -457,10 +458,11 @@ private function generateAndPersistKeyset(): SigningKeySet $signingKeySet = new SigningKeySet(); $signingKey = new SigningKey( $keyPair['publicKey'], - $keyPair['privateKey'], $keyPair['alg'], $signingKeySet, ); + + $this->signingKeyEncryptionService->setPrivateKey($signingKey, $keyPair['privateKey']); $this->doctrine->getManager()->persist($signingKeySet); $this->doctrine->getManager()->persist($signingKey); diff --git a/src/Entity/SigningKey.php b/src/Entity/SigningKey.php index deeaf7d5f..2d9d23db0 100644 --- a/src/Entity/SigningKey.php +++ b/src/Entity/SigningKey.php @@ -25,7 +25,7 @@ class SigningKey private string $publicKey; #[ORM\Column(type: "text")] - private string $privateKey; + private ?string $privateKey; #[ORM\Column(type: "string", length: 200)] private string $algorithm; @@ -36,13 +36,11 @@ class SigningKey public function __construct( string $publicKey, - string $privateKey, string $algorithm, ?SigningKeySet $signingKeySet = null ) { $this->publicKey = $publicKey; - $this->privateKey = $privateKey; $this->algorithm = $algorithm; $this->signingKeySet = $signingKeySet; } @@ -64,12 +62,12 @@ public function setPublicKey(string $publicKey): static return $this; } - public function getPrivateKey(): string + public function getPrivateKeyEncrypted(): ?string { return $this->privateKey; } - public function setPrivateKey(string $privateKey): static + public function setPrivateKeyEncrypted(string $privateKey): static { $this->privateKey = $privateKey; diff --git a/src/Services/Encryption/SigningKeyEncryptionService.php b/src/Services/Encryption/SigningKeyEncryptionService.php new file mode 100644 index 000000000..372b92bd8 --- /dev/null +++ b/src/Services/Encryption/SigningKeyEncryptionService.php @@ -0,0 +1,25 @@ +setPrivateKeyEncrypted($this->encryptionService->encrypt($secret)); + } + + public function getPrivateKey(SigningKey $signingKey): string + { + return $this->encryptionService->decrypt($signingKey->getPrivateKeyEncrypted()); + } + +} \ No newline at end of file From 2745d312ba779fc364617e4afc25cf70923c96a9 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 11:07:47 -0400 Subject: [PATCH 16/28] Add unique constraint to registration client ID and iss --- src/Command/CreateRegistrationCommand.php | 101 +++++++++++------- .../Version20260529205050.php | 31 ++++++ src/Entity/Registration.php | 4 + 3 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 src/DoctrineMigrations/Version20260529205050.php diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 66972304f..4413d4b6e 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -9,6 +9,8 @@ use App\Repository\SigningKeySetRepository; use App\Services\Encryption\RegistrationEncryptionService; use App\Services\Encryption\SigningKeyEncryptionService; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -30,12 +32,18 @@ class CreateRegistrationCommand extends Command implements SignalableCommandInte private int $totalFileRegistrations = 0; private int $finishedFileRegistrations = 0; + private EntityManagerInterface $em; + public function __construct( private ManagerRegistry $doctrine, private SigningKeySetRepository $signingKeySetRepo, private RegistrationEncryptionService $registrationEncryptionService, private SigningKeyEncryptionService $signingKeyEncryptionService ) { + $em = $doctrine->getManager(); + assert($em instanceof EntityManagerInterface); + $this->em = $em; + parent::__construct(); } @@ -114,12 +122,12 @@ private function runInteractive($io) $institution->setRegistration($registration); - $this->doctrine->getManager()->persist($institution); - $this->doctrine->getManager()->persist($registration); + $this->em->persist($institution); + $this->em->persist($registration); // This should be the only flush so that no DB operation is // committed if the user exits the progam before it finishes. - $this->doctrine->getManager()->flush(); + $this->em->flush(); return Command::SUCCESS; } @@ -217,6 +225,10 @@ private function runFromFile(string $filePath, SymfonyStyle $io) try { $this->validateEntry($entry); + if (!$this->em->isOpen()) { + $this->doctrine->resetManager(); + $this->em = $this->doctrine->getManager(); + } $this->createRegistrationFromEntry($entry, $io); $io->success("Created successfully."); $successCount++; @@ -278,38 +290,55 @@ private function validateEntry(array $entry): void */ private function createRegistrationFromEntry(array $entry, SymfonyStyle $io): void { - $platform = $this->resolvePlatformFromEntry($entry['platform']); - $keyset = $this->resolveKeysetFromEntry($entry['keyset'], $io); - - $institution = new Institution(); - $institution->setTitle($entry['title']); - $institution->setLmsDomain($entry['lms_domain']); - $institution->setLmsId($entry['lms_id']); - $institution->setLmsAccountId($entry['lms_account_id']); - $institution->setStatus(true); - $institution->setVanityUrl($entry['vanity_url']); - $institution->setCreated(new \DateTime()); - - $registration = new Registration( - $platform['issuer'], - $entry['lti_client_id'], - $platform['loginAuthEndpoint'], - $platform['jwkEndpoint'], - $platform['serviceAuthEndpoint'], - $platform['serviceLoginEndpoint'], - $entry['api_client_id'], - $keyset, - $institution - ); + $this->em->beginTransaction(); + + try { + + + $platform = $this->resolvePlatformFromEntry($entry['platform']); + $keyset = $this->resolveKeysetFromEntry($entry['keyset'], $io); + + $institution = new Institution(); + $institution->setTitle($entry['title']); + $institution->setLmsDomain($entry['lms_domain']); + $institution->setLmsId($entry['lms_id']); + $institution->setLmsAccountId($entry['lms_account_id']); + $institution->setStatus(true); + $institution->setVanityUrl($entry['vanity_url']); + $institution->setCreated(new \DateTime()); + + $registration = new Registration( + $platform['issuer'], + $entry['lti_client_id'], + $platform['loginAuthEndpoint'], + $platform['jwkEndpoint'], + $platform['serviceAuthEndpoint'], + $platform['serviceLoginEndpoint'], + $entry['api_client_id'], + $keyset, + $institution + ); + + $this->registrationEncryptionService->setClientSecret($registration, $entry['api_client_secret']); + + $institution->setRegistration($registration); + + $this->em->persist($institution); + $this->em->persist($registration); + + // Save changes after every successful registration. + $this->em->flush(); + $this->em->commit(); + } catch (UniqueConstraintViolationException $e) { + + $this->em->rollback(); + $this->em->clear(); + $io->error('Transaction failed. The pair (issuer, client_id) must be unique.'); + + throw $e; + + } - $this->registrationEncryptionService->setClientSecret($registration, $entry['api_client_secret']); - - $institution->setRegistration($registration); - - $this->doctrine->getManager()->persist($institution); - $this->doctrine->getManager()->persist($registration); - // Save changes after every successful registration. - $this->doctrine->getManager()->flush(); } /** @@ -464,8 +493,8 @@ private function generateAndPersistKeyset(): SigningKeySet $this->signingKeyEncryptionService->setPrivateKey($signingKey, $keyPair['privateKey']); - $this->doctrine->getManager()->persist($signingKeySet); - $this->doctrine->getManager()->persist($signingKey); + $this->em->persist($signingKeySet); + $this->em->persist($signingKey); // Don't flush here so that if user cancels the program, the keys // won't be saved. diff --git a/src/DoctrineMigrations/Version20260529205050.php b/src/DoctrineMigrations/Version20260529205050.php new file mode 100644 index 000000000..f24b2a7cc --- /dev/null +++ b/src/DoctrineMigrations/Version20260529205050.php @@ -0,0 +1,31 @@ +addSql('CREATE UNIQUE INDEX unique_issuer_client_id_combination ON registration (issuer, client_id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX unique_issuer_client_id_combination ON registration'); + } +} diff --git a/src/Entity/Registration.php b/src/Entity/Registration.php index eae132b38..299601033 100644 --- a/src/Entity/Registration.php +++ b/src/Entity/Registration.php @@ -8,6 +8,10 @@ #[ORM\Entity(repositoryClass: "App\Repository\RegistrationRepository")] +#[ORM\UniqueConstraint( + name: "unique_issuer_client_id_combination", + columns: ["issuer", "client_id"] +)] #[ORM\Table(name: 'registration')] class Registration { From 1bf2060f69409f71a95cf9a94fa8095642ebc5aa Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 11:09:53 -0400 Subject: [PATCH 17/28] Fix login auth endpoint prompt missing --- src/Command/CreateRegistrationCommand.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 4413d4b6e..d10ab15f1 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -468,9 +468,10 @@ private function getPlatform(SymfonyStyle $io) } $iss = $io->ask('Enter issuer'); - $loginAuthEndpoint = $io->ask('Enter login auth endpoint'); - $serviceAuthEndpoint = $io->ask('Enter service auth endpoint'); - $jwkEndpoint = $io->ask('Enter JWK endpoint'); + $loginAuthEndpoint = $io->ask('Enter the login auth endpoint'); + $serviceAuthEndpoint = $io->ask('Enter the service auth endpoint'); + $serviceLoginEndpoint = $io->ask('Enter the login auth endpoint'); + $jwkEndpoint = $io->ask('Enter the JWK endpoint'); return [ 'issuer' => $iss, From a4ff7399c5c4f7553ced3ac17ed8628fca77416f Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 11:23:12 -0400 Subject: [PATCH 18/28] Remove JSON support for registration command --- src/Command/CreateRegistrationCommand.php | 69 ++++++++++------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index d10ab15f1..5a0a689cc 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -53,7 +53,7 @@ protected function configure() 'file', 'f', InputOption::VALUE_REQUIRED, - 'Path to a JSON or YAML file containing one or more registrations to create in batch' + 'Path to a YAML file containing one or more registrations to create in batch' ); } @@ -133,41 +133,39 @@ private function runInteractive($io) } /** - * Process one or more registrations defined in a JSON or YAML file. + * Process one or more registrations defined in a YAML file. * * Expected top-level structure (array of registration objects): * - * JSON example (registrations.json): - * [ - * { - * "title": "My University", - * "lms_domain": "myuniversity.instructure.com", - * "lms_id": "canvas", - * "vanity_url": "myuniversity.instructure.com", - * "lms_account_id": "12345", - * "lti_client_id": "abc123", - * "api_client_id": "def456", - * "api_client_secret": "supersecret", - * "platform": { - * "preset": "Production Canvas" - * // OR supply all four fields manually: - * // "issuer": "https://canvas.instructure.com", - * // "login_auth_endpoint": "...", - * // "service_auth_endpoint": "...", - * // "service_login_endpoint: "...", - * // "jwk_endpoint": "..." - * }, - * "keyset": { - * "generate": true - * // true will ALWAYS generate new set, false only generates if no key available + * YAML example (registrations.yaml): + * - title: My University + * lms_domain: myuniversity.instructure.com + * lms_id: canvas + * vanity_url: myuniversity.instructure.com + * lms_account_id: "12345" + * lti_client_id: abc123 + * api_client_id: def456 + * api_client_secret: supersecret + * + * platform: + * preset: Production Canvas * - * // OR use an existing keyset by ID: - * // "existing_id": 7 - * } - * } - * ] + * # OR supply all fields manually: + * # issuer: https://canvas.instructure.com + * # login_auth_endpoint: https://... + * # service_auth_endpoint: https://... + * # service_login_endpoint: https://... + * # jwk_endpoint: https://... + * + * keyset: + * generate: true + * + * # true will ALWAYS generate a new keyset + * # false only generates if no key is available + * + * # OR use an existing keyset: + * # existing_id: 7 * - * YAML files follow the same structure with YAML syntax. * A single object (not wrapped in an array) is also accepted. */ private function runFromFile(string $filePath, SymfonyStyle $io) @@ -182,10 +180,9 @@ private function runFromFile(string $filePath, SymfonyStyle $io) try { $data = match ($extension) { - 'json' => json_decode($content, true, 512, JSON_THROW_ON_ERROR), 'yaml', 'yml' => Yaml::parse($content), default => throw new \InvalidArgumentException( - "Unsupported file extension \".{$extension}\". Use .json, .yaml, or .yml." + "Unsupported file extension \".{$extension}\". Use .yaml or .yml." ), }; } catch (\Throwable $e) { @@ -194,11 +191,7 @@ private function runFromFile(string $filePath, SymfonyStyle $io) } // Allow a single object or an array of objects. - if (isset($data['title'])) { - $registrations = [$data]; - } else { - $registrations = $data; - } + $registrations = isset($data['title']) ? [$data] : $data; if (empty($registrations) || !is_array($registrations)) { $io->error('The file must contain at least one registration entry.'); From 35f3b7e98c6018558f9d7406b6a965ad0b2a457a Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 13:37:12 -0400 Subject: [PATCH 19/28] Update docs to describe registration steps --- HEROKU.md | 9 ++--- INSTALL_CANVAS.md | 74 ++++++++++++++++++++++++++-------------- INSTALL_D2L.md | 72 +++++++++++++++++++++----------------- institution.example.yaml | 16 +++++++++ 4 files changed, 107 insertions(+), 64 deletions(-) create mode 100644 institution.example.yaml diff --git a/HEROKU.md b/HEROKU.md index 64483ccb1..33062e7c3 100644 --- a/HEROKU.md +++ b/HEROKU.md @@ -17,8 +17,7 @@ Click the Heroku button and follow the instructions below: * `canvas` if you are using the Canvas LMS. * `d2l` if you are using the D2l Brightspace LMS. 5. Fill out the `BASE_URL` field with `https://yourapp.herokuapp.com`. (Replace 'yourapp' with the name you gave in step 1.2.) -6. Fill out the `JWK_BASE_URL` field with the URL to your LMS. The default value works for instructure hosted instances of Canvas, but will need to be modified if your JWK configuration is hosted at a different domain than `iss`. -7. Click the Deploy button and wait for the process to complete. +1. Click the Deploy button and wait for the process to complete. The above deploy uses the Heroku Postgres Mini plan by default. Please check `https://elements.heroku.com/addons/heroku-postgresql` for Heroku Postgresql plan details. You can upgrade Postgresql plan inside Heroku UI later. @@ -40,11 +39,7 @@ php bin/console cache:warmup --env=prod ```sql psql ``` -6. Insert your institution in to the institution table as described in INSTALL_\.md. It may be something like: -```sql -INSERT INTO institution (id, title, lms_domain, lms_id, lms_account_id, created, status, vanity_url, metadata, api_client_id, api_client_secret) VALUES (0, 'Canvas', 'myinstitution.instructure.com', 'canvas', '1', '2021-10-21', true, 'vanity.example.com', '{"lang":"en"}', '123456', 'abcdefghijklmnopqrstuvwxyz'); -``` -(DO NOT copy that command and use it as-is!) +8. Insert your institution data into the database as described in INSTALL_\.md. ### Step 3: Finish Install the app following the instructions described in INSTALL_\.md. diff --git a/INSTALL_CANVAS.md b/INSTALL_CANVAS.md index 44a793978..aa79cc874 100644 --- a/INSTALL_CANVAS.md +++ b/INSTALL_CANVAS.md @@ -115,36 +115,58 @@ Follow the steps below, replacing `` with the `BASE_URL` va 4. Click Save. 5. Click `ON` to enable the newly created key. -## Update the Institutions Table -UDOIT is built to support more than one LMS instance. For this purpose, we have an `institution` table that must be populated with the LMS information. +## Add institution data to the database -1. Inside the UDOIT directory, run `cp .ins.env.example .ins.env` -2. open `.ins.env` with a text editor (i.e. Notepad, VS Code, etc.) +UDOIT is built to support more than one LMS instance. There are two supported methods to populating the database with institution data. + +### Method 1 (recommended): Create a configuration file + +1. Inside the UDOIT directory, run + ```bash + cp institution.example.yaml institution.secret.yaml + ``` +2. Open `institution.secret.yaml` in a text editor (i.e. Notepad, VS Code, etc.) 3. Fill in the fields with the appropriate values -- `TITLE` = Your institution's name -- `LMS_DOMAIN` = The Canvas domain name of your institution (i.e. `myschool.instructure.com`) -- `LMS_ID` = `canvas` -- `LMS_ACCOUNT_ID` = The Canvas account ID (as a string) where UDOIT will be installed -- `CREATED` = Date in this format: `2021-06-08` -- `STATUS` = `1` if you are using MySQL or MariaDB (or Docker), `true` if you are using PostgreSQL -- `VANITY_URL` = Your LMS vanity URL (i.e. `canvas.myschool.edu`) -- `METADATA` = Optional. Institution-specific settings, such as language or excluded tests. Text representation of a JSON object. (i.e. `{"lang":"en"}`) -- `API_CLIENT_ID` = The ID of the developer API key you created earlier -- `API_CLIENT_SECRET` = The secret for the API key you created earlier - -With all the values now set up, you're ready to run the command that will automate the creation of your `institutions` table! Run the following command if you have a MySQL database setup: -``` -make ins-mysql -``` -Or this one if you have a PostgreSQL setup: -``` -make ins-psql -``` -Your database should now show a new row in the `institution` table, containing all the values you input above. +- `title`: Your institution's name +- `lms_domain`: The Canvas domain name of your institution (i.e. `myschool.instructure.com`) +- `vanity_url`: Your LMS vanity URL (i.e. `canvas.myschool.edu`) +- `lms_id`: MUST be `canvas` +- `lms_account_id`: The Canvas account ID (as a string) where UDOIT will be installed +- `lti_client_id`: The ID of the developer LTI key you created earlier +- `api_client_id`: The ID of the developer API key you created earlier +- `api_client_secret`: The secret for the API key you created earlier +- `platform`: Can be one of two options + - Manual entry; specify the following fields + - `issuer` - The token issuer of your LMS (usually `https://canvas.instructure.com` unless in a test or beta environment) + - `login_auth_endpoint` - The redirect endpoint specified in your LMS (usually `https://sso.canvaslms.com/api/lti/authorize_redirect` if hosted by Canvas) + - `service_auth_endpoint` - The OAuth token endpoint of your LMS (usually `https://sso.canvaslms.com/login/oauth2/token` if hosted by Canvas) + - `service_login_endpoint` - The OAuth login endpoint of your LMS (usually `https://sso.canvaslms.com/login/oauth2/auth` if hosted by Canvas). This is the endpoint that the user will be redirected to during the OAuth process to request consent to use their Canvas API key with the tool + - `jwk_endpoint` - The JWK endpoint of your LMS (usually `https://sso.canvaslms.com/api/lti/security/jwks` if hosted by Canvas) + - Use a preset + - Add a single field called `preset` and populate it with one of the following supported preset options: `Production Canvas`, `Test Canvas`, `Beta Canvas`, `Devhub` + - For example, your `platform` field may look like: + ```yaml + platform: + preset: Test Canvas + ``` +- `keyset` Can be one of two options + - Specify the following field + - `generate`: If this field is set to `true`, a new signing keyset will be generated without exception. If it is `false`, the institution's keyset will be set to the keyset in the database with the smallest ID. If no keyset exists, a new one will be created. Use `generate: false` for every institution if you do not want different signing key sets for every institution + - `existing_id`: The database ID of the keyset that you want to reuse +4. Run the following command in the UDOIT directory to populate the databse with your institution data + ```bash + make create-registration FILE="institution.secret.yaml" + ``` + +### Method 2: Manual entry through the CLI -## .ENV Setup -For cloud-hosted canvas instances, the default value for the `JWK_BASE_URL` environmental variable will work out of the box. If you are not cloud-hosted, you may need to change the value of this variable in `.env.local` to match your canvas instance. +1. Inside the UDOIT directory, run + +```bash +make create-registration +``` +2. Follow the prompts and input required information. You will have to input the same information as required in the file-based initialization but will not have easy access to previously entered information both during and after the process, so using this option is not recommended. It remains an option for temporary or testing purposes. --- ## Install the App diff --git a/INSTALL_D2L.md b/INSTALL_D2L.md index 2463e8301..0375fcfbe 100644 --- a/INSTALL_D2L.md +++ b/INSTALL_D2L.md @@ -37,39 +37,49 @@ UDOIT uses LTI 1.3 to integrate with the LMS. --- -## Update the Institutions Table -UDOIT is built to support more than one LMS instance. For this purpose we have an `institution` table that must be populated with the LMS information. +## Add institution data to the database -**Note:** This step requires knowledge of MySQL. +UDOIT is built to support more than one LMS instance. There are two supported methods to populating the database with institution data. -The following fields need to be populated in the `institution` table. -* title - * Your institution's name -* lms_domain - * The D2L domain name of your institution. - * Do not include `https://` or a trailing slash. - * Example: `myschool.d2l.com` -* lms_id - * `d2l` -* lms_account_id - * The D2L org unit ID where UDOIT will be installed. -* created - * Date in this format: `"2021-06-08"` -* status - * `1` if you are using MySQL or MariaDB - * `true` if you are using PostgreSQL -* vanity_url - * Your LMS vanity URL - * Example: `d2l.myschool.edu` -* metadata - * Optional - * Institution specific settings, such as language or excluded tests. - * Text representation of a JSON object. - * Example: `'{"lang":"es"}'` - * Currently supported languages are English (en) and Spanish (es). -* api_client_id -* api_client_secret - * This key will be encrypted and stored as encrypted on the first use of the key. +### Method 1 (recommended): Create a configuration file + +1. Inside the UDOIT directory, run + ```bash + cp institution.example.yaml institution.secret.yaml + ``` +2. Open `institution.secret.yaml` in a text editor (i.e. Notepad, VS Code, etc.) +3. Fill in the fields with the appropriate values +- `title`: Your institution's name +- `lms_domain`: The D2L domain name of your institution (i.e. `myschool.brightspace.com`) +- `vanity_url`: Your LMS vanity URL (i.e. `d2l.myschool.edu`) +- `lms_id`: MUST be `d2l` +- `lms_account_id`: The D2L org unit ID where UDOIT will be installed. +- `lti_client_id`: The ID of the developer LTI key you created earlier +- `api_client_id`: The ID of the developer API key you created earlier +- `api_client_secret`: The secret for the API key you created earlier +- `platform`: Specify the following fields inside the platform field + - `issuer` - The token issuer of your LMS (usually `https://.brightspace.com`) + - `login_auth_endpoint` - The redirect endpoint specified in your LMS (usually `https://.brightspace.com/d2l/lti/authenticate`) + - `service_auth_endpoint` - The OAuth token endpoint of your LMS (usually `https://auth.brightspace.com/core/connect/token`) + - `service_login_endpoint` - The OAuth login endpoint of your LMS (usually `https://auth.brightspace.com/oauth2/auth`). This is the endpoint that the user will be redirected to during the OAuth process to request consent to use their D2L API key with the tool + - `jwk_endpoint` - The JWK endpoint of your LMS (usually `https://.brightspace.com/d2l/.well-known/jwks`) +- `keyset` Can be one of two options + - Specify the following field + - `generate`: If this field is set to `true`, a new signing keyset will be generated without exception. If it is `false`, the institution's keyset will be set to the keyset in the database with the smallest ID. If no keyset exists, a new one will be created. Use `generate: false` for every institution if you do not want different signing key sets for every institution + - `existing_id`: The database ID of the keyset that you want to reuse +4. Run the following command in the UDOIT directory to populate the databse with your institution data + ```bash + make create-registration FILE="institution.secret.yaml" + ``` + +### Method 2: Manual entry through the CLI + +1. Inside the UDOIT directory, run + +```bash +make create-registration +``` +2. Follow the prompts and input required information. You will have to input the same information as required in the file-based initialization but will not have easy access to previously entered information both during and after the process, so using this option is not recommended. It remains an option for temporary or testing purposes. --- ## Install the App diff --git a/institution.example.yaml b/institution.example.yaml new file mode 100644 index 000000000..ef7bf446e --- /dev/null +++ b/institution.example.yaml @@ -0,0 +1,16 @@ +title: My School +lms_domain: myschool.instructure.com +vanity_url: webcourses.myschool.edu +lms_id: canvas +lms_account_id: "123" +lti_client_id: my_lti_client_id +api_client_id: my_api_client_id +api_client_secret: super_secret_client_secret +platform: + issuer: https://canvas.instructure.com + login_auth_endpoint: https://sso.canvaslms.com/api/lti/authorize_redirect + service_auth_endpoint: https://sso.canvaslms.com/login/oauth2/token + service_login_endpoint: https://sso.canvaslms.com/login/oauth2/auth + jwk_endpoint: https://sso.canvaslms.com/api/lti/security/jwks +keyset: + generate: false From a49b2aeed9f3a1bd66eb5ed4ced961e4338a0b4f Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 13:42:00 -0400 Subject: [PATCH 20/28] Remove `JWK_BASE_URL` from instructions and env files --- .env.example | 4 ---- INSTALL.md | 1 - app.json | 4 ---- 3 files changed, 9 deletions(-) diff --git a/.env.example b/.env.example index 6703cc667..07b0150f9 100644 --- a/.env.example +++ b/.env.example @@ -110,10 +110,6 @@ VIMEO_API_KEY="" YOUTUBE_API_KEY="" ###> api keys ### -###> jwk base url ### -JWK_BASE_URL="https://canvas.instructure.com/" -###> jwk base url ### - ############# Admin Panel Data Retrieval Params: ###> admin panel retrieval task lms url ### diff --git a/INSTALL.md b/INSTALL.md index 55890e08e..241ed7f91 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -43,7 +43,6 @@ This command copies the `.env.example` into `.env`, creating the `.env` file in - `BASE_URL`: If you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to the URL of your instance of UDOIT. - `WEBPACK_PUBLIC_PATH`: Uf you are hosting UDOIT on Docker or your local machine, leave it as it is. Otherwise, change it to match the `BASE_URL`in such a way that `/build` is located at the root of the `BASE_URL` (Example: If your `BASE_URL` is set to `http://127.0.0.1:8000`, your `WEBPACK_PUBLIC_PATH` should be `/build`). - `APP_LMS`: `canvas` for Canvas LMS. `d2l` for D2l Brightspace LMS. - - `JWK_BASE_URL`: If you are self-hosting Canvas, you may set it to the URL of your instance of Canvas. (Example: `JWK_BASE_URL="https://canvas.dev.myschool.edu"`) - `DEFAULT_LANG`: (optional) `en` for English. `es` for Spanish. This is English by default. ## Installation diff --git a/app.json b/app.json index 7004b63c1..5411f9eb7 100644 --- a/app.json +++ b/app.json @@ -26,10 +26,6 @@ "description": "Full URL to your instance.", "value": "https://your.herokuapp.com" }, - "JWK_BASE_URL": { - "description": "Overrides `iss` to use an alternative url for JWK. Leave empty if using cloud hosted Canvas.", - "value": "" - }, "HEROKU_TIMEOUT": { "description": "Web requests time out at 30 seconds causing the app to crash. Value should be significantly less than 30 to prevent this.", "value": "15" From fcc35cb2485793578e003299802b2b2504942fdd Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 13:49:31 -0400 Subject: [PATCH 21/28] Squash LTI table migrations --- .../Version20260529205050.php | 31 ------------------- ...28151648.php => Version20260602174632.php} | 4 +-- 2 files changed, 2 insertions(+), 33 deletions(-) delete mode 100644 src/DoctrineMigrations/Version20260529205050.php rename src/DoctrineMigrations/{Version20260528151648.php => Version20260602174632.php} (95%) diff --git a/src/DoctrineMigrations/Version20260529205050.php b/src/DoctrineMigrations/Version20260529205050.php deleted file mode 100644 index f24b2a7cc..000000000 --- a/src/DoctrineMigrations/Version20260529205050.php +++ /dev/null @@ -1,31 +0,0 @@ -addSql('CREATE UNIQUE INDEX unique_issuer_client_id_combination ON registration (issuer, client_id)'); - } - - public function down(Schema $schema): void - { - // this down() migration is auto-generated, please modify it to your needs - $this->addSql('DROP INDEX unique_issuer_client_id_combination ON registration'); - } -} diff --git a/src/DoctrineMigrations/Version20260528151648.php b/src/DoctrineMigrations/Version20260602174632.php similarity index 95% rename from src/DoctrineMigrations/Version20260528151648.php rename to src/DoctrineMigrations/Version20260602174632.php index a0bccbccf..8f506bbd8 100644 --- a/src/DoctrineMigrations/Version20260528151648.php +++ b/src/DoctrineMigrations/Version20260602174632.php @@ -10,7 +10,7 @@ /** * Auto-generated Migration: Please modify to your needs! */ -final class Version20260528151648 extends AbstractMigration +final class Version20260602174632 extends AbstractMigration { public function getDescription(): string { @@ -21,7 +21,7 @@ public function up(Schema $schema): void { // this up() migration is auto-generated, please modify it to your needs $this->addSql('CREATE TABLE deployment (id INT AUTO_INCREMENT NOT NULL, lms_deployment_id VARCHAR(2048) NOT NULL, registration_id INT NOT NULL, INDEX IDX_EB1255BE833D8F43 (registration_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); - $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, service_auth_endpoint VARCHAR(2048) NOT NULL, service_login_endpoint VARCHAR(2048) NOT NULL, api_client_id VARCHAR(255) NOT NULL, api_client_secret_encrypted VARCHAR(255) NOT NULL, institution_id INT NOT NULL, signing_key_set_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_62A8A7A710405986 (institution_id), INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); + $this->addSql('CREATE TABLE registration (id INT AUTO_INCREMENT NOT NULL, issuer VARCHAR(255) NOT NULL, client_id VARCHAR(255) NOT NULL, login_auth_endpoint VARCHAR(2048) NOT NULL, jwks_endpoint VARCHAR(2048) NOT NULL, service_auth_endpoint VARCHAR(2048) NOT NULL, service_login_endpoint VARCHAR(2048) NOT NULL, api_client_id VARCHAR(255) NOT NULL, api_client_secret_encrypted VARCHAR(255) NOT NULL, institution_id INT NOT NULL, signing_key_set_id INT DEFAULT NULL, UNIQUE INDEX UNIQ_62A8A7A710405986 (institution_id), INDEX IDX_62A8A7A7A409F300 (signing_key_set_id), UNIQUE INDEX unique_issuer_client_id_combination (issuer, client_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key (id INT AUTO_INCREMENT NOT NULL, public_key LONGTEXT NOT NULL, private_key LONGTEXT NOT NULL, algorithm VARCHAR(200) NOT NULL, signing_key_set_id INT DEFAULT NULL, INDEX IDX_3DAB554EA409F300 (signing_key_set_id), PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('CREATE TABLE signing_key_set (id INT AUTO_INCREMENT NOT NULL, PRIMARY KEY (id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci`'); $this->addSql('ALTER TABLE deployment ADD CONSTRAINT FK_EB1255BE833D8F43 FOREIGN KEY (registration_id) REFERENCES registration (id)'); From 59713474872ec7e60c43c74a204a8be768e2b57c Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 14:03:31 -0400 Subject: [PATCH 22/28] Update .gitignore to exclude institution YAML config --- .gitignore | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitignore b/.gitignore index 6103d1289..c5ee031f9 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,9 @@ yarn-error.log .phpunit.result.cache ###< phpunit/phpunit ### +###> YAML ### +*.secret.yaml +*.secret.yml +###< YAML ### + *.env \ No newline at end of file From 697fb039b586750624c9e3385983204ce56b3de2 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 14:13:03 -0400 Subject: [PATCH 23/28] Remove old institution make scripts --- Makefile | 25 +------------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/Makefile b/Makefile index 800067bae..fa8bafad8 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,3 @@ -# checks if .ins.env exists and includes it -ifneq (,$(wildcard ./.ins.env)) - include .ins.env - export -endif - # ────────────────────────────────────────────── # Variables # ────────────────────────────────────────────── @@ -87,27 +81,10 @@ admin-panel-retrieve-data: clean-cache exit 1; \ fi $(COMPOSE) run --rm php php bin/console app:admin-panel-retrieval $(foreach table,$(TABLES),--tables=$(table)) + # ────────────────────────────────────────────── # Institution Seeding # ────────────────────────────────────────────── -## Insert institution row (MySQL) -## Reads variables from .ins.env -ins-mysql: - docker exec -it udoit3-db mysql -u root -proot udoit3 \ - -e "INSERT INTO institution \ - (title, lms_domain, lms_id, lms_account_id, created, status, vanity_url, metadata, api_client_id, api_client_secret) \ - VALUES \ - ('$(TITLE)', '$(LMS_DOMAIN)', '$(LMS_ID)', '$(LMS_ACCOUNT_ID)', '$(CREATED)', '$(STATUS)', '$(VANITY_URL)', '$(METADATA)', '$(API_CLIENT_ID)', '$(API_CLIENT_SECRET)');" - -## Insert institution row (PostgreSQL) -## Reads variables from .ins.env -ins-psql: - docker exec -it -e PGPASSWORD=root udoit3-db psql -U root -d udoit3 -w \ - -c "INSERT INTO institution \ - (title, lms_domain, lms_id, lms_account_id, created, status, vanity_url, metadata, api_client_id, api_client_secret) \ - VALUES \ - ('$(TITLE)', '$(LMS_DOMAIN)', '$(LMS_ID)', '$(LMS_ACCOUNT_ID)', '$(CREATED)', '$(STATUS)', '$(VANITY_URL)', '$(API_CLIENT_ID)', '$(API_CLIENT_SECRET)');" - create-registration: $(COMPOSE) run --rm php php bin/console app:create-registration $(if $(FILE),--file=$(FILE),) From 6e675ec84e789a23fe732238ebd73e1767f81e7a Mon Sep 17 00:00:00 2001 From: CJ Young Date: Tue, 2 Jun 2026 14:30:32 -0400 Subject: [PATCH 24/28] Update Heroku README --- HEROKU.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/HEROKU.md b/HEROKU.md index 33062e7c3..1713f1765 100644 --- a/HEROKU.md +++ b/HEROKU.md @@ -17,7 +17,7 @@ Click the Heroku button and follow the instructions below: * `canvas` if you are using the Canvas LMS. * `d2l` if you are using the D2l Brightspace LMS. 5. Fill out the `BASE_URL` field with `https://yourapp.herokuapp.com`. (Replace 'yourapp' with the name you gave in step 1.2.) -1. Click the Deploy button and wait for the process to complete. +6. Click the Deploy button and wait for the process to complete. The above deploy uses the Heroku Postgres Mini plan by default. Please check `https://elements.heroku.com/addons/heroku-postgresql` for Heroku Postgresql plan details. You can upgrade Postgresql plan inside Heroku UI later. @@ -35,11 +35,7 @@ php bin/console doctrine:migrations:migrate php bin/console cache:warmup --env=prod ``` 6. Set up developer keys according to the instructions in INSTALL_\.md. -7. Access the Postgres database by running the following command in the Console: -```sql -psql -``` -8. Insert your institution data into the database as described in INSTALL_\.md. +7. Insert your institution data into the database as described in INSTALL_\.md. ### Step 3: Finish Install the app following the instructions described in INSTALL_\.md. From 036a10b3d03fdab25dee69a2bd352715cbee8879 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Wed, 3 Jun 2026 16:25:52 -0400 Subject: [PATCH 25/28] Remove Devhub from institution presets --- INSTALL_CANVAS.md | 4 +-- src/Command/CreateRegistrationCommand.php | 31 ----------------------- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/INSTALL_CANVAS.md b/INSTALL_CANVAS.md index aa79cc874..8f926fe83 100644 --- a/INSTALL_CANVAS.md +++ b/INSTALL_CANVAS.md @@ -144,7 +144,7 @@ UDOIT is built to support more than one LMS instance. There are two supported me - `service_login_endpoint` - The OAuth login endpoint of your LMS (usually `https://sso.canvaslms.com/login/oauth2/auth` if hosted by Canvas). This is the endpoint that the user will be redirected to during the OAuth process to request consent to use their Canvas API key with the tool - `jwk_endpoint` - The JWK endpoint of your LMS (usually `https://sso.canvaslms.com/api/lti/security/jwks` if hosted by Canvas) - Use a preset - - Add a single field called `preset` and populate it with one of the following supported preset options: `Production Canvas`, `Test Canvas`, `Beta Canvas`, `Devhub` + - Add a single field called `preset` and populate it with one of the following supported preset options: `Production Canvas`, `Test Canvas`, `Beta Canvas` - For example, your `platform` field may look like: ```yaml platform: @@ -154,7 +154,7 @@ UDOIT is built to support more than one LMS instance. There are two supported me - Specify the following field - `generate`: If this field is set to `true`, a new signing keyset will be generated without exception. If it is `false`, the institution's keyset will be set to the keyset in the database with the smallest ID. If no keyset exists, a new one will be created. Use `generate: false` for every institution if you do not want different signing key sets for every institution - `existing_id`: The database ID of the keyset that you want to reuse -4. Run the following command in the UDOIT directory to populate the databse with your institution data +1. Run the following command in the UDOIT directory to populate the databse with your institution data ```bash make create-registration FILE="institution.secret.yaml" ``` diff --git a/src/Command/CreateRegistrationCommand.php b/src/Command/CreateRegistrationCommand.php index 5a0a689cc..dfa6ec2c6 100644 --- a/src/Command/CreateRegistrationCommand.php +++ b/src/Command/CreateRegistrationCommand.php @@ -424,14 +424,6 @@ private function getPlatformPresets(): array 'serviceLoginEndpoint' => 'https://sso.beta.canvaslms.com/login/oauth2/auth', 'jwkEndpoint' => 'https://sso.beta.canvaslms.com/api/lti/security/jwks', ], - [ - 'name' => 'Devhub', - 'issuer' => 'https://canvas.instructure.com', - 'loginAuthEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/authorize_redirect', - 'serviceAuthEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/token', - 'serviceLoginEndpoint' => 'https://devhub.cdl.ucf.edu/login/oauth2/auth', - 'jwkEndpoint' => 'https://devhub.cdl.ucf.edu/api/lti/security/jwks', - ], ]; } @@ -496,29 +488,6 @@ private function generateAndPersistKeyset(): SigningKeySet return $signingKeySet; } - - private function getIss(SymfonyStyle $io) - { - $canvasIss = 'https://canvas.instructure.com'; - $devhubIss = 'https://devhub.cdl.ucf.edu'; - $customIss = 'Custom'; - - $issuerOptions = [ - $canvasIss, - $devhubIss, - $customIss, - ]; - - $iss = $io->choice('What is the issuer?', $issuerOptions); - - if ($iss === $customIss) - { - $iss = $io->ask('Enter custom issuer'); - } - - return $iss; - } - private function getLtiClientId(SymfonyStyle $io) { return $io->ask('Enter your LTI client ID'); From ed4339ea0914c1ea4cd5d9b7b819d42aa5cfb798 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Thu, 4 Jun 2026 15:00:58 -0400 Subject: [PATCH 26/28] Remove logger --- src/Controller/LtiController.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Controller/LtiController.php b/src/Controller/LtiController.php index 7707bd887..2cdc12cff 100644 --- a/src/Controller/LtiController.php +++ b/src/Controller/LtiController.php @@ -11,7 +11,6 @@ use Doctrine\Persistence\ManagerRegistry; use Firebase\JWT\JWK; use Firebase\JWT\JWT; -use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpClient\HttpClient; use Symfony\Component\HttpFoundation\Cookie; @@ -40,7 +39,6 @@ public function __construct( ManagerRegistry $doctrine, SessionService $sessionService, RegistrationRepository $registrationRepository, - private LoggerInterface $logger ) { $this->doctrine = $doctrine; $this->sessionService = $sessionService; @@ -231,8 +229,6 @@ public function ltiAuthorizeCheck( // Remove old sessions $sessionService->removeExpiredSessions(); - $this->logger->info(json_encode($this->session)); - $authCookie = Cookie::create('AUTH_TOKEN') ->withValue($this->session->getUuid()) ->withExpires(0) From 9185fef00d64ddd8abfde6c49efe7396866daec1 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Wed, 17 Jun 2026 09:42:45 -0400 Subject: [PATCH 27/28] Fix incorrect variable reference --- src/Controller/AuthController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controller/AuthController.php b/src/Controller/AuthController.php index cb25351a8..9c7b3868b 100644 --- a/src/Controller/AuthController.php +++ b/src/Controller/AuthController.php @@ -124,7 +124,7 @@ public function encryptDeveloperKey( $instId = $request->query->get('id'); $registration = $registrationRepository->getByInstitutionId($instId); - $registrationEncryptionService->encryptKey($registration); + $this->registrationEncryptionService->encryptKey($registration); $this->doctrine->getManager()->flush(); @@ -143,7 +143,7 @@ protected function requestApiKeyFromLms(): mixed $userAgent = 'UDOIT/' . !empty($_ENV['VERSION_NUMBER']) ? $_ENV['VERSION_NUMBER'] : '4.0.0'; if (empty($clientSecret)) { - $registrationEncryptionService->encryptKey($registration); + $this->registrationEncryptionService->encryptKey($registration); $this->doctrine->getManager()->flush(); $clientSecret = $this->registrationEncryptionService->getClientSecret($registration); } From 6de192deec29546e7599a568cc293fd74d47c1f5 Mon Sep 17 00:00:00 2001 From: CJ Young Date: Wed, 17 Jun 2026 09:48:40 -0400 Subject: [PATCH 28/28] Separate GET and POST params in LTI launch login --- src/Controller/LtiController.php | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/Controller/LtiController.php b/src/Controller/LtiController.php index 2cdc12cff..c203bb233 100644 --- a/src/Controller/LtiController.php +++ b/src/Controller/LtiController.php @@ -97,11 +97,10 @@ public function ltiAuthorize( $this->saveRequestToSession(); - $postParams = $request->request->all(); - $getParams = $request->query->all(); - $allParams = array_merge($postParams, $getParams); - $iss = $allParams['iss']; - $clientId = $allParams['client_id']; + $params = $request->isMethod('POST') ? $request->request->all() : $request->query->all(); + + $iss = $params['iss']; + $clientId = $params['client_id']; return $this->redirect($this->getLtiAuthResponseUrl($iss, $clientId)); }