offset-smoker / edu-standards
Education interoperability standards for PHP: Open Badges 3.0 issuing and verification, OneRoster CSV validation and LTI 1.3 launch validation.
Requires
- php: ^8.3
- ext-json: *
- ext-openssl: *
- ext-sodium: *
- ext-zlib: *
- opis/json-schema: ^2.6
- psr/clock: ^1.0
- psr/http-client: ^1.0
- psr/http-factory: ^1.1
- psr/http-message: ^2.0
- psr/simple-cache: ^3.0
Requires (Dev)
- larastan/larastan: ^3.13
- laravel/pint: ^1.32
- orchestra/testbench: ^11.3
- pestphp/pest: ^4.7
- phpstan/phpstan: ^2.1
- phpstan/phpstan-strict-rules: ^2.1
Suggests
- ext-pdo: For PdoLaunchStateStore, the LTI state store that stays single-use under concurrent requests (SQLite, PostgreSQL or MySQL).
- illuminate/support: Required for the optional Laravel service provider (tested with Laravel 13).
Provides
None
Conflicts
None
Replaces
None
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 withinmaxStaleSecondshas 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_urimust all match the login they belong to. - No open redirects.
target_link_urimust 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,lisvalues that are not strings, adocument_targetoutside the vocabulary. The record lists every such choice. - The keys under
tests/Fixtures/lti/keysare 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: Open Badges 3.0, OneRoster 1.1 CSV Tables, OneRoster 1.2 CSV Binding, LTI Core 1.3, LTI Deep Linking 2.0, LTI Assignment and Grade Services 2.0, LTI Names and Role Provisioning Services 2.0 and the 1EdTech Security Framework 1.0.
- W3C: Verifiable Credentials Data Model v2.0 and Bitstring Status List v1.0.
- IETF: RFC 7515, 7517, 7518, 7519, 7523, 7638 and 8037 (JOSE), RFC 6749 (OAuth 2.0), RFC 4180 (CSV) and RFC 4648 (base64url).
- OpenID Foundation: OpenID Connect Core 1.0.
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 withkidor embed the publicjwk(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,x5candx5tare refused. - The key decides the algorithm. Each key is bound to one algorithm by
its type. The header
algmust match it, soalg: none, HMAC and cross-type confusion (an RSA key with anES256header, 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
validFromandvalidUntil, and, when you setmaxStatusListAgeSeconds, no older than that (see above). - Private keys. Every signing key hides its secret from
var_dump()andprint_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.Ed25519SigningKeywipes its secret withsodium_memzero(); RSA and EC keys live in OpenSSL handles thatdestroy()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/keysis 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
kidURLs or resolving DIDs; supply keys yourself. - Endorsements,
Result/ResultDescription,termsOfUse,refreshService,Address,Related,otherIdentifier. - Status purposes
refreshandmessage, 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.