Search by

offset-smoker / edu-standards

offset-smoker

Education interoperability standards for PHP: Open Badges 3.0 issuing and verification, OneRoster CSV validation and LTI 1.3 launch validation.

v0.1.0 2026-10-15 14:17 UTC

This package is auto-updated.

Last update: 2026-10-27 00:44:04 UTC


README

Education interoperability standards for PHP, written from the published specifications:

Module Standard Status
OpenBadges Open Badges 3.0: issue and verify credentials as VC-JWTs, revoke them with Bitstring Status Lists Implemented
OneRoster OneRoster 1.1 and 1.2 CSV packages: streaming validation with file, row and column for every issue, and typed import Implemented
Lti LTI 1.3 tool side: OIDC login, launch validation, Deep Linking 2.0 responses; groundwork for Assignment and Grade Services and Names and Role Provisioning Implemented (overview)

The core is plain PHP with no framework dependency. A small optional service provider wires it into Laravel.

Validator results, not certification. On 14 October 2026, RS256 credentials issued by this package were run through 1EdTech's public Open Badges 3.0 validator (OB30Inspector) and it reported no errors; its only warning was that the fictional issuer homepage, example.org, is unreachable. ES256 credentials gave the same result. EdDSA is also supported, but that validator does not accept it, so prefer RS256, the algorithm Open Badges 3.0 requires every implementation to support. These are the results of running a public validator; the package has not been certified by 1EdTech for any of these standards. Report ids and details are in decision record 0001.

Requirements

  • PHP 8.3 or later (CI runs 8.3 and 8.4, each also with the lowest dependency versions composer.json allows)
  • Extensions: json, openssl, sodium, zlib (all bundled with standard PHP builds)

Installation

composer require offset-smoker/edu-standards
vendor/bin/fetch-spec-files --runtime-only

The second command downloads the one 1EdTech file the library reads at runtime, the Open Badges 3.0 AchievementCredential JSON Schema. It is 1EdTech's, so this package does not redistribute it (see Specifications and third-party material). The script fetches it from its official URL, https://purl.imsglobal.org/spec/ob/v3p0/schema/json/ob_v3p0_achievementcredential_schema.json, checks it against the SHA-256 pinned in resources/spec-files.json, refuses it on a mismatch, and does nothing when a verified copy is already there. The verifier never fetches anything itself.

By default the schema goes into the package's own var/spec directory, which Composer replaces whenever it reinstalls the package. In production, keep it outside vendor/:

vendor/bin/fetch-spec-files --runtime-only --dir=storage/app/edu-standards

and pass its path, storage/app/edu-standards/openbadges/ob_v3p0_achievementcredential_schema.json, as new AchievementCredentialSchema(schemaFile: $path) to the CredentialVerifier (schema: argument), or set EDU_STANDARDS_OB_SCHEMA_PATH in Laravel. Running the script in your deployment, or in a Composer post-install-cmd script, keeps the file in place.

If the schema file is missing or is not the pinned version, verification fails closed: every credential fails the schema check with a message that names the path and says to run composer fetch-spec (vendor/bin/fetch-spec-files in an application), and AchievementCredentialSchema::validate() throws SpecFileUnavailable. Issuing credentials, OneRoster and LTI do not need the file.

Open Badges 3.0

1. Create a signing key and publish its public half

use OffsetSmoker\EduStandards\Jose\Algorithm;
use OffsetSmoker\EduStandards\Jose\Jwks;
use OffsetSmoker\EduStandards\Jose\Keys;

// Once, at setup. RS256 (RSA 2048) is the default; Algorithm::ES256 and
// Algorithm::EdDSA are available. Store the private JWK in your secret manager.
$key = Keys::generate('https://badges.example.org/issuers/1/keys/1');
$privateJwk = json_encode($key->exportPrivateJwk());

// Serve this at the URL in the kid so verifiers can find it.
$publicJwks = (new Jwks($key->verificationKey()))->toJson();

// Later, load it back. PEM works too: RsaSigningKey::fromPem($kid, $pem).
$key = Keys::signingKeyFromJwk(json_decode($privateJwk, true));
Key Algorithm JWK
RsaSigningKey / RsaVerificationKey RS256 (2048 bits or more, public exponent 65537 or another odd value below 2^256) kty RSA
EcSigningKey / EcVerificationKey ES256 (raw 64-byte R || S signatures) kty EC, crv P-256
Ed25519SigningKey / Ed25519VerificationKey EdDSA kty OKP, crv Ed25519

The algorithm belongs to the key. A JWK's alg, when present, must name it; a token's alg header must match it exactly, and a key is never used with another algorithm.

2. Describe and issue a credential

use OffsetSmoker\EduStandards\OpenBadges\AchievementCredentialBuilder;
use OffsetSmoker\EduStandards\OpenBadges\CredentialIssuer;
use OffsetSmoker\EduStandards\OpenBadges\Model\Achievement;
use OffsetSmoker\EduStandards\OpenBadges\Model\AchievementSubject;
use OffsetSmoker\EduStandards\OpenBadges\Model\AchievementType;
use OffsetSmoker\EduStandards\OpenBadges\Model\Criteria;
use OffsetSmoker\EduStandards\OpenBadges\Model\Profile;
use OffsetSmoker\EduStandards\OpenBadges\Status\BitstringStatusListEntry;

