henderjon/php-oauth1
Index GitHub

Package Oauth1 / BasicLti1

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.

Scope. 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.
Quick Start Browse the Index

Installation

composer require henderjon/php-oauth1

▾ Quick Start: a Basic LTI launch

The common case this library exists for: an LMS (Tool Consumer) signing a launch, and a tool (Tool Provider) verifying it.

<?php

use BasicLti1\LaunchRequestBuilderFactory;
use BasicLti1\LaunchVerifierFactory;
use Oauth1\Credentials;

// Agreed on out of band, ahead of time, by both parties.
$credentials = new Credentials('consumer-key', 'consumer-secret');

// Tool Consumer side - building a launch.
$launch = (new LaunchRequestBuilderFactory())->make()->build(
    'https://tool.example.com/launch',
    $credentials,
    [
        'resource_link_id' => 'link-1',
        'user_id' => 'user-42',
        'roles' => 'Instructor',
    ],
);

// $launch->parameters is a flat array<string,string> - render every entry as
// a hidden form field and submit. See LaunchRequest below for why this
// library stops there instead of rendering markup itself.

// Tool Provider side - verifying an incoming launch.
(new LaunchVerifierFactory())->make($psr16NonceCache)->verify(
    'https://tool.example.com/launch',
    $credentials,
    $_POST,
);

// No exception means the signature, timestamp, nonce, and required Basic
// LTI parameters all checked out.

See the example/ directory for a runnable version of both sides of this, driven over real HTTP with PHP's built-in dev server - including a deliberate replay and a deliberate tamper, and what each one looks like once rejected.

▾ Quick Start: raw OAuth 1.0 signing

The Oauth1 namespace works standalone too, for anything else that still speaks OAuth 1.0.

<?php

use Oauth1\Credentials;
use Oauth1\RequestSignerFactory;
use Oauth1\RequestVerifierFactory;
use Oauth1\SignatureMethod;

$credentials = new Credentials('consumer-key', 'consumer-secret');

$signed = (new RequestSignerFactory())->forMethod(SignatureMethod::HmacSha1)
    ->sign('POST', 'https://api.example.com/resource', $credentials, [ 'foo' => 'bar' ]);

// $signed->oauthParameters holds every oauth_* parameter, including
// oauth_signature - merge them into the request body, query string, or an
// Authorization header via $signed->authorizationHeaderValue().

(new RequestVerifierFactory())->forMethod(SignatureMethod::HmacSha1, $psr16NonceCache)
    ->verify('POST', 'https://api.example.com/resource', $credentials, $incomingParameters);

Index

Terminology

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 termBasic LTI termIn practice
clientTool Consumer (TC)The LMS launching into a tool - the signer role, RequestSigner / LaunchRequestBuilder.
serverTool Provider (TP)The tool receiving the launch - the verifier role, RequestVerifier / LaunchVerifier.
client credentialsoauth_consumer_key / consumer secretShared 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.

Logging

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.

LevelWhen
debugThe happy path. Every successful sign()/verify()/build() call, at every layer, logs one trace.
alertA 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.
errorEvery 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."

What gets logged, and what does not

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":

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.

ClassLogs
RequestSignerdebug 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.
RequestVerifierdebug 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 / RsaSha1Verifierdebug once the key loads/signs; error (false) for an unreadable key, with a header-plus-footer summary of it.
RequestSignerFactory / RequestVerifierFactorydebug naming the signature method assembled; error (false) for RSA-SHA1 requested with no key.
LaunchRequestBuilder / LaunchVerifierdebug 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.

↑ back to index

type LogLevelFilterLogger

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.

final class LogLevelFilterLogger extends Psr\Log\AbstractLogger { public function __construct( Psr\Log\LoggerInterface $logger, array $levels, LogLevelFilterMode $mode = LogLevelFilterMode::AllowList, ) public static function all( Psr\Log\LoggerInterface $logger ): self }

$levels and $mode together express four distinct patterns, not just one allow-list:

PatternUsageMeaning
nonenew LogLevelFilterLogger($logger, [])Nothing passes. $levels is empty and $mode defaults to AllowList, so no level can ever satisfy the membership test.
none, exceptnew LogLevelFilterLogger($logger, [ LogLevel::DEBUG ])The ordinary allow-list case: nothing passes except the levels named.
allnew LogLevelFilterLogger($logger, [], LogLevelFilterMode::DenyList)
Sugar: LogLevelFilterLogger::all($logger)
Everything passes, including a level neither PSR-3 nor the caller has defined yet.
all, exceptnew 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.

↑ back to index

enum LogLevelFilterMode

How LogLevelFilterLogger's $levels is interpreted.

