A small, dependency-light OAuth 1.0 (RFC 5849)
request signing and verification library for PHP, covering both sides of a request - the
signer role and the verifier role - rather than only the client half most
remaining OAuth 1.0 libraries assume. A companion namespace, BasicLti1, layers
Basic LTI 1.0/1.1 tool-launch construction and verification on top of it.
Most remaining uses of OAuth 1.0 are not a full three-legged authorization dance against a public API - they are a fixed pair of parties (a Learning Management System and a tool, most often) signing one-legged, no-token requests to prove who sent them. This library targets that case well, rather than chasing every OAuth 1.0 provider's own quirks.
BasicLti1 covers Basic LTI 1.0/1.1 launches only - the
protocol did not change between those two versions. It deliberately stops short of LTI
1.1's Basic Outcomes Service and its oauth_body_hash extension (signing a
POX/XML body instead of form parameters), which needs more design thought before it is
added.
composer require henderjon/php-oauth1
OAuth 1.0's own generic names, and what Basic LTI calls the same roles instead. Here because the two vocabularies genuinely differ, not just in spelling.
| OAuth 1.0 term | Basic LTI term | In practice |
|---|---|---|
| client | Tool Consumer (TC) | The LMS launching into a tool - the signer role, RequestSigner / LaunchRequestBuilder. |
| server | Tool Provider (TP) | The tool receiving the launch - the verifier role, RequestVerifier / LaunchVerifier. |
| client credentials | oauth_consumer_key / consumer secret | Shared credentials the TC and TP agree on ahead of time - Credentials. |
| token credentials | (unused) | Basic LTI is one-legged: no token request/authorization/exchange step at all. Credentials::$token/$tokenSecret stay empty. |
| resource owner | (no equivalent) | Basic LTI has no third-party-authorization redirect - the launch itself, signed with a shared secret, is the trust signal. |
Every constructor in this library takes an optional PSR-3 Psr\Log\LoggerInterface,
defaulting to a no-op NullLogger - passing one is opt-in. The scheme mirrors
php-oidc's own, including its noise: the debug happy-path trace on
every layer is a lot of volume to hand a caller's own logger unfiltered.
LogLevelFilterLogger exists specifically to
mediate it - wrap your own logger in one to receive only the levels you actually want, rather
than reconfiguring or replacing your logger just to cope with the noise.
| Level | When |
|---|---|
debug | The happy path. Every successful sign()/verify()/build() call, at every layer, logs one trace. |
alert | A configuration choice worth a developer's own review, logged on every use, not once - currently just PLAINTEXT signing/verifying, which "MUST only be used over TLS" and this library has no way to enforce. |
error | Every failure, always logged immediately before the exception it precedes is thrown - never after, never instead of. Always carries a security_relevant boolean. |
warning is unused. In php-oidc it marks a fail-open
decision or a clean "nothing found" lookup - this library has no fail-open path by design
(every check either passes or throws), so nothing here has an equivalent. This is a
deliberate omission, not a gap - revisit it only if a fail-open path is ever added.
security_relevant is true only for the small set of outcomes that
are essentially unexplainable except as tampering or a replay attempt -
InvalidSignature and
NonceReplayed. Every other failure
(a missing parameter, an unsupported version/method, a mismatched consumer key, a
non-canonical or out-of-tolerance timestamp, an unreadable key, a missing Basic LTI
parameter) is
false, since each is just as plausibly a caller mistake, a stale integration,
or ordinary clock skew as an attack. This mirrors php-oidc's own curated list
(signature failure, alg: none, nonce mismatch are true; an
expired token or an audience mismatch - despite sounding just as security-relevant - are
false). false means "not in that curated set," never "confirmed
benign."
The consumer secret, the token secret, and RSA key material are never logged, not even
partially - only Credentials::$consumerKey (a public identifier, the same
way Oidc always logs client_id in full but never
client_secret) ever appears in a log line. Two values needed a specific,
deliberate answer beyond "just don't log the secret":
lis_person_name_full, lis_person_contact_email_primary).
RequestSigner/RequestVerifier are generic - unlike
Oidc\TokenEndpointClient, which can name its own fixed, small set of
sensitive parameter keys to redact, this library has no way to know which of an
arbitrary caller's parameters are sensitive. Rather than guess, this library never logs
the base string itself, only a SHA-256 hash of it - and even that hash is never logged
on a successful call. RequestVerifier/RequestSigner's
oauth1.signature_base_string_built debug line is a bare trace, like every
other happy-path debug line - the hash itself only becomes reachable off
the object each side's call already produces: getBaseStringSha256() on a
RequestVerificationException/SigningException instance thrown
after the base string this failure concerns was already built, on the verify side; the
$baseStringSha256 property on SignedRequest/LaunchRequest,
on the build side, since RequestSigner::sign() essentially never fails
once a base string exists (see that type's own docblock). A caller comparing what two
parties computed reads it off the object it already has either way, never off a debug
log line that would otherwise carry it on every request, success included - see
Exceptions.
RsaSha1Signer/
RsaSha1Verifier log PemPreview::describe()'s summary of the
key: its PEM header line (e.g. -----BEGIN RSA PRIVATE KEY-----) plus
whether the matching footer is present anywhere after it - both fixed boilerplate
defined by the PEM format itself, never derived from the key's own bytes. The header
alone distinguishes "the wrong key type was passed," "nothing was passed," and "this
was never a PEM at all" from each other, but says nothing about the single most common
real failure this exists for - a key truncated partway through keeps its header fully
intact, since that is always the first line - which is why the footer check exists
too, confirmed directly against a real key cut in half.
$url is caller/attacker data of
unbounded length - on RequestVerifier's side, straight from an incoming,
not-yet-validated request. Rather than truncate it for logging (as with
oauth_consumer_key), SignatureBaseString's own
SigningException message deliberately never interpolates $url
at all, since the caller catching it already has the same value in scope - it is the
same argument they passed into sign()/verify(). Nothing is
lost, and nothing unbounded (PII included, if a caller built a malformed URL from user
input) reaches the log line that reports the exception.
Separately, oauth_consumer_key (in RequestVerifier) and
resource_link_id (in LaunchVerifier) are taken straight from an
incoming, not-yet-validated request and are unbounded in length until checked - both are
capped at 255 characters (Truncate::to(), UTF-8-safe unlike
Oidc\Truncate's own byte-based cut - see Truncate's docblock)
before appearing in any log line. 255 matches
Oidc\IncomingAuthorizationResponse::MAX_ERROR_FIELD_LENGTH's own reasoning,
not Oidc\AuthorizationStateStore's 64 - see RequestVerifier's
own docblock for why a value this library never generates itself, with no spec length
limit, warrants a different cap than a value the library generates and bounds itself.
Neither the value a security comparison runs against nor the value
RequestVerificationException/SigningException actually carries
is ever capped - capping the comparison value would let a long-enough forged key collide
with a truncated legitimate prefix, and capping what an exception hands back to calling
code would silently give a caller a key that no longer matches anything in its own
store, if it tries to look one up after catching a failure. This is a deliberate
divergence from Oidc\AuthorizationStateStore, which caps what its own
exception carries too - see RequestVerifier's own docblock for why a
library-generated, opaque correlation token like OIDC's state and a
caller-meaningful business identifier like a consumer key warrant different answers here.
| Class | Logs |
|---|---|
RequestSigner | debug once the base string is built and again on success (neither carries the hash - see Logging); debug when a caller-supplied request parameter collides with a reserved oauth_* one and is silently overridden; alert for PLAINTEXT; error (false) for a malformed URL. |
RequestVerifier | debug once the base string is built and again on success (neither carries the hash - it travels on the exception instead, when one is thrown); alert for PLAINTEXT; error for every VerificationFailureReason (true for InvalidSignature/NonceReplayed, false otherwise) and for a malformed URL - oauth_consumer_key length-capped in every one of these log lines, never in the exception thrown alongside it. |
RsaSha1Signer / RsaSha1Verifier | debug once the key loads/signs; error (false) for an unreadable key, with a header-plus-footer summary of it. |
RequestSignerFactory / RequestVerifierFactory | debug naming the signature method assembled; error (false) for RSA-SHA1 requested with no key. |
LaunchRequestBuilder / LaunchVerifier | debug on success (resource_link_id length-capped in LaunchVerifier); debug (builder only) when a caller-supplied lti_message_type/lti_version collides with the fixed value and is overridden; error (false) for a missing/invalid Basic LTI parameter. |
HmacSha1Signer, PlaintextSigner, SignatureBaseString, PercentEncoding, and NonceStore log nothing themselves - each is a pure computation or a thin cache wrapper with no independent failure surface of its own; the layer that calls them (RequestSigner/RequestVerifier) is where a decision, and therefore a log line, actually belongs.
A PSR-3 decorator that forwards a log call to the wrapped logger only when its level is
in an explicit allow-list, dropping every other level outright. A conventional "minimum
severity" filter cannot express this on its own: set at debug, it lets
everything through, since debug is already PSR-3's lowest severity - there
is no threshold that means "debug only." Wrap your own logger in one of these to route,
say, only this library's debug-level tracing somewhere separate (a different file, a
different verbosity, temporarily on during troubleshooting) without also receiving, or
reconfiguring, everything else your logger already handles. Ported directly from
php-oidc's own LogLevelFilterLogger - nothing here is
OAuth1/Basic LTI-specific, it decorates any PSR-3 LoggerInterface.
$levels and $mode together express four distinct patterns,
not just one allow-list:
| Pattern | Usage | Meaning |
|---|---|---|
| none | new LogLevelFilterLogger($logger, []) | Nothing passes. $levels is empty and $mode defaults to AllowList, so no level can ever satisfy the membership test. |
| none, except | new LogLevelFilterLogger($logger, [ LogLevel::DEBUG ]) | The ordinary allow-list case: nothing passes except the levels named. |
| all | new LogLevelFilterLogger($logger, [], LogLevelFilterMode::DenyList)Sugar: LogLevelFilterLogger::all($logger) | Everything passes, including a level neither PSR-3 nor the caller has defined yet. |
| all, except | new LogLevelFilterLogger($logger, [ LogLevel::DEBUG ], LogLevelFilterMode::DenyList) | The deny-list case: everything passes except the levels named, including one that does not exist yet. The mirror image of "none, except." |
The default, opt-in posture - "none, except" - is the same reason this class exists at
all: the default stance toward this library's noise is to filter all of it, not to let
everything through until told otherwise. Pass an empty array with the default
$mode and every log call is dropped, including a level a caller might assume
always gets through, such as error - nothing passes unless its exact level is
named.
$levels is a discrete set, not a range, in either the allow-list or the
deny-list case - any combination is valid, including combinations no linear severity
ordering could express together (debug and critical alone,
with everything between them excluded). This class never validates a level against
PSR-3's own eight constants (Psr\Log\LogLevel) - it is a plain string
comparison, so $levels can equally well hold PSR-3's own levels, a caller's
own custom level, or a mix of both. Pass it wherever this library expects a
Psr\Log\LoggerInterface, e.g. RequestSignerFactory:
$logger = new LogLevelFilterLogger($yourAppLogger, [ LogLevel::DEBUG ]); $factory = new RequestSignerFactory(logger: $logger);
"Every level, without having to name any of them" - the one case not reachable just by
listing levels, custom or not, since it does not require knowing in advance which levels
might ever be logged - is technically reachable directly as an empty deny-list
(new LogLevelFilterLogger($logger, [], LogLevelFilterMode::DenyList)), but
that is not obviously what it means at a glance. Use the all() named
constructor instead:
$logger = LogLevelFilterLogger::all($yourAppLogger);
The default, LogLevelFilterMode::AllowList, fails closed - a level
nobody named gets dropped, including one this library or a wrapped logger adds later.
DenyList fails open - a level nobody named gets forwarded instead.
That is a deliberate trade for the specific case it exists for (skip this library's
debug tracing, forward every other level unconditionally), not a reason to
prefer it generally over naming an allow-list directly. It is also why
$levels is not called $allowedLevels or
$excludedLevels: it holds an allow-list under AllowList and a
deny-list under DenyList, and a name tied to one mode would be wrong under
the other.
How LogLevelFilterLogger's $levels is interpreted.
AllowList$levels is forwarded - an empty array means nothing passes, including a level nobody has named yet.DenyList$levels - an empty array means nothing is excluded, so everything passes, including a level neither PSR-3 nor the caller has defined yet.
Assembles a RequestSigner for one of the three RFC
5849 §3.4 signature methods, from a shared clock, nonce generator, and logger - so a
caller wiring up one signer per integration never reaches for new on
RequestSigner or its collaborators itself.
$rsaPrivateKey is the key's own PEM content (e.g.
file_get_contents('key.pem')), not a path to it - see
RsaSha1Signer's own note on the same
distinction. Required, and used, only for SignatureMethod::RsaSha1 - throws
SigningException
if omitted for that method. Ignored for HmacSha1/Plaintext, which
sign with Credentials' own consumer/token secrets
instead.
Signs one outgoing request: assembles the oauth_* protocol parameters RFC 5849
§3.1 requires, builds the signature base string (§3.4.1) for HMAC-SHA1/RSA-SHA1,
and delegates the signature itself to the injected SignerInterface - so
choosing HMAC-SHA1, RSA-SHA1, or PLAINTEXT is a constructor argument, never a branch in
this class.
$requestParameters are the request's other, non-OAuth parameters (query string
and/or form body) - included in the signature base string per RFC 5849 §3.4.1.3.1, but
never returned on the result; the caller already has them. $callback sets
oauth_callback, for a Temporary Credential Request (RFC 5849 §2.1) -
omitted entirely otherwise, since it has no meaning on any other request.
oauth_timestamp and oauth_nonce are omitted for PLAINTEXT, per
§3.1 and §3.4.4 - PLAINTEXT uses neither. Every PLAINTEXT call also logs an
alert, every time, not once - see Logging.
// RFC 5849 §3.1 / §3.4.1.1's own worked example, reproduced by this // library's test suite byte for byte (the signature base string) and via an // independently cross-checked value (the signature itself - see // HmacSha1Signer below for why §3.1's own printed signature does not // actually recompute). $credentials = new Credentials( consumerKey: '9djdj82h48djs9d2', consumerSecret: 'j49sk3j29djd', token: 'kkk9d7dh3k39sjv7', tokenSecret: 'dh893hdasih9', ); $signed = $signer->sign( 'POST', 'http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b', $credentials, [ 'b5' => '=%3D', 'a3' => [ 'a', '2 q' ], 'c@' => '', 'a2' => 'r b', 'c2' => '' ], ); $signed->oauthParameters['oauth_signature']; // 'OB33pYjWAnf+xtOHN4Gmbdil168='
The identifiers OAuth 1.0 attaches to a request: the consumer key and, once a token has
been issued, the token identifier. Does not carry RSA key material - RFC 5849
§3.4.1 itself notes RSA-SHA1 "does not use the token shared-secret, or any provisioned
client shared-secret" at all. See RsaSha1Signer /
RsaSha1Verifier for where a key pair lives
instead.
$token/
$tokenSecret simply stay at their default, empty value.
The three signature methods RFC 5849 §3.4 defines. Backed by the exact string each puts in the oauth_signature_method protocol parameter.
The oauth_* protocol parameters RequestSigner produced for one request, including oauth_signature - never the request's other, non-OAuth parameters, which the caller already has.
$baseStringSha256 is the SHA-256 hash of the signature base string
RequestSigner::sign() built for this request - null for PLAINTEXT, which
never builds one at all. It exists for the same reason
RequestVerificationException/SigningException carry the same
hash on the verify side (see Exceptions): a caller
comparing what two parties computed reads it here, off the object sign()
already returns, rather than off a debug log line that would otherwise have to carry it
on every call, success included.
RFC 5849 §3.5.1: the OAuth Authorization header value, each name and value encoded per §3.6 and quoted.
$signed->authorizationHeaderValue(); // OAuth oauth_consumer_key="0685bd9184jfhq22", oauth_signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D" $signed->authorizationHeaderValue('Example'); // OAuth realm="Example", oauth_consumer_key="0685bd9184jfhq22", ...
One RFC 5849 §3.4 signature method's signing/verifying half each.
RequestSigner and
RequestVerifier depend on these, not a concrete
implementation, so choosing HMAC-SHA1 vs RSA-SHA1 vs PLAINTEXT is a constructor argument,
never a branch inside either class.
RFC 5849 §3.4.2: HMAC-SHA1 over the signature base string, keyed by the consumer
secret and token secret (each percent-encoded per §3.6) joined by & -
present even when one side is empty, so a signing-only, one-legged request (no token
secret at all) still has a key of secret&.
oauth_signature value
(bYT5CMsGcbgUdFHObYMEfcx6bsw=) for the exact base string §3.4.1.1 also
prints - but that value does not actually recompute. It is a documentation artifact
inherited from the original OAuth Core community spec, not a property of this
implementation. This library's tests use a value cross-checked independently against
Python's hmac/hashlib module instead of copying that number from
the RFC text.
RFC 5849 §3.4.4: no signature algorithm at all - oauth_signature is just
the consumer secret and token secret (each percent-encoded), joined by &.
PlaintextSigner is the thing responsible for it.RFC 5849 §3.4.3: RSASSA-PKCS1-v1_5 over the signature base string, using SHA-1 and the client's own RSA private key - never a shared secret.
$privateKey is the key's own PEM content (e.g.
file_get_contents('key.pem')), not a path to it. A bare filename with no
scheme is not a path here either - PHP's underlying openssl_pkey_get_private()
treats it as literal, invalid key content, and this fails exactly like any other
unreadable key. A file://-prefixed path does work, as an incidental
consequence of PHP's own stream wrapper support (this class passes $privateKey
straight through untouched) - but that is openssl's contract, not one this
constructor documents or was tested against.
Throws SigningException if the private key cannot be read, or if openssl_sign() itself rejects the input - logging an error (security_relevant: false, carrying PemPreview::describe()'s header-plus-footer summary of the key, never any of its actual bytes) immediately before, and a debug trace on success. See Logging.
The verifying half of RSA-SHA1 (RFC 5849 §3.4.3): checks a signature against the
client's RSA public key. Deliberately a separate class from
RsaSha1Signer - one key material never signs and
verifies through the same object, since a client is only ever issued its own private key.
$publicKey is the key's own PEM content, not a path to it - see
RsaSha1Signer's matching note for the exact
same reasoning, applied to openssl_pkey_get_public() here.
Throws SigningException if the public key cannot be read, or if openssl_verify() itself rejects the input (e.g. a key of the wrong type/algorithm) rather than computing a signature and finding it did not match - logging an error (security_relevant: false) immediately before either way, and a debug trace once the key loads. Deliberately not collapsed into a returned false, which RequestVerifier would otherwise log and throw as security_relevant: true tampering, misreporting a key/config problem as an attack. See Logging.
Assembles a RequestVerifier for one of the three
RFC 5849 §3.4 signature methods, from a shared clock and logger and a per-call nonce
cache - so a caller verifying requests never reaches for new on
RequestVerifier, NonceStore, or a concrete
VerifierInterface itself. (
NonceStore is an internal collaborator wired up here, not part of this
library's public surface to construct directly.)
$nonceCache is a PSR-16 cache used to record which nonces have already been
seen, so a replayed request is rejected the second time - session storage is fine for a
single-server app; a load-balanced deployment needs something shared (Memcache, Redis)
injected here instead. $rsaPublicKey is the key's own PEM content, not a
path to it (see RsaSha1Verifier's own note) -
required, and used, only for SignatureMethod::RsaSha1. $cacheKeySuffix
is appended to every nonce cache key this verifier writes - pass a distinct value per
RequestVerifier instance sharing one cache (say, one Redis pool backing
several Tool Providers or several signature methods) to keep their nonce claims from
colliding with each other.
Verifies one incoming request: recomputes its signature from the parameters the caller
already parsed and compares it, checks oauth_version when present, and - for
HMAC-SHA1/RSA-SHA1 - rejects a non-canonical or malformed oauth_timestamp, a
timestamp outside the configured tolerance, or an already-claimed nonce (RFC 5849
§3.2, §3.3).
RequestVerificationException
rather than returning false - a caller cannot accidentally treat "did not
check" the same as "checked and passed". Every one of those failures also logs an
error immediately before throwing, and PLAINTEXT logs an alert
on every call - see Logging.
oauth_nonce/oauth_timestamp/oauth_consumer_key are
all plaintext request parameters, visible to anyone who can see the wire - claiming the
nonce before checking the signature would let an attacker who cannot sign anything still
burn a legitimate nonce with a forged request that copies them, denying the real request
that nonce belonged to. This ordering is deliberate, not incidental.
NonceStore is computed from
oauth_timestamp + timestampToleranceSeconds - now, not from
timestampToleranceSeconds alone. A cache write that honestly fails throws
rather than being treated as a successful claim or a replay - logged and rethrown as
SigningException
(security_relevant: false), the same reasoning as
RsaSha1Verifier's openssl_verify()
-1 case: an infrastructure problem is not tampering.
$credentials must already carry the consumer secret (and token secret, if any)
the caller looked up for the incoming oauth_consumer_key - this class does not
know how or where that lookup happens. $parameters is every parameter the
request carries, from every source RFC 5849 §3.4.1.3.1 lists (URI query,
Authorization header minus realm, form-encoded body) - the
oauth_* ones included, already decoded to their original values.
Why RequestVerifier rejected a request - attached to RequestVerificationException so a caller can branch on it without parsing the exception message.
Produces the oauth_nonce value RFC 5849 §3.3 requires. Injectable so a test can assert against a fixed nonce instead of a random one.
The default clock for both factories - wraps the system clock via PSR-20's ClockInterface, so tests can inject a fixed one instead.
Every exception here extends OAuth1Exception (itself a plain \RuntimeException), so a caller that only wants one catch clause for "signing or verification failed" always has one available.
OAuth1Exception also carries getConsumerKey(): ?string - the
oauth_consumer_key this failure happened against, when one was in scope.
It is null for a failure with nothing yet to correlate with: a malformed URL passed
directly to SignatureBaseString (excluded from the public docs, see the
footer), or a factory misconfigured before any specific request is in play
(RequestSignerFactory/RequestVerifierFactory requesting
RSA-SHA1 with no key). When RequestSigner/RequestVerifier catch
that same malformed-URL SigningException from SignatureBaseString,
they do not rethrow it as-is - they construct a new one carrying the consumer key they
have and the original as its own getPrevious(), the same boundary-attach
pattern Oidc\OpenIDConnectClient uses to add an ID token a lower-level
collaborator's own exception has no way to carry.
SigningException and RequestVerificationException each also
carry their own getBaseStringSha256(): ?string - deliberately not hoisted
onto OAuth1Exception alongside getConsumerKey(), the same way
getReason() stays on RequestVerificationException alone.
getConsumerKey() is a universal question every failure in this library has
some answer to; a base string hash is not - it is an artifact of exactly how
RequestVerifier/RequestSigner's own baseString()
happen to be implemented today, meaningless for a failure (a malformed URL, a
misconfigured factory) that never touches one at all. It is the SHA-256 hash
baseString() already built for this call, when this failure happened after
that build succeeded - null on a RequestVerificationException for every
VerificationFailureReason other than InvalidSignature/NonceReplayed (each of
those is caught before baseString() ever runs), and null on a
SigningException for a malformed URL or SignatureBaseString::build()
itself failing (no base string ever existed to hash either way). This is the one piece of
diagnostic data worth keeping past the happy path - see Logging for
why it is never written to a log line.
| Exception | Thrown when | Carries |
|---|---|---|
OAuth1Exception | Base class for every exception below. Never thrown directly. | getConsumerKey() |
SigningException | A signer or verifier cannot even attempt a signature - a malformed URL with no scheme/host, an unreadable RSA key, or openssl itself rejecting the input. | getConsumerKey(), getBaseStringSha256() |
RequestVerificationException | A request fails verification for any of the reasons VerificationFailureReason lists. | getConsumerKey(), getBaseStringSha256(), getReason(): VerificationFailureReason |
Assembles a LaunchRequestBuilder wired to
HMAC-SHA1 - the Basic LTI Implementation Guide's own words: "TC and TP must support and use
the HMAC-SHA1 signing method" - so a caller never has an opportunity to wire up a signature
method Basic LTI does not allow.
Takes the same raw collaborators (nonce generator, clock, logger)
Oauth1\RequestSignerFactory does, rather
than a pre-built one, and builds one internally - mirroring
Oidc\OpenIDConnectClientFactory's own pattern. This is what lets one
$logger passed here reach both this layer's own debug/error
calls and RequestSigner's, without wiring the same logger into two factories
separately. See Logging.
Builds one Basic LTI launch: sets lti_message_type/lti_version
(never left to the caller to get right or wrong), requires resource_link_id,
and signs the whole parameter set with the injected Oauth1\RequestSigner.
Logs a debug trace on success, or an error (security_relevant: false) immediately before throwing InvalidLaunchException for a missing, empty, or non-string resource_link_id - the same guard LaunchVerifier applies on the receiving side, since nothing enforces this array's documented shape at a caller's own call site. See Logging.
about:blank - not passed through
RequestSigner's own $callback argument, and so not part of the
signature base string at all. The Basic LTI v1.0 Implementation Guide's own reference
launch example (Appendix B) includes oauth_callback as a submitted form field
but excludes it from the base string it signs, and that example's own printed
oauth_signature only reproduces when this class does the same. RFC 5849
§3.4.1.3.1 would include it if strictly followed, but matching the spec's own worked
example - and the real Tool Consumers that copied it - is the more useful behavior for
interoperating with actual Basic LTI traffic. LaunchVerifier
makes the matching choice on the receiving side.
// Appendix B.5 of the Basic LTI v1.0 Implementation Guide, reproduced end to // end by this library's test suite: the exact consumer key, secret, nonce, // and timestamp the guide uses, signed by LaunchRequestBuilder, matches the // guide's own printed oauth_signature. $launch = $builder->build( 'http://dr-chuck.com/ims/php-simple/tool.php', new Credentials('12345', 'secret'), [ 'resource_link_id' => '120988f929-274612', 'user_id' => '292832126', 'roles' => 'Instructor', // ... context_id, lis_person_name_full, and so on ], ); $launch->parameters['oauth_signature']; // 'TPFPK4u3NwmtLt0nDMP1G1zG30U='
A fully-assembled Basic LTI launch, ready to submit as one POST: every launch parameter the caller supplied, plus lti_message_type/lti_version, plus the oauth_* parameters LaunchRequestBuilder produced.
<script> tag into every consumer, including one whose
Content-Security-Policy requires a nonce on every script tag that this library
has no way to know. Turning $parameters into hidden form fields is the
consuming application's job - see example/
for what that looks like in practice.
$baseStringSha256 is carried straight through from the underlying
Oauth1\SignedRequest - see that type's own
section for what it is. Never actually null for a launch this class builds in practice,
since LaunchRequestBuilderFactory
hard-wires HMAC-SHA1, never PLAINTEXT; nullable only because it mirrors
SignedRequest's own type.
Assembles a LaunchVerifier wired to HMAC-SHA1 - see LaunchRequestBuilderFactory for why that is the only signature method Basic LTI allows.
Takes the same raw collaborators (clock, logger) Oauth1\RequestVerifierFactory does, rather than a pre-built one - see LaunchRequestBuilderFactory's docblock for why, on the signing side, that same reasoning applies here too.
$nonceCache/$cacheKeySuffix/$timestampToleranceSeconds
are passed straight through to Oauth1\RequestVerifierFactory::forMethod() -
see that method's own note, in particular for why $cacheKeySuffix matters
once one cache backs more than one LaunchVerifier.
Verifies one incoming Basic LTI launch: checks the OAuth 1.0 signature first (delegated to
the injected Oauth1\RequestVerifier), then that lti_message_type,
lti_version, and resource_link_id are present and correct.
Authentication before content, deliberately - a request that is not who it claims to be is
not worth inspecting further.
Logs a debug trace on success, or an error (always
security_relevant: false - see this class's own docblock for why) immediately
before throwing InvalidLaunchException for
any of the three content checks above. See Logging.
Throws Oauth1\Exceptions\RequestVerificationException for a signature/timestamp/nonce failure, or Oauth1\Exceptions\SigningException for a malformed launch URL, the nonce store failing to persist a claim, or the OAuth signature method itself throwing one - neither is caught or wrapped here, so a caller distinguishing OAuth failures from Basic LTI content failures can catch either directly. Throws InvalidLaunchException for a missing/invalid Basic LTI parameter, once the signature itself has already checked out.
These three types share no common ancestor narrower than PHP's own \RuntimeException
- deliberately, per BasicLti1\Exceptions\*'s own note on why the
two hierarchies stay separate. A caller who only needs "did this launch succeed, yes or no,"
without distinguishing which of the three reasons, can catch \RuntimeException around
this call - every exception this library throws extends it - rather than writing three catch
blocks. That is a wider net than this library's own exceptions alone, so it also catches a bug
elsewhere in the same try block; keep the block scoped to just this call if that
distinction matters.
Why a launch was rejected as invalid Basic LTI, independent of whether it was signed correctly - attached to InvalidLaunchException.
The two protocol-identifying parameters every Basic LTI launch carries, and the one launch-specific parameter the spec requires. Fixed for both LTI 1.0 and LTI 1.1 - the launch protocol itself did not change between them.
Every exception here extends BasicLti1Exception (itself a plain \RuntimeException) - a separate hierarchy from Oauth1\Exceptions\*, since a signature failure and a Basic LTI content failure are different concerns.
BasicLti1Exception also carries getConsumerKey(): ?string - mirrors Oauth1\Exceptions\OAuth1Exception's own rider of the same name; see that section for the full reasoning.
| Exception | Thrown when | Carries |
|---|---|---|
BasicLti1Exception | Base class for every exception below. Never thrown directly. | getConsumerKey() |
InvalidLaunchException | A launch is missing or carries the wrong value for lti_message_type, lti_version, or resource_link_id. | getConsumerKey(), getReason(): LaunchValidationFailureReason |