$achievement = new Achievement(
    id: 'https://badges.example.org/achievements/reading-1',
    name: 'Independent Reader',
    description: 'Read and reflected on ten books in one term.',
    criteria: Criteria::narrative('Submit ten reading reflections approved by a teacher.'),
    achievementType: AchievementType::Badge,
);

$credential = AchievementCredentialBuilder::create()
    ->id('urn:uuid:' . $uuid)
    ->issuer(new Profile('https://badges.example.org/issuers/1', name: 'Example Badge Issuer'))
    ->subject(new AchievementSubject($achievement, id: 'did:example:learner-1'))
    ->validFrom(new DateTimeImmutable())
    ->status(new BitstringStatusListEntry('https://badges.example.org/status/1', $randomIndex))
    ->build();

$jwt = (new CredentialIssuer($key))->issue($credential);   // a compact JWS string

The header names the key by kid. If your verifiers cannot reach the kid URL (the 1EdTech validator, a wallet offline), embed the public key instead: new CredentialIssuer($key, KeyReference::EmbeddedJwk).

Every value object validates itself when constructed and throws InvalidCredential naming the property and the rule. Messages never repeat the rejected value, because credential data is usually about a learner.

To identify a recipient by email without publishing the address, add a salted hash (OB 3.0 section 9.3). A credential issued as a VC-JWT still needs a credentialSubject.id, because it becomes the sub claim; issue() throws CannotIssueCredential without one, so give the subject a URI of your own, such as a urn:uuid:, alongside the hash:

use OffsetSmoker\EduStandards\OpenBadges\Model\AchievementSubject;
use OffsetSmoker\EduStandards\OpenBadges\Model\IdentifierType;
use OffsetSmoker\EduStandards\OpenBadges\Model\IdentityObject;

$identity = IdentityObject::hashed($email, IdentifierType::EmailAddress, bin2hex(random_bytes(16)));
$identity->matches($email); // true

$subject = new AchievementSubject($achievement, id: 'urn:uuid:' . $recipientUuid, identifiers: [$identity]);

3. Verify a credential

use OffsetSmoker\EduStandards\Jose\Jwks;
use OffsetSmoker\EduStandards\OpenBadges\CredentialVerifier;
use OffsetSmoker\EduStandards\OpenBadges\TrustedIssuers;

$verifier = new CredentialVerifier(
    trustedIssuers: (new TrustedIssuers())
        ->with('https://badges.example.org/issuers/1', Jwks::fromJson($publishedJwks)),
    clock: $psr20Clock,              // e.g. new \OffsetSmoker\EduStandards\Support\SystemClock()
    statusLists: $statusListResolver, // your StatusListResolver, see below
    leewaySeconds: 60,
    maxStatusListAgeSeconds: null,    // the default; see "Revoke credentials with a status list"
);

$result = $verifier->verify($jwt);

if ($result->isValid()) {
    $credential = $result->credential; // the verified payload
}

foreach ($result->failures() as $failure) {
    $logger->info($failure->check->value . ': ' . $failure->reason);
}

verify() does not throw on bad input. It returns one CheckResult per check, in this order, and the credential is valid only if all of them pass:

Check Passes when
structure three base64url segments; JSON object header and payload; within size and depth limits
algorithm alg is RS256, ES256 or EdDSA and equals the algorithm of the trusted key; none, HS*, PS*, RS384+, ES384+ and anything else are refused
header_parameters only alg, kid, jwk, typ; typ is JWT if present; an embedded jwk is public; no jku, x5u, x5c, x5t
trusted_issuer iss names an issuer in your trust list
key_id kid (or the thumbprint of an embedded jwk) names a key you trust for that issuer
signature the signature verifies with that key, using the key's own algorithm
issuer_claim iss equals issuer.id
subject_claim sub equals credentialSubject.id
credential_id_claim jti equals id
not_before nbf equals validFrom, and that time has come (with leeway)
expiry exp equals validUntil when either is present, and it has not passed (with leeway)
schema the payload conforms to the 1EdTech AchievementCredential JSON Schema (the pinned copy fetched at install; see Installation)
status every credentialStatus entry resolves, authenticates and shows a clear bit

If the signature does not verify, the later checks are reported as not evaluated and no status list is fetched.

4. Revoke credentials with a status list

use OffsetSmoker\EduStandards\OpenBadges\Status\BitstringStatusList;
use OffsetSmoker\EduStandards\OpenBadges\Status\StatusListCredential;

$list = BitstringStatusList::create();           // 131,072 entries, all clear
$list = $list->set($revokedIndex);               // immutable: returns a new list
$stored = $list->encode();                       // "u" + base64url(gzip(bits)); keep this

$statusListJwt = (new CredentialIssuer($key))->issueStatusList(new StatusListCredential(
    id: 'https://badges.example.org/status/1',   // the URL you serve it from
    issuerId: 'https://badges.example.org/issuers/1',
    statusList: BitstringStatusList::decode($stored),
    validFrom: new DateTimeImmutable(),
));

Assign each credential a random, unused index rather than a sequential one; the specification asks for this so an index does not reveal when a credential was issued.

The package never makes HTTP requests. To check status, implement the one-method StatusListResolver with the HTTP client and cache you already use, and throw StatusListUnavailable when the list cannot be fetched (the verifier then fails the status check rather than assuming "not revoked"):