enum LogLevelFilterMode { case AllowList; case DenyList; }
AllowList
Only a level named in $levels is forwarded - an empty array means nothing passes, including a level nobody has named yet.
DenyList
Every level is forwarded except the ones named in $levels - an empty array means nothing is excluded, so everything passes, including a level neither PSR-3 nor the caller has defined yet.

↑ back to index

type RequestSignerFactory

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.

final class RequestSignerFactory { public function __construct( NonceGeneratorInterface $nonceGenerator = new RandomNonceGenerator, Psr\Clock\ClockInterface $clock = new CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function forMethod( SignatureMethod $method, ?string $rsaPrivateKey = null, string $rsaPassphrase = "", ): RequestSigner }

forMethod

$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.

↑ back to index

type RequestSigner

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.

final class RequestSigner { public function __construct( SignerInterface $signer, NonceGeneratorInterface $nonceGenerator = new RandomNonceGenerator, Psr\Clock\ClockInterface $clock = new CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function sign( string $httpMethod, string $url, Credentials $credentials, array $requestParameters = [], ?string $callback = null, ): SignedRequest }

sign

$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='

↑ back to index

type Credentials

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.

A Basic LTI launch is one-legged: no token exchange at all. $token/ $tokenSecret simply stay at their default, empty value.
final class Credentials { public function __construct( public readonly string $consumerKey, public readonly string $consumerSecret = "", public readonly string $token = "", public readonly string $tokenSecret = "", ) public function withConsumerKey(string $consumerKey): self public function withConsumerSecret(string $consumerSecret): self public function withToken(string $token): self public function withTokenSecret(string $tokenSecret): self }

↑ back to index

enum SignatureMethod

The three signature methods RFC 5849 §3.4 defines. Backed by the exact string each puts in the oauth_signature_method protocol parameter.

enum SignatureMethod: string { case HmacSha1 = 'HMAC-SHA1'; case RsaSha1 = 'RSA-SHA1'; case Plaintext = 'PLAINTEXT'; }

↑ back to index

type SignedRequest

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.

final class SignedRequest { public function __construct( public readonly array $oauthParameters, public readonly ?string $baseStringSha256 = null, ) public function authorizationHeaderValue(?string $realm = null): string }

$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.

authorizationHeaderValue

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", ...

↑ back to index

interface SignerInterface / VerifierInterface

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.

interface SignerInterface { public function method(): SignatureMethod; public function sign(string $baseString, Credentials $credentials): string; } interface VerifierInterface { public function method(): SignatureMethod; public function verify(string $baseString, Credentials $credentials, string $signature): bool; }

↑ back to index

type HmacSha1Signer

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&.

final class HmacSha1Signer implements SignerInterface, VerifierInterface { public function method(): SignatureMethod public function sign(string $baseString, Credentials $credentials): string public function verify(string $baseString, Credentials $credentials, string $signature): bool }
RFC 5849 §3.1's own worked example prints an 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.

↑ back to index

type PlaintextSigner

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 &.

MUST only be used over TLS. This class has no way to enforce that - whatever constructs a PlaintextSigner is the thing responsible for it.
final class PlaintextSigner implements SignerInterface, VerifierInterface { public function method(): SignatureMethod public function sign(string $baseString, Credentials $credentials): string public function verify(string $baseString, Credentials $credentials, string $signature): bool }

↑ back to index

type RsaSha1Signer

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.
final class RsaSha1Signer implements SignerInterface { public function __construct( string $privateKey, string $passphrase = "", Psr\Log\LoggerInterface $logger = new NullLogger, ) public function method(): SignatureMethod public function sign(string $baseString, Credentials $credentials): string }

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.

↑ back to index

type RsaSha1Verifier

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.
final class RsaSha1Verifier implements VerifierInterface { public function __construct( string $publicKey, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function method(): SignatureMethod public function verify(string $baseString, Credentials $credentials, string $signature): bool }

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.

↑ back to index

type RequestVerifierFactory

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.)

final class RequestVerifierFactory { public function __construct( Psr\Clock\ClockInterface $clock = new CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function forMethod( SignatureMethod $method, Psr\SimpleCache\CacheInterface $nonceCache, string $cacheKeySuffix = "", int $timestampToleranceSeconds = 300, ?string $rsaPublicKey = null, ): RequestVerifier }

forMethod

$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.

↑ back to index

type RequestVerifier

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).

Fail-closed: every failure throws 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.
The nonce is claimed only after the signature is proven genuine. 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.
The nonce stays claimed for the full timestamp window, not a flat tolerance from claim time. A client's clock reading ahead of the server (ordinary skew, within the tolerance already allowed) makes those two different - counting from claim time would let the cache entry expire before the request naturally stops being "fresh," letting a captured, unmodified request replay successfully in that gap with no forged signature needed. The TTL passed to 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.
final class RequestVerifier { public function __construct( VerifierInterface $verifier, NonceStore $nonceStore, Psr\Clock\ClockInterface $clock = new CurrentClock, int $timestampToleranceSeconds = 300, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function verify( string $httpMethod, string $url, Credentials $credentials, array $parameters, ): void }

verify

$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.

↑ back to index

enum VerificationFailureReason

Why RequestVerifier rejected a request - attached to RequestVerificationException so a caller can branch on it without parsing the exception message.

enum VerificationFailureReason { case MissingParameter; case UnsupportedVersion; case UnsupportedSignatureMethod; case ConsumerKeyMismatch; case MalformedTimestamp; case TimestampOutOfWindow; case NonceReplayed; case InvalidSignature; }

↑ back to index

interface NonceGeneratorInterface / type RandomNonceGenerator

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.

interface NonceGeneratorInterface { public function generate(): string; } final class RandomNonceGenerator implements NonceGeneratorInterface { public function generate(): string }

↑ back to index

type CurrentClock

The default clock for both factories - wraps the system clock via PSR-20's ClockInterface, so tests can inject a fixed one instead.

final class CurrentClock implements Psr\Clock\ClockInterface { public function now(): \DateTimeImmutable }

↑ back to index

namespace Oauth1\Exceptions

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.

ExceptionThrown whenCarries
OAuth1ExceptionBase class for every exception below. Never thrown directly.getConsumerKey()
SigningExceptionA 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()
RequestVerificationExceptionA request fails verification for any of the reasons VerificationFailureReason lists.getConsumerKey(), getBaseStringSha256(), getReason(): VerificationFailureReason
Exception messages here are safe to log but are not written for end users.

↑ back to index

type LaunchRequestBuilderFactory

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.

final class LaunchRequestBuilderFactory { public function __construct( Oauth1\NonceGeneratorInterface $nonceGenerator = new Oauth1\RandomNonceGenerator, Psr\Clock\ClockInterface $clock = new Oauth1\CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function make(): LaunchRequestBuilder }

↑ back to index

type LaunchRequestBuilder

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.

final class LaunchRequestBuilder { public function __construct( Oauth1\RequestSigner $signer, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function build( string $launchUrl, Oauth1\Credentials $credentials, array $launchParameters, ): LaunchRequest }

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.

oauth_callback is deliberately not signed. It is added to the launch as a plain, unsigned parameter set to 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='

↑ back to index

type LaunchRequest

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.

final class LaunchRequest { public function __construct( public readonly string $launchUrl, public readonly array $parameters, public readonly ?string $baseStringSha256 = null, ) }
Data only, not markup. Basic LTI's own examples submit a launch as an auto-submitting HTML form, but rendering one here would bake this library's opinion of a <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.

↑ back to index

type LaunchVerifierFactory

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.

final class LaunchVerifierFactory { public function __construct( Psr\Clock\ClockInterface $clock = new Oauth1\CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function make( Psr\SimpleCache\CacheInterface $nonceCache, string $cacheKeySuffix = "", int $timestampToleranceSeconds = 300, ): LaunchVerifier }

$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.

↑ back to index

type 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.

final class LaunchVerifier { public function __construct( Oauth1\RequestVerifier $requestVerifier, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function verify( string $launchUrl, Oauth1\Credentials $credentials, array $parameters, ): void }

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.

↑ back to index

enum LaunchValidationFailureReason

Why a launch was rejected as invalid Basic LTI, independent of whether it was signed correctly - attached to InvalidLaunchException.

enum LaunchValidationFailureReason { case MissingOrInvalidMessageType; case MissingOrInvalidVersion; case MissingResourceLinkId; }

↑ back to index

type Launch

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.

final class Launch { public const MESSAGE_TYPE_PARAM = 'lti_message_type'; public const MESSAGE_TYPE = 'basic-lti-launch-request'; public const VERSION_PARAM = 'lti_version'; public const VERSION = 'LTI-1p0'; public const RESOURCE_LINK_ID_PARAM = 'resource_link_id'; }

↑ back to index

namespace BasicLti1\Exceptions

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.

ExceptionThrown whenCarries
BasicLti1ExceptionBase class for every exception below. Never thrown directly.getConsumerKey()
InvalidLaunchExceptionA launch is missing or carries the wrong value for lti_message_type, lti_version, or resource_link_id.getConsumerKey(), getReason(): LaunchValidationFailureReason

↑ back to index