Verify FLINK's signed assertions in a Laravel backend. An embedded FLINK journey hands your page the applicant's eID data (from ID Austria, for example) together with a copy signed by FLINK. This package verifies that signed copy, so your backend can check that the names and birthdate in a submitted form are the ones the eID returned. The same verifier checks FLINK's other signed assertions, such as a session's results.
Using Statamic? Follow docs/statamic.md end to end.
composer require mohemian/flink-sdk-php
php artisan flink:install # publishes config/flink.php and lists what is still unsetPHP 8.2+ (8.3+ for Laravel 13), Laravel 10 to 13, ext-intl.
FLINK_TENANT_ID=… # the console's journey page, "Start & integrate"
FLINK_JOURNEYS=… # the journey's public id, same place; several comma-separated
# FLINK_URL=https://flink-services.mohemian.rocks (default)
# FLINK_AUDIENCE=https://www.example.at (default: APP_URL's origin)
# FLINK_RESULTS_AUDIENCE=… (only to verify results assertions)On the page, launch the journey with this session's reference and post prefillJwt from onResult
to your backend (see the
browser SDK docs):
<script>
Flink.createFlink({ journey: 'SCD7DDPK258G', reference: '@flinkReference', onResult: … });
</script>On the backend:
use Mohemian\Flink\Exceptions\FlinkException;
use Mohemian\Flink\Facades\Flink;
try {
$prefill = Flink::prefill($request->input('prefill_jwt')); // checked against this session's reference
} catch (FlinkException $e) {
logger()->warning('FLINK prefill refused', ['reason' => $e->getMessage()]); // never log the JWT
return response()->noContent(422);
}
$prefill->data; // ['givenName' => 'Erika', 'familyName' => 'Mustermann', 'address' => [...]]
$prefill->get('address.street'); // 'Hauptstraße'
$prefill->journey; $prefill->method; $prefill->sessionId; $prefill->id; $prefill->expiresAt;
$prefill->changed($submitted); // e.g. ['address.street']Or as a validation rule, which keeps the verified prefill for you:
use Mohemian\Flink\Rules\ValidPrefill;
$request->validate(['prefill_jwt' => ['required', $rule = new ValidPrefill]]);
$rule->assertion->data;Flink::prefill() throws a FlinkException unless every one of these holds:
- signed by FLINK: RS256 only, key chosen by
kidfrom FLINK's JWKS (cached a day, refetched at most once a minute for an unknownkid), headertyp=flow-prefill+jwt; otherwiseInvalidSignature, orInvalidAssertionfor anothertyp iss=FLINK_URL,aud= your page's origin, not expired,ver= 1 (iatandexpallow 5 seconds of clock skew)tenant_idis yours andjourneyis one ofFLINK_JOURNEYS: any FLINK tenant can register your origin, soaudalone is not enoughmethodis one you accept (prefill.allowed_methods, default['eid']).eidis any eID provider route (ID Austria, SkIDentity or SK Authentigate); the claim does not say which onereferenceis this session's (or the one you pass as the second argument), so a JWT copied from someone else's browser fails
Everything but the signature is an InvalidAssertion. Two failures are deliberately not a
FlinkException, because a broken setup should fail loudly rather than refuse every applicant
quietly: MissingConfiguration for an unset variable, and KeysUnavailable when FLINK's keys cannot
be fetched (a wrong FLINK_URL, or an outage).
A journey with no identity step (email only, say) issues a JWT without method, and the prefill is
refused. That is deliberate: nothing in such a JWT is identity-backed, the email address included,
so it cannot stand in for a verified identity.
changed() returns the verified fields the submission changed or left out, as dotted keys; pass
only: to compare a subset. It compares strings after trimming and Unicode NFC normalisation, and
nothing more. Which changes you accept is your policy: docs/statamic.md locks
names and birthdate and lets the address change.
Flink::reference() issues this session's reference on first use (@flinkReference in Blade),
Flink::forgetReference() rotates it, so one journey run backs one submission. $prefill->id is
the JWT's jti: the same on every retry of one release, so to be sure of one submission per
release regardless of the session, accept each id once.
The JWT is personal data that stays verifiable after it expires: send it in a request body, verify it as soon as it arrives (it expires 5 minutes after the journey ends), and do not log or store it.
Every assertion FLINK signs verifies the same way; the caller always names the type it expects, so one type is never accepted as another (a results assertion never expires, so accepting it as a prefill would make it replayable forever):
use Mohemian\Flink\Assertions\PrefillAssertion;
use Mohemian\Flink\Assertions\ResultsAssertion;
$results = Flink::results($jwt); // flow-results+jwt, aud = FLINK_RESULTS_AUDIENCE
$results->results; $results->get('identity.givenName');
Flink::verify($jwt, ResultsAssertion::class); // the same, for any Assertion subclass
Flink::verify($jwt, PrefillAssertion::class, ['reference' => $reference]); // override an expectationFlink::fake() verifies against a key generated for the test, and signs assertions your config
accepts, so your tests never reach FLINK:
$fake = Flink::fake();
$jwt = $fake->prefill(['givenName' => 'Erika']); // bound to this session's reference
$this->post('/flink/prefill', ['prefill_jwt' => $jwt])->assertNoContent();
$fake->prefill(claims: ['method' => 'doc']); // override or drop (null) a claim
$fake->results(['identity' => ['givenName' => 'Erika']]);php artisan flink:keys:clearforgets the cached keys, so the next verification fetches them.php artisan aboutshows the FLINK settings.- Set
flink.eventsto dispatchAssertionVerifiedandAssertionRejectedfor an audit log. The rejection carries the expected type, the reason and the exception class as strings, never the JWT or its claims, so a queued listener can take it. flink.cachepicks the store, key and lifetime of the cached keys;FLINK_JWKS_URLoverrides where they come from.
composer install
composer test # Pest
composer analyse # PHPStan
composer format # Rector, then Pint: rewrites the code in place
composer format:check # what CI runs: fails on anything `format` would changeMIT