use Psr\Http\Client\ClientInterface;
use Psr\Http\Message\RequestFactoryInterface;
use OffsetSmoker\EduStandards\OpenBadges\Exception\StatusListUnavailable;
use OffsetSmoker\EduStandards\OpenBadges\Status\StatusListResolver;

final readonly class HttpStatusListResolver implements StatusListResolver
{
    public function __construct(
        private ClientInterface $client,
        private RequestFactoryInterface $requests,
    ) {}

    public function resolve(string $statusListCredentialUrl): string
    {
        try {
            $response = $this->client->sendRequest($this->requests->createRequest('GET', $statusListCredentialUrl));
        } catch (\Psr\Http\Client\ClientExceptionInterface $exception) {
            throw StatusListUnavailable::forUrl($statusListCredentialUrl, $exception);
        }

        if ($response->getStatusCode() !== 200) {
            throw StatusListUnavailable::forUrl($statusListCredentialUrl);
        }

        return trim((string) $response->getBody());
    }
}

The verifier authenticates whatever the resolver returns: the list must be signed by the credential's own issuer, carry the requested URL as its id (or jti), and be within its own validity period. If another issuer publishes your status lists, trust its keys and delegate to it explicitly: $trustedIssuers->withStatusListIssuer($credentialIssuerId, $statusListIssuerId) (status_list_issuers in the Laravel config). Without that binding, any issuer you trust could publish a clear list at another issuer's status URL and un-revoke its credentials.

Freshness follows Bitstring Status List v1.0: a list is current from its validFrom until its validUntil (with the leeway), and its ttl (milliseconds) tells caches when to refresh. To cache, wrap your resolver in CachingStatusListResolver, which keeps each list for its ttl (five minutes when it has none, at most an hour) and never past its validUntil: new CachingStatusListResolver(new HttpStatusListResolver(...), $psr16Cache). When you issue lists, set validUntil and ttlMilliseconds on StatusListCredential to say how long a copy stays good.

A genuinely signed copy from before a revocation still verifies within its validity period, so replaying it would un-revoke the credential. To narrow that window beyond what the issuer's validUntil allows, set maxStatusListAgeSeconds: lists issued (by iat, else validFrom) longer ago are then refused. It is off by default, because the specification has no such rule and an issuer that republishes weekly would always be refused at a one-day limit. A list that claims to be issued in the future (beyond the leeway) is refused whatever the setting.

Laravel

The service provider is auto-discovered. Publish the configuration with

php artisan vendor:publish --tag=edu-standards-config

then set EDU_STANDARDS_OB_SIGNING_KEY to the private JWK (as JSON) and list your trusted issuers in config/edu-standards.php. Set EDU_STANDARDS_OB_SCHEMA_PATH to the JSON Schema you fetched outside vendor/ (see Installation); left unset, the verifier reads the package's own var/spec copy. CredentialIssuer, CredentialVerifier and TrustedIssuers are bound as singletons. A misconfiguration raises InvalidConfiguration with a message naming the setting. Laravel is a development dependency only; nothing outside src/Laravel imports it.

End-to-end example

php examples/issue-and-verify.php

It generates an RS256 key, issues a credential, verifies it, revokes it and verifies it again, printing every check.

OneRoster 1.1 and 1.2 CSV

Validate a package and get every problem at once, each with its file, row (the header is row 1) and column, a stable code and a plain-English message. Then load a valid package as typed records and map them into your own tables. The module never touches a database.

use OffsetSmoker\EduStandards\OneRoster\OneRosterImporter;
use OffsetSmoker\EduStandards\OneRoster\Package\DirectorySource;
use OffsetSmoker\EduStandards\OneRoster\Package\ZipSource;
use OffsetSmoker\EduStandards\OneRoster\Vocabulary\Role;

$importer = new OneRosterImporter();               // optional: ImportLimits, a PSR-20 clock
$source = new ZipSource($uploadedZip);             // or new DirectorySource($extractedDirectory)

$report = $importer->validate($source);            // never throws for bad content

if (! $report->isValid()) {
    foreach ($report->errors() as $issue) {
        // users.csv row 14, enabledUser [invalid_boolean]: The value must be "true" or "false", in lower case.
        $log->warning("{$issue->file} row {$issue->row}, {$issue->column} [{$issue->code->value}]: {$issue->message}");
    }

    $store->put('oneroster-report.json', $report->toJson());   // counts per code and per file included
    return;
}

$roster = $importer->import($source);              // validates again, then loads; throws InvalidPackage on errors

foreach ($roster->classes as $class) {
    $teachers = $roster->usersInClass($class->sourcedId, Role::Teacher);
    $students = $roster->usersInClass($class->sourcedId, Role::Student);
}

$roster->users['usr-student-001']->grades;         // ['03']
$roster->orgIdsOfUser('usr-teacher-004');          // 1.1 users.orgSourcedIds or 1.2 roles.csv
$roster->deletions;                                // delta rows marked tobedeleted

What is checked:

Area Checks
Package manifest.csv present and well formed; oneroster.version is 1.1 or 1.2; every data file of that version declared absent, bulk or delta; files present but declared absent, declared but missing, not declared, misnamed, in a subdirectory, or from the other version
CSV RFC 4180 quoting; UTF-8 (a byte order mark is skipped and noted; invalid bytes are reported with row and column); no carriage returns inside fields; field count per row; records end with CRLF or LF (a lone CR is refused); limits on file size, total package size (counted as bytes are read, so a ZIP cannot understate it), rows, field length and columns
Headers every defined column present, in the binding's order, case-sensitive, unique; extension columns named metadata.* and placed last
Values required values; enumerations for each version, including ext: terms where 1.2 allows them; true/false; dates, timestamps with milliseconds, years, numbers; sourcedId length and (1.2) characters; lists and {type:id} items; CEDS grade codes
Bulk and delta bulk rows leave status and dateLastModified empty; delta rows fill both; tobedeleted rows may omit other fields
Records sourcedIds unique per file; every reference resolves in a bulk package (class to course, school and terms; enrollment to class, school and user; user to orgs and agents; role to user, org and profile; and the rest); referenced orgs and sessions have the required type; no cycles in org or session parents; one primary role per user and org; date ranges and score ranges in order

Errors make a package invalid; warnings (an empty date range, a birth date in the future, a deprecated 1.1 inactive status, a non-CEDS grade in 1.2, an enrollment marked primary whose role is not teacher) do not. Messages quote sourcedIds, header names and file names, but never other cell values, and a first row that is really data (a file exported without its header) is reported without repeating it. Every value taken from the package is escaped to printable ASCII and truncated, and a report lists at most 100 issues of one kind and 1,000 in all, then says how many more there were (ImportLimits), so a report is safe to log and stays small.

validate() reports a file it cannot read, such as a damaged ZIP entry, as an unreadable_file error and does not throw, even under a framework error handler that turns PHP warnings into exceptions: every read that can warn runs inside a handler scoped to that one call. Constructing a ZipSource throws UnreadablePackage for a file that is not a ZIP archive.

A ZIP package needs ext-zip, which is optional: without it, extract the archive and use DirectorySource, or implement PackageSource for your own storage.

Large packages

Files are streamed, so validating needs memory for the sourcedIds (packed at about their own length plus a few bytes each), not for the rows. import() returns a RosterSnapshot that holds every record with its indexes, which suits packages that fit comfortably in memory. For a large district, stream() validates the same way and then reads the typed records one file at a time, as you iterate:

$stream = $importer->stream($source);              // validates; throws InvalidPackage on errors

foreach ($stream->files() as $file) {              // orgs first, then the files that refer to them
    $db->beginTransaction();

    foreach ($stream->records($file) as $record) { // an Org, a User, an Enrollment ... or a Deletion
        $mapper->save($record);
    }

    $db->commit();   // records() throws InvalidRecord at the end if the file changed since validation
}

Each file is read again as you iterate and compared with the bytes that validated only when it ends, so commit a file's records only after its loop finishes.

Measured on an Apple M4 Pro (24 GB) with PHP 8.4.25 CLI (no opcache), on a generated OneRoster 1.2 bulk package read from a directory: 200,000 users (190,000 students, 10,000 teachers), 50 schools, 2,000 courses, 38,000 classes, 200,000 roles and 1,178,000 enrollments, 112 MB of CSV. Peak memory is memory_get_peak_usage(true).

Call Time Peak memory
validate() 20.3 s 48 MB
stream(), reading every record 32.6 s 48 MB
import() (RosterSnapshot) 35.6 s 1,099 MB

Before the second review validate() peaked at 216 MB on this package, over PHP's default memory_limit of 128M; stream() did not exist. At four times the size (800,000 users and 4,778,000 enrollments, near the default limit of 5,000,000 rows per file) validate() takes 80.4 s and peaks at 180 MB, so packages that large need a higher memory_limit.

Try it on the bundled synthetic packages:

php examples/oneroster-validate.php
php examples/oneroster-validate.php tests/Fixtures/oneroster/v1p2-broken

Specifications: OneRoster v1.1 CSV Tables (1EdTech Final Release 1.1.1, document 2, August 2023) and OneRoster 1.2 CSV Binding (1EdTech Final Release 1.2.1, document 5, August 2023). The rules, the version differences and the ambiguities found in the specifications are in decision record 0002.

LTI 1.3

The tool side of an LTI 1.3 launch, written from the 1EdTech Security Framework 1.0, LTI Core 1.3 and LTI Deep Linking 2.0 (versions and sections in decision record 0003). The package never makes a request of its own: you pass in a PSR-18 HTTP client and PSR-17 request factory (to fetch platform key sets), a PSR-16 cache and a PSR-20 clock.

1. Register platforms and describe the tool

use OffsetSmoker\EduStandards\Lti\Registration\InMemoryPlatformRegistry;
use OffsetSmoker\EduStandards\Lti\Registration\PlatformRegistration;
use OffsetSmoker\EduStandards\Lti\Registration\ToolConfiguration;

// One per (issuer, client_id) the platform gave you. Implement
// PlatformRegistry over your database to hold many.
$platforms = new InMemoryPlatformRegistry(new PlatformRegistration(
    issuer: 'https://lms.example.edu',
    clientId: '7a1f3c52-tool-client',
    authenticationEndpoint: 'https://lms.example.edu/lti/authorize',
    deploymentIds: ['deployment-0f3a'],
    keySetUrl: 'https://lms.example.edu/lti/jwks',          // or keySet: Jwks::fromJson(...)
    accessTokenEndpoint: 'https://lms.example.edu/lti/token',
));

$tool = new ToolConfiguration(
    redirectUris: ['https://tool.example.com/lti/launch-response'],   // matched exactly
    launchUrls: ['https://tool.example.com/lti/'],                     // exact URLs, or prefixes ending in "/"
);

2. Answer the platform's login

use OffsetSmoker\EduStandards\Exception\EduStandardsException;
use OffsetSmoker\EduStandards\Lti\Exception\StoreUnavailable;
use OffsetSmoker\EduStandards\Lti\Login\CacheLaunchStateStore;
use OffsetSmoker\EduStandards\Lti\Login\LoginInitiator;

$states = new CacheLaunchStateStore($psr16Cache);
$login = new LoginInitiator($platforms, $tool, $states, $psr20Clock);

try {
    $request = $login->initiate($_SERVER['REQUEST_METHOD'] === 'POST' ? $_POST : $_GET);
} catch (StoreUnavailable $exception) {
    http_response_code(503);   // the tool's own store failed; alert on it
    exit;
} catch (EduStandardsException $exception) {
    http_response_code(400);   // never redirect anywhere on a refused login
    exit;
}

// Bind the state to this browser, then send it to the platform. One cookie
// per state, named after it, so launches in two tabs or two iframes at once
// do not overwrite each other's binding.
setcookie('lti_state_' . $request->state, '1', ['secure' => true, 'httponly' => true, 'samesite' => 'None', 'expires' => time() + 300]);
header('Location: ' . $request->url());

initiate() throws InvalidLoginRequest unless the issuer, client and deployment are registered and target_link_uri is one of the tool's launch URLs. The state and nonce are 256-bit values from random_bytes(), saved with the login for five minutes.

Pick the state store by how the tool runs:

Store Use it when Single use under concurrent requests
PdoLaunchStateStore more than one PHP worker serves launches (the usual case) yes: the database deletes a state once and inserts a nonce once, so exactly one of two racing requests wins
CacheLaunchStateStore one worker, or a cache you cannot avoid no: PSR-16 has no atomic read-and-delete, so two requests carrying the same state at the same moment can both succeed
InMemoryLaunchStateStore tests and single-process tools within one process only

PdoLaunchStateStore needs only PDO and one table, and runs on SQLite (3.24 or later), PostgreSQL and MySQL 8; PdoLaunchStateStore::schema() returns the CREATE TABLE and CREATE INDEX statements for a migration:

use OffsetSmoker\EduStandards\Lti\Login\PdoLaunchStateStore;

foreach (PdoLaunchStateStore::schema() as $statement) {   // once, in a migration
    $pdo->exec($statement);
}

$states = new PdoLaunchStateStore($pdo);                    // instead of CacheLaunchStateStore

Its tests race four processes for one state and for one nonce, on SQLite in every run and on PostgreSQL 17 and MySQL 8.0 when their DSNs are set (EDU_STANDARDS_TEST_PGSQL, EDU_STANDARDS_TEST_MYSQL); exactly one process wins each round. CI runs them against PostgreSQL 17 and MySQL 8.0 service containers, and fails if any of them is skipped. Expired rows are deleted as logins are saved; purgeExpired() does it on demand.

3. Validate the launch

use OffsetSmoker\EduStandards\Lti\KeySet\PlatformKeySets;
use OffsetSmoker\EduStandards\Lti\Launch\LaunchValidator;
use OffsetSmoker\EduStandards\Lti\MessageType;
use OffsetSmoker\EduStandards\Lti\Roles\ContextRole;

$validator = new LaunchValidator(
    platforms: $platforms,
    states: $states,
    clock: $psr20Clock,
    keySets: new PlatformKeySets($psr18Client, $psr17RequestFactory, $psr16Cache),
);

// The state is bound to this browser if this browser holds its cookie.
$state = is_string($_POST['state'] ?? null) ? $_POST['state'] : '';
$browserState = isset($_COOKIE['lti_state_' . $state]) ? $state : null;
setcookie('lti_state_' . $state, '', ['secure' => true, 'httponly' => true, 'samesite' => 'None', 'expires' => 1]);

$result = $validator->validate($_POST, $browserState);

if (! $result->isValid()) {
    $logger->warning('LTI launch refused', $result->toArray());
    http_response_code(401);
    exit;
}

$launch = $result->launch;
$userKey = [$launch->issuer, $launch->user?->sub];    // identify users by issuer and sub only
$isInstructor = $launch->roles->hasContextRole(ContextRole::Instructor);

if ($launch->messageType === MessageType::DeepLinkingRequest) {
    // show the content picker; see step 4
}

validate() never throws for bad input. It returns one result per check, in this order, and a typed Launch only when all of them pass. A check that could not be decided because the tool's own storage failed (the state store, the nonce store or the key set cache, including a PSR-16 adapter whose set() quietly returns false) has the outcome unavailable rather than failed, so a broken cache is never reported as a replay, an expired login or a rate limit:

Check Passes when
state the state was issued by this tool, matches the one bound to this browser, has not expired, and is used now for the first and only time
structure id_token is a compact JWS with JSON object header and payload, within the size and depth limits
algorithm alg is exactly RS256; none, HS*, RS384, PS256, ES256 and the rest are refused
header_parameters the header names its key by kid; jwk, jku, x5u, x5c, x5t and crit are refused
issuer iss is exactly the registered issuer the login was started with
key_id kid names an RSA key in that platform's registered key set (refetched once if unknown)
signature the RS256 signature verifies with that key
audience aud contains the client_id and no audience the registration does not trust; with several audiences azp is present; azp, when present, is the client_id
expiry exp is a NumericDate that has not passed (with leeway)
issued_at iat is a NumericDate, not in the future, at most five minutes old by default (with leeway)
not_before nbf, when present, has been reached (with leeway)
nonce nonce is the one generated for this login and has not been used before
deployment_id the deployment is registered for the platform and client, and repeats the login's lti_deployment_id
message_type LtiResourceLinkRequest or LtiDeepLinkingRequest
version 1.3.0
target_link_uri equals the login's target_link_uri (required for resource link launches)
subject sub identifies the user; or the launch is anonymous (a deep linking request, or a resource link launch you accept with acceptAnonymousLaunches: true) and carries no identity claims at all
roles an array of role URIs (required for resource link launches) with at least one LTI role when not empty; role_scope_mentor only with the Mentor role
resource_link a resource link launch has resource_link.id
deep_linking_settings a deep linking request has deep_link_return_url (https), accept_types and accept_presentation_document_targets
optional_claims in context, tool_platform, launch_presentation, custom, lis, role_scope_mentor and the AGS and NRPS endpoint claims, the properties the tool reads have the right type (null reads as absent), service URLs are https and custom values are strings; everything else in them is ignored

The Launch carries the message type, issuer, client and deployment, the User (null when anonymous), parsed Roles, Context, ResourceLink, LaunchPresentation, PlatformInstance, custom properties, Lis, the deep linking settings and the AGS and NRPS endpoints. Claims it does not interpret are in $launch->extensionClaims().

Roles are recognized in every form LTI 1.3 allows: the full URIs, context sub-roles (membership/Instructor#TeachingAssistant), simple context role names, the LTI 1.1 URNs and the deprecated person# URIs. Role questions are strict: hasContextRole(ContextRole::Instructor) ignores teaching assistants unless you pass includeSubRoles: true. Unrecognized roles are kept in $launch->roles->unrecognized.

Options on LaunchValidator: leewaySeconds (60, at most 300), maxTokenAgeSeconds (300), acceptAnonymousLaunches (false), requireBrowserBinding (true; see below) and limits.

In an LMS iframe the state cookie is a third-party cookie, and Safari blocks those whatever SameSite=None says, so a launch there is refused at the state check. Prefer opening the tool in a new window, where the cookie is first-party; also marking the cookie Partitioned (CHIPS) helps in browsers that support it. requireBrowserBinding: false makes such launches work but gives up the protection against login CSRF: an attacker can make a victim's browser complete the attacker's launch. 1EdTech's postMessage platform storage is not implemented. Decision record 0003 has the details and the reasons. PlatformKeySets takes maxCacheAgeSeconds (3600), refetchIntervalSeconds (60), maxResponseBytes (65536), maxStaleSeconds (86400; see below) and a PSR-20 clock.

When a cached key set has expired and the platform cannot be reached (or the refetch interval holds back another try), PlatformKeySets keeps using the expired copy for up to maxStaleSeconds, as HTTP's stale-if-error does, so a platform's key set endpoint being down for a minute does not stop every launch. Such a launch is valid, and $result->notices() (and the key_id check's notice) says the key came from a stale copy, when it was fetched, and why it was not refreshed: log or alert on it. A copy is only replaced by a successful fetch, so a key the platform removes stops working at its first successful refetch; but while the platform is unreachable, a key it removed can be accepted for up to maxStaleSeconds after the copy expired. Set it to 0 to refuse instead.

4. Answer a deep linking request

use OffsetSmoker\EduStandards\Lti\DeepLinking\ContentItem\LineItem;
use OffsetSmoker\EduStandards\Lti\DeepLinking\ContentItem\LtiResourceLink;
use OffsetSmoker\EduStandards\Lti\DeepLinking\DeepLinkingResponder;

$responder = new DeepLinkingResponder($toolKey, $psr20Clock);   // an RS256 SigningKey

$response = $responder->respond($launch, [
    new LtiResourceLink(
        url: 'https://tool.example.com/lti/resources/linear-equations',
        title: 'Linear equations practice',
        lineItem: new LineItem(scoreMaximum: 20, label: 'Linear equations practice'),
    ),
]);

echo $response->autoSubmitForm($cspNonce);   // posts JWT=… to the platform's deep_link_return_url

The items are checked against the request before anything is signed: each type must be in accept_types, several items need accept_multiple, a file must state a media type in accept_media_types when the platform lists them, and a window, iframe or embed presentation must be in accept_presentation_document_targets. Link, File, Html and Image items are available too; every URL must be https. The form escapes every value.

5. Publish the tool's public key

use OffsetSmoker\EduStandards\Jose\Jwks;
use OffsetSmoker\EduStandards\Jose\Keys;

$toolKey = Keys::generate('tool-key-2026-10');                // RS256, once; keep exportPrivateJwk() secret
$publicJwks = (new Jwks($toolKey->verificationKey()))->toJson(); // serve this at the URL you register

Services (groundwork)

Services\AccessTokenRequest::create() builds the OAuth 2.0 client-credentials request with an RFC 7523 JWT assertion for the platform's token endpoint, and Services\Ags (LineItem, Score, Result) and Services\Nrps (MembershipContainer, Member) type and validate the service bodies. The HTTP clients that call the services are not written yet.

LTI security notes

  • Keys come only from the registration. A token names its key by kid; only the registered key set URL is ever fetched, at most once per minute, and a fetch or parse failure refuses the launch, unless an expired copy within maxStaleSeconds has the key, which the result reports as a notice.
  • The launch is bound to its login. The state is single-use and bound to the browser; the nonce, issuer, client, deployment and target_link_uri must all match the login they belong to.
  • No open redirects. target_link_uri must be one of the tool's launch URLs, prefixes match only on the same origin and never through dot segments, and the redirect URI is the tool's own.
  • Strict about what it relies on, tolerant of what it does not understand. Untrusted extra audiences, identity claims without sub, service URLs that are not https, custom values that are not strings and wrongly typed values of the properties the tool reads are refused, each with a reason. Claims, properties and values the tool does not understand are ignored, as LTI Core sections 3.5 and 4.3 require: extra properties inside the claims it types, JSON nulls for optional values, lis values that are not strings, a document_target outside the vocabulary. The record lists every such choice.
  • The keys under tests/Fixtures/lti/keys are published test keys. Never trust them.

Not implemented in LTI

The platform side; algorithms other than RS256; HTTP clients for the Assignment and Grade Services and Names and Role Provisioning Services; Dynamic Registration; message types other than resource link and deep linking; postMessage platform storage; a Laravel binding for the LTI classes.

LTI example

php examples/lti-launch-and-deep-link.php

It runs a full login, deep linking request, Deep Linking response and resource link launch against the simulated platform from the test suite, printing every check.

Specifications implemented

Specification Version
Open Badges Specification 3.0 1EdTech Final Release, document version 1.4.5, 29 June 2026: data model (appendix B.1), JSON Web Token proof format (section 8.2), verification (section 9.1)
Verifiable Credentials Data Model v2.0 W3C Recommendation, 15 May 2025, the version OB 3.0 cites
Bitstring Status List v1.0 W3C Recommendation, 15 May 2025
AchievementCredential JSON Schema draft 2019-09, served with Last-Modified 23 October 2025; not redistributed, fetched and checked against the SHA-256 in resources/spec-files.json
RFC 7515 JWS, RFC 7517 JWK, RFC 7519 JWT, RFC 7518 JWA (RS256, ES256), RFC 7638 JWK thumbprints, RFC 8037 EdDSA in JOSE

The reasoning behind each choice, the ambiguities found in the specifications, and the 1EdTech validator results are in docs/decisions/0001-open-badges.md.

Specifications and third-party material

This library implements these published standards; the decision records give the exact versions and sections:

1EdTech material is not redistributed here. 1EdTech specifications, schemas and examples are copyright 1EdTech Consortium, Inc., and are used under the 1EdTech specification license, which permits implementing them but not redistributing them. This repository therefore contains none of them. The Open Badges JSON Schema the verifier needs, and the specification examples the tests use, are listed with their official URLs and SHA-256 hashes in resources/spec-files.json and downloaded by composer fetch-spec (vendor/bin/fetch-spec-files in an application) into var/spec/, which is not committed. The code follows the specifications but contains no text copied from them beyond identifiers the standards define (claim names, vocabulary terms, URIs) and occasional one-sentence quotations cited by section in the decision records.

Trademarks. Open Badges, OneRoster, LTI and Learning Tools Interoperability are trademarks of 1EdTech Consortium, Inc. This project is independent: it is not affiliated with, endorsed by or certified by 1EdTech. The validator results above come from running 1EdTech's public validator; they are not a 1EdTech certification or conformance claim.

W3C and IETF material. The tests include one example value from the W3C Bitstring Status List v1.0 Recommendation and test vectors from RFC 4648, RFC 7515, RFC 7638 and RFC 8037. They are reproduced under the W3C Software and Document License and the IETF Trust's licence for code components; the notices those licences require are in THIRD-PARTY-NOTICES.md.

Security notes

  • Keys come only from you. Each trusted key is bound to one issuer, and a status list only counts for credentials of the issuer that signed it (or of an issuer that delegated to it with withStatusListIssuer()). A token may name its key with kid or embed the public jwk (both allowed by OB 3.0 section 8.2.3), but an embedded key is only matched by RFC 7638 thumbprint against your trust list; it is never trusted on its own. jku, x5u, x5c and x5t are refused.
  • The key decides the algorithm. Each key is bound to one algorithm by its type. The header alg must match it, so alg: none, HMAC and cross-type confusion (an RSA key with an ES256 header, say) are refused before any signature is checked. RSA keys under 2048 bits are refused, and so are public exponents that are even, below 65537 or 2^256 and above (FIPS 186-5 appendix A.1.1), on both the signing and the verifying side; ES256 signatures must be exactly 64 bytes with non-zero halves before they are converted to DER for OpenSSL.
  • Bounded input. Tokens over 1 MiB, JSON nested deeper than 32 levels and status lists that expand past 4 MiB are refused before further work (configurable through Support\Limits). Base64url decoding is strict and canonical, JSON decoding throws, and large integers are never rounded.
  • Fail closed. A status list that cannot be fetched, authenticated or understood fails the status check, including when a resolver throws something other than StatusListUnavailable. Only http(s) status list URLs are handed to the resolver, and one credential can trigger at most eight status fetches.
  • Status lists must be current: within their own validFrom and validUntil, and, when you set maxStatusListAgeSeconds, no older than that (see above).
  • Private keys. Every signing key hides its secret from var_dump() and print_r(), refuses serialization and cloning, and marks secret parameters #[SensitiveParameter], including every function a private JWK passes through, so it never appears in stack-trace arguments. Ed25519SigningKey wipes its secret with sodium_memzero(); RSA and EC keys live in OpenSSL handles that destroy() releases but PHP cannot wipe. var_export() and (array) casts cannot be intercepted in PHP, so never pass a key to them.
  • Privacy. Verification reasons quote issuer and key identifiers but not learner identifiers. Status lists keep the 131,072-entry minimum the specification sets for herd privacy.
  • The key under tests/Fixtures/keys is a published test key. Never trust it.

Report vulnerabilities as described in SECURITY.md.

Not implemented

  • Data Integrity (eddsa-rdfc-2022) embedded proofs; this package secures credentials as VC-JWTs only.
  • RS384/RS512, PS256 and the other JWS algorithms.
  • The VCDM 1.1 vc-claim JWT form.
  • Fetching keys from kid URLs or resolving DIDs; supply keys yourself.
  • Endorsements, Result/ResultDescription, termsOfUse, refreshService, Address, Related, otherIdentifier.
  • Status purposes refresh and message, and multi-bit status entries.
  • Baked PNG/SVG badges, and the Open Badges REST API.
  • LTI: see its own list.

Dependencies

Package Why
ext-openssl RS256 and ES256 signing and verification, including LTI platform and tool keys
ext-sodium Ed25519 signing and verification, and wiping secrets
ext-zlib GZIP for Bitstring Status Lists
ext-json JSON encoding and decoding
opis/json-schema ^2.6 Validates against the 1EdTech schema, which is JSON Schema draft 2019-09. Opis supports drafts 2019-09 and 2020-12, is pure PHP, and is actively released (2.6.0, October 2025). justinrainbow/json-schema, the other common choice, has historically targeted the older drafts.
psr/clock Callers inject the clock, so validity checks are testable
psr/http-client, psr/http-factory, psr/http-message The LTI module fetches platform key sets through the caller's own HTTP client
psr/simple-cache The LTI module caches key sets, and can keep login state, in the caller's own cache

The PSR packages contain interfaces only.

Development

composer install
composer fetch-spec                        # the 1EdTech schema and examples, into var/spec (not committed)
vendor/bin/pint --test                     # code style
vendor/bin/phpstan clear-result-cache
vendor/bin/phpstan analyse                 # level 8, strict rules, Larastan, no baseline
vendor/bin/pest                            # unit, feature and architecture tests
php examples/issue-and-verify.php
php examples/oneroster-validate.php
php examples/lti-launch-and-deep-link.php

Without composer fetch-spec, the tests that need a 1EdTech file (the schema, or a specification example) are skipped, and each skip names the file and says how to get it; everything else runs. CI fetches the files first and sets EDU_STANDARDS_REQUIRE_SPEC_FILES=1, which turns any such skip into a failure.

See CONTRIBUTING.md.

How this was built

Brandon Wagner designed and directed the library, and it was written with Claude Code. The three modules were built in a single working session, each from the published specifications listed above; the decision records cite the section behind every rule. Every module passes the same gates: PHPStan at level 8 with strict rules, Larastan and no baseline; Pest, with architecture tests and adversarial tests (forged and tampered tokens, replayed launches, hostile CSV packages); and Pint.

Three reviews followed. Each was a separate Claude Code session (Claude Opus) working from the code and the specifications, without the builder's notes or conversation. None was a third-party audit, and no outside firm or person has reviewed the library.

The first, a security review, reported twelve findings: CPU and memory exhaustion and a personal-data leak in OneRoster validation, a symbolic-link escape from a package directory, un-revocation through a status list signed by another trusted issuer, log forging through package-supplied names, and six lower-severity gaps in status list freshness, Deep Linking checks, LTI state handling and the import's second read. Each was reproduced as a failing test, then fixed; the changelog lists them.

The second asked whether the library was ready for production, with reproductions for each of eleven findings. Three were serious: LTI launches from real platforms failed on claim content the specification says to ignore (a Canvas-style lis claim), a damaged ZIP entry made validate() throw under a framework error handler, and a 200,000-user OneRoster package needed 216 MB to validate and 1 GB to import. The rest: key sets refused for one unusable key or for one failed fetch, PSR-16 caches whose silent failures were reported as replays and rate limits, no state store that stays single-use under concurrent requests, a status list age limit the Bitstring Status List specification does not have, weak RSA exponents, five hand mutants (deliberately broken guards) that no test caught, and no CI job for the lowest allowed dependencies. It also found three claims in these documents untrue. Each finding was again turned into a failing test before it was fixed, the reviewer's reproduction scripts were re-run against the fix, and the documents now say what the code does; the changelog lists the changes. The re-run of all 34 hand mutants kills 33; the remaining one cannot be killed, because a status list reaches the age check only after its validFrom and iat have been read, so its age is never unknown there. Safari's handling of cookies inside LMS iframes was investigated and documented, not tested, since no browser was available.

The third found five more: on MySQL a NOT NULL violation in the state store was reported as a replayed nonce, the store kept the state and nonce in plain text although its documentation said it did not, CI never ran the store's PostgreSQL and MySQL tests, sourcedId pairs were never packed, and the race test broke on a deprecation printed by an old dependency. The Open Badges example also exited 0 when verification failed. Each was again reproduced as a failing test first.

License

MIT. See LICENSE.